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 ||