wave-code 1.5.1 → 1.6.1
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/README.md +3 -2
- package/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +735 -686
- package/package.json +8 -10
- package/src/cli.tsx +59 -3
- package/src/components/App.tsx +13 -13
- package/src/components/ChatInterface.tsx +4 -2
- package/src/components/InputBox.tsx +6 -3
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/Notifications.tsx +6 -3
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/contexts/useChat.tsx +88 -43
- package/src/daemon/commands.ts +266 -44
- package/src/daemon/daemonLog.ts +132 -0
- package/src/daemon/socketPath.ts +123 -0
- package/src/daemon-cli.ts +19 -6
- package/src/hooks/useExitSignals.ts +83 -0
- package/src/hooks/useRemoteControl.ts +16 -4
- package/src/index.ts +6 -0
- package/src/print-cli.ts +20 -10
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +5 -4
- package/src/remote/notificationCoalescer.ts +2 -2
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +46 -8
- package/src/remote/remoteControlService.ts +75 -18
- package/src/remote/remoteNotifications.ts +31 -9
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +407 -72
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/startupStage.ts +22 -0
- package/src/stdio/uiPayload.ts +49 -0
- package/src/stdio-cli.ts +9 -1
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +1 -1
- package/src/utils/worktree.ts +222 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "CLI-based code assistant powered by AI, built with React and Ink",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,9 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@anthropic-ai/sandbox-runtime": "^0.0.81",
|
|
32
|
-
"
|
|
33
|
-
"qrcode": "^1.5.4",
|
|
34
|
-
"sharp": "^0.35.4"
|
|
32
|
+
"qrcode": "^1.5.4"
|
|
35
33
|
},
|
|
36
34
|
"devDependencies": {
|
|
37
35
|
"@types/qrcode": "^1.5.6",
|
|
@@ -59,7 +57,7 @@
|
|
|
59
57
|
"wrap-ansi": "^10.0.0",
|
|
60
58
|
"yargs": "^17.7.2",
|
|
61
59
|
"zod": "^3.23.8",
|
|
62
|
-
"wave-agent-sdk": "1.
|
|
60
|
+
"wave-agent-sdk": "1.6.1"
|
|
63
61
|
},
|
|
64
62
|
"engines": {
|
|
65
63
|
"node": ">=22"
|
|
@@ -69,11 +67,11 @@
|
|
|
69
67
|
"wave": "tsx --tsconfig tsconfig.dev.json src/index.ts",
|
|
70
68
|
"build": "rimraf dist && node scripts/bundle.mjs",
|
|
71
69
|
"type-check": "tsc --noEmit --incremental",
|
|
72
|
-
"test": "vitest run
|
|
73
|
-
"test:coverage": "vitest run --coverage
|
|
74
|
-
"test:unit": "vitest run --
|
|
75
|
-
"test:unit:coverage": "vitest run --coverage --
|
|
76
|
-
"test:integration": "vitest run
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:coverage": "vitest run --coverage",
|
|
72
|
+
"test:unit": "vitest run --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
|
|
73
|
+
"test:unit:coverage": "vitest run --coverage --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
|
|
74
|
+
"test:integration": "vitest run tests/integration .integration.test",
|
|
77
75
|
"lint": "oxlint",
|
|
78
76
|
"format": "prettier --write ."
|
|
79
77
|
}
|
package/src/cli.tsx
CHANGED
|
@@ -2,10 +2,51 @@ import React from "react";
|
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { ensureRuntimeDeps } from "wave-agent-sdk";
|
|
4
4
|
import { App } from "./components/App.js";
|
|
5
|
+
import { getPendingAgentDestroy } from "./contexts/useChat.js";
|
|
5
6
|
import { cleanupLogs } from "./utils/logger.js";
|
|
6
|
-
import { removeWorktree } from "./utils/worktree.js";
|
|
7
|
+
import { describeWorktreeSurvival, removeWorktree } from "./utils/worktree.js";
|
|
7
8
|
import { BaseAppProps } from "./types.js";
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Upper bound on waiting for `Agent.destroy()` after the UI is gone.
|
|
12
|
+
*
|
|
13
|
+
* The teardown is bounded on its own side (its async-work drain has a timeout),
|
|
14
|
+
* but a wedged MCP child or LSP server must not turn closing the window into a
|
|
15
|
+
* process that never exits. Past this point the orphan risk is accepted in
|
|
16
|
+
* exchange for a terminal that actually comes back.
|
|
17
|
+
*/
|
|
18
|
+
const AGENT_DESTROY_TIMEOUT_MS = 5_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Await the teardown that the unmount cleanup started.
|
|
22
|
+
*
|
|
23
|
+
* React effect cleanups are synchronous, so unmounting only *begins*
|
|
24
|
+
* `Agent.destroy()`; without this wait, `process.exit()` below terminates the
|
|
25
|
+
* process while the bash process trees, MCP children and LSP servers that
|
|
26
|
+
* `destroy()` was about to signal are still alive. Detached bash children then
|
|
27
|
+
* outlive their parent as launchd-reparented orphans.
|
|
28
|
+
*/
|
|
29
|
+
async function awaitAgentDestroy(): Promise<void> {
|
|
30
|
+
const pending = getPendingAgentDestroy();
|
|
31
|
+
if (!pending) return;
|
|
32
|
+
|
|
33
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
34
|
+
const timeout = new Promise<void>((resolve) => {
|
|
35
|
+
timeoutHandle = setTimeout(() => {
|
|
36
|
+
console.warn(
|
|
37
|
+
"Agent teardown timed out; exiting without waiting further.",
|
|
38
|
+
);
|
|
39
|
+
resolve();
|
|
40
|
+
}, AGENT_DESTROY_TIMEOUT_MS);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await Promise.race([pending, timeout]);
|
|
45
|
+
} finally {
|
|
46
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
9
50
|
export interface CliOptions extends BaseAppProps {
|
|
10
51
|
restoreSessionId?: string;
|
|
11
52
|
continueLastSession?: boolean;
|
|
@@ -40,8 +81,13 @@ export async function startCli(options: CliOptions): Promise<void> {
|
|
|
40
81
|
|
|
41
82
|
// Continue with ink-based UI for normal mode
|
|
42
83
|
let shouldRemoveWorktree = false;
|
|
84
|
+
let isExiting = false;
|
|
43
85
|
|
|
44
86
|
const handleExit = (shouldRemove: boolean) => {
|
|
87
|
+
// A single close can arrive twice (SIGHUP plus the TTY-revocation poll, or
|
|
88
|
+
// an outside kill racing the keypress). Only the first one may unmount.
|
|
89
|
+
if (isExiting) return;
|
|
90
|
+
isExiting = true;
|
|
45
91
|
shouldRemoveWorktree = shouldRemove;
|
|
46
92
|
unmount();
|
|
47
93
|
};
|
|
@@ -83,6 +129,10 @@ export async function startCli(options: CliOptions): Promise<void> {
|
|
|
83
129
|
// Wait for the app to finish unmounting
|
|
84
130
|
await waitUntilExit();
|
|
85
131
|
|
|
132
|
+
// ...which only *started* the agent teardown. Let it finish before exiting,
|
|
133
|
+
// otherwise the bash trees it was about to signal survive as orphans.
|
|
134
|
+
await awaitAgentDestroy();
|
|
135
|
+
|
|
86
136
|
try {
|
|
87
137
|
// Clean up old log files
|
|
88
138
|
await cleanupLogs().catch((error) => {
|
|
@@ -96,8 +146,14 @@ export async function startCli(options: CliOptions): Promise<void> {
|
|
|
96
146
|
if (shouldRemoveWorktree && worktreeSession) {
|
|
97
147
|
process.stdout.write("\nDeleting worktree ...\n");
|
|
98
148
|
process.chdir(worktreeSession.repoRoot);
|
|
99
|
-
|
|
100
|
-
|
|
149
|
+
// "Done." only when it is: removal is best-effort, and a surviving
|
|
150
|
+
// directory (with its branch kept for it) needs the cleanup commands
|
|
151
|
+
// printed rather than a success line over the leftovers.
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
(await removeWorktree(worktreeSession))
|
|
154
|
+
? "Done.\n"
|
|
155
|
+
: `\n${describeWorktreeSurvival(worktreeSession)}\n`,
|
|
156
|
+
);
|
|
101
157
|
}
|
|
102
158
|
|
|
103
159
|
process.exit(0);
|
package/src/components/App.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useState,
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
2
|
import { useInput } from "ink";
|
|
3
3
|
import { ChatInterface } from "./ChatInterface.js";
|
|
4
4
|
import { ChatProvider } from "../contexts/useChat.js";
|
|
5
5
|
import { AppProvider } from "../contexts/useAppConfig.js";
|
|
6
6
|
import { WorktreeExitPrompt } from "./WorktreeExitPrompt.js";
|
|
7
|
+
import { useExitSignals } from "../hooks/useExitSignals.js";
|
|
7
8
|
import {
|
|
8
9
|
hasUncommittedChanges,
|
|
9
10
|
hasNewCommits,
|
|
@@ -58,18 +59,11 @@ const ChatWithExitPrompt: React.FC<{
|
|
|
58
59
|
}
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
process.on("SIGTERM", onSigTerm);
|
|
67
|
-
|
|
68
|
-
return () => {
|
|
69
|
-
process.off("SIGINT", onSigInt);
|
|
70
|
-
process.off("SIGTERM", onSigTerm);
|
|
71
|
-
};
|
|
72
|
-
}, [handleSignal]);
|
|
62
|
+
// Terminal close / outside kill: the worktree prompt still applies, because
|
|
63
|
+
// the same uncommitted-changes check has to run for an exit nobody typed.
|
|
64
|
+
useExitSignals(() => {
|
|
65
|
+
void handleSignal();
|
|
66
|
+
});
|
|
73
67
|
|
|
74
68
|
if (isExiting && worktreeStatus) {
|
|
75
69
|
return (
|
|
@@ -116,6 +110,12 @@ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
|
|
|
116
110
|
}
|
|
117
111
|
});
|
|
118
112
|
|
|
113
|
+
// An outside kill or a terminal close never reaches `useInput` — it arrives
|
|
114
|
+
// as a signal, or on macOS as nothing at all. With a worktree, the prompt
|
|
115
|
+
// component owns that handler (it has uncommitted changes to check first), so
|
|
116
|
+
// this registration stays out of the way.
|
|
117
|
+
useExitSignals(() => onExit(false), !worktreeSession);
|
|
118
|
+
|
|
119
119
|
if (worktreeSession) {
|
|
120
120
|
return (
|
|
121
121
|
<ChatProvider
|
|
@@ -26,7 +26,8 @@ export const ChatInterface: React.FC = () => {
|
|
|
26
26
|
isExpanded,
|
|
27
27
|
sessionId,
|
|
28
28
|
latestTotalTokens,
|
|
29
|
-
|
|
29
|
+
contextWindow,
|
|
30
|
+
maxOutputTokens,
|
|
30
31
|
slashCommands,
|
|
31
32
|
hasSlashCommand,
|
|
32
33
|
hooks,
|
|
@@ -112,7 +113,8 @@ export const ChatInterface: React.FC = () => {
|
|
|
112
113
|
hasSlashCommand={hasSlashCommand}
|
|
113
114
|
hooks={hooks}
|
|
114
115
|
latestTotalTokens={latestTotalTokens}
|
|
115
|
-
|
|
116
|
+
contextWindow={contextWindow}
|
|
117
|
+
maxOutputTokens={maxOutputTokens}
|
|
116
118
|
showLoginHint={showLoginHint}
|
|
117
119
|
/>
|
|
118
120
|
</>
|
|
@@ -63,7 +63,8 @@ export interface InputBoxProps {
|
|
|
63
63
|
>;
|
|
64
64
|
// Token usage
|
|
65
65
|
latestTotalTokens?: number;
|
|
66
|
-
|
|
66
|
+
contextWindow?: number;
|
|
67
|
+
maxOutputTokens?: number;
|
|
67
68
|
// Login hint
|
|
68
69
|
showLoginHint?: boolean;
|
|
69
70
|
}
|
|
@@ -81,7 +82,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
81
82
|
hasSlashCommand = () => false,
|
|
82
83
|
hooks = {},
|
|
83
84
|
latestTotalTokens = 0,
|
|
84
|
-
|
|
85
|
+
contextWindow = 200000,
|
|
86
|
+
maxOutputTokens = 32000,
|
|
85
87
|
showLoginHint = false,
|
|
86
88
|
}) => {
|
|
87
89
|
const {
|
|
@@ -509,7 +511,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
509
511
|
/>
|
|
510
512
|
<Notifications
|
|
511
513
|
latestTotalTokens={latestTotalTokens}
|
|
512
|
-
|
|
514
|
+
contextWindow={contextWindow}
|
|
515
|
+
maxOutputTokens={maxOutputTokens}
|
|
513
516
|
showLoginHint={showLoginHint}
|
|
514
517
|
pluginHint={pluginHint}
|
|
515
518
|
/>
|
|
@@ -157,6 +157,26 @@ export const McpManager: React.FC<McpManagerProps> = ({
|
|
|
157
157
|
</Text>
|
|
158
158
|
</Box>
|
|
159
159
|
)}
|
|
160
|
+
{/* Not dialed yet (lazy dial): the tool list comes from the local
|
|
161
|
+
snapshot of the last successful connection, and says so. */}
|
|
162
|
+
{selectedServer.status !== "connected" &&
|
|
163
|
+
selectedServer.toolsFromCache && (
|
|
164
|
+
<Box>
|
|
165
|
+
<Text>
|
|
166
|
+
<Text color="blue">Tools:</Text>{" "}
|
|
167
|
+
{selectedServer.toolsFromCache.length} tools{" "}
|
|
168
|
+
<Text color="gray" dimColor>
|
|
169
|
+
(cached
|
|
170
|
+
{selectedServer.toolsCachedAt
|
|
171
|
+
? ` · last connected ${formatTime(
|
|
172
|
+
selectedServer.toolsCachedAt,
|
|
173
|
+
)}`
|
|
174
|
+
: ""}
|
|
175
|
+
)
|
|
176
|
+
</Text>
|
|
177
|
+
</Text>
|
|
178
|
+
</Box>
|
|
179
|
+
)}
|
|
160
180
|
{selectedServer.capabilities && (
|
|
161
181
|
<Box>
|
|
162
182
|
<Text>
|
|
@@ -270,6 +290,12 @@ export const McpManager: React.FC<McpManagerProps> = ({
|
|
|
270
290
|
{server.status === "connected" && server.toolCount && (
|
|
271
291
|
<Text color="green"> · {server.toolCount} tools</Text>
|
|
272
292
|
)}
|
|
293
|
+
{server.status !== "connected" && server.toolsFromCache && (
|
|
294
|
+
<Text color="gray" dimColor>
|
|
295
|
+
{" "}
|
|
296
|
+
· {server.toolsFromCache.length} tools (cached)
|
|
297
|
+
</Text>
|
|
298
|
+
)}
|
|
273
299
|
</Text>
|
|
274
300
|
{index === state.selectedIndex && (
|
|
275
301
|
<Box marginLeft={4} flexDirection="column">
|
|
@@ -41,9 +41,21 @@ export const MessageBlockItem = ({
|
|
|
41
41
|
</Box>
|
|
42
42
|
)}
|
|
43
43
|
|
|
44
|
-
{
|
|
44
|
+
{/* A line for the human, never sent to the model (see NoticeBlock in the
|
|
45
|
+
SDK): a failed turn (`error`, red) or a hook's status line (`info`,
|
|
46
|
+
muted). */}
|
|
47
|
+
{block.type === "notice" && (
|
|
45
48
|
<Box>
|
|
46
|
-
|
|
49
|
+
{block.level === "error" ? (
|
|
50
|
+
<Text color="red">Error: {block.content}</Text>
|
|
51
|
+
) : (
|
|
52
|
+
<Text
|
|
53
|
+
color={block.level === "warning" ? "yellow" : "gray"}
|
|
54
|
+
dimColor
|
|
55
|
+
>
|
|
56
|
+
{block.content}
|
|
57
|
+
</Text>
|
|
58
|
+
)}
|
|
47
59
|
</Box>
|
|
48
60
|
)}
|
|
49
61
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { percentOf } from "../remote/remoteNotifications.js";
|
|
3
4
|
|
|
4
5
|
export interface NotificationsProps {
|
|
5
6
|
latestTotalTokens?: number;
|
|
6
|
-
|
|
7
|
+
contextWindow?: number;
|
|
8
|
+
maxOutputTokens?: number;
|
|
7
9
|
showLoginHint?: boolean;
|
|
8
10
|
/**
|
|
9
11
|
* 一次性中性提示(插件变更待应用 / 重载完成)。由宿主显式设置并在数秒后清除,
|
|
@@ -14,13 +16,14 @@ export interface NotificationsProps {
|
|
|
14
16
|
|
|
15
17
|
export const Notifications: React.FC<NotificationsProps> = ({
|
|
16
18
|
latestTotalTokens = 0,
|
|
17
|
-
|
|
19
|
+
contextWindow = 200000,
|
|
20
|
+
maxOutputTokens = 32000,
|
|
18
21
|
showLoginHint = false,
|
|
19
22
|
pluginHint = null,
|
|
20
23
|
}) => {
|
|
21
24
|
const percentage =
|
|
22
25
|
latestTotalTokens > 0
|
|
23
|
-
?
|
|
26
|
+
? percentOf(latestTotalTokens, contextWindow, maxOutputTokens)
|
|
24
27
|
: 0;
|
|
25
28
|
|
|
26
29
|
const contextColor =
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RemoteControlView — `/remote-control` 的覆盖层(把这条前台会话交给手机)。
|
|
3
3
|
*
|
|
4
|
-
* 规格:docs/specs/
|
|
4
|
+
* 规格:docs/specs/remote/remote-web.md「前台 CLI 会话也能被手机接管」。
|
|
5
5
|
*
|
|
6
6
|
* **这个斜杠命令不带参数**:没有 `on` / `off` / `status` 子命令,也不收机器名。
|
|
7
7
|
* 判据是「状态自己决定动作」——`off` / `error` 时唯一合法动作是开启,`connecting`
|
package/src/contexts/useChat.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../hooks/useRemoteControl.js";
|
|
15
15
|
import type {
|
|
16
16
|
Message,
|
|
17
|
+
MessageBlock,
|
|
17
18
|
McpServerStatus,
|
|
18
19
|
BackgroundTask,
|
|
19
20
|
Task,
|
|
@@ -100,7 +101,8 @@ export interface ChatContextType {
|
|
|
100
101
|
recallQueuedMessage: () => QueuedMessage | null;
|
|
101
102
|
removeQueuedMessageById: (id: string) => boolean;
|
|
102
103
|
latestTotalTokens: number;
|
|
103
|
-
|
|
104
|
+
contextWindow: number;
|
|
105
|
+
maxOutputTokens: number;
|
|
104
106
|
// Model functionality
|
|
105
107
|
currentModel: string;
|
|
106
108
|
configuredModels: string[];
|
|
@@ -201,6 +203,23 @@ export interface ChatContextType {
|
|
|
201
203
|
|
|
202
204
|
const ChatContext = createContext<ChatContextType | null>(null);
|
|
203
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Handoff for the agent teardown that React unmount can only *start*.
|
|
208
|
+
*
|
|
209
|
+
* A React effect cleanup is synchronous: it cannot await `Agent.destroy()`,
|
|
210
|
+
* whose teardown (bash process trees, MCP children, LSP, async work drain) is
|
|
211
|
+
* async. `cli.tsx` used to call `process.exit(0)` the moment `waitUntilExit()`
|
|
212
|
+
* resolved, which cut that teardown short — the detached bash process group
|
|
213
|
+
* never received its SIGTERM and survived as an orphan. The promise is parked
|
|
214
|
+
* here so the exit path can await it before exiting.
|
|
215
|
+
*/
|
|
216
|
+
let pendingAgentDestroy: Promise<void> | null = null;
|
|
217
|
+
|
|
218
|
+
/** The in-flight teardown started by the unmount cleanup, if any. */
|
|
219
|
+
export function getPendingAgentDestroy(): Promise<void> | null {
|
|
220
|
+
return pendingAgentDestroy;
|
|
221
|
+
}
|
|
222
|
+
|
|
204
223
|
export const useChat = () => {
|
|
205
224
|
const context = useContext(ChatContext);
|
|
206
225
|
if (!context) {
|
|
@@ -363,6 +382,38 @@ const applyToolBlockUpdate = (
|
|
|
363
382
|
});
|
|
364
383
|
};
|
|
365
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Append a block to the last message — only when it is an assistant message
|
|
387
|
+
* (the current turn's in-flight reply). If the last message is a user message,
|
|
388
|
+
* the block belongs BELOW it: create a new assistant message instead of
|
|
389
|
+
* polluting the stale assistant from an earlier turn. Same rule the webview
|
|
390
|
+
* reducer applies (APPEND_NOTICE_BLOCK).
|
|
391
|
+
*
|
|
392
|
+
* Both the error and the system line use this: the SDK appends them to the
|
|
393
|
+
* same place (`appendBlockToLastAssistant`), so the CLI must not invent a
|
|
394
|
+
* second placement rule.
|
|
395
|
+
*/
|
|
396
|
+
const appendBlockToLastAssistant = (
|
|
397
|
+
prev: Message[],
|
|
398
|
+
block: MessageBlock,
|
|
399
|
+
): Message[] => {
|
|
400
|
+
const last = prev[prev.length - 1];
|
|
401
|
+
if (last && last.role === "assistant") {
|
|
402
|
+
return prev.map((m, idx) =>
|
|
403
|
+
idx === prev.length - 1 ? { ...m, blocks: [...m.blocks, block] } : m,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return [
|
|
407
|
+
...prev,
|
|
408
|
+
{
|
|
409
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
410
|
+
role: "assistant",
|
|
411
|
+
timestamp: new Date().toISOString(),
|
|
412
|
+
blocks: [block],
|
|
413
|
+
},
|
|
414
|
+
];
|
|
415
|
+
};
|
|
416
|
+
|
|
366
417
|
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
367
418
|
children,
|
|
368
419
|
bypassPermissions,
|
|
@@ -392,7 +443,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
392
443
|
|
|
393
444
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
394
445
|
const [latestTotalTokens, setLatestTotalTokens] = useState(0);
|
|
395
|
-
const [
|
|
446
|
+
const [contextWindow, setContextWindow] = useState(200000);
|
|
447
|
+
const [maxOutputTokens, setMaxOutputTokens] = useState(32000);
|
|
396
448
|
|
|
397
449
|
// All message-state updates apply IMMEDIATELY via setMessages — no data-layer
|
|
398
450
|
// throttling (removed 2026-10-15 after observing fully-immediate rendering in
|
|
@@ -726,39 +778,19 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
726
778
|
// `end` carries the authoritative final values.
|
|
727
779
|
setMessages((prev) => applyToolBlockUpdate(prev, params));
|
|
728
780
|
},
|
|
729
|
-
|
|
730
|
-
remoteStream?.
|
|
781
|
+
onNoticeAdded: ({ level, content }) => {
|
|
782
|
+
remoteStream?.onNoticeAdded({ level, content });
|
|
731
783
|
if (isExpandedRef.current) return;
|
|
732
|
-
// One-shot structural update — applied directly (
|
|
733
|
-
// delayed by the tool-running window
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (last && last.role === "assistant") {
|
|
743
|
-
return prev.map((m, idx) =>
|
|
744
|
-
idx === prev.length - 1
|
|
745
|
-
? {
|
|
746
|
-
...m,
|
|
747
|
-
blocks: [...m.blocks, { type: "error", content: error }],
|
|
748
|
-
}
|
|
749
|
-
: m,
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
return [
|
|
753
|
-
...prev,
|
|
754
|
-
{
|
|
755
|
-
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
756
|
-
role: "assistant",
|
|
757
|
-
timestamp: new Date().toISOString(),
|
|
758
|
-
blocks: [{ type: "error", content: error }],
|
|
759
|
-
},
|
|
760
|
-
];
|
|
761
|
-
});
|
|
784
|
+
// One-shot structural update — applied directly (a failure must not
|
|
785
|
+
// be delayed by the tool-running window; a hook's round line must
|
|
786
|
+
// not wait for the next `refreshMessages`).
|
|
787
|
+
setMessages((prev) =>
|
|
788
|
+
appendBlockToLastAssistant(prev, {
|
|
789
|
+
type: "notice",
|
|
790
|
+
level,
|
|
791
|
+
content,
|
|
792
|
+
}),
|
|
793
|
+
);
|
|
762
794
|
},
|
|
763
795
|
onLatestTotalTokensChange: (tokens) => {
|
|
764
796
|
setLatestTotalTokens(tokens);
|
|
@@ -908,7 +940,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
908
940
|
setWorkingDirectory(agent.currentWorkdir);
|
|
909
941
|
setCurrentModelState(agent.getModelConfig().model || "");
|
|
910
942
|
setConfiguredModels(agent.getConfiguredModels().map((m) => m.name));
|
|
911
|
-
|
|
943
|
+
setContextWindow(agent.getContextWindow());
|
|
944
|
+
setMaxOutputTokens(agent.getMaxOutputTokens?.() ?? 32_000);
|
|
912
945
|
|
|
913
946
|
// Get initial MCP servers state
|
|
914
947
|
const initialMcpServers = agent.getMcpServers?.() || [];
|
|
@@ -1017,16 +1050,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1017
1050
|
useEffect(() => {
|
|
1018
1051
|
return () => {
|
|
1019
1052
|
if (agentRef.current) {
|
|
1053
|
+
const agent = agentRef.current;
|
|
1020
1054
|
try {
|
|
1021
1055
|
// Display usage summary before cleanup
|
|
1022
|
-
const usages =
|
|
1023
|
-
const sessionFilePath =
|
|
1056
|
+
const usages = agent.usages;
|
|
1057
|
+
const sessionFilePath = agent.sessionFilePath;
|
|
1024
1058
|
displayUsageSummary(usages, sessionFilePath);
|
|
1025
1059
|
} catch {
|
|
1026
1060
|
// Silently ignore usage summary errors during cleanup
|
|
1027
1061
|
}
|
|
1028
1062
|
|
|
1029
|
-
|
|
1063
|
+
// Park the promise instead of dropping it: the exit path in `cli.tsx`
|
|
1064
|
+
// awaits it before `process.exit()` so the teardown actually runs
|
|
1065
|
+
// (see `pendingAgentDestroy`). Failures are swallowed so a broken
|
|
1066
|
+
// teardown cannot turn a clean exit into an unhandled rejection.
|
|
1067
|
+
pendingAgentDestroy = agent.destroy().catch((error: unknown) => {
|
|
1068
|
+
logger.warn("Agent teardown failed during exit:", error);
|
|
1069
|
+
});
|
|
1030
1070
|
}
|
|
1031
1071
|
};
|
|
1032
1072
|
}, []);
|
|
@@ -1213,7 +1253,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1213
1253
|
timestamp: new Date().toISOString(),
|
|
1214
1254
|
blocks: [
|
|
1215
1255
|
{
|
|
1216
|
-
type: "
|
|
1256
|
+
type: "notice",
|
|
1257
|
+
level: "error",
|
|
1217
1258
|
content: `Failed to resume conversation: ${
|
|
1218
1259
|
error instanceof Error ? error.message : String(error)
|
|
1219
1260
|
}`,
|
|
@@ -1442,7 +1483,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1442
1483
|
isLoading,
|
|
1443
1484
|
permissionMode,
|
|
1444
1485
|
latestTotalTokens,
|
|
1445
|
-
|
|
1486
|
+
contextWindow,
|
|
1487
|
+
maxOutputTokens,
|
|
1446
1488
|
// 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
|
|
1447
1489
|
// 重命名后即刻生效),所以按「取当前值」的函数递给门面。
|
|
1448
1490
|
customTitle: () => agentRef.current?.customTitle,
|
|
@@ -1467,8 +1509,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1467
1509
|
renameSession: (title) => agentRef.current?.renameSession(title),
|
|
1468
1510
|
compact: (instructions) => compact(instructions),
|
|
1469
1511
|
slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1512
|
+
// 手机页取数都读 agent 手上那份列表(压缩后的上下文):首屏尾段(`limit`)、
|
|
1513
|
+
// 重连补差(`since`)、滚到顶往回翻(`before` + `limit`)三种窗口原样交给
|
|
1514
|
+
// SDK,切片与 `hasMore` / `anchorLost` 都由它判。分页是 2026-11-06 起远控页
|
|
1515
|
+
// 也有的能力,所以这里不能只认前两种。
|
|
1472
1516
|
messagePage: (window) =>
|
|
1473
1517
|
agentRef.current?.getMessagePage(window) ??
|
|
1474
1518
|
Promise.resolve({ messages: [] }),
|
|
@@ -1589,7 +1633,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1589
1633
|
recallQueuedMessage,
|
|
1590
1634
|
removeQueuedMessageById,
|
|
1591
1635
|
latestTotalTokens,
|
|
1592
|
-
|
|
1636
|
+
contextWindow,
|
|
1637
|
+
maxOutputTokens,
|
|
1593
1638
|
currentModel,
|
|
1594
1639
|
configuredModels,
|
|
1595
1640
|
getConfiguredModels,
|