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
|
@@ -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
|
|
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 = [],
|