@fresh-editor/fresh-editor 0.3.6 → 0.3.7

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