wave-code 1.6.0 → 1.6.2
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/dist/bundle/wave.mjs +656 -642
- package/package.json +5 -7
- package/src/cli.tsx +50 -0
- package/src/components/App.tsx +13 -13
- package/src/components/ChatInterface.tsx +4 -2
- package/src/components/CommandSelector.tsx +6 -3
- package/src/components/FileSelector.tsx +19 -8
- package/src/components/InputBox.tsx +6 -3
- package/src/components/Notifications.tsx +6 -3
- package/src/contexts/useChat.tsx +41 -10
- package/src/hooks/useExitSignals.ts +83 -0
- package/src/hooks/useRemoteControl.ts +16 -4
- package/src/managers/inputReducer.ts +1 -1
- package/src/remote/relayClient.ts +122 -43
- package/src/remote/remoteControlCliHost.ts +20 -4
- package/src/remote/remoteControlService.ts +35 -2
- package/src/remote/remoteNotifications.ts +26 -5
- package/src/stdio/agentBridge.ts +107 -7
- package/src/stdio/startupStage.ts +22 -0
- package/src/stdio-cli.ts +9 -1
- package/src/utils/oneLine.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "CLI-based code assistant powered by AI, built with React and Ink",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,16 +29,14 @@
|
|
|
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",
|
|
38
36
|
"@types/react": "^19.3.0",
|
|
39
37
|
"@types/semver": "^7.7.1",
|
|
40
38
|
"@types/yargs": "^17.0.0",
|
|
41
|
-
"@vitest/coverage-v8": "^
|
|
39
|
+
"@vitest/coverage-v8": "^5.1.0",
|
|
42
40
|
"chalk": "^5.6.2",
|
|
43
41
|
"diff": "^8.0.2",
|
|
44
42
|
"esbuild": "^0.28.1",
|
|
@@ -55,11 +53,11 @@
|
|
|
55
53
|
"semver": "^7.7.4",
|
|
56
54
|
"string-width": "^8.2.2",
|
|
57
55
|
"tsx": "^4.20.4",
|
|
58
|
-
"vitest": "^
|
|
56
|
+
"vitest": "^5.1.0",
|
|
59
57
|
"wrap-ansi": "^10.0.0",
|
|
60
58
|
"yargs": "^17.7.2",
|
|
61
59
|
"zod": "^3.23.8",
|
|
62
|
-
"wave-agent-sdk": "1.6.
|
|
60
|
+
"wave-agent-sdk": "1.6.2"
|
|
63
61
|
},
|
|
64
62
|
"engines": {
|
|
65
63
|
"node": ">=22"
|
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
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) => {
|
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
|
</>
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
selectorReducer,
|
|
7
7
|
type SelectorState,
|
|
8
8
|
} from "../reducers/selectorReducer.js";
|
|
9
|
+
import { toOneLine } from "../utils/oneLine.js";
|
|
9
10
|
|
|
10
11
|
export interface CommandSelectorProps {
|
|
11
12
|
searchQuery: string;
|
|
@@ -140,9 +141,11 @@ export const CommandSelector: React.FC<CommandSelectorProps> = ({
|
|
|
140
141
|
{isSelected ? "▶ " : " "}/{command.id}
|
|
141
142
|
</Text>
|
|
142
143
|
{isSelected && (
|
|
143
|
-
<Box marginLeft={4}>
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
<Box marginLeft={4} minHeight={1}>
|
|
145
|
+
{/* One line, always: the slot keeps the menu height stable
|
|
146
|
+
whatever the description's length. */}
|
|
147
|
+
<Text color="gray" dimColor wrap="truncate">
|
|
148
|
+
{toOneLine(command.description ?? "")}
|
|
146
149
|
</Text>
|
|
147
150
|
</Box>
|
|
148
151
|
)}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
selectorReducer,
|
|
6
6
|
type SelectorState,
|
|
7
7
|
} from "../reducers/selectorReducer.js";
|
|
8
|
+
import { toOneLine } from "../utils/oneLine.js";
|
|
8
9
|
|
|
9
10
|
export type { FileSelectorItem };
|
|
10
11
|
|
|
@@ -119,15 +120,25 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
119
120
|
|
|
120
121
|
return (
|
|
121
122
|
<Box key={fileItem.path}>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
{/* The name keeps its width; the description gives way. */}
|
|
124
|
+
<Box flexShrink={0}>
|
|
125
|
+
<Text
|
|
126
|
+
color={isSelected ? "black" : "white"}
|
|
127
|
+
backgroundColor={isSelected ? "cyan" : undefined}
|
|
128
|
+
>
|
|
129
|
+
{isSelected ? "▶ " : " "}
|
|
130
|
+
{fileItem.agentName
|
|
131
|
+
? `@${fileItem.agentName}`
|
|
132
|
+
: fileItem.path}
|
|
133
|
+
</Text>
|
|
134
|
+
</Box>
|
|
129
135
|
{fileItem.agentDescription && (
|
|
130
|
-
<
|
|
136
|
+
<Box flexShrink={1}>
|
|
137
|
+
<Text dimColor wrap="truncate">
|
|
138
|
+
{" "}
|
|
139
|
+
{toOneLine(fileItem.agentDescription)}
|
|
140
|
+
</Text>
|
|
141
|
+
</Box>
|
|
131
142
|
)}
|
|
132
143
|
</Box>
|
|
133
144
|
);
|
|
@@ -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
|
/>
|
|
@@ -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 =
|
package/src/contexts/useChat.tsx
CHANGED
|
@@ -101,7 +101,8 @@ export interface ChatContextType {
|
|
|
101
101
|
recallQueuedMessage: () => QueuedMessage | null;
|
|
102
102
|
removeQueuedMessageById: (id: string) => boolean;
|
|
103
103
|
latestTotalTokens: number;
|
|
104
|
-
|
|
104
|
+
contextWindow: number;
|
|
105
|
+
maxOutputTokens: number;
|
|
105
106
|
// Model functionality
|
|
106
107
|
currentModel: string;
|
|
107
108
|
configuredModels: string[];
|
|
@@ -202,6 +203,23 @@ export interface ChatContextType {
|
|
|
202
203
|
|
|
203
204
|
const ChatContext = createContext<ChatContextType | null>(null);
|
|
204
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
|
+
|
|
205
223
|
export const useChat = () => {
|
|
206
224
|
const context = useContext(ChatContext);
|
|
207
225
|
if (!context) {
|
|
@@ -425,7 +443,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
425
443
|
|
|
426
444
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
427
445
|
const [latestTotalTokens, setLatestTotalTokens] = useState(0);
|
|
428
|
-
const [
|
|
446
|
+
const [contextWindow, setContextWindow] = useState(200000);
|
|
447
|
+
const [maxOutputTokens, setMaxOutputTokens] = useState(32000);
|
|
429
448
|
|
|
430
449
|
// All message-state updates apply IMMEDIATELY via setMessages — no data-layer
|
|
431
450
|
// throttling (removed 2026-10-15 after observing fully-immediate rendering in
|
|
@@ -921,7 +940,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
921
940
|
setWorkingDirectory(agent.currentWorkdir);
|
|
922
941
|
setCurrentModelState(agent.getModelConfig().model || "");
|
|
923
942
|
setConfiguredModels(agent.getConfiguredModels().map((m) => m.name));
|
|
924
|
-
|
|
943
|
+
setContextWindow(agent.getContextWindow());
|
|
944
|
+
setMaxOutputTokens(agent.getMaxOutputTokens?.() ?? 32_000);
|
|
925
945
|
|
|
926
946
|
// Get initial MCP servers state
|
|
927
947
|
const initialMcpServers = agent.getMcpServers?.() || [];
|
|
@@ -1030,16 +1050,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1030
1050
|
useEffect(() => {
|
|
1031
1051
|
return () => {
|
|
1032
1052
|
if (agentRef.current) {
|
|
1053
|
+
const agent = agentRef.current;
|
|
1033
1054
|
try {
|
|
1034
1055
|
// Display usage summary before cleanup
|
|
1035
|
-
const usages =
|
|
1036
|
-
const sessionFilePath =
|
|
1056
|
+
const usages = agent.usages;
|
|
1057
|
+
const sessionFilePath = agent.sessionFilePath;
|
|
1037
1058
|
displayUsageSummary(usages, sessionFilePath);
|
|
1038
1059
|
} catch {
|
|
1039
1060
|
// Silently ignore usage summary errors during cleanup
|
|
1040
1061
|
}
|
|
1041
1062
|
|
|
1042
|
-
|
|
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
|
+
});
|
|
1043
1070
|
}
|
|
1044
1071
|
};
|
|
1045
1072
|
}, []);
|
|
@@ -1456,7 +1483,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1456
1483
|
isLoading,
|
|
1457
1484
|
permissionMode,
|
|
1458
1485
|
latestTotalTokens,
|
|
1459
|
-
|
|
1486
|
+
contextWindow,
|
|
1487
|
+
maxOutputTokens,
|
|
1460
1488
|
// 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
|
|
1461
1489
|
// 重命名后即刻生效),所以按「取当前值」的函数递给门面。
|
|
1462
1490
|
customTitle: () => agentRef.current?.customTitle,
|
|
@@ -1481,8 +1509,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1481
1509
|
renameSession: (title) => agentRef.current?.renameSession(title),
|
|
1482
1510
|
compact: (instructions) => compact(instructions),
|
|
1483
1511
|
slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1512
|
+
// 手机页取数都读 agent 手上那份列表(压缩后的上下文):首屏尾段(`limit`)、
|
|
1513
|
+
// 重连补差(`since`)、滚到顶往回翻(`before` + `limit`)三种窗口原样交给
|
|
1514
|
+
// SDK,切片与 `hasMore` / `anchorLost` 都由它判。分页是 2026-11-06 起远控页
|
|
1515
|
+
// 也有的能力,所以这里不能只认前两种。
|
|
1486
1516
|
messagePage: (window) =>
|
|
1487
1517
|
agentRef.current?.getMessagePage(window) ??
|
|
1488
1518
|
Promise.resolve({ messages: [] }),
|
|
@@ -1603,7 +1633,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1603
1633
|
recallQueuedMessage,
|
|
1604
1634
|
removeQueuedMessageById,
|
|
1605
1635
|
latestTotalTokens,
|
|
1606
|
-
|
|
1636
|
+
contextWindow,
|
|
1637
|
+
maxOutputTokens,
|
|
1607
1638
|
currentModel,
|
|
1608
1639
|
configuredModels,
|
|
1609
1640
|
getConfiguredModels,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process-level exit detection for the interactive CLI.
|
|
5
|
+
*
|
|
6
|
+
* Ink runs the terminal in raw mode, so Ctrl-C normally arrives as a keypress
|
|
7
|
+
* (`useInput`) rather than a signal — but the signals still arrive when the
|
|
8
|
+
* process is killed from outside, and the terminal itself sends one when the
|
|
9
|
+
* user closes the window. Every one of those paths has to unwind through React
|
|
10
|
+
* unmount, because that is what triggers `Agent.destroy()`; without a handler
|
|
11
|
+
* Node applies the default action and terminates on the spot, leaving the
|
|
12
|
+
* agent's async teardown — and every child process it owns — unreaped.
|
|
13
|
+
*
|
|
14
|
+
* `SIGHUP` is the one that actually bit us. The Bash tool spawns its shell with
|
|
15
|
+
* `detached: true`, which makes the child the leader of its own process group
|
|
16
|
+
* and therefore immune to the terminal's SIGHUP broadcast. If nothing reaps it,
|
|
17
|
+
* the shell and everything under it survive as launchd-reparented orphans
|
|
18
|
+
* (observed: one `zsh -> ping -> tail` group alive for almost three days).
|
|
19
|
+
* Claude Code registers the same signal for the same reason.
|
|
20
|
+
*
|
|
21
|
+
* The polling branch covers macOS specifically: closing a terminal there
|
|
22
|
+
* revokes the TTY file descriptors *instead of* delivering a signal, leaving
|
|
23
|
+
* the process alive but unable to read or write. Nothing else would ever
|
|
24
|
+
* notice, so the stdio handles are probed on the same 30s cadence Claude Code
|
|
25
|
+
* uses in `gracefulShutdown`.
|
|
26
|
+
*/
|
|
27
|
+
const TTY_REVOCATION_CHECK_INTERVAL_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Invoke `onExitSignal` on SIGINT / SIGTERM / SIGHUP, and when the controlling
|
|
31
|
+
* TTY is revoked out from under the process.
|
|
32
|
+
*
|
|
33
|
+
* `onExitSignal` is held in a ref, so callers do not need to memoize it and a
|
|
34
|
+
* re-render does not churn the signal registrations. Callers are responsible
|
|
35
|
+
* for making the teardown itself idempotent: a single terminal close can arrive
|
|
36
|
+
* as a signal *and* as a revoked TTY.
|
|
37
|
+
*
|
|
38
|
+
* @param enabled - set false to skip registration entirely (e.g. a wrapper
|
|
39
|
+
* component that renders a child which owns the handler).
|
|
40
|
+
*/
|
|
41
|
+
export function useExitSignals(onExitSignal: () => void, enabled = true): void {
|
|
42
|
+
const handlerRef = useRef(onExitSignal);
|
|
43
|
+
handlerRef.current = onExitSignal;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!enabled) return;
|
|
47
|
+
|
|
48
|
+
const handle = () => handlerRef.current();
|
|
49
|
+
|
|
50
|
+
// Windows has no SIGHUP: the event never fires there, so registering it
|
|
51
|
+
// would only imply a coverage the platform cannot provide.
|
|
52
|
+
const signals: NodeJS.Signals[] =
|
|
53
|
+
process.platform === "win32"
|
|
54
|
+
? ["SIGINT", "SIGTERM"]
|
|
55
|
+
: ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
56
|
+
for (const signal of signals) {
|
|
57
|
+
process.on(signal, handle);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Only meaningful for an interactive terminal; piping either stream out
|
|
61
|
+
// means the handles legitimately stop being readable/writable.
|
|
62
|
+
let ttyRevocationPoll: NodeJS.Timeout | undefined;
|
|
63
|
+
if (process.stdin.isTTY) {
|
|
64
|
+
ttyRevocationPoll = setInterval(() => {
|
|
65
|
+
if (!process.stdout.writable || !process.stdin.readable) {
|
|
66
|
+
// Stop polling before handing off: the revoked TTY stays revoked, so
|
|
67
|
+
// a surviving interval would re-fire the teardown every 30s.
|
|
68
|
+
if (ttyRevocationPoll) clearInterval(ttyRevocationPoll);
|
|
69
|
+
handle();
|
|
70
|
+
}
|
|
71
|
+
}, TTY_REVOCATION_CHECK_INTERVAL_MS);
|
|
72
|
+
// Never keep the process alive just to poll for its own death.
|
|
73
|
+
ttyRevocationPoll.unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
for (const signal of signals) {
|
|
78
|
+
process.off(signal, handle);
|
|
79
|
+
}
|
|
80
|
+
if (ttyRevocationPoll) clearInterval(ttyRevocationPoll);
|
|
81
|
+
};
|
|
82
|
+
}, [enabled]);
|
|
83
|
+
}
|
|
@@ -61,7 +61,8 @@ export interface UseRemoteControlOptions {
|
|
|
61
61
|
isLoading: boolean;
|
|
62
62
|
permissionMode: PermissionMode;
|
|
63
63
|
latestTotalTokens: number;
|
|
64
|
-
|
|
64
|
+
contextWindow: number;
|
|
65
|
+
maxOutputTokens: number;
|
|
65
66
|
/**
|
|
66
67
|
* The title `/rename` sets, read live off the agent — the page's session list
|
|
67
68
|
* labels the row with it (and the page itself may be the one that renamed it).
|
|
@@ -194,10 +195,20 @@ export function useRemoteControl(
|
|
|
194
195
|
if (!sink || !sessionId) return;
|
|
195
196
|
sink.sendNotification(
|
|
196
197
|
"contextUsage",
|
|
197
|
-
remoteWire.contextUsage(
|
|
198
|
+
remoteWire.contextUsage(
|
|
199
|
+
latestTotalTokens,
|
|
200
|
+
options.contextWindow,
|
|
201
|
+
options.maxOutputTokens,
|
|
202
|
+
),
|
|
198
203
|
sessionId,
|
|
199
204
|
);
|
|
200
|
-
}, [
|
|
205
|
+
}, [
|
|
206
|
+
sink,
|
|
207
|
+
sessionId,
|
|
208
|
+
latestTotalTokens,
|
|
209
|
+
options.contextWindow,
|
|
210
|
+
options.maxOutputTokens,
|
|
211
|
+
]);
|
|
201
212
|
|
|
202
213
|
// Approvals are announced once, when they appear. The page answers with
|
|
203
214
|
// `permissionResponse`, which the host routes back into the same promise the
|
|
@@ -318,7 +329,8 @@ function adapterOf(latest: {
|
|
|
318
329
|
contextUsagePercent: () =>
|
|
319
330
|
percentOf(
|
|
320
331
|
latest.current.latestTotalTokens,
|
|
321
|
-
latest.current.
|
|
332
|
+
latest.current.contextWindow,
|
|
333
|
+
latest.current.maxOutputTokens,
|
|
322
334
|
),
|
|
323
335
|
latestTotalTokens: () => latest.current.latestTotalTokens,
|
|
324
336
|
permissionMode: () => latest.current.permissionMode,
|
|
@@ -39,7 +39,7 @@ export interface FileSelectorItem {
|
|
|
39
39
|
type: "file" | "directory" | "agent";
|
|
40
40
|
/** 子代理名(`type === "agent"` 时有值),也是插入正文的名字。 */
|
|
41
41
|
agentName?: string;
|
|
42
|
-
/** 子代理定义的 description
|
|
42
|
+
/** 子代理定义的 description,接在名字后面同一行显示;超宽截断成一行。 */
|
|
43
43
|
agentDescription?: string;
|
|
44
44
|
}
|
|
45
45
|
|