Fix minimal TUI submitted-line duplicate appearance

This commit is contained in:
William Valentin
2026-02-22 17:21:22 -08:00
parent 0775c9ede2
commit 9a9375ef5d
3 changed files with 72 additions and 2 deletions
+13 -1
View File
@@ -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%)",
+42
View File
@@ -39,6 +39,7 @@ function minimalTuiPrivates(value: MinimalTui): {
handleCommand: (command: unknown) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
clearSubmittedPromptLine: () => boolean;
prompt: (text: string) => Promise<string>;
rl: {
once: (event: string, cb: () => void) => void;
@@ -61,6 +62,7 @@ function minimalTuiPrivates(value: MinimalTui): {
handleCommand: (command: unknown) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
clearSubmittedPromptLine: () => boolean;
prompt: (text: string) => Promise<string>;
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',
+17 -1
View File
@@ -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<void> {
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`);