import { type Component, extractPrintableText, matchesKey, padding, replaceTabs, truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; import { sanitizeText } from "@oh-my-pi/pi-utils"; import { theme } from "../modes/theme/theme"; import { copyToClipboard } from "../utils/clipboard"; import { formatDebugLogExpandedLines, formatDebugLogLine, 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 = "### MOVE UP TO LOAD MORE..."; const INITIAL_LOG_CHUNK = 50; const LOAD_OLDER_CHUNK = 50; type LogEntry = { rawLine: string; timestampMs: number | undefined; 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"; } | { kind: "load-older"; } | { 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"); } export class DebugLogViewerModel { #entries: LogEntry[]; #rows: ViewerRow[]; #visibleLogIndices: number[]; #selectableRowIndices: number[]; #cursorSelectableIndex = 0; #selectionAnchorSelectableIndex: number | undefined; #expandedLogIndices = new Set(); #filterQuery = ""; #processStartMs: number; #loadedStartIndex: number; #processFilterEnabled = false; #processPid: number; #hasOlderLogs?: () => boolean; #loadOlderLogs?: (limitDays?: number) => Promise; constructor(logText: string, options: DebugLogViewerModelOptions = {}) { const { processStartMs = getProcessStartMs(), processPid = process.pid, hasOlderLogs, loadOlderLogs } = options; this.#entries = splitLogText(logText).map(rawLine => ({ rawLine, timestampMs: parseDebugLogTimestampMs(rawLine), pid: parseDebugLogPid(rawLine), })); 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 = []; this.#selectableRowIndices = []; this.#rebuildRows(); } get logCount(): number { return this.#entries.length; } get visibleLogCount(): number { return this.#visibleLogIndices.length; } get rows(): readonly ViewerRow[] { return this.#rows; } get cursorRowIndex(): number | undefined { return this.#selectableRowIndices[this.#cursorSelectableIndex]; } get cursorLogIndex(): number | undefined { const row = this.#getCursorRow(); return row?.kind === "log" ? row.logIndex : undefined; } get filterQuery(): string { return this.#filterQuery; } get cursorRowKind(): ViewerRow["kind"] | undefined { return this.#getCursorRow()?.kind; } get expandedCount(): number { return this.#expandedLogIndices.size; } isProcessFilterEnabled(): boolean { return this.#processFilterEnabled; } isCursorAtFirstSelectableRow(): boolean { return this.#cursorSelectableIndex === 0; } getRawLine(logIndex: number): string { return this.#entries[logIndex]?.rawLine ?? ""; } setFilterQuery(query: string): void { if (query === this.#filterQuery) { return; } this.#filterQuery = query; this.#rebuildRows(); } toggleProcessFilter(): void { this.#processFilterEnabled = !this.#processFilterEnabled; this.#rebuildRows(); } moveCursor(delta: number, extendSelection: boolean): void { if (this.#selectableRowIndices.length === 0) { return; } if (extendSelection && this.#selectionAnchorSelectableIndex === undefined) { const row = this.#getCursorRow(); if (row?.kind === "log") { this.#selectionAnchorSelectableIndex = this.#cursorSelectableIndex; } } this.#cursorSelectableIndex = Math.max( 0, Math.min(this.#selectableRowIndices.length - 1, this.#cursorSelectableIndex + delta), ); if (!extendSelection) { this.#selectionAnchorSelectableIndex = undefined; } if (this.#getCursorRow()?.kind !== "log" && !extendSelection) { this.#selectionAnchorSelectableIndex = undefined; } } getSelectedLogIndices(): number[] { if (this.#selectableRowIndices.length === 0) { return []; } const cursorRow = this.#getCursorRow(); if (this.#selectionAnchorSelectableIndex === undefined) { if (cursorRow?.kind !== "log") { return []; } return [cursorRow.logIndex]; } const min = Math.min(this.#selectionAnchorSelectableIndex, this.#cursorSelectableIndex); const max = Math.max(this.#selectionAnchorSelectableIndex, this.#cursorSelectableIndex); const selected: number[] = []; for (let i = min; i <= max; i++) { const rowIndex = this.#selectableRowIndices[i]; const row = rowIndex === undefined ? undefined : this.#rows[rowIndex]; if (row?.kind === "log") { selected.push(row.logIndex); } } 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)); } selectAllVisible(): void { if (this.#selectableRowIndices.length === 0) { return; } let firstLogIndex: number | undefined; let lastLogIndex: number | undefined; for (let i = 0; i < this.#selectableRowIndices.length; i++) { const rowIndex = this.#selectableRowIndices[i]; const row = rowIndex === undefined ? undefined : this.#rows[rowIndex]; if (row?.kind === "log") { if (firstLogIndex === undefined) { firstLogIndex = i; } lastLogIndex = i; } } if (firstLogIndex === undefined || lastLogIndex === undefined) { return; } this.#selectionAnchorSelectableIndex = firstLogIndex; this.#cursorSelectableIndex = lastLogIndex; } canLoadOlder(): boolean { return this.#loadedStartIndex > 0 || this.#hasExternalOlderLogs(); } 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 false; } this.#loadedStartIndex = nextStart; this.#rebuildRows(); return true; } #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++) { const entry = this.#entries[i]; if (!entry) { continue; } if (this.#matchesFilters(entry, query)) { visible.push(i); } } this.#visibleLogIndices = visible; const rows: ViewerRow[] = []; if (this.#hasOlderEntries(query)) { rows.push({ kind: "load-older" }); } 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; this.#selectableRowIndices = rows .map((row, index) => (row.kind === "warning" ? undefined : index)) .filter((index): index is number => index !== undefined); if (this.#selectableRowIndices.length === 0) { this.#cursorSelectableIndex = 0; this.#selectionAnchorSelectableIndex = undefined; return; } if (previousCursor?.kind === "log") { const rowIndex = this.#rows.findIndex(row => row.kind === "log" && row.logIndex === previousCursor.logIndex); const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex); if (selectableIndex >= 0) { this.#cursorSelectableIndex = selectableIndex; } else { this.#cursorSelectableIndex = this.#selectableRowIndices.length - 1; } } else if (previousCursor?.kind === "load-older") { const rowIndex = this.#rows.findIndex(row => row.kind === "load-older"); const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex); this.#cursorSelectableIndex = selectableIndex >= 0 ? selectableIndex : this.#selectableRowIndices.length - 1; } else { this.#cursorSelectableIndex = this.#selectableRowIndices.length - 1; } if (previousAnchorLogIndex !== undefined) { const rowIndex = this.#rows.findIndex(row => row.kind === "log" && row.logIndex === previousAnchorLogIndex); const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex); this.#selectionAnchorSelectableIndex = selectableIndex >= 0 ? selectableIndex : undefined; } else { this.#selectionAnchorSelectableIndex = undefined; } } #matchesFilters(entry: LogEntry, query: string): boolean { if (query.length > 0 && !entry.rawLine.toLowerCase().includes(query)) { return false; } if (!this.#processFilterEnabled) { return true; } return entry.pid === this.#processPid; } #hasOlderEntries(query: string): boolean { if (this.#hasExternalOlderLogs()) { return true; } if (this.#loadedStartIndex === 0) { return false; } for (let i = 0; i < this.#loadedStartIndex; i++) { const entry = this.#entries[i]; if (entry && this.#matchesFilters(entry, query)) { return true; } } return false; } #hasExternalOlderLogs(): boolean { return this.#hasOlderLogs?.() ?? false; } #getCursorRow(): ViewerRow | undefined { const rowIndex = this.cursorRowIndex; return rowIndex === undefined ? undefined : this.#rows[rowIndex]; } #getCursorToken(): CursorToken | undefined { const row = this.#getCursorRow(); if (!row) { return undefined; } if (row.kind === "log") { return { kind: "log", logIndex: row.logIndex }; } if (row.kind === "load-older") { return { kind: "load-older" }; } return undefined; } #getAnchorLogIndex(): number | undefined { if (this.#selectionAnchorSelectableIndex === undefined) { return undefined; } const rowIndex = this.#selectableRowIndices[this.#selectionAnchorSelectableIndex]; const row = rowIndex === undefined ? undefined : this.#rows[rowIndex]; return row?.kind === "log" ? row.logIndex : undefined; } } interface DebugLogViewerComponentOptions { logs: string; terminalRows: number; onExit: () => void; onStatus?: (message: string) => void; onError?: (message: string) => void; processStartMs?: number; processPid?: number; logSource?: DebugLogSource; onUpdate?: () => void; } export class DebugLogViewerComponent implements Component { #model: DebugLogViewerModel; #terminalRows: number; #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.#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 { if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { this.#onExit(); return; } if (matchesKey(keyData, "ctrl+c")) { this.#copySelected(); return; } if (matchesKey(keyData, "ctrl+p")) { this.#statusMessage = undefined; this.#model.toggleProcessFilter(); this.#ensureCursorVisible(); return; } if (matchesKey(keyData, "ctrl+a")) { this.#statusMessage = undefined; this.#model.selectAllVisible(); this.#ensureCursorVisible(); return; } if (matchesKey(keyData, "ctrl+o")) { this.#statusMessage = undefined; void this.#handleLoadOlder(this.#bodyHeight() + 1); return; } if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) { if (this.#model.cursorRowKind === "load-older") { this.#statusMessage = undefined; void this.#handleLoadOlder(); } return; } if (matchesKey(keyData, "shift+up")) { this.#statusMessage = undefined; void this.#handleMoveUp(true); return; } if (matchesKey(keyData, "shift+down")) { this.#statusMessage = undefined; this.#model.moveCursor(1, true); this.#ensureCursorVisible(); return; } if (matchesKey(keyData, "up")) { this.#statusMessage = undefined; void this.#handleMoveUp(false); return; } if (matchesKey(keyData, "down")) { this.#statusMessage = undefined; this.#model.moveCursor(1, false); this.#ensureCursorVisible(); return; } if (matchesKey(keyData, "right")) { this.#statusMessage = undefined; if (this.#model.cursorRowKind === "load-older") { void this.#handleLoadOlder(); return; } this.#model.expandSelected(); return; } 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 printableText = extractPrintableText(keyData); if (printableText) { this.#statusMessage = undefined; this.#model.setFilterQuery(this.#model.filterQuery + printableText); this.#ensureCursorVisible(); } } 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 = this.#bodyHeight(); const rows = this.#renderRows(innerWidth); const visibleBodyLines = this.#renderVisibleBodyLines(rows, innerWidth, bodyHeight); return [ this.#frameTop(innerWidth), this.#frameLine(this.#summaryText(), innerWidth), this.#frameSeparator(innerWidth), this.#frameLine(this.#filterText(), innerWidth), this.#frameSeparator(innerWidth), ...visibleBodyLines, this.#frameLine(this.#statusText(), innerWidth), this.#frameBottom(innerWidth), ]; } #summaryText(): string { return ` # ${this.#model.visibleLogCount}/${this.#model.logCount} logs | ${this.#controlsText()}`; } #controlsText(): string { 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 { const sanitized = replaceTabs(sanitizeText(this.#model.filterQuery)); const query = sanitized.length === 0 ? "" : theme.fg("accent", sanitized); const pidStatus = this.#model.isProcessFilterEnabled() ? theme.fg("success", "pid:on") : theme.fg("muted", "pid:off"); return ` filter: ${query} ${pidStatus}`; } #statusText(): string { const base = ` Selected: ${this.#model.getSelectedCount()} Expanded: ${this.#model.expandedCount}`; if (this.#statusMessage) { return `${base} ${this.#statusMessage}`; } return base; } #bodyHeight(): number { return Math.max(3, this.#terminalRows - 8); } async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise { const loaded = await this.#loadOlder(additionalCount); if (loaded) { this.#ensureCursorVisible(); 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()) { const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK); if (loaded) { this.#model.moveCursor(-1, extendSelection); this.#ensureCursorVisible(); this.#onUpdate?.(); return; } } this.#model.moveCursor(-1, extendSelection); this.#ensureCursorVisible(); 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 }> { 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("muted", truncateToWidth(SESSION_BOUNDARY_WARNING, innerWidth))], }); continue; } if (row.kind === "load-older") { const active = this.#model.cursorRowIndex === rowIndex; const marker = active ? theme.fg("accent", "❯") : " "; const prefix = `${marker} `; const contentWidth = Math.max(1, innerWidth - visibleWidth(prefix)); const label = truncateToWidth(LOAD_OLDER_LABEL, contentWidth); rendered.push({ rowIndex, lines: [truncateToWidth(`${prefix}${theme.fg("muted", label)}`, innerWidth)], }); continue; } const logIndex = row.logIndex; const selected = this.#model.isSelected(logIndex); const cursorLogIndex = this.#model.cursorLogIndex; const active = cursorLogIndex !== undefined && 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[] = []; 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) { 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; } /** Returns the number of rendered screen lines for a given row index. */ #getRowRenderedLineCount(rowIndex: number, innerWidth: number): number { const row = this.#model.rows[rowIndex]; if (!row || row.kind === "warning" || row.kind === "load-older") { return 1; } if (!this.#model.isExpanded(row.logIndex)) { return 1; } // Expanded row: compute prefix visible width and get wrapped line count // Prefix is marker(1) + fold(1) + space(1) = 3 visible chars const prefixVisibleWidth = 3; const contentWidth = Math.max(1, innerWidth - prefixVisibleWidth); const wrapped = formatDebugLogExpandedLines(this.#model.getRawLine(row.logIndex), contentWidth); return Math.max(1, wrapped.length); } #ensureCursorVisible(): void { const cursorRowIndex = this.#model.cursorRowIndex; if (cursorRowIndex === undefined) { this.#scrollRowOffset = 0; return; } const bodyHeight = Math.max(1, this.#bodyHeight()); const innerWidth = Math.max(1, this.#lastRenderWidth - 2); // Scroll up: cursor is above viewport if (cursorRowIndex < this.#scrollRowOffset) { this.#scrollRowOffset = cursorRowIndex; return; } // Scroll down: sum rendered line heights from scrollRowOffset to cursorRowIndex (inclusive) // to check if the cursor row actually fits in the viewport let usedLines = 0; for (let i = this.#scrollRowOffset; i <= cursorRowIndex; i++) { usedLines += this.#getRowRenderedLineCount(i, innerWidth); } if (usedLines > bodyHeight) { // Advance scrollRowOffset until the cursor row fits while (this.#scrollRowOffset < cursorRowIndex) { usedLines -= this.#getRowRenderedLineCount(this.#scrollRowOffset, innerWidth); this.#scrollRowOffset++; if (usedLines <= bodyHeight) { break; } } } } #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}`; } #copySelected() { 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 { 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}`); } } }