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.
This commit is contained in:
julia
2026-02-14 00:43:54 +00:00
committed by can1357
parent 2047b9ccd1
commit 3b7c2336c4
3 changed files with 224 additions and 62 deletions
+1
View File
@@ -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
+150 -62
View File
@@ -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<number>();
#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;
@@ -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());