From 36bc8d0e2ae78578898617236212ec7cca3d5d66 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Apr 2026 10:23:55 -0700 Subject: [PATCH] fix(tui): surface visible feedback when copying a log entry The [c] copy handler was writing a success/failure log entry to the buffer, but in expanded mode the panel hides the buffer so users got no indication the copy ran. Add a transient inline "Copied!" flash in both list and expanded views, and clamp the index to match the display cursor so the copy never silently misses. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cli/src/commands/dashboard-tui/app.tsx | 40 +++++++++++++++++-- .../src/commands/dashboard-tui/controller.ts | 18 +++++++++ .../cli/src/commands/dashboard-tui/state.ts | 5 +++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 92b27d561..51c89657a 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -493,13 +493,17 @@ function LogsPanel({ const hiddenAbove = visibleStart; const hiddenBelow = entries.length - visibleEnd; + const panelTitle = state.clipboardFlash + ? `Logs (${state.logEntries.length}/1000) · ${state.clipboardFlash.ok ? "✓ Copied!" : "✗ Copy failed"}` + : `Logs (${state.logEntries.length}/1000)`; return ( - + {logsExpandedMode && entries[cursor] ? ( ) : entries.length === 0 ? ( No log entries yet. @@ -584,10 +588,27 @@ function LogsPanel({ ); } -function ExpandedLog({ entry, index, total }: { entry: LogEntry; index: number; total: number }) { +function ExpandedLog({ + entry, + index, + total, + clipboardFlash, +}: { + entry: LogEntry; + index: number; + total: number; + clipboardFlash: { ok: boolean; at: number } | null; +}) { return ( - Entry {index + 1}/{total} · [Enter/Esc] close · [c] copy + + Entry {index + 1}/{total} · [Enter/Esc] close · [c] copy + {clipboardFlash && ( + + {clipboardFlash.ok ? "✓ Copied!" : "✗ Copy failed"} + + )} + Time: @@ -4039,12 +4060,20 @@ export function DashboardApp({ controller }: DashboardAppProps) { } if (input === "c" || input === "C") { - const target = filteredEntries[state.selectedLogIndex]; + // Clamp the index to match the display logic in LogsPanel — the cursor + // shown is always Math.min(Math.max(idx, 0), entries.length - 1), so + // we copy whatever the user is actually looking at instead of silently + // hitting `undefined` when selectedLogIndex is briefly out of range. + const idx = filteredEntries.length === 0 + ? -1 + : Math.min(Math.max(state.selectedLogIndex, 0), filteredEntries.length - 1); + const target = idx >= 0 ? filteredEntries[idx] : undefined; if (target) { const ts = formatTimestamp(target.timestamp); const prefix = target.prefix ? `[${target.prefix}] ` : ""; const text = `${ts} ${target.level.toUpperCase()} ${prefix}${target.message}`; void copyToClipboard(text).then((ok) => { + controller.flashClipboard(ok); if (ok) { controller.log("Log entry copied to clipboard.", "clipboard"); } else { @@ -4054,6 +4083,9 @@ export function DashboardApp({ controller }: DashboardAppProps) { ); } }); + } else { + controller.warn("No log entry to copy.", "clipboard"); + controller.flashClipboard(false); } return; } diff --git a/packages/cli/src/commands/dashboard-tui/controller.ts b/packages/cli/src/commands/dashboard-tui/controller.ts index a59a15d63..bd5d0265a 100644 --- a/packages/cli/src/commands/dashboard-tui/controller.ts +++ b/packages/cli/src/commands/dashboard-tui/controller.ts @@ -90,6 +90,8 @@ export class DashboardTUI { // Throttle so we don't spam kills while the sampler keeps firing during // sustained pressure (sampler runs every 2s). private lastAutoKillAt = 0; + clipboardFlash: { ok: boolean; at: number } | null = null; + private clipboardFlashTimer: ReturnType | null = null; interactiveData: InteractiveData | null = null; interactiveView: InteractiveView = "board"; interactiveInputLocked = false; @@ -162,6 +164,7 @@ export class DashboardTUI { autoKillVitestOnPressure: this.autoKillVitestOnPressure, vitestKillThreshold: this.vitestKillThreshold, updateStatus: this.updateStatus, + clipboardFlash: this.clipboardFlash, }; return this.cachedSnapshot; } @@ -421,6 +424,17 @@ export class DashboardTUI { this.addLog({ level: "info", message, prefix }); } + flashClipboard(ok: boolean): void { + this.clipboardFlash = { ok, at: Date.now() }; + if (this.clipboardFlashTimer) clearTimeout(this.clipboardFlashTimer); + this.clipboardFlashTimer = setTimeout(() => { + this.clipboardFlash = null; + this.clipboardFlashTimer = null; + this.notify(); + }, 1800); + this.notify(); + } + warn(message: string, prefix?: string): void { this.addLog({ level: "warn", message, prefix }); } @@ -713,6 +727,10 @@ export class DashboardTUI { clearTimeout(this.resizeDebounceTimer); this.resizeDebounceTimer = null; } + if (this.clipboardFlashTimer) { + clearTimeout(this.clipboardFlashTimer); + this.clipboardFlashTimer = null; + } if (this.inkInstance) { this.inkInstance.unmount(); diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index e1e326152..c746186b1 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -350,6 +350,10 @@ export interface DashboardState { autoKillVitestOnPressure: boolean; vitestKillThreshold: number; updateStatus: UpdateStatus | null; + // Transient flash shown after the user copies a log entry. `at` is a + // monotonic timestamp so the view can render "Copied!" briefly before the + // controller clears it via setTimeout. + clipboardFlash: { ok: boolean; at: number } | null; } export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"]; @@ -377,5 +381,6 @@ export function createInitialState(): DashboardState { autoKillVitestOnPressure: true, vitestKillThreshold: 0.9, updateStatus: null, + clipboardFlash: null, }; }