wave-code 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
package/src/daemon/commands.ts
CHANGED
|
@@ -103,7 +103,9 @@ function startDaemon(socketPath: string): void {
|
|
|
103
103
|
* connection (or daemonStartTimeout.ms elapses). Exits (nonzero) with the
|
|
104
104
|
* spec'd error when the daemon cannot be started/reached.
|
|
105
105
|
*/
|
|
106
|
-
async function connectDaemonOrExit(
|
|
106
|
+
export async function connectDaemonOrExit(
|
|
107
|
+
socketPath: string,
|
|
108
|
+
): Promise<SocketClient> {
|
|
107
109
|
try {
|
|
108
110
|
return await connectDaemon(socketPath);
|
|
109
111
|
} catch (err) {
|
|
@@ -133,7 +135,7 @@ function fail(message: string): never {
|
|
|
133
135
|
|
|
134
136
|
interface DaemonSessionEntry {
|
|
135
137
|
sessionId: string;
|
|
136
|
-
|
|
138
|
+
sessionCwd: string;
|
|
137
139
|
isLoading: boolean;
|
|
138
140
|
/**
|
|
139
141
|
* The other two halves of the idle predicate `wait` / `status` share with
|
|
@@ -230,11 +232,15 @@ async function attachSession(
|
|
|
230
232
|
*/
|
|
231
233
|
onInitialized?: (sessionId: string) => void;
|
|
232
234
|
} = {},
|
|
233
|
-
): Promise<{ sessionId: string;
|
|
235
|
+
): Promise<{ sessionId: string; currentWorkdir: string }> {
|
|
234
236
|
const init = (await client.request("initialize", {
|
|
235
237
|
workdir: process.cwd(),
|
|
236
238
|
restoreSessionId: sessionId,
|
|
237
|
-
|
|
239
|
+
// A daemon restart leaves the session on disk and re-hosts it here; the
|
|
240
|
+
// re-hosting client is the CLI, and the session file carries no record of
|
|
241
|
+
// its original creator (see the entrypoint story in the telemetry spec).
|
|
242
|
+
entrypoint: "cli",
|
|
243
|
+
})) as { sessionId: string; currentWorkdir: string };
|
|
238
244
|
const initId = init.sessionId;
|
|
239
245
|
options.onInitialized?.(initId);
|
|
240
246
|
try {
|
|
@@ -357,6 +363,9 @@ export async function daemonCreateCommand(
|
|
|
357
363
|
workdir,
|
|
358
364
|
permissionMode: mode,
|
|
359
365
|
model: options.model,
|
|
366
|
+
// The daemon hosts sessions built from several front ends; this one is
|
|
367
|
+
// being created by the CLI (`wave daemon create`).
|
|
368
|
+
entrypoint: "cli",
|
|
360
369
|
})) as { sessionId: string };
|
|
361
370
|
console.log(result.sessionId);
|
|
362
371
|
if (worktreePath !== undefined) {
|
|
@@ -389,7 +398,7 @@ export async function daemonListCommand(socketPath: string): Promise<void> {
|
|
|
389
398
|
// owns running background work.
|
|
390
399
|
status: isIdleSession(s) ? "idle" : "generating",
|
|
391
400
|
messageCount: String(s.messageCount),
|
|
392
|
-
|
|
401
|
+
sessionCwd: s.sessionCwd,
|
|
393
402
|
}));
|
|
394
403
|
const width = (key: keyof (typeof rows)[number]) =>
|
|
395
404
|
Math.max(...rows.map((r) => r[key].length), key.length);
|
|
@@ -400,7 +409,7 @@ export async function daemonListCommand(socketPath: string): Promise<void> {
|
|
|
400
409
|
);
|
|
401
410
|
for (const r of rows) {
|
|
402
411
|
console.log(
|
|
403
|
-
`${pad(r.sessionId, width("sessionId"))} ${pad(r.status, width("status"))} ${pad(r.messageCount, width("messageCount"))} ${r.
|
|
412
|
+
`${pad(r.sessionId, width("sessionId"))} ${pad(r.status, width("status"))} ${pad(r.messageCount, width("messageCount"))} ${r.sessionCwd}`,
|
|
404
413
|
);
|
|
405
414
|
}
|
|
406
415
|
} else {
|
|
@@ -523,7 +532,7 @@ function parseAskUserQuestionAnswer(
|
|
|
523
532
|
|
|
524
533
|
interface SessionSnapshot {
|
|
525
534
|
sessionId: string;
|
|
526
|
-
|
|
535
|
+
currentWorkdir: string;
|
|
527
536
|
/** `idle` / `generating` / `waiting for approval`. */
|
|
528
537
|
status: string;
|
|
529
538
|
pending: PendingPermission[];
|
|
@@ -543,7 +552,7 @@ interface SessionSnapshot {
|
|
|
543
552
|
*/
|
|
544
553
|
function renderSessionSnapshot(snapshot: SessionSnapshot): void {
|
|
545
554
|
console.log(`Session: ${snapshot.sessionId}`);
|
|
546
|
-
console.log(`Working directory: ${snapshot.
|
|
555
|
+
console.log(`Working directory: ${snapshot.currentWorkdir}`);
|
|
547
556
|
console.log(`Status: ${snapshot.status}`);
|
|
548
557
|
|
|
549
558
|
if (snapshot.pending.length > 0) {
|
|
@@ -648,7 +657,7 @@ export async function daemonStatusCommand(
|
|
|
648
657
|
: "idle";
|
|
649
658
|
renderSessionSnapshot({
|
|
650
659
|
sessionId: initId,
|
|
651
|
-
|
|
660
|
+
currentWorkdir: init.currentWorkdir,
|
|
652
661
|
status,
|
|
653
662
|
pending,
|
|
654
663
|
messages: messages.messages,
|
|
@@ -866,7 +875,7 @@ export async function daemonWaitCommand(
|
|
|
866
875
|
};
|
|
867
876
|
renderSessionSnapshot({
|
|
868
877
|
sessionId: watch.id,
|
|
869
|
-
|
|
878
|
+
currentWorkdir: init.currentWorkdir,
|
|
870
879
|
status,
|
|
871
880
|
pending,
|
|
872
881
|
messages: result.messages ?? [],
|
|
@@ -1275,7 +1284,7 @@ function runGit(cwd: string, args: string[]): Promise<string> {
|
|
|
1275
1284
|
}
|
|
1276
1285
|
|
|
1277
1286
|
/**
|
|
1278
|
-
* Resolve the worktree hosting `
|
|
1287
|
+
* Resolve the worktree hosting `sessionCwd` for removal, mirroring the
|
|
1279
1288
|
* values protocol createWorktree returns: worktree path via
|
|
1280
1289
|
* `git rev-parse --show-toplevel`, branch via `git branch --show-current`, and
|
|
1281
1290
|
* repoRoot = the MAIN repo root (first entry of `git worktree list` — where
|
|
@@ -1284,7 +1293,7 @@ function runGit(cwd: string, args: string[]): Promise<string> {
|
|
|
1284
1293
|
* check would pass trivially while removeWorktree's fs.rmSync fallback could
|
|
1285
1294
|
* delete the whole repository.
|
|
1286
1295
|
*/
|
|
1287
|
-
async function resolveWorktreeForRemoval(
|
|
1296
|
+
async function resolveWorktreeForRemoval(sessionCwd: string): Promise<{
|
|
1288
1297
|
path: string;
|
|
1289
1298
|
branch: string;
|
|
1290
1299
|
repoRoot: string;
|
|
@@ -1294,17 +1303,17 @@ async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
|
|
|
1294
1303
|
let branch: string;
|
|
1295
1304
|
try {
|
|
1296
1305
|
const [toplevel, current] = await Promise.all([
|
|
1297
|
-
runGit(
|
|
1298
|
-
runGit(
|
|
1306
|
+
runGit(sessionCwd, ["rev-parse", "--show-toplevel"]),
|
|
1307
|
+
runGit(sessionCwd, ["branch", "--show-current"]),
|
|
1299
1308
|
]);
|
|
1300
1309
|
worktreePath = toplevel;
|
|
1301
1310
|
branch = current;
|
|
1302
1311
|
} catch {
|
|
1303
1312
|
throw new Error(
|
|
1304
|
-
`Cannot remove worktree: ${
|
|
1313
|
+
`Cannot remove worktree: ${sessionCwd} is not inside a git repository`,
|
|
1305
1314
|
);
|
|
1306
1315
|
}
|
|
1307
|
-
const repoRoot = getGitMainRepoRoot(
|
|
1316
|
+
const repoRoot = getGitMainRepoRoot(sessionCwd);
|
|
1308
1317
|
if (path.resolve(worktreePath) === path.resolve(repoRoot)) {
|
|
1309
1318
|
throw new Error(
|
|
1310
1319
|
`Refusing to remove the main working tree: ${repoRoot} (not a linked worktree)`,
|
|
@@ -1324,7 +1333,7 @@ async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
|
|
|
1324
1333
|
* abort there is no attach step: destroy is a pure registry operation keyed by
|
|
1325
1334
|
* the envelope sessionId, so unknown sessions succeed without touching disk.
|
|
1326
1335
|
* With --remove-worktree the session's git worktree is resolved from its
|
|
1327
|
-
*
|
|
1336
|
+
* sessionCwd (getSessionInfo) and removed via protocol removeWorktree
|
|
1328
1337
|
* first, then the session is destroyed.
|
|
1329
1338
|
*/
|
|
1330
1339
|
export async function daemonDestroyCommand(
|
|
@@ -1340,8 +1349,8 @@ export async function daemonDestroyCommand(
|
|
|
1340
1349
|
"getSessionInfo",
|
|
1341
1350
|
undefined,
|
|
1342
1351
|
sessionId,
|
|
1343
|
-
)) as {
|
|
1344
|
-
const wt = await resolveWorktreeForRemoval(info.
|
|
1352
|
+
)) as { sessionCwd: string };
|
|
1353
|
+
const wt = await resolveWorktreeForRemoval(info.sessionCwd);
|
|
1345
1354
|
await client.request("removeWorktree", {
|
|
1346
1355
|
path: wt.path,
|
|
1347
1356
|
branch: wt.branch,
|
|
@@ -1367,7 +1376,7 @@ export const daemonStopTimeout = { ms: 10_000 };
|
|
|
1367
1376
|
const DAEMON_STOP_POLL_INTERVAL_MS = 200;
|
|
1368
1377
|
|
|
1369
1378
|
/** Connect to the daemon socket without starting one; undefined when it is not running. */
|
|
1370
|
-
async function
|
|
1379
|
+
export async function connectDaemonIfRunning(
|
|
1371
1380
|
socketPath: string,
|
|
1372
1381
|
): Promise<SocketClient | undefined> {
|
|
1373
1382
|
try {
|
|
@@ -1381,7 +1390,7 @@ async function tryConnectDaemon(
|
|
|
1381
1390
|
async function waitForDaemonExit(socketPath: string): Promise<void> {
|
|
1382
1391
|
const deadline = Date.now() + daemonStopTimeout.ms;
|
|
1383
1392
|
while (Date.now() < deadline) {
|
|
1384
|
-
const client = await
|
|
1393
|
+
const client = await connectDaemonIfRunning(socketPath);
|
|
1385
1394
|
if (!client) return;
|
|
1386
1395
|
client.dispose();
|
|
1387
1396
|
await sleep(DAEMON_STOP_POLL_INTERVAL_MS);
|
|
@@ -1399,7 +1408,7 @@ async function waitForDaemonExit(socketPath: string): Promise<void> {
|
|
|
1399
1408
|
* Throws when a running daemon fails to exit in time.
|
|
1400
1409
|
*/
|
|
1401
1410
|
async function stopDaemon(socketPath: string): Promise<boolean> {
|
|
1402
|
-
const client = await
|
|
1411
|
+
const client = await connectDaemonIfRunning(socketPath);
|
|
1403
1412
|
if (!client) return false;
|
|
1404
1413
|
try {
|
|
1405
1414
|
// The daemon may drop the socket before responding to the RPC — the wait
|
package/src/daemon-cli.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { ensureRuntimeDeps } from "wave-agent-sdk";
|
|
12
12
|
import { DaemonServer } from "./stdio/daemonServer.js";
|
|
13
|
+
import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
|
|
13
14
|
|
|
14
15
|
export async function startDaemonCli(socketPath: string): Promise<void> {
|
|
15
16
|
// Install the on-demand runtime dependencies (image codec + ripgrep) before
|
|
@@ -17,7 +18,11 @@ export async function startDaemonCli(socketPath: string): Promise<void> {
|
|
|
17
18
|
// install belongs here — and it must land before the first client attaches,
|
|
18
19
|
// since the host reports "daemon ready" on this socket.
|
|
19
20
|
await ensureRuntimeDeps();
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
// The resident daemon owns the remote sessions, so it is the process that
|
|
23
|
+
// serves a paired phone. Off (undefined) unless the switch says otherwise.
|
|
24
|
+
const createRemoteSink = createRemoteControlSinkFactory(process.cwd());
|
|
25
|
+
const server = new DaemonServer({ socketPath, createRemoteSink });
|
|
21
26
|
await server.start();
|
|
22
27
|
// Ready — any error that follows goes to the daemon log via stderr.
|
|
23
28
|
}
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ESC_DOUBLE_PRESS_TIMEOUT_MS,
|
|
8
8
|
btwOverlayActiveRef,
|
|
9
9
|
} from "../managers/inputReducer.js";
|
|
10
|
-
import { createBracketedPasteDetector } from "../utils/bracketedPaste.js";
|
|
11
10
|
import {
|
|
12
11
|
searchFiles as searchFilesUtil,
|
|
13
12
|
PermissionMode,
|
|
@@ -21,11 +20,6 @@ export const useInputManager = (
|
|
|
21
20
|
) => {
|
|
22
21
|
const [state, dispatch] = useReducer(inputReducer, initialState);
|
|
23
22
|
|
|
24
|
-
// Detects bracketed paste (DECSET 2004) markers so pasted text is inserted
|
|
25
|
-
// without triggering submit — a pasted trailing \r must not be treated as
|
|
26
|
-
// Enter (see utils/bracketedPaste.ts).
|
|
27
|
-
const pasteDetectorRef = useRef(createBracketedPasteDetector());
|
|
28
|
-
|
|
29
23
|
// Abort plumbing for the /btw side question: ABORT_BTW (set when the overlay
|
|
30
24
|
// is dismissed while loading) aborts the in-flight onAskBtw call; the
|
|
31
25
|
// dismissed ref suppresses the late SET_BTW_STATE dispatch / error logging.
|
|
@@ -290,6 +284,8 @@ export const useInputManager = (
|
|
|
290
284
|
await onAddDir?.(effect.args);
|
|
291
285
|
} else if (command === "rename") {
|
|
292
286
|
await onRenameSession?.(effect.args);
|
|
287
|
+
} else if (command === "remote-control") {
|
|
288
|
+
dispatch({ type: "SET_SHOW_REMOTE_CONTROL", payload: true });
|
|
293
289
|
} else if (command === "plan") {
|
|
294
290
|
await onPlanCommand?.(effect.args);
|
|
295
291
|
}
|
|
@@ -533,6 +529,10 @@ export const useInputManager = (
|
|
|
533
529
|
dispatch({ type: "SET_SHOW_STATUS_COMMAND", payload: show });
|
|
534
530
|
}, []);
|
|
535
531
|
|
|
532
|
+
const setShowRemoteControl = useCallback((show: boolean) => {
|
|
533
|
+
dispatch({ type: "SET_SHOW_REMOTE_CONTROL", payload: show });
|
|
534
|
+
}, []);
|
|
535
|
+
|
|
536
536
|
const setShowLoginCommand = useCallback((show: boolean) => {
|
|
537
537
|
dispatch({ type: "SET_SHOW_LOGIN_COMMAND", payload: show });
|
|
538
538
|
}, []);
|
|
@@ -628,44 +628,27 @@ export const useInputManager = (
|
|
|
628
628
|
dispatch({ type: "CLEAR_LONG_TEXT_MAP" });
|
|
629
629
|
}, []);
|
|
630
630
|
|
|
631
|
+
// Pasted text is insert-only. ink delivers a bracketed paste as one string on
|
|
632
|
+
// its own channel (`usePaste`), so a trailing newline inside it can never be
|
|
633
|
+
// mistaken for Enter — going through HANDLE_KEY would submit instead, because
|
|
634
|
+
// its coalesced-Enter branch fires on any chunk ending with a single \r.
|
|
635
|
+
const insertPastedText = useCallback((text: string) => {
|
|
636
|
+
if (text === "") {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// \r → \n normalizes CRLF terminals to the newline the input uses.
|
|
640
|
+
dispatch({
|
|
641
|
+
type: "INSERT_TEXT_WITH_PLACEHOLDER",
|
|
642
|
+
payload: text.replace(/\r/g, "\n"),
|
|
643
|
+
});
|
|
644
|
+
}, []);
|
|
645
|
+
|
|
631
646
|
const handleInput = useCallback(
|
|
632
647
|
async (input: string, key: Key) => {
|
|
633
|
-
const result = pasteDetectorRef.current.process(input);
|
|
634
|
-
|
|
635
|
-
if (result.kind === "consume") {
|
|
636
|
-
// Content of an in-flight bracketed paste (or an empty paste):
|
|
637
|
-
// hold it, never submit or insert prematurely.
|
|
638
|
-
return true;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
if (result.kind === "paste") {
|
|
642
|
-
if (result.leadingInput) {
|
|
643
|
-
dispatch({
|
|
644
|
-
type: "HANDLE_KEY",
|
|
645
|
-
payload: {
|
|
646
|
-
input: result.leadingInput,
|
|
647
|
-
key,
|
|
648
|
-
hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
|
|
649
|
-
hasQueuedMessages: hasQueuedMessagesProp ?? false,
|
|
650
|
-
isIdle: isIdleProp ?? false,
|
|
651
|
-
},
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
if (result.text !== "") {
|
|
655
|
-
// Insert-only: \r → \n normalizes CRLF terminals, matching the
|
|
656
|
-
// canonical paste path (inputHandlers.handlePasteInput).
|
|
657
|
-
dispatch({
|
|
658
|
-
type: "INSERT_TEXT_WITH_PLACEHOLDER",
|
|
659
|
-
payload: result.text.replace(/\r/g, "\n"),
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
return true;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
648
|
dispatch({
|
|
666
649
|
type: "HANDLE_KEY",
|
|
667
650
|
payload: {
|
|
668
|
-
input
|
|
651
|
+
input,
|
|
669
652
|
key,
|
|
670
653
|
hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
|
|
671
654
|
hasQueuedMessages: hasQueuedMessagesProp ?? false,
|
|
@@ -698,6 +681,7 @@ export const useInputManager = (
|
|
|
698
681
|
showResumeSelector: state.showResumeSelector,
|
|
699
682
|
showHelp: state.showHelp,
|
|
700
683
|
showStatusCommand: state.showStatusCommand,
|
|
684
|
+
showRemoteControl: state.showRemoteControl,
|
|
701
685
|
showLoginCommand: state.showLoginCommand,
|
|
702
686
|
showPluginManager: state.showPluginManager,
|
|
703
687
|
showModelSelector: state.showModelSelector,
|
|
@@ -746,6 +730,7 @@ export const useInputManager = (
|
|
|
746
730
|
setShowRewindManager,
|
|
747
731
|
setShowHelp,
|
|
748
732
|
setShowStatusCommand,
|
|
733
|
+
setShowRemoteControl,
|
|
749
734
|
setShowLoginCommand,
|
|
750
735
|
setShowPluginManager,
|
|
751
736
|
setShowModelSelector,
|
|
@@ -764,6 +749,7 @@ export const useInputManager = (
|
|
|
764
749
|
|
|
765
750
|
// Paste and text handling
|
|
766
751
|
handlePasteInput,
|
|
752
|
+
insertPastedText,
|
|
767
753
|
handleSubmit,
|
|
768
754
|
expandLongTextPlaceholders,
|
|
769
755
|
clearLongTextMap,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import { useInput } from "ink";
|
|
3
3
|
|
|
4
4
|
export interface UseLineScrollOptions {
|
|
@@ -6,33 +6,68 @@ export interface UseLineScrollOptions {
|
|
|
6
6
|
totalLines: number;
|
|
7
7
|
/** Number of rows visible at once (computed from the available height). */
|
|
8
8
|
visibleCount: number;
|
|
9
|
+
/**
|
|
10
|
+
* Rows moved by one ↑/↓ (or Ctrl+P/Ctrl+N) press. Callers that leave it out
|
|
11
|
+
* never see the arrow keys handled at all — the overlay keeps only the page
|
|
12
|
+
* keys, as the /plan and confirmation overlays do.
|
|
13
|
+
*/
|
|
14
|
+
lineStep?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Resets the viewport to the top when this value changes. Only needed by
|
|
17
|
+
* callers that swap the content of a mounted overlay (the /btw answer of the
|
|
18
|
+
* next question); a component that is remounted per content set gets the
|
|
19
|
+
* reset for free.
|
|
20
|
+
*/
|
|
21
|
+
resetKey?: unknown;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
25
|
* Fixed-height line scrolling shared by overlays whose content may exceed the
|
|
13
26
|
* terminal: keeps a scroll offset clamped to the visible window and handles
|
|
14
27
|
* PgUp/PgDn (full page) plus Ctrl+u/Ctrl+d (half page, aligned with Claude
|
|
15
|
-
* Code's modal pager keys).
|
|
16
|
-
*
|
|
28
|
+
* Code's modal pager keys). With `lineStep`, ↑/↓ and Ctrl+P/Ctrl+N move that
|
|
29
|
+
* many rows — the /btw viewport's keys. Returns the clamped offset, the ↑/↓
|
|
30
|
+
* indicator flags so callers can render "N more" lines, and `canScroll` (the
|
|
31
|
+
* content overflows the viewport).
|
|
32
|
+
*
|
|
33
|
+
* The handlers act only while the content overflows: a viewport with nothing to
|
|
34
|
+
* scroll must not react to keys the surrounding UI still owns.
|
|
17
35
|
*
|
|
18
36
|
* Scroll position starts at the top on mount; callers that display a new
|
|
19
|
-
* content set remount this hook (e.g. an overlay component keyed by
|
|
20
|
-
* which resets the offset naturally.
|
|
37
|
+
* content set either remount this hook (e.g. an overlay component keyed by
|
|
38
|
+
* content) or pass `resetKey`, which resets the offset naturally.
|
|
21
39
|
*/
|
|
22
40
|
export function useLineScroll({
|
|
23
41
|
totalLines,
|
|
24
42
|
visibleCount,
|
|
43
|
+
lineStep,
|
|
44
|
+
resetKey,
|
|
25
45
|
}: UseLineScrollOptions): {
|
|
26
46
|
scrollOffset: number;
|
|
27
47
|
hasMoreAbove: boolean;
|
|
28
48
|
hasMoreBelow: boolean;
|
|
49
|
+
canScroll: boolean;
|
|
29
50
|
} {
|
|
30
51
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
31
52
|
|
|
32
53
|
const maxScroll = Math.max(0, totalLines - visibleCount);
|
|
33
54
|
const halfPage = Math.max(1, Math.ceil(visibleCount / 2));
|
|
55
|
+
const canScroll = totalLines > visibleCount;
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setScrollOffset(0);
|
|
59
|
+
}, [resetKey]);
|
|
34
60
|
|
|
35
61
|
useInput((input, key) => {
|
|
62
|
+
// Nothing to scroll: leave every key to the surrounding UI instead of
|
|
63
|
+
// acting on it. The check lives inside the handler rather than in
|
|
64
|
+
// `useInput`'s `isActive`: the row count is measured one commit after the
|
|
65
|
+
// mount frame, and a handler subscribed only once the overflow is known
|
|
66
|
+
// would drop the keypresses that arrive in between (which is exactly when a
|
|
67
|
+
// user, seeing the content clipped, starts pressing PgDn/↓).
|
|
68
|
+
if (!canScroll) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
36
71
|
if (key.pageUp) {
|
|
37
72
|
setScrollOffset((offset) => Math.max(0, offset - visibleCount));
|
|
38
73
|
}
|
|
@@ -47,6 +82,16 @@ export function useLineScroll({
|
|
|
47
82
|
if (key.ctrl && input === "d") {
|
|
48
83
|
setScrollOffset((offset) => Math.min(maxScroll, offset + halfPage));
|
|
49
84
|
}
|
|
85
|
+
if (lineStep) {
|
|
86
|
+
// ↑/↓ scroll the viewport line by line; Ctrl+P/Ctrl+N are the same
|
|
87
|
+
// keys for terminals that swallow the arrows.
|
|
88
|
+
if (key.upArrow || (key.ctrl && input === "p")) {
|
|
89
|
+
setScrollOffset((offset) => Math.max(0, offset - lineStep));
|
|
90
|
+
}
|
|
91
|
+
if (key.downArrow || (key.ctrl && input === "n")) {
|
|
92
|
+
setScrollOffset((offset) => Math.min(maxScroll, offset + lineStep));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
50
95
|
});
|
|
51
96
|
|
|
52
97
|
const clampedOffset = Math.min(scrollOffset, maxScroll);
|
|
@@ -54,5 +99,6 @@ export function useLineScroll({
|
|
|
54
99
|
scrollOffset: clampedOffset,
|
|
55
100
|
hasMoreAbove: clampedOffset > 0,
|
|
56
101
|
hasMoreBelow: clampedOffset + visibleCount < totalLines,
|
|
102
|
+
canScroll,
|
|
57
103
|
};
|
|
58
104
|
}
|