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:
7
.changeset/tui-raw-logs.md
Normal file
7
.changeset/tui-raw-logs.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user