feat(tui): add multiline paste mode in minimal UI

This commit is contained in:
William Valentin
2026-02-22 23:30:28 -08:00
parent 8411641061
commit 266c37b353
6 changed files with 107 additions and 1 deletions
+17 -1
View File
@@ -3,6 +3,21 @@
"updated_at": "2026-02-23",
"description": "Tracks the status of all Flynn plans and implementation phases",
"plans": {
"minimal-tui-multiline-paste-mode": {
"status": "completed",
"date": "2026-02-23",
"updated": "2026-02-23",
"summary": "Added multiline compose support to minimal TUI via `/paste` (alias `/multiline`) so users can enter/paste multi-line prompts without truncation. The mode collects lines until a single `.` terminator and submits one combined message. Also added fullscreen-mode handling for the new command plus parser/help/tooltip coverage and multiline flow tests.",
"files_modified": [
"src/frontends/tui/commands.ts",
"src/frontends/tui/minimal.ts",
"src/frontends/tui/components/App.tsx",
"src/frontends/tui/commands.test.ts",
"src/frontends/tui/minimal.test.ts",
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/frontends/tui/commands.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing"
},
"toolloop-execution-claim-recovery": {
"status": "completed",
"date": "2026-02-23",
@@ -6311,7 +6326,7 @@
}
},
"overall_progress": {
"total_test_count": 1962,
"total_test_count": 1965,
"all_tests_passing": true,
"p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)",
@@ -6333,6 +6348,7 @@
"gmail_filter_creation": "completed — gmail.filter.create tool added with criteria/action validation; gmail-auth now requests full Gmail scope (https://mail.google.com/) for complete filter permissions",
"toolloop_action_intent_recovery": "completed — when a model claims it will execute a tool but emits no tool call, NativeAgent now issues one internal nudge and continues the same turn to execute tools or produce a concrete blocker",
"toolloop_execution_claim_recovery": "completed — when a model claims a known tool already succeeded/failed without emitting a tool call, NativeAgent now nudges once and retries the same turn before returning text",
"minimal_tui_multiline_paste_mode": "completed — minimal TUI now supports `/paste`/`/multiline` multiline compose mode ending with single '.' line, preventing newline truncation for pasted prompts",
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback, plus 2026-02-23 arg hydration hardening, tool.args_rewritten audit metric, transient fetch retry/timeout hardening, localhost->127.0.0.1 fallback for transcription endpoint connectivity, and whisper docker-compose entrypoint arg fix for port 18801",
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
"next_up": "Track OpenClaw evolution regularly for inspiration and feature ideas"
+6
View File
@@ -18,6 +18,11 @@ describe('parseCommand', () => {
expect(parseCommand('/?')).toEqual({ type: 'help' });
});
it('parses /paste and /multiline commands', () => {
expect(parseCommand('/paste')).toEqual({ type: 'multiline' });
expect(parseCommand('/multiline')).toEqual({ type: 'multiline' });
});
it('parses /status command', () => {
expect(parseCommand('/status')).toEqual({ type: 'status' });
});
@@ -139,6 +144,7 @@ describe('getHelpText', () => {
it('returns help text with all commands', () => {
const help = getHelpText();
expect(help).toContain('/help');
expect(help).toContain('/paste');
expect(help).toContain('/model');
expect(help).toContain('/tools');
expect(help).toContain('/research');
+11
View File
@@ -2,6 +2,7 @@ export type Command =
| { type: 'quit' }
| { type: 'reset' }
| { type: 'help' }
| { type: 'multiline' }
| { type: 'status' }
| { type: 'tools' }
| { type: 'research'; task: string }
@@ -63,6 +64,11 @@ export function parseCommand(input: string): Command | null {
return { type: 'help' };
}
// Multiline paste mode
if (trimmed === '/paste' || trimmed === '/multiline') {
return { type: 'multiline' };
}
// Status
if (trimmed === '/status') {
return { type: 'status' };
@@ -212,6 +218,7 @@ export function getHelpText(): string {
return `
Commands:
/help, /? Show this help
/paste, /multiline Enter multiline mode (finish with single '.' line)
/tools Show available tools in this session
/model [name] Show or switch model tier (local, default, fast, complex)
/model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4)
@@ -249,6 +256,8 @@ export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'so
// List of all slash commands for autocompletion
export const SLASH_COMMANDS = [
'/help',
'/paste',
'/multiline',
'/tools',
'/model',
'/backend',
@@ -279,6 +288,8 @@ export const SLASH_COMMANDS = [
// Command descriptions for tooltips
export const COMMAND_TOOLTIPS: Record<string, string> = {
'/help': 'Show available commands',
'/paste': 'Compose a multiline message; finish with a single "." line',
'/multiline': 'Compose a multiline message; finish with a single "." line',
'/tools': 'Show authoritative runtime tool list for this session',
'/model': 'Show or switch model (local, default, fast, complex)',
'/backend': 'Show or switch local backend (ollama, llamacpp)',
+4
View File
@@ -713,6 +713,10 @@ export function App({
return;
}
case 'multiline':
pushAssistantMessage('Multiline compose mode is currently available in minimal TUI only. In fullscreen mode, submit as a single message block.');
return;
case 'message':
break;
+44
View File
@@ -37,6 +37,7 @@ function minimalTuiPrivates(value: MinimalTui): {
handleVerboseCommand: () => void;
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleMessage: (content: string) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
clearSubmittedPromptLine: () => boolean;
@@ -60,6 +61,7 @@ function minimalTuiPrivates(value: MinimalTui): {
handleVerboseCommand: () => void;
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleMessage: (content: string) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
clearSubmittedPromptLine: () => boolean;
@@ -401,6 +403,48 @@ describe('MinimalTui backend command', () => {
}
});
it('collects multiline input from /paste and sends as one message', async () => {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
};
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const tui = new MinimalTui({
session: asSession(mockSession),
modelClient: asModelClient({}),
systemPrompt: 'test',
});
const promptSpy = vi.fn()
.mockResolvedValueOnce('first line')
.mockResolvedValueOnce('second line')
.mockResolvedValueOnce('.');
minimalTuiPrivates(tui).prompt = promptSpy;
const handleMessageSpy = vi.fn(async () => {});
minimalTuiPrivates(tui).handleMessage = handleMessageSpy;
minimalTuiPrivates(tui).running = true;
minimalTuiPrivates(tui).rl = {
once: vi.fn(),
removeListener: vi.fn(),
question: vi.fn(),
write: vi.fn(),
prompt: vi.fn(),
};
await minimalTuiPrivates(tui).handleCommand({ type: 'multiline' });
expect(handleMessageSpy).toHaveBeenCalledWith('first line\nsecond line');
expect(promptSpy).toHaveBeenCalledTimes(3);
} finally {
logSpy.mockRestore();
}
});
it('only renders tool activity when verbose mode is enabled', () => {
const mockSession = {
id: 'test',
+25
View File
@@ -476,6 +476,10 @@ export class MinimalTui {
console.log(getHelpText() + '\n');
break;
case 'multiline':
await this.handleMultilineCommand();
break;
case 'status':
this.printStatus();
break;
@@ -603,6 +607,27 @@ export class MinimalTui {
console.log(`${output}\n`);
}
private async handleMultilineCommand(): Promise<void> {
console.log(`${colors.gray}Multiline mode: paste/type content. End with a single "." on its own line.${colors.reset}`);
const lines: string[] = [];
while (this.running && this.rl) {
const line = await this.prompt(`${colors.orange}...${colors.reset} `);
if (line === '.') {
break;
}
lines.push(line);
}
const content = lines.join('\n').trim();
if (!content) {
console.log(`${colors.gray}Multiline input cancelled.${colors.reset}\n`);
return;
}
await this.handleMessage(content);
}
private handleContextCommand(): void {
const history = this.config.session.getHistory();
const estimated = estimateMessageTokens(history);