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
|
@@ -2,12 +2,14 @@ import { Key } from "ink";
|
|
|
2
2
|
import type { PermissionDecision, PermissionMode } from "wave-agent-sdk";
|
|
3
3
|
import {
|
|
4
4
|
BASH_TOOL_NAME,
|
|
5
|
+
EDIT_TOOL_NAME,
|
|
5
6
|
EXIT_PLAN_MODE_TOOL_NAME,
|
|
6
7
|
ENTER_PLAN_MODE_TOOL_NAME,
|
|
8
|
+
WRITE_TOOL_NAME,
|
|
7
9
|
} from "wave-agent-sdk";
|
|
8
10
|
|
|
9
11
|
export interface ConfirmationState {
|
|
10
|
-
selectedOption: "auto" | "bypass" | "allow" | "alternative";
|
|
12
|
+
selectedOption: "auto" | "bypass" | "allow" | "addDirectory" | "alternative";
|
|
11
13
|
alternativeText: string;
|
|
12
14
|
alternativeCursorPosition: number;
|
|
13
15
|
hasUserInput: boolean;
|
|
@@ -30,6 +32,7 @@ export type ConfirmationAction =
|
|
|
30
32
|
toolInput?: Record<string, unknown>;
|
|
31
33
|
suggestedPrefix?: string;
|
|
32
34
|
hidePersistentOption?: boolean;
|
|
35
|
+
outsideSafeZoneDirectory?: string;
|
|
33
36
|
permissionMode?: PermissionMode;
|
|
34
37
|
};
|
|
35
38
|
|
|
@@ -96,6 +99,7 @@ export function confirmationReducer(
|
|
|
96
99
|
toolInput,
|
|
97
100
|
suggestedPrefix,
|
|
98
101
|
hidePersistentOption,
|
|
102
|
+
outsideSafeZoneDirectory,
|
|
99
103
|
permissionMode,
|
|
100
104
|
} = action;
|
|
101
105
|
|
|
@@ -135,6 +139,16 @@ export function confirmationReducer(
|
|
|
135
139
|
behavior: "allow",
|
|
136
140
|
newPermissionMode: "bypassPermissions",
|
|
137
141
|
};
|
|
142
|
+
} else if (
|
|
143
|
+
state.selectedOption === "addDirectory" &&
|
|
144
|
+
outsideSafeZoneDirectory
|
|
145
|
+
) {
|
|
146
|
+
// Approve this operation AND add the out-of-Safe-Zone target
|
|
147
|
+
// directory to this session's Safe Zone (in-memory only).
|
|
148
|
+
decision = {
|
|
149
|
+
behavior: "allow",
|
|
150
|
+
newAdditionalDirectory: outsideSafeZoneDirectory,
|
|
151
|
+
};
|
|
138
152
|
} else if (state.alternativeText.trim()) {
|
|
139
153
|
decision = {
|
|
140
154
|
behavior: "deny",
|
|
@@ -176,6 +190,11 @@ export function confirmationReducer(
|
|
|
176
190
|
|
|
177
191
|
const availableOptions: ConfirmationState["selectedOption"][] = [];
|
|
178
192
|
availableOptions.push("allow");
|
|
193
|
+
if (
|
|
194
|
+
(toolName === EDIT_TOOL_NAME || toolName === WRITE_TOOL_NAME) &&
|
|
195
|
+
outsideSafeZoneDirectory
|
|
196
|
+
)
|
|
197
|
+
availableOptions.push("addDirectory");
|
|
179
198
|
if (!hidePersistentOption) availableOptions.push("auto");
|
|
180
199
|
if (
|
|
181
200
|
toolName === EXIT_PLAN_MODE_TOOL_NAME ||
|
|
@@ -15,6 +15,7 @@ export type SelectorAction<T = unknown> =
|
|
|
15
15
|
| { type: "MOVE_DOWN" }
|
|
16
16
|
| { type: "RESET_INDEX" }
|
|
17
17
|
| { type: "SET_ITEMS"; items: T[] }
|
|
18
|
+
| { type: "SET_INDEX"; index: number }
|
|
18
19
|
| { type: "HANDLE_KEY"; key: Key; hasInsert: boolean }
|
|
19
20
|
| { type: "TOGGLE_ALL_PROJECTS" }
|
|
20
21
|
| { type: "TOGGLE_ALL_WORKTREES" }
|
|
@@ -39,6 +40,8 @@ export function selectorReducer<T = unknown>(
|
|
|
39
40
|
return { ...state, selectedIndex: 0 };
|
|
40
41
|
case "SET_ITEMS":
|
|
41
42
|
return { ...state, items: action.items, selectedIndex: 0 };
|
|
43
|
+
case "SET_INDEX":
|
|
44
|
+
return { ...state, selectedIndex: action.index };
|
|
42
45
|
case "TOGGLE_ALL_PROJECTS":
|
|
43
46
|
return { ...state, showAllProjects: !state.showAllProjects };
|
|
44
47
|
case "TOGGLE_ALL_WORKTREES":
|
|
@@ -1,263 +1,17 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import { render
|
|
3
|
-
import { existsSync } from "fs";
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "ink";
|
|
4
3
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
truncateContent,
|
|
9
|
-
type SessionMetadata,
|
|
10
|
-
} from "wave-agent-sdk";
|
|
11
|
-
import { SessionSelector } from "./components/SessionSelector.js";
|
|
12
|
-
import { setClipboardText } from "./utils/clipboard.js";
|
|
4
|
+
SessionSelectorFlow,
|
|
5
|
+
type SessionSelectorResult,
|
|
6
|
+
} from "./components/SessionSelectorFlow.js";
|
|
13
7
|
|
|
14
|
-
export
|
|
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
|
-
/** POSIX single-quote wrapping for use inside a `cd <path> && ...` command. */
|
|
26
|
-
function quotePath(p: string): string {
|
|
27
|
-
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type SessionOwnership =
|
|
31
|
-
| { kind: "current" }
|
|
32
|
-
| { kind: "worktree"; resumeWorkdir: string }
|
|
33
|
-
| { kind: "cross-project"; command: string };
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Decide how a selected session relates to the current working directory.
|
|
37
|
-
*
|
|
38
|
-
* Ownership is decided by comparing encoded directory names — the decoded
|
|
39
|
-
* workdir is lossy for paths containing "-", but re-encoding it reproduces the
|
|
40
|
-
* original encoded directory name exactly.
|
|
41
|
-
*
|
|
42
|
-
* - current: session belongs to the current directory → resume in place
|
|
43
|
-
* - worktree: session belongs to a sibling same-repo worktree → resume
|
|
44
|
-
* after chdir into it (skipped when the worktree dir is gone)
|
|
45
|
-
* - cross-project: session belongs to another project → print a cd command
|
|
46
|
-
*/
|
|
47
|
-
export async function resolveSessionOwnership(
|
|
48
|
-
session: { id: string; workdir: string },
|
|
49
|
-
opts: { worktreePaths: string[]; currentWorkdir: string },
|
|
50
|
-
): Promise<SessionOwnership> {
|
|
51
|
-
const encoder = new PathEncoder();
|
|
52
|
-
// encode() resolves the path via realpath and throws ENOENT when the
|
|
53
|
-
// directory no longer exists (e.g. a deleted worktree); fall back to the
|
|
54
|
-
// pure string encodeSync so the picker still works for ghost sessions.
|
|
55
|
-
let sessionEncoded: string;
|
|
56
|
-
try {
|
|
57
|
-
sessionEncoded = await encoder.encode(session.workdir);
|
|
58
|
-
} catch {
|
|
59
|
-
sessionEncoded = encoder.encodeSync(session.workdir);
|
|
60
|
-
}
|
|
61
|
-
const cwdEncoded = await encoder.encode(opts.currentWorkdir);
|
|
62
|
-
|
|
63
|
-
if (sessionEncoded === cwdEncoded) {
|
|
64
|
-
return { kind: "current" };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
for (const wt of opts.worktreePaths) {
|
|
68
|
-
let wtEncoded: string;
|
|
69
|
-
try {
|
|
70
|
-
wtEncoded = await encoder.encode(wt);
|
|
71
|
-
} catch {
|
|
72
|
-
wtEncoded = encoder.encodeSync(wt);
|
|
73
|
-
}
|
|
74
|
-
if (wtEncoded === sessionEncoded && existsSync(wt)) {
|
|
75
|
-
return { kind: "worktree", resumeWorkdir: wt };
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
kind: "cross-project",
|
|
81
|
-
command: `cd ${quotePath(session.workdir)} && wave --restore ${session.id}`,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Interactive session picker for `wave -r` (restore without an ID).
|
|
87
|
-
*
|
|
88
|
-
* Three scopes, toggled with Ctrl+A / Ctrl+W (mirroring Claude Code's resume
|
|
89
|
-
* picker):
|
|
90
|
-
* - current: sessions in the current working directory (default)
|
|
91
|
-
* - worktrees: sessions in any worktree of the same git repository
|
|
92
|
-
* - all: sessions across every project directory
|
|
93
|
-
*
|
|
94
|
-
* Resume decision on selection:
|
|
95
|
-
* - current dir session → resume in place
|
|
96
|
-
* - same-repo worktree → resume after chdir into that worktree
|
|
97
|
-
* - other project → print `cd <path> && wave --restore <id>`,
|
|
98
|
-
* copy it to the clipboard, and exit
|
|
99
|
-
*/
|
|
100
|
-
function SessionSelectorFlow({
|
|
101
|
-
currentWorkdir,
|
|
102
|
-
worktreePaths,
|
|
103
|
-
onDone,
|
|
104
|
-
}: {
|
|
105
|
-
currentWorkdir: string;
|
|
106
|
-
worktreePaths: string[];
|
|
107
|
-
onDone: (result: SessionSelectorResult | null) => void;
|
|
108
|
-
}): React.ReactNode {
|
|
109
|
-
const [showAllProjects, setShowAllProjects] = useState(false);
|
|
110
|
-
const [showAllWorktrees, setShowAllWorktrees] = useState(false);
|
|
111
|
-
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
|
112
|
-
const [loading, setLoading] = useState(true);
|
|
113
|
-
const [crossProjectCommand, setCrossProjectCommand] = useState<string | null>(
|
|
114
|
-
null,
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
// Mirror of the scope flags for use inside async reload/decision callbacks.
|
|
118
|
-
const scopeRef = useRef({ showAllProjects: false, showAllWorktrees: false });
|
|
119
|
-
|
|
120
|
-
const loadSessions = useCallback(
|
|
121
|
-
async (allProjects: boolean, allWorktrees: boolean) => {
|
|
122
|
-
if (allProjects) {
|
|
123
|
-
return listAllSessions();
|
|
124
|
-
}
|
|
125
|
-
if (allWorktrees) {
|
|
126
|
-
return listAllSessions({ worktreePaths, workdir: currentWorkdir });
|
|
127
|
-
}
|
|
128
|
-
return listSessions(currentWorkdir);
|
|
129
|
-
},
|
|
130
|
-
[worktreePaths, currentWorkdir],
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
const reload = useCallback(
|
|
134
|
-
async (allProjects: boolean, allWorktrees: boolean) => {
|
|
135
|
-
setLoading(true);
|
|
136
|
-
try {
|
|
137
|
-
const result = await loadSessions(allProjects, allWorktrees);
|
|
138
|
-
setSessions(
|
|
139
|
-
result.map((s) => ({
|
|
140
|
-
...s,
|
|
141
|
-
firstMessage: truncateContent(s.firstMessage || "No content", 80),
|
|
142
|
-
})),
|
|
143
|
-
);
|
|
144
|
-
} finally {
|
|
145
|
-
setLoading(false);
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
[loadSessions],
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
// Initial load: current directory scope
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
void reload(false, false);
|
|
154
|
-
}, [reload]);
|
|
155
|
-
|
|
156
|
-
const handleToggleAllProjects = useCallback(
|
|
157
|
-
(value: boolean) => {
|
|
158
|
-
scopeRef.current.showAllProjects = value;
|
|
159
|
-
setShowAllProjects(value);
|
|
160
|
-
void reload(value, scopeRef.current.showAllWorktrees);
|
|
161
|
-
},
|
|
162
|
-
[reload],
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const handleToggleAllWorktrees = useCallback(
|
|
166
|
-
(value: boolean) => {
|
|
167
|
-
scopeRef.current.showAllWorktrees = value;
|
|
168
|
-
setShowAllWorktrees(value);
|
|
169
|
-
void reload(scopeRef.current.showAllProjects, value);
|
|
170
|
-
},
|
|
171
|
-
[reload],
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
const handleSelect = useCallback(
|
|
175
|
-
async (sessionId: string) => {
|
|
176
|
-
const session = sessions.find((s) => s.id === sessionId);
|
|
177
|
-
if (!session) return;
|
|
178
|
-
|
|
179
|
-
const ownership = await resolveSessionOwnership(session, {
|
|
180
|
-
worktreePaths,
|
|
181
|
-
currentWorkdir,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
switch (ownership.kind) {
|
|
185
|
-
case "current":
|
|
186
|
-
onDone({ sessionId });
|
|
187
|
-
return;
|
|
188
|
-
case "worktree":
|
|
189
|
-
onDone({ sessionId, resumeWorkdir: ownership.resumeWorkdir });
|
|
190
|
-
return;
|
|
191
|
-
case "cross-project":
|
|
192
|
-
// Different project — show the cd command instead of resuming in place
|
|
193
|
-
await setClipboardText(ownership.command);
|
|
194
|
-
setCrossProjectCommand(ownership.command);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
[sessions, worktreePaths, currentWorkdir, onDone],
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
if (crossProjectCommand) {
|
|
202
|
-
return (
|
|
203
|
-
<CrossProjectMessage
|
|
204
|
-
command={crossProjectCommand}
|
|
205
|
-
onExit={() => onDone(null)}
|
|
206
|
-
/>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (loading) {
|
|
211
|
-
return (
|
|
212
|
-
<Box>
|
|
213
|
-
<Text dimColor>Loading conversations…</Text>
|
|
214
|
-
</Box>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return (
|
|
219
|
-
<Box padding={1}>
|
|
220
|
-
<SessionSelector
|
|
221
|
-
sessions={sessions}
|
|
222
|
-
worktreePaths={worktreePaths}
|
|
223
|
-
showProjectPath={showAllProjects || showAllWorktrees}
|
|
224
|
-
onSelect={(id) => void handleSelect(id)}
|
|
225
|
-
onCancel={() => onDone(null)}
|
|
226
|
-
onToggleAllProjects={handleToggleAllProjects}
|
|
227
|
-
onToggleAllWorktrees={handleToggleAllWorktrees}
|
|
228
|
-
/>
|
|
229
|
-
</Box>
|
|
230
|
-
);
|
|
231
|
-
}
|
|
8
|
+
export type { SessionSelectorResult };
|
|
232
9
|
|
|
233
10
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
11
|
+
* Interactive session picker for `wave -r` (restore without an ID), rendered in
|
|
12
|
+
* its own Ink instance before the main TUI starts. The picker itself is shared
|
|
13
|
+
* with the in-TUI `/resume` command (see `SessionSelectorFlow`).
|
|
236
14
|
*/
|
|
237
|
-
function CrossProjectMessage({
|
|
238
|
-
command,
|
|
239
|
-
onExit,
|
|
240
|
-
}: {
|
|
241
|
-
command: string;
|
|
242
|
-
onExit: () => void;
|
|
243
|
-
}): React.ReactNode {
|
|
244
|
-
useEffect(() => {
|
|
245
|
-
const timer = setTimeout(onExit, 100);
|
|
246
|
-
return () => clearTimeout(timer);
|
|
247
|
-
}, [onExit]);
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<Box flexDirection="column" gap={1}>
|
|
251
|
-
<Text>This conversation is from a different directory.</Text>
|
|
252
|
-
<Box flexDirection="column">
|
|
253
|
-
<Text>To resume, run:</Text>
|
|
254
|
-
<Text> {command}</Text>
|
|
255
|
-
</Box>
|
|
256
|
-
<Text dimColor>(Command copied to clipboard)</Text>
|
|
257
|
-
</Box>
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
15
|
export async function startSessionSelectorCli({
|
|
262
16
|
workdir,
|
|
263
17
|
worktreePaths = [],
|