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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 10:23:55 -07:00
parent 71e9b28db2
commit c5c7329aa0
3 changed files with 59 additions and 4 deletions

View File

@@ -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 (
<Panel title={`Logs (${state.logEntries.length}/1000)`} isFocused={isFocused} flexGrow={1}>
<Panel title={panelTitle} isFocused={isFocused} flexGrow={1}>
{logsExpandedMode && entries[cursor] ? (
<ExpandedLog
entry={entries[cursor]}
index={cursor}
total={entries.length}
clipboardFlash={state.clipboardFlash}
/>
) : entries.length === 0 ? (
<Text dimColor>No log entries yet.</Text>
@@ -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 (
<Box flexDirection="column" flexGrow={1} width="100%">
<Text dimColor>Entry {index + 1}/{total} · [Enter/Esc] close · [c] copy</Text>
<Box flexDirection="row" gap={1}>
<Text dimColor>Entry {index + 1}/{total} · [Enter/Esc] close · [c] copy</Text>
{clipboardFlash && (
<Text color={clipboardFlash.ok ? "greenBright" : "redBright"} bold>
{clipboardFlash.ok ? "✓ Copied!" : "✗ Copy failed"}
</Text>
)}
</Box>
<Box height={1} />
<Box flexDirection="row" gap={1}>
<Text dimColor>Time:</Text>
@@ -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;
}

View File

@@ -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<typeof setTimeout> | 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();

View File

@@ -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,
};
}