From 2047b9ccd1e5f3350570e9df209159e5bad1c9d3 Mon Sep 17 00:00:00 2001 From: julia Date: Sat, 14 Feb 2026 00:24:37 +0000 Subject: [PATCH] feat(coding-agent/debug): Added interactive debug log viewer with selection controls - Replaced the log preview with an interactive viewer supporting navigation, selection, expansion, and clipboard copy. - Added log formatting helpers to wrap multi-line entries and parse timestamps for session boundary detection. - Covered viewer selection, copy payload sanitization, expanded formatting, and timestamp parsing with new tests. --- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/debug/index.ts | 27 +- .../coding-agent/src/debug/log-formatting.ts | 33 ++ packages/coding-agent/src/debug/log-viewer.ts | 429 ++++++++++++++++++ .../test/debug/log-formatting.test.ts | 26 +- .../test/debug/log-viewer.test.ts | 53 +++ 6 files changed, 554 insertions(+), 17 deletions(-) create mode 100644 packages/coding-agent/src/debug/log-viewer.ts create mode 100644 packages/coding-agent/test/debug/log-viewer.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e20570b50..37c355390 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,9 @@ - Fixed `session.abort()` not clearing `promptInFlight` flag due to microtask ordering, which blocked subsequent prompts +### Added +- Added interactive debug log viewer with selection, copy, and expand/collapse controls + ### Fixed - Sanitized debug log display to strip control codes, normalize tabs, and trim width diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index 7981efa86..8f05521b0 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -11,7 +11,7 @@ import { DynamicBorder } from "../modes/components/dynamic-border"; import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme"; import type { InteractiveModeContext } from "../modes/types"; import { openPath } from "../utils/open"; -import { formatDebugLogLine } from "./log-formatting"; +import { DebugLogViewerComponent } from "./log-viewer"; import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler"; import { clearArtifactCache, createReportBundle, getArtifactCacheStats, getRecentLogs } from "./report-bundle"; import { collectSystemInfo, formatSystemInfo } from "./system-info"; @@ -279,22 +279,17 @@ export class DebugSelectorComponent extends Container { return; } - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Recent Logs")), 1, 0)); - this.ctx.chatContainer.addChild(new Spacer(1)); + const viewer = new DebugLogViewerComponent({ + logs, + terminalRows: this.ctx.ui.terminal.rows, + onExit: () => this.ctx.showDebugSelector(), + onStatus: message => this.ctx.showStatus(message, { dim: true }), + onError: message => this.ctx.showError(message), + }); - // Display logs with dim styling - const maxWidth = Math.max(1, this.ctx.ui.terminal.columns - 2); - const lines = logs.split("\n").slice(-50); - for (const line of lines) { - const formatted = formatDebugLogLine(line, maxWidth); - if (formatted.trim()) { - this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatted), 1, 0)); - } - } - - this.ctx.chatContainer.addChild(new DynamicBorder()); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(viewer); + this.ctx.ui.setFocus(viewer); } catch (err) { this.ctx.showError(`Failed to read logs: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/packages/coding-agent/src/debug/log-formatting.ts b/packages/coding-agent/src/debug/log-formatting.ts index 0602a053d..f72ff2ff6 100644 --- a/packages/coding-agent/src/debug/log-formatting.ts +++ b/packages/coding-agent/src/debug/log-formatting.ts @@ -7,3 +7,36 @@ export function formatDebugLogLine(line: string, maxWidth: number): string { const width = Math.max(1, maxWidth); return truncateToWidth(normalized, width); } + +export function formatDebugLogExpandedLines(line: string, maxWidth: number): string[] { + const sanitized = sanitizeText(line); + const normalized = replaceTabs(sanitized); + const width = Math.max(1, maxWidth); + + if (normalized.length === 0) { + return [""]; + } + + return normalized + .split("\n") + .flatMap(segment => Bun.wrapAnsi(segment, width, { hard: true, trim: false, wordWrap: true }).split("\n")); +} + +export function parseDebugLogTimestampMs(line: string): number | undefined { + try { + const parsed: unknown = JSON.parse(line); + if (!parsed || typeof parsed !== "object") { + return undefined; + } + + const timestamp = (parsed as { timestamp?: unknown }).timestamp; + if (typeof timestamp !== "string") { + return undefined; + } + + const timestampMs = Date.parse(timestamp); + return Number.isFinite(timestampMs) ? timestampMs : undefined; + } catch { + return undefined; + } +} diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts new file mode 100644 index 000000000..6cd46b694 --- /dev/null +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -0,0 +1,429 @@ +import { copyToClipboard } from "@oh-my-pi/pi-natives"; +import { + type Component, + isKittyProtocolActive, + matchesKey, + padding, + truncateToWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui"; +import { sanitizeText } from "@oh-my-pi/pi-utils"; +import { theme } from "../modes/theme/theme"; +import { formatDebugLogExpandedLines, formatDebugLogLine, parseDebugLogTimestampMs } from "./log-formatting"; + +export const SESSION_BOUNDARY_WARNING = "### WARNING - Logs above are older than current session!"; + +type LogEntry = { + rawLine: string; + timestampMs: number | undefined; +}; + +type ViewerRow = + | { + kind: "warning"; + } + | { + kind: "log"; + logIndex: number; + }; + +function getProcessStartMs(): number { + return Date.now() - process.uptime() * 1000; +} + +export function splitLogText(logText: string): string[] { + return logText.split("\n").filter(line => line.length > 0); +} + +export function buildLogCopyPayload(lines: string[]): string { + return lines + .map(line => sanitizeText(line)) + .filter(line => line.length > 0) + .join("\n"); +} + +function findSessionBoundaryIndex(entries: LogEntry[], processStartMs: number): number | undefined { + let olderSeen = false; + + for (let i = 0; i < entries.length; i++) { + const timestampMs = entries[i]?.timestampMs; + if (timestampMs === undefined) { + continue; + } + + if (timestampMs < processStartMs) { + olderSeen = true; + continue; + } + + if (olderSeen) { + return i; + } + } + + return undefined; +} + +export class DebugLogViewerModel { + #entries: LogEntry[]; + #rows: ViewerRow[]; + #cursorLogIndex = 0; + #selectionAnchorLogIndex: number | undefined; + #expandedLogIndices = new Set(); + + constructor(logText: string, processStartMs: number = getProcessStartMs()) { + this.#entries = splitLogText(logText).map(rawLine => ({ + rawLine, + timestampMs: parseDebugLogTimestampMs(rawLine), + })); + + const boundaryIndex = findSessionBoundaryIndex(this.#entries, processStartMs); + const rows: ViewerRow[] = []; + for (let i = 0; i < this.#entries.length; i++) { + if (boundaryIndex !== undefined && i === boundaryIndex) { + rows.push({ kind: "warning" }); + } + rows.push({ kind: "log", logIndex: i }); + } + this.#rows = rows; + } + + get logCount(): number { + return this.#entries.length; + } + + get rows(): readonly ViewerRow[] { + return this.#rows; + } + + get cursorLogIndex(): number { + return this.#cursorLogIndex; + } + + get expandedCount(): number { + return this.#expandedLogIndices.size; + } + + getRawLine(logIndex: number): string { + return this.#entries[logIndex]?.rawLine ?? ""; + } + + moveCursor(delta: number, extendSelection: boolean): void { + if (this.#entries.length === 0) { + return; + } + + if (extendSelection && this.#selectionAnchorLogIndex === undefined) { + this.#selectionAnchorLogIndex = this.#cursorLogIndex; + } + + this.#cursorLogIndex = Math.max(0, Math.min(this.#entries.length - 1, this.#cursorLogIndex + delta)); + + if (!extendSelection) { + this.#selectionAnchorLogIndex = undefined; + } + } + + getSelectedLogIndices(): number[] { + if (this.#entries.length === 0) { + return []; + } + + if (this.#selectionAnchorLogIndex === undefined) { + return [this.#cursorLogIndex]; + } + + const min = Math.min(this.#selectionAnchorLogIndex, this.#cursorLogIndex); + const max = Math.max(this.#selectionAnchorLogIndex, this.#cursorLogIndex); + const selected: number[] = []; + for (let i = min; i <= max; i++) { + selected.push(i); + } + return selected; + } + + getSelectedCount(): number { + return this.getSelectedLogIndices().length; + } + + isSelected(logIndex: number): boolean { + const selected = this.getSelectedLogIndices(); + return selected.includes(logIndex); + } + + isExpanded(logIndex: number): boolean { + return this.#expandedLogIndices.has(logIndex); + } + + expandSelected(): void { + for (const index of this.getSelectedLogIndices()) { + this.#expandedLogIndices.add(index); + } + } + + collapseSelected(): void { + for (const index of this.getSelectedLogIndices()) { + this.#expandedLogIndices.delete(index); + } + } + + getSelectedRawLines(): string[] { + const selectedIndices = this.getSelectedLogIndices(); + return selectedIndices.map(index => this.getRawLine(index)); + } +} + +interface DebugLogViewerComponentOptions { + logs: string; + terminalRows: number; + onExit: () => void; + onStatus?: (message: string) => void; + onError?: (message: string) => void; + processStartMs?: number; +} + +export class DebugLogViewerComponent implements Component { + #model: DebugLogViewerModel; + #terminalRows: number; + #onExit: () => void; + #onStatus?: (message: string) => void; + #onError?: (message: string) => void; + #lastRenderWidth = 80; + #scrollRowOffset = 0; + #statusMessage: string | undefined; + + constructor(options: DebugLogViewerComponentOptions) { + this.#model = new DebugLogViewerModel(options.logs, options.processStartMs); + this.#terminalRows = options.terminalRows; + this.#onExit = options.onExit; + this.#onStatus = options.onStatus; + this.#onError = options.onError; + } + + handleInput(keyData: string): void { + if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + this.#onExit(); + return; + } + + if (matchesKey(keyData, "ctrl+c")) { + void this.#copySelected(); + return; + } + + if (matchesKey(keyData, "shift+up")) { + this.#statusMessage = undefined; + this.#model.moveCursor(-1, true); + this.#ensureCursorVisible(); + return; + } + + if (matchesKey(keyData, "shift+down")) { + this.#statusMessage = undefined; + this.#model.moveCursor(1, true); + this.#ensureCursorVisible(); + return; + } + + if (matchesKey(keyData, "up")) { + this.#statusMessage = undefined; + this.#model.moveCursor(-1, false); + this.#ensureCursorVisible(); + return; + } + + if (matchesKey(keyData, "down")) { + this.#statusMessage = undefined; + this.#model.moveCursor(1, false); + this.#ensureCursorVisible(); + return; + } + + if (matchesKey(keyData, "right")) { + this.#statusMessage = undefined; + this.#model.expandSelected(); + return; + } + + if (matchesKey(keyData, "left")) { + this.#statusMessage = undefined; + this.#model.collapseSelected(); + } + } + + invalidate(): void { + // no cached child state + } + + render(width: number): string[] { + this.#lastRenderWidth = Math.max(20, width); + this.#ensureCursorVisible(); + + const innerWidth = Math.max(1, this.#lastRenderWidth - 2); + const bodyHeight = Math.max(3, this.#terminalRows - 7); + + const rows = this.#renderRows(innerWidth); + const visibleBodyLines = this.#renderVisibleBodyLines(rows, innerWidth, bodyHeight); + + return [ + this.#frameTop(innerWidth), + this.#frameLine(` Debug Logs (${this.#model.logCount}) `, innerWidth), + this.#frameLine(this.#helpText(), innerWidth), + this.#frameSeparator(innerWidth), + ...visibleBodyLines, + this.#frameLine(this.#statusText(), innerWidth), + this.#frameBottom(innerWidth), + ]; + } + + #helpText(): string { + const shiftHint = isKittyProtocolActive() ? "" : " (Shift+Arrows may require Kitty keyboard protocol)"; + return ` Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+C: copy Esc: back${shiftHint}`; + } + + #statusText(): string { + const base = ` Selected: ${this.#model.getSelectedCount()} Expanded: ${this.#model.expandedCount} Total: ${this.#model.logCount}`; + if (this.#statusMessage) { + return `${base} ${this.#statusMessage}`; + } + return base; + } + + #renderRows(innerWidth: number): Array<{ lines: string[]; rowIndex: number }> { + const rendered: Array<{ lines: string[]; rowIndex: number }> = []; + + for (let rowIndex = 0; rowIndex < this.#model.rows.length; rowIndex++) { + const row = this.#model.rows[rowIndex]; + if (!row) { + continue; + } + + if (row.kind === "warning") { + rendered.push({ + rowIndex, + lines: [theme.fg("warning", truncateToWidth(SESSION_BOUNDARY_WARNING, innerWidth))], + }); + continue; + } + + const logIndex = row.logIndex; + const selected = this.#model.isSelected(logIndex); + const active = this.#model.cursorLogIndex === logIndex; + const expanded = this.#model.isExpanded(logIndex); + const marker = active ? theme.fg("accent", "❯") : selected ? theme.fg("accent", "•") : " "; + const fold = expanded ? theme.fg("accent", "▾") : theme.fg("muted", "▸"); + const prefix = `${marker}${fold} `; + const contentWidth = Math.max(1, innerWidth - visibleWidth(prefix)); + + if (expanded) { + const wrapped = formatDebugLogExpandedLines(this.#model.getRawLine(logIndex), contentWidth); + const indent = padding(visibleWidth(prefix)); + const lines = wrapped.map((segment, index) => { + const content = selected ? theme.bold(segment) : segment; + return truncateToWidth(`${index === 0 ? prefix : indent}${content}`, innerWidth); + }); + rendered.push({ rowIndex, lines }); + continue; + } + + const preview = formatDebugLogLine(this.#model.getRawLine(logIndex), contentWidth); + const content = selected ? theme.bold(preview) : preview; + rendered.push({ rowIndex, lines: [truncateToWidth(`${prefix}${content}`, innerWidth)] }); + } + + return rendered; + } + + #renderVisibleBodyLines( + rows: Array<{ lines: string[]; rowIndex: number }>, + innerWidth: number, + bodyHeight: number, + ): string[] { + const lines: string[] = []; + for (let i = this.#scrollRowOffset; i < rows.length; i++) { + const row = rows[i]; + if (!row) { + continue; + } + + for (const line of row.lines) { + if (lines.length >= bodyHeight) { + break; + } + lines.push(this.#frameLine(line, innerWidth)); + } + + if (lines.length >= bodyHeight) { + break; + } + } + + while (lines.length < bodyHeight) { + lines.push(this.#frameLine("", innerWidth)); + } + + return lines; + } + + #ensureCursorVisible(): void { + const cursorRowIndex = this.#model.rows.findIndex( + row => row.kind === "log" && row.logIndex === this.#model.cursorLogIndex, + ); + if (cursorRowIndex < 0) { + this.#scrollRowOffset = 0; + return; + } + + const maxVisibleRows = Math.max(1, Math.max(3, this.#terminalRows - 7)); + if (cursorRowIndex < this.#scrollRowOffset) { + this.#scrollRowOffset = cursorRowIndex; + return; + } + + const maxIndex = this.#scrollRowOffset + maxVisibleRows - 1; + if (cursorRowIndex > maxIndex) { + this.#scrollRowOffset = cursorRowIndex - maxVisibleRows + 1; + } + } + + #frameTop(innerWidth: number): string { + return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + } + + #frameSeparator(innerWidth: number): string { + return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + } + + #frameBottom(innerWidth: number): string { + return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + } + + #frameLine(content: string, innerWidth: number): string { + const truncated = truncateToWidth(content, innerWidth); + const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); + return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + } + + async #copySelected(): Promise { + const selectedPayload = buildLogCopyPayload(this.#model.getSelectedRawLines()); + const selected = selectedPayload.length === 0 ? [] : selectedPayload.split("\n"); + + if (selected.length === 0) { + const message = "No log entry selected"; + this.#statusMessage = message; + this.#onStatus?.(message); + return; + } + + try { + await copyToClipboard(selectedPayload); + const message = `Copied ${selected.length} log ${selected.length === 1 ? "entry" : "entries"}`; + this.#statusMessage = message; + this.#onStatus?.(message); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.#statusMessage = `Copy failed: ${message}`; + this.#onError?.(`Failed to copy logs: ${message}`); + } + } +} diff --git a/packages/coding-agent/test/debug/log-formatting.test.ts b/packages/coding-agent/test/debug/log-formatting.test.ts index d6dbb9cf9..c0df00f5f 100644 --- a/packages/coding-agent/test/debug/log-formatting.test.ts +++ b/packages/coding-agent/test/debug/log-formatting.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { formatDebugLogLine } from "../../src/debug/log-formatting"; +import { + formatDebugLogExpandedLines, + formatDebugLogLine, + parseDebugLogTimestampMs, +} from "../../src/debug/log-formatting"; describe("formatDebugLogLine", () => { it("strips ANSI codes and carriage returns", () => { @@ -26,4 +30,24 @@ describe("formatDebugLogLine", () => { expect(Bun.stringWidth(result)).toBeLessThanOrEqual(10); expect(result.startsWith("012345")).toBe(true); }); + + it("wraps expanded log lines without dropping content", () => { + const input = "0123456789ABCDEFGHIJ"; + const lines = formatDebugLogExpandedLines(input, 6); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(6); + } + }); + + it("parses timestamp from JSON log lines", () => { + const input = '{"timestamp":"2026-02-14T12:34:56.000Z","level":"info","message":"ok"}'; + expect(parseDebugLogTimestampMs(input)).toBe(Date.parse("2026-02-14T12:34:56.000Z")); + }); + + it("returns undefined when timestamp is missing or invalid", () => { + expect(parseDebugLogTimestampMs('{"message":"ok"}')).toBeUndefined(); + expect(parseDebugLogTimestampMs('{"timestamp":"not-a-date"}')).toBeUndefined(); + expect(parseDebugLogTimestampMs("not-json")).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/test/debug/log-viewer.test.ts b/packages/coding-agent/test/debug/log-viewer.test.ts new file mode 100644 index 000000000..b26a40a24 --- /dev/null +++ b/packages/coding-agent/test/debug/log-viewer.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import { buildLogCopyPayload, DebugLogViewerModel, SESSION_BOUNDARY_WARNING } from "../../src/debug/log-viewer"; + +describe("DebugLogViewerModel", () => { + it("inserts session boundary warning between older and current-session logs", () => { + const processStartMs = Date.parse("2026-02-14T12:00:00.000Z"); + const logs = [ + '{"timestamp":"2026-02-14T11:59:00.000Z","level":"info","message":"old"}', + '{"timestamp":"2026-02-14T11:59:30.000Z","level":"info","message":"old-2"}', + '{"timestamp":"2026-02-14T12:00:05.000Z","level":"info","message":"current"}', + ].join("\n"); + + const model = new DebugLogViewerModel(logs, processStartMs); + const rowKinds = model.rows.map(row => + row.kind === "warning" ? SESSION_BOUNDARY_WARNING : `log:${row.logIndex}`, + ); + + expect(rowKinds).toEqual(["log:0", "log:1", SESSION_BOUNDARY_WARNING, "log:2"]); + }); + + it("supports shift-range selection and reset on plain movement", () => { + const logs = ["a", "b", "c", "d"].join("\n"); + const model = new DebugLogViewerModel(logs, Date.now()); + + model.moveCursor(1, true); + model.moveCursor(1, true); + expect(model.getSelectedLogIndices()).toEqual([0, 1, 2]); + + model.moveCursor(1, false); + expect(model.getSelectedLogIndices()).toEqual([3]); + }); + + it("expands and collapses all selected rows", () => { + const logs = ["a", "b", "c"].join("\n"); + const model = new DebugLogViewerModel(logs, Date.now()); + + model.moveCursor(1, true); + model.expandSelected(); + expect(model.isExpanded(0)).toBe(true); + expect(model.isExpanded(1)).toBe(true); + + model.collapseSelected(); + expect(model.isExpanded(0)).toBe(false); + expect(model.isExpanded(1)).toBe(false); + }); +}); + +describe("buildLogCopyPayload", () => { + it("joins selected lines and strips control/ansi sequences", () => { + const payload = buildLogCopyPayload(["plain", "\u001b[31mred\u001b[0m", "ok\u0007", ""]); + expect(payload).toBe("plain\nred\nok"); + }); +});