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.
- package/dist/bundle/wave.mjs +713 -842
- package/package.json +7 -7
- package/src/cli.tsx +14 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +22 -15
- 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 -13
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- 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 +117 -3
- package/src/contexts/useChat.tsx +87 -165
- package/src/daemon/commands.ts +18 -18
- package/src/hooks/useInputManager.ts +24 -42
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +23 -1
- package/src/managers/inputHandlers.ts +4 -1
- package/src/managers/inputReducer.ts +39 -1
- package/src/remote/messageWindow.ts +18 -69
- package/src/remote/notificationCoalescer.ts +21 -3
- package/src/remote/relayClient.ts +48 -26
- package/src/remote/remoteControlCliHost.ts +72 -43
- package/src/remote/remoteControlConfig.ts +25 -6
- package/src/remote/remoteControlService.ts +93 -70
- package/src/remote/remoteControlSink.ts +4 -3
- package/src/remote/remoteNotifications.ts +8 -2
- package/src/stdio/agentBridge.ts +325 -91
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/uiPayload.ts +65 -0
- package/src/types.ts +7 -0
- package/src/utils/bracketedPaste.ts +0 -170
package/src/contexts/useChat.tsx
CHANGED
|
@@ -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
|
-
//
|
|
585
|
-
//
|
|
586
|
-
// Static output
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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 —
|
|
607
|
-
//
|
|
608
|
-
//
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
// `agent.messages`
|
|
613
|
-
|
|
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 —
|
|
924
|
-
//
|
|
925
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
package/src/daemon/commands.ts
CHANGED
|
@@ -135,7 +135,7 @@ function fail(message: string): never {
|
|
|
135
135
|
|
|
136
136
|
interface DaemonSessionEntry {
|
|
137
137
|
sessionId: string;
|
|
138
|
-
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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(
|
|
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(
|
|
1307
|
-
runGit(
|
|
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: ${
|
|
1313
|
+
`Cannot remove worktree: ${sessionCwd} is not inside a git repository`,
|
|
1314
1314
|
);
|
|
1315
1315
|
}
|
|
1316
|
-
const repoRoot = getGitMainRepoRoot(
|
|
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
|
-
*
|
|
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 {
|
|
1353
|
-
const wt = await resolveWorktreeForRemoval(info.
|
|
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
|
-
|
|
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
|
|
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,
|