@fresh-editor/fresh-editor 0.3.6 → 0.3.8

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