fix(cli): make the raw logs view actually reachable, and stop it shadowing [v]

Two defects in my own previous commit, both visible in one screenshot of the dashboard.

IT DID NOTHING ON A WIDE TERMINAL. The escape was added inside the single-pane layout, which
is the NARROW one. A wide terminal renders the grid layout instead — System / Stats /
Utilities / Settings beside Logs — so the toggle flipped state and the screen did not change.
The whole point is that no chrome survives a rectangular selection, and chrome is drawn by
both layouts plus the header, so the escape has to happen before either is chosen. It now
replaces the entire frame, above the layout choice, and a test pins that ordering plus the
fact that only one place may render it.

IT SHADOWED AN EXISTING LEGEND. The Utilities panel already advertises `[v] Auto-Kill Vitest`
on the same screen. The two handlers are mutually exclusive at runtime — utility actions
require the Utilities section, this branch requires log focus — so nothing actually clashed,
but two different `[v]` legends visible at once is a UI anyone would misread. Raw mode is now
Shift+V; lowercase v stays with auto-kill.

pnpm lint 0 errors, CLI typecheck clean, dashboard-tui suites 138/138, test:gate green.
This commit is contained in:
Fusion Agent
2026-08-27 00:48:09 +00:00
parent 61f26ca4e7
commit 47d2890031
3 changed files with 93 additions and 18 deletions

View File

@@ -2,6 +2,6 @@
"@runfusion/fusion": minor
---
summary: Press [v] in the TUI Logs panel for a chrome-free view you can select and copy with the mouse.
summary: Press [Shift+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.
dev: The Logs panel keeps a border, title and filter row and sits between a header and a 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 above the narrow/grid layout choice — so it works on wide terminals, where the grid layout is used — keeps one trailing hint row, and is excluded from `wantsMouse` so native click-drag works. Bound to `Shift+V` because the Utilities panel already advertises `[v] Auto-Kill Vitest` on the same screen; Esc clears raw mode ahead of the expanded-entry escape. Line shape matches the existing `[c]` single-line copy so mouse and keyboard copies produce identical text. Covered by `raw-logs-mode.test.ts`, which pins the above-layout escape, the mouse release, the binding, and the exit hint.

View File

@@ -0,0 +1,61 @@
/*
FNXC:TuiRawLogs 2026-08-26-14:40:
Raw mode exists so a terminal click-drag can select log text cleanly. Two properties make that true,
and both were wrong in the first attempt:
- it must replace the WHOLE frame, above the layout choice. It was added inside the single-pane
layout only, which renders on a NARROW terminal; a wide terminal draws the grid layout instead
(System / Stats / Utilities / Settings beside Logs), so the toggle flipped state and nothing moved
on screen.
- it must release mouse reporting, which this panel enables for wheel scrolling and which swallows
a click-drag before the terminal ever sees it.
And the binding is SHIFT+V because the Utilities panel already advertises `[v] Auto-Kill Vitest` on
the same screen; the handlers are mutually exclusive at runtime, but two `[v]` legends at once is a UI
anyone would misread.
*/
import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
const appSource = () => readFile(new URL("../app.tsx", import.meta.url), "utf8");
describe("TUI raw logs mode", () => {
it("escapes above the layout choice, so it works on wide and narrow terminals alike", async () => {
const source = await appSource();
const rawReturn = source.indexOf("state.mode === \"status\" && state.logsRawMode");
// The grid is the WIDE layout, and the one the first attempt never reached.
const gridRender = source.indexOf("<StatusModeGrid");
const singleRender = source.indexOf("<StatusModeSingle");
expect(rawReturn, "raw mode must be reachable").toBeGreaterThan(-1);
expect(gridRender).toBeGreaterThan(-1);
expect(rawReturn, "the escape must precede the grid layout").toBeLessThan(gridRender);
expect(rawReturn, "the escape must precede the single-pane layout").toBeLessThan(singleRender);
// Exactly one place may render it: a second copy inside a layout is how the grid was missed.
expect(source.split("<RawLogs ").length - 1).toBe(1);
});
it("releases mouse reporting so a click-drag reaches the terminal", async () => {
const source = await appSource();
expect(source).toContain("!state.logsExpandedMode && !state.logsRawMode");
});
it("binds SHIFT+V, leaving lowercase v to the Utilities auto-kill toggle", async () => {
const source = await appSource();
expect(source).toContain('if (input === "V") {');
// The lowercase legend the Utilities panel owns must stay untouched.
expect(source).toContain('{ key: "v", label: t("tui.utilitiesAutoKillVitest"');
// And the raw-mode handler must not claim lowercase v.
expect(source).not.toContain('if (input === "v" || input === "V") {\n controller.setLogsRawMode');
});
it("leaves a visible way out", async () => {
const source = await appSource();
expect(source).toContain("tui.rawLogsHint");
// Esc must clear raw mode before the expanded-entry escape, or the two modes fight.
const escapeBlock = source.slice(source.indexOf("if (key.escape) {", source.indexOf("logsFocused")));
expect(escapeBlock.indexOf("state.logsRawMode")).toBeLessThan(escapeBlock.indexOf("state.logsExpandedMode"));
});
});

View File

@@ -889,7 +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)")],
["[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)")],
@@ -1060,7 +1060,6 @@ function StatusModeSingle({
state: DashboardState;
controller: DashboardTUI;
}) {
const { t } = useTranslation("cli");
const focused = state.activeSection;
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
@@ -1116,17 +1115,6 @@ function StatusModeSingle({
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">
@@ -4643,12 +4631,17 @@ export function DashboardApp({ controller }: DashboardAppProps) {
}
/*
FNXC:TuiRawLogs 2026-08-26-14:10:
[v] shows the logs alone, chrome-free and mouse-released, so a terminal click-drag copies clean
FNXC:TuiRawLogs 2026-08-26-14:40:
SHIFT+V, not [v]: the Utilities panel already advertises `[v] Auto-Kill Vitest` on the same
screen. The two handlers are mutually exclusive at runtime (utility actions require the
Utilities section, this branch requires log focus), so there is no functional clash — but two
different `[v]` legends visible at once is a UI anyone would misread.
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") {
if (input === "V") {
controller.setLogsRawMode(!state.logsRawMode);
if (!state.logsRawMode) controller.setShowHelp(false);
return;
@@ -4753,6 +4746,27 @@ export function DashboardApp({ controller }: DashboardAppProps) {
hasSystemInfo: Boolean(state.systemInfo),
});
/*
FNXC:TuiRawLogs 2026-08-26-14:40:
Raw mode replaces the ENTIRE frame, above the layout choice — header included.
It was first added inside the single-pane layout only, which is the narrow one. On a wide terminal
the dashboard renders the GRID layout instead (System / Stats / Utilities / Settings beside Logs),
so the toggle changed state and nothing moved on screen. The point of this mode is that NO chrome
survives a rectangular selection, and chrome is drawn by both layouts plus the header — so the
escape has to happen before either is chosen.
*/
if (state.mode === "status" && state.logsRawMode) {
return (
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
<RawLogs state={state} rows={Math.max(1, rows - 1)} />
<Box height={1} flexShrink={0}>
<Text dimColor>{t("tui.rawLogsHint", "[V/Esc] leave raw logs · select with the mouse to copy")}</Text>
</Box>
</Box>
);
}
return (
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
{/* Header: explicit height={1} so the wrapper always reserves row 0. */}