From 9a9375ef5ddcd2097184301ad3c4ea6a097eaed1 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 17:21:22 -0800 Subject: [PATCH] Fix minimal TUI submitted-line duplicate appearance --- docs/plans/state.json | 14 ++++++++++- src/frontends/tui/minimal.test.ts | 42 +++++++++++++++++++++++++++++++ src/frontends/tui/minimal.ts | 18 ++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 4f0a49b..e127417 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,18 @@ "updated_at": "2026-02-23", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "minimal-tui-submitted-line-dedupe": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Fixed duplicate-looking user messages in minimal TUI by clearing the just-submitted readline prompt line before rendering the timestamped `You` block, so sent content is shown once in the conversation transcript.", + "files_modified": [ + "src/frontends/tui/minimal.ts", + "src/frontends/tui/minimal.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/frontends/tui/minimal.test.ts passing" + }, "dashboard-local-backend-update-actions": { "status": "completed", "date": "2026-02-23", @@ -6010,7 +6022,7 @@ } }, "overall_progress": { - "total_test_count": 1941, + "total_test_count": 1942, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index 4a9a30b..06eff83 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -39,6 +39,7 @@ function minimalTuiPrivates(value: MinimalTui): { handleCommand: (command: unknown) => Promise; handleEscapeAction: () => boolean; handleCtrlCPress: (nowMs?: number) => boolean; + clearSubmittedPromptLine: () => boolean; prompt: (text: string) => Promise; rl: { once: (event: string, cb: () => void) => void; @@ -61,6 +62,7 @@ function minimalTuiPrivates(value: MinimalTui): { handleCommand: (command: unknown) => Promise; handleEscapeAction: () => boolean; handleCtrlCPress: (nowMs?: number) => boolean; + clearSubmittedPromptLine: () => boolean; prompt: (text: string) => Promise; rl: { once: (event: string, cb: () => void) => void; @@ -428,6 +430,46 @@ describe('MinimalTui backend command', () => { }); describe('MinimalTui prompt cancellation', () => { + it('omits leading newline when submitted prompt line was cleared', async () => { + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + const mockAgent = { + process: vi.fn(async () => 'ok'), + }; + + const tui = new MinimalTui({ + session: asSession(mockSession), + modelClient: asRouter({}), + agent: asAgent(mockAgent), + systemPrompt: 'test', + }); + + const clearSpy = vi.fn(() => true); + minimalTuiPrivates(tui).clearSubmittedPromptLine = clearSpy; + + await minimalTuiPrivates(tui).handleCommand({ type: 'message', content: 'hello' }); + + expect(clearSpy).toHaveBeenCalledOnce(); + const userHeader = writeSpy.mock.calls + .map(([chunk]) => String(chunk)) + .find((chunk) => chunk.includes('You')); + expect(userHeader).toBeDefined(); + expect(userHeader?.startsWith('\n')).toBe(false); + } finally { + writeSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + it('cancels an active prompt without closing the TUI', async () => { const mockSession = { id: 'test', diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 52a94fc..c37154d 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -237,6 +237,21 @@ export class MinimalTui { } } + private clearSubmittedPromptLine(): boolean { + if (!process.stdout.isTTY) { + return false; + } + + try { + readline.moveCursor(process.stdout, 0, -1); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + return true; + } catch { + return false; + } + } + private handleToolEvent(event: ToolUseEvent): void { if (!this.verbose) { return; @@ -1309,9 +1324,10 @@ export class MinimalTui { } private async handleMessage(content: string): Promise { + const clearedPromptLine = this.clearSubmittedPromptLine(); const userTimestamp = formatMessageTimestampParts(Date.now()); process.stdout.write( - `\n${colors.blue}${colors.bold}You${colors.reset} ${colors.gray}[${userTimestamp.date} | ${userTimestamp.time}]${colors.reset}\n`, + `${clearedPromptLine ? '' : '\n'}${colors.blue}${colors.bold}You${colors.reset} ${colors.gray}[${userTimestamp.date} | ${userTimestamp.time}]${colors.reset}\n`, ); process.stdout.write(`${content}\n`);