From 3e78270cade163f23f37f034b9920af022ec75cc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:36:21 -0400 Subject: [PATCH] fix: chat mode misrepresents terminal output, looks stuck, omits user messages (#3092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes addressed: 1. PtyChatParser: user input echoed after a bare prompt line (e.g. "❯ \n" followed by "hello\n") was misclassified as assistant content. Added _awaitingInput flag that flips true on prompt boundary and classifies the next content line as role=user. 2. Chat mode "looks stuck": when the session is idle (connected, not streaming, has timeline content), no visual cue indicated GSD was waiting for input. Added a "Ready for your input" indicator with a pulsing dot. 3. Transcript overflow misalignment: chatUserMessages was not trimmed when liveTranscript/completedTurnSegments overflowed MAX_TRANSCRIPT_BLOCKS, causing index-based interleaving to pair user messages with wrong assistant responses. Also exposed isAwaitingInput() on PtyChatParser so chat UIs can query whether the session is waiting for user input, and widened the > and $ prompt marker regexes to match bare prompts after trimEnd strips trailing whitespace. Closes #2707 Co-authored-by: Claude Opus 4.6 --- src/tests/pty-chat-parser.test.ts | 128 ++++++++++++++++++++++++++++++ web/components/gsd/chat-mode.tsx | 12 +++ web/lib/gsd-workspace-store.tsx | 6 ++ web/lib/pty-chat-parser.ts | 43 +++++++++- 4 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/tests/pty-chat-parser.test.ts b/src/tests/pty-chat-parser.test.ts index 5ed060fb0..07e21b63b 100644 --- a/src/tests/pty-chat-parser.test.ts +++ b/src/tests/pty-chat-parser.test.ts @@ -19,3 +19,131 @@ test("PtyChatParser.flush emits a trailing partial line without waiting for a ne assert.equal(latest[0]?.role, "assistant"); assert.equal(latest[0]?.content, "All slices are complete — nothing to discuss.\n"); }); + +// ─── Bug #2707: User messages omitted ──────────────────────────────────────── + +test("user input echoed on the same prompt line is classified as role=user", () => { + const parser = new PtyChatParser("test"); + let latest = parser.getMessages(); + parser.onMessage(() => { + latest = parser.getMessages(); + }); + + // GSD prints assistant response, then prompt with user input on same line + parser.feed("Here is your task summary.\n"); + parser.feed("❯ show status\n"); + + const userMsgs = latest.filter((m) => m.role === "user"); + assert.equal(userMsgs.length, 1, "should have exactly one user message"); + assert.equal(userMsgs[0].content, "show status"); +}); + +test("user input on a separate line after bare prompt is classified as role=user, not assistant", () => { + const parser = new PtyChatParser("test"); + let latest = parser.getMessages(); + parser.onMessage(() => { + latest = parser.getMessages(); + }); + + // GSD prints assistant text, then bare prompt on its own line + parser.feed("Done processing.\n"); + parser.feed("❯ \n"); + // User input appears on the next line (PTY echo without prompt prefix) + parser.feed("hello world\n"); + + const userMsgs = latest.filter((m) => m.role === "user"); + assert.equal(userMsgs.length, 1, "should have exactly one user message"); + assert.equal(userMsgs[0].content, "hello world"); + + // The user input must NOT appear as assistant content + const assistantMsgs = latest.filter((m) => m.role === "assistant"); + for (const msg of assistantMsgs) { + assert.ok( + !msg.content.includes("hello world"), + "user input must not be misclassified as assistant content", + ); + } +}); + +test("multiple user turns: each user input after prompt is role=user", () => { + const parser = new PtyChatParser("test"); + let latest = parser.getMessages(); + parser.onMessage(() => { + latest = parser.getMessages(); + }); + + // Turn 1: assistant response, prompt, user input + parser.feed("Welcome to GSD.\n"); + parser.feed("❯ \n"); + parser.feed("discuss\n"); + + // Turn 2: assistant response, prompt, user input + parser.feed("Starting discussion mode.\n"); + parser.feed("❯ \n"); + parser.feed("plan my milestone\n"); + + const userMsgs = latest.filter((m) => m.role === "user"); + assert.equal(userMsgs.length, 2, "should have two user messages"); + assert.equal(userMsgs[0].content, "discuss"); + assert.equal(userMsgs[1].content, "plan my milestone"); +}); + +test("awaitingInput is true after prompt line, false after user input arrives", () => { + const parser = new PtyChatParser("test"); + + parser.feed("Task complete.\n"); + assert.equal(parser.isAwaitingInput(), false, "not awaiting input before prompt"); + + parser.feed("❯ \n"); + assert.equal(parser.isAwaitingInput(), true, "awaiting input after bare prompt"); + + parser.feed("next command\n"); + assert.equal(parser.isAwaitingInput(), false, "no longer awaiting after user input"); +}); + +test("awaitingInput resets when assistant content follows user input", () => { + const parser = new PtyChatParser("test"); + + parser.feed("Hello.\n"); + parser.feed("❯ \n"); + assert.equal(parser.isAwaitingInput(), true); + + parser.feed("do something\n"); + assert.equal(parser.isAwaitingInput(), false); + + // Assistant responds + parser.feed("Working on it...\n"); + assert.equal(parser.isAwaitingInput(), false, "should stay false during assistant output"); +}); + +// ─── Bug #2707: Chat looks stuck ──────────────────────────────────────────── + +test("prompt with empty user text does not create a user message but signals awaiting input", () => { + const parser = new PtyChatParser("test"); + let latest = parser.getMessages(); + parser.onMessage(() => { + latest = parser.getMessages(); + }); + + parser.feed("All done.\n"); + parser.feed("❯ \n"); + + const userMsgs = latest.filter((m) => m.role === "user"); + assert.equal(userMsgs.length, 0, "bare prompt should not create a user message"); + assert.equal(parser.isAwaitingInput(), true, "parser should signal awaiting input"); +}); + +test("alternate prompt markers (› and >) also trigger awaiting input", () => { + const parser = new PtyChatParser("test"); + + parser.feed("Response text.\n"); + parser.feed("› \n"); + assert.equal(parser.isAwaitingInput(), true, "› prompt should trigger awaiting input"); + + parser.feed("user reply\n"); + assert.equal(parser.isAwaitingInput(), false); + + parser.feed("More output.\n"); + parser.feed("> \n"); + assert.equal(parser.isAwaitingInput(), true, "> prompt should trigger awaiting input"); +}); diff --git a/web/components/gsd/chat-mode.tsx b/web/components/gsd/chat-mode.tsx index a715be651..f9a8dd716 100644 --- a/web/components/gsd/chat-mode.tsx +++ b/web/components/gsd/chat-mode.tsx @@ -2204,6 +2204,12 @@ export function ChatPane({ className, onOpenAction }: ChatPaneProps) { const showPlaceholder = timeline.length === 0 && !isStreaming + // Show an "awaiting input" indicator when the session is idle (connected, + // not streaming, has timeline content) so the UI does not appear stuck (#2707). + const showAwaitingInput = connected && !isStreaming && timeline.length > 0 + && !state.activeToolExecution + && state.pendingUiRequests.length === 0 + // Auto-scroll ref const scrollRef = useRef(null) const isNearBottomRef = useRef(true) @@ -2309,6 +2315,12 @@ export function ChatPane({ className, onOpenAction }: ChatPaneProps) { return } })} + {showAwaitingInput && ( +
+ + Ready for your input +
+ )}
)} diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index 7c0d1d399..3465ec91d 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -5052,10 +5052,16 @@ export class GSDWorkspaceStore { const nextThinking = [...this.state.liveThinkingTranscript, ""] const nextSegments = [...this.state.completedTurnSegments, finalSegments] const overflow = nextTranscript.length > MAX_TRANSCRIPT_BLOCKS ? nextTranscript.length - MAX_TRANSCRIPT_BLOCKS : 0 + // When overflow trims the front of parallel arrays, also trim + // chatUserMessages to keep index-based interleaving aligned (#2707). + const trimmedUserMsgs = overflow > 0 + ? this.state.chatUserMessages.slice(overflow) + : undefined this.patchState({ liveTranscript: overflow > 0 ? nextTranscript.slice(overflow) : nextTranscript, liveThinkingTranscript: overflow > 0 ? nextThinking.slice(overflow) : nextThinking, completedTurnSegments: overflow > 0 ? nextSegments.slice(overflow) : nextSegments, + ...(trimmedUserMsgs !== undefined ? { chatUserMessages: trimmedUserMsgs } : {}), streamingAssistantText: "", streamingThinkingText: "", currentTurnSegments: [], diff --git a/web/lib/pty-chat-parser.ts b/web/lib/pty-chat-parser.ts index 30b53e54c..097f538d9 100644 --- a/web/lib/pty-chat-parser.ts +++ b/web/lib/pty-chat-parser.ts @@ -115,8 +115,8 @@ export function stripAnsi(s: string): string { const PROMPT_MARKERS = [ /^❯\s*/, // Pi default primary prompt /^›\s*/, // Pi alternate prompt - /^>\s+/, // Simple > prompt (some themes) - /^\$\s+/, // Shell prompt fallback + /^>(\s+|$)/, // Simple > prompt (some themes) — bare ">" or "> text" + /^\$(\s+|$)/, // Shell prompt fallback — bare "$" or "$ text" ] /** @@ -304,6 +304,15 @@ export class PtyChatParser { */ private _completionEmitted = false + /** + * True when the parser has seen a prompt boundary and is waiting for user + * input. The next non-system, non-prompt, non-TUI content line after the + * prompt is classified as role="user" instead of "assistant". + * Reset to false once that user line arrives (or when a new assistant + * message explicitly starts via a different signal). + */ + private _awaitingInput = false + constructor(source = "default") { this._source = source } @@ -329,6 +338,15 @@ export class PtyChatParser { return [...this._messages] } + /** + * Returns true when the parser has detected a prompt boundary and is + * waiting for user input. Chat UIs can use this to show an "awaiting + * input" indicator so the session does not appear stuck. + */ + isAwaitingInput(): boolean { + return this._awaitingInput + } + /** * Flush any trailing partial buffer even if it does not end with a newline. * Useful for terminal UIs that leave the final status line unterminated. @@ -373,6 +391,7 @@ export class PtyChatParser { this._lastHeaderText = "" this._lastInputAt = 0 this._completionEmitted = false + this._awaitingInput = false if (this._completionTimer) { clearTimeout(this._completionTimer) this._completionTimer = null @@ -489,6 +508,11 @@ export class PtyChatParser { if (userText.length > 0) { const msg = this._startMessage("user", userText) this._completeMessage(msg) // user lines are typically single-line + this._awaitingInput = false + } else { + // Bare prompt with no inline user text — mark as awaiting input + // so the next content line is classified as user input. + this._awaitingInput = true } return } @@ -533,6 +557,21 @@ export class PtyChatParser { this._lastHeaderText = trimmed } + // ── Awaiting input → classify as user ────────────────────────────────── + // After a bare prompt line (e.g. "❯ \n"), the next content line is + // the user's typed input echoed back by the PTY (without prompt prefix). + if (this._awaitingInput) { + this._awaitingInput = false + const msg = this._startMessage("user", trimmed) + this._completeMessage(msg) + console.debug( + "[pty-chat-parser] user input detected (post-prompt echo) id=%s source=%s", + msg.id, + this._source, + ) + return + } + // ── Regular content line → assistant ──────────────────────────────────── if ( this._activeMessage === null ||