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
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
useReducer,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Box, Text, useInput, type Key } from "ink";
|
|
3
9
|
import type { SessionMetadata } from "wave-agent-sdk";
|
|
4
10
|
import {
|
|
5
11
|
selectorReducer,
|
|
@@ -23,8 +29,22 @@ export interface SessionSelectorProps {
|
|
|
23
29
|
onToggleAllProjects?: (showAllProjects: boolean) => void;
|
|
24
30
|
/** Emitted with the new scope when the user toggles all-worktrees (Ctrl+W). */
|
|
25
31
|
onToggleAllWorktrees?: (showAllWorktrees: boolean) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Ctrl+R renames the focused session. The parent persists the new title
|
|
34
|
+
* (the session may belong to another project, so only it knows the file);
|
|
35
|
+
* the row shows the new title as soon as the promise resolves. Rejecting
|
|
36
|
+
* keeps the old title and surfaces a visible failure notice.
|
|
37
|
+
*/
|
|
38
|
+
onRename?: (sessionId: string, title: string) => Promise<void>;
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
type RenameState = {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
value: string;
|
|
44
|
+
cursor: number;
|
|
45
|
+
saving: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
28
48
|
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
29
49
|
sessions,
|
|
30
50
|
onSelect,
|
|
@@ -33,6 +53,7 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
33
53
|
showProjectPath = false,
|
|
34
54
|
onToggleAllProjects,
|
|
35
55
|
onToggleAllWorktrees,
|
|
56
|
+
onRename,
|
|
36
57
|
}) => {
|
|
37
58
|
const [state, dispatch] = useReducer(selectorReducer<SessionItem>, {
|
|
38
59
|
selectedIndex: 0,
|
|
@@ -50,12 +71,113 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
50
71
|
showAllWorktrees,
|
|
51
72
|
} = state;
|
|
52
73
|
|
|
74
|
+
// Titles committed during this picker session. The parent's list is only
|
|
75
|
+
// re-synced through SET_ITEMS (which resets the selection), so overlaying the
|
|
76
|
+
// new title here keeps the row, the scope and the selected index intact.
|
|
77
|
+
const [renamedTitles, setRenamedTitles] = useState<Record<string, string>>(
|
|
78
|
+
{},
|
|
79
|
+
);
|
|
80
|
+
const [renaming, setRenaming] = useState<RenameState | null>(null);
|
|
81
|
+
const [renameError, setRenameError] = useState<string | null>(null);
|
|
82
|
+
|
|
53
83
|
// Sync sessions into reducer state
|
|
54
84
|
useEffect(() => {
|
|
55
85
|
dispatch({ type: "SET_ITEMS", items: sessions });
|
|
56
86
|
}, [sessions]);
|
|
57
87
|
|
|
88
|
+
const submitRename = useCallback(async () => {
|
|
89
|
+
if (!renaming || renaming.saving) return;
|
|
90
|
+
const { sessionId, value } = renaming;
|
|
91
|
+
const title = value.trim();
|
|
92
|
+
// Blank title: nothing to save, just leave the editor (spec: 空标题为 no-op).
|
|
93
|
+
if (!title) {
|
|
94
|
+
setRenaming(null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setRenaming({ ...renaming, saving: true });
|
|
98
|
+
try {
|
|
99
|
+
await onRename?.(sessionId, title);
|
|
100
|
+
setRenamedTitles((prev) => ({ ...prev, [sessionId]: title }));
|
|
101
|
+
setRenameError(null);
|
|
102
|
+
setRenaming(null);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
setRenaming(null);
|
|
105
|
+
setRenameError(
|
|
106
|
+
`Failed to rename: ${
|
|
107
|
+
error instanceof Error ? error.message : String(error)
|
|
108
|
+
}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}, [renaming, onRename]);
|
|
112
|
+
|
|
113
|
+
const handleRenameKeys = (input: string, key: Key) => {
|
|
114
|
+
// While saving, swallow everything — the editor closes on the response.
|
|
115
|
+
if (!renaming || renaming.saving) return;
|
|
116
|
+
|
|
117
|
+
if (key.escape) {
|
|
118
|
+
setRenaming(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.return) {
|
|
122
|
+
void submitRename();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (key.backspace || key.delete) {
|
|
126
|
+
if (renaming.cursor === 0) return;
|
|
127
|
+
const nextCursor = renaming.cursor - 1;
|
|
128
|
+
setRenaming({
|
|
129
|
+
...renaming,
|
|
130
|
+
value:
|
|
131
|
+
renaming.value.slice(0, nextCursor) +
|
|
132
|
+
renaming.value.slice(renaming.cursor),
|
|
133
|
+
cursor: nextCursor,
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (key.leftArrow) {
|
|
138
|
+
setRenaming({
|
|
139
|
+
...renaming,
|
|
140
|
+
cursor: Math.max(0, renaming.cursor - 1),
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (key.rightArrow) {
|
|
145
|
+
setRenaming({
|
|
146
|
+
...renaming,
|
|
147
|
+
cursor: Math.min(renaming.value.length, renaming.cursor + 1),
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
input &&
|
|
153
|
+
!key.ctrl &&
|
|
154
|
+
!key.meta &&
|
|
155
|
+
!key.return &&
|
|
156
|
+
!key.escape &&
|
|
157
|
+
!key.backspace &&
|
|
158
|
+
!key.delete &&
|
|
159
|
+
!key.leftArrow &&
|
|
160
|
+
!key.rightArrow
|
|
161
|
+
) {
|
|
162
|
+
setRenaming({
|
|
163
|
+
...renaming,
|
|
164
|
+
value:
|
|
165
|
+
renaming.value.slice(0, renaming.cursor) +
|
|
166
|
+
input +
|
|
167
|
+
renaming.value.slice(renaming.cursor),
|
|
168
|
+
cursor: renaming.cursor + input.length,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// One subscription for both modes: while the inline title editor is open it
|
|
174
|
+
// owns every key (spec: 编辑期间按键不得冒泡触发列表操作), so a single handler
|
|
175
|
+
// keeps the editor from racing a second `useInput` subscription.
|
|
58
176
|
useInput((input, key) => {
|
|
177
|
+
if (renaming) {
|
|
178
|
+
handleRenameKeys(input, key);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
59
181
|
// Ctrl+A — toggle all-projects scope (only when the parent supports it)
|
|
60
182
|
if (key.ctrl && input.toLowerCase() === "a" && onToggleAllProjects) {
|
|
61
183
|
dispatch({ type: "TOGGLE_ALL_PROJECTS" });
|
|
@@ -71,6 +193,24 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
71
193
|
dispatch({ type: "TOGGLE_ALL_WORKTREES" });
|
|
72
194
|
return;
|
|
73
195
|
}
|
|
196
|
+
// Ctrl+R — rename the focused session in place
|
|
197
|
+
if (key.ctrl && input.toLowerCase() === "r" && onRename) {
|
|
198
|
+
const target = items[selectedIndex];
|
|
199
|
+
if (!target) return;
|
|
200
|
+
const initial =
|
|
201
|
+
renamedTitles[target.id] ??
|
|
202
|
+
target.customTitle ??
|
|
203
|
+
target.firstMessage ??
|
|
204
|
+
"";
|
|
205
|
+
setRenameError(null);
|
|
206
|
+
setRenaming({
|
|
207
|
+
sessionId: target.id,
|
|
208
|
+
value: initial,
|
|
209
|
+
cursor: initial.length,
|
|
210
|
+
saving: false,
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
74
214
|
dispatch({ type: "HANDLE_KEY", key, hasInsert: false });
|
|
75
215
|
});
|
|
76
216
|
|
|
@@ -135,6 +275,14 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
135
275
|
startIndex + MAX_VISIBLE_ITEMS,
|
|
136
276
|
);
|
|
137
277
|
|
|
278
|
+
// Display precedence: custom title > first message > placeholder (shared with
|
|
279
|
+
// every other host — spec: session-management.md 用户故事「会话自定义标题」).
|
|
280
|
+
const sessionTitle = (session: SessionItem): string =>
|
|
281
|
+
renamedTitles[session.id] ??
|
|
282
|
+
session.customTitle ??
|
|
283
|
+
session.firstMessage ??
|
|
284
|
+
"";
|
|
285
|
+
|
|
138
286
|
const hints = [
|
|
139
287
|
onToggleAllProjects
|
|
140
288
|
? `Ctrl+A ${showAllProjects ? "current directory" : "all projects"}`
|
|
@@ -142,6 +290,7 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
142
290
|
onToggleAllWorktrees && worktreePaths.length > 1
|
|
143
291
|
? `Ctrl+W ${showAllWorktrees ? "current worktree" : "all worktrees"}`
|
|
144
292
|
: null,
|
|
293
|
+
onRename ? "Ctrl+R rename" : null,
|
|
145
294
|
"↑↓ select",
|
|
146
295
|
"Enter confirm",
|
|
147
296
|
"Esc cancel",
|
|
@@ -171,6 +320,7 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
171
320
|
const actualIndex = startIndex + index;
|
|
172
321
|
const isSelected = actualIndex === selectedIndex;
|
|
173
322
|
const lastActiveAt = new Date(session.lastActiveAt).toLocaleString();
|
|
323
|
+
const isRenaming = renaming?.sessionId === session.id;
|
|
174
324
|
|
|
175
325
|
return (
|
|
176
326
|
<Box key={session.id} flexDirection="column" width="100%">
|
|
@@ -200,12 +350,28 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
200
350
|
</Text>
|
|
201
351
|
</Box>
|
|
202
352
|
</Box>
|
|
203
|
-
{
|
|
353
|
+
{isRenaming && renaming ? (
|
|
204
354
|
<Box marginLeft={2} width="100%">
|
|
205
|
-
<Text
|
|
206
|
-
|
|
355
|
+
<Text>
|
|
356
|
+
<Text dimColor>Rename: </Text>
|
|
357
|
+
{renaming.value.slice(0, renaming.cursor)}
|
|
358
|
+
<Text backgroundColor="white" color="black">
|
|
359
|
+
{renaming.cursor < renaming.value.length
|
|
360
|
+
? renaming.value[renaming.cursor]
|
|
361
|
+
: " "}
|
|
362
|
+
</Text>
|
|
363
|
+
{renaming.value.slice(renaming.cursor + 1)}
|
|
207
364
|
</Text>
|
|
208
365
|
</Box>
|
|
366
|
+
) : (
|
|
367
|
+
isSelected &&
|
|
368
|
+
sessionTitle(session) && (
|
|
369
|
+
<Box marginLeft={2} width="100%">
|
|
370
|
+
<Text dimColor italic wrap="truncate-end">
|
|
371
|
+
{sessionTitle(session).replace(/\n/g, "\\n")}
|
|
372
|
+
</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
)
|
|
209
375
|
)}
|
|
210
376
|
</Box>
|
|
211
377
|
);
|
|
@@ -220,8 +386,20 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
220
386
|
</Box>
|
|
221
387
|
)}
|
|
222
388
|
|
|
389
|
+
{renameError && (
|
|
390
|
+
<Box>
|
|
391
|
+
<Text color="red">{renameError}</Text>
|
|
392
|
+
</Box>
|
|
393
|
+
)}
|
|
394
|
+
|
|
223
395
|
<Box>
|
|
224
|
-
<Text dimColor>
|
|
396
|
+
<Text dimColor>
|
|
397
|
+
{renaming
|
|
398
|
+
? renaming.saving
|
|
399
|
+
? "Saving…"
|
|
400
|
+
: "Enter save · Esc cancel"
|
|
401
|
+
: hints}
|
|
402
|
+
</Text>
|
|
225
403
|
</Box>
|
|
226
404
|
</Box>
|
|
227
405
|
);
|
|
@@ -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
|
+
}
|