From 3b7c2336c4e844aee306ce0eb436cbd0f5d11b4e Mon Sep 17 00:00:00 2001 From: julia Date: Sat, 14 Feb 2026 00:43:54 +0000 Subject: [PATCH] feat(coding-agent/debug): Added inline filtering to debug log viewer - Added case-insensitive substring filtering with inline query entry and visible count line in the debug log viewer. - Preserved cursor and selection when filters change and show a no matches placeholder with adjusted scroll bounds. - Updated filter sanitization and frame layout, removing Kitty-specific help text and aligning status counters. - Expanded tests to cover filtering, selection anchoring, copy payloads, and session boundary warnings. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/debug/log-viewer.ts | 212 +++++++++++++----- .../test/debug/log-viewer.test.ts | 73 ++++++ 3 files changed, 224 insertions(+), 62 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 37c355390..32283b5d0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added - Added interactive debug log viewer with selection, copy, and expand/collapse controls +- Added inline filtering and count display to the debug log viewer ### Fixed - Sanitized debug log display to strip control codes, normalize tabs, and trim width diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index 6cd46b694..b92c997be 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -1,14 +1,8 @@ import { copyToClipboard } from "@oh-my-pi/pi-natives"; -import { - type Component, - isKittyProtocolActive, - matchesKey, - padding, - truncateToWidth, - visibleWidth, -} from "@oh-my-pi/pi-tui"; +import { type Component, 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 { replaceTabs } from "../tools/render-utils"; import { formatDebugLogExpandedLines, formatDebugLogLine, parseDebugLogTimestampMs } from "./log-formatting"; export const SESSION_BOUNDARY_WARNING = "### WARNING - Logs above are older than current session!"; @@ -42,62 +36,45 @@ export function buildLogCopyPayload(lines: string[]): string { .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; + #visibleLogIndices: number[]; + #cursorVisibleIndex = 0; + #selectionAnchorVisibleIndex: number | undefined; #expandedLogIndices = new Set(); + #filterQuery = ""; + #processStartMs: number; 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; + this.#processStartMs = processStartMs; + this.#rows = []; + this.#visibleLogIndices = []; + this.#rebuildRows(); } get logCount(): number { return this.#entries.length; } + get visibleLogCount(): number { + return this.#visibleLogIndices.length; + } + get rows(): readonly ViewerRow[] { return this.#rows; } + get filterQuery(): string { + return this.#filterQuery; + } + get cursorLogIndex(): number { - return this.#cursorLogIndex; + return this.#visibleLogIndices[this.#cursorVisibleIndex] ?? 0; } get expandedCount(): number { @@ -108,36 +85,50 @@ export class DebugLogViewerModel { return this.#entries[logIndex]?.rawLine ?? ""; } + setFilterQuery(query: string): void { + if (query === this.#filterQuery) { + return; + } + this.#filterQuery = query; + this.#rebuildRows(); + } + moveCursor(delta: number, extendSelection: boolean): void { - if (this.#entries.length === 0) { + if (this.#visibleLogIndices.length === 0) { return; } - if (extendSelection && this.#selectionAnchorLogIndex === undefined) { - this.#selectionAnchorLogIndex = this.#cursorLogIndex; + if (extendSelection && this.#selectionAnchorVisibleIndex === undefined) { + this.#selectionAnchorVisibleIndex = this.#cursorVisibleIndex; } - this.#cursorLogIndex = Math.max(0, Math.min(this.#entries.length - 1, this.#cursorLogIndex + delta)); + this.#cursorVisibleIndex = Math.max( + 0, + Math.min(this.#visibleLogIndices.length - 1, this.#cursorVisibleIndex + delta), + ); if (!extendSelection) { - this.#selectionAnchorLogIndex = undefined; + this.#selectionAnchorVisibleIndex = undefined; } } getSelectedLogIndices(): number[] { - if (this.#entries.length === 0) { + if (this.#visibleLogIndices.length === 0) { return []; } - if (this.#selectionAnchorLogIndex === undefined) { - return [this.#cursorLogIndex]; + if (this.#selectionAnchorVisibleIndex === undefined) { + return [this.cursorLogIndex]; } - const min = Math.min(this.#selectionAnchorLogIndex, this.#cursorLogIndex); - const max = Math.max(this.#selectionAnchorLogIndex, this.#cursorLogIndex); + const min = Math.min(this.#selectionAnchorVisibleIndex, this.#cursorVisibleIndex); + const max = Math.max(this.#selectionAnchorVisibleIndex, this.#cursorVisibleIndex); const selected: number[] = []; for (let i = min; i <= max; i++) { - selected.push(i); + const logIndex = this.#visibleLogIndices[i]; + if (logIndex !== undefined) { + selected.push(logIndex); + } } return selected; } @@ -171,6 +162,69 @@ export class DebugLogViewerModel { const selectedIndices = this.getSelectedLogIndices(); return selectedIndices.map(index => this.getRawLine(index)); } + + #rebuildRows(): void { + const previousVisible = this.#visibleLogIndices; + const previousCursorLogIndex = previousVisible[this.#cursorVisibleIndex]; + const previousAnchorLogIndex = + this.#selectionAnchorVisibleIndex === undefined + ? undefined + : previousVisible[this.#selectionAnchorVisibleIndex]; + + const query = this.#filterQuery.toLowerCase(); + const visible: number[] = []; + for (let i = 0; i < this.#entries.length; i++) { + const entry = this.#entries[i]; + if (!entry) { + continue; + } + if (query.length === 0 || entry.rawLine.toLowerCase().includes(query)) { + visible.push(i); + } + } + this.#visibleLogIndices = visible; + + const rows: ViewerRow[] = []; + let olderSeen = false; + let warningInserted = false; + for (const logIndex of visible) { + const timestampMs = this.#entries[logIndex]?.timestampMs; + if (timestampMs !== undefined) { + if (timestampMs < this.#processStartMs) { + olderSeen = true; + } else if (olderSeen && !warningInserted) { + rows.push({ kind: "warning" }); + warningInserted = true; + } + } + rows.push({ kind: "log", logIndex }); + } + this.#rows = rows; + + if (visible.length === 0) { + this.#cursorVisibleIndex = 0; + this.#selectionAnchorVisibleIndex = undefined; + return; + } + + if (previousCursorLogIndex !== undefined) { + const cursorIndex = visible.indexOf(previousCursorLogIndex); + if (cursorIndex >= 0) { + this.#cursorVisibleIndex = cursorIndex; + } else { + this.#cursorVisibleIndex = Math.min(this.#cursorVisibleIndex, visible.length - 1); + } + } else { + this.#cursorVisibleIndex = Math.min(this.#cursorVisibleIndex, visible.length - 1); + } + + if (previousAnchorLogIndex !== undefined) { + const anchorIndex = visible.indexOf(previousAnchorLogIndex); + this.#selectionAnchorVisibleIndex = anchorIndex >= 0 ? anchorIndex : undefined; + } else { + this.#selectionAnchorVisibleIndex = undefined; + } + } } interface DebugLogViewerComponentOptions { @@ -248,6 +302,26 @@ export class DebugLogViewerComponent implements Component { if (matchesKey(keyData, "left")) { this.#statusMessage = undefined; this.#model.collapseSelected(); + return; + } + + if (matchesKey(keyData, "backspace")) { + if (this.#model.filterQuery.length > 0) { + this.#statusMessage = undefined; + this.#model.setFilterQuery(this.#model.filterQuery.slice(0, -1)); + this.#ensureCursorVisible(); + } + return; + } + + const hasControlChars = [...keyData].some(ch => { + const code = ch.charCodeAt(0); + return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); + }); + if (!hasControlChars && keyData.length > 0) { + this.#statusMessage = undefined; + this.#model.setFilterQuery(this.#model.filterQuery + keyData); + this.#ensureCursorVisible(); } } @@ -260,15 +334,17 @@ export class DebugLogViewerComponent implements Component { this.#ensureCursorVisible(); const innerWidth = Math.max(1, this.#lastRenderWidth - 2); - const bodyHeight = Math.max(3, this.#terminalRows - 7); + const bodyHeight = Math.max(3, this.#terminalRows - 8); 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), + this.#frameLine(this.#summaryText(), innerWidth), + this.#frameSeparator(innerWidth), + this.#frameLine(this.#filterText(), innerWidth), this.#frameSeparator(innerWidth), ...visibleBodyLines, this.#frameLine(this.#statusText(), innerWidth), @@ -276,13 +352,22 @@ export class DebugLogViewerComponent implements Component { ]; } - #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}`; + #summaryText(): string { + return ` # ${this.#model.visibleLogCount}/${this.#model.logCount} logs | ${this.#controlsText()}`; + } + + #controlsText(): string { + return "Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+C: copy Esc: back"; + } + + #filterText(): string { + const sanitized = replaceTabs(sanitizeText(this.#model.filterQuery)); + const query = sanitized.length === 0 ? "" : theme.fg("accent", sanitized); + return ` filter: ${query}`; } #statusText(): string { - const base = ` Selected: ${this.#model.getSelectedCount()} Expanded: ${this.#model.expandedCount} Total: ${this.#model.logCount}`; + const base = ` Selected: ${this.#model.getSelectedCount()} Expanded: ${this.#model.expandedCount}`; if (this.#statusMessage) { return `${base} ${this.#statusMessage}`; } @@ -340,6 +425,9 @@ export class DebugLogViewerComponent implements Component { bodyHeight: number, ): string[] { const lines: string[] = []; + if (rows.length === 0) { + lines.push(this.#frameLine(theme.fg("muted", "no matches"), innerWidth)); + } for (let i = this.#scrollRowOffset; i < rows.length; i++) { const row = rows[i]; if (!row) { @@ -374,7 +462,7 @@ export class DebugLogViewerComponent implements Component { return; } - const maxVisibleRows = Math.max(1, Math.max(3, this.#terminalRows - 7)); + const maxVisibleRows = Math.max(1, Math.max(3, this.#terminalRows - 8)); if (cursorRowIndex < this.#scrollRowOffset) { this.#scrollRowOffset = cursorRowIndex; return; diff --git a/packages/coding-agent/test/debug/log-viewer.test.ts b/packages/coding-agent/test/debug/log-viewer.test.ts index b26a40a24..b89da91c3 100644 --- a/packages/coding-agent/test/debug/log-viewer.test.ts +++ b/packages/coding-agent/test/debug/log-viewer.test.ts @@ -18,6 +18,79 @@ describe("DebugLogViewerModel", () => { expect(rowKinds).toEqual(["log:0", "log:1", SESSION_BOUNDARY_WARNING, "log:2"]); }); + it("filters logs with case-insensitive substring matching", () => { + const logs = ["Alpha", "beta", "Gamma", "BETTER"].join("\n"); + const model = new DebugLogViewerModel(logs, Date.now()); + + model.setFilterQuery("be"); + const rowKinds = model.rows.map(row => + row.kind === "warning" ? SESSION_BOUNDARY_WARNING : `log:${row.logIndex}`, + ); + expect(rowKinds).toEqual(["log:1", "log:3"]); + expect(model.visibleLogCount).toBe(2); + }); + + it("clamps cursor when filtered list shrinks", () => { + const logs = ["alpha", "beta", "gamma"].join("\n"); + const model = new DebugLogViewerModel(logs, Date.now()); + + model.moveCursor(2, false); + expect(model.cursorLogIndex).toBe(2); + + model.setFilterQuery("alpha"); + expect(model.cursorLogIndex).toBe(0); + expect(model.visibleLogCount).toBe(1); + }); + + it("resets selection anchor when filtered view drops the anchor log", () => { + const logs = ["alpha", "beta", "gamma", "delta"].join("\n"); + const model = new DebugLogViewerModel(logs, Date.now()); + + model.moveCursor(2, true); + expect(model.getSelectedLogIndices()).toEqual([0, 1, 2]); + + model.setFilterQuery("beta"); + expect(model.getSelectedLogIndices()).toEqual([1]); + }); + + it("shows session boundary warning only when older and newer logs are visible", () => { + 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-14T12:00:05.000Z","level":"info","message":"current"}', + '{"timestamp":"2026-02-14T12:00:10.000Z","level":"info","message":"current-2"}', + ].join("\n"); + const model = new DebugLogViewerModel(logs, processStartMs); + + model.setFilterQuery("old"); + expect(model.rows.map(row => row.kind)).toEqual(["log"]); + + model.setFilterQuery("current-2"); + expect(model.rows.map(row => row.kind)).toEqual(["log"]); + + model.setFilterQuery("current"); + const rowKinds = model.rows.map(row => + row.kind === "warning" ? SESSION_BOUNDARY_WARNING : `log:${row.logIndex}`, + ); + expect(rowKinds).toEqual(["log:1", "log:2"]); + + model.setFilterQuery(""); + const fullRowKinds = model.rows.map(row => + row.kind === "warning" ? SESSION_BOUNDARY_WARNING : `log:${row.logIndex}`, + ); + expect(fullRowKinds).toEqual(["log:0", SESSION_BOUNDARY_WARNING, "log:1", "log:2"]); + }); + + it("copies only selected visible entries", () => { + const logs = ["alpha", "bar", "baz"].join("\n"); + const model = new DebugLogViewerModel(logs, Date.now()); + + model.setFilterQuery("ba"); + model.moveCursor(1, true); + const payload = buildLogCopyPayload(model.getSelectedRawLines()); + expect(payload).toBe("bar\nbaz"); + }); + 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());