feat(cli): add a raw, selectable logs view to the TUI

Reported: the logs cannot be highlighted and copied without column artifacts.

Two things cause that, and the code already named the second one: the Logs panel keeps a
border, a title and a filter row, and sits between a header and a status bar — so a
rectangular drag captures box-drawing characters and unrelated rows — while mouse reporting
is deliberately ON for that panel to drive wheel scrolling, which swallows the click-drag
before the terminal ever sees it.

`[v]` now shows the log lines alone: no border, no title, no filter row, no header, no status
bar, every line starting at column 0, and mouse reporting released so the terminal's own
selection works. One trailing hint row stays, because a full-screen view with no visible way
out is worse than one extra row. `[v]` or Esc returns, and the escape is ordered ahead of the
expanded-entry escape so the two modes cannot fight.

The rendered line shape matches the existing `[c]` single-line copy, so selecting with the
mouse and copying with the keyboard produce the same text. `[c]` remains the path for one
line; this is the path for a range, which no keyboard shortcut can express.

pnpm lint 0 errors, CLI typecheck clean, dashboard-tui suites 134/134, test:gate green.
This commit is contained in:
Fusion Agent
2026-08-26 21:54:39 +00:00
parent 4d2b42b068
commit 61f26ca4e7
4 changed files with 90 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Press [v] in the TUI Logs panel for a chrome-free view you can select and copy with the mouse.
category: feature
dev: The Logs panel already fills the pane when focused, but keeps a panel border, title, filter row, header and status bar, so a rectangular terminal drag captures box-drawing characters and neighbouring rows; mouse reporting is also enabled there for wheel scrolling and swallows the drag entirely. New `logsRawMode` (controller + state) renders only plain log lines starting at column 0, replaces the whole frame except one trailing hint row, and is excluded from `wantsMouse` so native click-drag selection works. Toggled with `[v]`, left with `[v]` or Esc (ordered ahead of the expanded-entry escape), and listed in the help overlay. Line shape matches the existing `[c]` single-line copy, so a mouse selection and a keyboard copy produce identical text.

View File

@@ -770,6 +770,34 @@ function LogsPanel({
);
}
/*
FNXC:TuiRawLogs 2026-08-26-14:10:
Logs, and nothing else: no panel border, no title, no filter row, no header, no status bar. A
rectangular terminal selection over the normal view captures box-drawing characters and neighbouring
rows, which is what makes a copied log unusable; here every line is plain text starting at column 0.
Mouse reporting is released alongside it (see `wantsMouse`), because it is enabled for wheel
scrolling on this panel and otherwise swallows the click-drag entirely.
The timestamp/level/prefix shape matches the `[c]` single-line copy, so a mouse selection and a
keyboard copy produce the same text.
*/
function RawLogs({ state, rows }: { state: DashboardState; rows: number }) {
const entries = state.logEntries.filter((entry) =>
state.logsSeverityFilter === "all" || entry.level === state.logsSeverityFilter);
const visible = entries.slice(Math.max(0, entries.length - Math.max(1, rows)));
return (
<Box flexDirection="column" flexGrow={1} width="100%">
{visible.map((entry, index) => {
const prefix = entry.prefix ? `[${entry.prefix}] ` : "";
return (
<Text key={`${entry.timestamp}-${index}`} wrap="wrap">
{`${formatTimestamp(entry.timestamp)} ${entry.level.toUpperCase()} ${prefix}${stripLeadingDetailedTimestamp(entry.message)}`}
</Text>
);
})}
</Box>
);
}
function ExpandedLog({
entry,
index,
@@ -861,6 +889,7 @@ function HelpOverlay() {
["[→] / [↓] / [n]", t("tui.helpShortcutNextPanel", "Next panel (Main; ↑/↓ scroll on Logs)")],
["[←] / [↑] / [p]", t("tui.helpShortcutPrevPanel", "Previous panel (Main; ↑/↓ scroll on Logs)")],
["[Enter]", t("tui.helpShortcutExpandLog", "Expand log + release mouse for text selection (Logs)")],
["[v]", t("tui.helpShortcutRawLogs", "Raw logs: no borders, mouse released — select a range to copy (Logs)")],
["[r]", t("tui.helpShortcutRefreshStats", "Refresh stats (Utilities)")],
["[c]", t("tui.helpShortcutClearLogs", "Clear logs (Utilities)")],
["[k]", t("tui.helpShortcutKillVitest", "Kill all vitest processes (Utilities)")],
@@ -1031,6 +1060,7 @@ function StatusModeSingle({
state: DashboardState;
controller: DashboardTUI;
}) {
const { t } = useTranslation("cli");
const focused = state.activeSection;
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
@@ -1079,6 +1109,24 @@ function StatusModeSingle({
}
};
/*
FNXC:TuiRawLogs 2026-08-26-14:10:
Raw mode replaces the WHOLE frame, status bar included. Leaving any chrome on screen defeats the
point: a rectangular drag would still capture it, and the borders are precisely what made copied
logs unusable. One trailing hint line stays, because a full-screen view with no visible way out is
worse than one extra row.
*/
if (state.logsRawMode) {
return (
<Box flexDirection="column" flexGrow={1}>
<RawLogs state={state} rows={Math.max(1, rows - 1)} />
<Box flexShrink={0}>
<Text dimColor>{t("tui.rawLogsHint", "[v/Esc] leave raw logs · select with the mouse to copy")}</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column" flexGrow={1}>
<Box flexGrow={1} flexDirection="column" overflow="hidden">
@@ -4269,7 +4317,8 @@ export function DashboardApp({ controller }: DashboardAppProps) {
? (state.interactiveView === "files"
|| state.interactiveView === "git"
|| state.interactiveView === "board")
: (statusLogsFocused && !state.logsExpandedMode);
/* FNXC:TuiRawLogs 2026-08-26-14:10: raw mode exists to allow native click-drag, so it must release the mouse. */
: (statusLogsFocused && !state.logsExpandedMode && !state.logsRawMode);
useEffect(() => {
if (state.mouseEnabled !== wantsMouse) {
controller.setMouseEnabled(wantsMouse);
@@ -4572,7 +4621,9 @@ export function DashboardApp({ controller }: DashboardAppProps) {
const filteredEntries = controller.getFilteredLogEntries();
if (key.escape) {
if (state.logsExpandedMode) {
if (state.logsRawMode) {
controller.setLogsRawMode(false);
} else if (state.logsExpandedMode) {
controller.setLogsExpandedMode(false);
controller.setShowHelp(false);
} else if (state.showHelp) {
@@ -4591,6 +4642,18 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return;
}
/*
FNXC:TuiRawLogs 2026-08-26-14:10:
[v] shows the logs alone, chrome-free and mouse-released, so a terminal click-drag copies clean
text. `[c]` already copies ONE selected line; this is the path for copying a range, which no
keyboard shortcut can express. Esc returns (handled with the other escapes above).
*/
if (input === "v" || input === "V") {
controller.setLogsRawMode(!state.logsRawMode);
if (!state.logsRawMode) controller.setShowHelp(false);
return;
}
if (input === "w" || input === "W") {
controller.setLogsWrapEnabled(!state.logsWrapEnabled);
return;

View File

@@ -68,6 +68,15 @@ export class DashboardTUI {
logsSeverityFilter: "all" | LogEntry["level"] = "all";
logsWrapEnabled = false;
logsExpandedMode = false;
/*
FNXC:TuiRawLogs 2026-08-26-14:10:
Raw mode renders the log lines with NO chrome and releases mouse reporting, so a terminal
click-drag selects clean text. Every other view keeps borders, a title and a filter row, and the
panel sits between a header and a status bar 2014 a rectangular drag therefore captures box-drawing
characters and unrelated rows, which is exactly what makes copied logs unusable. Mouse reporting is
the other half: it is enabled for wheel scrolling on this panel and swallows the drag entirely.
*/
logsRawMode = false;
selectedLogIndex = 0;
logsViewportStart = 0;
// True when the narrow single-pane view is split and the bottom log
@@ -202,6 +211,7 @@ export class DashboardTUI {
logsSeverityFilter: this.logsSeverityFilter,
logsWrapEnabled: this.logsWrapEnabled,
logsExpandedMode: this.logsExpandedMode,
logsRawMode: this.logsRawMode,
selectedLogIndex: this.selectedLogIndex,
logsViewportStart: this.logsViewportStart,
narrowLogSplitFocused: this.narrowLogSplitFocused,
@@ -559,6 +569,11 @@ export class DashboardTUI {
this.notify();
}
setLogsRawMode(raw: boolean): void {
this.logsRawMode = raw;
this.notify();
}
setNarrowLogSplitFocused(focused: boolean): void {
if (this.narrowLogSplitFocused === focused) return;
this.narrowLogSplitFocused = focused;

View File

@@ -363,6 +363,8 @@ export interface DashboardState {
logsSeverityFilter: "all" | LogEntry["level"];
logsWrapEnabled: boolean;
logsExpandedMode: boolean;
/* FNXC:TuiRawLogs 2026-08-26-14:10: chrome-free, mouse-released logs view for native text selection. */
logsRawMode: boolean;
selectedLogIndex: number;
logsViewportStart: number;
// When the narrow single-pane main view is split horizontally to show a
@@ -412,6 +414,7 @@ export function createInitialState(): DashboardState {
logsSeverityFilter: "all",
logsWrapEnabled: false,
logsExpandedMode: false,
logsRawMode: false,
selectedLogIndex: 0,
logsViewportStart: 0,
narrowLogSplitFocused: false,