diff --git a/.changeset/tui-raw-logs.md b/.changeset/tui-raw-logs.md index f95c6f3721..ccbd65df1d 100644 --- a/.changeset/tui-raw-logs.md +++ b/.changeset/tui-raw-logs.md @@ -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. diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/raw-logs-mode.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/raw-logs-mode.test.ts new file mode 100644 index 0000000000..78e254f5f0 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/raw-logs-mode.test.ts @@ -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(" { + 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")); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 216253f8df..9138bb0c35 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -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 ( - - - - {t("tui.rawLogsHint", "[v/Esc] leave raw logs · select with the mouse to copy")} - - - ); - } - return ( @@ -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 ( + + + + {t("tui.rawLogsHint", "[V/Esc] leave raw logs · select with the mouse to copy")} + + + ); + } + return ( {/* Header: explicit height={1} so the wrapper always reserves row 0. */}