@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +9 -2
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +84 -0
  5. package/plugins/audit_mode.ts +139 -3
  6. package/plugins/config-schema.json +33 -3
  7. package/plugins/dashboard.ts +34 -111
  8. package/plugins/flash.ts +22 -4
  9. package/plugins/git_blame.ts +10 -6
  10. package/plugins/git_log.ts +705 -323
  11. package/plugins/git_statusbar.i18n.json +72 -0
  12. package/plugins/git_statusbar.ts +133 -0
  13. package/plugins/goto_with_selection.i18n.json +58 -0
  14. package/plugins/goto_with_selection.ts +17 -0
  15. package/plugins/lib/fresh.d.ts +911 -15
  16. package/plugins/lib/index.ts +34 -0
  17. package/plugins/lib/widgets.ts +903 -0
  18. package/plugins/live_diff.ts +442 -32
  19. package/plugins/merge_conflict.ts +89 -64
  20. package/plugins/orchestrator.ts +3425 -0
  21. package/plugins/pkg.ts +235 -54
  22. package/plugins/rust-lsp.ts +58 -40
  23. package/plugins/schemas/theme.schema.json +18 -0
  24. package/plugins/search_replace.i18n.json +140 -28
  25. package/plugins/search_replace.ts +1335 -515
  26. package/plugins/tab_actions.i18n.json +212 -0
  27. package/plugins/tab_actions.ts +76 -0
  28. package/plugins/theme_editor.i18n.json +112 -0
  29. package/plugins/theme_editor.ts +30 -5
  30. package/plugins/tsconfig.json +3 -0
  31. package/plugins/vi_mode.ts +49 -17
  32. package/themes/dark.json +1 -0
  33. package/themes/dracula.json +1 -0
  34. package/themes/high-contrast.json +1 -0
  35. package/themes/light.json +1 -0
  36. package/themes/nord.json +1 -0
  37. package/themes/nostalgia.json +1 -0
  38. package/themes/solarized-dark.json +1 -0
  39. 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
+ );