diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7d4425998..5ef5e2b5e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added mouse support for scrolling and interaction in the debug log and raw SSE stream viewers + - Added the `statusLine.compactThinkingLevel` setting to render the model segment's thinking level as a single leading glyph (replacing the model icon) instead of a separate ` · ` suffix. - Added support for tracking reasoning tokens in session and advisor statistics - Added `compaction.remoteStreamingV2Enabled` setting to toggle V2 streaming for remote compaction @@ -29,7 +31,6 @@ ### Fixed - Fixed interrupted thinking being lost in LLM provider requests after user interrupts by properly stripping trailing reasoning blocks from assistant turns while preserving them in the UI and session history - - Fixed the live todo HUD going stale during long tool-use loops by adding a mid-run reconciliation reminder: after several consecutive tool-use turns without invoking the `todo` tool, the agent now receives a `` listing the still-incomplete items so it flips them as work completes rather than batch-marking everything `done` at the very end of a run. ([#3651](https://github.com/can1357/oh-my-pi/issues/3651)) - Preserved interrupted assistant thinking as hidden durable context after user interrupts. - Fixed resumed OpenAI / OpenAI-Codex sessions losing encrypted reasoning and native assistant turns: rehydration only strips Responses replay metadata for GitHub Copilot now (the sole provider that 401s on warmed-session replay), so remote compaction rebuilds faithful native history instead of sending tool-call-only context with no reasoning. diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index 886137dea..3e53a7b95 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -3,6 +3,7 @@ import { extractPrintableText, matchesKey, padding, + parseSgrMouse, replaceTabs, truncateToWidth, visibleWidth, @@ -187,6 +188,31 @@ export class DebugLogViewerModel { } } + moveCursorToRow(rowIndex: number, extendSelection: boolean): boolean { + const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex); + if (selectableIndex < 0) { + return false; + } + + if (extendSelection && this.#selectionAnchorSelectableIndex === undefined) { + const row = this.#getCursorRow(); + if (row?.kind === "log") { + this.#selectionAnchorSelectableIndex = this.#cursorSelectableIndex; + } + } + + this.#cursorSelectableIndex = selectableIndex; + + if (!extendSelection) { + this.#selectionAnchorSelectableIndex = undefined; + } + + if (this.#getCursorRow()?.kind !== "log" && !extendSelection) { + this.#selectionAnchorSelectableIndex = undefined; + } + return true; + } + getSelectedLogIndices(): number[] { if (this.#selectableRowIndices.length === 0) { return []; @@ -487,6 +513,9 @@ export class DebugLogViewerComponent implements Component { #scrollRowOffset = 0; #statusMessage: string | undefined; #loadingOlder = false; + #bodyRowStart = 0; + #bodyRowCount = 0; + #bodyLineToRowIndex: Array = []; constructor(options: DebugLogViewerComponentOptions) { this.#logSource = options.logSource; @@ -504,6 +533,10 @@ export class DebugLogViewerComponent implements Component { } handleInput(keyData: string): void { + if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) { + return; + } + if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { this.#onExit(); return; @@ -601,6 +634,42 @@ export class DebugLogViewerComponent implements Component { } } + #handleMouse(keyData: string): boolean { + const event = parseSgrMouse(keyData); + if (!event) return false; + + const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount; + if (event.wheel !== null && overBody) { + this.#statusMessage = undefined; + const maxOffset = Math.max(0, this.#model.rows.length - 1); + this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, this.#scrollRowOffset + event.wheel * 3)); + this.#onUpdate?.(); + return true; + } + + if (!event.leftClick || !overBody) return false; + const rowIndex = this.#bodyLineToRowIndex[event.row - this.#bodyRowStart]; + if (rowIndex === undefined) return false; + + const target = this.#model.rows[rowIndex]; + if (!target || target.kind === "warning") return false; + this.#statusMessage = undefined; + this.#model.moveCursorToRow(rowIndex, false); + if (target.kind === "load-older") { + void this.#handleLoadOlder(); + return true; + } + + if (this.#model.isExpanded(target.logIndex)) { + this.#model.collapseSelected(); + } else { + this.#model.expandSelected(); + } + this.#ensureCursorVisible(); + this.#onUpdate?.(); + return true; + } + invalidate(): void { // no cached child state } @@ -613,6 +682,8 @@ export class DebugLogViewerComponent implements Component { const bodyHeight = this.#bodyHeight(); const rows = this.#renderRows(contentWidth); + this.#bodyRowStart = 4; + this.#bodyRowCount = bodyHeight; const visibleBodyLines = this.#renderVisibleBodyLines(rows, bodyHeight); return [ @@ -635,7 +706,7 @@ export class DebugLogViewerComponent implements Component { } #controlsText(): string { - return "Esc close · Ctrl+C copy · ↑/↓ move · Shift+↑/↓ select · ←/→ collapse/expand · Ctrl+A all · Ctrl+O older · Ctrl+P pid"; + return "Esc close · Ctrl+C copy · ↑/↓/wheel move · click toggle · Shift+↑/↓ select · ←/→ collapse/expand · Ctrl+A all · Ctrl+O older · Ctrl+P pid"; } #filterText(): string { @@ -786,8 +857,10 @@ export class DebugLogViewerComponent implements Component { } #renderVisibleBodyLines(rows: Array<{ lines: string[]; rowIndex: number }>, bodyHeight: number): string[] { + this.#bodyLineToRowIndex = []; const lines: string[] = []; if (rows.length === 0) { + this.#bodyLineToRowIndex.push(undefined); lines.push(row(theme.fg("muted", "no matches"), this.#lastRenderWidth)); } for (let i = this.#scrollRowOffset; i < rows.length; i++) { @@ -800,6 +873,7 @@ export class DebugLogViewerComponent implements Component { if (lines.length >= bodyHeight) { break; } + this.#bodyLineToRowIndex.push(renderedRow.rowIndex); lines.push(row(line, this.#lastRenderWidth)); } @@ -809,6 +883,7 @@ export class DebugLogViewerComponent implements Component { } while (lines.length < bodyHeight) { + this.#bodyLineToRowIndex.push(undefined); lines.push(row("", this.#lastRenderWidth)); } diff --git a/packages/coding-agent/src/debug/raw-sse.ts b/packages/coding-agent/src/debug/raw-sse.ts index 275e7cd13..3b6b3d0f3 100644 --- a/packages/coding-agent/src/debug/raw-sse.ts +++ b/packages/coding-agent/src/debug/raw-sse.ts @@ -1,4 +1,4 @@ -import { type Component, matchesKey, replaceTabs, ScrollView, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { type Component, matchesKey, parseSgrMouse, replaceTabs, ScrollView, truncateToWidth } from "@oh-my-pi/pi-tui"; import { sanitizeText } from "@oh-my-pi/pi-utils"; import { bottomBorder, divider, row, topBorder } from "../modes/components/overlay-box"; import { theme } from "../modes/theme/theme"; @@ -72,6 +72,8 @@ export class RawSseViewerComponent implements Component { #followTail = true; #lastRenderWidth = MIN_VIEWER_WIDTH; #statusMessage: string | undefined; + #bodyRowStart = 0; + #bodyRowCount = 0; // Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is // the JSON.parse + JSON.stringify per `data:` line, so we cache the result — // the render path runs on every keypress and from `#maxScrollOffset()`. @@ -96,6 +98,10 @@ export class RawSseViewerComponent implements Component { } handleInput(keyData: string): void { + if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) { + return; + } + if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { this.dispose(); this.#onExit(); @@ -142,6 +148,35 @@ export class RawSseViewerComponent implements Component { } } + #handleMouse(keyData: string): boolean { + const event = parseSgrMouse(keyData); + if (!event) return false; + + const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount; + if (event.wheel !== null && overBody) { + this.#followTail = false; + this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), this.#scrollOffset + event.wheel * 3)); + this.#onUpdate?.(); + return true; + } + + if (!event.leftClick) return false; + if (event.row === 1) { + this.#followTail = !this.#followTail; + this.#followIfNeeded(); + this.#onUpdate?.(); + return true; + } + if (overBody) { + this.#followTail = false; + const clickedOffset = this.#scrollOffset + event.row - this.#bodyRowStart; + this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), clickedOffset)); + this.#onUpdate?.(); + return true; + } + return false; + } + invalidate(): void {} render(width: number): readonly string[] { @@ -159,6 +194,8 @@ export class RawSseViewerComponent implements Component { }); sv.setScrollOffset(this.#scrollOffset); const bodyRows = sv.render(contentWidth); + this.#bodyRowStart = 3; + this.#bodyRowCount = bodyHeight; return [ topBorder(this.#lastRenderWidth, "Raw Provider Stream"), @@ -229,7 +266,7 @@ export class RawSseViewerComponent implements Component { } #statusText(): string { - const help = "Esc close · Ctrl+C copy raw · End follow tail · ↑/↓ PgUp/PgDn scroll"; + const help = "Esc close · Ctrl+C copy raw · End follow tail · wheel scroll · click summary toggles follow"; return this.#statusMessage ? `${theme.fg("success", this.#statusMessage)} ${theme.fg("dim", help)}` : theme.fg("dim", help); diff --git a/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts b/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts index 7d76795c5..b54482b1c 100644 --- a/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts @@ -190,9 +190,9 @@ describe("AgentSession interrupted thinking persistence", () => { const llm = convertToLlm(messages); const assistantLlm = llm.find(entry => entry.role === "assistant"); expect(assistantLlm).toBeDefined(); - expect(Array.isArray(assistantLlm?.content) && assistantLlm.content.some(block => block.type === "thinking")).toBe( - false, - ); + expect( + Array.isArray(assistantLlm?.content) && assistantLlm.content.some(block => block.type === "thinking"), + ).toBe(false); const developerLlm = llm.filter(entry => entry.role === "developer"); expect(developerLlm.some(entry => JSON.stringify(entry.content).includes(REASONING_TEXT))).toBe(true); });