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.
Files changed (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. package/src/utils/bracketedPaste.ts +0 -170
@@ -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(socketPath: string): Promise<SocketClient> {
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
- workingDirectory: string;
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; workingDirectory: 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
- })) as { sessionId: string; workingDirectory: string };
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
- workingDirectory: s.workingDirectory,
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.workingDirectory}`,
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
- workingDirectory: string;
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.workingDirectory}`);
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
- workingDirectory: init.workingDirectory,
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
- workingDirectory: init.workingDirectory,
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 `workingDirectory` for removal, mirroring the
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(workingDirectory: string): Promise<{
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(workingDirectory, ["rev-parse", "--show-toplevel"]),
1298
- runGit(workingDirectory, ["branch", "--show-current"]),
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: ${workingDirectory} is not inside a git repository`,
1313
+ `Cannot remove worktree: ${sessionCwd} is not inside a git repository`,
1305
1314
  );
1306
1315
  }
1307
- const repoRoot = getGitMainRepoRoot(workingDirectory);
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
- * workingDirectory (getSessionInfo) and removed via protocol removeWorktree
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 { workingDirectory: string };
1344
- const wt = await resolveWorktreeForRemoval(info.workingDirectory);
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 tryConnectDaemon(
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 tryConnectDaemon(socketPath);
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 tryConnectDaemon(socketPath);
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
- const server = new DaemonServer({ socketPath });
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: result.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). Returns the clamped offset and the ↑/↓ indicator
16
- * flags so callers can render "N more" lines.
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 content),
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
  }