From 266c37b35349b86eb04b5261a5a4326661c5f9ea Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 23:30:28 -0800 Subject: [PATCH] feat(tui): add multiline paste mode in minimal UI --- docs/plans/state.json | 18 +++++++++++- src/frontends/tui/commands.test.ts | 6 ++++ src/frontends/tui/commands.ts | 11 +++++++ src/frontends/tui/components/App.tsx | 4 +++ src/frontends/tui/minimal.test.ts | 44 ++++++++++++++++++++++++++++ src/frontends/tui/minimal.ts | 25 ++++++++++++++++ 6 files changed, 107 insertions(+), 1 deletion(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index d5c70f1..751b726 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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" diff --git a/src/frontends/tui/commands.test.ts b/src/frontends/tui/commands.test.ts index 7444073..ef845cb 100644 --- a/src/frontends/tui/commands.test.ts +++ b/src/frontends/tui/commands.test.ts @@ -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'); diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index b585644..aa73983 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -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

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 = { '/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)', diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 55a0f27..e71c79e 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -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; diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index 06eff83..67d0e1a 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -37,6 +37,7 @@ function minimalTuiPrivates(value: MinimalTui): { handleVerboseCommand: () => void; handleToolEvent: (event: unknown) => void; handleCommand: (command: unknown) => Promise; + handleMessage: (content: string) => Promise; 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; + handleMessage: (content: string) => Promise; 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', diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index c37154d..82adecb 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -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 { + 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);