feat(FN-3329): refine dashboard TUI system panel and setup wizard UX
- Expand dashboard TUI state/controller wiring for system panel interactions and mouse toggle behavior - Update dashboard TUI app rendering and tests to cover the new panel controls - Improve SetupWizardModal layout and interaction behavior with matching CSS and test updates - Restore docs/research.md wording and add a changeset for the published CLI package Fusion-Task-Id: FN-3329
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render } from "ink-testing-library";
|
||||
import { DashboardApp } from "../app.js";
|
||||
import { DashboardTUI } from "../controller.js";
|
||||
import { createInitialState } from "../state.js";
|
||||
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
|
||||
|
||||
function newController(): DashboardTUI {
|
||||
@@ -233,6 +234,23 @@ describe("DashboardApp smoke", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default active section", () => {
|
||||
it("createInitialState defaults to system panel", () => {
|
||||
const state = createInitialState();
|
||||
expect(state.activeSection).toBe("system");
|
||||
});
|
||||
|
||||
it("DashboardTUI controller defaults to system panel", () => {
|
||||
const controller = newController();
|
||||
expect(controller.getSnapshot().activeSection).toBe("system");
|
||||
});
|
||||
|
||||
it("createInitialState defaults to mouseEnabled = true", () => {
|
||||
const state = createInitialState();
|
||||
expect(state.mouseEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardTUI snapshot stability", () => {
|
||||
it("returns the same snapshot reference across reads when state has not changed", () => {
|
||||
const controller = newController();
|
||||
|
||||
@@ -267,6 +267,24 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
||||
<Text dimColor>Uptime</Text>
|
||||
<Text>{formatUptime(Date.now() - info.startTimeMs)}</Text>
|
||||
</Box>
|
||||
{isFocused && (
|
||||
// Inline hint row — only shown when the System panel is focused.
|
||||
// Discoverability for Enter / [c] / [M] since mouse-mode blocks
|
||||
// click-drag selection of the token. Single <Text wrap="truncate-end">
|
||||
// so on narrow terminals the hint clips cleanly to one row instead
|
||||
// of wrapping into 2-3 lines and overflowing the SYSTEM_HEIGHT
|
||||
// budget. Disappears when the user moves focus.
|
||||
<Box flexShrink={0}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
<Text color="cyanBright">[Enter]</Text> open URL
|
||||
{info.authToken ? (
|
||||
<Text> · <Text color="cyanBright">[c]</Text> copy token</Text>
|
||||
) : null}
|
||||
{" · "}
|
||||
<Text color="cyanBright">[M]</Text> mouse {state.mouseEnabled ? "on" : "off (drag to select)"}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -683,6 +701,10 @@ function HelpOverlay() {
|
||||
["[k]", "Kill all vitest processes (Utilities)"],
|
||||
["[v]", "Toggle auto-kill vitest on memory pressure (Utilities)"],
|
||||
["[+/-]", "Adjust vitest kill memory threshold (Utilities)"],
|
||||
["[Enter]", "Open dashboard URL in browser (System)"],
|
||||
["[c]", "Copy auth token to clipboard (System)"],
|
||||
["[M]", "Toggle mouse mode (off → click-drag selects text; works under tmux)"],
|
||||
["[Shift+drag]", "Bypass mouse mode to select text (most native terminals; not tmux)"],
|
||||
["[↑/↓/k/j]", "Navigate list / log entries"],
|
||||
["[Home / G]", "First / last log entry (Logs)"],
|
||||
["[Enter/Space]", "Expand log entry (Logs)"],
|
||||
@@ -748,7 +770,10 @@ function StatusModeGrid({
|
||||
// System panel is normally 4 rows (border 2 + 2 content rows so chips wrap to
|
||||
// a second line). When auth is on, the Token chip is long enough that it
|
||||
// routinely wraps to a third row; bump to 5 so it isn't clipped.
|
||||
const SYSTEM_HEIGHT = state.systemInfo?.authToken ? 5 : 4;
|
||||
// Add one extra row when the System panel is focused so the inline
|
||||
// [Enter]/[c]/[M] hint isn't clipped against Logs.
|
||||
const systemFocused = focused === "system";
|
||||
const SYSTEM_HEIGHT = (state.systemInfo?.authToken ? 5 : 4) + (systemFocused ? 1 : 0);
|
||||
const bottomShare = Math.min(10, Math.max(6, Math.floor(middleHeight * 0.35)));
|
||||
const logsShare = Math.max(1, middleHeight - SYSTEM_HEIGHT - bottomShare);
|
||||
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
|
||||
@@ -4098,7 +4123,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
}
|
||||
|
||||
// Number keys 1-5 always jump to a status-mode section (matching the
|
||||
// [1]System [2]Logs [3]Utilities [4]Stats [5]Settings tabs in the
|
||||
// [1]System [2]Logs [3]Stats [4]Utilities [5]Settings tabs in the
|
||||
// MainHeader). They switch back from interactive mode if needed —
|
||||
// the interactive views still have letter shortcuts (b/a/g/t).
|
||||
const sectionForNumber: Record<string, SectionId | undefined> = {
|
||||
@@ -4135,6 +4160,44 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// System panel: [c] copies the auth token to the clipboard. Mouse mode
|
||||
// is on for log scrolling, which blocks normal click-drag selection of
|
||||
// the token text in the panel — this gives users a keyboard path.
|
||||
if (
|
||||
(input === "c" || input === "C") &&
|
||||
state.activeSection === "system" &&
|
||||
state.systemInfo?.authToken
|
||||
) {
|
||||
const token = state.systemInfo.authToken;
|
||||
void copyToClipboard(token).then((ok) => {
|
||||
controller.flashClipboard(ok);
|
||||
if (ok) {
|
||||
controller.log("Auth token copied to clipboard.", "clipboard");
|
||||
} else {
|
||||
controller.warn(
|
||||
"Clipboard copy failed (no pbcopy/xclip/wl-copy/clip available).",
|
||||
"clipboard",
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// [M] toggles mouse reporting. With it off, native click-drag selection
|
||||
// works (the only path that works under tmux's `mouse on`); cost is
|
||||
// wheel-scroll on Logs/Files/Git stops working until re-enabled.
|
||||
if (input === "M") {
|
||||
const next = !state.mouseEnabled;
|
||||
controller.setMouseEnabled(next);
|
||||
controller.log(
|
||||
next
|
||||
? "Mouse mode ON — wheel scrolls panels."
|
||||
: "Mouse mode OFF — click-drag to select text; press M to restore wheel.",
|
||||
"mouse",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab / Shift+Tab cycle focused panel
|
||||
if (key.tab) {
|
||||
const shift = key.shift;
|
||||
|
||||
@@ -60,7 +60,7 @@ import { SECTION_ORDER } from "./state.js";
|
||||
|
||||
export class DashboardTUI {
|
||||
// State fields mirror the original private layout so tests can access them.
|
||||
activeSection: SectionId = "logs";
|
||||
activeSection: SectionId = "system";
|
||||
// Named `logBuffer` to match what captureConsole tests access via
|
||||
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
|
||||
logBuffer: LogRingBuffer;
|
||||
@@ -144,6 +144,11 @@ export class DashboardTUI {
|
||||
// requested. (See ink#222 / @zenobius/ink-mouse for prior art.)
|
||||
private wheelHandlers: Set<(direction: "up" | "down") => void> = new Set();
|
||||
private mouseStdinListener: ((chunk: Buffer | string) => void) | null = null;
|
||||
// Tracks the desired mouse-reporting state. Toggled at runtime by the
|
||||
// user (e.g. to allow native click-drag selection under tmux, where
|
||||
// Shift-bypass is intercepted by tmux itself before reaching the
|
||||
// terminal). Default true; only flipped via setMouseEnabled().
|
||||
mouseEnabled: boolean = true;
|
||||
|
||||
constructor() {
|
||||
this.logBuffer = new LogRingBuffer();
|
||||
@@ -192,6 +197,7 @@ export class DashboardTUI {
|
||||
updateStatus: this.updateStatus,
|
||||
clipboardFlash: this.clipboardFlash,
|
||||
remoteStatus: this.remoteStatus,
|
||||
mouseEnabled: this.mouseEnabled,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
@@ -503,6 +509,25 @@ export class DashboardTUI {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Toggle xterm mouse reporting at runtime. When disabled, the terminal
|
||||
// owns the mouse — click-drag does native text selection (the only path
|
||||
// that works under tmux, where Shift-bypass is intercepted by tmux).
|
||||
// Re-enable to restore wheel-driven log/list scrolling.
|
||||
setMouseEnabled(enabled: boolean): void {
|
||||
if (this.mouseEnabled === enabled) return;
|
||||
this.mouseEnabled = enabled;
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
if (enabled) {
|
||||
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
||||
this.installMouseListener();
|
||||
} else {
|
||||
this.uninstallMouseListener();
|
||||
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
||||
}
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setLogsWrapEnabled(enabled: boolean): void {
|
||||
this.logsWrapEnabled = enabled;
|
||||
this.notify();
|
||||
|
||||
@@ -358,13 +358,22 @@ export interface DashboardState {
|
||||
// `interactiveData.remote` is available. Used to surface tunnel state
|
||||
// (state/url) globally in the TUI header.
|
||||
remoteStatus: RemoteStatus | null;
|
||||
// Whether xterm mouse reporting is currently enabled. When true, the
|
||||
// controller decodes wheel events into log/list scrolling. When false,
|
||||
// the terminal owns the mouse — needed for native click-drag selection
|
||||
// under tmux, where Shift-bypass is intercepted by tmux itself.
|
||||
mouseEnabled: boolean;
|
||||
}
|
||||
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||
// Order matches the visual layout in StatusModeGrid: System (top), Logs
|
||||
// (middle), then the bottom row left-to-right (Stats, Utilities, Settings).
|
||||
// Both Tab/Shift+Tab (PANEL_ORDER in app.tsx) and ←/→ (cycleSection) use
|
||||
// this same order so panel navigation matches what the user sees.
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "stats", "utilities", "settings"];
|
||||
|
||||
export function createInitialState(): DashboardState {
|
||||
return {
|
||||
activeSection: "logs",
|
||||
activeSection: "system",
|
||||
logEntries: [],
|
||||
systemInfo: null,
|
||||
taskStats: null,
|
||||
@@ -387,5 +396,6 @@ export function createInitialState(): DashboardState {
|
||||
updateStatus: null,
|
||||
clipboardFlash: null,
|
||||
remoteStatus: null,
|
||||
mouseEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user