@fresh-editor/fresh-editor 0.3.5 → 0.3.7
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/CHANGELOG.md +147 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +84 -0
- package/plugins/audit_mode.ts +139 -3
- package/plugins/config-schema.json +33 -3
- package/plugins/dashboard.ts +34 -111
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +705 -323
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/goto_with_selection.i18n.json +58 -0
- package/plugins/goto_with_selection.ts +17 -0
- package/plugins/lib/fresh.d.ts +911 -15
- package/plugins/lib/index.ts +34 -0
- package/plugins/lib/widgets.ts +903 -0
- package/plugins/live_diff.ts +442 -32
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +3425 -0
- package/plugins/pkg.ts +235 -54
- package/plugins/rust-lsp.ts +58 -40
- package/plugins/schemas/theme.schema.json +18 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +1335 -515
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +112 -0
- package/plugins/theme_editor.ts +30 -5
- package/plugins/tsconfig.json +3 -0
- package/plugins/vi_mode.ts +49 -17
- package/themes/dark.json +1 -0
- package/themes/dracula.json +1 -0
- package/themes/high-contrast.json +1 -0
- package/themes/light.json +1 -0
- package/themes/nord.json +1 -0
- package/themes/nostalgia.json +1 -0
- package/themes/solarized-dark.json +1 -0
- package/themes/terminal.json +4 -0
|
@@ -0,0 +1,3425 @@
|
|
|
1
|
+
/// <reference path="./lib/fresh.d.ts" />
|
|
2
|
+
//
|
|
3
|
+
// Orchestrator — multi-agent / multi-worktree session orchestration.
|
|
4
|
+
//
|
|
5
|
+
// MVP scope (`docs/internal/orchestrator-sessions-design.md`):
|
|
6
|
+
//
|
|
7
|
+
// - "Orchestrator: Open" opens a floating overlay prompt listing
|
|
8
|
+
// every session with its state column. Up/Down navigates,
|
|
9
|
+
// Enter dives into the selected session.
|
|
10
|
+
// - "Orchestrator: New Session" opens a single floating widget
|
|
11
|
+
// form with three optional fields (session name, agent
|
|
12
|
+
// command, branch), allocates a worktree-rooted session and
|
|
13
|
+
// spawns the agent in a terminal attached to it.
|
|
14
|
+
// - "Orchestrator: Kill Selected" closes the session whose row is
|
|
15
|
+
// currently highlighted in the open prompt.
|
|
16
|
+
// - Agent state column updates from terminal_output regex and
|
|
17
|
+
// terminal_exit code: RUNNING / AWAITING / READY / ERRORED.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
button,
|
|
21
|
+
col,
|
|
22
|
+
flexSpacer,
|
|
23
|
+
FloatingWidgetPanel,
|
|
24
|
+
hintBar,
|
|
25
|
+
key as widgetKey,
|
|
26
|
+
labeledSection,
|
|
27
|
+
list,
|
|
28
|
+
row,
|
|
29
|
+
overlay,
|
|
30
|
+
spacer,
|
|
31
|
+
styledRow,
|
|
32
|
+
text,
|
|
33
|
+
textInputChar,
|
|
34
|
+
toggle,
|
|
35
|
+
windowEmbed,
|
|
36
|
+
type WidgetSpec,
|
|
37
|
+
} from "./lib/widgets.ts";
|
|
38
|
+
|
|
39
|
+
const editor = getEditor();
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Types
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
type AgentState = "running" | "awaiting" | "ready" | "errored" | "killed";
|
|
46
|
+
|
|
47
|
+
// One row in the completion popup. `kind: "history"` items
|
|
48
|
+
// render with a leading `↶` marker + italic styling so the user
|
|
49
|
+
// can tell at-a-glance that the row came from their submission
|
|
50
|
+
// history rather than from the live completion source. Sent to
|
|
51
|
+
// the host via `formPanel.setCompletions`; the host renders the
|
|
52
|
+
// marker + style.
|
|
53
|
+
type CompletionItem = { value: string; kind?: "history" };
|
|
54
|
+
|
|
55
|
+
interface AgentSession {
|
|
56
|
+
// Editor's stable session id.
|
|
57
|
+
id: number;
|
|
58
|
+
// Display label (defaults to root basename — Orchestrator never
|
|
59
|
+
// renames externally-created sessions).
|
|
60
|
+
label: string;
|
|
61
|
+
// Absolute filesystem root.
|
|
62
|
+
root: string;
|
|
63
|
+
// Canonical project root this session belongs to (set at
|
|
64
|
+
// create time from the Project Path field). `null` for
|
|
65
|
+
// sessions created outside the new-session form (e.g. the
|
|
66
|
+
// editor's base session, or sessions from before the
|
|
67
|
+
// Project Path field shipped).
|
|
68
|
+
projectPath: string | null;
|
|
69
|
+
// `true` if the session was created with the worktree
|
|
70
|
+
// checkbox unchecked (shared worktree / non-git path).
|
|
71
|
+
sharedWorktree: boolean;
|
|
72
|
+
// The terminal id Orchestrator spawned in this session, if any.
|
|
73
|
+
terminalId: number | null;
|
|
74
|
+
// Last parsed agent state. "active" is computed at render
|
|
75
|
+
// time from `editor.activeWindow()`, not stored.
|
|
76
|
+
state: AgentState;
|
|
77
|
+
// Wall-clock ms when orchestrator.new fired createWindow.
|
|
78
|
+
createdAt: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Module state — editor-global, survives every dive.
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
const orchestratorSessions = new Map<number, AgentSession>();
|
|
86
|
+
|
|
87
|
+
// Pending session-creation intent. Stashed across the
|
|
88
|
+
// async `createWindow → window_created hook` handoff so the
|
|
89
|
+
// hook handler can attach the spawned terminal. (Internally
|
|
90
|
+
// the editor calls these "windows"; Orchestrator still presents
|
|
91
|
+
// them as "sessions" in its UX.)
|
|
92
|
+
let pendingNewSession:
|
|
93
|
+
| {
|
|
94
|
+
label: string;
|
|
95
|
+
branch: string;
|
|
96
|
+
cmd: string;
|
|
97
|
+
root: string;
|
|
98
|
+
// Recorded for `setWindowState` after `window_created`.
|
|
99
|
+
// `projectPath` is the canonical project root the user
|
|
100
|
+
// pointed the form at; `sharedWorktree` is `true` when the
|
|
101
|
+
// session shares its working tree (no dedicated `git
|
|
102
|
+
// worktree add`).
|
|
103
|
+
projectPath: string;
|
|
104
|
+
sharedWorktree: boolean;
|
|
105
|
+
}
|
|
106
|
+
| null = null;
|
|
107
|
+
|
|
108
|
+
// New-session form state. `null` ⇒ the floating form isn't
|
|
109
|
+
// open. Each field's `value` + `cursor` mirrors what the host
|
|
110
|
+
// renders inside the panel's TextInput widgets; the `submitting`
|
|
111
|
+
// flag debounces double-Enter on the Create button; `lastError`
|
|
112
|
+
// is rendered as a styled error row inside the form when the
|
|
113
|
+
// most recent submit failed (status bar would get clobbered —
|
|
114
|
+
// see MEMORY.md).
|
|
115
|
+
interface NewSessionForm {
|
|
116
|
+
// Project Path: the directory the session is rooted at. When
|
|
117
|
+
// `createWorktree` is true (default for git paths) this is
|
|
118
|
+
// the *base* repo for `git worktree add`. When false, this
|
|
119
|
+
// is the session root itself (no git interaction).
|
|
120
|
+
projectPath: { value: string; cursor: number };
|
|
121
|
+
name: { value: string; cursor: number };
|
|
122
|
+
cmd: { value: string; cursor: number };
|
|
123
|
+
branch: { value: string; cursor: number };
|
|
124
|
+
// Whether to create a new git worktree under
|
|
125
|
+
// `<XDG>/orchestrator/<slug>/<session>/` (true) or run the
|
|
126
|
+
// session directly inside `projectPath` (false). Enabled
|
|
127
|
+
// only when the resolved `projectPath` is inside a git
|
|
128
|
+
// working tree (`projectPathIsGit === true`). Forced to
|
|
129
|
+
// false on non-git paths and the checkbox is disabled.
|
|
130
|
+
createWorktree: boolean;
|
|
131
|
+
submitting: boolean;
|
|
132
|
+
lastError: string | null;
|
|
133
|
+
// Resolved canonical project root from the editor's cwd —
|
|
134
|
+
// surfaced as the Project Path placeholder. Empty while the
|
|
135
|
+
// async probe runs at `openForm` time.
|
|
136
|
+
defaultProjectPath: string;
|
|
137
|
+
// `true`: resolved Project Path is inside a git working
|
|
138
|
+
// tree (worktree checkbox enabled). `false`: non-git path
|
|
139
|
+
// (checkbox disabled, branch field inert). `null`: probe
|
|
140
|
+
// in flight (keep checkbox in its last-known state).
|
|
141
|
+
projectPathIsGit: boolean | null;
|
|
142
|
+
// Concrete session name the auto-generator would produce
|
|
143
|
+
// for the current Project Path (e.g. "session-3"). Surfaced
|
|
144
|
+
// as the Session Name placeholder so the user sees the
|
|
145
|
+
// exact name an empty submit would create. Empty while the
|
|
146
|
+
// refs probe runs.
|
|
147
|
+
defaultSessionName: string;
|
|
148
|
+
// Resolved default branch (e.g. "origin/main"). Empty while
|
|
149
|
+
// the async `git fetch + symbolic-ref` probe is in flight;
|
|
150
|
+
// the branch input's placeholder reads this so the user sees
|
|
151
|
+
// the exact base ref the worktree will fork off if they
|
|
152
|
+
// leave the field blank.
|
|
153
|
+
defaultBranch: string;
|
|
154
|
+
// True when the default branch fell through to bare `HEAD`
|
|
155
|
+
// because no `origin` is configured. Surfaced in the
|
|
156
|
+
// placeholder as `HEAD (no origin configured)` so the user
|
|
157
|
+
// knows why.
|
|
158
|
+
defaultBranchIsHeadFallback: boolean;
|
|
159
|
+
// Previously-submitted Agent Command (persisted across editor
|
|
160
|
+
// sessions via `orchestrator.last_cmd`). Rendered as the cmd
|
|
161
|
+
// field's *placeholder*, and used as the actual command when
|
|
162
|
+
// the user leaves the field blank — submitting "" with a
|
|
163
|
+
// visible placeholder of "python3" was confusing because the
|
|
164
|
+
// host ignored the hint and spawned a bare shell. Now the
|
|
165
|
+
// placeholder is the command if the value is empty.
|
|
166
|
+
lastCmd: string;
|
|
167
|
+
// True when this form was opened from the picker (Alt+N or
|
|
168
|
+
// the "+ New Session" button). On cancel (Esc / Cancel
|
|
169
|
+
// button) we re-open the picker so the user lands back where
|
|
170
|
+
// they were instead of being dropped into the bare editor.
|
|
171
|
+
fromPicker: boolean;
|
|
172
|
+
// Token incremented every time the user changes the Project
|
|
173
|
+
// Path field. Async probes (is-git, session-name, default-
|
|
174
|
+
// branch) capture the token at launch and bail on result if
|
|
175
|
+
// a newer token has been issued — prevents stale probes from
|
|
176
|
+
// overwriting fresh state on rapid typing.
|
|
177
|
+
probeToken: number;
|
|
178
|
+
// Per-field input-history cursor. -1 = "not in history"
|
|
179
|
+
// (showing the user's current draft). 0 = most recent, 1 =
|
|
180
|
+
// older, etc. (Now only consulted by the host-side `↶`
|
|
181
|
+
// history rows mixed into the completion popup — Up/Down on a
|
|
182
|
+
// history-bearing field reopens the popup, where historical
|
|
183
|
+
// entries appear after live completion candidates.)
|
|
184
|
+
historyCursor: { project_path: number; name: number; cmd: number; branch: number };
|
|
185
|
+
// Saved draft text per field: when the user first presses Up
|
|
186
|
+
// we squirrel away whatever was in `value` so Down can
|
|
187
|
+
// restore it.
|
|
188
|
+
historyDraft: { project_path: string; name: string; cmd: string; branch: string };
|
|
189
|
+
// Inline-dropdown completion state. `field` names which input
|
|
190
|
+
// the suggestion list belongs to; the list is only rendered
|
|
191
|
+
// while that input is focused. `items` is the post-filter set
|
|
192
|
+
// (already in display order); `selectedIndex` is the
|
|
193
|
+
// highlighted row. `anchor` is the value the user had typed
|
|
194
|
+
// when the candidates were last fetched — used to ignore
|
|
195
|
+
// stale async results that land after the user keeps typing.
|
|
196
|
+
// `token` mirrors the project-path probe pattern: every fresh
|
|
197
|
+
// fetch bumps it; results bail if they're not the latest.
|
|
198
|
+
completion: {
|
|
199
|
+
field: "project_path" | "branch" | null;
|
|
200
|
+
items: CompletionItem[];
|
|
201
|
+
selectedIndex: number;
|
|
202
|
+
anchor: string;
|
|
203
|
+
token: number;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
let form: NewSessionForm | null = null;
|
|
207
|
+
let formPanel: FloatingWidgetPanel | null = null;
|
|
208
|
+
|
|
209
|
+
const NEW_SESSION_MODE = "orchestrator-new-form";
|
|
210
|
+
|
|
211
|
+
// Open dialog state. `null` ⇒ the picker isn't mounted. Lives
|
|
212
|
+
// alongside the new-session form state but is independent of
|
|
213
|
+
// it — the two dialogs share the orchestrator mode plumbing but
|
|
214
|
+
// not their data.
|
|
215
|
+
interface OpenDialogState {
|
|
216
|
+
// Filter input value + cursor byte. Mirrors what the host
|
|
217
|
+
// renders inside the panel's filter TextInput.
|
|
218
|
+
filter: { value: string; cursor: number };
|
|
219
|
+
// Subset of `orchestratorSessions` keys that pass the filter,
|
|
220
|
+
// in display order. Recomputed on every filter change.
|
|
221
|
+
filteredIds: number[];
|
|
222
|
+
// The selection inside the list widget. The host owns the
|
|
223
|
+
// authoritative copy as instance state; this mirror lets
|
|
224
|
+
// `buildOpenSpec` render the matching preview pane without a
|
|
225
|
+
// round-trip.
|
|
226
|
+
selectedIndex: number;
|
|
227
|
+
// Active session at the moment the dialog opened. Recorded
|
|
228
|
+
// so a future "Esc restores active" affordance has the
|
|
229
|
+
// anchor it needs.
|
|
230
|
+
originalActiveSession: number;
|
|
231
|
+
// When non-null, the preview pane swaps to a confirmation
|
|
232
|
+
// panel for the named action against the named session id.
|
|
233
|
+
// Cleared on Cancel or after the action completes.
|
|
234
|
+
pendingConfirm:
|
|
235
|
+
| { action: "stop" | "archive" | "delete"; sessionId: number }
|
|
236
|
+
| null;
|
|
237
|
+
// Rows the embed reserves and rows the sessions list shows.
|
|
238
|
+
// Captured once at dialog-open from the editor's viewport so
|
|
239
|
+
// the layout stays constant across re-renders — recomputing
|
|
240
|
+
// mid-dialog would let the size jitter when the active
|
|
241
|
+
// window's viewport changes (e.g. terminal buffer's shorter
|
|
242
|
+
// height vs. a file buffer's).
|
|
243
|
+
listVisibleRows: number;
|
|
244
|
+
embedRows: number;
|
|
245
|
+
// Toggle between "compact preview" (default — buttons + live
|
|
246
|
+
// embed only, no info row) and "details" (state + path metadata
|
|
247
|
+
// row visible above the embed). Compact is the default because
|
|
248
|
+
// the embed is the part the user actually wants to see; the
|
|
249
|
+
// metadata row is rarely read and just eats embed height.
|
|
250
|
+
showDetails: boolean;
|
|
251
|
+
// The session id whose lifecycle action (archive / delete) is
|
|
252
|
+
// currently running. While set:
|
|
253
|
+
// - that session's preview pane swaps to an "Archiving…" /
|
|
254
|
+
// "Deleting…" panel with no action buttons, so the user
|
|
255
|
+
// sees the operation is in flight rather than wondering
|
|
256
|
+
// why their click took no effect.
|
|
257
|
+
// - the user can still navigate to other sessions and act on
|
|
258
|
+
// them; only the in-flight session is disabled.
|
|
259
|
+
// Cleared by the async handler on success or failure. The row
|
|
260
|
+
// disappears from the list naturally once the editor's
|
|
261
|
+
// `window_closed` hook fires `refreshOpenDialog`.
|
|
262
|
+
inFlight: { action: "archive" | "delete"; sessionId: number } | null;
|
|
263
|
+
// Last user-visible error from a refused lifecycle action
|
|
264
|
+
// (e.g. "cannot archive the base session", "dive elsewhere
|
|
265
|
+
// first…"). Rendered as a banner row above the filter so it's
|
|
266
|
+
// hard to miss — the status bar at the bottom of the screen is
|
|
267
|
+
// too easy to skip over when the user's eyes are on the dialog.
|
|
268
|
+
// Cleared on the next nav / filter change.
|
|
269
|
+
lastError: string | null;
|
|
270
|
+
}
|
|
271
|
+
let openDialog: OpenDialogState | null = null;
|
|
272
|
+
let openPanel: FloatingWidgetPanel | null = null;
|
|
273
|
+
const OPEN_MODE = "orchestrator-open";
|
|
274
|
+
|
|
275
|
+
// =============================================================================
|
|
276
|
+
// Session-list reconciliation
|
|
277
|
+
// =============================================================================
|
|
278
|
+
|
|
279
|
+
function reconcileSessions(): void {
|
|
280
|
+
const editorSessions = editor.listWindows();
|
|
281
|
+
const seen = new Set<number>();
|
|
282
|
+
for (const s of editorSessions) {
|
|
283
|
+
seen.add(s.id);
|
|
284
|
+
const existing = orchestratorSessions.get(s.id);
|
|
285
|
+
if (!existing) {
|
|
286
|
+
orchestratorSessions.set(s.id, {
|
|
287
|
+
id: s.id,
|
|
288
|
+
label: s.label,
|
|
289
|
+
root: s.root,
|
|
290
|
+
projectPath: s.project_path ?? null,
|
|
291
|
+
sharedWorktree: s.shared_worktree ?? false,
|
|
292
|
+
terminalId: null,
|
|
293
|
+
// The base session has no agent; everything else
|
|
294
|
+
// defaults to "running" until a terminal_output /
|
|
295
|
+
// terminal_exit arrives.
|
|
296
|
+
state: "running",
|
|
297
|
+
createdAt: Date.now(),
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
existing.label = s.label;
|
|
301
|
+
existing.root = s.root;
|
|
302
|
+
if (s.project_path != null) existing.projectPath = s.project_path;
|
|
303
|
+
if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const id of orchestratorSessions.keys()) {
|
|
307
|
+
if (!seen.has(id)) orchestratorSessions.delete(id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// =============================================================================
|
|
312
|
+
// Session display helpers
|
|
313
|
+
// =============================================================================
|
|
314
|
+
|
|
315
|
+
const STATE_GLYPH: Record<AgentState, string> = {
|
|
316
|
+
running: "RUN ",
|
|
317
|
+
awaiting: "WAIT",
|
|
318
|
+
ready: "DONE",
|
|
319
|
+
errored: "ERR ",
|
|
320
|
+
killed: "KILL",
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function ageString(createdAt: number): string {
|
|
324
|
+
const sec = Math.max(0, Math.floor((Date.now() - createdAt) / 1000));
|
|
325
|
+
if (sec < 60) return `${sec}s`;
|
|
326
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
327
|
+
return `${Math.floor(sec / 3600)}h`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// =============================================================================
|
|
331
|
+
// Open dialog — widget-based session picker (Phase 1 of the
|
|
332
|
+
// open-dialog redesign; see docs/internal/
|
|
333
|
+
// orchestrator-open-dialog-and-lifecycle.md).
|
|
334
|
+
//
|
|
335
|
+
// Dive is the only action the dialog wires up directly. Other
|
|
336
|
+
// lifecycle commands (Stop / Archive / Delete / New) ship in
|
|
337
|
+
// later phases. New session is still reachable through the
|
|
338
|
+
// "Orchestrator: New Session" palette command in the meantime.
|
|
339
|
+
// =============================================================================
|
|
340
|
+
|
|
341
|
+
// Case-insensitive substring match over a session's label and
|
|
342
|
+
// root path. Ordering: prefix-of-label hits beat substring hits,
|
|
343
|
+
// then ties broken by label length so shorter matches surface
|
|
344
|
+
// first. Empty needle returns the full list in numeric-id order.
|
|
345
|
+
//
|
|
346
|
+
// The picker is cross-project by design — every session is a
|
|
347
|
+
// candidate regardless of which project the active window
|
|
348
|
+
// points at — so there is no project-scope filter here.
|
|
349
|
+
function filterSessions(needle: string): number[] {
|
|
350
|
+
reconcileSessions();
|
|
351
|
+
const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
|
|
352
|
+
if (!needle) return ids;
|
|
353
|
+
const n = needle.toLowerCase();
|
|
354
|
+
type Scored = { id: number; score: number; len: number };
|
|
355
|
+
const matches: Scored[] = [];
|
|
356
|
+
for (const id of ids) {
|
|
357
|
+
const s = orchestratorSessions.get(id)!;
|
|
358
|
+
const label = s.label.toLowerCase();
|
|
359
|
+
const root = s.root.toLowerCase();
|
|
360
|
+
if (label.startsWith(n)) {
|
|
361
|
+
matches.push({ id, score: 0, len: label.length });
|
|
362
|
+
} else if (label.includes(n)) {
|
|
363
|
+
matches.push({ id, score: 1, len: label.length });
|
|
364
|
+
} else if (root.includes(n)) {
|
|
365
|
+
matches.push({ id, score: 2, len: label.length });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
matches.sort((a, b) => a.score - b.score || a.len - b.len || a.id - b.id);
|
|
369
|
+
return matches.map((m) => m.id);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build one rendered list-item row for `id`. Style cues:
|
|
373
|
+
// * `[id]` in `ui.help_key_fg`
|
|
374
|
+
// * `ACT` (active session) in `ui.tab_active_fg` + bold
|
|
375
|
+
// * other states use the default fg
|
|
376
|
+
// * label in default fg
|
|
377
|
+
function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
378
|
+
const s = orchestratorSessions.get(id);
|
|
379
|
+
if (!s) {
|
|
380
|
+
return styledRow([{ text: `[${id}] (unknown)` }]);
|
|
381
|
+
}
|
|
382
|
+
const isActive = id === activeId;
|
|
383
|
+
const isBase = id === 1;
|
|
384
|
+
const stateText = isActive ? "ACT " : STATE_GLYPH[s.state];
|
|
385
|
+
const entries: { text: string; style?: Record<string, unknown> }[] = [
|
|
386
|
+
{ text: `[${id}] `, style: { fg: "ui.help_key_fg" } },
|
|
387
|
+
{
|
|
388
|
+
text: stateText,
|
|
389
|
+
style: isActive
|
|
390
|
+
? { fg: "ui.tab_active_fg", bold: true }
|
|
391
|
+
: { fg: "ui.menu_disabled_fg" },
|
|
392
|
+
},
|
|
393
|
+
{ text: ` ${s.label}` },
|
|
394
|
+
];
|
|
395
|
+
// BASE badge: the base session is the editor process itself —
|
|
396
|
+
// archive / delete would close the editor (and possibly destroy
|
|
397
|
+
// the user's current worktree), so the host refuses both. The
|
|
398
|
+
// badge makes that special status visible up-front instead of
|
|
399
|
+
// surfacing as "Archive disabled, why?" after a Tab.
|
|
400
|
+
if (isBase) {
|
|
401
|
+
entries.push({
|
|
402
|
+
text: " BASE",
|
|
403
|
+
style: { fg: "ui.help_key_fg", bold: true },
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// SHARED badge for the row when this session shares its
|
|
407
|
+
// worktree (with another session or with the project root
|
|
408
|
+
// directly). Mirrors the preview pane's badge so the
|
|
409
|
+
// shared-worktree status is visible at-a-glance from the
|
|
410
|
+
// list too.
|
|
411
|
+
if (s.sharedWorktree || countSiblingsAtRoot(s.root) > 1) {
|
|
412
|
+
entries.push({
|
|
413
|
+
text: " ⇄",
|
|
414
|
+
style: { fg: "ui.menu_disabled_fg" },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return styledRow(entries as Parameters<typeof styledRow>[0]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Preview-pane content for the currently selected session.
|
|
421
|
+
// Plain info for Phase 1; later phases append pgid/pids + the
|
|
422
|
+
// last terminal lines.
|
|
423
|
+
function buildPreviewEntries(
|
|
424
|
+
s: AgentSession | undefined,
|
|
425
|
+
): TextPropertyEntry[] {
|
|
426
|
+
if (!s) {
|
|
427
|
+
return [
|
|
428
|
+
styledRow([
|
|
429
|
+
{
|
|
430
|
+
text: "No session selected",
|
|
431
|
+
style: { fg: "editor.whitespace_indicator_fg", italic: true },
|
|
432
|
+
},
|
|
433
|
+
]),
|
|
434
|
+
];
|
|
435
|
+
}
|
|
436
|
+
const activeId = editor.activeWindow();
|
|
437
|
+
const isActive = s.id === activeId;
|
|
438
|
+
const isBase = s.id === 1;
|
|
439
|
+
const stateText = isActive ? "ACT" : STATE_GLYPH[s.state].trim();
|
|
440
|
+
// Count siblings sharing the same `root`. The set includes
|
|
441
|
+
// `s` itself; `> 1` means at least one other session lives at
|
|
442
|
+
// the same path (shared-worktree mode, or two sessions
|
|
443
|
+
// explicitly aimed at the same directory).
|
|
444
|
+
const sharedCount = countSiblingsAtRoot(s.root);
|
|
445
|
+
const headerEntries: { text: string; style?: Record<string, unknown> }[] = [
|
|
446
|
+
{
|
|
447
|
+
text: stateText,
|
|
448
|
+
style: isActive
|
|
449
|
+
? { fg: "ui.tab_active_fg", bold: true }
|
|
450
|
+
: { fg: "ui.menu_disabled_fg" },
|
|
451
|
+
},
|
|
452
|
+
{ text: " " },
|
|
453
|
+
{ text: ageString(s.createdAt), style: { fg: "ui.menu_disabled_fg" } },
|
|
454
|
+
];
|
|
455
|
+
if (isBase) {
|
|
456
|
+
// BASE badge in the preview — the long-form counterpart to
|
|
457
|
+
// the list-row badge, with an inline explanation so the user
|
|
458
|
+
// doesn't have to wonder why Stop / Archive / Delete are
|
|
459
|
+
// greyed out.
|
|
460
|
+
headerEntries.push(
|
|
461
|
+
{ text: " " },
|
|
462
|
+
{
|
|
463
|
+
text: "BASE",
|
|
464
|
+
style: { fg: "ui.help_key_fg", bold: true },
|
|
465
|
+
},
|
|
466
|
+
{ text: " — editor session", style: { fg: "ui.menu_disabled_fg", italic: true } },
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (sharedCount > 1) {
|
|
470
|
+
headerEntries.push(
|
|
471
|
+
{ text: " " },
|
|
472
|
+
{
|
|
473
|
+
text: `SHARED ×${sharedCount}`,
|
|
474
|
+
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
} else if (s.sharedWorktree) {
|
|
478
|
+
// Single-session shared-worktree mode (the user opted out of
|
|
479
|
+
// a dedicated worktree even though no second session is on
|
|
480
|
+
// this root yet). Still worth surfacing so the user knows
|
|
481
|
+
// why Archive / Delete refuse to run a `git worktree
|
|
482
|
+
// remove` here.
|
|
483
|
+
headerEntries.push(
|
|
484
|
+
{ text: " " },
|
|
485
|
+
{
|
|
486
|
+
text: "SHARED",
|
|
487
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return [
|
|
492
|
+
styledRow(headerEntries as Parameters<typeof styledRow>[0]),
|
|
493
|
+
styledRow([
|
|
494
|
+
{ text: s.root, style: { fg: "ui.menu_disabled_fg" } },
|
|
495
|
+
]),
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/// Return the number of orchestrator sessions whose `root`
|
|
500
|
+
/// equals `root`. Used to surface "SHARED ×N" in the preview
|
|
501
|
+
/// pane and to refuse Archive / Delete on a shared root
|
|
502
|
+
/// while another session still lives there.
|
|
503
|
+
function countSiblingsAtRoot(root: string): number {
|
|
504
|
+
let n = 0;
|
|
505
|
+
for (const s of orchestratorSessions.values()) {
|
|
506
|
+
if (s.root === root) n += 1;
|
|
507
|
+
}
|
|
508
|
+
return n;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Blank-row separator used inside the Sessions column between
|
|
512
|
+
// the filter, the new-session button, and the list.
|
|
513
|
+
function sessionsSeparator(): WidgetSpec {
|
|
514
|
+
return spacer(0);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Approximate number of session rows the picker's list pane
|
|
518
|
+
// should show. Sized off the full terminal (not the active
|
|
519
|
+
// buffer's viewport — that shrinks with vertical splits and made
|
|
520
|
+
// the picker collapse to ~half its `heightPct: 90` budget).
|
|
521
|
+
function openListVisibleRows(): number {
|
|
522
|
+
const screen = editor.getScreenSize();
|
|
523
|
+
const h = screen.height > 0 ? screen.height : 30;
|
|
524
|
+
const panelH = Math.floor(h * 0.9);
|
|
525
|
+
// panel borders (2) + header (1) + spacer (1) + sessions
|
|
526
|
+
// section borders (2) + filter row (1) + separator (1) +
|
|
527
|
+
// new-session button row (1) + separator (1) + footer (1) =
|
|
528
|
+
// 11 rows of chrome. Floor at 4 so a tiny terminal still
|
|
529
|
+
// shows something.
|
|
530
|
+
return Math.max(4, panelH - 11);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Compose the right-hand preview pane. Normally it shows info
|
|
534
|
+
// + action buttons (Stop, Archive, Delete); when a destructive
|
|
535
|
+
// action is pending confirmation it swaps to a "Confirm
|
|
536
|
+
// <action>?" panel with [ Confirm <action> ] / [ Cancel ]
|
|
537
|
+
// buttons. Cancel is default-focused for safety.
|
|
538
|
+
function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
539
|
+
// In-flight overlay: when the selected session is currently
|
|
540
|
+
// being archived/deleted, swap the preview pane for a
|
|
541
|
+
// non-interactive status panel. The git operations take a few
|
|
542
|
+
// hundred ms; without this the user clicks Confirm Archive and
|
|
543
|
+
// sees no visible reaction until the editor's `window_closed`
|
|
544
|
+
// hook eventually fires and drops the row. The overlay makes
|
|
545
|
+
// the in-flight state explicit and hides the action buttons so
|
|
546
|
+
// a second click can't double-fire.
|
|
547
|
+
if (openDialog?.inFlight && s && openDialog.inFlight.sessionId === s.id) {
|
|
548
|
+
const label = openDialog.inFlight.action === "archive"
|
|
549
|
+
? "Archiving…"
|
|
550
|
+
: "Deleting…";
|
|
551
|
+
return labeledSection({
|
|
552
|
+
label,
|
|
553
|
+
child: col(
|
|
554
|
+
{
|
|
555
|
+
kind: "raw",
|
|
556
|
+
entries: [
|
|
557
|
+
styledRow([
|
|
558
|
+
{
|
|
559
|
+
text: `${label} [${s.id}] ${s.label}`,
|
|
560
|
+
style: { bold: true, fg: "ui.menu_disabled_fg" },
|
|
561
|
+
},
|
|
562
|
+
]),
|
|
563
|
+
styledRow([{ text: "" }]),
|
|
564
|
+
styledRow([
|
|
565
|
+
{
|
|
566
|
+
text: "Waiting for git…",
|
|
567
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
568
|
+
},
|
|
569
|
+
]),
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (openDialog?.pendingConfirm && s && openDialog.pendingConfirm.sessionId === s.id) {
|
|
576
|
+
const action = openDialog.pendingConfirm.action;
|
|
577
|
+
if (action === "stop") {
|
|
578
|
+
return labeledSection({
|
|
579
|
+
label: "Confirm Stop",
|
|
580
|
+
child: col(
|
|
581
|
+
{
|
|
582
|
+
kind: "raw",
|
|
583
|
+
entries: [
|
|
584
|
+
styledRow([
|
|
585
|
+
{
|
|
586
|
+
text: `Stop session [${s.id}] ${s.label}?`,
|
|
587
|
+
style: { bold: true },
|
|
588
|
+
},
|
|
589
|
+
]),
|
|
590
|
+
styledRow([{ text: "" }]),
|
|
591
|
+
styledRow([{ text: "This will:" }]),
|
|
592
|
+
styledRow([{ text: " • send SIGTERM to all session processes" }]),
|
|
593
|
+
styledRow([{ text: " • SIGKILL after a short grace period" }]),
|
|
594
|
+
styledRow([{ text: "" }]),
|
|
595
|
+
styledRow([{ text: "The worktree and session record remain." }]),
|
|
596
|
+
],
|
|
597
|
+
},
|
|
598
|
+
spacer(0),
|
|
599
|
+
row(
|
|
600
|
+
flexSpacer(),
|
|
601
|
+
button("Cancel", { key: "confirm-cancel" }),
|
|
602
|
+
spacer(2),
|
|
603
|
+
button("Confirm Stop", {
|
|
604
|
+
intent: "danger",
|
|
605
|
+
key: "confirm-stop",
|
|
606
|
+
}),
|
|
607
|
+
),
|
|
608
|
+
),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (action === "archive") {
|
|
612
|
+
return labeledSection({
|
|
613
|
+
label: "Confirm Archive",
|
|
614
|
+
child: col(
|
|
615
|
+
{
|
|
616
|
+
kind: "raw",
|
|
617
|
+
entries: [
|
|
618
|
+
styledRow([
|
|
619
|
+
{
|
|
620
|
+
text: `Archive session [${s.id}] ${s.label}?`,
|
|
621
|
+
style: { bold: true },
|
|
622
|
+
},
|
|
623
|
+
]),
|
|
624
|
+
styledRow([{ text: "" }]),
|
|
625
|
+
styledRow([{ text: "This will:" }]),
|
|
626
|
+
styledRow([{ text: " • SIGKILL all session processes" }]),
|
|
627
|
+
styledRow([{ text: " • close the editor session" }]),
|
|
628
|
+
styledRow([{ text: " • move the worktree to .archived/" }]),
|
|
629
|
+
styledRow([{ text: "" }]),
|
|
630
|
+
styledRow([{ text: "Reversible via Unarchive." }]),
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
spacer(0),
|
|
634
|
+
row(
|
|
635
|
+
flexSpacer(),
|
|
636
|
+
button("Cancel", { key: "confirm-cancel" }),
|
|
637
|
+
spacer(2),
|
|
638
|
+
button("Confirm Archive", {
|
|
639
|
+
intent: "danger",
|
|
640
|
+
key: "confirm-archive",
|
|
641
|
+
}),
|
|
642
|
+
),
|
|
643
|
+
),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (action === "delete") {
|
|
647
|
+
return labeledSection({
|
|
648
|
+
label: "Confirm Delete",
|
|
649
|
+
child: col(
|
|
650
|
+
{
|
|
651
|
+
kind: "raw",
|
|
652
|
+
entries: [
|
|
653
|
+
styledRow([
|
|
654
|
+
{
|
|
655
|
+
text: `Delete session [${s.id}] ${s.label}?`,
|
|
656
|
+
style: { bold: true },
|
|
657
|
+
},
|
|
658
|
+
]),
|
|
659
|
+
styledRow([{ text: "" }]),
|
|
660
|
+
styledRow([{ text: "This will:" }]),
|
|
661
|
+
styledRow([{ text: " • stop all session processes" }]),
|
|
662
|
+
styledRow([{ text: " • run `git worktree remove`" }]),
|
|
663
|
+
styledRow([{ text: " • drop the session record" }]),
|
|
664
|
+
styledRow([{ text: "" }]),
|
|
665
|
+
styledRow([
|
|
666
|
+
{
|
|
667
|
+
text: "Uncommitted changes will be lost.",
|
|
668
|
+
style: {
|
|
669
|
+
fg: "ui.status_error_indicator_fg",
|
|
670
|
+
bold: true,
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
]),
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
spacer(0),
|
|
677
|
+
row(
|
|
678
|
+
flexSpacer(),
|
|
679
|
+
button("Cancel", { key: "confirm-cancel" }),
|
|
680
|
+
spacer(2),
|
|
681
|
+
button("Confirm Delete", {
|
|
682
|
+
intent: "danger",
|
|
683
|
+
key: "confirm-delete",
|
|
684
|
+
}),
|
|
685
|
+
),
|
|
686
|
+
),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Match the sessions column's content height so the two panes'
|
|
691
|
+
// bottom borders land on the same row. Sessions column inside
|
|
692
|
+
// its borders = filter (1) + separator (1) + new-session button
|
|
693
|
+
// (1) + separator (1) + list (listVisibleRows) = listVisibleRows
|
|
694
|
+
// + 4. Preview inside its borders = button row (1) + spacer (1)
|
|
695
|
+
// + embedRows, so embedRows must equal listVisibleRows + 2.
|
|
696
|
+
// When details ARE shown, two info rows + a spacer eat three
|
|
697
|
+
// more lines — `_DETAILS_CHROME_ROWS` accounts for that.
|
|
698
|
+
const totalEmbedBase = (openDialog?.listVisibleRows ?? 6) + 2;
|
|
699
|
+
const detailsOn = openDialog?.showDetails ?? false;
|
|
700
|
+
const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
|
|
701
|
+
const embedRows = Math.max(
|
|
702
|
+
3,
|
|
703
|
+
totalEmbedBase - (detailsOn ? _DETAILS_CHROME_ROWS : 0),
|
|
704
|
+
);
|
|
705
|
+
// Gate the action buttons on having a session to act on. When
|
|
706
|
+
// the filter matches nothing (or no session is highlighted) the
|
|
707
|
+
// preview pane shows just "No session selected" + an empty
|
|
708
|
+
// embed reservation — showing Stop/Archive/Delete in that state
|
|
709
|
+
// is misleading because they have nothing to operate on. The
|
|
710
|
+
// empty `windowEmbed({windowId: 0})` is a no-op on the host
|
|
711
|
+
// side but keeps the preview pane the same height as the
|
|
712
|
+
// (padded) sessions list pane so the dialog doesn't shrink
|
|
713
|
+
// jarringly when the filter matches nothing.
|
|
714
|
+
if (!s) {
|
|
715
|
+
return labeledSection({
|
|
716
|
+
label: "Preview",
|
|
717
|
+
child: col(
|
|
718
|
+
{ kind: "raw", entries: buildPreviewEntries(s) },
|
|
719
|
+
windowEmbed({ windowId: 0, rows: embedRows, key: "live-preview" }),
|
|
720
|
+
),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
// The "details" toggle: when off, the picker shows just the
|
|
724
|
+
// action buttons + the live embed (compact, max embed height).
|
|
725
|
+
// When on, the state/age/path metadata row appears above the
|
|
726
|
+
// embed and the embed shrinks to make room. Toggle button
|
|
727
|
+
// labels with the *target* state — pressing `[ Details ]`
|
|
728
|
+
// turns details on, pressing `[ Preview ]` turns them off
|
|
729
|
+
// (back to compact).
|
|
730
|
+
const detailsToggleLabel = detailsOn ? "Preview" : "Details";
|
|
731
|
+
// Per-action availability. The row always renders all four
|
|
732
|
+
// buttons (no layout shift between selections), but each is
|
|
733
|
+
// marked disabled when its action would be refused against the
|
|
734
|
+
// current selection. Disabled buttons show in `ui.menu_disabled_fg`,
|
|
735
|
+
// drop out of the Tab cycle, and reject clicks — matching the
|
|
736
|
+
// same conditions that `stopSelectedSession`, `enterConfirm`,
|
|
737
|
+
// and the lifecycle handlers already check internally.
|
|
738
|
+
//
|
|
739
|
+
// * Stop: refused on the base session (id 1).
|
|
740
|
+
// * Archive / Delete: also refused on the base session, plus
|
|
741
|
+
// when this session shares its worktree with the project
|
|
742
|
+
// root (no `git worktree` entry to remove) or shares a root
|
|
743
|
+
// with other live sessions (would yank disk out from
|
|
744
|
+
// under them).
|
|
745
|
+
const isBase = s.id === 1;
|
|
746
|
+
const siblings = countSiblingsAtRoot(s.root);
|
|
747
|
+
const sharesRoot = siblings > 1 || s.sharedWorktree;
|
|
748
|
+
const stopDisabled = isBase;
|
|
749
|
+
const lifecycleDisabled = isBase || sharesRoot;
|
|
750
|
+
const buttonRow = row(
|
|
751
|
+
button("Visit", { intent: "primary", key: "visit" }),
|
|
752
|
+
spacer(2),
|
|
753
|
+
flexSpacer(),
|
|
754
|
+
button(detailsToggleLabel, { key: "toggle-details" }),
|
|
755
|
+
spacer(2),
|
|
756
|
+
button("Stop", { key: "stop", disabled: stopDisabled }),
|
|
757
|
+
spacer(2),
|
|
758
|
+
button("Archive", { key: "archive", disabled: lifecycleDisabled }),
|
|
759
|
+
spacer(2),
|
|
760
|
+
button("Delete", {
|
|
761
|
+
intent: "danger",
|
|
762
|
+
key: "delete",
|
|
763
|
+
disabled: lifecycleDisabled,
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
const embedWidget = windowEmbed({
|
|
767
|
+
windowId: s.id,
|
|
768
|
+
rows: embedRows,
|
|
769
|
+
key: "live-preview",
|
|
770
|
+
});
|
|
771
|
+
const body = detailsOn
|
|
772
|
+
? col(
|
|
773
|
+
buttonRow,
|
|
774
|
+
spacer(0),
|
|
775
|
+
{ kind: "raw", entries: buildPreviewEntries(s) },
|
|
776
|
+
spacer(0),
|
|
777
|
+
embedWidget,
|
|
778
|
+
)
|
|
779
|
+
: col(buttonRow, spacer(0), embedWidget);
|
|
780
|
+
// Surface BASE in the preview section label so it's always visible
|
|
781
|
+
// (the list-row badge gets truncated at 25% column width). The
|
|
782
|
+
// base session is the editor process itself — closing or moving
|
|
783
|
+
// its worktree would close the editor / break the user's current
|
|
784
|
+
// tree, so Stop / Archive / Delete refuse against it.
|
|
785
|
+
const sectionLabel = isBase
|
|
786
|
+
? `[${s.id}] ${s.label} BASE — editor session`
|
|
787
|
+
: `[${s.id}] ${s.label}`;
|
|
788
|
+
return labeledSection({
|
|
789
|
+
label: sectionLabel,
|
|
790
|
+
child: body,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function buildOpenSpec(): WidgetSpec {
|
|
795
|
+
if (!openDialog) return col();
|
|
796
|
+
// Re-derive row counts on every spec build as a fallback for the
|
|
797
|
+
// resize hook not always firing reliably through tmux's SIGWINCH
|
|
798
|
+
// propagation (Finding I). **One-way ratchet**: only adopt the
|
|
799
|
+
// new value when it's *larger* than the current one. The
|
|
800
|
+
// `editor.getViewport()` height shrinks while the picker is
|
|
801
|
+
// mounted (the floating panel covers part of the buffer area),
|
|
802
|
+
// and naively re-reading it on every refresh fed that shrink
|
|
803
|
+
// back into the dialog size — pressing Up/Down caused the
|
|
804
|
+
// picker to oscillate smaller on every keystroke. A real
|
|
805
|
+
// terminal-grow event still flows through because the new
|
|
806
|
+
// viewport height exceeds the cached value; a spurious shrink
|
|
807
|
+
// (because the panel itself is up) is ignored.
|
|
808
|
+
const liveListVisibleRows = openListVisibleRows();
|
|
809
|
+
if (liveListVisibleRows > openDialog.listVisibleRows) {
|
|
810
|
+
openDialog.listVisibleRows = liveListVisibleRows;
|
|
811
|
+
openDialog.embedRows = Math.max(3, liveListVisibleRows - 5);
|
|
812
|
+
}
|
|
813
|
+
const filtered = openDialog.filteredIds;
|
|
814
|
+
const activeId = editor.activeWindow();
|
|
815
|
+
const items = filtered.map((id) => renderListItem(id, activeId));
|
|
816
|
+
const itemKeys = filtered.map(String);
|
|
817
|
+
const selIdx = filtered.length === 0
|
|
818
|
+
? -1
|
|
819
|
+
: Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
|
|
820
|
+
const selectedId = selIdx >= 0 ? filtered[selIdx] : -1;
|
|
821
|
+
const selectedSession = selectedId > 0
|
|
822
|
+
? orchestratorSessions.get(selectedId)
|
|
823
|
+
: undefined;
|
|
824
|
+
|
|
825
|
+
// The "New Session" button advertises Alt+N (or whatever the
|
|
826
|
+
// user re-bound `orchestrator_open_new_from_picker` to). The
|
|
827
|
+
// label reads the binding dynamically through the host's
|
|
828
|
+
// `getKeybindingLabel` so a re-bound key shows correctly, and
|
|
829
|
+
// the host's `format_keybinding` already renders Mac-native
|
|
830
|
+
// symbols (⌥, ⌘, …) when running on macOS — no plugin-side
|
|
831
|
+
// platform detection needed.
|
|
832
|
+
//
|
|
833
|
+
// The button is the *first* tabbable in the dialog (top of the
|
|
834
|
+
// sessions column, before the filter input) so default focus
|
|
835
|
+
// lands on it directly — Enter creates a new session without
|
|
836
|
+
// requiring the user to navigate first.
|
|
837
|
+
const newKey = editor.getKeybindingLabel(
|
|
838
|
+
"orchestrator_open_new_from_picker",
|
|
839
|
+
OPEN_MODE,
|
|
840
|
+
);
|
|
841
|
+
const newLabel = newKey
|
|
842
|
+
? `+ New Session ${newKey}`
|
|
843
|
+
: "+ New Session";
|
|
844
|
+
const inConfirm = openDialog.pendingConfirm !== null;
|
|
845
|
+
// While a confirmation prompt is up the filter is rendered
|
|
846
|
+
// without a `key`. The host's `collect_tabbable` only adds
|
|
847
|
+
// widgets that carry a non-empty key, so a keyless text widget
|
|
848
|
+
// is unreachable by Tab and doesn't receive `mode_text_input`
|
|
849
|
+
// — the bracketed input still paints normally, just inert.
|
|
850
|
+
// Keeping the visual chrome (instead of swapping it for a
|
|
851
|
+
// "(disabled)" label) means the dialog doesn't reflow under
|
|
852
|
+
// the user's eyes when the confirm view opens / closes.
|
|
853
|
+
const filterInput = text({
|
|
854
|
+
value: openDialog.filter.value,
|
|
855
|
+
cursorByte: openDialog.filter.cursor,
|
|
856
|
+
placeholder: "type to filter…",
|
|
857
|
+
fullWidth: true,
|
|
858
|
+
key: inConfirm ? undefined : "filter",
|
|
859
|
+
});
|
|
860
|
+
const errorBanner: WidgetSpec | null = openDialog.lastError
|
|
861
|
+
? {
|
|
862
|
+
kind: "raw",
|
|
863
|
+
entries: [
|
|
864
|
+
styledRow([
|
|
865
|
+
{
|
|
866
|
+
text: "⚠ ",
|
|
867
|
+
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
text: openDialog.lastError,
|
|
871
|
+
style: { fg: "ui.status_error_indicator_fg" },
|
|
872
|
+
},
|
|
873
|
+
]),
|
|
874
|
+
],
|
|
875
|
+
}
|
|
876
|
+
: null;
|
|
877
|
+
return col(
|
|
878
|
+
{
|
|
879
|
+
kind: "raw",
|
|
880
|
+
entries: [
|
|
881
|
+
styledRow([
|
|
882
|
+
{
|
|
883
|
+
text: "ORCHESTRATOR :: Sessions",
|
|
884
|
+
style: { fg: "ui.popup_border_fg", bold: true },
|
|
885
|
+
},
|
|
886
|
+
]),
|
|
887
|
+
],
|
|
888
|
+
},
|
|
889
|
+
...(errorBanner ? [errorBanner] : []),
|
|
890
|
+
spacer(0),
|
|
891
|
+
// Two-pane: sessions list | preview. Renderer's `row()`
|
|
892
|
+
// horizontally zips multi-line children so this composes
|
|
893
|
+
// the wireframed shape directly. Width split 25 / 75 —
|
|
894
|
+
// the preview pane carries the action buttons and the
|
|
895
|
+
// (Phase 7) live-window render, so it earns the bulk of
|
|
896
|
+
// the dialog.
|
|
897
|
+
row(
|
|
898
|
+
labeledSection({
|
|
899
|
+
label: `Sessions (${filtered.length})`,
|
|
900
|
+
widthPct: 25,
|
|
901
|
+
// Sessions column: new-session button, separator,
|
|
902
|
+
// filter, separator, list. The button is first so it
|
|
903
|
+
// gets initial focus (Enter immediately opens the new
|
|
904
|
+
// session form). Separators are long `─` strings that
|
|
905
|
+
// the renderer truncates to the column's inner width —
|
|
906
|
+
// no need to measure cells from the plugin side.
|
|
907
|
+
child: col(
|
|
908
|
+
row(
|
|
909
|
+
button(newLabel, {
|
|
910
|
+
intent: "primary",
|
|
911
|
+
// Drop the key while a confirm prompt is up so the
|
|
912
|
+
// button is non-tabbable and click-inert — same
|
|
913
|
+
// pattern the filter input uses. Otherwise it stays
|
|
914
|
+
// the first tabbable in the panel and the confirm
|
|
915
|
+
// view's "first-tabbable wins" focus fallback lands
|
|
916
|
+
// here instead of on Cancel.
|
|
917
|
+
key: inConfirm ? undefined : "new-session",
|
|
918
|
+
}),
|
|
919
|
+
flexSpacer(),
|
|
920
|
+
),
|
|
921
|
+
sessionsSeparator(),
|
|
922
|
+
filterInput,
|
|
923
|
+
sessionsSeparator(),
|
|
924
|
+
list({
|
|
925
|
+
items,
|
|
926
|
+
itemKeys,
|
|
927
|
+
selectedIndex: selIdx,
|
|
928
|
+
visibleRows: openDialog.listVisibleRows,
|
|
929
|
+
// Excluded from the Tab cycle — Up/Down on the
|
|
930
|
+
// filter input forwards to this list via host
|
|
931
|
+
// smart-keys, so Tab jumps straight to the action
|
|
932
|
+
// buttons instead of stopping here.
|
|
933
|
+
focusable: false,
|
|
934
|
+
// Drop the `key` while a confirmation prompt is up so
|
|
935
|
+
// `find_scrollable_widget_key` (`plugin_dispatch.rs`)
|
|
936
|
+
// can't find this list — Up/Down on the focused Cancel
|
|
937
|
+
// button would otherwise forward to the list and let
|
|
938
|
+
// the user move the selection off the session being
|
|
939
|
+
// confirmed (which would break the confirm view because
|
|
940
|
+
// it only renders when the selected row matches
|
|
941
|
+
// `pendingConfirm.sessionId`).
|
|
942
|
+
key: inConfirm ? undefined : "sessions",
|
|
943
|
+
}),
|
|
944
|
+
),
|
|
945
|
+
}),
|
|
946
|
+
// Preview pane has no explicit width — picks up the
|
|
947
|
+
// remaining 75% by default since the sessions list took
|
|
948
|
+
// 25%.
|
|
949
|
+
buildPreviewPane(selectedSession),
|
|
950
|
+
),
|
|
951
|
+
row(
|
|
952
|
+
flexSpacer(),
|
|
953
|
+
hintBar([
|
|
954
|
+
{ keys: "↑↓", label: "nav" },
|
|
955
|
+
{ keys: "Enter", label: "dive" },
|
|
956
|
+
{ keys: "Tab", label: "focus" },
|
|
957
|
+
{ keys: "Esc", label: "close" },
|
|
958
|
+
]),
|
|
959
|
+
flexSpacer(),
|
|
960
|
+
syncIndicator(),
|
|
961
|
+
),
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Tiny status glyph rendered at the trailing edge of the
|
|
966
|
+
// footer. `↻` while a push is in flight, `⤒` when the last
|
|
967
|
+
// push failed (with the error in the tooltip — for now, just a
|
|
968
|
+
// status-bar setStatus on focus), and an empty entry otherwise
|
|
969
|
+
// so the layout stays put.
|
|
970
|
+
function syncIndicator(): WidgetSpec {
|
|
971
|
+
let glyph = "";
|
|
972
|
+
let style: { fg?: string; italic?: boolean } | undefined;
|
|
973
|
+
switch (syncStatus) {
|
|
974
|
+
case "syncing":
|
|
975
|
+
glyph = " ↻ ";
|
|
976
|
+
style = { fg: "editor.whitespace_indicator_fg" };
|
|
977
|
+
break;
|
|
978
|
+
case "error":
|
|
979
|
+
glyph = " ⤒ ";
|
|
980
|
+
style = { fg: "ui.status_error_indicator_fg" };
|
|
981
|
+
break;
|
|
982
|
+
default:
|
|
983
|
+
glyph = " ";
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
kind: "raw",
|
|
987
|
+
entries: [styledRow([{ text: glyph, style }])],
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Surface a lifecycle-action refusal in two places: the dialog
|
|
992
|
+
// itself (a coloured banner above the filter, hard to miss while
|
|
993
|
+
// the user's attention is on the dialog) and the status bar
|
|
994
|
+
// (matches the long-standing convention and survives if the
|
|
995
|
+
// dialog closes). Pass the bare reason — the picker prepends
|
|
996
|
+
// "Orchestrator: " for the status bar.
|
|
997
|
+
function setDialogError(msg: string): void {
|
|
998
|
+
if (openDialog) {
|
|
999
|
+
openDialog.lastError = msg;
|
|
1000
|
+
}
|
|
1001
|
+
editor.setStatus(`Orchestrator: ${msg}`);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function clearDialogError(): void {
|
|
1005
|
+
if (openDialog?.lastError) {
|
|
1006
|
+
openDialog.lastError = null;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function refreshOpenDialog(): void {
|
|
1011
|
+
if (!openPanel || !openDialog) return;
|
|
1012
|
+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
1013
|
+
// Clamp the selection into range so a fresh filter or a
|
|
1014
|
+
// session vanishing under us doesn't leave us pointing past
|
|
1015
|
+
// the end of the list.
|
|
1016
|
+
if (openDialog.filteredIds.length === 0) {
|
|
1017
|
+
openDialog.selectedIndex = 0;
|
|
1018
|
+
} else if (openDialog.selectedIndex >= openDialog.filteredIds.length) {
|
|
1019
|
+
openDialog.selectedIndex = openDialog.filteredIds.length - 1;
|
|
1020
|
+
} else if (openDialog.selectedIndex < 0) {
|
|
1021
|
+
openDialog.selectedIndex = 0;
|
|
1022
|
+
}
|
|
1023
|
+
openPanel.update(buildOpenSpec());
|
|
1024
|
+
// The list widget's `selectedIndex` in the spec is initial-only;
|
|
1025
|
+
// pin it via mutation so re-renders don't snap back to 0.
|
|
1026
|
+
if (openDialog.filteredIds.length > 0) {
|
|
1027
|
+
openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function openControlRoom(): void {
|
|
1032
|
+
if (openPanel) return;
|
|
1033
|
+
reconcileSessions();
|
|
1034
|
+
const activeId = editor.activeWindow();
|
|
1035
|
+
const listVisibleRows = openListVisibleRows();
|
|
1036
|
+
openDialog = {
|
|
1037
|
+
filter: { value: "", cursor: 0 },
|
|
1038
|
+
filteredIds: [],
|
|
1039
|
+
selectedIndex: 0,
|
|
1040
|
+
originalActiveSession: activeId,
|
|
1041
|
+
pendingConfirm: null,
|
|
1042
|
+
listVisibleRows,
|
|
1043
|
+
// Mirror buildPreviewPane's chrome: 1 button row + 1 spacer
|
|
1044
|
+
// + 2 info rows + 1 spacer = 4 rows reserved above the embed.
|
|
1045
|
+
// Preview chrome above the embed: 1 button row + 1 spacer + 2
|
|
1046
|
+
// info rows + 1 spacer = 5 rows. The labeledSection's top/bottom
|
|
1047
|
+
// borders match the sessions list's, so subtracting just the
|
|
1048
|
+
// chrome makes the preview pane's apparent height match the
|
|
1049
|
+
// list pane's (`visible_rows + 2 borders`) exactly. Floored at
|
|
1050
|
+
// 3 so a tiny terminal still leaves enough rows for the embed
|
|
1051
|
+
// to paint something meaningful.
|
|
1052
|
+
embedRows: Math.max(3, listVisibleRows - 5),
|
|
1053
|
+
showDetails: false,
|
|
1054
|
+
inFlight: null,
|
|
1055
|
+
lastError: null,
|
|
1056
|
+
};
|
|
1057
|
+
openDialog.filteredIds = filterSessions("");
|
|
1058
|
+
const activeIdx = openDialog.filteredIds.indexOf(activeId);
|
|
1059
|
+
openDialog.selectedIndex = activeIdx >= 0 ? activeIdx : 0;
|
|
1060
|
+
openPanel = new FloatingWidgetPanel();
|
|
1061
|
+
// 90% × 90% of the terminal — the open dialog wants room for
|
|
1062
|
+
// a real session list + preview pane, unlike the new-session
|
|
1063
|
+
// form which stays compact.
|
|
1064
|
+
openPanel.mount(buildOpenSpec(), { widthPct: 90, heightPct: 90 });
|
|
1065
|
+
if (openDialog.filteredIds.length > 0) {
|
|
1066
|
+
openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
|
|
1067
|
+
}
|
|
1068
|
+
// Visit is the dialog's primary action — land focus there on
|
|
1069
|
+
// mount so Enter immediately opens the selected session. The
|
|
1070
|
+
// tabbable order is unchanged (new-session → filter → preview-
|
|
1071
|
+
// pane buttons); we just override the default-first-tabbable
|
|
1072
|
+
// selection. The host clamps to the first tabbable when "visit"
|
|
1073
|
+
// isn't in the spec (empty filter result, no session), which is
|
|
1074
|
+
// safe — there's nothing to act on then anyway.
|
|
1075
|
+
openPanel.setFocusKey("visit");
|
|
1076
|
+
editor.setEditorMode(OPEN_MODE);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function closeOpenDialog(): void {
|
|
1080
|
+
if (openPanel) {
|
|
1081
|
+
openPanel.unmount();
|
|
1082
|
+
openPanel = null;
|
|
1083
|
+
}
|
|
1084
|
+
openDialog = null;
|
|
1085
|
+
editor.setEditorMode(null);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Stop every process the highlighted session owns. Sends
|
|
1089
|
+
// SIGTERM first via the host's `signalWindow` (which fans
|
|
1090
|
+
// out through the window's process-group tracker), then
|
|
1091
|
+
// follows up with SIGKILL after a short grace period so
|
|
1092
|
+
// ill-behaved agents that ignore SIGTERM still get reaped.
|
|
1093
|
+
// The session record stays put — Stop only kills processes,
|
|
1094
|
+
// it doesn't touch the worktree or the editor session.
|
|
1095
|
+
function stopSelectedSession(): void {
|
|
1096
|
+
if (!openDialog) return;
|
|
1097
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
1098
|
+
if (typeof id !== "number" || id <= 0) return;
|
|
1099
|
+
if (id === 1) {
|
|
1100
|
+
setDialogError("cannot stop the base session");
|
|
1101
|
+
refreshOpenDialog();
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
editor.signalWindow(id, "SIGTERM");
|
|
1105
|
+
// SIGKILL fallback for agents that ignore SIGTERM. The
|
|
1106
|
+
// host's signalWindow is idempotent on already-exited
|
|
1107
|
+
// process groups, so the second call is safe whether or
|
|
1108
|
+
// not the first one took. QuickJS has no `setTimeout`;
|
|
1109
|
+
// the host exposes `editor.delay(ms)` as the asynchronous
|
|
1110
|
+
// sleep primitive, which we kick off but don't await.
|
|
1111
|
+
void editor.delay(2000).then(() => {
|
|
1112
|
+
editor.signalWindow(id, "SIGKILL");
|
|
1113
|
+
});
|
|
1114
|
+
editor.setStatus(`Orchestrator: stop signal sent to session [${id}]`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ---------------------------------------------------------------------
|
|
1118
|
+
// Archive manifest — `<XDG>/orchestrator/<repo-slug>/archived.json`.
|
|
1119
|
+
// Records sessions that have been archived (stopped + worktree moved
|
|
1120
|
+
// to `.archived/`). Used today by the Archive action; Unarchive and
|
|
1121
|
+
// "Show archived" surface in a follow-up phase.
|
|
1122
|
+
// ---------------------------------------------------------------------
|
|
1123
|
+
|
|
1124
|
+
interface ArchivedSession {
|
|
1125
|
+
label: string;
|
|
1126
|
+
/** Current path of the moved worktree, under `.archived/`. */
|
|
1127
|
+
root: string;
|
|
1128
|
+
/** Path the worktree lived at before archiving. */
|
|
1129
|
+
original_root: string;
|
|
1130
|
+
/** Branch the worktree was on. */
|
|
1131
|
+
branch: string;
|
|
1132
|
+
/** ISO 8601 timestamp of when the session was archived. */
|
|
1133
|
+
archived_at: string;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
interface ArchiveManifest {
|
|
1137
|
+
version: number;
|
|
1138
|
+
sessions: ArchivedSession[];
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function archiveManifestPath(repoRoot: string): string {
|
|
1142
|
+
return editor.pathJoin(
|
|
1143
|
+
editor.getDataDir(),
|
|
1144
|
+
"orchestrator",
|
|
1145
|
+
slugify(repoRoot),
|
|
1146
|
+
"archived.json",
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function loadArchiveManifest(repoRoot: string): ArchiveManifest {
|
|
1151
|
+
const path = archiveManifestPath(repoRoot);
|
|
1152
|
+
const raw = editor.readFile(path);
|
|
1153
|
+
if (!raw) return { version: 1, sessions: [] };
|
|
1154
|
+
try {
|
|
1155
|
+
const parsed = JSON.parse(raw);
|
|
1156
|
+
if (
|
|
1157
|
+
parsed && typeof parsed === "object" &&
|
|
1158
|
+
Array.isArray(parsed.sessions)
|
|
1159
|
+
) {
|
|
1160
|
+
return parsed as ArchiveManifest;
|
|
1161
|
+
}
|
|
1162
|
+
} catch (_) {
|
|
1163
|
+
// Fall through to fresh manifest — bad data shouldn't
|
|
1164
|
+
// brick the dialog.
|
|
1165
|
+
}
|
|
1166
|
+
return { version: 1, sessions: [] };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function saveArchiveManifest(repoRoot: string, m: ArchiveManifest): boolean {
|
|
1170
|
+
const path = archiveManifestPath(repoRoot);
|
|
1171
|
+
const dir = editor.pathDirname(path);
|
|
1172
|
+
if (!editor.createDir(dir)) return false;
|
|
1173
|
+
return editor.writeFile(path, JSON.stringify(m, null, 2));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Pick a session id to make active so that `excludeId` can be
|
|
1177
|
+
// closed. `close_window` refuses to close the active window, so
|
|
1178
|
+
// archive/delete of the currently-active session needs to switch
|
|
1179
|
+
// away first. Prefers a session already visible in the open
|
|
1180
|
+
// dialog's current filter (keeps the user in roughly the same
|
|
1181
|
+
// project context they were browsing), falls back to the base
|
|
1182
|
+
// session — which always exists and can't itself be archived /
|
|
1183
|
+
// deleted, so this is guaranteed to return a valid target.
|
|
1184
|
+
function pickNextActiveSession(excludeId: number): number {
|
|
1185
|
+
if (openDialog) {
|
|
1186
|
+
const inFilter = openDialog.filteredIds.find(
|
|
1187
|
+
(sid) => sid !== excludeId && sid > 0,
|
|
1188
|
+
);
|
|
1189
|
+
if (typeof inFilter === "number") return inFilter;
|
|
1190
|
+
}
|
|
1191
|
+
for (const sid of orchestratorSessions.keys()) {
|
|
1192
|
+
if (sid !== excludeId && sid > 0) return sid;
|
|
1193
|
+
}
|
|
1194
|
+
return 1;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Archive flow: stop all processes (SIGKILL — archive is a
|
|
1198
|
+
// "I'm done with this for now" action, no graceful teardown
|
|
1199
|
+
// needed since the worktree stays on disk), close the editor
|
|
1200
|
+
// session, move the worktree to the `.archived/` graveyard,
|
|
1201
|
+
// and append a manifest entry so a future Unarchive flow can
|
|
1202
|
+
// reverse it.
|
|
1203
|
+
async function archiveSelectedSession(explicitId?: number): Promise<void> {
|
|
1204
|
+
if (!openDialog) return;
|
|
1205
|
+
// Prefer the explicit id from the confirm path. Otherwise read
|
|
1206
|
+
// the currently selected row — used by the legacy direct-call
|
|
1207
|
+
// entry points. Once the row is hidden synchronously after
|
|
1208
|
+
// confirm, `filteredIds[selectedIndex]` no longer points at the
|
|
1209
|
+
// session being archived (it shifts to whatever is now under
|
|
1210
|
+
// the cursor).
|
|
1211
|
+
const id = typeof explicitId === "number"
|
|
1212
|
+
? explicitId
|
|
1213
|
+
: openDialog.filteredIds[openDialog.selectedIndex];
|
|
1214
|
+
// Clear the in-flight marker so the preview pane stops showing
|
|
1215
|
+
// "Archiving…" if the operation refuses or fails. After
|
|
1216
|
+
// `closeWindow` succeeds the row is gone from `listWindows()`
|
|
1217
|
+
// anyway, so clearing then is harmless.
|
|
1218
|
+
const clearInFlight = () => {
|
|
1219
|
+
if (
|
|
1220
|
+
openDialog?.inFlight && typeof id === "number" &&
|
|
1221
|
+
openDialog.inFlight.sessionId === id
|
|
1222
|
+
) {
|
|
1223
|
+
openDialog.inFlight = null;
|
|
1224
|
+
refreshOpenDialog();
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
if (typeof id !== "number" || id <= 0) return;
|
|
1228
|
+
if (id === 1) {
|
|
1229
|
+
setDialogError("cannot archive the base session");
|
|
1230
|
+
clearInFlight();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
// close_window refuses to close the active window; swap to a
|
|
1234
|
+
// different session first. The pick prefers something already
|
|
1235
|
+
// in the dialog's current filter, falls back to the base
|
|
1236
|
+
// session — both always exist (base is undeletable, and we'd
|
|
1237
|
+
// have nothing to archive without at least one session).
|
|
1238
|
+
if (id === editor.activeWindow()) {
|
|
1239
|
+
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1240
|
+
}
|
|
1241
|
+
const session = orchestratorSessions.get(id);
|
|
1242
|
+
if (!session) {
|
|
1243
|
+
clearInFlight();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Resolve the repo root from cwd (the user is in the
|
|
1248
|
+
// umbrella session's tree).
|
|
1249
|
+
const cwd = editor.getCwd();
|
|
1250
|
+
const top = await spawnCollect(
|
|
1251
|
+
"git",
|
|
1252
|
+
["rev-parse", "--show-toplevel"],
|
|
1253
|
+
cwd,
|
|
1254
|
+
);
|
|
1255
|
+
if (top.exit_code !== 0) {
|
|
1256
|
+
editor.setStatus("Orchestrator: archive failed — not a git repository");
|
|
1257
|
+
clearInFlight();
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const repoRoot = (top.stdout || "").trim();
|
|
1261
|
+
|
|
1262
|
+
// SIGKILL the session's process group so the pty children
|
|
1263
|
+
// release any locks on the worktree, then close the editor
|
|
1264
|
+
// session. closeWindow already kills the pty via the child
|
|
1265
|
+
// killer; signaling first via the window-level pg tracker
|
|
1266
|
+
// catches stray subprocesses outside the pty.
|
|
1267
|
+
editor.signalWindow(id, "SIGKILL");
|
|
1268
|
+
editor.closeWindow(id);
|
|
1269
|
+
|
|
1270
|
+
// Brief settle so the filesystem reflects the pty's exit
|
|
1271
|
+
// before we move the worktree out from under it.
|
|
1272
|
+
await editor.delay(250);
|
|
1273
|
+
|
|
1274
|
+
// git worktree move keeps git's internal bookkeeping
|
|
1275
|
+
// consistent (the new path stays registered as a worktree).
|
|
1276
|
+
const archivedRoot = editor.pathJoin(
|
|
1277
|
+
editor.getDataDir(),
|
|
1278
|
+
"orchestrator",
|
|
1279
|
+
slugify(repoRoot),
|
|
1280
|
+
".archived",
|
|
1281
|
+
session.label,
|
|
1282
|
+
);
|
|
1283
|
+
const parent = editor.pathDirname(archivedRoot);
|
|
1284
|
+
if (!editor.createDir(parent)) {
|
|
1285
|
+
editor.setStatus(
|
|
1286
|
+
`Orchestrator: archive failed — could not create ${parent}`,
|
|
1287
|
+
);
|
|
1288
|
+
clearInFlight();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const moveRes = await spawnCollect(
|
|
1292
|
+
"git",
|
|
1293
|
+
["-C", repoRoot, "worktree", "move", session.root, archivedRoot],
|
|
1294
|
+
repoRoot,
|
|
1295
|
+
);
|
|
1296
|
+
if (moveRes.exit_code !== 0) {
|
|
1297
|
+
editor.setStatus(
|
|
1298
|
+
`Orchestrator: worktree move failed: ${
|
|
1299
|
+
lastNonEmptyLine(moveRes.stderr) || "unknown error"
|
|
1300
|
+
}`,
|
|
1301
|
+
);
|
|
1302
|
+
clearInFlight();
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Append manifest entry. The branch info is best-effort:
|
|
1307
|
+
// we assume Orchestrator's convention of branch==label (set in
|
|
1308
|
+
// the new-session form) until a session knows its branch
|
|
1309
|
+
// separately.
|
|
1310
|
+
const manifest = loadArchiveManifest(repoRoot);
|
|
1311
|
+
manifest.sessions.push({
|
|
1312
|
+
label: session.label,
|
|
1313
|
+
root: archivedRoot,
|
|
1314
|
+
original_root: session.root,
|
|
1315
|
+
branch: session.label,
|
|
1316
|
+
archived_at: new Date().toISOString(),
|
|
1317
|
+
});
|
|
1318
|
+
if (!saveArchiveManifest(repoRoot, manifest)) {
|
|
1319
|
+
editor.setStatus(
|
|
1320
|
+
"Orchestrator: archived, but failed to write archived.json",
|
|
1321
|
+
);
|
|
1322
|
+
} else {
|
|
1323
|
+
editor.setStatus(`Orchestrator: archived [${id}] ${session.label}`);
|
|
1324
|
+
}
|
|
1325
|
+
clearInFlight();
|
|
1326
|
+
triggerSyncAsync(repoRoot);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ---------------------------------------------------------------------
|
|
1330
|
+
// Cross-machine recovery (Phase 6)
|
|
1331
|
+
//
|
|
1332
|
+
// Every lifecycle action that mutates the local archive manifest also
|
|
1333
|
+
// fires an asynchronous push to `refs/heads/<user>/fresh-sessions` on
|
|
1334
|
+
// origin so the same sessions can be recovered on another machine.
|
|
1335
|
+
// The push runs in the background and never blocks the user-visible
|
|
1336
|
+
// action; failures get surfaced through `syncStatus` (and a small ⤒
|
|
1337
|
+
// glyph in the dialog footer when the error is fresh).
|
|
1338
|
+
//
|
|
1339
|
+
// The branch is orphan-style: a single root file `sessions.json` and
|
|
1340
|
+
// commits with the sessions snapshot. We maintain it through a
|
|
1341
|
+
// dedicated worktree at `<XDG>/orchestrator/.sync-workspace` so we don't
|
|
1342
|
+
// disturb the user's normal `git worktree` set.
|
|
1343
|
+
// ---------------------------------------------------------------------
|
|
1344
|
+
|
|
1345
|
+
type SyncStatus = "idle" | "syncing" | "error";
|
|
1346
|
+
let syncStatus: SyncStatus = "idle";
|
|
1347
|
+
let syncError: string | null = null;
|
|
1348
|
+
|
|
1349
|
+
function deriveSyncUser(): string {
|
|
1350
|
+
// Priority order documented in
|
|
1351
|
+
// docs/internal/orchestrator-open-dialog-and-lifecycle.md.
|
|
1352
|
+
const envOverride = editor.getEnv("FRESH_SESSIONS_USER");
|
|
1353
|
+
if (envOverride && envOverride.trim()) return envOverride.trim();
|
|
1354
|
+
const localPart = (envEmailLocalPart() || "").trim();
|
|
1355
|
+
if (localPart) return localPart;
|
|
1356
|
+
const u = editor.getEnv("USER");
|
|
1357
|
+
if (u && u.trim()) return u.trim();
|
|
1358
|
+
return "fresh";
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function envEmailLocalPart(): string | null {
|
|
1362
|
+
// Best-effort sync read of git config user.email's local-part.
|
|
1363
|
+
// Reading from env first (since spawnProcess is async) keeps
|
|
1364
|
+
// deriveSyncUser synchronous; users with no env override will
|
|
1365
|
+
// probably have `$USER` available as fallback.
|
|
1366
|
+
const email = editor.getEnv("GIT_AUTHOR_EMAIL") ||
|
|
1367
|
+
editor.getEnv("EMAIL");
|
|
1368
|
+
if (!email) return null;
|
|
1369
|
+
const at = email.indexOf("@");
|
|
1370
|
+
return at > 0 ? email.slice(0, at) : null;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function syncWorkspacePath(): string {
|
|
1374
|
+
return editor.pathJoin(editor.getDataDir(), "orchestrator", ".sync-workspace");
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Fire-and-forget sync. Never blocks the caller; updates
|
|
1378
|
+
// `syncStatus`/`syncError` and refreshes the dialog (if open)
|
|
1379
|
+
// so the footer indicator can reflect the result.
|
|
1380
|
+
function triggerSyncAsync(repoRoot: string): void {
|
|
1381
|
+
void (async () => {
|
|
1382
|
+
syncStatus = "syncing";
|
|
1383
|
+
if (openPanel) refreshOpenDialog();
|
|
1384
|
+
const result = await syncSessions(repoRoot);
|
|
1385
|
+
if (result.ok) {
|
|
1386
|
+
syncStatus = "idle";
|
|
1387
|
+
syncError = null;
|
|
1388
|
+
} else {
|
|
1389
|
+
syncStatus = "error";
|
|
1390
|
+
syncError = result.err ?? "unknown error";
|
|
1391
|
+
}
|
|
1392
|
+
if (openPanel) refreshOpenDialog();
|
|
1393
|
+
})();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
interface SyncResult {
|
|
1397
|
+
ok: boolean;
|
|
1398
|
+
err?: string;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async function syncSessions(repoRoot: string): Promise<SyncResult> {
|
|
1402
|
+
const user = deriveSyncUser();
|
|
1403
|
+
const branch = `${user}/fresh-sessions`;
|
|
1404
|
+
const wt = syncWorkspacePath();
|
|
1405
|
+
|
|
1406
|
+
// Ensure the sync worktree exists and is on the right branch.
|
|
1407
|
+
// First-time setup creates the worktree as an orphan branch
|
|
1408
|
+
// with no parent commit (cleanest history; no leftover files
|
|
1409
|
+
// from the original tree).
|
|
1410
|
+
if (!editor.createDir(editor.pathDirname(wt))) {
|
|
1411
|
+
return { ok: false, err: "createDir failed for sync workspace parent" };
|
|
1412
|
+
}
|
|
1413
|
+
const branchExists = await spawnCollect(
|
|
1414
|
+
"git",
|
|
1415
|
+
["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
1416
|
+
repoRoot,
|
|
1417
|
+
);
|
|
1418
|
+
const wtExists = await spawnCollect(
|
|
1419
|
+
"git",
|
|
1420
|
+
["-C", repoRoot, "worktree", "list", "--porcelain"],
|
|
1421
|
+
repoRoot,
|
|
1422
|
+
);
|
|
1423
|
+
const wtAlreadyTracked = wtExists.exit_code === 0 &&
|
|
1424
|
+
wtExists.stdout.includes(wt);
|
|
1425
|
+
|
|
1426
|
+
if (!wtAlreadyTracked) {
|
|
1427
|
+
if (branchExists.exit_code === 0) {
|
|
1428
|
+
const addRes = await spawnCollect(
|
|
1429
|
+
"git",
|
|
1430
|
+
["-C", repoRoot, "worktree", "add", wt, branch],
|
|
1431
|
+
repoRoot,
|
|
1432
|
+
);
|
|
1433
|
+
if (addRes.exit_code !== 0) {
|
|
1434
|
+
return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
// Create an orphan worktree by adding detached then
|
|
1438
|
+
// switching to a new orphan branch.
|
|
1439
|
+
const addRes = await spawnCollect(
|
|
1440
|
+
"git",
|
|
1441
|
+
["-C", repoRoot, "worktree", "add", "--detach", wt, "HEAD"],
|
|
1442
|
+
repoRoot,
|
|
1443
|
+
);
|
|
1444
|
+
if (addRes.exit_code !== 0) {
|
|
1445
|
+
return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
|
|
1446
|
+
}
|
|
1447
|
+
const orphanRes = await spawnCollect(
|
|
1448
|
+
"git",
|
|
1449
|
+
["-C", wt, "checkout", "--orphan", branch],
|
|
1450
|
+
wt,
|
|
1451
|
+
);
|
|
1452
|
+
if (orphanRes.exit_code !== 0) {
|
|
1453
|
+
return { ok: false, err: lastNonEmptyLine(orphanRes.stderr) };
|
|
1454
|
+
}
|
|
1455
|
+
// Strip everything inherited from HEAD's tree so the
|
|
1456
|
+
// orphan branch starts clean.
|
|
1457
|
+
await spawnCollect("git", ["-C", wt, "rm", "-rf", "."], wt);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Snapshot active + archived sessions into the JSON that
|
|
1462
|
+
// lives at the root of the sync branch.
|
|
1463
|
+
const snapshot = await buildSyncSnapshot(repoRoot);
|
|
1464
|
+
const sessionsPath = editor.pathJoin(wt, "sessions.json");
|
|
1465
|
+
if (!editor.writeFile(sessionsPath, JSON.stringify(snapshot, null, 2))) {
|
|
1466
|
+
return { ok: false, err: "writeFile sessions.json failed" };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const addRes = await spawnCollect(
|
|
1470
|
+
"git",
|
|
1471
|
+
["-C", wt, "add", "sessions.json"],
|
|
1472
|
+
wt,
|
|
1473
|
+
);
|
|
1474
|
+
if (addRes.exit_code !== 0) {
|
|
1475
|
+
return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
|
|
1476
|
+
}
|
|
1477
|
+
// The commit may noop when nothing changed — git exits with
|
|
1478
|
+
// 1 in that case, which we treat as success rather than an
|
|
1479
|
+
// error.
|
|
1480
|
+
const commitRes = await spawnCollect(
|
|
1481
|
+
"git",
|
|
1482
|
+
[
|
|
1483
|
+
"-C",
|
|
1484
|
+
wt,
|
|
1485
|
+
"commit",
|
|
1486
|
+
"--allow-empty-message",
|
|
1487
|
+
"-m",
|
|
1488
|
+
"Update sessions",
|
|
1489
|
+
],
|
|
1490
|
+
wt,
|
|
1491
|
+
);
|
|
1492
|
+
if (commitRes.exit_code !== 0 && !commitRes.stdout.includes("nothing to commit")) {
|
|
1493
|
+
// Permissive: stderr "nothing to commit" / "working tree clean"
|
|
1494
|
+
// means there was nothing new to push. Skip the push and
|
|
1495
|
+
// report success.
|
|
1496
|
+
if (!commitRes.stderr.includes("nothing to commit")) {
|
|
1497
|
+
// Other commit failures: report.
|
|
1498
|
+
return { ok: false, err: lastNonEmptyLine(commitRes.stderr) };
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const pushRes = await spawnCollect(
|
|
1503
|
+
"git",
|
|
1504
|
+
["-C", wt, "push", "origin", branch],
|
|
1505
|
+
wt,
|
|
1506
|
+
);
|
|
1507
|
+
if (pushRes.exit_code !== 0) {
|
|
1508
|
+
return { ok: false, err: lastNonEmptyLine(pushRes.stderr) };
|
|
1509
|
+
}
|
|
1510
|
+
return { ok: true };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
|
|
1514
|
+
const manifest = loadArchiveManifest(repoRoot);
|
|
1515
|
+
return {
|
|
1516
|
+
version: 1,
|
|
1517
|
+
machine_id: editor.getEnv("HOSTNAME") || "unknown",
|
|
1518
|
+
updated_at: new Date().toISOString(),
|
|
1519
|
+
active: Array.from(orchestratorSessions.values()).map((s) => ({
|
|
1520
|
+
label: s.label,
|
|
1521
|
+
branch: s.label,
|
|
1522
|
+
base_ref: "origin/master",
|
|
1523
|
+
created_at: new Date(s.createdAt).toISOString(),
|
|
1524
|
+
})),
|
|
1525
|
+
archived: manifest.sessions,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Delete flow: stop processes (SIGKILL), close the editor
|
|
1530
|
+
// session, then `git worktree remove --force` to drop the
|
|
1531
|
+
// worktree from disk. If the session was archived (manifest
|
|
1532
|
+
// entry exists), the manifest entry is dropped too. No
|
|
1533
|
+
// recovery after this point.
|
|
1534
|
+
async function deleteConfirmedSession(): Promise<void> {
|
|
1535
|
+
if (!openDialog || !openDialog.pendingConfirm) return;
|
|
1536
|
+
const { sessionId: id } = openDialog.pendingConfirm;
|
|
1537
|
+
openDialog.pendingConfirm = null;
|
|
1538
|
+
// Clear the in-flight marker on early failure. Mirrors the
|
|
1539
|
+
// pattern in `archiveSelectedSession` — the confirm-delete
|
|
1540
|
+
// handler set `inFlight` before kicking off this async work,
|
|
1541
|
+
// and any path that aborts before `closeWindow` needs to undo
|
|
1542
|
+
// it so the "Deleting…" overlay disappears.
|
|
1543
|
+
const clearInFlight = () => {
|
|
1544
|
+
if (openDialog?.inFlight && openDialog.inFlight.sessionId === id) {
|
|
1545
|
+
openDialog.inFlight = null;
|
|
1546
|
+
refreshOpenDialog();
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
const session = orchestratorSessions.get(id);
|
|
1550
|
+
if (!session) {
|
|
1551
|
+
clearInFlight();
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
// Same auto-switch as archive — close_window refuses to close
|
|
1555
|
+
// the active window, so swap to a different session first.
|
|
1556
|
+
if (id === editor.activeWindow()) {
|
|
1557
|
+
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const cwd = editor.getCwd();
|
|
1561
|
+
const top = await spawnCollect(
|
|
1562
|
+
"git",
|
|
1563
|
+
["rev-parse", "--show-toplevel"],
|
|
1564
|
+
cwd,
|
|
1565
|
+
);
|
|
1566
|
+
if (top.exit_code !== 0) {
|
|
1567
|
+
editor.setStatus("Orchestrator: delete failed — not a git repository");
|
|
1568
|
+
clearInFlight();
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const repoRoot = (top.stdout || "").trim();
|
|
1572
|
+
|
|
1573
|
+
editor.signalWindow(id, "SIGKILL");
|
|
1574
|
+
editor.closeWindow(id);
|
|
1575
|
+
await editor.delay(250);
|
|
1576
|
+
|
|
1577
|
+
// `--force` because the worktree may have unstaged changes
|
|
1578
|
+
// the user explicitly chose to discard via the confirm step.
|
|
1579
|
+
const removeRes = await spawnCollect(
|
|
1580
|
+
"git",
|
|
1581
|
+
["-C", repoRoot, "worktree", "remove", "--force", session.root],
|
|
1582
|
+
repoRoot,
|
|
1583
|
+
);
|
|
1584
|
+
if (removeRes.exit_code !== 0) {
|
|
1585
|
+
editor.setStatus(
|
|
1586
|
+
`Orchestrator: worktree remove failed: ${
|
|
1587
|
+
lastNonEmptyLine(removeRes.stderr) || "unknown error"
|
|
1588
|
+
}`,
|
|
1589
|
+
);
|
|
1590
|
+
clearInFlight();
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Drop the matching manifest entry too, in case the session
|
|
1595
|
+
// was already archived (delete-from-archived is the natural
|
|
1596
|
+
// way to drop dormant sessions).
|
|
1597
|
+
const manifest = loadArchiveManifest(repoRoot);
|
|
1598
|
+
const before = manifest.sessions.length;
|
|
1599
|
+
manifest.sessions = manifest.sessions.filter(
|
|
1600
|
+
(e) => e.label !== session.label,
|
|
1601
|
+
);
|
|
1602
|
+
if (manifest.sessions.length !== before) {
|
|
1603
|
+
saveArchiveManifest(repoRoot, manifest);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
editor.setStatus(`Orchestrator: deleted [${id}] ${session.label}`);
|
|
1607
|
+
clearInFlight();
|
|
1608
|
+
triggerSyncAsync(repoRoot);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// `Alt+N` from inside the picker opens the new-session form — saves
|
|
1612
|
+
// the user the "Esc, Ctrl+P, type Orchestrator: New Session, Enter"
|
|
1613
|
+
// dance when they realise mid-picker that they want to spawn another
|
|
1614
|
+
// agent. All other keys (Up/Down/Enter/Tab/Esc/printable chars)
|
|
1615
|
+
// route through `dispatch_floating_widget_key`'s smart-key defaults
|
|
1616
|
+
// since OPEN_MODE doesn't claim them here.
|
|
1617
|
+
editor.defineMode(
|
|
1618
|
+
OPEN_MODE,
|
|
1619
|
+
[["M-n", "orchestrator_open_new_from_picker"]],
|
|
1620
|
+
true,
|
|
1621
|
+
true,
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1624
|
+
registerHandler("orchestrator_open_new_from_picker", () => {
|
|
1625
|
+
if (!openDialog) return;
|
|
1626
|
+
closeOpenDialog();
|
|
1627
|
+
openForm({ fromPicker: true });
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
// =============================================================================
|
|
1631
|
+
// New-session floating form
|
|
1632
|
+
// =============================================================================
|
|
1633
|
+
|
|
1634
|
+
function slugify(p: string): string {
|
|
1635
|
+
// Drop any leading separator so the slug isn't anchored to the
|
|
1636
|
+
// filesystem root; replace remaining separators with underscores.
|
|
1637
|
+
return p.replace(/^[\\\/]+/, "").replace(/[\\\/]+/g, "_");
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// =============================================================================
|
|
1641
|
+
// Input history (Up / Down) for the new-session form
|
|
1642
|
+
//
|
|
1643
|
+
// Per-field MRU lists keyed under `orchestrator.history.<field>` in
|
|
1644
|
+
// the editor's global plugin-state store (persisted across editor
|
|
1645
|
+
// restarts). Submit appends the resolved value to each field's
|
|
1646
|
+
// history; Up/Down on a focused input walks the list (saving the
|
|
1647
|
+
// user's in-progress draft on the first ↑ so ↓ can return to it).
|
|
1648
|
+
// Capped at 100 entries per field, MRU-trimmed.
|
|
1649
|
+
// =============================================================================
|
|
1650
|
+
|
|
1651
|
+
type HistoryField = "project_path" | "name" | "cmd" | "branch";
|
|
1652
|
+
const HISTORY_FIELDS: HistoryField[] = ["project_path", "name", "cmd", "branch"];
|
|
1653
|
+
const HISTORY_CAP = 100;
|
|
1654
|
+
|
|
1655
|
+
/// Plugin-side focus tracker for the new-session form. The host
|
|
1656
|
+
/// owns the actual focus key, but doesn't expose a "what's
|
|
1657
|
+
/// focused right now?" query to plugins, and doesn't fire focus-
|
|
1658
|
+
/// change events. So we mirror the cycle ourselves: openForm
|
|
1659
|
+
/// resets to the first tabbable, Tab / S-Tab advance / retreat,
|
|
1660
|
+
/// `change` events on a known widget snap focus to that widget
|
|
1661
|
+
/// (covers mouse clicks too).
|
|
1662
|
+
///
|
|
1663
|
+
/// The mirror is "best-effort" — it can drift if the host
|
|
1664
|
+
/// reorders focus in ways we don't intercept (e.g. an explicit
|
|
1665
|
+
/// `focusAdvance` action we issued ourselves), but for the
|
|
1666
|
+
/// keys this form actually binds it stays in sync.
|
|
1667
|
+
let formFocusCycle: string[] = [];
|
|
1668
|
+
let formFocusIndex = 0;
|
|
1669
|
+
|
|
1670
|
+
function rebuildFormFocusCycle(): void {
|
|
1671
|
+
if (!form) {
|
|
1672
|
+
formFocusCycle = [];
|
|
1673
|
+
formFocusIndex = 0;
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const worktreeEnabled = form.projectPathIsGit !== false;
|
|
1677
|
+
const branchInert = !(worktreeEnabled && form.createWorktree);
|
|
1678
|
+
const cycle: string[] = ["project_path"];
|
|
1679
|
+
if (worktreeEnabled) cycle.push("worktree");
|
|
1680
|
+
cycle.push("name", "cmd");
|
|
1681
|
+
if (!branchInert) cycle.push("branch");
|
|
1682
|
+
cycle.push("cancel", "create");
|
|
1683
|
+
formFocusCycle = cycle;
|
|
1684
|
+
if (formFocusIndex >= cycle.length) formFocusIndex = 0;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function formFocusedKey(): string {
|
|
1688
|
+
return formFocusCycle[formFocusIndex] ?? "";
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function advanceFormFocus(delta: 1 | -1): void {
|
|
1692
|
+
if (formFocusCycle.length === 0) return;
|
|
1693
|
+
formFocusIndex =
|
|
1694
|
+
(formFocusIndex + delta + formFocusCycle.length) % formFocusCycle.length;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function snapFormFocusTo(key: string): void {
|
|
1698
|
+
const idx = formFocusCycle.indexOf(key);
|
|
1699
|
+
if (idx >= 0) formFocusIndex = idx;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function historyKey(field: HistoryField): string {
|
|
1703
|
+
return `orchestrator.history.${field}`;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function readHistory(field: HistoryField): string[] {
|
|
1707
|
+
const raw = editor.getGlobalState(historyKey(field));
|
|
1708
|
+
if (Array.isArray(raw)) {
|
|
1709
|
+
return raw.filter((v): v is string => typeof v === "string");
|
|
1710
|
+
}
|
|
1711
|
+
return [];
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function writeHistory(field: HistoryField, items: string[]): void {
|
|
1715
|
+
editor.setGlobalState(historyKey(field), items as unknown as object);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function appendHistory(field: HistoryField, value: string): void {
|
|
1719
|
+
const v = (value || "").trim();
|
|
1720
|
+
if (!v) return;
|
|
1721
|
+
const prev = readHistory(field).filter((x) => x !== v);
|
|
1722
|
+
prev.unshift(v);
|
|
1723
|
+
if (prev.length > HISTORY_CAP) prev.length = HISTORY_CAP;
|
|
1724
|
+
writeHistory(field, prev);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/// Map a focused widget key to its history field, or null if the
|
|
1728
|
+
/// key isn't a history-bearing input.
|
|
1729
|
+
function focusToHistoryField(focusKey: string): HistoryField | null {
|
|
1730
|
+
return (HISTORY_FIELDS as readonly string[]).includes(focusKey)
|
|
1731
|
+
? (focusKey as HistoryField)
|
|
1732
|
+
: null;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/// Walk the history of `field` by `delta` (-1 = older / ↑, +1 =
|
|
1736
|
+
/// newer / ↓). Updates the form's value, cursor, and history
|
|
1737
|
+
/// cursor in place. No-op when the history is empty (or when ↓
|
|
1738
|
+
/// is hit past the bottom of the stack).
|
|
1739
|
+
function walkHistory(field: HistoryField, delta: -1 | 1): void {
|
|
1740
|
+
if (!form) return;
|
|
1741
|
+
const history = readHistory(field);
|
|
1742
|
+
if (history.length === 0) return;
|
|
1743
|
+
const slot = formSlot(field);
|
|
1744
|
+
if (!slot) return;
|
|
1745
|
+
|
|
1746
|
+
const curr = form.historyCursor[field];
|
|
1747
|
+
let next = curr + delta; // -1 → 0 for first ↑
|
|
1748
|
+
|
|
1749
|
+
if (next < -1) {
|
|
1750
|
+
// Already at the draft slot, ↓ does nothing more.
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
if (next >= history.length) {
|
|
1754
|
+
// Past the oldest entry — stay put.
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (curr === -1 && delta === -1) {
|
|
1759
|
+
// First ↑: save the in-progress draft so the user can ↓
|
|
1760
|
+
// back to whatever they were typing.
|
|
1761
|
+
form.historyDraft[field] = slot.value;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (next === -1) {
|
|
1765
|
+
// ↓ off the top of the stack → restore the saved draft.
|
|
1766
|
+
slot.value = form.historyDraft[field];
|
|
1767
|
+
} else {
|
|
1768
|
+
slot.value = history[next];
|
|
1769
|
+
}
|
|
1770
|
+
slot.cursor = slot.value.length;
|
|
1771
|
+
form.historyCursor[field] = next;
|
|
1772
|
+
|
|
1773
|
+
// Sync the rendered widget so cursor + value match (the host
|
|
1774
|
+
// tracks text input state separately from the spec).
|
|
1775
|
+
if (formPanel) {
|
|
1776
|
+
formPanel.setValue(field, slot.value, slot.cursor);
|
|
1777
|
+
}
|
|
1778
|
+
// Re-probe defaults if the user just rolled history into the
|
|
1779
|
+
// Project Path field.
|
|
1780
|
+
if (field === "project_path") scheduleProjectPathReprobe();
|
|
1781
|
+
renderForm();
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function formSlot(field: HistoryField): { value: string; cursor: number } | null {
|
|
1785
|
+
if (!form) return null;
|
|
1786
|
+
switch (field) {
|
|
1787
|
+
case "project_path": return form.projectPath;
|
|
1788
|
+
case "name": return form.name;
|
|
1789
|
+
case "cmd": return form.cmd;
|
|
1790
|
+
case "branch": return form.branch;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function lastNonEmptyLine(s: string): string {
|
|
1795
|
+
const lines = (s || "").split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
1796
|
+
return lines.length ? lines[lines.length - 1].trim() : "";
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/// Split the user's "Agent Command" string into an argv suitable for
|
|
1800
|
+
/// `editor.createTerminal({ command })`. Honours single- and
|
|
1801
|
+
/// double-quoted segments so `claude --append "hello world"` parses
|
|
1802
|
+
/// as three args rather than four. Backslash escaping is intentionally
|
|
1803
|
+
/// *not* supported — agent commands are short typed-in strings; if
|
|
1804
|
+
/// they need that level of escaping the user should write a wrapper
|
|
1805
|
+
/// shell script.
|
|
1806
|
+
///
|
|
1807
|
+
/// Returns `[]` for an empty or whitespace-only input.
|
|
1808
|
+
function splitAgentCmd(s: string): string[] {
|
|
1809
|
+
const out: string[] = [];
|
|
1810
|
+
let cur = "";
|
|
1811
|
+
let quote: '"' | "'" | null = null;
|
|
1812
|
+
for (let i = 0; i < s.length; i++) {
|
|
1813
|
+
const c = s[i];
|
|
1814
|
+
if (quote) {
|
|
1815
|
+
if (c === quote) {
|
|
1816
|
+
quote = null;
|
|
1817
|
+
} else {
|
|
1818
|
+
cur += c;
|
|
1819
|
+
}
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
if (c === '"' || c === "'") {
|
|
1823
|
+
quote = c;
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
if (c === " " || c === "\t") {
|
|
1827
|
+
if (cur.length > 0) {
|
|
1828
|
+
out.push(cur);
|
|
1829
|
+
cur = "";
|
|
1830
|
+
}
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
cur += c;
|
|
1834
|
+
}
|
|
1835
|
+
if (cur.length > 0) out.push(cur);
|
|
1836
|
+
return out;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async function spawnCollect(
|
|
1840
|
+
command: string,
|
|
1841
|
+
args: string[],
|
|
1842
|
+
cwd: string,
|
|
1843
|
+
): Promise<SpawnResult> {
|
|
1844
|
+
return await editor.spawnProcess(command, args, cwd);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/// Resolve the origin's default branch as `"origin/<name>"` from
|
|
1848
|
+
/// the locally-cached symbolic-ref. Returns `"HEAD"` when there's
|
|
1849
|
+
/// no `origin` remote (purely-local repos) or the symbolic ref is
|
|
1850
|
+
/// missing — the caller treats that as the silent fallback.
|
|
1851
|
+
///
|
|
1852
|
+
/// Deliberately does NOT fetch: `refs/remotes/origin/HEAD` is set
|
|
1853
|
+
/// at clone time and only changes when the remote renames its
|
|
1854
|
+
/// default branch (rare). A network round-trip per dialog open
|
|
1855
|
+
/// is too high a cost for that case.
|
|
1856
|
+
async function detectDefaultBranch(repoRoot: string): Promise<string> {
|
|
1857
|
+
return (await detectDefaultBranchWithFallback(repoRoot)).ref;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/// Like `detectDefaultBranch` but also reports whether we had to
|
|
1861
|
+
/// fall back to bare `HEAD` because no `origin` is configured. The
|
|
1862
|
+
/// caller uses that to surface a context note in the placeholder
|
|
1863
|
+
/// ("HEAD (no origin configured)") so the user isn't confused
|
|
1864
|
+
/// about why their repo's default isn't being detected.
|
|
1865
|
+
async function detectDefaultBranchWithFallback(
|
|
1866
|
+
repoRoot: string,
|
|
1867
|
+
): Promise<{ ref: string; isHeadFallback: boolean }> {
|
|
1868
|
+
const res = await spawnCollect(
|
|
1869
|
+
"git",
|
|
1870
|
+
["-C", repoRoot, "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
1871
|
+
repoRoot,
|
|
1872
|
+
);
|
|
1873
|
+
if (res.exit_code === 0) {
|
|
1874
|
+
const trimmed = (res.stdout || "").trim();
|
|
1875
|
+
const prefix = "refs/remotes/";
|
|
1876
|
+
if (trimmed.startsWith(prefix)) {
|
|
1877
|
+
// e.g. "refs/remotes/origin/main" → "origin/main". This is
|
|
1878
|
+
// what the new worktree is forked off, so the user sees the
|
|
1879
|
+
// exact ref name they'd otherwise have to type by hand.
|
|
1880
|
+
return { ref: trimmed.slice(prefix.length), isHeadFallback: false };
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return { ref: "HEAD", isHeadFallback: true };
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/// Resolve a directory to the *main* worktree's root if it's
|
|
1887
|
+
/// inside a git working tree. Returns `null` for non-git paths
|
|
1888
|
+
/// so the caller can pick the no-git path explicitly.
|
|
1889
|
+
async function resolveCanonicalRepoRoot(
|
|
1890
|
+
cwd: string,
|
|
1891
|
+
): Promise<string | null> {
|
|
1892
|
+
const top = await spawnCollect(
|
|
1893
|
+
"git",
|
|
1894
|
+
["rev-parse", "--show-toplevel"],
|
|
1895
|
+
cwd,
|
|
1896
|
+
);
|
|
1897
|
+
if (top.exit_code !== 0) return null;
|
|
1898
|
+
const toplevel = (top.stdout || "").trim();
|
|
1899
|
+
if (!toplevel) return null;
|
|
1900
|
+
// `--git-common-dir` returns the shared `.git` dir even when
|
|
1901
|
+
// we're inside a linked worktree. `dirname(...)` gives the
|
|
1902
|
+
// main worktree's root, which is what we want as the
|
|
1903
|
+
// canonical project identifier.
|
|
1904
|
+
const common = await spawnCollect(
|
|
1905
|
+
"git",
|
|
1906
|
+
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
1907
|
+
toplevel,
|
|
1908
|
+
);
|
|
1909
|
+
if (common.exit_code === 0) {
|
|
1910
|
+
const parent = editor.pathDirname((common.stdout || "").trim());
|
|
1911
|
+
if (parent) return parent;
|
|
1912
|
+
}
|
|
1913
|
+
return toplevel;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/// Is `path` inside a git working tree? Returns `null` on any
|
|
1917
|
+
/// error so the caller can keep its UI in a "in-flight / unknown"
|
|
1918
|
+
/// state rather than flipping to a wrong answer.
|
|
1919
|
+
async function pathIsInsideGitWorkTree(
|
|
1920
|
+
path: string,
|
|
1921
|
+
): Promise<boolean | null> {
|
|
1922
|
+
if (!path) return null;
|
|
1923
|
+
const res = await spawnCollect(
|
|
1924
|
+
"git",
|
|
1925
|
+
["-C", path, "rev-parse", "--is-inside-work-tree"],
|
|
1926
|
+
path,
|
|
1927
|
+
);
|
|
1928
|
+
if (res.exit_code !== 0) return false; // non-zero = not a repo
|
|
1929
|
+
return (res.stdout || "").trim() === "true";
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function nextAutoSessionName(
|
|
1933
|
+
repoRoot: string,
|
|
1934
|
+
options?: { persist?: boolean },
|
|
1935
|
+
): Promise<string> {
|
|
1936
|
+
// Persisted counter so consecutive empty submits produce
|
|
1937
|
+
// session-1, session-2, … even across plugin reloads. But the
|
|
1938
|
+
// counter alone isn't sufficient: a previous run may have left a
|
|
1939
|
+
// branch / worktree behind (orchestrator's archive / external git
|
|
1940
|
+
// delete / interrupted submit), so `session-${counter+1}` can
|
|
1941
|
+
// collide and `git worktree add` would fail with the noisy
|
|
1942
|
+
// "already used by worktree at …" message. Probe the local git
|
|
1943
|
+
// refs once and increment past any reserved name before
|
|
1944
|
+
// returning.
|
|
1945
|
+
//
|
|
1946
|
+
// `persist: false` (the default) computes the name without
|
|
1947
|
+
// advancing the persisted counter — for placeholder previews
|
|
1948
|
+
// that happen on every Project Path keystroke. The submit
|
|
1949
|
+
// path passes `persist: true` so consecutive submissions
|
|
1950
|
+
// increment normally.
|
|
1951
|
+
const persist = options?.persist === true;
|
|
1952
|
+
const counterBefore = (editor.getGlobalState("orchestrator.session_counter") as
|
|
1953
|
+
| number
|
|
1954
|
+
| undefined) ?? 0;
|
|
1955
|
+
let next = counterBefore + 1;
|
|
1956
|
+
|
|
1957
|
+
// Collect existing branch names that look like `session-N` so we
|
|
1958
|
+
// can skip past them. `git for-each-ref` is faster and tighter
|
|
1959
|
+
// than parsing `git worktree list` output.
|
|
1960
|
+
const refs = await spawnCollect(
|
|
1961
|
+
"git",
|
|
1962
|
+
["-C", repoRoot, "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
|
|
1963
|
+
repoRoot,
|
|
1964
|
+
);
|
|
1965
|
+
const taken = new Set<number>();
|
|
1966
|
+
if (refs.exit_code === 0) {
|
|
1967
|
+
for (const line of (refs.stdout || "").split(/\r?\n/)) {
|
|
1968
|
+
const m = /^session-(\d+)$/.exec(line.trim());
|
|
1969
|
+
if (m) {
|
|
1970
|
+
taken.add(parseInt(m[1], 10));
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
while (taken.has(next)) {
|
|
1975
|
+
next += 1;
|
|
1976
|
+
}
|
|
1977
|
+
if (persist) {
|
|
1978
|
+
editor.setGlobalState("orchestrator.session_counter", next);
|
|
1979
|
+
}
|
|
1980
|
+
return `session-${next}`;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Three distinct styles for the header line: section keyword
|
|
1984
|
+
// ("ORCHESTRATOR"), structural separators ("::"), and step label. The
|
|
1985
|
+
// border-fg key picks up the same accent the floating panel border
|
|
1986
|
+
// uses, so the title visually anchors to the dialog chrome.
|
|
1987
|
+
const HEADER_KEYWORD_STYLE = {
|
|
1988
|
+
fg: "ui.popup_border_fg",
|
|
1989
|
+
bold: true,
|
|
1990
|
+
} as const;
|
|
1991
|
+
const HEADER_SEP_STYLE = { fg: "ui.menu_disabled_fg" } as const;
|
|
1992
|
+
const HEADER_LABEL_STYLE = { fg: "ui.menu_active_fg", bold: true } as const;
|
|
1993
|
+
|
|
1994
|
+
// Subtitle splits the static prefix "Project:" from the project
|
|
1995
|
+
// path so each gets its own foreground — matching the three-tier
|
|
1996
|
+
// (label / label-value / input) palette the design calls for.
|
|
1997
|
+
const SUBTITLE_LABEL_STYLE = { fg: "ui.menu_disabled_fg" } as const;
|
|
1998
|
+
const SUBTITLE_VALUE_STYLE = { fg: "ui.help_key_fg", bold: true } as const;
|
|
1999
|
+
|
|
2000
|
+
function buildFormSpec(): WidgetSpec {
|
|
2001
|
+
if (!form) return col();
|
|
2002
|
+
|
|
2003
|
+
// Worktree-toggle enable state. The checkbox is disabled
|
|
2004
|
+
// (rendered without a `key` so the host skips it in the tab
|
|
2005
|
+
// cycle, and the label gets a `(disabled — non-git)` suffix)
|
|
2006
|
+
// when the resolved Project Path is not inside a git working
|
|
2007
|
+
// tree. `null` (probe in flight) keeps it in its last-known
|
|
2008
|
+
// state — no flicker on rapid typing.
|
|
2009
|
+
const worktreeEnabled = form.projectPathIsGit !== false;
|
|
2010
|
+
const effectiveCreateWorktree = worktreeEnabled && form.createWorktree;
|
|
2011
|
+
const branchInert = !effectiveCreateWorktree;
|
|
2012
|
+
|
|
2013
|
+
// Branch placeholder: surface origin/main, fall back to a
|
|
2014
|
+
// contextual hint when no origin is configured, and become
|
|
2015
|
+
// inert when worktree creation is off.
|
|
2016
|
+
let branchPlaceholder: string;
|
|
2017
|
+
if (branchInert) {
|
|
2018
|
+
branchPlaceholder = worktreeEnabled
|
|
2019
|
+
? "shared worktree — N/A"
|
|
2020
|
+
: "no git — N/A";
|
|
2021
|
+
} else if (!form.defaultBranch) {
|
|
2022
|
+
branchPlaceholder = "detecting default branch…";
|
|
2023
|
+
} else if (form.defaultBranchIsHeadFallback) {
|
|
2024
|
+
branchPlaceholder = "HEAD (no origin configured)";
|
|
2025
|
+
} else {
|
|
2026
|
+
branchPlaceholder = form.defaultBranch;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const children: WidgetSpec[] = [
|
|
2030
|
+
// === Header: centered title (no stale `Review Synthesized`). =
|
|
2031
|
+
row(
|
|
2032
|
+
flexSpacer(),
|
|
2033
|
+
{
|
|
2034
|
+
kind: "raw",
|
|
2035
|
+
entries: [
|
|
2036
|
+
styledRow([
|
|
2037
|
+
{ text: "ORCHESTRATOR", style: HEADER_KEYWORD_STYLE },
|
|
2038
|
+
{ text: " :: ", style: HEADER_SEP_STYLE },
|
|
2039
|
+
{ text: "New Session", style: HEADER_LABEL_STYLE },
|
|
2040
|
+
]),
|
|
2041
|
+
],
|
|
2042
|
+
},
|
|
2043
|
+
flexSpacer(),
|
|
2044
|
+
),
|
|
2045
|
+
spacer(0),
|
|
2046
|
+
// === Project Path: the new top-of-form field. ================
|
|
2047
|
+
// Placeholder surfaces the resolved canonical repo root (or
|
|
2048
|
+
// editor cwd for non-git launches). Empty submit uses the
|
|
2049
|
+
// placeholder verbatim, so the user can land on a sensible
|
|
2050
|
+
// default just by pressing Enter through the form.
|
|
2051
|
+
// The completion popup hangs off the bottom of this Text
|
|
2052
|
+
// widget — host-rendered chrome, no separate widget. The
|
|
2053
|
+
// plugin pushes candidates via `formPanel.setCompletions`
|
|
2054
|
+
// and reacts to the `completion_accept` event when the user
|
|
2055
|
+
// hits Tab; the labeledSection wrapper extends its side
|
|
2056
|
+
// borders down through the popup automatically.
|
|
2057
|
+
labeledSection({
|
|
2058
|
+
label: "Project Path",
|
|
2059
|
+
child: text({
|
|
2060
|
+
value: form.projectPath.value,
|
|
2061
|
+
cursorByte: form.projectPath.cursor,
|
|
2062
|
+
placeholder: form.defaultProjectPath || "detecting project root…",
|
|
2063
|
+
fullWidth: true,
|
|
2064
|
+
key: "project_path",
|
|
2065
|
+
}),
|
|
2066
|
+
}),
|
|
2067
|
+
// === Worktree toggle. ========================================
|
|
2068
|
+
// Enabled only when the Project Path resolves to a git work
|
|
2069
|
+
// tree. When disabled, render with a dim-fg `raw` row using
|
|
2070
|
+
// the same `[ ] / [v]` glyph (so the user still recognises
|
|
2071
|
+
// it as a checkbox) and append a `(disabled — non-git)`
|
|
2072
|
+
// suffix. The raw row has no `key`, so it stays out of the
|
|
2073
|
+
// Tab cycle and Space-to-toggle has nothing to land on.
|
|
2074
|
+
worktreeEnabled
|
|
2075
|
+
? toggle(
|
|
2076
|
+
effectiveCreateWorktree,
|
|
2077
|
+
"Create a new git worktree for this session",
|
|
2078
|
+
{ key: "worktree" },
|
|
2079
|
+
)
|
|
2080
|
+
: {
|
|
2081
|
+
kind: "raw",
|
|
2082
|
+
entries: [
|
|
2083
|
+
styledRow([
|
|
2084
|
+
{
|
|
2085
|
+
text: "[ ] Create a new git worktree for this session",
|
|
2086
|
+
style: { fg: "editor.whitespace_indicator_fg" },
|
|
2087
|
+
},
|
|
2088
|
+
{
|
|
2089
|
+
text: " (disabled — non-git)",
|
|
2090
|
+
style: { fg: "editor.whitespace_indicator_fg", italic: true },
|
|
2091
|
+
},
|
|
2092
|
+
]),
|
|
2093
|
+
],
|
|
2094
|
+
},
|
|
2095
|
+
// === Form body: labeled, full-width inputs. ==================
|
|
2096
|
+
// Labels are plain — the `▸` glyph used to be baked into all
|
|
2097
|
+
// three strings and stayed put regardless of focus, which was
|
|
2098
|
+
// misleading. The input's own focused-bg styling (set by the
|
|
2099
|
+
// host based on the panel's focus_key) is the authoritative
|
|
2100
|
+
// focus cue.
|
|
2101
|
+
labeledSection({
|
|
2102
|
+
label: "Session Name",
|
|
2103
|
+
child: text({
|
|
2104
|
+
value: form.name.value,
|
|
2105
|
+
cursorByte: form.name.cursor,
|
|
2106
|
+
// Concrete default (e.g. "session-3") rather than the
|
|
2107
|
+
// literal `(auto-generated)` — the user sees the exact
|
|
2108
|
+
// name an empty submit would create. Empty while the
|
|
2109
|
+
// ref probe runs.
|
|
2110
|
+
placeholder: form.defaultSessionName || "auto-generating…",
|
|
2111
|
+
fullWidth: true,
|
|
2112
|
+
key: "name",
|
|
2113
|
+
}),
|
|
2114
|
+
}),
|
|
2115
|
+
labeledSection({
|
|
2116
|
+
label: "Agent Command",
|
|
2117
|
+
child: text({
|
|
2118
|
+
value: form.cmd.value,
|
|
2119
|
+
cursorByte: form.cmd.cursor,
|
|
2120
|
+
// Empty submission spawns a bare terminal — the host
|
|
2121
|
+
// picks the shell with the same logic it uses for any
|
|
2122
|
+
// other embedded terminal, so the plugin doesn't have
|
|
2123
|
+
// to second-guess `$SHELL` resolution. If the user
|
|
2124
|
+
// submitted a non-empty cmd in the previous run we
|
|
2125
|
+
// surface it here as a hint (placeholder only — see
|
|
2126
|
+
// `NewSessionForm.lastCmd`).
|
|
2127
|
+
placeholder: form.lastCmd || "terminal",
|
|
2128
|
+
fullWidth: true,
|
|
2129
|
+
key: "cmd",
|
|
2130
|
+
}),
|
|
2131
|
+
}),
|
|
2132
|
+
labeledSection({
|
|
2133
|
+
label: "Branch",
|
|
2134
|
+
child: text({
|
|
2135
|
+
value: form.branch.value,
|
|
2136
|
+
cursorByte: form.branch.cursor,
|
|
2137
|
+
placeholder: branchPlaceholder,
|
|
2138
|
+
fullWidth: true,
|
|
2139
|
+
// Drop the key when the branch field is inert so Tab
|
|
2140
|
+
// skips it — there's no `git worktree add` to apply
|
|
2141
|
+
// it to.
|
|
2142
|
+
key: branchInert ? undefined : "branch",
|
|
2143
|
+
}),
|
|
2144
|
+
}),
|
|
2145
|
+
];
|
|
2146
|
+
if (form.lastError) {
|
|
2147
|
+
children.push(spacer(0));
|
|
2148
|
+
children.push({
|
|
2149
|
+
kind: "raw",
|
|
2150
|
+
entries: [
|
|
2151
|
+
styledRow([
|
|
2152
|
+
{
|
|
2153
|
+
text: "Error: ",
|
|
2154
|
+
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
2155
|
+
},
|
|
2156
|
+
{ text: form.lastError },
|
|
2157
|
+
]),
|
|
2158
|
+
],
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
children.push(
|
|
2162
|
+
spacer(0),
|
|
2163
|
+
// === Button row: bottom-right aligned. =======================
|
|
2164
|
+
row(
|
|
2165
|
+
flexSpacer(),
|
|
2166
|
+
button("Cancel", { intent: "danger", key: "cancel" }),
|
|
2167
|
+
spacer(2),
|
|
2168
|
+
button("Create Session", { intent: "primary", key: "create" }),
|
|
2169
|
+
),
|
|
2170
|
+
spacer(0),
|
|
2171
|
+
// === Footer: keybinding helper, centered. ====================
|
|
2172
|
+
row(
|
|
2173
|
+
flexSpacer(),
|
|
2174
|
+
hintBar([
|
|
2175
|
+
{ keys: "Tab", label: "next / accept" },
|
|
2176
|
+
{ keys: "S-Tab", label: "prev" },
|
|
2177
|
+
{ keys: "↑↓", label: "suggest / history" },
|
|
2178
|
+
{ keys: "Space", label: "toggle" },
|
|
2179
|
+
{ keys: "Enter", label: "advance / act" },
|
|
2180
|
+
{ keys: "Esc", label: "cancel" },
|
|
2181
|
+
]),
|
|
2182
|
+
flexSpacer(),
|
|
2183
|
+
),
|
|
2184
|
+
);
|
|
2185
|
+
return col(...children);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// Derive a "my_org/project_name" style label from the current
|
|
2189
|
+
// working directory's tail. Orchestrator never opens this dialog
|
|
2190
|
+
// outside of a workspace; if the cwd has fewer than two
|
|
2191
|
+
// components we fall back to whatever's there.
|
|
2192
|
+
function deriveProjectLabel(): string {
|
|
2193
|
+
const cwd = editor.getCwd();
|
|
2194
|
+
const base = editor.pathBasename(cwd);
|
|
2195
|
+
const parent = editor.pathBasename(editor.pathDirname(cwd));
|
|
2196
|
+
if (parent && parent !== base) return `${parent}/${base}`;
|
|
2197
|
+
return base || cwd;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
function renderForm(): void {
|
|
2202
|
+
if (!form || !formPanel) return;
|
|
2203
|
+
// Keep the focus mirror in step with the spec's tabbable set
|
|
2204
|
+
// (worktree may toggle disabled, branch may go inert) on every
|
|
2205
|
+
// render, BEFORE we ship the spec — `rebuildFormFocusCycle`
|
|
2206
|
+
// clamps the index if the previously focused entry has
|
|
2207
|
+
// disappeared.
|
|
2208
|
+
rebuildFormFocusCycle();
|
|
2209
|
+
formPanel.update(buildFormSpec());
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
function openForm(options?: { fromPicker?: boolean }): void {
|
|
2213
|
+
pendingNewSession = null;
|
|
2214
|
+
const lastCmd =
|
|
2215
|
+
(editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
|
|
2216
|
+
form = {
|
|
2217
|
+
projectPath: { value: "", cursor: 0 },
|
|
2218
|
+
name: { value: "", cursor: 0 },
|
|
2219
|
+
// Empty value — `lastCmd` shows as the placeholder. If the
|
|
2220
|
+
// user submits an empty cmd, the placeholder is used as the
|
|
2221
|
+
// actual command (see `submitForm`). This makes the
|
|
2222
|
+
// placeholder a genuine "press Enter to re-use this" hint
|
|
2223
|
+
// rather than a visual lie.
|
|
2224
|
+
cmd: { value: "", cursor: 0 },
|
|
2225
|
+
branch: { value: "", cursor: 0 },
|
|
2226
|
+
// Default checkbox state is `true` (the historical behaviour
|
|
2227
|
+
// of "always create a worktree"); the renderer demotes this
|
|
2228
|
+
// to `false` automatically when the resolved Project Path is
|
|
2229
|
+
// non-git.
|
|
2230
|
+
createWorktree: true,
|
|
2231
|
+
submitting: false,
|
|
2232
|
+
lastError: null,
|
|
2233
|
+
defaultProjectPath: "",
|
|
2234
|
+
projectPathIsGit: null,
|
|
2235
|
+
defaultSessionName: "",
|
|
2236
|
+
defaultBranch: "",
|
|
2237
|
+
defaultBranchIsHeadFallback: false,
|
|
2238
|
+
lastCmd,
|
|
2239
|
+
fromPicker: !!options?.fromPicker,
|
|
2240
|
+
probeToken: 0,
|
|
2241
|
+
historyCursor: { project_path: -1, name: -1, cmd: -1, branch: -1 },
|
|
2242
|
+
historyDraft: { project_path: "", name: "", cmd: "", branch: "" },
|
|
2243
|
+
completion: { field: null, items: [], selectedIndex: 0, anchor: "", token: 0 },
|
|
2244
|
+
};
|
|
2245
|
+
formPanel = new FloatingWidgetPanel();
|
|
2246
|
+
// Width 60 / height 90: the host shrinks the panel to its actual
|
|
2247
|
+
// content height when content is shorter than the requested cap,
|
|
2248
|
+
// so a generous height ceiling doesn't waste space on tall
|
|
2249
|
+
// terminals (the form usually renders ~20 rows). The previous
|
|
2250
|
+
// 50% cap was a fixed canvas in disguise — on a 24-row terminal
|
|
2251
|
+
// it left the dialog 12 rows tall, clipping the Branch input,
|
|
2252
|
+
// the Cancel / Create Session buttons, and the hint bar.
|
|
2253
|
+
formPanel.mount(buildFormSpec(), { widthPct: 60, heightPct: 90 });
|
|
2254
|
+
editor.setEditorMode(NEW_SESSION_MODE);
|
|
2255
|
+
// Mirror the host's focus cycle so Up/Down can route to the
|
|
2256
|
+
// right field's history. Initial focus is on `project_path`
|
|
2257
|
+
// (the first tabbable in `buildFormSpec`).
|
|
2258
|
+
rebuildFormFocusCycle();
|
|
2259
|
+
formFocusIndex = 0;
|
|
2260
|
+
|
|
2261
|
+
// Kick off the placeholder probes (canonical repo root,
|
|
2262
|
+
// default branch, next session name) against the editor's
|
|
2263
|
+
// cwd. Each probe is async and re-renders on completion.
|
|
2264
|
+
void probeProjectPathDefaults();
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/// Resolve placeholders for the Project Path / Session Name /
|
|
2268
|
+
/// Branch fields based on the *currently-effective* project
|
|
2269
|
+
/// path: the user-typed value if any, else the editor's cwd
|
|
2270
|
+
/// (the canonical-root probe runs against the latter). Re-runs
|
|
2271
|
+
/// on every Project Path keystroke (debounced via the caller).
|
|
2272
|
+
async function probeProjectPathDefaults(): Promise<void> {
|
|
2273
|
+
if (!form) return;
|
|
2274
|
+
const token = ++form.probeToken;
|
|
2275
|
+
const typedPath = form.projectPath.value.trim();
|
|
2276
|
+
|
|
2277
|
+
// (1) Default Project Path: only meaningful when the user
|
|
2278
|
+
// hasn't typed anything. Resolve cwd → canonical root,
|
|
2279
|
+
// fall back to cwd verbatim for non-git launches.
|
|
2280
|
+
if (!typedPath) {
|
|
2281
|
+
const resolved = await resolveCanonicalRepoRoot(editor.getCwd());
|
|
2282
|
+
if (!form || form.probeToken !== token) return;
|
|
2283
|
+
form.defaultProjectPath = resolved || editor.getCwd();
|
|
2284
|
+
} else {
|
|
2285
|
+
// User typed a path: that IS the project, no canonical
|
|
2286
|
+
// resolution needed. Defaults that depend on it (session
|
|
2287
|
+
// name, default branch) still need to run against it below.
|
|
2288
|
+
form.defaultProjectPath = typedPath;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// (2) Is-inside-work-tree probe drives the worktree checkbox.
|
|
2292
|
+
const effectivePath = typedPath || form.defaultProjectPath;
|
|
2293
|
+
const isGit = await pathIsInsideGitWorkTree(effectivePath);
|
|
2294
|
+
if (!form || form.probeToken !== token) return;
|
|
2295
|
+
form.projectPathIsGit = isGit;
|
|
2296
|
+
|
|
2297
|
+
// (3) Default branch + session name probes only make sense on
|
|
2298
|
+
// a git path. On non-git, leave both empty (the renderer
|
|
2299
|
+
// surfaces a "no git — N/A" branch placeholder, and the
|
|
2300
|
+
// session name still works against the counter alone).
|
|
2301
|
+
if (isGit) {
|
|
2302
|
+
const [{ ref, isHeadFallback }, sessionName] = await Promise.all([
|
|
2303
|
+
detectDefaultBranchWithFallback(effectivePath),
|
|
2304
|
+
nextAutoSessionName(effectivePath),
|
|
2305
|
+
]);
|
|
2306
|
+
if (!form || form.probeToken !== token) return;
|
|
2307
|
+
form.defaultBranch = ref;
|
|
2308
|
+
form.defaultBranchIsHeadFallback = isHeadFallback;
|
|
2309
|
+
form.defaultSessionName = sessionName;
|
|
2310
|
+
} else {
|
|
2311
|
+
// Non-git: still surface a numeric placeholder for Session
|
|
2312
|
+
// Name so the user sees what an empty submit will produce.
|
|
2313
|
+
// `nextAutoSessionName` falls back cleanly when the refs
|
|
2314
|
+
// probe fails (no git → empty set → counter+1).
|
|
2315
|
+
const sessionName = await nextAutoSessionName(effectivePath);
|
|
2316
|
+
if (!form || form.probeToken !== token) return;
|
|
2317
|
+
form.defaultBranch = "";
|
|
2318
|
+
form.defaultBranchIsHeadFallback = false;
|
|
2319
|
+
form.defaultSessionName = sessionName;
|
|
2320
|
+
}
|
|
2321
|
+
renderForm();
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
/// Schedule a debounced re-probe after the user changes the
|
|
2325
|
+
/// Project Path field. 200ms feels snappy without spawning a
|
|
2326
|
+
/// git subprocess on every keystroke. QuickJS has no
|
|
2327
|
+
/// `setTimeout` — `editor.delay(ms)` is the async-sleep
|
|
2328
|
+
/// primitive; the `probeToken` already enforces "only the
|
|
2329
|
+
/// latest scheduled probe wins" so back-to-back keystrokes
|
|
2330
|
+
/// collapse cleanly without an explicit timer handle.
|
|
2331
|
+
function scheduleProjectPathReprobe(): void {
|
|
2332
|
+
if (!form) return;
|
|
2333
|
+
const token = ++form.probeToken;
|
|
2334
|
+
void editor.delay(200).then(() => {
|
|
2335
|
+
if (!form || form.probeToken !== token) return;
|
|
2336
|
+
void probeProjectPathDefaults();
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// =============================================================================
|
|
2341
|
+
// Inline-dropdown completion (Phase 7)
|
|
2342
|
+
//
|
|
2343
|
+
// For Project Path and Branch we render a `list` below the input
|
|
2344
|
+
// when the candidate set is non-empty. Candidates are fetched
|
|
2345
|
+
// asynchronously (filesystem read for paths, git for branches);
|
|
2346
|
+
// the `completion.token` makes only the freshest fetch's result
|
|
2347
|
+
// land — same pattern as the project-path is-git probe.
|
|
2348
|
+
// =============================================================================
|
|
2349
|
+
|
|
2350
|
+
const COMPLETION_VISIBLE_ROWS = 6;
|
|
2351
|
+
const COMPLETION_MAX_ITEMS = 50;
|
|
2352
|
+
|
|
2353
|
+
/// Fire a fresh fetch of completion candidates for the named
|
|
2354
|
+
/// field. Stale fetches (older `token`) discard their results
|
|
2355
|
+
/// on completion. Caller is responsible for re-rendering once
|
|
2356
|
+
/// the fetch lands — `setCompletionItems` does that.
|
|
2357
|
+
function scheduleCompletionRefresh(
|
|
2358
|
+
field: "project_path" | "branch",
|
|
2359
|
+
): void {
|
|
2360
|
+
if (!form) return;
|
|
2361
|
+
const anchor = form[field === "project_path" ? "projectPath" : "branch"].value;
|
|
2362
|
+
const token = ++form.completion.token;
|
|
2363
|
+
form.completion.field = field;
|
|
2364
|
+
form.completion.anchor = anchor;
|
|
2365
|
+
// Path completion reads from `editor.readDir`, which is a
|
|
2366
|
+
// synchronous host call (no IPC waiting). Run it inline so
|
|
2367
|
+
// Tab pressed immediately after the last keystroke picks
|
|
2368
|
+
// from the up-to-date candidate list rather than a stale
|
|
2369
|
+
// one — the user reported that with the debounce in place,
|
|
2370
|
+
// typing "repo" + Tab would accept the *previous* prefix's
|
|
2371
|
+
// top match (e.g. "Desktop") because the popup hadn't
|
|
2372
|
+
// refreshed yet.
|
|
2373
|
+
if (field === "project_path") {
|
|
2374
|
+
const items = computePathCompletions(anchor);
|
|
2375
|
+
if (!form || form.completion.token !== token) return;
|
|
2376
|
+
setCompletionItems(field, items);
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
// Branch completion shells out to `git for-each-ref` — that
|
|
2380
|
+
// *is* async, so a sync flush isn't possible. Keep the
|
|
2381
|
+
// 150ms debounce so we coalesce rapid typing into a single
|
|
2382
|
+
// subprocess invocation; Tab during the gap accepts the
|
|
2383
|
+
// last known list, which is the same behaviour `bash`'s
|
|
2384
|
+
// tab completion exhibits while a long-running compspec is
|
|
2385
|
+
// catching up.
|
|
2386
|
+
void editor.delay(150).then(async () => {
|
|
2387
|
+
if (!form || form.completion.token !== token) return;
|
|
2388
|
+
const items = await fetchBranchCompletions(anchor);
|
|
2389
|
+
if (!form || form.completion.token !== token) return;
|
|
2390
|
+
setCompletionItems(field, items);
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/// Synchronous variant of `fetchPathCompletions` — same logic,
|
|
2395
|
+
/// but doesn't go through a `Promise` so it can run inline from
|
|
2396
|
+
/// the `change` event handler. `fetchPathCompletions` keeps the
|
|
2397
|
+
/// async signature for the legacy debounce path (in case the
|
|
2398
|
+
/// fetcher ever grows an async step), but delegates here so the
|
|
2399
|
+
/// two paths can't drift.
|
|
2400
|
+
function computePathCompletions(typed: string): string[] {
|
|
2401
|
+
const slashIdx = typed.lastIndexOf("/");
|
|
2402
|
+
let parent: string;
|
|
2403
|
+
let basename: string;
|
|
2404
|
+
if (slashIdx < 0) {
|
|
2405
|
+
parent = typed ? "." : editor.getCwd();
|
|
2406
|
+
basename = typed;
|
|
2407
|
+
} else if (slashIdx === 0) {
|
|
2408
|
+
parent = "/";
|
|
2409
|
+
basename = typed.slice(1);
|
|
2410
|
+
} else {
|
|
2411
|
+
parent = typed.slice(0, slashIdx);
|
|
2412
|
+
basename = typed.slice(slashIdx + 1);
|
|
2413
|
+
}
|
|
2414
|
+
const entries = editor.readDir(parent);
|
|
2415
|
+
const matches = entries
|
|
2416
|
+
.filter((e) => !basename || e.name.startsWith(basename))
|
|
2417
|
+
.filter((e) => !e.name.startsWith(".") || basename.startsWith("."));
|
|
2418
|
+
matches.sort((a, b) => {
|
|
2419
|
+
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
|
2420
|
+
return a.name.localeCompare(b.name);
|
|
2421
|
+
});
|
|
2422
|
+
const prefix = parent.endsWith("/") ? parent : `${parent}/`;
|
|
2423
|
+
return matches.map((e) => `${prefix}${e.name}${e.is_dir ? "/" : ""}`);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function setCompletionItems(
|
|
2427
|
+
field: "project_path" | "branch",
|
|
2428
|
+
items: string[],
|
|
2429
|
+
): void {
|
|
2430
|
+
if (!form) return;
|
|
2431
|
+
// Compose the popup row list: live completion candidates
|
|
2432
|
+
// first (regular `kind: undefined`), then any history entries
|
|
2433
|
+
// for this field that aren't already in the live list,
|
|
2434
|
+
// marked `kind: "history"` so the host renders them with the
|
|
2435
|
+
// `↶` marker + italic. Duplicate suppression keeps the popup
|
|
2436
|
+
// from showing the same path twice when a candidate happens
|
|
2437
|
+
// to match a previous submission.
|
|
2438
|
+
const live: CompletionItem[] = items
|
|
2439
|
+
.slice(0, COMPLETION_MAX_ITEMS)
|
|
2440
|
+
.map((value) => ({ value }));
|
|
2441
|
+
const histField = focusToHistoryField(field);
|
|
2442
|
+
let composed: CompletionItem[] = live;
|
|
2443
|
+
if (histField) {
|
|
2444
|
+
const seen = new Set(live.map((i) => i.value));
|
|
2445
|
+
const historyRows: CompletionItem[] = readHistory(histField)
|
|
2446
|
+
.filter((v) => !seen.has(v))
|
|
2447
|
+
.slice(0, COMPLETION_MAX_ITEMS)
|
|
2448
|
+
.map((value) => ({ value, kind: "history" as const }));
|
|
2449
|
+
composed = [...live, ...historyRows].slice(0, COMPLETION_MAX_ITEMS);
|
|
2450
|
+
}
|
|
2451
|
+
form.completion.field = field;
|
|
2452
|
+
form.completion.items = composed;
|
|
2453
|
+
form.completion.selectedIndex = 0;
|
|
2454
|
+
// Push the candidate list to the host's Text-widget instance
|
|
2455
|
+
// state. The host repaints the popup chrome (dim separator,
|
|
2456
|
+
// side borders, selected-row highlight) on its own — the
|
|
2457
|
+
// plugin doesn't need to drive a re-render.
|
|
2458
|
+
if (formPanel) {
|
|
2459
|
+
formPanel.setCompletions(field, form.completion.items);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
function closeCompletion(): void {
|
|
2464
|
+
if (!form) return;
|
|
2465
|
+
if (form.completion.field === null && form.completion.items.length === 0) {
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
const prevField = form.completion.field;
|
|
2469
|
+
form.completion.field = null;
|
|
2470
|
+
form.completion.items = [];
|
|
2471
|
+
form.completion.selectedIndex = 0;
|
|
2472
|
+
form.completion.token += 1; // invalidate any in-flight fetch
|
|
2473
|
+
// Mirror the close in host instance state so its popup goes
|
|
2474
|
+
// away in the same frame. Without this the host would keep
|
|
2475
|
+
// painting the candidate list until the next spec push
|
|
2476
|
+
// happened to land for this widget.
|
|
2477
|
+
if (formPanel && prevField) {
|
|
2478
|
+
formPanel.setCompletions(prevField, []);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/// Split typed Project Path into (parent, basename), list
|
|
2483
|
+
/// `parent` via the host's `readDir`, and filter to entries
|
|
2484
|
+
/// whose name starts with `basename`. Directories get a
|
|
2485
|
+
/// trailing `/` so the user sees the type and Tab keeps
|
|
2486
|
+
/// descending. Empty input lists the user's home directory's
|
|
2487
|
+
/// top-level entries as a starting point.
|
|
2488
|
+
async function fetchPathCompletions(typed: string): Promise<string[]> {
|
|
2489
|
+
// Heuristic for "where to list". `parent` is everything up
|
|
2490
|
+
// to and including the last `/`; `basename` is the unfinished
|
|
2491
|
+
// tail we filter on. `/foo/ba` → parent `/foo/`, basename
|
|
2492
|
+
// `ba`. `bar` (no slash) → parent `.`, basename `bar`. `/`
|
|
2493
|
+
// → parent `/`, basename `""`. Delegates to the sync
|
|
2494
|
+
// `computePathCompletions` so the two paths can't drift —
|
|
2495
|
+
// see `scheduleCompletionRefresh` for the sync use case.
|
|
2496
|
+
return computePathCompletions(typed);
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/// List the project's local + remote branches and tags via
|
|
2500
|
+
/// `git for-each-ref` (one subprocess instead of three). Filter
|
|
2501
|
+
/// by substring of the typed value — branch names commonly
|
|
2502
|
+
/// carry slash-separated prefixes (`feat/`, `release/`) that
|
|
2503
|
+
/// the user often doesn't type first.
|
|
2504
|
+
async function fetchBranchCompletions(typed: string): Promise<string[]> {
|
|
2505
|
+
if (!form) return [];
|
|
2506
|
+
const projectPath = form.projectPath.value.trim() || form.defaultProjectPath;
|
|
2507
|
+
if (!projectPath) return [];
|
|
2508
|
+
if (form.projectPathIsGit === false) return [];
|
|
2509
|
+
const res = await spawnCollect(
|
|
2510
|
+
"git",
|
|
2511
|
+
[
|
|
2512
|
+
"-C",
|
|
2513
|
+
projectPath,
|
|
2514
|
+
"for-each-ref",
|
|
2515
|
+
"--format=%(refname:short)",
|
|
2516
|
+
"refs/heads/",
|
|
2517
|
+
"refs/remotes/",
|
|
2518
|
+
"refs/tags/",
|
|
2519
|
+
],
|
|
2520
|
+
projectPath,
|
|
2521
|
+
);
|
|
2522
|
+
if (res.exit_code !== 0) return [];
|
|
2523
|
+
const lines = (res.stdout || "")
|
|
2524
|
+
.split(/\r?\n/)
|
|
2525
|
+
.map((l) => l.trim())
|
|
2526
|
+
.filter((l) => l.length > 0 && l !== "origin/HEAD");
|
|
2527
|
+
const needle = typed.toLowerCase();
|
|
2528
|
+
const matches = needle
|
|
2529
|
+
? lines.filter((l) => l.toLowerCase().includes(needle))
|
|
2530
|
+
: lines;
|
|
2531
|
+
// Dedup the common `origin/<branch>` vs `<branch>` pair when
|
|
2532
|
+
// the local copy exists. Prefer the local short name; drop the
|
|
2533
|
+
// origin alias unless the user explicitly typed `origin`.
|
|
2534
|
+
const local = new Set(matches.filter((l) => !l.includes("/")));
|
|
2535
|
+
const wantsOrigin = needle.startsWith("origin/");
|
|
2536
|
+
const filtered = matches.filter((l) => {
|
|
2537
|
+
if (!wantsOrigin && l.startsWith("origin/")) {
|
|
2538
|
+
const bare = l.slice("origin/".length);
|
|
2539
|
+
if (local.has(bare)) return false;
|
|
2540
|
+
}
|
|
2541
|
+
return true;
|
|
2542
|
+
});
|
|
2543
|
+
// Stable order: exact-match-first, then prefix-match, then
|
|
2544
|
+
// substring; ties broken by length so shorter names surface.
|
|
2545
|
+
filtered.sort((a, b) => {
|
|
2546
|
+
const ascore = a.toLowerCase() === needle ? 0 : a.toLowerCase().startsWith(needle) ? 1 : 2;
|
|
2547
|
+
const bscore = b.toLowerCase() === needle ? 0 : b.toLowerCase().startsWith(needle) ? 1 : 2;
|
|
2548
|
+
if (ascore !== bscore) return ascore - bscore;
|
|
2549
|
+
return a.length - b.length || a.localeCompare(b);
|
|
2550
|
+
});
|
|
2551
|
+
return filtered;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
/// Apply the user-accepted completion candidate to its field.
|
|
2555
|
+
/// Fired in response to the host's `completion_accept` event
|
|
2556
|
+
/// (Tab on a Text-with-open-completions): the host has already
|
|
2557
|
+
/// figured out which row was selected — we just write it into
|
|
2558
|
+
/// the form model and update the field's value. For Project
|
|
2559
|
+
/// Path accepts that end in `/` (directory descent) we re-
|
|
2560
|
+
/// fetch the candidate list for the new path so the user can
|
|
2561
|
+
/// keep Tab-ing into deeper subdirs without first typing
|
|
2562
|
+
/// anything; the host preserves the open popup across the
|
|
2563
|
+
/// fetch, so it just refreshes in place.
|
|
2564
|
+
function applyAcceptedCompletion(
|
|
2565
|
+
field: "project_path" | "branch",
|
|
2566
|
+
item: string,
|
|
2567
|
+
): void {
|
|
2568
|
+
if (!form) return;
|
|
2569
|
+
const slot = field === "project_path" ? form.projectPath : form.branch;
|
|
2570
|
+
slot.value = item;
|
|
2571
|
+
slot.cursor = item.length;
|
|
2572
|
+
if (formPanel) formPanel.setValue(field, slot.value, slot.cursor);
|
|
2573
|
+
if (field === "project_path") {
|
|
2574
|
+
scheduleProjectPathReprobe();
|
|
2575
|
+
if (item.endsWith("/")) {
|
|
2576
|
+
scheduleCompletionRefresh("project_path");
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
closeCompletion();
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function closeForm(): void {
|
|
2584
|
+
if (formPanel) {
|
|
2585
|
+
formPanel.unmount();
|
|
2586
|
+
formPanel = null;
|
|
2587
|
+
}
|
|
2588
|
+
form = null;
|
|
2589
|
+
editor.setEditorMode(null);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// Cancel path: tear down the form, and if it was reached via the
|
|
2593
|
+
// picker (Alt+N or "+ New Session" button), reopen the picker so
|
|
2594
|
+
// Esc behaves like a true "back" rather than dropping the user
|
|
2595
|
+
// into the bare editor.
|
|
2596
|
+
function cancelForm(): void {
|
|
2597
|
+
const wasFromPicker = !!form?.fromPicker;
|
|
2598
|
+
closeForm();
|
|
2599
|
+
if (wasFromPicker) {
|
|
2600
|
+
openControlRoom();
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
async function submitForm(): Promise<void> {
|
|
2605
|
+
if (!form || form.submitting) return;
|
|
2606
|
+
form.submitting = true;
|
|
2607
|
+
form.lastError = null;
|
|
2608
|
+
renderForm();
|
|
2609
|
+
|
|
2610
|
+
// Honour the placeholder: when the user leaves Agent Command
|
|
2611
|
+
// blank, fall back to `lastCmd` (the placeholder text). The
|
|
2612
|
+
// placeholder is rendered as a hint — if the user accepts it by
|
|
2613
|
+
// pressing Enter on an empty field, the dialog should actually
|
|
2614
|
+
// run that command rather than silently spawning a bare shell.
|
|
2615
|
+
const cmd = form.cmd.value.trim() || form.lastCmd.trim();
|
|
2616
|
+
const branchInput = form.branch.value.trim();
|
|
2617
|
+
|
|
2618
|
+
// Project Path: typed value wins; otherwise the resolved
|
|
2619
|
+
// canonical-root placeholder (or, if that probe never
|
|
2620
|
+
// completed, the editor cwd). The picked value drives the
|
|
2621
|
+
// entire submission flow.
|
|
2622
|
+
const projectPath = form.projectPath.value.trim() ||
|
|
2623
|
+
form.defaultProjectPath ||
|
|
2624
|
+
editor.getCwd();
|
|
2625
|
+
|
|
2626
|
+
// Re-probe is-git so we trust the latest filesystem state
|
|
2627
|
+
// rather than a possibly-stale UI flag (race: user pressed
|
|
2628
|
+
// Enter while the debounced probe was still in flight).
|
|
2629
|
+
const isGit = await pathIsInsideGitWorkTree(projectPath);
|
|
2630
|
+
if (!form) return;
|
|
2631
|
+
const createWorktree = isGit === true && form.createWorktree;
|
|
2632
|
+
|
|
2633
|
+
// Resolve the repo's main worktree root when we're in a
|
|
2634
|
+
// worktree-create flow — same logic as before, but rooted at
|
|
2635
|
+
// `projectPath` instead of cwd so the user can target a repo
|
|
2636
|
+
// other than the one the editor was launched in.
|
|
2637
|
+
let repoRoot = projectPath;
|
|
2638
|
+
if (createWorktree) {
|
|
2639
|
+
const canonical = await resolveCanonicalRepoRoot(projectPath);
|
|
2640
|
+
if (canonical) repoRoot = canonical;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// Session name resolution: explicit value wins. Otherwise
|
|
2644
|
+
// auto-generate by scanning `refs/heads/session-N` for the
|
|
2645
|
+
// next free index (the same probe that filled the
|
|
2646
|
+
// placeholder).
|
|
2647
|
+
const sessionName = form.name.value.trim() ||
|
|
2648
|
+
(await nextAutoSessionName(repoRoot, { persist: true }));
|
|
2649
|
+
if (!form) return;
|
|
2650
|
+
|
|
2651
|
+
// Session root resolution:
|
|
2652
|
+
// - createWorktree=true → fresh worktree under
|
|
2653
|
+
// `<XDG>/orchestrator/<slug>/<session>/`.
|
|
2654
|
+
// - createWorktree=false → run inside `projectPath` itself
|
|
2655
|
+
// (shared worktree / non-git path / multiple sessions on
|
|
2656
|
+
// the same root).
|
|
2657
|
+
const root = createWorktree
|
|
2658
|
+
? editor.pathJoin(
|
|
2659
|
+
editor.getDataDir(),
|
|
2660
|
+
"orchestrator",
|
|
2661
|
+
slugify(repoRoot),
|
|
2662
|
+
sessionName,
|
|
2663
|
+
)
|
|
2664
|
+
: projectPath;
|
|
2665
|
+
|
|
2666
|
+
if (createWorktree) {
|
|
2667
|
+
const parent = editor.pathDirname(root);
|
|
2668
|
+
if (!editor.createDir(parent)) {
|
|
2669
|
+
if (!form) return;
|
|
2670
|
+
form.submitting = false;
|
|
2671
|
+
form.lastError = `mkdir failed: ${parent}`;
|
|
2672
|
+
editor.setStatus(`Orchestrator: ${form.lastError}`);
|
|
2673
|
+
renderForm();
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
const defaultBranch = await detectDefaultBranch(repoRoot);
|
|
2678
|
+
const branchName = branchInput || sessionName;
|
|
2679
|
+
// Try `-b <new>` first; if it fails because the branch
|
|
2680
|
+
// already exists, fall back to checking out the existing
|
|
2681
|
+
// branch into a new worktree.
|
|
2682
|
+
let addRes = await spawnCollect(
|
|
2683
|
+
"git",
|
|
2684
|
+
["-C", repoRoot, "worktree", "add", root, "-b", branchName, defaultBranch],
|
|
2685
|
+
repoRoot,
|
|
2686
|
+
);
|
|
2687
|
+
if (addRes.exit_code !== 0) {
|
|
2688
|
+
const fallback = await spawnCollect(
|
|
2689
|
+
"git",
|
|
2690
|
+
["-C", repoRoot, "worktree", "add", root, branchName],
|
|
2691
|
+
repoRoot,
|
|
2692
|
+
);
|
|
2693
|
+
if (fallback.exit_code !== 0) {
|
|
2694
|
+
if (!form) return;
|
|
2695
|
+
form.submitting = false;
|
|
2696
|
+
// Prefer the fallback's stderr: when both attempts
|
|
2697
|
+
// fail, the `-b` branch's error is usually "branch
|
|
2698
|
+
// already exists" (which is *why* we tried the
|
|
2699
|
+
// fallback), and the fallback's error is the more
|
|
2700
|
+
// informative one.
|
|
2701
|
+
form.lastError = lastNonEmptyLine(fallback.stderr) ||
|
|
2702
|
+
lastNonEmptyLine(addRes.stderr) ||
|
|
2703
|
+
"git worktree add failed";
|
|
2704
|
+
editor.setStatus(`Orchestrator: ${form.lastError}`);
|
|
2705
|
+
renderForm();
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
addRes = fallback;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
if (cmd) {
|
|
2713
|
+
editor.setGlobalState("orchestrator.last_cmd", cmd);
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
// Branch / cmd values used for `pendingNewSession` — `branchName`
|
|
2717
|
+
// only exists in the worktree-create flow above; for the
|
|
2718
|
+
// shared-worktree / non-git case we report whatever's currently
|
|
2719
|
+
// checked out (best-effort) so the new session record matches
|
|
2720
|
+
// the situation on disk.
|
|
2721
|
+
const reportedBranch = createWorktree
|
|
2722
|
+
? (branchInput || sessionName)
|
|
2723
|
+
: "";
|
|
2724
|
+
|
|
2725
|
+
// Append the user-effective values to per-field input
|
|
2726
|
+
// history so ↑/↓ can recall them on the next form open.
|
|
2727
|
+
appendHistory("project_path", projectPath);
|
|
2728
|
+
appendHistory("name", sessionName);
|
|
2729
|
+
if (cmd) appendHistory("cmd", cmd);
|
|
2730
|
+
if (createWorktree) appendHistory("branch", reportedBranch);
|
|
2731
|
+
|
|
2732
|
+
pendingNewSession = {
|
|
2733
|
+
label: sessionName,
|
|
2734
|
+
branch: reportedBranch,
|
|
2735
|
+
cmd,
|
|
2736
|
+
root,
|
|
2737
|
+
projectPath,
|
|
2738
|
+
sharedWorktree: !createWorktree,
|
|
2739
|
+
};
|
|
2740
|
+
closeForm();
|
|
2741
|
+
editor.createWindow(root, sessionName);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
function startNewSession(): void {
|
|
2745
|
+
if (form) return; // already open
|
|
2746
|
+
openForm();
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// Form key bindings — each delegates to smart-key dispatch on the
|
|
2750
|
+
// panel, which routes to the focused widget. `mode_text_input`
|
|
2751
|
+
// handles printable input outside this list.
|
|
2752
|
+
// Enter is bound to a thin shim that closes the completion
|
|
2753
|
+
// dropdown without accepting (Tab is the only accept path —
|
|
2754
|
+
// matches bash / fish / readline path-completion conventions),
|
|
2755
|
+
// then forwards Enter to the host's smart-key dispatch so the
|
|
2756
|
+
// normal behaviour applies: Enter-on-button → activate (Cancel
|
|
2757
|
+
// cancels, Create Session submits via their `widget_event`
|
|
2758
|
+
// "activate" branches), Enter-on-text-input → focus advance.
|
|
2759
|
+
// Without the shim, the host's picker-style Enter wiring would
|
|
2760
|
+
// fire the sibling completion list's activate event and silently
|
|
2761
|
+
// overwrite the typed text with the highlighted suggestion.
|
|
2762
|
+
const FORM_MODE_BINDINGS: [string, string][] = [
|
|
2763
|
+
["Tab", "orchestrator_form_key_tab"],
|
|
2764
|
+
["S-Tab", "orchestrator_form_key_shift_tab"],
|
|
2765
|
+
["Enter", "orchestrator_form_key_enter"],
|
|
2766
|
+
["Escape", "orchestrator_form_key_escape"],
|
|
2767
|
+
["Backspace", "orchestrator_form_key_backspace"],
|
|
2768
|
+
["Delete", "orchestrator_form_key_delete"],
|
|
2769
|
+
["Home", "orchestrator_form_key_home"],
|
|
2770
|
+
["End", "orchestrator_form_key_end"],
|
|
2771
|
+
["Left", "orchestrator_form_key_left"],
|
|
2772
|
+
["Right", "orchestrator_form_key_right"],
|
|
2773
|
+
["Up", "orchestrator_form_key_up"],
|
|
2774
|
+
["Down", "orchestrator_form_key_down"],
|
|
2775
|
+
];
|
|
2776
|
+
|
|
2777
|
+
editor.defineMode(NEW_SESSION_MODE, FORM_MODE_BINDINGS, true, true);
|
|
2778
|
+
|
|
2779
|
+
function dispatchFormKey(name: string): void {
|
|
2780
|
+
if (!form || !formPanel) return;
|
|
2781
|
+
formPanel.command(widgetKey(name));
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// Tab / Enter / Up / Down / Escape are all routed straight to
|
|
2785
|
+
// the host's smart-key dispatch via `dispatchFormKey`. The host
|
|
2786
|
+
// owns the completion popup state (instance state on the Text
|
|
2787
|
+
// widget), so when the popup is open it short-circuits these
|
|
2788
|
+
// keys to popup-specific behaviour (accept, dismiss, move
|
|
2789
|
+
// selection) and falls through to the widget's default key
|
|
2790
|
+
// handling otherwise. The plugin just reacts to the events the
|
|
2791
|
+
// host emits — `completion_accept` and `completion_dismiss`,
|
|
2792
|
+
// handled in the `widget_event` dispatch below.
|
|
2793
|
+
registerHandler("orchestrator_form_key_tab", () => {
|
|
2794
|
+
if (completionVisibleForFocused()) {
|
|
2795
|
+
// Host fires completion_accept; plugin's widget_event
|
|
2796
|
+
// handler applies the value. No focus advance.
|
|
2797
|
+
dispatchFormKey("Tab");
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
advanceFormFocus(1);
|
|
2801
|
+
dispatchFormKey("Tab");
|
|
2802
|
+
});
|
|
2803
|
+
registerHandler("orchestrator_form_key_enter", () => {
|
|
2804
|
+
// When the popup is open, the host's smart-key fires
|
|
2805
|
+
// `completion_dismiss` (plugin syncs local state via that
|
|
2806
|
+
// event) without firing the form's picker-Enter or focus
|
|
2807
|
+
// advance — Enter is "dismiss the popup, stay focused on
|
|
2808
|
+
// the text input". When the popup is closed, Enter falls
|
|
2809
|
+
// through to the host's normal Text-widget Enter (picker
|
|
2810
|
+
// activate or focus advance). On a focus advance, the host
|
|
2811
|
+
// fires a `widget_event { event_type: "focus" }` and the
|
|
2812
|
+
// plugin snaps `formFocusIndex` from that authoritative
|
|
2813
|
+
// signal — see the `focus` branch in the widget_event
|
|
2814
|
+
// handler below.
|
|
2815
|
+
dispatchFormKey("Enter");
|
|
2816
|
+
});
|
|
2817
|
+
registerHandler(
|
|
2818
|
+
"orchestrator_form_key_shift_tab",
|
|
2819
|
+
() => {
|
|
2820
|
+
// Shift+Tab doesn't accept — it always reverses focus.
|
|
2821
|
+
// (The convention is that S-Tab is the "go back" gesture;
|
|
2822
|
+
// overloading it to accept-then-go-back is more confusing
|
|
2823
|
+
// than useful.)
|
|
2824
|
+
closeCompletion();
|
|
2825
|
+
advanceFormFocus(-1);
|
|
2826
|
+
dispatchFormKey("Shift+Tab");
|
|
2827
|
+
},
|
|
2828
|
+
);
|
|
2829
|
+
registerHandler("orchestrator_form_key_escape", () => {
|
|
2830
|
+
// When the popup is open, the host dismisses on Escape and
|
|
2831
|
+
// emits `completion_dismiss`; the plugin's local state
|
|
2832
|
+
// resync happens in the widget_event handler. Only when
|
|
2833
|
+
// the popup is already closed does Escape cancel the form.
|
|
2834
|
+
if (completionVisibleForFocused()) {
|
|
2835
|
+
dispatchFormKey("Escape");
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
if (form) cancelForm();
|
|
2839
|
+
});
|
|
2840
|
+
registerHandler(
|
|
2841
|
+
"orchestrator_form_key_backspace",
|
|
2842
|
+
() => dispatchFormKey("Backspace"),
|
|
2843
|
+
);
|
|
2844
|
+
registerHandler("orchestrator_form_key_delete", () => dispatchFormKey("Delete"));
|
|
2845
|
+
registerHandler("orchestrator_form_key_home", () => dispatchFormKey("Home"));
|
|
2846
|
+
registerHandler("orchestrator_form_key_end", () => dispatchFormKey("End"));
|
|
2847
|
+
registerHandler("orchestrator_form_key_left", () => dispatchFormKey("Left"));
|
|
2848
|
+
registerHandler("orchestrator_form_key_right", () => dispatchFormKey("Right"));
|
|
2849
|
+
registerHandler("orchestrator_form_key_up", () => {
|
|
2850
|
+
// Popup-open: dispatch straight through so the host moves
|
|
2851
|
+
// the popup-selection cursor.
|
|
2852
|
+
// Popup-closed: on a completion-bearing field
|
|
2853
|
+
// (project_path / branch) re-fetch the popup so the user
|
|
2854
|
+
// gets back live candidates AND any `↶`-marked history rows
|
|
2855
|
+
// mixed in (see `setCompletionItems`). On a history-bearing
|
|
2856
|
+
// non-completion field (name / cmd) walk history in place.
|
|
2857
|
+
// Otherwise pass through.
|
|
2858
|
+
if (completionVisibleForFocused()) {
|
|
2859
|
+
dispatchFormKey("Up");
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
const focusKey = formFocusedKey();
|
|
2863
|
+
if (focusKey === "project_path" || focusKey === "branch") {
|
|
2864
|
+
scheduleCompletionRefresh(focusKey);
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
const histField = focusToHistoryField(focusKey);
|
|
2868
|
+
if (histField) {
|
|
2869
|
+
walkHistory(histField, -1);
|
|
2870
|
+
} else {
|
|
2871
|
+
dispatchFormKey("Up");
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
registerHandler("orchestrator_form_key_down", () => {
|
|
2875
|
+
if (completionVisibleForFocused()) {
|
|
2876
|
+
dispatchFormKey("Down");
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
const focusKey = formFocusedKey();
|
|
2880
|
+
if (focusKey === "project_path" || focusKey === "branch") {
|
|
2881
|
+
scheduleCompletionRefresh(focusKey);
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
const histField = focusToHistoryField(focusKey);
|
|
2885
|
+
if (histField) {
|
|
2886
|
+
walkHistory(histField, 1);
|
|
2887
|
+
} else {
|
|
2888
|
+
dispatchFormKey("Down");
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
/// Is the completion popup open for the currently focused
|
|
2893
|
+
/// input? Tracked plugin-side because the plugin still needs
|
|
2894
|
+
/// to know in order to gate history-walk (Up/Down on an empty-
|
|
2895
|
+
/// popup history-bearing input walks the history list, not
|
|
2896
|
+
/// the popup). The host's instance state is authoritative for
|
|
2897
|
+
/// the popup itself; the plugin mirrors the open/closed bit
|
|
2898
|
+
/// here by populating `form.completion.items` from
|
|
2899
|
+
/// `setCompletionItems` and clearing it from
|
|
2900
|
+
/// `closeCompletion` / on the `completion_dismiss` event.
|
|
2901
|
+
function completionVisibleForFocused(): boolean {
|
|
2902
|
+
if (!form) return false;
|
|
2903
|
+
const c = form.completion;
|
|
2904
|
+
if (c.field === null || c.items.length === 0) return false;
|
|
2905
|
+
return formFocusedKey() === c.field;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// Printable input arrives via the global `mode_text_input` action.
|
|
2909
|
+
// Other plugins may also register a `mode_text_input` handler;
|
|
2910
|
+
// guard on `form` so this handler is a no-op outside the form.
|
|
2911
|
+
//
|
|
2912
|
+
// Special-case: a space character on a focused Toggle / Button
|
|
2913
|
+
// is "activate this control", not "insert a literal space into
|
|
2914
|
+
// the value". The host's smart-key dispatch already does this
|
|
2915
|
+
// for `widgetCommand({kind: "key", name: "Space"})`, but the
|
|
2916
|
+
// mode binding for "Space" is shadowed by the global text-input
|
|
2917
|
+
// path (printable chars route to `mode_text_input` ahead of the
|
|
2918
|
+
// custom mode keymap), so we intercept here instead.
|
|
2919
|
+
function orchestrator_mode_text_input(args: { text: string }): void {
|
|
2920
|
+
if (!form || !formPanel || !args?.text) return;
|
|
2921
|
+
formPanel.command(textInputChar(args.text));
|
|
2922
|
+
}
|
|
2923
|
+
registerHandler("mode_text_input", orchestrator_mode_text_input);
|
|
2924
|
+
|
|
2925
|
+
// Open the confirm panel for `action` against the currently
|
|
2926
|
+
// selected session, rebuild the spec, and ensure the Cancel
|
|
2927
|
+
// button gets default focus.
|
|
2928
|
+
//
|
|
2929
|
+
// `buildOpenSpec` drops the `key` from the filter input and the
|
|
2930
|
+
// `+ New Session` button while `pendingConfirm` is set, so they
|
|
2931
|
+
// fall out of the Tab cycle. Cancel still isn't the first
|
|
2932
|
+
// tabbable in raw declaration order, though — `setFocusKey`
|
|
2933
|
+
// pins it explicitly so a stray Enter on mount is a no-op
|
|
2934
|
+
// rather than a worktree wipe (confirm prompts for destructive
|
|
2935
|
+
// actions should be biased toward the safe path).
|
|
2936
|
+
function enterConfirm(action: "stop" | "archive" | "delete"): void {
|
|
2937
|
+
if (!openDialog || !openPanel) return;
|
|
2938
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2939
|
+
if (typeof id !== "number" || id <= 0) return;
|
|
2940
|
+
// Refuse Archive / Delete on a shared root while other
|
|
2941
|
+
// sessions still live there. Both actions either move
|
|
2942
|
+
// (`git worktree move`) or remove (`git worktree remove`)
|
|
2943
|
+
// the on-disk path — doing that under another running
|
|
2944
|
+
// session would yank the rug out from under it. Stop is
|
|
2945
|
+
// fine: it only signals THIS session's process group, no
|
|
2946
|
+
// disk operation.
|
|
2947
|
+
if (action === "archive" || action === "delete") {
|
|
2948
|
+
const session = orchestratorSessions.get(id);
|
|
2949
|
+
if (session) {
|
|
2950
|
+
const siblings = countSiblingsAtRoot(session.root);
|
|
2951
|
+
if (siblings > 1) {
|
|
2952
|
+
setDialogError(
|
|
2953
|
+
`cannot ${action} session [${id}] ${session.label} — ${siblings - 1} other session(s) share this worktree; close them first`,
|
|
2954
|
+
);
|
|
2955
|
+
refreshOpenDialog();
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
if (session.sharedWorktree) {
|
|
2959
|
+
// Single-session shared-worktree mode: there's no
|
|
2960
|
+
// `git worktree` entry to remove for this session.
|
|
2961
|
+
// Block both lifecycle actions so we don't run
|
|
2962
|
+
// `git worktree remove` against a non-worktree path
|
|
2963
|
+
// and rm-rf the user's actual project directory.
|
|
2964
|
+
setDialogError(
|
|
2965
|
+
`cannot ${action} session [${id}] ${session.label} — session shares its working tree with the project root; close it via the editor instead`,
|
|
2966
|
+
);
|
|
2967
|
+
refreshOpenDialog();
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
openDialog.pendingConfirm = { action, sessionId: id };
|
|
2973
|
+
openPanel.update(buildOpenSpec());
|
|
2974
|
+
openPanel.setFocusKey("confirm-cancel");
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
editor.on("widget_event", (e) => {
|
|
2978
|
+
// ---------------------------------------------------------------------
|
|
2979
|
+
// New-session form
|
|
2980
|
+
// ---------------------------------------------------------------------
|
|
2981
|
+
if (form && formPanel && e.panel_id === formPanel.id()) {
|
|
2982
|
+
if (e.event_type === "focus") {
|
|
2983
|
+
// Host fires this whenever the panel's focused widget
|
|
2984
|
+
// changes — key-driven (Tab / Shift-Tab / Enter focus-
|
|
2985
|
+
// advance), click-driven, or any other host-side focus
|
|
2986
|
+
// mutation. The plugin keeps a local `formFocusIndex`
|
|
2987
|
+
// mirror so handlers like Up/Down can look up the right
|
|
2988
|
+
// history field without first asking the host; we snap
|
|
2989
|
+
// that mirror from the authoritative signal here so the
|
|
2990
|
+
// plugin never has to predict host-side focus rules.
|
|
2991
|
+
snapFormFocusTo(e.widget_key);
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
if (e.event_type === "change") {
|
|
2995
|
+
const field = e.widget_key;
|
|
2996
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
2997
|
+
const value = payload.value;
|
|
2998
|
+
const cursor = payload.cursorByte;
|
|
2999
|
+
if (typeof value !== "string") return;
|
|
3000
|
+
const slot = field === "project_path"
|
|
3001
|
+
? form.projectPath
|
|
3002
|
+
: field === "name"
|
|
3003
|
+
? form.name
|
|
3004
|
+
: field === "cmd"
|
|
3005
|
+
? form.cmd
|
|
3006
|
+
: field === "branch"
|
|
3007
|
+
? form.branch
|
|
3008
|
+
: null;
|
|
3009
|
+
if (slot) {
|
|
3010
|
+
slot.value = value;
|
|
3011
|
+
if (typeof cursor === "number") slot.cursor = cursor;
|
|
3012
|
+
// Typing in any history-bearing field invalidates the
|
|
3013
|
+
// history cursor — the user is composing a new draft.
|
|
3014
|
+
const histField = focusToHistoryField(field);
|
|
3015
|
+
if (histField) form.historyCursor[histField] = -1;
|
|
3016
|
+
// Snap our focus mirror to wherever the change just
|
|
3017
|
+
// landed — covers mouse-click focus changes (no Tab key
|
|
3018
|
+
// for us to intercept).
|
|
3019
|
+
snapFormFocusTo(field);
|
|
3020
|
+
}
|
|
3021
|
+
if (field === "project_path") {
|
|
3022
|
+
scheduleProjectPathReprobe();
|
|
3023
|
+
scheduleCompletionRefresh("project_path");
|
|
3024
|
+
} else if (field === "branch") {
|
|
3025
|
+
scheduleCompletionRefresh("branch");
|
|
3026
|
+
} else {
|
|
3027
|
+
// Any other field's change implicitly closes the
|
|
3028
|
+
// dropdown (the user moved on).
|
|
3029
|
+
closeCompletion();
|
|
3030
|
+
}
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
if (e.event_type === "toggle" && e.widget_key === "worktree") {
|
|
3034
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
3035
|
+
const checked = payload.checked;
|
|
3036
|
+
if (typeof checked === "boolean") {
|
|
3037
|
+
form.createWorktree = checked;
|
|
3038
|
+
} else {
|
|
3039
|
+
form.createWorktree = !form.createWorktree;
|
|
3040
|
+
}
|
|
3041
|
+
renderForm();
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
if (e.event_type === "completion_accept") {
|
|
3045
|
+
// Host fires this on Tab against a Text widget with an
|
|
3046
|
+
// open completion popup. The payload carries the
|
|
3047
|
+
// candidate that was highlighted.
|
|
3048
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
3049
|
+
const value = payload.value;
|
|
3050
|
+
if (typeof value !== "string") return;
|
|
3051
|
+
if (e.widget_key === "project_path" || e.widget_key === "branch") {
|
|
3052
|
+
applyAcceptedCompletion(e.widget_key, value);
|
|
3053
|
+
}
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
if (e.event_type === "completion_dismiss") {
|
|
3057
|
+
// Host fires this on Enter / Esc against a Text widget
|
|
3058
|
+
// with an open popup. Sync plugin-side state so the
|
|
3059
|
+
// history-walk gate (Up/Down on an empty-popup history-
|
|
3060
|
+
// bearing field) reads `false` again.
|
|
3061
|
+
closeCompletion();
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
if (e.event_type === "activate") {
|
|
3065
|
+
if (e.widget_key === "create") {
|
|
3066
|
+
void submitForm();
|
|
3067
|
+
} else if (e.widget_key === "cancel") {
|
|
3068
|
+
cancelForm();
|
|
3069
|
+
}
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
if (e.event_type === "cancel") {
|
|
3073
|
+
// Host fires this when Esc unmounts the floating panel —
|
|
3074
|
+
// mirror our own state and (if reached from the picker)
|
|
3075
|
+
// bounce back to the picker so Esc is "back", not "out".
|
|
3076
|
+
const wasFromPicker = !!form?.fromPicker;
|
|
3077
|
+
form = null;
|
|
3078
|
+
formPanel = null;
|
|
3079
|
+
editor.setEditorMode(null);
|
|
3080
|
+
if (wasFromPicker) {
|
|
3081
|
+
openControlRoom();
|
|
3082
|
+
}
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// ---------------------------------------------------------------------
|
|
3089
|
+
// Open dialog (session picker)
|
|
3090
|
+
// ---------------------------------------------------------------------
|
|
3091
|
+
if (openPanel && openDialog && e.panel_id === openPanel.id()) {
|
|
3092
|
+
if (e.event_type === "change" && e.widget_key === "filter") {
|
|
3093
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
3094
|
+
const value = payload.value;
|
|
3095
|
+
const cursor = payload.cursorByte;
|
|
3096
|
+
if (typeof value !== "string") return;
|
|
3097
|
+
openDialog.filter.value = value;
|
|
3098
|
+
if (typeof cursor === "number") openDialog.filter.cursor = cursor;
|
|
3099
|
+
// Filter change implies the user has moved on from any
|
|
3100
|
+
// previous error — clear the banner so it doesn't shadow
|
|
3101
|
+
// the typing experience.
|
|
3102
|
+
clearDialogError();
|
|
3103
|
+
// Preserve highlighted session across the filter narrowing
|
|
3104
|
+
// when possible — if the previously selected id is still in
|
|
3105
|
+
// the new filtered set, keep it; otherwise reset to 0.
|
|
3106
|
+
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
|
|
3107
|
+
const next = filterSessions(value);
|
|
3108
|
+
openDialog.filteredIds = next;
|
|
3109
|
+
const nextIdx = prevId !== undefined ? next.indexOf(prevId) : -1;
|
|
3110
|
+
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
|
|
3111
|
+
refreshOpenDialog();
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
if (e.event_type === "select" && e.widget_key === "sessions") {
|
|
3115
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
3116
|
+
const idx = payload.index;
|
|
3117
|
+
if (typeof idx === "number") {
|
|
3118
|
+
openDialog.selectedIndex = idx;
|
|
3119
|
+
clearDialogError();
|
|
3120
|
+
// Update preview pane.
|
|
3121
|
+
openPanel.update(buildOpenSpec());
|
|
3122
|
+
// Re-pin the list selection so the spec re-emit doesn't
|
|
3123
|
+
// snap it back to 0.
|
|
3124
|
+
openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
|
|
3125
|
+
// Up/Down on a focused action button (Stop / Archive /
|
|
3126
|
+
// Delete / Details / +New Session) routes to the sessions
|
|
3127
|
+
// list via the host's smart-key dispatch but leaves focus
|
|
3128
|
+
// on the button. Snap focus back to Visit so the user can
|
|
3129
|
+
// press Enter to open the newly-highlighted session — the
|
|
3130
|
+
// dialog's whole reason for being. Idempotent when focus
|
|
3131
|
+
// is already on Visit.
|
|
3132
|
+
openPanel.setFocusKey("visit");
|
|
3133
|
+
}
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
if (
|
|
3137
|
+
e.event_type === "activate" &&
|
|
3138
|
+
(e.widget_key === "sessions" || e.widget_key === "visit")
|
|
3139
|
+
) {
|
|
3140
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
3141
|
+
if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
|
|
3142
|
+
editor.setActiveWindow(id);
|
|
3143
|
+
}
|
|
3144
|
+
closeOpenDialog();
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
if (e.event_type === "activate" && e.widget_key === "new-session") {
|
|
3148
|
+
closeOpenDialog();
|
|
3149
|
+
openForm({ fromPicker: true });
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
if (e.event_type === "activate" && e.widget_key === "toggle-details") {
|
|
3153
|
+
openDialog.showDetails = !openDialog.showDetails;
|
|
3154
|
+
refreshOpenDialog();
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
if (e.event_type === "activate" && e.widget_key === "stop") {
|
|
3158
|
+
enterConfirm("stop");
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
if (e.event_type === "activate" && e.widget_key === "archive") {
|
|
3162
|
+
enterConfirm("archive");
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
if (e.event_type === "activate" && e.widget_key === "delete") {
|
|
3166
|
+
enterConfirm("delete");
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
|
|
3170
|
+
openDialog.pendingConfirm = null;
|
|
3171
|
+
openPanel.update(buildOpenSpec());
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-stop") {
|
|
3175
|
+
openDialog.pendingConfirm = null;
|
|
3176
|
+
stopSelectedSession();
|
|
3177
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-archive") {
|
|
3181
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
3182
|
+
openDialog.pendingConfirm = null;
|
|
3183
|
+
// Mark the session in-flight so the preview swaps to
|
|
3184
|
+
// "Archiving…" and its action buttons disappear until git
|
|
3185
|
+
// finishes. The row stays in the list — `editor.listWindows()`
|
|
3186
|
+
// is still the source of truth and will drop it on
|
|
3187
|
+
// `closeWindow`, which is intentional: a slightly-laggy real
|
|
3188
|
+
// state beats a synchronously faked one that can desync from
|
|
3189
|
+
// git reality (e.g. when `git worktree move` fails).
|
|
3190
|
+
if (typeof id === "number" && id > 0) {
|
|
3191
|
+
openDialog.inFlight = { action: "archive", sessionId: id };
|
|
3192
|
+
}
|
|
3193
|
+
void archiveSelectedSession(id);
|
|
3194
|
+
refreshOpenDialog();
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
3197
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-delete") {
|
|
3198
|
+
const id = openDialog.pendingConfirm?.sessionId;
|
|
3199
|
+
// Mark in-flight — see comment on confirm-archive above.
|
|
3200
|
+
// `deleteConfirmedSession` clears `pendingConfirm` itself, so
|
|
3201
|
+
// we capture the id here before it goes away.
|
|
3202
|
+
if (typeof id === "number" && id > 0) {
|
|
3203
|
+
openDialog.inFlight = { action: "delete", sessionId: id };
|
|
3204
|
+
}
|
|
3205
|
+
void deleteConfirmedSession();
|
|
3206
|
+
refreshOpenDialog();
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
if (e.event_type === "cancel") {
|
|
3210
|
+
// Esc unmounted the panel — sync our own state.
|
|
3211
|
+
openDialog = null;
|
|
3212
|
+
openPanel = null;
|
|
3213
|
+
editor.setEditorMode(null);
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
});
|
|
3219
|
+
|
|
3220
|
+
// Legacy kill helper retained for the `Orchestrator: Kill Selected`
|
|
3221
|
+
// command-palette command. In the widget-based picker (Phase 1)
|
|
3222
|
+
// the open dialog has no kill action — Phase 3-5 will replace
|
|
3223
|
+
// this with Stop / Archive / Delete. When invoked while the
|
|
3224
|
+
// open dialog is up, it targets that dialog's selection; when
|
|
3225
|
+
// invoked from the palette outside the dialog, it status-bars
|
|
3226
|
+
// with guidance.
|
|
3227
|
+
function killSelected(): void {
|
|
3228
|
+
if (!openDialog) {
|
|
3229
|
+
editor.setStatus(
|
|
3230
|
+
"Orchestrator: open the session list (Ctrl+P → Orchestrator: Open) first",
|
|
3231
|
+
);
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
const ids = openDialog.filteredIds;
|
|
3235
|
+
if (ids.length === 0) {
|
|
3236
|
+
editor.setStatus("Orchestrator: no session selected");
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
const id = ids[Math.max(0, Math.min(openDialog.selectedIndex, ids.length - 1))];
|
|
3240
|
+
if (id <= 0) {
|
|
3241
|
+
editor.setStatus("Orchestrator: select a session row first");
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
if (id === 1) {
|
|
3245
|
+
editor.setStatus("Orchestrator: cannot kill the base session");
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
if (id === editor.activeWindow()) {
|
|
3249
|
+
editor.setStatus(
|
|
3250
|
+
"Orchestrator: dive elsewhere first, then kill this session",
|
|
3251
|
+
);
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
const s = orchestratorSessions.get(id);
|
|
3255
|
+
if (s && s.terminalId !== null) {
|
|
3256
|
+
editor.closeTerminal(s.terminalId);
|
|
3257
|
+
}
|
|
3258
|
+
editor.closeWindow(id);
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
// =============================================================================
|
|
3262
|
+
// Lifecycle hook handlers
|
|
3263
|
+
// =============================================================================
|
|
3264
|
+
|
|
3265
|
+
editor.on("window_created", async (payload) => {
|
|
3266
|
+
const id = payload.id;
|
|
3267
|
+
if (
|
|
3268
|
+
pendingNewSession &&
|
|
3269
|
+
payload.label === pendingNewSession.label
|
|
3270
|
+
) {
|
|
3271
|
+
const intent = pendingNewSession;
|
|
3272
|
+
pendingNewSession = null;
|
|
3273
|
+
// Dive into the new session FIRST so its terminal_manager is
|
|
3274
|
+
// the editor-active one. Subsequent `createTerminal` /
|
|
3275
|
+
// `sendTerminalInput` calls then resolve against the new
|
|
3276
|
+
// session's window without needing a cross-window terminal
|
|
3277
|
+
// lookup. Creating a session is a visit-now action anyway —
|
|
3278
|
+
// the dive isn't user-visible flicker, it's the desired
|
|
3279
|
+
// landing state.
|
|
3280
|
+
editor.setActiveWindow(id);
|
|
3281
|
+
// Record the new session's project_path / shared_worktree
|
|
3282
|
+
// into per-window plugin state — these survive editor
|
|
3283
|
+
// restarts via `orchestrator_persistence.rs`, and feed the
|
|
3284
|
+
// Open dialog's "this project" filter on the next launch.
|
|
3285
|
+
// `setWindowState` writes to the active window, which we
|
|
3286
|
+
// just set above.
|
|
3287
|
+
editor.setWindowState("project_path", intent.projectPath);
|
|
3288
|
+
editor.setWindowState("shared_worktree", intent.sharedWorktree);
|
|
3289
|
+
// When the user provided a non-empty agent command, spawn it as
|
|
3290
|
+
// the PTY child directly (no shell middleman). Tab title reads
|
|
3291
|
+
// the command name ("python3", "claude", ...) instead of the
|
|
3292
|
+
// generic "*Terminal N*". When `cmd` is empty the host picks
|
|
3293
|
+
// the user's shell as before.
|
|
3294
|
+
const argv = splitAgentCmd(intent.cmd);
|
|
3295
|
+
const term = await editor.createTerminal({
|
|
3296
|
+
cwd: intent.root,
|
|
3297
|
+
focus: false,
|
|
3298
|
+
command: argv.length > 0 ? argv : undefined,
|
|
3299
|
+
title: argv.length > 0 ? argv[0] : undefined,
|
|
3300
|
+
});
|
|
3301
|
+
const tracked: AgentSession = {
|
|
3302
|
+
id,
|
|
3303
|
+
label: intent.label,
|
|
3304
|
+
root: intent.root,
|
|
3305
|
+
terminalId: term.terminalId,
|
|
3306
|
+
state: "running",
|
|
3307
|
+
createdAt: Date.now(),
|
|
3308
|
+
};
|
|
3309
|
+
orchestratorSessions.set(id, tracked);
|
|
3310
|
+
// Legacy `sendTerminalInput` path is no longer needed when the
|
|
3311
|
+
// command is spawned directly. Kept for the shell-only case
|
|
3312
|
+
// would be `editor.sendTerminalInput(term.terminalId, "\n")` to
|
|
3313
|
+
// wake up the prompt, but that's unnecessary — the shell prints
|
|
3314
|
+
// its own prompt on startup.
|
|
3315
|
+
}
|
|
3316
|
+
refreshOpenDialog();
|
|
3317
|
+
});
|
|
3318
|
+
|
|
3319
|
+
editor.on("window_closed", () => {
|
|
3320
|
+
refreshOpenDialog();
|
|
3321
|
+
});
|
|
3322
|
+
|
|
3323
|
+
editor.on("active_window_changed", () => {
|
|
3324
|
+
refreshOpenDialog();
|
|
3325
|
+
});
|
|
3326
|
+
|
|
3327
|
+
// Re-flow the open-picker on terminal resize. The dialog's
|
|
3328
|
+
// `listVisibleRows` / `embedRows` are captured at open-time
|
|
3329
|
+
// (orchestrator.ts:`openControlRoom`); without this subscription
|
|
3330
|
+
// they stay frozen at the pre-resize values and the live preview
|
|
3331
|
+
// embed gets clipped (or leaves blank space) when the user
|
|
3332
|
+
// resizes their tmux pane. The host also re-renders the panel
|
|
3333
|
+
// against the new screen width unconditionally (see
|
|
3334
|
+
// `Editor::resize` in `lifecycle.rs`); this handler just refreshes
|
|
3335
|
+
// the spec so the *plugin's* row-count knobs adopt the new
|
|
3336
|
+
// viewport at the same time.
|
|
3337
|
+
editor.on("resize", () => {
|
|
3338
|
+
if (openDialog && openPanel) {
|
|
3339
|
+
const listVisibleRows = openListVisibleRows();
|
|
3340
|
+
openDialog.listVisibleRows = listVisibleRows;
|
|
3341
|
+
openDialog.embedRows = Math.max(3, listVisibleRows - 5);
|
|
3342
|
+
refreshOpenDialog();
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
3345
|
+
|
|
3346
|
+
// =============================================================================
|
|
3347
|
+
// Agent state inference from terminal output / exit
|
|
3348
|
+
// =============================================================================
|
|
3349
|
+
|
|
3350
|
+
// Match common AI-agent prompts: "(Y/n)", "(y/N)", "Press <key>",
|
|
3351
|
+
// or a trailing question mark followed by optional whitespace.
|
|
3352
|
+
// Conservative — false positives mistakenly classify a busy
|
|
3353
|
+
// agent as "awaiting", which is recoverable by next output;
|
|
3354
|
+
// false negatives are worse (user thinks agent is busy when
|
|
3355
|
+
// it's actually waiting), so we err on the side of detecting.
|
|
3356
|
+
const AWAITING_RX = /(\(\s*[YyNn]\s*\/\s*[YyNn]\s*\):?\s*$)|(Press\s+(?:enter|return|any\s+key)[^\n]*$)|(\?\s*$)/i;
|
|
3357
|
+
|
|
3358
|
+
editor.on("terminal_output", (payload) => {
|
|
3359
|
+
const last = payload.last_line || "";
|
|
3360
|
+
for (const s of orchestratorSessions.values()) {
|
|
3361
|
+
if (s.terminalId === payload.terminal_id) {
|
|
3362
|
+
// RUNNING is the default; flip to AWAITING only when the
|
|
3363
|
+
// last visible line matches a prompt pattern. New output
|
|
3364
|
+
// that doesn't match restores RUNNING — agents usually
|
|
3365
|
+
// print their next chunk over the prompt line, so this
|
|
3366
|
+
// gives the right transition even for chatty agents.
|
|
3367
|
+
s.state = AWAITING_RX.test(last) ? "awaiting" : "running";
|
|
3368
|
+
break;
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
refreshOpenDialog();
|
|
3372
|
+
});
|
|
3373
|
+
|
|
3374
|
+
editor.on("terminal_exit", (payload) => {
|
|
3375
|
+
for (const s of orchestratorSessions.values()) {
|
|
3376
|
+
if (s.terminalId === payload.terminal_id) {
|
|
3377
|
+
const code = payload.exit_code;
|
|
3378
|
+
// exit_code is currently always null (the editor's
|
|
3379
|
+
// wait-status capture is a follow-up). Treat unknown as
|
|
3380
|
+
// ready — Orchestrator doesn't have a better heuristic and
|
|
3381
|
+
// mis-marking a real error as "ready" is recoverable
|
|
3382
|
+
// (the user opens the dive and sees the failure).
|
|
3383
|
+
s.state = code === null || code === 0 ? "ready" : "errored";
|
|
3384
|
+
break;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
refreshOpenDialog();
|
|
3388
|
+
});
|
|
3389
|
+
|
|
3390
|
+
// =============================================================================
|
|
3391
|
+
// Commands
|
|
3392
|
+
// =============================================================================
|
|
3393
|
+
|
|
3394
|
+
registerHandler("orchestrator_open", openControlRoom);
|
|
3395
|
+
registerHandler("orchestrator_new", startNewSession);
|
|
3396
|
+
registerHandler("orchestrator_kill", killSelected);
|
|
3397
|
+
|
|
3398
|
+
// `terminalBypass: true` keeps these commands reachable from a
|
|
3399
|
+
// keyboard-focused terminal pane — a user with `Ctrl+O` bound to
|
|
3400
|
+
// `Orchestrator: Open` shouldn't need to first hit `Ctrl+Space` to
|
|
3401
|
+
// exit terminal mode to switch sessions. The bypass routes the
|
|
3402
|
+
// key past `TerminalModeInputHandler` (which would otherwise
|
|
3403
|
+
// forward it to the PTY child) and dispatches the action
|
|
3404
|
+
// directly.
|
|
3405
|
+
editor.registerCommand(
|
|
3406
|
+
"Orchestrator: Open",
|
|
3407
|
+
"Show all editor sessions in a floating selector",
|
|
3408
|
+
"orchestrator_open",
|
|
3409
|
+
null,
|
|
3410
|
+
{ terminalBypass: true },
|
|
3411
|
+
);
|
|
3412
|
+
editor.registerCommand(
|
|
3413
|
+
"Orchestrator: New Session",
|
|
3414
|
+
"Spawn a new editor session in a worktree",
|
|
3415
|
+
"orchestrator_new",
|
|
3416
|
+
null,
|
|
3417
|
+
{ terminalBypass: true },
|
|
3418
|
+
);
|
|
3419
|
+
editor.registerCommand(
|
|
3420
|
+
"Orchestrator: Kill Selected",
|
|
3421
|
+
"Close the session highlighted in the open Orchestrator prompt",
|
|
3422
|
+
"orchestrator_kill",
|
|
3423
|
+
null,
|
|
3424
|
+
{ terminalBypass: true },
|
|
3425
|
+
);
|