diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 732803cf6..6f7e8baa6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Added interactive debug log viewer with selection, copy, and expand/collapse controls - Added inline filtering and count display to the debug log viewer - Added pid filter toggle and load-older pagination controls to the debug log viewer +- Enabled loading older debug logs from archived files in viewer ### 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 26dc742e8..9e54aa035 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -13,7 +13,7 @@ import type { InteractiveModeContext } from "../modes/types"; import { openPath } from "../utils/open"; import { DebugLogViewerComponent } from "./log-viewer"; import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler"; -import { clearArtifactCache, createReportBundle, getArtifactCacheStats, getLogText } from "./report-bundle"; +import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle"; import { collectSystemInfo, formatSystemInfo } from "./system-info"; /** Debug menu options */ @@ -273,8 +273,9 @@ export class DebugSelectorComponent extends Container { async #handleViewLogs(): Promise { try { - const logs = await getLogText(); - if (!logs) { + const logSource = await createDebugLogSource(); + const logs = await logSource.getInitialText(); + if (!logs && !logSource.hasOlderLogs()) { this.ctx.showWarning("No log entries found for today."); return; } @@ -285,6 +286,8 @@ export class DebugSelectorComponent extends Container { onExit: () => this.ctx.showDebugSelector(), onStatus: message => this.ctx.showStatus(message, { dim: true }), onError: message => this.ctx.showError(message), + onUpdate: () => this.ctx.ui.requestRender(), + logSource, }); this.ctx.editorContainer.clear(); diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index 1db262066..a74f2b0a0 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -9,9 +9,10 @@ import { parseDebugLogPid, parseDebugLogTimestampMs, } from "./log-formatting"; +import type { DebugLogSource } from "./report-bundle"; export const SESSION_BOUNDARY_WARNING = "### WARNING - Logs above are older than current session!"; -export const LOAD_OLDER_LABEL = "### LOAD UP TO 50 OLDER ENTRIES"; +export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE..."; const INITIAL_LOG_CHUNK = 50; const LOAD_OLDER_CHUNK = 50; @@ -22,6 +23,15 @@ type LogEntry = { pid: number | undefined; }; +type CursorToken = { kind: "log"; logIndex: number } | { kind: "load-older" }; + +type DebugLogViewerModelOptions = { + processStartMs?: number; + processPid?: number; + hasOlderLogs?: () => boolean; + loadOlderLogs?: (limitDays?: number) => Promise; +}; + type ViewerRow = | { kind: "warning"; @@ -62,8 +72,11 @@ export class DebugLogViewerModel { #loadedStartIndex: number; #processFilterEnabled = false; #processPid: number; + #hasOlderLogs?: () => boolean; + #loadOlderLogs?: (limitDays?: number) => Promise; - constructor(logText: string, processStartMs: number = getProcessStartMs(), processPid: number = process.pid) { + constructor(logText: string, options: DebugLogViewerModelOptions = {}) { + const { processStartMs = getProcessStartMs(), processPid = process.pid, hasOlderLogs, loadOlderLogs } = options; this.#entries = splitLogText(logText).map(rawLine => ({ rawLine, timestampMs: parseDebugLogTimestampMs(rawLine), @@ -71,6 +84,8 @@ export class DebugLogViewerModel { })); this.#processStartMs = processStartMs; this.#processPid = processPid; + this.#hasOlderLogs = hasOlderLogs; + this.#loadOlderLogs = loadOlderLogs; this.#loadedStartIndex = Math.max(0, this.#entries.length - INITIAL_LOG_CHUNK); this.#rows = []; this.#visibleLogIndices = []; @@ -245,28 +260,73 @@ export class DebugLogViewerModel { } canLoadOlder(): boolean { - return this.#loadedStartIndex > 0; + return this.#loadedStartIndex > 0 || this.#hasExternalOlderLogs(); } - loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): void { - if (!this.canLoadOlder()) { - return; + async loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise { + if (this.#loadedStartIndex > 0) { + return this.#loadOlderInMemory(additionalCount); } + if (!this.#loadOlderLogs || !this.#hasExternalOlderLogs()) { + return false; + } + const olderText = await this.#loadOlderLogs(); + if (olderText.length === 0) { + if (!this.#hasExternalOlderLogs()) { + this.#rebuildRows(); + } + return false; + } + const added = this.prependLogs(olderText); + if (added === 0) { + if (!this.#hasExternalOlderLogs()) { + this.#rebuildRows(); + } + return false; + } + return this.#loadOlderInMemory(additionalCount); + } + prependLogs(logText: string): number { + const previousCursor = this.#getCursorToken(); + const previousAnchorLogIndex = this.#getAnchorLogIndex(); + const newEntries = splitLogText(logText).map(rawLine => ({ + rawLine, + timestampMs: parseDebugLogTimestampMs(rawLine), + pid: parseDebugLogPid(rawLine), + })); + if (newEntries.length === 0) { + return 0; + } + const offset = newEntries.length; + this.#entries = [...newEntries, ...this.#entries]; + this.#loadedStartIndex += offset; + this.#expandedLogIndices = new Set([...this.#expandedLogIndices].map(logIndex => logIndex + offset)); + const adjustedCursor: CursorToken | undefined = + previousCursor?.kind === "log" ? { kind: "log", logIndex: previousCursor.logIndex + offset } : previousCursor; + const adjustedAnchor = previousAnchorLogIndex === undefined ? undefined : previousAnchorLogIndex + offset; + this.#rebuildRows(adjustedCursor, adjustedAnchor); + return offset; + } + + #loadOlderInMemory(additionalCount: number = LOAD_OLDER_CHUNK): boolean { + if (this.#loadedStartIndex === 0) { + return false; + } const requested = Math.max(1, additionalCount); const nextStart = Math.max(0, this.#loadedStartIndex - requested); if (nextStart === this.#loadedStartIndex) { - return; + return false; } - this.#loadedStartIndex = nextStart; this.#rebuildRows(); + return true; } - #rebuildRows(): void { - const previousCursor = this.#getCursorToken(); - const previousAnchorLogIndex = this.#getAnchorLogIndex(); - + #rebuildRows( + previousCursor: CursorToken | undefined = this.#getCursorToken(), + previousAnchorLogIndex = this.#getAnchorLogIndex(), + ): void { const query = this.#filterQuery.toLowerCase(); const visible: number[] = []; for (let i = this.#loadedStartIndex; i < this.#entries.length; i++) { @@ -345,6 +405,9 @@ export class DebugLogViewerModel { } #hasOlderEntries(query: string): boolean { + if (this.#hasExternalOlderLogs()) { + return true; + } if (this.#loadedStartIndex === 0) { return false; } @@ -357,12 +420,16 @@ export class DebugLogViewerModel { return false; } + #hasExternalOlderLogs(): boolean { + return this.#hasOlderLogs?.() ?? false; + } + #getCursorRow(): ViewerRow | undefined { const rowIndex = this.cursorRowIndex; return rowIndex === undefined ? undefined : this.#rows[rowIndex]; } - #getCursorToken(): { kind: "log"; logIndex: number } | { kind: "load-older" } | undefined { + #getCursorToken(): CursorToken | undefined { const row = this.#getCursorRow(); if (!row) { return undefined; @@ -393,6 +460,9 @@ interface DebugLogViewerComponentOptions { onStatus?: (message: string) => void; onError?: (message: string) => void; processStartMs?: number; + processPid?: number; + logSource?: DebugLogSource; + onUpdate?: () => void; } export class DebugLogViewerComponent implements Component { @@ -401,16 +471,26 @@ export class DebugLogViewerComponent implements Component { #onExit: () => void; #onStatus?: (message: string) => void; #onError?: (message: string) => void; + #onUpdate?: () => void; + #logSource?: DebugLogSource; #lastRenderWidth = 80; #scrollRowOffset = 0; #statusMessage: string | undefined; + #loadingOlder = false; constructor(options: DebugLogViewerComponentOptions) { - this.#model = new DebugLogViewerModel(options.logs, options.processStartMs); + this.#logSource = options.logSource; + this.#model = new DebugLogViewerModel(options.logs, { + processStartMs: options.processStartMs, + processPid: options.processPid, + hasOlderLogs: this.#logSource?.hasOlderLogs.bind(this.#logSource), + loadOlderLogs: this.#logSource?.loadOlderLogs.bind(this.#logSource), + }); this.#terminalRows = options.terminalRows; this.#onExit = options.onExit; this.#onStatus = options.onStatus; this.#onError = options.onError; + this.#onUpdate = options.onUpdate; } handleInput(keyData: string): void { @@ -440,27 +520,21 @@ export class DebugLogViewerComponent implements Component { if (matchesKey(keyData, "ctrl+o")) { this.#statusMessage = undefined; - this.#model.loadOlder(this.#bodyHeight() + 1); - this.#ensureCursorVisible(); + void this.#handleLoadOlder(this.#bodyHeight() + 1); return; } if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) { if (this.#model.cursorRowKind === "load-older") { this.#statusMessage = undefined; - this.#model.loadOlder(); - this.#ensureCursorVisible(); + void this.#handleLoadOlder(); } return; } if (matchesKey(keyData, "shift+up")) { this.#statusMessage = undefined; - if (this.#maybeLoadOlderOnUp(true)) { - return; - } - this.#model.moveCursor(-1, true); - this.#ensureCursorVisible(); + void this.#handleMoveUp(true); return; } @@ -473,11 +547,7 @@ export class DebugLogViewerComponent implements Component { if (matchesKey(keyData, "up")) { this.#statusMessage = undefined; - if (this.#maybeLoadOlderOnUp(false)) { - return; - } - this.#model.moveCursor(-1, false); - this.#ensureCursorVisible(); + void this.#handleMoveUp(false); return; } @@ -491,8 +561,7 @@ export class DebugLogViewerComponent implements Component { if (matchesKey(keyData, "right")) { this.#statusMessage = undefined; if (this.#model.cursorRowKind === "load-older") { - this.#model.loadOlder(); - this.#ensureCursorVisible(); + void this.#handleLoadOlder(); return; } this.#model.expandSelected(); @@ -541,7 +610,6 @@ export class DebugLogViewerComponent implements Component { return [ this.#frameTop(innerWidth), - this.#frameSeparator(innerWidth), this.#frameLine(this.#summaryText(), innerWidth), this.#frameSeparator(innerWidth), this.#frameLine(this.#filterText(), innerWidth), @@ -557,7 +625,7 @@ export class DebugLogViewerComponent implements Component { } #controlsText(): string { - return "Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+P: pid filter Ctrl+O: load older Ctrl+C: copy Esc: back"; + return "Esc: back Ctrl+C: copy Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+O: load older Ctrl+P: pid filter"; } #filterText(): string { @@ -581,21 +649,72 @@ export class DebugLogViewerComponent implements Component { return Math.max(3, this.#terminalRows - 8); } - #maybeLoadOlderOnUp(extendSelection: boolean): boolean { - if (this.#model.cursorRowKind === "load-older") { - this.#model.loadOlder(); + async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise { + const loaded = await this.#loadOlder(additionalCount); + if (loaded) { this.#ensureCursorVisible(); - return true; + this.#onUpdate?.(); + } + } + + async #handleMoveUp(extendSelection: boolean): Promise { + if (this.#model.cursorRowKind === "load-older") { + const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK); + if (loaded) { + this.#ensureCursorVisible(); + this.#onUpdate?.(); + return; + } } - if (!this.#model.canLoadOlder() || !this.#model.isCursorAtFirstSelectableRow()) { - return false; + if (this.#model.canLoadOlder() && this.#model.isCursorAtFirstSelectableRow()) { + const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK); + if (loaded) { + this.#model.moveCursor(-1, extendSelection); + this.#ensureCursorVisible(); + this.#onUpdate?.(); + return; + } } - this.#model.loadOlder(); this.#model.moveCursor(-1, extendSelection); this.#ensureCursorVisible(); - return true; + this.#onUpdate?.(); + } + + async #loadOlder(additionalCount: number): Promise { + if (this.#loadingOlder || !this.#model.canLoadOlder()) { + return false; + } + this.#loadingOlder = true; + const previousCursorRowIndex = this.#model.cursorRowIndex; + const previousScrollOffset = this.#scrollRowOffset; + try { + const didLoad = await this.#model.loadOlder(additionalCount); + if (didLoad) { + this.#preserveScrollPosition(previousCursorRowIndex, previousScrollOffset); + } + return didLoad; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.#statusMessage = `Load older failed: ${message}`; + this.#onError?.(`Failed to load older logs: ${message}`); + this.#onUpdate?.(); + return false; + } finally { + this.#loadingOlder = false; + } + } + + #preserveScrollPosition(previousCursorRowIndex: number | undefined, previousScrollOffset: number): void { + const cursorRowIndex = this.#model.cursorRowIndex; + if (previousCursorRowIndex === undefined || cursorRowIndex === undefined) { + return; + } + const delta = cursorRowIndex - previousCursorRowIndex; + const nextOffset = previousScrollOffset + delta; + const maxOffset = Math.max(0, this.#model.rows.length - this.#bodyHeight()); + this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, nextOffset)); } #renderRows(innerWidth: number): Array<{ lines: string[]; rowIndex: number }> { diff --git a/packages/coding-agent/src/debug/report-bundle.ts b/packages/coding-agent/src/debug/report-bundle.ts index 7f255d1bf..2f546a3a5 100644 --- a/packages/coding-agent/src/debug/report-bundle.ts +++ b/packages/coding-agent/src/debug/report-bundle.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { WorkProfile } from "@oh-my-pi/pi-natives"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { getLogPath, getReportsDir } from "@oh-my-pi/pi-utils/dirs"; +import { APP_NAME, getLogPath, getLogsDir, getReportsDir } from "@oh-my-pi/pi-utils/dirs"; import type { CpuProfile, HeapSnapshot } from "./profiler"; import { collectSystemInfo, sanitizeEnv } from "./system-info"; @@ -41,6 +41,12 @@ export interface ReportBundleResult { files: string[]; } +export interface DebugLogSource { + getInitialText(): Promise; + hasOlderLogs(): boolean; + loadOlderLogs(limitDays?: number): Promise; +} + /** * Create a debug report bundle. * @@ -220,6 +226,73 @@ export async function getLogText(): Promise { } } +const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`); + +export async function createDebugLogSource(): Promise { + const logsDir = getLogsDir(); + const todayPath = getLogPath(); + const todayName = path.basename(todayPath); + let olderFiles: string[] = []; + try { + const entries = await fs.readdir(logsDir, { withFileTypes: true }); + const datedFiles = entries + .filter(entry => entry.isFile()) + .map(entry => { + const match = LOG_FILE_PATTERN.exec(entry.name); + return match ? { name: entry.name, date: match[1] } : undefined; + }) + .filter((entry): entry is { name: string; date: string } => entry !== undefined) + .filter(entry => entry.name !== todayName) + .sort((a, b) => b.date.localeCompare(a.date)); + olderFiles = datedFiles.map(entry => entry.name); + } catch { + olderFiles = []; + } + + let cursor = 0; + + const getInitialText = async (): Promise => { + try { + return await Bun.file(todayPath).text(); + } catch (err) { + if (isEnoent(err)) return ""; + throw err; + } + }; + + const hasOlderLogs = (): boolean => cursor < olderFiles.length; + + const loadOlderLogs = async (limitDays: number = 1): Promise => { + if (!hasOlderLogs()) { + return ""; + } + const count = Math.max(1, limitDays); + const slice = olderFiles.slice(cursor, cursor + count); + cursor += slice.length; + const chunks: string[] = []; + for (const filename of slice.reverse()) { + const filePath = path.join(logsDir, filename); + try { + const content = await Bun.file(filePath).text(); + if (content.length > 0) { + chunks.push(content); + } + } catch (err) { + if (!isEnoent(err)) { + throw err; + } + } + } + return chunks.filter(chunk => chunk.length > 0).join("\n"); + }; + + return { + getInitialText, + hasOlderLogs, + loadOlderLogs, + }; +} + /** Calculate total size of artifact cache */ export async function getArtifactCacheStats( sessionsDir: string, diff --git a/packages/coding-agent/test/debug/log-viewer.test.ts b/packages/coding-agent/test/debug/log-viewer.test.ts index 5f6b53974..121c73102 100644 --- a/packages/coding-agent/test/debug/log-viewer.test.ts +++ b/packages/coding-agent/test/debug/log-viewer.test.ts @@ -22,7 +22,7 @@ describe("DebugLogViewerModel", () => { it("defaults cursor to the newest log entry", () => { const logs = ["alpha", "beta", "gamma"].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); expect(model.cursorLogIndex).toBe(2); }); it("inserts session boundary warning between older and current-session logs", () => { @@ -33,7 +33,7 @@ describe("DebugLogViewerModel", () => { '{"timestamp":"2026-02-14T12:00:05.000Z","level":"info","message":"current"}', ].join("\n"); - const model = new DebugLogViewerModel(logs, processStartMs); + const model = new DebugLogViewerModel(logs, { processStartMs }); const rowKinds = model.rows.map(row => describeRow(row as { kind: string; logIndex?: number })); expect(rowKinds).toEqual(["log:0", "log:1", SESSION_BOUNDARY_WARNING, "log:2"]); @@ -41,7 +41,7 @@ describe("DebugLogViewerModel", () => { it("filters logs with case-insensitive substring matching", () => { const logs = ["Alpha", "beta", "Gamma", "BETTER"].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.setFilterQuery("be"); const rowKinds = model.rows.map(row => describeRow(row as { kind: string; logIndex?: number })); @@ -55,7 +55,7 @@ describe("DebugLogViewerModel", () => { '{"pid":84,"level":"info","message":"beta"}', '{"level":"info","message":"missing"}', ].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now(), 42); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now(), processPid: 42 }); expect(model.visibleLogCount).toBe(3); model.toggleProcessFilter(); @@ -65,26 +65,26 @@ describe("DebugLogViewerModel", () => { it("selects all visible log rows", () => { const logs = ["alpha", "beta", "gamma"].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.selectAllVisible(); expect(model.getSelectedLogIndices()).toEqual([0, 1, 2]); }); - it("progressively loads older entries in chunks", () => { + it("progressively loads older entries in chunks", async () => { const logs = Array.from({ length: 120 }, (_, index) => `log-${index}`).join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); expect(model.visibleLogCount).toBe(50); const initialKinds = model.rows.slice(0, 2).map(row => describeRow(row as { kind: string; logIndex?: number })); expect(initialKinds).toEqual([LOAD_OLDER_LABEL, "log:70"]); - model.loadOlder(5); + await model.loadOlder(5); expect(model.visibleLogCount).toBe(55); const expandedKinds = model.rows.slice(0, 2).map(row => describeRow(row as { kind: string; logIndex?: number })); expect(expandedKinds).toEqual([LOAD_OLDER_LABEL, "log:65"]); - model.loadOlder(50); + await model.loadOlder(50); expect(model.visibleLogCount).toBe(105); const expandedAgainKinds = model.rows .slice(0, 2) @@ -92,9 +92,27 @@ describe("DebugLogViewerModel", () => { expect(expandedAgainKinds).toEqual([LOAD_OLDER_LABEL, "log:15"]); }); + it("loads older entries from external sources while keeping cursor stable", async () => { + const logs = ["new-1", "new-2", "new-3"].join("\n"); + let hasOlder = true; + const model = new DebugLogViewerModel(logs, { + processStartMs: Date.now(), + hasOlderLogs: () => hasOlder, + loadOlderLogs: async () => { + hasOlder = false; + return ["old-1", "old-2"].join("\n"); + }, + }); + + expect(model.rows[0]?.kind).toBe("load-older"); + await model.loadOlder(1); + expect(model.getRawLine(model.cursorLogIndex ?? 0)).toBe("new-3"); + expect(model.rows[0]?.kind).toBe("load-older"); + }); + it("clamps cursor when filtered list shrinks", () => { const logs = ["alpha", "beta", "gamma"].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.moveCursor(2, false); expect(model.cursorLogIndex).toBe(2); @@ -106,7 +124,7 @@ describe("DebugLogViewerModel", () => { 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()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.moveCursor(-999, false); model.moveCursor(2, true); @@ -123,7 +141,7 @@ describe("DebugLogViewerModel", () => { '{"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); + const model = new DebugLogViewerModel(logs, { processStartMs }); model.setFilterQuery("old"); expect(model.rows.map(row => row.kind)).toEqual(["log"]); @@ -142,7 +160,7 @@ describe("DebugLogViewerModel", () => { it("copies only selected visible entries", () => { const logs = ["alpha", "bar", "baz"].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.setFilterQuery("ba"); model.moveCursor(-999, false); @@ -153,7 +171,7 @@ describe("DebugLogViewerModel", () => { 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()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.moveCursor(-999, false); model.moveCursor(1, true); @@ -166,7 +184,7 @@ describe("DebugLogViewerModel", () => { it("expands and collapses all selected rows", () => { const logs = ["a", "b", "c"].join("\n"); - const model = new DebugLogViewerModel(logs, Date.now()); + const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() }); model.moveCursor(-999, false); model.moveCursor(1, true);