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.
Files changed (64) hide show
  1. package/dist/bundle/wave.mjs +1738 -893
  2. package/package.json +6 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/commands/update.ts +62 -22
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/ChatInterface.tsx +1 -0
  9. package/src/components/ConfirmationDetails.tsx +51 -16
  10. package/src/components/ConfirmationSelector.tsx +39 -0
  11. package/src/components/InputBox.tsx +41 -2
  12. package/src/components/Markdown.tsx +21 -8
  13. package/src/components/MarketplaceDetail.tsx +10 -27
  14. package/src/components/MarketplaceView.tsx +17 -3
  15. package/src/components/MessageBlockItem.tsx +0 -6
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +22 -17
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/components/WorkflowManager.tsx +38 -138
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +24 -0
  27. package/src/contexts/useChat.tsx +517 -155
  28. package/src/daemon/commands.ts +582 -50
  29. package/src/daemon-cli.ts +12 -1
  30. package/src/hooks/useInputManager.ts +20 -0
  31. package/src/hooks/usePluginManager.ts +104 -78
  32. package/src/hooks/useRemoteControl.ts +324 -0
  33. package/src/index.ts +97 -8
  34. package/src/managers/inputHandlers.ts +16 -9
  35. package/src/managers/inputReducer.ts +30 -9
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/confirmationReducer.ts +20 -1
  38. package/src/reducers/selectorReducer.ts +3 -0
  39. package/src/reducers/workflowManagerReducer.ts +21 -2
  40. package/src/remote/frameBudget.ts +45 -0
  41. package/src/remote/hostIdentity.ts +128 -0
  42. package/src/remote/messageWindow.ts +97 -0
  43. package/src/remote/notificationCoalescer.ts +174 -0
  44. package/src/remote/relayClient.ts +668 -0
  45. package/src/remote/remoteControlCliHost.ts +391 -0
  46. package/src/remote/remoteControlCommands.ts +197 -0
  47. package/src/remote/remoteControlConfig.ts +334 -0
  48. package/src/remote/remoteControlService.ts +799 -0
  49. package/src/remote/remoteControlSink.ts +77 -0
  50. package/src/remote/remoteNotifications.ts +194 -0
  51. package/src/session-selector-cli.tsx +9 -255
  52. package/src/stdio/agentBridge.ts +1001 -165
  53. package/src/stdio/daemonServer.ts +30 -0
  54. package/src/stdio/jsonRpcConnection.ts +11 -2
  55. package/src/stdio/protocol.ts +14 -106
  56. package/src/stdio/stdioServer.ts +33 -2
  57. package/src/stdio-cli.ts +16 -1
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/sessionOwnership.ts +62 -0
  62. package/src/utils/workspaceDiff.ts +620 -0
  63. package/src/utils/worktree.ts +97 -0
  64. package/src/components/PlanDisplay.tsx +0 -18
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The seam between a session holder and the outbound relay that serves a
3
+ * paired phone.
4
+ *
5
+ * Every process that holds sessions needs it, for the same reason and with the
6
+ * same shape:
7
+ *
8
+ * - `--daemon <socket>` — the resident process on a remote host;
9
+ * - `--stdio` — the child each GUI host (desktop / VS Code / JetBrains) spawns
10
+ * and drives over JSON-RPC;
11
+ * - the interactive CLI itself — the session the user is sitting in front of,
12
+ * which lives in that process's React state and nowhere else.
13
+ *
14
+ * Whichever one holds the sessions has to be the one serving them: a phone
15
+ * pointed at another process would show a different session set than the user
16
+ * is looking at. So the sink is built where the sessions are, never shared
17
+ * across processes — and, for the CLI, that means built on a `RemoteControlHost`
18
+ * the front end implements rather than on an `AgentBridge` (which the CLI has
19
+ * none of).
20
+ */
21
+
22
+ import { isRemoteControlEnabled } from "wave-agent-sdk";
23
+ import { resolveRemoteControlEndpoint } from "./remoteControlConfig.js";
24
+ import {
25
+ RemoteControlService,
26
+ type RemoteControlController,
27
+ type RemoteControlHost,
28
+ } from "./remoteControlService.js";
29
+
30
+ /**
31
+ * A second fan-out target for a server's notifications, beside its local
32
+ * clients (unix-socket clients for the daemon, the host's pipe for `--stdio`).
33
+ *
34
+ * Its lifecycle is deliberately decoupled from the client set: the daemon is
35
+ * resident and a paired phone keeps watching while no desktop is attached —
36
+ * that is the point of serving from the daemon rather than from a GUI host.
37
+ */
38
+ export interface RemoteNotificationSink {
39
+ /** Same signature as `JsonRpcConnection.sendNotification`. */
40
+ sendNotification(method: string, params: unknown, sessionId?: string): void;
41
+ stop(): void;
42
+ }
43
+
44
+ /**
45
+ * The two halves of a remote-control sink: the server fans notifications into
46
+ * it, and the bridge routes the three `remoteControl*` RPCs to it.
47
+ */
48
+ export interface RemoteControlSink
49
+ extends RemoteNotificationSink,
50
+ RemoteControlController {}
51
+
52
+ /**
53
+ * Build the sink factory a session holder takes, or `undefined` when remote
54
+ * control is switched off for this process.
55
+ *
56
+ * The switch is read **once**, here, at process start: a machine that was
57
+ * already running keeps its old answer, which is why the CLI hint says to
58
+ * restart (the daemon, or the TUI itself) after changing it. `workdir` decides
59
+ * which settings files are merged — the user-level `~/.wave/settings.json` is
60
+ * always consulted, the workdir's own `.wave/settings.json` only when the
61
+ * process was started there (a GUI host's `--stdio` child inherits the app's
62
+ * cwd, not the project's, so user-level is the reliable place to set it).
63
+ */
64
+ export function createRemoteControlSinkFactory(
65
+ workdir: string,
66
+ ): ((host: RemoteControlHost) => RemoteControlSink) | undefined {
67
+ // Opt-in twice over: this switch says the machine *may* serve its sessions to
68
+ // a paired phone, and `wave remote-control on` / `/remote-control` is what
69
+ // actually dials out. The sink is therefore built but not connected.
70
+ if (!isRemoteControlEnabled(workdir)) {
71
+ return undefined;
72
+ }
73
+ return (host: RemoteControlHost): RemoteControlSink =>
74
+ new RemoteControlService(host, {
75
+ resolveEndpoint: resolveRemoteControlEndpoint,
76
+ });
77
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The single table of notification **shapes**, shared by the two serving paths.
3
+ *
4
+ * `AgentBridge` (a daemon or a GUI-spawned `--stdio` child) puts the agent's
5
+ * callbacks straight onto the wire; the foreground TUI has no notification
6
+ * fan-out at all (`useRemoteControl` supplies it from its own React state). They
7
+ * are two different hosts — one has a session registry, the other a component
8
+ * tree — so each still decides *which* callbacks it emits. What must not exist in
9
+ * two copies is the **contract**: a method name and the fields of its payload.
10
+ * Both hosts build every frame they share from `remoteWire` below, so a payload
11
+ * change lands once and the phone cannot tell which process served it (spec:
12
+ * remote-control「前台 CLI 会话也能被手机接管」场景 10/11).
13
+ *
14
+ * What is deliberately *not* here: the families only a bridge emits (session
15
+ * registry reads, `tasksChange`, `queuedMessagesChange`, `commandRunningChange`,
16
+ * the compaction / plan / mcp changes). Nothing mirrors those, so there is no
17
+ * shape to keep in sync — and the foreground host is not required to invent one:
18
+ * the page reads that state off `getSessionInfo` on its alignment paths (spec
19
+ * 同故事场景 11).
20
+ */
21
+
22
+ import type {
23
+ AgentCallbacks,
24
+ BackgroundTask,
25
+ BackgroundTaskSummary,
26
+ PermissionMode,
27
+ ToolBlockUpdateCallbackParams,
28
+ ToolPermissionContext,
29
+ } from "wave-agent-sdk";
30
+
31
+ /**
32
+ * A content delta exactly as the SDK callback hands it over — derived rather
33
+ * than re-declared, so a field added there cannot leave this table describing a
34
+ * shape the agent does not produce.
35
+ */
36
+ type StreamDelta = Parameters<
37
+ NonNullable<AgentCallbacks["onAssistantContentUpdated"]>
38
+ >[0];
39
+
40
+ /**
41
+ * Context-window usage as the percentage the ring renders — one rule for every
42
+ * producer (the bridge's change push and its `getMessages` read, the CLI host's
43
+ * push and its `getSessionInfo` read). 0 when the cap is unknown, so a caller
44
+ * that has one reads a number rather than a special case.
45
+ */
46
+ export function percentOf(tokens: number, maxInputTokens: number): number {
47
+ if (!maxInputTokens || maxInputTokens <= 0) return 0;
48
+ return Math.min(100, Math.round((tokens / maxInputTokens) * 100));
49
+ }
50
+
51
+ /** The shapes both hosts put on the wire, keyed by the method they ride under. */
52
+ export const remoteWire = {
53
+ /**
54
+ * A pure-delta frame carries the delta and nothing else — no accumulated value,
55
+ * so a notification's size is independent of how long the stream has been
56
+ * running (spec: stdio-transport「流式通知纯增量负载」). Explicit pick rather than
57
+ * a spread, so a field added to the SDK callback does not silently join the wire.
58
+ */
59
+ assistantContentUpdated: (params: StreamDelta) => ({
60
+ messageId: params.messageId,
61
+ chunk: params.chunk,
62
+ stage: params.stage,
63
+ }),
64
+
65
+ assistantReasoningUpdated: (params: StreamDelta) => ({
66
+ messageId: params.messageId,
67
+ chunk: params.chunk,
68
+ stage: params.stage,
69
+ }),
70
+
71
+ /**
72
+ * `streaming` frames carry only the delta (`parametersChunk`), while `start` /
73
+ * `running` (one-time snapshots) and `end` (the authoritative full value) keep
74
+ * `parameters`. The coalescer's merge rule is keyed on exactly this shape, so
75
+ * this pick is what makes the 150 ms window behave the same in both hosts.
76
+ */
77
+ toolBlockUpdated: (params: ToolBlockUpdateCallbackParams) => {
78
+ if (params.stage !== "streaming") return params;
79
+ const { parameters, ...rest } = params;
80
+ void parameters;
81
+ return rest;
82
+ },
83
+
84
+ errorBlockAdded: (error: string) => ({ error }),
85
+
86
+ loadingChange: (loading: boolean, latestTotalTokens?: number) => ({
87
+ loading,
88
+ ...(latestTotalTokens === undefined ? {} : { latestTotalTokens }),
89
+ }),
90
+
91
+ contextUsage: (latestTotalTokens: number, maxInputTokens: number) => ({
92
+ percent: percentOf(latestTotalTokens, maxInputTokens),
93
+ }),
94
+
95
+ permissionModeChange: (mode: PermissionMode) => ({ mode }),
96
+
97
+ permissionRequest: (requestId: string, context: ToolPermissionContext) => ({
98
+ requestId,
99
+ context,
100
+ }),
101
+
102
+ /**
103
+ * `messagesChange` has exactly one shape now — content-free, on **every** path
104
+ * (spec「messagesChange 只有一种形态」): the bridge's re-attach push, the
105
+ * foreground CLI's structural notice and the relay's fan-out all carry `{}`.
106
+ * The phone answers it with `getMessages({ since })`; no producer attaches the
107
+ * list.
108
+ */
109
+ messagesChangeSignal: () => ({}),
110
+ } as const;
111
+
112
+ /** The methods this table defines — the only ones a mirroring host may send. */
113
+ export type RemoteMethod = keyof typeof remoteWire;
114
+
115
+ /**
116
+ * A background task (shell / subagent / workflow) as it may travel: the
117
+ * serializable half of `BackgroundTask`.
118
+ *
119
+ * A shape, hence this table's business — the two hosts each own the *list* (the
120
+ * bridge reads it off the agent on every snapshot, the foreground TUI off its own
121
+ * state) and both have to describe a task the same way, or the `/tasks` dialog
122
+ * renders two different things depending on which process served the page. The
123
+ * fields dropped here are the reasons it exists: `process` (a `ChildProcess`) and
124
+ * `onStop` (a function) cannot go over a relay, and the full `stdout`/`stderr` are
125
+ * fetched per task instead (`getBackgroundTaskOutput`).
126
+ */
127
+ export function toBackgroundTaskSummaries(
128
+ tasks: BackgroundTask[],
129
+ ): BackgroundTaskSummary[] {
130
+ return tasks.map((task) => ({
131
+ id: task.id,
132
+ type: task.type,
133
+ status: task.status,
134
+ startTime: task.startTime,
135
+ endTime: task.endTime,
136
+ command: task.command,
137
+ description: task.description,
138
+ exitCode: task.exitCode,
139
+ runtime: task.runtime,
140
+ outputPath: task.outputPath,
141
+ }));
142
+ }
143
+
144
+ /**
145
+ * What `RemoteControlSink.sendNotification` expects of a frame, minus the
146
+ * session it belongs to (the sink already knows). The method is narrowed to the
147
+ * table's keys so a caller cannot mirror a family that has no agreed shape.
148
+ */
149
+ export type RemoteNotify = (method: RemoteMethod, params: unknown) => void;
150
+
151
+ /**
152
+ * The callbacks whose **payload** a foreground host has to mirror, mapped onto
153
+ * the remote path under the bridge's method names.
154
+ *
155
+ * One rule decides membership: **can a pull get the change back?** A streaming
156
+ * delta grows a reply the phone is already holding, and an error block is
157
+ * appended *inside* the last message — in both cases the `since` anchor is that
158
+ * very message, so a signal would be a round trip that returns nothing and the
159
+ * change would never reach the phone (spec remote-control「前台 CLI 会话也能被手机
160
+ * 接管」场景 10/11). Structural changes (the list's id composition) and the state
161
+ * the page reads off `getSessionInfo` travel on the hook's own signals instead.
162
+ */
163
+ export function remoteNotifications(
164
+ notify: RemoteNotify,
165
+ ): Required<
166
+ Pick<
167
+ AgentCallbacks,
168
+ | "onAssistantContentUpdated"
169
+ | "onAssistantReasoningUpdated"
170
+ | "onToolBlockUpdated"
171
+ | "onErrorBlockAdded"
172
+ >
173
+ > {
174
+ return {
175
+ onAssistantContentUpdated: (params) => {
176
+ notify(
177
+ "assistantContentUpdated",
178
+ remoteWire.assistantContentUpdated(params),
179
+ );
180
+ },
181
+ onAssistantReasoningUpdated: (params) => {
182
+ notify(
183
+ "assistantReasoningUpdated",
184
+ remoteWire.assistantReasoningUpdated(params),
185
+ );
186
+ },
187
+ onToolBlockUpdated: (params) => {
188
+ notify("toolBlockUpdated", remoteWire.toolBlockUpdated(params));
189
+ },
190
+ onErrorBlockAdded: (error: string) => {
191
+ notify("errorBlockAdded", remoteWire.errorBlockAdded(error));
192
+ },
193
+ };
194
+ }
@@ -1,263 +1,17 @@
1
- import React, { useState, useCallback, useEffect, useRef } from "react";
2
- import { render, Box, Text } from "ink";
3
- import { existsSync } from "fs";
1
+ import React from "react";
2
+ import { render } from "ink";
4
3
  import {
5
- PathEncoder,
6
- listSessions,
7
- listAllSessions,
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 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
- /** 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
- * Cross-project message auto-exits after a short delay (mirrors Claude Code's
235
- * resume picker, which prints the cd command and exits).
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 = [],