@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.
- package/CHANGELOG.md +91 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +84 -0
- package/plugins/audit_mode.ts +139 -3
- package/plugins/config-schema.json +27 -3
- package/plugins/dashboard.ts +18 -18
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +534 -124
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/lib/fresh.d.ts +305 -6
- package/plugins/lib/widgets.ts +111 -4
- package/plugins/live_diff.ts +156 -41
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +1982 -242
- package/plugins/pkg.ts +1 -1
- package/plugins/schemas/theme.schema.json +14 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +674 -117
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/tsconfig.json +1 -0
- package/plugins/vi_mode.ts +11 -0
- package/themes/dark.json +1 -0
- package/themes/dracula.json +1 -0
- package/themes/high-contrast.json +1 -0
- package/themes/light.json +1 -0
- package/themes/nord.json +1 -0
- package/themes/nostalgia.json +1 -0
- package/themes/solarized-dark.json +1 -0
- package/themes/terminal.json +1 -0
package/plugins/orchestrator.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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:
|
|
279
|
-
style:
|
|
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
|
-
{
|
|
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.
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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
|
|
299
|
-
const h =
|
|
522
|
+
const screen = editor.getScreenSize();
|
|
523
|
+
const h = screen.height > 0 ? screen.height : 30;
|
|
300
524
|
const panelH = Math.floor(h * 0.9);
|
|
301
|
-
//
|
|
302
|
-
// section borders (2) +
|
|
303
|
-
//
|
|
304
|
-
|
|
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
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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:
|
|
529
|
-
selectedIndex:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1607
|
+
clearInFlight();
|
|
1013
1608
|
triggerSyncAsync(repoRoot);
|
|
1014
1609
|
}
|
|
1015
1610
|
|
|
1016
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
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
|
|
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
|
|
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
|
-
// ===
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
2133
|
+
label: "Branch",
|
|
1159
2134
|
child: text({
|
|
1160
2135
|
value: form.branch.value,
|
|
1161
2136
|
cursorByte: form.branch.cursor,
|
|
1162
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
form.
|
|
1262
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
form.
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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 =
|
|
1331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
["
|
|
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
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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", () =>
|
|
1397
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
3158
|
+
enterConfirm("stop");
|
|
1496
3159
|
return;
|
|
1497
3160
|
}
|
|
1498
3161
|
if (e.event_type === "activate" && e.widget_key === "archive") {
|
|
1499
|
-
|
|
3162
|
+
enterConfirm("archive");
|
|
1500
3163
|
return;
|
|
1501
3164
|
}
|
|
1502
3165
|
if (e.event_type === "activate" && e.widget_key === "delete") {
|
|
1503
|
-
|
|
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
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
);
|