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