feat(dashboard-tui): auto-toggle mouse mode based on focused panel

Default mouse reporting OFF so click-drag selection works by default —
useful for copying the auth token directly off the System panel without
needing the [c] keyboard shortcut. Auto-enable when the user focuses a
panel that consumes wheel events:

  - Status mode → on while Logs is focused, off elsewhere
  - Interactive → on for Files / Git / Board (which uses wheel in the
    task-detail screen), off for Agents / Settings

Implemented as a useEffect in DashboardApp that maps state.activeSection
+ state.mode + state.interactiveView → controller.setMouseEnabled. [M]
remains a manual override; the next focus change reapplies the auto
policy. Controller's start() now honors initial mouseEnabled rather than
unconditionally writing the SGR enable sequence.

Updated System-panel hint to advertise click-drag selection, simplified
the help-overlay [M] entry, and flipped the createInitialState test
expectation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-04 18:21:57 -07:00
parent 08e41caf54
commit ca4cce4e13
4 changed files with 45 additions and 20 deletions

View File

@@ -245,9 +245,9 @@ describe("Default active section", () => {
expect(controller.getSnapshot().activeSection).toBe("system");
});
it("createInitialState defaults to mouseEnabled = true", () => {
it("createInitialState defaults to mouseEnabled = false (selection-friendly; auto-toggled by panel focus)", () => {
const state = createInitialState();
expect(state.mouseEnabled).toBe(true);
expect(state.mouseEnabled).toBe(false);
});
});

View File

@@ -269,19 +269,18 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
</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.
// Mouse reporting is auto-off here so users can click-drag to
// select the token; it auto-toggles on when they focus Logs /
// Files / Git / Board for wheel scrolling. [c] is the keyboard
// shortcut to copy the token in one keystroke.
<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> · <Text color="cyanBright">[c]</Text> copy token · drag to select</Text>
) : (
<Text> · drag to select</Text>
)}
</Text>
</Box>
)}
@@ -703,8 +702,7 @@ function HelpOverlay() {
["[+/-]", "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)"],
["[M]", "Manual mouse-mode toggle (auto: on for Logs/Files/Git/Board, off elsewhere)"],
["[↑/↓/k/j]", "Navigate list / log entries"],
["[Home / G]", "First / last log entry (Logs)"],
["[Enter/Space]", "Expand log entry (Logs)"],
@@ -4015,6 +4013,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
});
}, [controller]);
// Auto-toggle xterm mouse reporting based on whether the focused panel
// benefits from wheel scrolling. We default-off so click-drag selection
// works (e.g. copying the auth token from the System panel), and switch
// on for panels that wire `controller.onWheel` consumers:
// - Status mode: Logs panel
// - Interactive: Files / Git / Board (task detail uses the wheel too)
// Other status panels (System / Stats / Utilities / Settings) leave it
// off so the user can select text natively. [M] is still a manual
// override, but the next focus change will reapply this policy.
const wantsMouse = state.mode === "interactive"
? (state.interactiveView === "files"
|| state.interactiveView === "git"
|| state.interactiveView === "board")
: state.activeSection === "logs";
useEffect(() => {
if (state.mouseEnabled !== wantsMouse) {
controller.setMouseEnabled(wantsMouse);
}
}, [controller, wantsMouse, state.mouseEnabled]);
// Global QR overlay state — populated when the user hits Ctrl+Q on a
// running tunnel. `loading` covers the network request; `error` surfaces
// the message inline so the overlay never sits blank.

View File

@@ -144,11 +144,13 @@ 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;
// Tracks the desired mouse-reporting state. Default OFF so click-drag
// text selection works out of the box — terminal owns the mouse. The
// dashboard auto-enables it via setMouseEnabled() when the user focuses
// a panel that uses wheel scrolling (Logs / Files / Git / Board task
// detail). [M] is also a manual override, but the next focus change
// will reapply the auto policy.
mouseEnabled: boolean = false;
constructor() {
this.logBuffer = new LogRingBuffer();
@@ -665,7 +667,7 @@ export class DashboardTUI {
// before raw mode is on, the terminal can deliver the first wheel
// report's leading ESC byte alone, which Ink would parse as a bare
// Esc keypress (closing modals on every wheel tick).
if (process.stdin?.isTTY) {
if (process.stdin?.isTTY && this.mouseEnabled) {
// Enable xterm mouse reporting with SGR-encoded coordinates.
// ?1000h = button press/release reports (includes wheel as
// buttons 64/65)
@@ -677,6 +679,11 @@ export class DashboardTUI {
// terminals plain click+drag) keeps doing native text selection.
// Holding Shift always works as a hard override even on terminals
// that grab the bare drag gesture.
//
// Gated by `this.mouseEnabled` so the default-off policy
// (selection-friendly on the System panel) holds at startup. The
// app component re-enables via setMouseEnabled() as soon as the
// user focuses a panel that uses wheel scrolling.
process.stdout.write("\x1b[?1000h\x1b[?1006h");
this.installMouseListener();
}

View File

@@ -396,6 +396,6 @@ export function createInitialState(): DashboardState {
updateStatus: null,
clipboardFlash: null,
remoteStatus: null,
mouseEnabled: true,
mouseEnabled: false,
};
}