wave-code 1.4.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 (41) hide show
  1. package/dist/bundle/wave.mjs +713 -842
  2. package/package.json +7 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/components/AgentsManager.tsx +3 -3
  5. package/src/components/App.tsx +5 -0
  6. package/src/components/BtwDisplay.tsx +71 -15
  7. package/src/components/CompactDisplay.tsx +1 -6
  8. package/src/components/ConfirmationDetails.tsx +22 -15
  9. package/src/components/ConfirmationSelector.tsx +31 -36
  10. package/src/components/DiffDisplay.tsx +1 -1
  11. package/src/components/HooksManager.tsx +3 -3
  12. package/src/components/InputBox.tsx +37 -13
  13. package/src/components/LoginCommand.tsx +16 -35
  14. package/src/components/Markdown.tsx +7 -3
  15. package/src/components/MarketplaceAddForm.tsx +15 -19
  16. package/src/components/RemoteControlView.tsx +175 -0
  17. package/src/components/ScrolledContent.tsx +15 -13
  18. package/src/components/SkillsManager.tsx +3 -3
  19. package/src/components/TaskList.tsx +3 -3
  20. package/src/components/ToolDisplay.tsx +3 -18
  21. package/src/components/WorkflowManager.tsx +117 -3
  22. package/src/contexts/useChat.tsx +87 -165
  23. package/src/daemon/commands.ts +18 -18
  24. package/src/hooks/useInputManager.ts +24 -42
  25. package/src/hooks/useLineScroll.ts +51 -5
  26. package/src/hooks/useRemoteControl.ts +23 -1
  27. package/src/managers/inputHandlers.ts +4 -1
  28. package/src/managers/inputReducer.ts +39 -1
  29. package/src/remote/messageWindow.ts +18 -69
  30. package/src/remote/notificationCoalescer.ts +21 -3
  31. package/src/remote/relayClient.ts +48 -26
  32. package/src/remote/remoteControlCliHost.ts +72 -43
  33. package/src/remote/remoteControlConfig.ts +25 -6
  34. package/src/remote/remoteControlService.ts +93 -70
  35. package/src/remote/remoteControlSink.ts +4 -3
  36. package/src/remote/remoteNotifications.ts +8 -2
  37. package/src/stdio/agentBridge.ts +325 -91
  38. package/src/stdio/hostSlashCommands.ts +209 -0
  39. package/src/stdio/uiPayload.ts +65 -0
  40. package/src/types.ts +7 -0
  41. package/src/utils/bracketedPaste.ts +0 -170
@@ -37,7 +37,6 @@ import {
37
37
  PLUGIN_CHANGE_PENDING_MESSAGE,
38
38
  PLUGIN_RELOADED_MESSAGE,
39
39
  } from "wave-agent-sdk";
40
- import { toString as qrToString } from "qrcode";
41
40
  import { logger } from "../utils/logger.js";
42
41
  import { displayUsageSummary } from "../utils/usageSummary.js";
43
42
  import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
@@ -47,54 +46,6 @@ import { readFile } from "node:fs/promises";
47
46
 
48
47
  import { BaseAppProps } from "../types.js";
49
48
 
50
- /** How long `/remote-control` waits for the relay's registration round trip. */
51
- const REMOTE_PAGE_WAIT_MS = 5_000;
52
- const REMOTE_PAGE_POLL_MS = 250;
53
-
54
- /**
55
- * Wait for the page address. It is assembled from this machine's channel key
56
- * after the relay registers it (an HTTP call) — nothing is pushed down the
57
- * WebSocket — so the first snapshot may not carry it yet.
58
- */
59
- async function waitForRemotePageUrl(
60
- api: RemoteControlApi,
61
- ): Promise<string | undefined> {
62
- const deadline = Date.now() + REMOTE_PAGE_WAIT_MS;
63
- while (Date.now() < deadline) {
64
- const snapshot = api.getSnapshot();
65
- if (snapshot.pageUrl) return snapshot.pageUrl;
66
- if (snapshot.state === "error") return undefined;
67
- await new Promise((resolve) => setTimeout(resolve, REMOTE_PAGE_POLL_MS));
68
- }
69
- return api.getSnapshot().pageUrl;
70
- }
71
-
72
- /**
73
- * The result block a TUI session shows: the same address and the same QR that
74
- * `wave remote-control on` prints, because that pair is the whole contract with
75
- * the phone — the two entry points must not diverge on how it is rendered.
76
- */
77
- async function describeRemotePage(pageUrl: string): Promise<string> {
78
- const lines = [
79
- "Remote control is on — this terminal's session is now served to your phone.",
80
- `Page: ${pageUrl}`,
81
- ];
82
- try {
83
- lines.push(
84
- "",
85
- await qrToString(pageUrl, {
86
- type: "utf8",
87
- errorCorrectionLevel: "L",
88
- }),
89
- );
90
- } catch {
91
- // The address above already carries the contract; a missing QR is not worth
92
- // failing the command over.
93
- }
94
- lines.push("", "Turn it off with /remote-control off");
95
- return lines.join("\n");
96
- }
97
-
98
49
  // Main Chat Context
99
50
  export interface PlanViewData {
100
51
  path?: string;
@@ -163,6 +114,10 @@ export interface ChatContextType {
163
114
  // Workflow runs
164
115
  workflowRuns: WorkflowRun[];
165
116
  stopWorkflowRun: (runId: string) => void;
117
+ saveWorkflowScript: (
118
+ runId: string,
119
+ options: { name: string; scope: "project" | "user"; overwrite?: boolean },
120
+ ) => Promise<{ name: string; path: string }>;
166
121
  // Tasks
167
122
  tasks: Task[];
168
123
  getBackgroundTaskOutput: (taskId: string) => {
@@ -192,13 +147,6 @@ export interface ChatContextType {
192
147
  * session that only exists here.
193
148
  */
194
149
  remoteControl: RemoteControlApi;
195
- /**
196
- * `/remote-control [on|off|status] [name]` — the TUI-side switch for the hook
197
- * above. Its CLI namesake talks to the daemon instead; the two are not
198
- * interchangeable, because a session that lives in this process is reachable
199
- * only from this process.
200
- */
201
- remoteControlCommand: (args?: string) => Promise<void>;
202
150
  // /plan overlay state + handler
203
151
  planView: PlanViewData | null;
204
152
  setPlanView: (view: PlanViewData | null) => void;
@@ -233,7 +181,7 @@ export interface ChatContextType {
233
181
  backgroundCurrentTask: () => void;
234
182
  // Remount functionality
235
183
  remountKey: number;
236
- forceRemount: () => void;
184
+ forceRemount: (options?: { preserveScrollback?: boolean }) => void;
237
185
  // Rewind functionality
238
186
  handleRewindSelect: (index: number) => Promise<void>;
239
187
  getFullMessageThread: () => Promise<{
@@ -431,6 +379,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
431
379
  version,
432
380
  model,
433
381
  mcpServers,
382
+ onClearOutput,
434
383
  }) => {
435
384
  const { restoreSessionId, continueLastSession } = useAppConfig();
436
385
  const { stdout } = useStdout();
@@ -581,14 +530,29 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
581
530
  // Remount state
582
531
  const [remountKey, setRemountKey] = useState(0);
583
532
 
584
- // Full terminal clear + remount so Ink's append-only <Static> re-renders.
585
- // Used on structural actions (/clear, /compact, rewind, ctrl-o) where stale
586
- // Static output must not linger on screen.
587
- const forceRemount = useCallback(() => {
588
- stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
589
- setRemountKey((prev) => prev + 1);
590
- });
591
- }, [stdout]);
533
+ // Remount so Ink's append-only <Static> re-renders from scratch. Used on
534
+ // structural actions (/clear, session switch, rewind, ctrl-o) where the
535
+ // already-written Static output no longer matches the message list.
536
+ //
537
+ // The re-render duplicates whatever is still on screen, so the default erases
538
+ // the screen *and* the terminal scrollback. `/clear` passes
539
+ // `preserveScrollback`: it only wants a fresh conversation, and keeping the
540
+ // old one scrollable above is the point of the command.
541
+ const forceRemount = useCallback(
542
+ (options?: { preserveScrollback?: boolean }) => {
543
+ const remount = () => setRemountKey((prev) => prev + 1);
544
+ if (options?.preserveScrollback && onClearOutput) {
545
+ // ink's own clear erases just the frame it drew, leaving scrollback
546
+ // intact — and resets the row bookkeeping a hand-written escape
547
+ // sequence cannot reach.
548
+ onClearOutput();
549
+ remount();
550
+ return;
551
+ }
552
+ stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", remount);
553
+ },
554
+ [onClearOutput, stdout],
555
+ );
592
556
 
593
557
  // Status metadata state
594
558
  const [workingDirectory, setWorkingDirectory] = useState("");
@@ -603,14 +567,17 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
603
567
  */
604
568
  const remoteNotifyRef = useRef<RemoteNotify | undefined>(undefined);
605
569
 
606
- // Full-list refresh — one-shot pull from the agent, used only for structural
607
- // changes (compact/clear/rewind/collapse/init). Streaming updates flow through
608
- // the incremental callbacks in initializeAgent below.
570
+ // Full-list refresh — used only for structural changes (compact/clear/rewind/
571
+ // collapse/init). Streaming updates flow through the incremental callbacks in
572
+ // initializeAgent below.
609
573
  const refreshMessages = useCallback(() => {
610
- if (!isExpandedRef.current && agentRef.current) {
611
- // Pull the full UI display stream (keeps pre-compaction history);
612
- // `agent.messages` would only expose the folded API context.
613
- const msgs = agentRef.current.displayMessages.map(snapshotMessage);
574
+ const agent = agentRef.current;
575
+ if (!isExpandedRef.current && agent) {
576
+ // `agent.messages` 是「最近一次压缩 + 其后消息」:CLI 屏幕只渲染最近几十
577
+ // 条、清屏重放也只放这些条数,压缩前的历史在这里没有任何读者,留着只是把
578
+ // 图片 base64 挂在堆上(spec ui/message-rendering-system.md「CLI 消息列表
579
+ // 只加载压缩之后」)。从未压缩过的会话这里就是整条线程。
580
+ const msgs = agent.messages.map(snapshotMessage);
614
581
  // The snapshot makes the SDK state authoritative: with no throttle
615
582
  // window there is nothing pending to cancel — every pre-pull update is
616
583
  // already applied and contained in `msgs`, and post-pull deltas append
@@ -807,6 +774,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
807
774
  setIsCompacting(isCompactingState);
808
775
  if (!isCompactingState) {
809
776
  setCompactionStream("");
777
+ // 压缩结束 = 上下文已经折叠成「摘要 + 保留轮次」:列表要跟着收缩,
778
+ // 否则这一轮之后仍挂着压缩前的全量(自动压缩不走 `/compact` 那条
779
+ // refresh 路径)。见 spec ui/message-rendering-system.md「CLI 消息
780
+ // 列表只加载压缩之后」。
781
+ refreshMessages();
810
782
  }
811
783
  },
812
784
  onCompactionContentUpdate: (content) => {
@@ -840,8 +812,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
840
812
  onModelChange: (model) => {
841
813
  setCurrentModelState(model);
842
814
  },
815
+ // CLI 的选择器只要名字,能力标识是 webview 输入卡片的事。
843
816
  onConfiguredModelsChange: (models) => {
844
- setConfiguredModels(models);
817
+ setConfiguredModels(models.map((m) => m.name));
845
818
  },
846
819
  onLoadingChange: (loading) => {
847
820
  setIsLoading(loading);
@@ -920,19 +893,22 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
920
893
  agent.setWorktreeSession(session);
921
894
  }
922
895
 
923
- // Get initial state — snapshot the SDK messages (never hold live
924
- // references; see snapshotMessage). Uses the full display stream so a
925
- // restored session shows pre-compaction history too.
896
+ // Get initial state — all synchronous reads, so a notification arriving
897
+ // mid-init cannot be overwritten by a stale read. The list is
898
+ // `agent.messages` (the API context): after compaction that is the
899
+ // summary plus the rounds the compaction kept, which is exactly what
900
+ // the CLI shows (spec ui/message-rendering-system.md「CLI 消息列表只加载
901
+ // 压缩之后」); a never-compacted session is the whole thread.
926
902
  setSessionId(agent.sessionId);
927
- setMessages(agent.displayMessages.map(snapshotMessage));
928
- setIsLoading(agent.isLoading);
903
+ setMessages(agent.messages.map(snapshotMessage));
929
904
  setLatestTotalTokens(extractLatestTotalTokens(agent.messages));
905
+ setIsLoading(agent.isLoading);
930
906
  setIsCommandRunning(agent.isCommandRunning);
931
907
  setIsCompacting(agent.isCompacting);
932
908
  setPermissionModeState(agent.getPermissionMode());
933
- setWorkingDirectory(agent.workingDirectory);
909
+ setWorkingDirectory(agent.currentWorkdir);
934
910
  setCurrentModelState(agent.getModelConfig().model || "");
935
- setConfiguredModels(agent.getConfiguredModels());
911
+ setConfiguredModels(agent.getConfiguredModels().map((m) => m.name));
936
912
  setMaxInputTokens(agent.getMaxInputTokens());
937
913
 
938
914
  // Get initial MCP servers state
@@ -984,6 +960,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
984
960
  model,
985
961
  initialPermissionMode,
986
962
  mcpServers,
963
+ refreshMessages,
987
964
  ],
988
965
  );
989
966
 
@@ -1115,7 +1092,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1115
1092
  const clearMessages = useCallback(async () => {
1116
1093
  await agentRef.current?.clearMessages();
1117
1094
  refreshMessages();
1118
- forceRemount();
1095
+ forceRemount({ preserveScrollback: true });
1119
1096
  }, [refreshMessages, forceRemount]);
1120
1097
 
1121
1098
  const compact = useCallback(
@@ -1249,7 +1226,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1249
1226
  }
1250
1227
 
1251
1228
  setSessionId(agent.sessionId);
1252
- setWorkingDirectory(agent.workingDirectory);
1229
+ setWorkingDirectory(agent.currentWorkdir);
1253
1230
  setIsLoading(agent.isLoading);
1254
1231
  refreshMessages();
1255
1232
  forceRemount();
@@ -1351,6 +1328,19 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1351
1328
  agentRef.current?.stopWorkflowRun(runId);
1352
1329
  }, []);
1353
1330
 
1331
+ const saveWorkflowScript = useCallback(
1332
+ async (
1333
+ runId: string,
1334
+ options: { name: string; scope: "project" | "user"; overwrite?: boolean },
1335
+ ) => {
1336
+ if (!agentRef.current) {
1337
+ throw new Error("Agent not available");
1338
+ }
1339
+ return agentRef.current.saveWorkflowScript(runId, options);
1340
+ },
1341
+ [],
1342
+ );
1343
+
1354
1344
  const hasSlashCommand = useCallback((commandId: string) => {
1355
1345
  if (!agentRef.current) return false;
1356
1346
  return agentRef.current.hasSlashCommand(commandId);
@@ -1445,6 +1435,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1445
1435
  // what keeps this the whole of the integration.
1446
1436
  const remoteControl = useRemoteControl({
1447
1437
  workdir: workdir ?? process.cwd(),
1438
+ // 会话身份取 agent 的会话起点(不随 bash `cd` 漂移),不是上面那个会动的 workdir。
1439
+ sessionCwd: () =>
1440
+ agentRef.current?.sessionWorkdir ?? workdir ?? process.cwd(),
1448
1441
  sessionId,
1449
1442
  messages,
1450
1443
  isLoading,
@@ -1475,6 +1468,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1475
1468
  renameSession: (title) => agentRef.current?.renameSession(title),
1476
1469
  compact: (instructions) => compact(instructions),
1477
1470
  slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
1471
+ // 手机页取首屏尾段 / 补差都读 agent 手上那份列表(压缩后的上下文),不去
1472
+ // 转录里找压缩前的历史。
1473
+ messagePage: (window) =>
1474
+ Promise.resolve(
1475
+ agentRef.current?.getMessagePage(window) ?? { messages: [] },
1476
+ ),
1477
+ currentThread: () =>
1478
+ Promise.resolve([...(agentRef.current?.messages ?? [])]),
1479
+ threadSummary: () =>
1480
+ agentRef.current?.getThreadSummary() ?? { messageCount: 0 },
1478
1481
  });
1479
1482
 
1480
1483
  // `initializeAgent` reads this ref when it runs, which is always in an effect
@@ -1483,87 +1486,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1483
1486
  // before anything can call the agent.
1484
1487
  remoteNotifyRef.current = remoteControl.notify;
1485
1488
 
1486
- /**
1487
- * `/remote-control [on|off|status] [name]` — offer this terminal's session to
1488
- * a phone. It is deliberately *not* a front end for `wave remote-control`:
1489
- * that subcommand drives the daemon, and this session exists only in this
1490
- * process, so the daemon could not serve it (spec: remote-control.md,
1491
- * "前台 CLI 会话也能被手机接管").
1492
- */
1493
- const remoteControlCommand = useCallback(
1494
- async (args?: string) => {
1495
- const showResult = (content: string) => {
1496
- setMessages((prev) => [
1497
- ...prev,
1498
- {
1499
- id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
1500
- role: "assistant",
1501
- timestamp: new Date().toISOString(),
1502
- blocks: [{ type: "text", content, stage: "end" }],
1503
- },
1504
- ]);
1505
- };
1506
-
1507
- const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
1508
- const action = parts[0]?.toLowerCase() ?? "";
1509
- if (
1510
- action &&
1511
- action !== "on" &&
1512
- action !== "off" &&
1513
- action !== "status"
1514
- ) {
1515
- showResult("Usage: /remote-control [on|off|status] [name]");
1516
- return;
1517
- }
1518
-
1519
- if (!remoteControl.enabled) {
1520
- showResult(
1521
- 'Remote control is unavailable in this process. Set "enableRemoteControl": true in ~/.wave/settings.json (or .wave/settings.json in the project), then restart the CLI — a process that is already running keeps the settings it started with.',
1522
- );
1523
- return;
1524
- }
1525
-
1526
- if (action === "off") {
1527
- remoteControl.disconnect();
1528
- showResult(
1529
- "Remote control is off — this terminal's session is no longer served.",
1530
- );
1531
- return;
1532
- }
1533
-
1534
- const current = remoteControl.getSnapshot();
1535
- const serving =
1536
- current.state === "online" || current.state === "connecting";
1537
- // A bare `/remote-control` means "start serving" the first time and
1538
- // "what is it doing?" afterwards, so a second one cannot silently dial
1539
- // again and cannot hide an address the user still needs.
1540
- if (action === "status" || (!action && serving)) {
1541
- showResult(
1542
- current.pageUrl
1543
- ? await describeRemotePage(current.pageUrl)
1544
- : `Remote control status: ${current.state}${
1545
- current.message ? ` — ${current.message}` : ""
1546
- }`,
1547
- );
1548
- return;
1549
- }
1550
-
1551
- remoteControl.connect(parts[1]);
1552
- const pageUrl = await waitForRemotePageUrl(remoteControl);
1553
- const after = remoteControl.getSnapshot();
1554
- if (!pageUrl) {
1555
- showResult(
1556
- after.state === "error"
1557
- ? `Remote control failed: ${after.message ?? "unknown error"}`
1558
- : "Connected to the relay, but no page address came back yet — run /remote-control status in a moment.",
1559
- );
1560
- return;
1561
- }
1562
- showResult(await describeRemotePage(pageUrl));
1563
- },
1564
- [remoteControl],
1565
- );
1566
-
1567
1489
  const backgroundCurrentTask = useCallback(() => {
1568
1490
  agentRef.current?.backgroundCurrentTask();
1569
1491
  }, []);
@@ -1608,7 +1530,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1608
1530
  if (!agentRef.current) {
1609
1531
  return [];
1610
1532
  }
1611
- return agentRef.current.getConfiguredModels();
1533
+ return agentRef.current.getConfiguredModels().map((m) => m.name);
1612
1534
  }, []);
1613
1535
 
1614
1536
  const setModel = useCallback((model: string) => {
@@ -1682,6 +1604,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1682
1604
  backgroundTasks,
1683
1605
  workflowRuns,
1684
1606
  stopWorkflowRun,
1607
+ saveWorkflowScript,
1685
1608
  tasks,
1686
1609
  getBackgroundTaskOutput,
1687
1610
  stopBackgroundTask,
@@ -1693,7 +1616,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1693
1616
  permissionMode,
1694
1617
  setPermissionMode,
1695
1618
  remoteControl,
1696
- remoteControlCommand,
1697
1619
  planView,
1698
1620
  setPlanView,
1699
1621
  handlePlanCommand,
@@ -135,7 +135,7 @@ function fail(message: string): never {
135
135
 
136
136
  interface DaemonSessionEntry {
137
137
  sessionId: string;
138
- workingDirectory: string;
138
+ sessionCwd: string;
139
139
  isLoading: boolean;
140
140
  /**
141
141
  * The other two halves of the idle predicate `wait` / `status` share with
@@ -232,7 +232,7 @@ async function attachSession(
232
232
  */
233
233
  onInitialized?: (sessionId: string) => void;
234
234
  } = {},
235
- ): Promise<{ sessionId: string; workingDirectory: string }> {
235
+ ): Promise<{ sessionId: string; currentWorkdir: string }> {
236
236
  const init = (await client.request("initialize", {
237
237
  workdir: process.cwd(),
238
238
  restoreSessionId: sessionId,
@@ -240,7 +240,7 @@ async function attachSession(
240
240
  // re-hosting client is the CLI, and the session file carries no record of
241
241
  // its original creator (see the entrypoint story in the telemetry spec).
242
242
  entrypoint: "cli",
243
- })) as { sessionId: string; workingDirectory: string };
243
+ })) as { sessionId: string; currentWorkdir: string };
244
244
  const initId = init.sessionId;
245
245
  options.onInitialized?.(initId);
246
246
  try {
@@ -398,7 +398,7 @@ export async function daemonListCommand(socketPath: string): Promise<void> {
398
398
  // owns running background work.
399
399
  status: isIdleSession(s) ? "idle" : "generating",
400
400
  messageCount: String(s.messageCount),
401
- workingDirectory: s.workingDirectory,
401
+ sessionCwd: s.sessionCwd,
402
402
  }));
403
403
  const width = (key: keyof (typeof rows)[number]) =>
404
404
  Math.max(...rows.map((r) => r[key].length), key.length);
@@ -409,7 +409,7 @@ export async function daemonListCommand(socketPath: string): Promise<void> {
409
409
  );
410
410
  for (const r of rows) {
411
411
  console.log(
412
- `${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}`,
413
413
  );
414
414
  }
415
415
  } else {
@@ -532,7 +532,7 @@ function parseAskUserQuestionAnswer(
532
532
 
533
533
  interface SessionSnapshot {
534
534
  sessionId: string;
535
- workingDirectory: string;
535
+ currentWorkdir: string;
536
536
  /** `idle` / `generating` / `waiting for approval`. */
537
537
  status: string;
538
538
  pending: PendingPermission[];
@@ -552,7 +552,7 @@ interface SessionSnapshot {
552
552
  */
553
553
  function renderSessionSnapshot(snapshot: SessionSnapshot): void {
554
554
  console.log(`Session: ${snapshot.sessionId}`);
555
- console.log(`Working directory: ${snapshot.workingDirectory}`);
555
+ console.log(`Working directory: ${snapshot.currentWorkdir}`);
556
556
  console.log(`Status: ${snapshot.status}`);
557
557
 
558
558
  if (snapshot.pending.length > 0) {
@@ -657,7 +657,7 @@ export async function daemonStatusCommand(
657
657
  : "idle";
658
658
  renderSessionSnapshot({
659
659
  sessionId: initId,
660
- workingDirectory: init.workingDirectory,
660
+ currentWorkdir: init.currentWorkdir,
661
661
  status,
662
662
  pending,
663
663
  messages: messages.messages,
@@ -875,7 +875,7 @@ export async function daemonWaitCommand(
875
875
  };
876
876
  renderSessionSnapshot({
877
877
  sessionId: watch.id,
878
- workingDirectory: init.workingDirectory,
878
+ currentWorkdir: init.currentWorkdir,
879
879
  status,
880
880
  pending,
881
881
  messages: result.messages ?? [],
@@ -1284,7 +1284,7 @@ function runGit(cwd: string, args: string[]): Promise<string> {
1284
1284
  }
1285
1285
 
1286
1286
  /**
1287
- * Resolve the worktree hosting `workingDirectory` for removal, mirroring the
1287
+ * Resolve the worktree hosting `sessionCwd` for removal, mirroring the
1288
1288
  * values protocol createWorktree returns: worktree path via
1289
1289
  * `git rev-parse --show-toplevel`, branch via `git branch --show-current`, and
1290
1290
  * repoRoot = the MAIN repo root (first entry of `git worktree list` — where
@@ -1293,7 +1293,7 @@ function runGit(cwd: string, args: string[]): Promise<string> {
1293
1293
  * check would pass trivially while removeWorktree's fs.rmSync fallback could
1294
1294
  * delete the whole repository.
1295
1295
  */
1296
- async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
1296
+ async function resolveWorktreeForRemoval(sessionCwd: string): Promise<{
1297
1297
  path: string;
1298
1298
  branch: string;
1299
1299
  repoRoot: string;
@@ -1303,17 +1303,17 @@ async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
1303
1303
  let branch: string;
1304
1304
  try {
1305
1305
  const [toplevel, current] = await Promise.all([
1306
- runGit(workingDirectory, ["rev-parse", "--show-toplevel"]),
1307
- runGit(workingDirectory, ["branch", "--show-current"]),
1306
+ runGit(sessionCwd, ["rev-parse", "--show-toplevel"]),
1307
+ runGit(sessionCwd, ["branch", "--show-current"]),
1308
1308
  ]);
1309
1309
  worktreePath = toplevel;
1310
1310
  branch = current;
1311
1311
  } catch {
1312
1312
  throw new Error(
1313
- `Cannot remove worktree: ${workingDirectory} is not inside a git repository`,
1313
+ `Cannot remove worktree: ${sessionCwd} is not inside a git repository`,
1314
1314
  );
1315
1315
  }
1316
- const repoRoot = getGitMainRepoRoot(workingDirectory);
1316
+ const repoRoot = getGitMainRepoRoot(sessionCwd);
1317
1317
  if (path.resolve(worktreePath) === path.resolve(repoRoot)) {
1318
1318
  throw new Error(
1319
1319
  `Refusing to remove the main working tree: ${repoRoot} (not a linked worktree)`,
@@ -1333,7 +1333,7 @@ async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
1333
1333
  * abort there is no attach step: destroy is a pure registry operation keyed by
1334
1334
  * the envelope sessionId, so unknown sessions succeed without touching disk.
1335
1335
  * With --remove-worktree the session's git worktree is resolved from its
1336
- * workingDirectory (getSessionInfo) and removed via protocol removeWorktree
1336
+ * sessionCwd (getSessionInfo) and removed via protocol removeWorktree
1337
1337
  * first, then the session is destroyed.
1338
1338
  */
1339
1339
  export async function daemonDestroyCommand(
@@ -1349,8 +1349,8 @@ export async function daemonDestroyCommand(
1349
1349
  "getSessionInfo",
1350
1350
  undefined,
1351
1351
  sessionId,
1352
- )) as { workingDirectory: string };
1353
- const wt = await resolveWorktreeForRemoval(info.workingDirectory);
1352
+ )) as { sessionCwd: string };
1353
+ const wt = await resolveWorktreeForRemoval(info.sessionCwd);
1354
1354
  await client.request("removeWorktree", {
1355
1355
  path: wt.path,
1356
1356
  branch: wt.branch,
@@ -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.
@@ -64,7 +58,6 @@ export const useInputManager = (
64
58
  onReloadPlugins,
65
59
  onAddDir,
66
60
  onRenameSession,
67
- onRemoteControlCommand,
68
61
  onPlanCommand,
69
62
  isIdle: isIdleProp,
70
63
  } = callbacks;
@@ -292,7 +285,7 @@ export const useInputManager = (
292
285
  } else if (command === "rename") {
293
286
  await onRenameSession?.(effect.args);
294
287
  } else if (command === "remote-control") {
295
- await onRemoteControlCommand?.(effect.args);
288
+ dispatch({ type: "SET_SHOW_REMOTE_CONTROL", payload: true });
296
289
  } else if (command === "plan") {
297
290
  await onPlanCommand?.(effect.args);
298
291
  }
@@ -326,7 +319,6 @@ export const useInputManager = (
326
319
  onReloadPlugins,
327
320
  onAddDir,
328
321
  onRenameSession,
329
- onRemoteControlCommand,
330
322
  onPlanCommand,
331
323
  ]);
332
324
 
@@ -537,6 +529,10 @@ export const useInputManager = (
537
529
  dispatch({ type: "SET_SHOW_STATUS_COMMAND", payload: show });
538
530
  }, []);
539
531
 
532
+ const setShowRemoteControl = useCallback((show: boolean) => {
533
+ dispatch({ type: "SET_SHOW_REMOTE_CONTROL", payload: show });
534
+ }, []);
535
+
540
536
  const setShowLoginCommand = useCallback((show: boolean) => {
541
537
  dispatch({ type: "SET_SHOW_LOGIN_COMMAND", payload: show });
542
538
  }, []);
@@ -632,44 +628,27 @@ export const useInputManager = (
632
628
  dispatch({ type: "CLEAR_LONG_TEXT_MAP" });
633
629
  }, []);
634
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
+
635
646
  const handleInput = useCallback(
636
647
  async (input: string, key: Key) => {
637
- const result = pasteDetectorRef.current.process(input);
638
-
639
- if (result.kind === "consume") {
640
- // Content of an in-flight bracketed paste (or an empty paste):
641
- // hold it, never submit or insert prematurely.
642
- return true;
643
- }
644
-
645
- if (result.kind === "paste") {
646
- if (result.leadingInput) {
647
- dispatch({
648
- type: "HANDLE_KEY",
649
- payload: {
650
- input: result.leadingInput,
651
- key,
652
- hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
653
- hasQueuedMessages: hasQueuedMessagesProp ?? false,
654
- isIdle: isIdleProp ?? false,
655
- },
656
- });
657
- }
658
- if (result.text !== "") {
659
- // Insert-only: \r → \n normalizes CRLF terminals, matching the
660
- // canonical paste path (inputHandlers.handlePasteInput).
661
- dispatch({
662
- type: "INSERT_TEXT_WITH_PLACEHOLDER",
663
- payload: result.text.replace(/\r/g, "\n"),
664
- });
665
- }
666
- return true;
667
- }
668
-
669
648
  dispatch({
670
649
  type: "HANDLE_KEY",
671
650
  payload: {
672
- input: result.input,
651
+ input,
673
652
  key,
674
653
  hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
675
654
  hasQueuedMessages: hasQueuedMessagesProp ?? false,
@@ -702,6 +681,7 @@ export const useInputManager = (
702
681
  showResumeSelector: state.showResumeSelector,
703
682
  showHelp: state.showHelp,
704
683
  showStatusCommand: state.showStatusCommand,
684
+ showRemoteControl: state.showRemoteControl,
705
685
  showLoginCommand: state.showLoginCommand,
706
686
  showPluginManager: state.showPluginManager,
707
687
  showModelSelector: state.showModelSelector,
@@ -750,6 +730,7 @@ export const useInputManager = (
750
730
  setShowRewindManager,
751
731
  setShowHelp,
752
732
  setShowStatusCommand,
733
+ setShowRemoteControl,
753
734
  setShowLoginCommand,
754
735
  setShowPluginManager,
755
736
  setShowModelSelector,
@@ -768,6 +749,7 @@ export const useInputManager = (
768
749
 
769
750
  // Paste and text handling
770
751
  handlePasteInput,
752
+ insertPastedText,
771
753
  handleSubmit,
772
754
  expandLongTextPlaceholders,
773
755
  clearLongTextMap,