wave-code 1.2.0 → 1.4.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 +1738 -893
- package/package.json +6 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ChatInterface.tsx +1 -0
- package/src/components/ConfirmationDetails.tsx +51 -16
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/InputBox.tsx +41 -2
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +22 -17
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +24 -0
- package/src/contexts/useChat.tsx +517 -155
- package/src/daemon/commands.ts +582 -50
- package/src/daemon-cli.ts +12 -1
- package/src/hooks/useInputManager.ts +20 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +97 -8
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +30 -9
- package/src/print-cli.ts +5 -0
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +97 -0
- package/src/remote/notificationCoalescer.ts +174 -0
- package/src/remote/relayClient.ts +668 -0
- package/src/remote/remoteControlCliHost.ts +391 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +334 -0
- package/src/remote/remoteControlService.ts +799 -0
- package/src/remote/remoteControlSink.ts +77 -0
- package/src/remote/remoteNotifications.ts +194 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +1001 -165
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -106
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio-cli.ts +16 -1
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/components/PlanDisplay.tsx +0 -18
|
@@ -10,6 +10,7 @@ import { AgentsManager } from "./AgentsManager.js";
|
|
|
10
10
|
import { SkillsManager } from "./SkillsManager.js";
|
|
11
11
|
import { HooksManager } from "./HooksManager.js";
|
|
12
12
|
import { RewindCommand } from "./RewindCommand.js";
|
|
13
|
+
import { ResumeCommand } from "./ResumeCommand.js";
|
|
13
14
|
import { HelpView } from "./HelpView.js";
|
|
14
15
|
import { StatusCommand } from "./StatusCommand.js";
|
|
15
16
|
import { LoginCommand } from "./LoginCommand.js";
|
|
@@ -86,6 +87,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
86
87
|
permissionMode: chatPermissionMode,
|
|
87
88
|
setPermissionMode: setChatPermissionMode,
|
|
88
89
|
handleRewindSelect,
|
|
90
|
+
resumeSession,
|
|
89
91
|
backgroundCurrentTask,
|
|
90
92
|
messages,
|
|
91
93
|
getFullMessageThread,
|
|
@@ -95,10 +97,14 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
95
97
|
clearMessages,
|
|
96
98
|
compact,
|
|
97
99
|
addDir,
|
|
100
|
+
renameSession,
|
|
101
|
+
remoteControlCommand,
|
|
98
102
|
currentModel,
|
|
99
103
|
configuredModels,
|
|
100
104
|
setModel,
|
|
101
|
-
|
|
105
|
+
reloadPlugins,
|
|
106
|
+
notifyPluginChange,
|
|
107
|
+
pluginHint,
|
|
102
108
|
recallQueuedMessage,
|
|
103
109
|
queuedMessages,
|
|
104
110
|
setIsBtwActive,
|
|
@@ -153,6 +159,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
153
159
|
showMcpManager,
|
|
154
160
|
showAgentsManager,
|
|
155
161
|
showRewindManager,
|
|
162
|
+
showResumeSelector,
|
|
156
163
|
showHelp,
|
|
157
164
|
showStatusCommand,
|
|
158
165
|
showLoginCommand,
|
|
@@ -165,6 +172,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
165
172
|
setShowMcpManager,
|
|
166
173
|
setShowAgentsManager,
|
|
167
174
|
setShowRewindManager,
|
|
175
|
+
setShowResumeSelector,
|
|
168
176
|
setShowHelp,
|
|
169
177
|
setShowStatusCommand,
|
|
170
178
|
setShowLoginCommand,
|
|
@@ -191,7 +199,10 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
191
199
|
onAskBtw: askBtw,
|
|
192
200
|
onClearMessages: clearMessages,
|
|
193
201
|
onCompact: compact,
|
|
202
|
+
onReloadPlugins: reloadPlugins,
|
|
194
203
|
onAddDir: addDir,
|
|
204
|
+
onRenameSession: renameSession,
|
|
205
|
+
onRemoteControlCommand: remoteControlCommand,
|
|
195
206
|
onHasSlashCommand: hasSlashCommand,
|
|
196
207
|
onAbortMessage: abortMessage,
|
|
197
208
|
onBackgroundCurrentTask: backgroundCurrentTask,
|
|
@@ -230,6 +241,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
230
241
|
if (
|
|
231
242
|
planView ||
|
|
232
243
|
showRewindManager ||
|
|
244
|
+
showResumeSelector ||
|
|
233
245
|
showHelp ||
|
|
234
246
|
showStatusCommand ||
|
|
235
247
|
showLoginCommand ||
|
|
@@ -283,6 +295,21 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
283
295
|
await handleRewindSelect(index);
|
|
284
296
|
};
|
|
285
297
|
|
|
298
|
+
const handleResumeSelectWithClose = async (
|
|
299
|
+
sessionId: string,
|
|
300
|
+
resumeWorkdir?: string,
|
|
301
|
+
) => {
|
|
302
|
+
setShowResumeSelector(false);
|
|
303
|
+
// The switch replaces the whole conversation; a draft typed for the old
|
|
304
|
+
// session would otherwise silently become the first message of the new one.
|
|
305
|
+
setInputText("");
|
|
306
|
+
await resumeSession(sessionId, resumeWorkdir);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const handleResumeCancel = () => {
|
|
310
|
+
setShowResumeSelector(false);
|
|
311
|
+
};
|
|
312
|
+
|
|
286
313
|
return (
|
|
287
314
|
<Box flexDirection="column">
|
|
288
315
|
<BtwDisplay btwState={btwState} />
|
|
@@ -363,6 +390,15 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
363
390
|
/>
|
|
364
391
|
)}
|
|
365
392
|
|
|
393
|
+
{showResumeSelector && (
|
|
394
|
+
<ResumeCommand
|
|
395
|
+
workdir={workingDirectory}
|
|
396
|
+
isBusy={!isIdle}
|
|
397
|
+
onSelect={handleResumeSelectWithClose}
|
|
398
|
+
onCancel={handleResumeCancel}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
401
|
+
|
|
366
402
|
{showHelp && (
|
|
367
403
|
<HelpView
|
|
368
404
|
onCancel={() => setShowHelp(false)}
|
|
@@ -381,7 +417,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
381
417
|
{showPluginManager && (
|
|
382
418
|
<PluginManagerShell
|
|
383
419
|
onCancel={() => setShowPluginManager(false)}
|
|
384
|
-
|
|
420
|
+
onPluginChanged={notifyPluginChange}
|
|
385
421
|
/>
|
|
386
422
|
)}
|
|
387
423
|
|
|
@@ -411,7 +447,9 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
411
447
|
showMcpManager ||
|
|
412
448
|
showAgentsManager ||
|
|
413
449
|
showSkillsManager ||
|
|
450
|
+
showHooksManager ||
|
|
414
451
|
showRewindManager ||
|
|
452
|
+
showResumeSelector ||
|
|
415
453
|
showHelp ||
|
|
416
454
|
showStatusCommand ||
|
|
417
455
|
showLoginCommand ||
|
|
@@ -451,6 +489,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
451
489
|
latestTotalTokens={latestTotalTokens}
|
|
452
490
|
maxInputTokens={maxInputTokens}
|
|
453
491
|
showLoginHint={showLoginHint}
|
|
492
|
+
pluginHint={pluginHint}
|
|
454
493
|
/>
|
|
455
494
|
</Box>
|
|
456
495
|
</Box>
|
|
@@ -137,21 +137,34 @@ class AnsiRenderer extends Renderer<string> {
|
|
|
137
137
|
|
|
138
138
|
const createRenderer = (columns: number) => new AnsiRenderer(columns);
|
|
139
139
|
|
|
140
|
+
/** Renders Markdown to ANSI-styled text using the same renderer as <Markdown>.
|
|
141
|
+
* Callers that must linearize content into rows (e.g. the confirmation
|
|
142
|
+
* details area, which slices rows for PgUp/PgDn scrolling) render first and
|
|
143
|
+
* then split the result into lines. */
|
|
144
|
+
export const renderMarkdownToAnsi = (
|
|
145
|
+
content: string,
|
|
146
|
+
columns: number,
|
|
147
|
+
): string => {
|
|
148
|
+
const ansi = marked.parse(content, {
|
|
149
|
+
renderer: createRenderer(columns),
|
|
150
|
+
gfm: true,
|
|
151
|
+
breaks: true,
|
|
152
|
+
}) as string;
|
|
153
|
+
return ansi.trim();
|
|
154
|
+
};
|
|
155
|
+
|
|
140
156
|
// Markdown component using custom ANSI renderer
|
|
141
157
|
export const Markdown = React.memo(({ children }: MarkdownProps) => {
|
|
142
158
|
const { stdout } = useStdout();
|
|
143
159
|
const columns = stdout?.columns ?? 80;
|
|
144
|
-
const ansiContent = useMemo(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
breaks: true,
|
|
149
|
-
}) as string;
|
|
150
|
-
}, [children, columns]);
|
|
160
|
+
const ansiContent = useMemo(
|
|
161
|
+
() => renderMarkdownToAnsi(children, columns),
|
|
162
|
+
[children, columns],
|
|
163
|
+
);
|
|
151
164
|
|
|
152
165
|
return (
|
|
153
166
|
<Box flexDirection="column">
|
|
154
|
-
<Text>{ansiContent
|
|
167
|
+
<Text>{ansiContent}</Text>
|
|
155
168
|
</Box>
|
|
156
169
|
);
|
|
157
170
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useReducer, useEffect
|
|
1
|
+
import React, { useReducer, useEffect } from "react";
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
3
|
import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
|
|
4
4
|
import {
|
|
@@ -6,6 +6,13 @@ import {
|
|
|
6
6
|
type SelectorState,
|
|
7
7
|
} from "../reducers/selectorReducer.js";
|
|
8
8
|
|
|
9
|
+
const ACTIONS = [
|
|
10
|
+
// 先刷新该市场检出,再把该市场内所有可更新插件一次升掉(范围恒为当前市场,
|
|
11
|
+
// spec 插件市场场景 13)
|
|
12
|
+
{ id: "update", label: "Update plugins (batch)" },
|
|
13
|
+
{ id: "remove", label: "Remove marketplace" },
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
9
16
|
export const MarketplaceDetail: React.FC = () => {
|
|
10
17
|
const { state, marketplaces, actions } = usePluginManagerContext();
|
|
11
18
|
const [selectorState, dispatch] = useReducer(
|
|
@@ -25,23 +32,10 @@ export const MarketplaceDetail: React.FC = () => {
|
|
|
25
32
|
|
|
26
33
|
const marketplace = marketplaces.find((m) => m.name === state.selectedId);
|
|
27
34
|
|
|
28
|
-
const ACTIONS = useMemo(
|
|
29
|
-
() =>
|
|
30
|
-
[
|
|
31
|
-
{
|
|
32
|
-
id: "toggle-auto-update",
|
|
33
|
-
label: `${marketplace?.autoUpdate ? "Disable" : "Enable"} auto-update`,
|
|
34
|
-
},
|
|
35
|
-
{ id: "update", label: "Update marketplace" },
|
|
36
|
-
{ id: "remove", label: "Remove marketplace" },
|
|
37
|
-
] as const,
|
|
38
|
-
[marketplace?.autoUpdate],
|
|
39
|
-
);
|
|
40
|
-
|
|
41
35
|
// Sync ACTIONS into reducer state
|
|
42
36
|
useEffect(() => {
|
|
43
37
|
dispatch({ type: "SET_ITEMS", items: [...ACTIONS] });
|
|
44
|
-
}, [
|
|
38
|
+
}, []);
|
|
45
39
|
|
|
46
40
|
useInput((_input, key) => {
|
|
47
41
|
if (state.isLoading && !key.escape) return;
|
|
@@ -58,9 +52,7 @@ export const MarketplaceDetail: React.FC = () => {
|
|
|
58
52
|
|
|
59
53
|
if (pendingDecision === "select" && marketplace && !state.isLoading) {
|
|
60
54
|
const action = actionsList[selectedActionIndex]?.id;
|
|
61
|
-
if (action === "
|
|
62
|
-
actions.toggleAutoUpdate(marketplace.name, !marketplace.autoUpdate);
|
|
63
|
-
} else if (action === "update") {
|
|
55
|
+
if (action === "update") {
|
|
64
56
|
actions.updateMarketplace(marketplace.name);
|
|
65
57
|
} else if (action === "remove") {
|
|
66
58
|
actions.removeMarketplace(marketplace.name);
|
|
@@ -104,15 +96,6 @@ export const MarketplaceDetail: React.FC = () => {
|
|
|
104
96
|
<Text>Source: {JSON.stringify(marketplace.source)}</Text>
|
|
105
97
|
</Box>
|
|
106
98
|
|
|
107
|
-
<Box marginBottom={1}>
|
|
108
|
-
<Text>
|
|
109
|
-
Auto-update:{" "}
|
|
110
|
-
<Text color={marketplace.autoUpdate ? "green" : "red"}>
|
|
111
|
-
{marketplace.autoUpdate ? "Enabled" : "Disabled"}
|
|
112
|
-
</Text>
|
|
113
|
-
</Text>
|
|
114
|
-
</Box>
|
|
115
|
-
|
|
116
99
|
{marketplace.lastUpdated && (
|
|
117
100
|
<Box marginBottom={1}>
|
|
118
101
|
<Text>
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
import { MarketplaceList } from "./MarketplaceList.js";
|
|
11
11
|
|
|
12
12
|
export const MarketplaceView: React.FC = () => {
|
|
13
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
marketplaces,
|
|
15
|
+
checkingForUpdates,
|
|
16
|
+
state: managerState,
|
|
17
|
+
actions,
|
|
18
|
+
} = usePluginManagerContext();
|
|
14
19
|
const [state, dispatch] = useReducer(selectorReducer<KnownMarketplace>, {
|
|
15
20
|
selectedIndex: 0,
|
|
16
21
|
pendingDecision: null,
|
|
@@ -19,10 +24,16 @@ export const MarketplaceView: React.FC = () => {
|
|
|
19
24
|
|
|
20
25
|
const { selectedIndex, pendingDecision, items } = state;
|
|
21
26
|
|
|
22
|
-
// Sync marketplaces into reducer state
|
|
27
|
+
// Sync marketplaces into reducer state;焦点落到当前选中的市场(新增市场后由
|
|
28
|
+
// addMarketplace 设成新市场名,spec「管理市场」场景 5),没有匹配(首帧、市场被
|
|
29
|
+
// 移除)时保持 SET_ITEMS 归零后的列表首项。
|
|
23
30
|
useEffect(() => {
|
|
24
31
|
dispatch({ type: "SET_ITEMS", items: marketplaces });
|
|
25
|
-
|
|
32
|
+
const index = marketplaces.findIndex(
|
|
33
|
+
(m) => m.name === managerState.selectedId,
|
|
34
|
+
);
|
|
35
|
+
if (index > 0) dispatch({ type: "SET_INDEX", index });
|
|
36
|
+
}, [marketplaces, managerState.selectedId]);
|
|
26
37
|
|
|
27
38
|
useInput((input, key) => {
|
|
28
39
|
if (input === "a") {
|
|
@@ -52,6 +63,9 @@ export const MarketplaceView: React.FC = () => {
|
|
|
52
63
|
<Box flexDirection="column">
|
|
53
64
|
<Box marginBottom={1}>
|
|
54
65
|
<Text color="green">Press 'a' to add a new marketplace</Text>
|
|
66
|
+
{/* 打开插件管理器触发的后台清单刷新(只拉检出、不升级插件):不阻塞
|
|
67
|
+
列表浏览,完成后重读并呈现最新清单(spec ecosystem/plugin 场景 12) */}
|
|
68
|
+
{checkingForUpdates && <Text dimColor> Checking for updates...</Text>}
|
|
55
69
|
</Box>
|
|
56
70
|
<MarketplaceList
|
|
57
71
|
marketplaces={marketplaces}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import type { Message, MessageBlock } from "wave-agent-sdk";
|
|
4
|
-
import { MessageSource } from "wave-agent-sdk";
|
|
5
4
|
import { ToolDisplay } from "./ToolDisplay.js";
|
|
6
5
|
import { CompactDisplay } from "./CompactDisplay.js";
|
|
7
6
|
import { ReasoningDisplay } from "./ReasoningDisplay.js";
|
|
@@ -25,11 +24,6 @@ export const MessageBlockItem = ({
|
|
|
25
24
|
<Box flexDirection="column" paddingTop={paddingTop}>
|
|
26
25
|
{block.type === "text" && block.content.trim() && (
|
|
27
26
|
<Box>
|
|
28
|
-
{block.source === MessageSource.HOOK && (
|
|
29
|
-
<Text color="magenta" bold>
|
|
30
|
-
~{" "}
|
|
31
|
-
</Text>
|
|
32
|
-
)}
|
|
33
27
|
{message.role === "user" || isExpanded ? (
|
|
34
28
|
<Text
|
|
35
29
|
backgroundColor={message.role === "user" ? "gray" : undefined}
|
|
@@ -5,12 +5,18 @@ export interface NotificationsProps {
|
|
|
5
5
|
latestTotalTokens?: number;
|
|
6
6
|
maxInputTokens?: number;
|
|
7
7
|
showLoginHint?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* 一次性中性提示(插件变更待应用 / 重载完成)。由宿主显式设置并在数秒后清除,
|
|
10
|
+
* 不提供持久可见标识(docs/specs/ecosystem/plugin.md「插件变更提示」)。
|
|
11
|
+
*/
|
|
12
|
+
pluginHint?: string | null;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
export const Notifications: React.FC<NotificationsProps> = ({
|
|
11
16
|
latestTotalTokens = 0,
|
|
12
17
|
maxInputTokens = 200000,
|
|
13
18
|
showLoginHint = false,
|
|
19
|
+
pluginHint = null,
|
|
14
20
|
}) => {
|
|
15
21
|
const percentage =
|
|
16
22
|
latestTotalTokens > 0
|
|
@@ -22,6 +28,7 @@ export const Notifications: React.FC<NotificationsProps> = ({
|
|
|
22
28
|
|
|
23
29
|
return (
|
|
24
30
|
<Box gap={1}>
|
|
31
|
+
{pluginHint && <Text color="gray">{pluginHint}</Text>}
|
|
25
32
|
{showLoginHint && <Text color="gray">Type /login to authenticate</Text>}
|
|
26
33
|
{percentage > 0 && (
|
|
27
34
|
<Text color={contextColor}>{percentage}% context</Text>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { Box, Text, useInput, useWindowSize } from "ink";
|
|
3
3
|
import { useLineScroll } from "../hooks/useLineScroll.js";
|
|
4
|
+
import { ScrolledContent } from "./ScrolledContent.js";
|
|
4
5
|
|
|
5
6
|
// Row budget for the scroll indicators (↑ more / ↓ more, up to one each).
|
|
6
7
|
const SCROLL_INDICATOR_BUDGET = 2;
|
|
@@ -60,8 +61,11 @@ export const PlanView: React.FC<PlanViewProps> = ({
|
|
|
60
61
|
!isMessageOnly && content !== undefined && content !== ""
|
|
61
62
|
? content.split("\n")
|
|
62
63
|
: [];
|
|
64
|
+
// Rows really occupied by the plan (wrapped rows included), measured after
|
|
65
|
+
// layout by <ScrolledContent>.
|
|
66
|
+
const [contentRows, setContentRows] = useState(0);
|
|
63
67
|
const { scrollOffset, hasMoreAbove, hasMoreBelow } = useLineScroll({
|
|
64
|
-
totalLines:
|
|
68
|
+
totalLines: contentRows,
|
|
65
69
|
visibleCount,
|
|
66
70
|
});
|
|
67
71
|
|
|
@@ -81,11 +85,6 @@ export const PlanView: React.FC<PlanViewProps> = ({
|
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
const visibleLines = planLines.slice(
|
|
85
|
-
scrollOffset,
|
|
86
|
-
scrollOffset + visibleCount,
|
|
87
|
-
);
|
|
88
|
-
|
|
89
88
|
return (
|
|
90
89
|
<Box
|
|
91
90
|
flexDirection="column"
|
|
@@ -110,19 +109,25 @@ export const PlanView: React.FC<PlanViewProps> = ({
|
|
|
110
109
|
</Text>
|
|
111
110
|
</Box>
|
|
112
111
|
)}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
<ScrolledContent
|
|
113
|
+
visibleRows={visibleCount}
|
|
114
|
+
scrollOffset={scrollOffset}
|
|
115
|
+
onMeasuredRows={setContentRows}
|
|
116
|
+
>
|
|
117
|
+
{planLines.length > 0 ? (
|
|
118
|
+
planLines.map((line, index) => (
|
|
119
|
+
<Box key={index}>
|
|
120
|
+
<Text>{line || " "}</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
))
|
|
123
|
+
) : (
|
|
124
|
+
<Text>No plan written yet.</Text>
|
|
125
|
+
)}
|
|
126
|
+
</ScrolledContent>
|
|
122
127
|
{hasMoreBelow && (
|
|
123
128
|
<Box>
|
|
124
129
|
<Text color="gray" dimColor>
|
|
125
|
-
↓ {
|
|
130
|
+
↓ {contentRows - scrollOffset - visibleCount} more
|
|
126
131
|
</Text>
|
|
127
132
|
</Box>
|
|
128
133
|
)}
|
|
@@ -13,9 +13,9 @@ import { PluginManagerContext } from "../contexts/PluginManagerContext.js";
|
|
|
13
13
|
export const PluginManagerShell: React.FC<{
|
|
14
14
|
children?: React.ReactNode;
|
|
15
15
|
onCancel?: () => void;
|
|
16
|
-
|
|
17
|
-
}> = ({ children, onCancel,
|
|
18
|
-
const pluginManager = usePluginManager({
|
|
16
|
+
onPluginChanged?: () => void;
|
|
17
|
+
}> = ({ children, onCancel, onPluginChanged }) => {
|
|
18
|
+
const pluginManager = usePluginManager({ onPluginChanged });
|
|
19
19
|
const { state, actions, discoverablePlugins } = pluginManager;
|
|
20
20
|
|
|
21
21
|
const setView = (view: ViewType) => {
|
|
@@ -24,6 +24,8 @@ export interface PluginManagerState {
|
|
|
24
24
|
export interface PluginManagerContextType {
|
|
25
25
|
state: PluginManagerState;
|
|
26
26
|
marketplaces: KnownMarketplace[];
|
|
27
|
+
/** 打开插件管理器触发的后台清单刷新是否仍在进行(只拉检出、不升级插件)。 */
|
|
28
|
+
checkingForUpdates: boolean;
|
|
27
29
|
installedPlugins: (InstalledPlugin & { enabled: boolean })[];
|
|
28
30
|
discoverablePlugins: (MarketplacePluginEntry & {
|
|
29
31
|
marketplace: string;
|
|
@@ -49,7 +51,6 @@ export interface PluginManagerContextType {
|
|
|
49
51
|
) => Promise<void>;
|
|
50
52
|
uninstallPlugin: (name: string, marketplace: string) => Promise<void>;
|
|
51
53
|
updatePlugin: (name: string, marketplace: string) => Promise<void>;
|
|
52
|
-
toggleAutoUpdate: (name: string, enabled: boolean) => Promise<void>;
|
|
53
54
|
refresh: () => Promise<void>;
|
|
54
55
|
clearPluginFeedback: () => void;
|
|
55
56
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { SessionSelectorFlow } from "./SessionSelectorFlow.js";
|
|
4
|
+
import { listWorktrees } from "../utils/worktree.js";
|
|
5
|
+
|
|
6
|
+
export interface ResumeCommandProps {
|
|
7
|
+
/** The session's current working directory (the picker's default scope). */
|
|
8
|
+
workdir: string;
|
|
9
|
+
/**
|
|
10
|
+
* True while a turn is in flight. Switching mid-turn is refused: the running
|
|
11
|
+
* turn must not be aborted (its abort error would land in the session being
|
|
12
|
+
* left behind), so the picker is replaced by a notice.
|
|
13
|
+
*/
|
|
14
|
+
isBusy: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Called with the chosen session. `resumeWorkdir` is set when the session
|
|
17
|
+
* belongs to a sibling worktree of the same repo — the session then moves
|
|
18
|
+
* into that directory.
|
|
19
|
+
*/
|
|
20
|
+
onSelect: (sessionId: string, resumeWorkdir?: string) => void;
|
|
21
|
+
onCancel: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* In-TUI `/resume` picker. Thin wrapper over the shared `SessionSelectorFlow`:
|
|
26
|
+
* the worktree list is loaded lazily on open (it needs a `git worktree list`
|
|
27
|
+
* round-trip), and a cross-project selection shows a dismissible notice instead
|
|
28
|
+
* of tearing down the TUI.
|
|
29
|
+
*/
|
|
30
|
+
export const ResumeCommand: React.FC<ResumeCommandProps> = ({
|
|
31
|
+
workdir,
|
|
32
|
+
isBusy,
|
|
33
|
+
onSelect,
|
|
34
|
+
onCancel,
|
|
35
|
+
}) => {
|
|
36
|
+
const [worktreePaths, setWorktreePaths] = useState<string[] | null>(null);
|
|
37
|
+
|
|
38
|
+
useInput((_input, key) => {
|
|
39
|
+
if (isBusy && key.escape) {
|
|
40
|
+
onCancel();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (isBusy) return;
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
listWorktrees(workdir).then((paths) => {
|
|
48
|
+
if (!cancelled) setWorktreePaths(paths);
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
}, [workdir, isBusy]);
|
|
54
|
+
|
|
55
|
+
if (isBusy) {
|
|
56
|
+
return (
|
|
57
|
+
<Box flexDirection="column" paddingX={1}>
|
|
58
|
+
<Text color="yellow">
|
|
59
|
+
Cannot resume a conversation while the agent is running.
|
|
60
|
+
</Text>
|
|
61
|
+
<Text dimColor>Esc to go back</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!worktreePaths) {
|
|
67
|
+
return (
|
|
68
|
+
<Box paddingX={1}>
|
|
69
|
+
<Text dimColor>Loading conversations…</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<SessionSelectorFlow
|
|
76
|
+
currentWorkdir={workdir}
|
|
77
|
+
worktreePaths={worktreePaths}
|
|
78
|
+
crossProjectMode="dismiss"
|
|
79
|
+
onDone={(result) =>
|
|
80
|
+
result ? onSelect(result.sessionId, result.resumeWorkdir) : onCancel()
|
|
81
|
+
}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useRef } from "react";
|
|
2
|
+
import { Box, type DOMElement } from "ink";
|
|
3
|
+
|
|
4
|
+
export interface ScrolledContentProps {
|
|
5
|
+
/** Every content row — rows are never sliced, the viewport clips them. */
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
/** Largest height the viewport may take, in terminal rows (the row budget
|
|
8
|
+
* for the content area). Shorter content keeps its natural height. */
|
|
9
|
+
visibleRows: number;
|
|
10
|
+
/** Rows scrolled off the top. */
|
|
11
|
+
scrollOffset: number;
|
|
12
|
+
/** Reports the content height in terminal rows, re-reporting on change. */
|
|
13
|
+
onMeasuredRows: (rows: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hard-bounded, clipping viewport for content that can be wider than the
|
|
18
|
+
* terminal: the content is rendered once and translated up by `scrollOffset`
|
|
19
|
+
* rows, and rows outside the viewport are clipped away.
|
|
20
|
+
*
|
|
21
|
+
* Height is bounded by construction — a line that wraps grows the content
|
|
22
|
+
* instead of the frame — so the row budget of the surrounding layout holds no
|
|
23
|
+
* matter how many rows the content really needs.
|
|
24
|
+
*
|
|
25
|
+
* The row count is read back from the layout rather than predicted from the
|
|
26
|
+
* text: by the time this effect runs, Yoga has wrapped the content to the
|
|
27
|
+
* terminal width, so the computed height already includes wrapped rows.
|
|
28
|
+
*/
|
|
29
|
+
export const ScrolledContent: React.FC<ScrolledContentProps> = ({
|
|
30
|
+
children,
|
|
31
|
+
visibleRows,
|
|
32
|
+
scrollOffset,
|
|
33
|
+
onMeasuredRows,
|
|
34
|
+
}) => {
|
|
35
|
+
const contentRef = useRef<DOMElement | null>(null);
|
|
36
|
+
const reportedRows = useRef(0);
|
|
37
|
+
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
const yogaNode = contentRef.current?.yogaNode;
|
|
40
|
+
if (!yogaNode) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const rows = Math.round(yogaNode.getComputedHeight());
|
|
44
|
+
if (rows > 0 && rows !== reportedRows.current) {
|
|
45
|
+
reportedRows.current = rows;
|
|
46
|
+
onMeasuredRows(rows);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Box
|
|
52
|
+
maxHeight={visibleRows}
|
|
53
|
+
overflow="hidden"
|
|
54
|
+
flexShrink={0}
|
|
55
|
+
flexDirection="column"
|
|
56
|
+
>
|
|
57
|
+
<Box
|
|
58
|
+
ref={contentRef}
|
|
59
|
+
flexDirection="column"
|
|
60
|
+
// Without this the fixed-height parent shrinks the content to fit and
|
|
61
|
+
// renders garbled rows instead of clipping them.
|
|
62
|
+
flexShrink={0}
|
|
63
|
+
marginTop={-scrollOffset}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</Box>
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
ScrolledContent.displayName = "ScrolledContent";
|