wave-code 1.2.0 → 1.3.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 +1049 -584
- package/package.json +4 -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/components/ChatInterface.tsx +1 -0
- package/src/components/ConfirmationDetails.tsx +36 -15
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/InputBox.tsx +39 -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/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/constants/commands.ts +18 -0
- package/src/contexts/useChat.tsx +158 -28
- package/src/daemon/commands.ts +569 -46
- package/src/daemon-cli.ts +6 -0
- package/src/hooks/useInputManager.ts +16 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/index.ts +62 -8
- package/src/managers/inputHandlers.ts +14 -9
- package/src/managers/inputReducer.ts +29 -9
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +397 -95
- package/src/stdio/protocol.ts +9 -0
- package/src/stdio-cli.ts +6 -0
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/components/PlanDisplay.tsx +0 -18
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import {
|
|
4
|
+
listSessions,
|
|
5
|
+
listAllSessions,
|
|
6
|
+
setSessionCustomTitle,
|
|
7
|
+
truncateContent,
|
|
8
|
+
type SessionMetadata,
|
|
9
|
+
} from "wave-agent-sdk";
|
|
10
|
+
import { SessionSelector } from "./SessionSelector.js";
|
|
11
|
+
import { setClipboardText } from "../utils/clipboard.js";
|
|
12
|
+
import { resolveSessionOwnership } from "../utils/sessionOwnership.js";
|
|
13
|
+
|
|
14
|
+
export interface SessionSelectorResult {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
/**
|
|
17
|
+
* When the selected session belongs to a sibling git worktree of the same
|
|
18
|
+
* repo, the caller should chdir into this path before restoring.
|
|
19
|
+
*/
|
|
20
|
+
resumeWorkdir?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type SessionItem = SessionMetadata & { firstMessage?: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Interactive session picker.
|
|
27
|
+
*
|
|
28
|
+
* Three scopes, toggled with Ctrl+A / Ctrl+W (mirroring Claude Code's resume
|
|
29
|
+
* picker):
|
|
30
|
+
* - current: sessions in the current working directory (default)
|
|
31
|
+
* - worktrees: sessions in any worktree of the same git repository
|
|
32
|
+
* - all: sessions across every project directory
|
|
33
|
+
*
|
|
34
|
+
* Resume decision on selection:
|
|
35
|
+
* - current dir session → resume in place
|
|
36
|
+
* - same-repo worktree → resume after chdir into that worktree
|
|
37
|
+
* - other project → print `cd <path> && wave --restore <id>`,
|
|
38
|
+
* copy it to the clipboard, and finish
|
|
39
|
+
*
|
|
40
|
+
* Shared by `wave -r` (own Ink render) and the in-TUI `/resume` command, so
|
|
41
|
+
* the two can never drift in scopes or ownership resolution.
|
|
42
|
+
*/
|
|
43
|
+
export function SessionSelectorFlow({
|
|
44
|
+
currentWorkdir,
|
|
45
|
+
worktreePaths,
|
|
46
|
+
onDone,
|
|
47
|
+
crossProjectMode = "exit",
|
|
48
|
+
}: {
|
|
49
|
+
currentWorkdir: string;
|
|
50
|
+
worktreePaths: string[];
|
|
51
|
+
onDone: (result: SessionSelectorResult | null) => void;
|
|
52
|
+
/**
|
|
53
|
+
* What happens after the cross-project `cd` command is shown:
|
|
54
|
+
* - "exit": finish the flow (the standalone `wave -r` picker then prints the
|
|
55
|
+
* command and the process exits).
|
|
56
|
+
* - "dismiss": stay in the flow until Esc — used by the in-TUI `/resume`
|
|
57
|
+
* picker, which must not tear down the surrounding conversation.
|
|
58
|
+
*/
|
|
59
|
+
crossProjectMode?: "exit" | "dismiss";
|
|
60
|
+
}): React.ReactNode {
|
|
61
|
+
const [showAllProjects, setShowAllProjects] = useState(false);
|
|
62
|
+
const [showAllWorktrees, setShowAllWorktrees] = useState(false);
|
|
63
|
+
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
|
64
|
+
const [loading, setLoading] = useState(true);
|
|
65
|
+
const [crossProjectCommand, setCrossProjectCommand] = useState<string | null>(
|
|
66
|
+
null,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Mirror of the scope flags for use inside async reload/decision callbacks.
|
|
70
|
+
const scopeRef = useRef({ showAllProjects: false, showAllWorktrees: false });
|
|
71
|
+
|
|
72
|
+
const loadSessions = useCallback(
|
|
73
|
+
async (allProjects: boolean, allWorktrees: boolean) => {
|
|
74
|
+
if (allProjects) {
|
|
75
|
+
return listAllSessions();
|
|
76
|
+
}
|
|
77
|
+
if (allWorktrees) {
|
|
78
|
+
return listAllSessions({ worktreePaths, workdir: currentWorkdir });
|
|
79
|
+
}
|
|
80
|
+
return listSessions(currentWorkdir);
|
|
81
|
+
},
|
|
82
|
+
[worktreePaths, currentWorkdir],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const reload = useCallback(
|
|
86
|
+
async (allProjects: boolean, allWorktrees: boolean) => {
|
|
87
|
+
setLoading(true);
|
|
88
|
+
try {
|
|
89
|
+
const result = await loadSessions(allProjects, allWorktrees);
|
|
90
|
+
setSessions(
|
|
91
|
+
result.map((s) => ({
|
|
92
|
+
...s,
|
|
93
|
+
firstMessage: truncateContent(s.firstMessage || "No content", 80),
|
|
94
|
+
})),
|
|
95
|
+
);
|
|
96
|
+
} finally {
|
|
97
|
+
setLoading(false);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
[loadSessions],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Initial load: current directory scope
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
void reload(false, false);
|
|
106
|
+
}, [reload]);
|
|
107
|
+
|
|
108
|
+
const handleToggleAllProjects = useCallback(
|
|
109
|
+
(value: boolean) => {
|
|
110
|
+
scopeRef.current.showAllProjects = value;
|
|
111
|
+
setShowAllProjects(value);
|
|
112
|
+
void reload(value, scopeRef.current.showAllWorktrees);
|
|
113
|
+
},
|
|
114
|
+
[reload],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const handleToggleAllWorktrees = useCallback(
|
|
118
|
+
(value: boolean) => {
|
|
119
|
+
scopeRef.current.showAllWorktrees = value;
|
|
120
|
+
setShowAllWorktrees(value);
|
|
121
|
+
void reload(scopeRef.current.showAllProjects, value);
|
|
122
|
+
},
|
|
123
|
+
[reload],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const handleSelect = useCallback(
|
|
127
|
+
async (sessionId: string) => {
|
|
128
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
129
|
+
if (!session) return;
|
|
130
|
+
|
|
131
|
+
const ownership = await resolveSessionOwnership(session, {
|
|
132
|
+
worktreePaths,
|
|
133
|
+
currentWorkdir,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
switch (ownership.kind) {
|
|
137
|
+
case "current":
|
|
138
|
+
onDone({ sessionId });
|
|
139
|
+
return;
|
|
140
|
+
case "worktree":
|
|
141
|
+
onDone({ sessionId, resumeWorkdir: ownership.resumeWorkdir });
|
|
142
|
+
return;
|
|
143
|
+
case "cross-project":
|
|
144
|
+
// Different project — show the cd command instead of resuming in place
|
|
145
|
+
await setClipboardText(ownership.command);
|
|
146
|
+
setCrossProjectCommand(ownership.command);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[sessions, worktreePaths, currentWorkdir, onDone],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Ctrl+R rename. The session may belong to another project (or a sibling
|
|
154
|
+
// worktree), so the write is addressed by the session's own workdir — the
|
|
155
|
+
// SDK falls back to scanning every project directory when that path no
|
|
156
|
+
// longer matches. Rejections bubble up so the picker can show the failure.
|
|
157
|
+
const handleRename = useCallback(
|
|
158
|
+
async (sessionId: string, title: string) => {
|
|
159
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
160
|
+
await setSessionCustomTitle(
|
|
161
|
+
sessionId,
|
|
162
|
+
session?.workdir ?? currentWorkdir,
|
|
163
|
+
title,
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
[sessions, currentWorkdir],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (crossProjectCommand) {
|
|
170
|
+
return (
|
|
171
|
+
<CrossProjectMessage
|
|
172
|
+
command={crossProjectCommand}
|
|
173
|
+
onExit={() =>
|
|
174
|
+
crossProjectMode === "dismiss"
|
|
175
|
+
? setCrossProjectCommand(null)
|
|
176
|
+
: onDone(null)
|
|
177
|
+
}
|
|
178
|
+
autoExit={crossProjectMode === "exit"}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (loading) {
|
|
184
|
+
return (
|
|
185
|
+
<Box>
|
|
186
|
+
<Text dimColor>Loading conversations…</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<Box padding={1}>
|
|
193
|
+
<SessionSelector
|
|
194
|
+
sessions={sessions}
|
|
195
|
+
worktreePaths={worktreePaths}
|
|
196
|
+
showProjectPath={showAllProjects || showAllWorktrees}
|
|
197
|
+
onSelect={(id) => void handleSelect(id)}
|
|
198
|
+
onCancel={() => onDone(null)}
|
|
199
|
+
onToggleAllProjects={handleToggleAllProjects}
|
|
200
|
+
onToggleAllWorktrees={handleToggleAllWorktrees}
|
|
201
|
+
onRename={handleRename}
|
|
202
|
+
/>
|
|
203
|
+
</Box>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Cross-project notice. `wave -r` auto-exits after a short delay (mirrors
|
|
209
|
+
* Claude Code's resume picker, which prints the cd command and exits); the
|
|
210
|
+
* in-TUI `/resume` picker waits for Esc instead, so the conversation behind it
|
|
211
|
+
* survives.
|
|
212
|
+
*/
|
|
213
|
+
function CrossProjectMessage({
|
|
214
|
+
command,
|
|
215
|
+
onExit,
|
|
216
|
+
autoExit,
|
|
217
|
+
}: {
|
|
218
|
+
command: string;
|
|
219
|
+
onExit: () => void;
|
|
220
|
+
autoExit: boolean;
|
|
221
|
+
}): React.ReactNode {
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!autoExit) return;
|
|
224
|
+
const timer = setTimeout(onExit, 100);
|
|
225
|
+
return () => clearTimeout(timer);
|
|
226
|
+
}, [autoExit, onExit]);
|
|
227
|
+
|
|
228
|
+
useInput((_input, key) => {
|
|
229
|
+
if (!autoExit && key.escape) {
|
|
230
|
+
onExit();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Box flexDirection="column" gap={1}>
|
|
236
|
+
<Text>This conversation is from a different directory.</Text>
|
|
237
|
+
<Box flexDirection="column">
|
|
238
|
+
<Text>To resume, run:</Text>
|
|
239
|
+
<Text> {command}</Text>
|
|
240
|
+
</Box>
|
|
241
|
+
<Text dimColor>
|
|
242
|
+
{autoExit ? "(Command copied to clipboard)" : "Esc to go back"}
|
|
243
|
+
</Text>
|
|
244
|
+
</Box>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -56,6 +56,12 @@ export const AVAILABLE_COMMANDS: SlashCommand[] = [
|
|
|
56
56
|
description: "View and manage plugins",
|
|
57
57
|
handler: () => {}, // Handler here won't be used, actual processing is in the hook
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
id: "reload-plugins",
|
|
61
|
+
name: "reload-plugins",
|
|
62
|
+
description: "Reload plugins in place, without restarting the session",
|
|
63
|
+
handler: () => {}, // Handler here won't be used, actual processing is in the hook
|
|
64
|
+
},
|
|
59
65
|
{
|
|
60
66
|
id: "btw",
|
|
61
67
|
name: "btw",
|
|
@@ -92,6 +98,18 @@ export const AVAILABLE_COMMANDS: SlashCommand[] = [
|
|
|
92
98
|
description: "Enable plan mode or view the current session plan",
|
|
93
99
|
handler: () => {}, // Handler here won't be used, actual processing is in the hook
|
|
94
100
|
},
|
|
101
|
+
{
|
|
102
|
+
id: "resume",
|
|
103
|
+
name: "resume",
|
|
104
|
+
description: "Switch to another conversation without leaving the session",
|
|
105
|
+
handler: () => {}, // Handler here won't be used, actual processing is in the hook
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "rename",
|
|
109
|
+
name: "rename",
|
|
110
|
+
description: "Set a custom title for the current conversation",
|
|
111
|
+
handler: () => {}, // Handler here won't be used, actual processing is in the hook
|
|
112
|
+
},
|
|
95
113
|
{
|
|
96
114
|
id: "clear",
|
|
97
115
|
name: "clear",
|
package/src/contexts/useChat.tsx
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
type WorktreeSession,
|
|
32
32
|
OPERATION_CANCELLED_BY_USER,
|
|
33
33
|
extractLatestTotalTokens,
|
|
34
|
+
PLUGIN_CHANGE_PENDING_MESSAGE,
|
|
35
|
+
PLUGIN_RELOADED_MESSAGE,
|
|
34
36
|
} from "wave-agent-sdk";
|
|
35
37
|
import { logger } from "../utils/logger.js";
|
|
36
38
|
import { displayUsageSummary } from "../utils/usageSummary.js";
|
|
@@ -77,6 +79,17 @@ export interface ChatContextType {
|
|
|
77
79
|
clearMessages: () => Promise<void>;
|
|
78
80
|
compact: (instructions?: string) => Promise<void>;
|
|
79
81
|
addDir: (args?: string) => Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Set the current session's custom title (`/rename <title>`). A blank title
|
|
84
|
+
* only prints the usage hint — no AI-generated titles (spec: session-management.md).
|
|
85
|
+
*/
|
|
86
|
+
renameSession: (args?: string) => Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Switch to another conversation in-process (`/resume`). Pass `resumeWorkdir`
|
|
89
|
+
* when the target session lives in a sibling worktree of the same repo — the
|
|
90
|
+
* session then moves into that directory too.
|
|
91
|
+
*/
|
|
92
|
+
resumeSession: (sessionId: string, resumeWorkdir?: string) => Promise<void>;
|
|
80
93
|
abortMessage: () => void;
|
|
81
94
|
recallQueuedMessage: () => QueuedMessage | null;
|
|
82
95
|
removeQueuedMessageById: (id: string) => boolean;
|
|
@@ -131,6 +144,7 @@ export interface ChatContextType {
|
|
|
131
144
|
input?: Record<string, unknown>;
|
|
132
145
|
suggestedPrefix?: string;
|
|
133
146
|
hidePersistentOption?: boolean;
|
|
147
|
+
outsideSafeZoneDirectory?: string;
|
|
134
148
|
planContent?: string;
|
|
135
149
|
permissionMode?: PermissionMode;
|
|
136
150
|
warning?: string;
|
|
@@ -143,6 +157,7 @@ export interface ChatContextType {
|
|
|
143
157
|
planContent?: string,
|
|
144
158
|
permissionMode?: PermissionMode,
|
|
145
159
|
warning?: string,
|
|
160
|
+
outsideSafeZoneDirectory?: string,
|
|
146
161
|
) => Promise<PermissionDecision>;
|
|
147
162
|
hideConfirmation: () => void;
|
|
148
163
|
handleConfirmationDecision: (decision: PermissionDecision) => void;
|
|
@@ -164,8 +179,10 @@ export interface ChatContextType {
|
|
|
164
179
|
workingDirectory: string;
|
|
165
180
|
version?: string;
|
|
166
181
|
workdir?: string;
|
|
167
|
-
//
|
|
168
|
-
|
|
182
|
+
// Plugin in-place reload (docs/specs/ecosystem/plugin.md「插件变更的就地重载」)
|
|
183
|
+
reloadPlugins: () => Promise<void>;
|
|
184
|
+
notifyPluginChange: () => void;
|
|
185
|
+
pluginHint: string | null;
|
|
169
186
|
}
|
|
170
187
|
|
|
171
188
|
const ChatContext = createContext<ChatContextType | null>(null);
|
|
@@ -512,6 +529,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
512
529
|
input?: Record<string, unknown>;
|
|
513
530
|
suggestedPrefix?: string;
|
|
514
531
|
hidePersistentOption?: boolean;
|
|
532
|
+
outsideSafeZoneDirectory?: string;
|
|
515
533
|
planContent?: string;
|
|
516
534
|
permissionMode?: PermissionMode;
|
|
517
535
|
warning?: string;
|
|
@@ -524,6 +542,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
524
542
|
toolInput?: Record<string, unknown>;
|
|
525
543
|
suggestedPrefix?: string;
|
|
526
544
|
hidePersistentOption?: boolean;
|
|
545
|
+
outsideSafeZoneDirectory?: string;
|
|
527
546
|
planContent?: string;
|
|
528
547
|
permissionMode?: PermissionMode;
|
|
529
548
|
warning?: string;
|
|
@@ -536,6 +555,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
536
555
|
toolInput?: Record<string, unknown>;
|
|
537
556
|
suggestedPrefix?: string;
|
|
538
557
|
hidePersistentOption?: boolean;
|
|
558
|
+
outsideSafeZoneDirectory?: string;
|
|
539
559
|
planContent?: string;
|
|
540
560
|
permissionMode?: PermissionMode;
|
|
541
561
|
warning?: string;
|
|
@@ -592,6 +612,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
592
612
|
planContent?: string,
|
|
593
613
|
permissionMode?: PermissionMode,
|
|
594
614
|
warning?: string,
|
|
615
|
+
outsideSafeZoneDirectory?: string,
|
|
595
616
|
): Promise<PermissionDecision> => {
|
|
596
617
|
return new Promise<PermissionDecision>((resolve, reject) => {
|
|
597
618
|
const queueItem = {
|
|
@@ -599,6 +620,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
599
620
|
toolInput,
|
|
600
621
|
suggestedPrefix,
|
|
601
622
|
hidePersistentOption,
|
|
623
|
+
outsideSafeZoneDirectory,
|
|
602
624
|
planContent,
|
|
603
625
|
permissionMode,
|
|
604
626
|
warning,
|
|
@@ -775,6 +797,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
775
797
|
context.planContent,
|
|
776
798
|
context.permissionMode,
|
|
777
799
|
context.warning,
|
|
800
|
+
context.outsideSafeZoneDirectory,
|
|
778
801
|
);
|
|
779
802
|
} catch {
|
|
780
803
|
// If confirmation was cancelled or failed, deny the operation
|
|
@@ -892,32 +915,50 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
892
915
|
],
|
|
893
916
|
);
|
|
894
917
|
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
918
|
+
// 插件变更落盘后只置一个「待应用」信号并提示一次,不再重建 Agent;真正换装由
|
|
919
|
+
// 用户敲 /reload-plugins 触发,在当前会话内完成(docs/specs/ecosystem/plugin.md
|
|
920
|
+
// 「插件变更的就地重载」:会…不重建、不打断回合、不新建会话)。
|
|
921
|
+
const [pluginHint, setPluginHint] = useState<string | null>(null);
|
|
922
|
+
const pluginHintTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
923
|
+
|
|
924
|
+
const showPluginHint = useCallback((message: string) => {
|
|
925
|
+
setPluginHint(message);
|
|
926
|
+
if (pluginHintTimer.current) {
|
|
927
|
+
clearTimeout(pluginHintTimer.current);
|
|
904
928
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
929
|
+
// 一次性提示:数秒后自行消失,不留常驻标识(spec「插件变更提示」)。
|
|
930
|
+
pluginHintTimer.current = setTimeout(() => setPluginHint(null), 8000);
|
|
931
|
+
}, []);
|
|
932
|
+
|
|
933
|
+
const notifyPluginChange = useCallback(() => {
|
|
934
|
+
showPluginHint(PLUGIN_CHANGE_PENDING_MESSAGE);
|
|
935
|
+
}, [showPluginHint]);
|
|
936
|
+
|
|
937
|
+
const reloadPlugins = useCallback(async () => {
|
|
938
|
+
const agent = agentRef.current;
|
|
939
|
+
if (!agent) return;
|
|
940
|
+
try {
|
|
941
|
+
const result = await agent.reloadPlugins();
|
|
942
|
+
// 换装后本进程各会话的能力已变,UI 侧的命令 / 子代理 / 技能列表同步重读。
|
|
943
|
+
setSlashCommands(agent.getSlashCommands?.() || []);
|
|
944
|
+
setAgentDefinitions(agent.getSubagentConfigurations?.() || []);
|
|
945
|
+
setSkills(agent.getSkillMetadata?.() || []);
|
|
946
|
+
if (result.failures.length > 0) {
|
|
947
|
+
// 失败不回滚:如实提示,会话继续可用(spec 场景 11)。
|
|
948
|
+
showPluginHint(
|
|
949
|
+
`${PLUGIN_RELOADED_MESSAGE}部分插件加载失败:${result.failures
|
|
950
|
+
.map((failure) => `${failure.path}: ${failure.error}`)
|
|
951
|
+
.join(";")}`,
|
|
952
|
+
);
|
|
953
|
+
} else {
|
|
954
|
+
showPluginHint(PLUGIN_RELOADED_MESSAGE);
|
|
955
|
+
}
|
|
956
|
+
} catch (error) {
|
|
957
|
+
showPluginHint(
|
|
958
|
+
`插件重载失败:${error instanceof Error ? error.message : String(error)}`,
|
|
959
|
+
);
|
|
919
960
|
}
|
|
920
|
-
}, [
|
|
961
|
+
}, [showPluginHint]);
|
|
921
962
|
|
|
922
963
|
// Run initial agent creation
|
|
923
964
|
useEffect(() => {
|
|
@@ -1061,6 +1102,90 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1061
1102
|
);
|
|
1062
1103
|
}, []);
|
|
1063
1104
|
|
|
1105
|
+
// /rename: set the current conversation's custom title. The title is stored
|
|
1106
|
+
// as a reserved `custom-title` entry appended to the session's own JSONL
|
|
1107
|
+
// file, so `wave -r` and every GUI host read the same value. A blank title
|
|
1108
|
+
// only prints the usage hint — no AI-generated titles (spec:
|
|
1109
|
+
// session-management.md "不做 AI 自动标题").
|
|
1110
|
+
const renameSession = useCallback(async (args?: string) => {
|
|
1111
|
+
const agent = agentRef.current;
|
|
1112
|
+
if (!agent) return;
|
|
1113
|
+
|
|
1114
|
+
const title = (args ?? "").trim();
|
|
1115
|
+
const showResult = (content: string) => {
|
|
1116
|
+
setMessages((prev) => [
|
|
1117
|
+
...prev,
|
|
1118
|
+
{
|
|
1119
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
1120
|
+
role: "assistant",
|
|
1121
|
+
timestamp: new Date().toISOString(),
|
|
1122
|
+
blocks: [{ type: "text", content, stage: "end" }],
|
|
1123
|
+
},
|
|
1124
|
+
]);
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
if (!title) {
|
|
1128
|
+
showResult("Usage: /rename <title>");
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
try {
|
|
1133
|
+
await agent.renameSession(title);
|
|
1134
|
+
showResult(`Conversation renamed to "${title}".`);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
showResult(
|
|
1137
|
+
`Failed to rename conversation: ${
|
|
1138
|
+
error instanceof Error ? error.message : String(error)
|
|
1139
|
+
}`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
}, []);
|
|
1143
|
+
|
|
1144
|
+
// /resume: switch to another conversation without leaving the process. The
|
|
1145
|
+
// session id and its on-disk project directory move together, so the next
|
|
1146
|
+
// message is appended to the target's own transcript. A failed restore leaves
|
|
1147
|
+
// the current session untouched and only surfaces a notice.
|
|
1148
|
+
const resumeSession = useCallback(
|
|
1149
|
+
async (sessionId: string, resumeWorkdir?: string) => {
|
|
1150
|
+
const agent = agentRef.current;
|
|
1151
|
+
if (!agent) return;
|
|
1152
|
+
|
|
1153
|
+
try {
|
|
1154
|
+
await agent.restoreSession(
|
|
1155
|
+
sessionId,
|
|
1156
|
+
resumeWorkdir ? { workdir: resumeWorkdir } : undefined,
|
|
1157
|
+
);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
// UI-only notice (same display path as /add-dir): nothing was switched,
|
|
1160
|
+
// so nothing about the failed target is persisted.
|
|
1161
|
+
setMessages((prev) => [
|
|
1162
|
+
...prev,
|
|
1163
|
+
{
|
|
1164
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
1165
|
+
role: "assistant",
|
|
1166
|
+
timestamp: new Date().toISOString(),
|
|
1167
|
+
blocks: [
|
|
1168
|
+
{
|
|
1169
|
+
type: "error",
|
|
1170
|
+
content: `Failed to resume conversation: ${
|
|
1171
|
+
error instanceof Error ? error.message : String(error)
|
|
1172
|
+
}`,
|
|
1173
|
+
},
|
|
1174
|
+
],
|
|
1175
|
+
},
|
|
1176
|
+
]);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
setSessionId(agent.sessionId);
|
|
1181
|
+
setWorkingDirectory(agent.workingDirectory);
|
|
1182
|
+
setIsLoading(agent.isLoading);
|
|
1183
|
+
refreshMessages();
|
|
1184
|
+
forceRemount();
|
|
1185
|
+
},
|
|
1186
|
+
[forceRemount, refreshMessages],
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1064
1189
|
// Unified interrupt method, interrupt both AI messages and command execution
|
|
1065
1190
|
const abortMessage = useCallback(() => {
|
|
1066
1191
|
agentRef.current?.abortMessage();
|
|
@@ -1170,6 +1295,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1170
1295
|
input: next.toolInput,
|
|
1171
1296
|
suggestedPrefix: next.suggestedPrefix,
|
|
1172
1297
|
hidePersistentOption: next.hidePersistentOption,
|
|
1298
|
+
outsideSafeZoneDirectory: next.outsideSafeZoneDirectory,
|
|
1173
1299
|
planContent: next.planContent,
|
|
1174
1300
|
permissionMode: next.permissionMode,
|
|
1175
1301
|
warning: next.warning,
|
|
@@ -1257,7 +1383,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1257
1383
|
|
|
1258
1384
|
const setModel = useCallback((model: string) => {
|
|
1259
1385
|
if (agentRef.current) {
|
|
1260
|
-
agentRef.current.setModel(model);
|
|
1386
|
+
void agentRef.current.setModel(model);
|
|
1261
1387
|
setCurrentModelState(model);
|
|
1262
1388
|
}
|
|
1263
1389
|
}, []);
|
|
@@ -1307,6 +1433,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1307
1433
|
clearMessages,
|
|
1308
1434
|
compact,
|
|
1309
1435
|
addDir,
|
|
1436
|
+
renameSession,
|
|
1437
|
+
resumeSession,
|
|
1310
1438
|
abortMessage,
|
|
1311
1439
|
recallQueuedMessage,
|
|
1312
1440
|
removeQueuedMessageById,
|
|
@@ -1355,7 +1483,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1355
1483
|
workingDirectory,
|
|
1356
1484
|
version,
|
|
1357
1485
|
workdir,
|
|
1358
|
-
|
|
1486
|
+
reloadPlugins,
|
|
1487
|
+
notifyPluginChange,
|
|
1488
|
+
pluginHint,
|
|
1359
1489
|
};
|
|
1360
1490
|
|
|
1361
1491
|
return (
|