feat(FN-XXXX): scroll TUI panes with mouse wheel

Enable xterm SGR mouse reporting in the dashboard TUI and dispatch
wheel events to the focused pane: task detail logs, Git lists
(commits/branches/worktrees), and Files view (tree or preview).
Mouse mode is enabled after Ink mounts so the leading ESC of wheel
reports never arrives alone — avoiding spurious Esc keypresses. We
omit motion-tracking modes so terminals still own drag gestures and
Shift+drag native text selection keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 23:18:19 -07:00
parent cb0c48f5c9
commit 4b7b1e669e
3 changed files with 192 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useSyncExternalStore, useCallback, useEffect } from "react";
import React, { useState, useSyncExternalStore, useCallback, useEffect, useRef } from "react";
import { Box, Text, useInput, useApp, useStdout } from "ink";
import Spinner from "ink-spinner";
import TextInput from "ink-text-input";
@@ -1202,10 +1202,12 @@ function TaskDetailScreen({
task,
projectPath,
interactiveData,
controller,
}: {
task: TaskItem;
projectPath: string | null;
interactiveData: DashboardState["interactiveData"];
controller: DashboardTUI;
}) {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
@@ -1290,6 +1292,35 @@ function TaskDetailScreen({
if (autoFollow) setLogScrollOffset(0);
}, [autoFollow, logCount]);
// ── Mouse wheel: scroll logs by WHEEL_STEP lines per tick ──
// Subscribes to controller wheel events; the controller decodes xterm SGR
// mouse sequences off stdin. Mirrors the keyboard arrow behavior including
// auto-follow toggling.
const WHEEL_STEP = 3;
// Latest log count + pane size in refs so the subscription doesn't need
// to re-register on every render (which would also miss wheel ticks
// arriving between renders).
const logCountRef = useRef(logCount);
const logPaneRowsRef = useRef(logPaneRows);
logCountRef.current = logCount;
logPaneRowsRef.current = logPaneRows;
useEffect(() => {
return controller.onWheel((dir) => {
const maxOffset = Math.max(0, logCountRef.current - logPaneRowsRef.current);
if (maxOffset === 0) return;
if (dir === "up") {
setAutoFollow(false);
setLogScrollOffset((o) => Math.min(maxOffset, o + WHEEL_STEP));
} else {
setLogScrollOffset((o) => {
const next = Math.max(0, o - WHEEL_STEP);
if (next === 0) setAutoFollow(true);
return next;
});
}
});
}, [controller]);
// ── Keyboard: ↑↓ / j/k scroll logs; G = jump to bottom; g = jump to top ──
useInput((input, key) => {
// All detail-screen keys except Esc/Backspace are consumed here so they
@@ -1756,6 +1787,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
task={selectedTask}
projectPath={selectedProject?.path ?? null}
interactiveData={state.interactiveData}
controller={controller}
/>
</Box>
) : tasksState.loading ? (
@@ -2782,7 +2814,7 @@ function PushModal({
);
}
function GitView({ state }: { state: DashboardState }) {
function GitView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
@@ -2950,6 +2982,27 @@ function GitView({ state }: { state: DashboardState }) {
}
});
// ── Mouse-wheel scrolling for the active list pane ──────────────────────
// Wheel moves selection by 3 rows in the focused list. Only active when
// the Git view is mounted (interactiveView === "git").
const gitWheelRef = useRef({ activePane, commits, branches, worktrees });
gitWheelRef.current = { activePane, commits, branches, worktrees };
useEffect(() => {
if (state.interactiveView !== "git") return;
return controller.onWheel((dir) => {
const { activePane: pane, commits: cs, branches: bs, worktrees: ws } = gitWheelRef.current;
const STEP = 3;
const delta = dir === "up" ? -STEP : STEP;
if (pane === "commits") {
setCommitIndex((i) => Math.max(0, Math.min(cs.length - 1, i + delta)));
} else if (pane === "branches") {
setBranchIndex((i) => Math.max(0, Math.min(bs.length - 1, i + delta)));
} else if (pane === "worktrees") {
setWorktreeIndex((i) => Math.max(0, Math.min(ws.length - 1, i + delta)));
}
});
}, [controller, state.interactiveView]);
// Narrow mode: collapse multi-pane layout to a single full-width pane so
// the stacked left+right columns don't overflow on small terminals.
const isNarrow = cols < NARROW_THRESHOLD;
@@ -3379,7 +3432,7 @@ function entriesToNodes(entries: FileEntry[], depth: number): TreeNode[] {
return [...dirs, ...files].map((e) => ({ entry: e, depth, expanded: false, children: undefined }));
}
function FilesView({ state }: { state: DashboardState }) {
function FilesView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
@@ -3614,6 +3667,28 @@ function FilesView({ state }: { state: DashboardState }) {
}
}, { isActive: state.interactiveView === "files" });
// ── Mouse-wheel scrolling for the focused pane ──────────────────────────
// Tree pane: wheel moves the selection cursor. Preview pane: wheel
// scrolls the file viewport. Active only on the Files view.
const filesWheelRef = useRef({ focusedPane, flatNodes, previewResult, previewHeight });
filesWheelRef.current = { focusedPane, flatNodes, previewResult, previewHeight };
useEffect(() => {
if (state.interactiveView !== "files") return;
return controller.onWheel((dir) => {
const { focusedPane: pane, flatNodes: nodes, previewResult: pr, previewHeight: ph } =
filesWheelRef.current;
const STEP = 3;
const delta = dir === "up" ? -STEP : STEP;
if (pane === "tree") {
setSelectedIndex((i) => Math.max(0, Math.min(nodes.length - 1, i + delta)));
} else {
const lineCount = pr?.lineCount ?? 0;
const maxScroll = Math.max(0, lineCount - ph);
setPreviewScroll((s) => Math.max(0, Math.min(maxScroll, s + delta)));
}
});
}, [controller, state.interactiveView]);
// Narrow mode: collapse tree+preview side-by-side to a single pane so the
// two columns don't overflow on terminals below the threshold.
const isNarrow = cols < NARROW_THRESHOLD;
@@ -3837,8 +3912,8 @@ function InteractiveMode({ state, controller }: { state: DashboardState; control
{state.interactiveView === "board" && <BoardView state={state} controller={controller} />}
{state.interactiveView === "agents" && <AgentsView state={state} />}
{state.interactiveView === "settings" && <SettingsInteractiveView state={state} controller={controller} />}
{state.interactiveView === "git" && <GitView state={state} />}
{state.interactiveView === "files" && <FilesView state={state} />}
{state.interactiveView === "git" && <GitView state={state} controller={controller} />}
{state.interactiveView === "files" && <FilesView state={state} controller={controller} />}
</Box>
</Box>
);
@@ -3889,6 +3964,27 @@ export function DashboardApp({ controller }: DashboardAppProps) {
useCallback(() => controller.getSnapshot(), [controller]),
);
// ── Mouse-wheel scrolling for the main logs section ─────────────────────
// Only active when the logs panel is focused. Uses a ref for `state` so
// the subscription doesn't need to re-register on every render.
const wheelStateRef = useRef(state);
wheelStateRef.current = state;
useEffect(() => {
return controller.onWheel((dir) => {
const s = wheelStateRef.current;
if (s.activeSection !== "logs") return;
const filtered = controller.getFilteredLogEntries();
if (filtered.length === 0) return;
const WHEEL_STEP = 3;
const cur = s.selectedLogIndex;
if (dir === "up") {
controller.setSelectedLogIndex(Math.max(0, cur - WHEEL_STEP));
} else {
controller.setSelectedLogIndex(Math.min(filtered.length - 1, cur + WHEEL_STEP));
}
});
}, [controller]);
// 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

@@ -135,6 +135,16 @@ export class DashboardTUI {
private remoteStatus: RemoteStatus | null = null;
private remoteStatusTimer: ReturnType<typeof setInterval> | null = null;
// Mouse-wheel handling. We enable xterm SGR mouse mode in start() so the
// terminal sends button reports for wheel up/down (buttons 64/65). A
// parallel `data` listener parses those reports and dispatches to wheel
// handlers. Ink's own keypress parser ignores SGR mouse sequences so
// long as the full sequence (including the leading ESC) arrives in one
// chunk — which it does once raw mode is enabled before mouse mode is
// 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;
constructor() {
this.logBuffer = new LogRingBuffer();
}
@@ -146,6 +156,16 @@ export class DashboardTUI {
return () => this.subscribers.delete(callback);
}
/**
* Subscribe to mouse-wheel events. Direction is "up" (scroll back/older
* content) or "down" (scroll forward/newer content). Only fires while the
* dashboard is running and the terminal supports xterm mouse reporting.
*/
onWheel(handler: (direction: "up" | "down") => void): () => void {
this.wheelHandlers.add(handler);
return () => this.wheelHandlers.delete(handler);
}
getSnapshot(): DashboardState {
if (this.cachedSnapshot) return this.cachedSnapshot;
this.cachedSnapshot = {
@@ -614,6 +634,27 @@ export class DashboardTUI {
createElement(DashboardApp, { controller: this }),
);
// Mouse mode must be enabled AFTER Ink mounts (which calls
// setRawMode(true) and resumes stdin). If we write the enable sequence
// 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) {
// Enable xterm mouse reporting with SGR-encoded coordinates.
// ?1000h = button press/release reports (includes wheel as
// buttons 64/65)
// ?1006h = SGR encoding (handles wide terminals; the legacy form
// caps coords at 223 columns/rows)
// We deliberately do NOT enable ?1002h (button-event tracking with
// motion) or ?1003h (any-event tracking). Without motion reporting
// the terminal still owns drag gestures, so Shift+drag (and on most
// 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.
process.stdout.write("\x1b[?1000h\x1b[?1006h");
this.installMouseListener();
}
// Reset Ink's internal frame buffer (log-update line tracking) on every
// terminal resize. Without this Ink keeps treating the previous frame's
// line count as the clear region, leaving stale rows above/below the
@@ -770,12 +811,57 @@ export class DashboardTUI {
// Leave the alt-screen buffer last so the user's shell scrollback
// is restored cleanly. \x1b[?1049l = leave alt-screen.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
this.uninstallMouseListener();
// Disable mouse reporting before leaving the alt-screen so the
// user's shell isn't left with mouse mode active.
process.stdout.write("\x1b[?1006l\x1b[?1000l");
process.stdout.write("\x1b[?1049l");
}
}
// ── Private helpers ────────────────────────────────────────────────────────
// Attach a parallel `data` listener that decodes xterm SGR mouse
// sequences and dispatches wheel events. Ink's own listener is also
// attached; SGR sequences arrive as a single chunk that Ink's keypress
// parser silently ignores, so we don't need to (and shouldn't) strip
// them from the stream.
private installMouseListener(): void {
if (this.mouseStdinListener) return;
const mouseRe = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
const listener = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (text.indexOf("\x1b[<") === -1) return;
mouseRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = mouseRe.exec(text)) !== null) {
const btn = Number.parseInt(m[1] ?? "", 10);
// Buttons 64/65 are wheel up/down. Higher codes (66/67) are
// wheel left/right on some terminals — ignored here.
if (btn === 64) this.dispatchWheel("up");
else if (btn === 65) this.dispatchWheel("down");
}
};
this.mouseStdinListener = listener;
process.stdin.on("data", listener);
}
private uninstallMouseListener(): void {
if (!this.mouseStdinListener) return;
process.stdin.off("data", this.mouseStdinListener);
this.mouseStdinListener = null;
}
private dispatchWheel(direction: "up" | "down"): void {
for (const handler of this.wheelHandlers) {
try {
handler(direction);
} catch (err) {
tuiDebug("wheel-handler-error", { err: String(err) });
}
}
}
private clampSelectedLogIndex(entries: LogEntry[]): void {
if (entries.length === 0) {
this.selectedLogIndex = 0;