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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user