@fresh-editor/fresh-editor 0.3.4 → 0.3.6
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 +72 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/config-schema.json +7 -1
- package/plugins/dashboard.ts +16 -93
- package/plugins/git_grep.ts +3 -1
- package/plugins/git_log.ts +196 -224
- package/plugins/goto_with_selection.i18n.json +58 -0
- package/plugins/goto_with_selection.ts +17 -0
- package/plugins/lib/finder.ts +27 -6
- package/plugins/lib/fresh.d.ts +620 -14
- package/plugins/lib/index.ts +34 -0
- package/plugins/lib/widgets.ts +796 -0
- package/plugins/live_diff.ts +324 -29
- package/plugins/live_grep.ts +114 -48
- package/plugins/orchestrator.ts +1685 -0
- package/plugins/pkg.ts +234 -53
- package/plugins/rust-lsp.ts +58 -40
- package/plugins/schemas/theme.schema.json +4 -0
- package/plugins/search_replace.ts +780 -517
- package/plugins/theme_editor.i18n.json +84 -0
- package/plugins/theme_editor.ts +30 -5
- package/plugins/tsconfig.json +2 -0
- package/plugins/vi_mode.ts +38 -17
- package/themes/terminal.json +3 -0
|
@@ -0,0 +1,1685 @@
|
|
|
1
|
+
/// <reference path="./lib/fresh.d.ts" />
|
|
2
|
+
//
|
|
3
|
+
// Orchestrator — multi-agent / multi-worktree session orchestration.
|
|
4
|
+
//
|
|
5
|
+
// MVP scope (`docs/internal/orchestrator-sessions-design.md`):
|
|
6
|
+
//
|
|
7
|
+
// - "Orchestrator: Open" opens a floating overlay prompt listing
|
|
8
|
+
// every session with its state column. Up/Down navigates,
|
|
9
|
+
// Enter dives into the selected session.
|
|
10
|
+
// - "Orchestrator: New Session" opens a single floating widget
|
|
11
|
+
// form with three optional fields (session name, agent
|
|
12
|
+
// command, branch), allocates a worktree-rooted session and
|
|
13
|
+
// spawns the agent in a terminal attached to it.
|
|
14
|
+
// - "Orchestrator: Kill Selected" closes the session whose row is
|
|
15
|
+
// currently highlighted in the open prompt.
|
|
16
|
+
// - Agent state column updates from terminal_output regex and
|
|
17
|
+
// terminal_exit code: RUNNING / AWAITING / READY / ERRORED.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
button,
|
|
21
|
+
col,
|
|
22
|
+
flexSpacer,
|
|
23
|
+
FloatingWidgetPanel,
|
|
24
|
+
hintBar,
|
|
25
|
+
key as widgetKey,
|
|
26
|
+
labeledSection,
|
|
27
|
+
list,
|
|
28
|
+
row,
|
|
29
|
+
spacer,
|
|
30
|
+
styledRow,
|
|
31
|
+
text,
|
|
32
|
+
textInputChar,
|
|
33
|
+
windowEmbed,
|
|
34
|
+
type WidgetSpec,
|
|
35
|
+
} from "./lib/widgets.ts";
|
|
36
|
+
|
|
37
|
+
const editor = getEditor();
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Types
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
type AgentState = "running" | "awaiting" | "ready" | "errored" | "killed";
|
|
44
|
+
|
|
45
|
+
interface AgentSession {
|
|
46
|
+
// Editor's stable session id.
|
|
47
|
+
id: number;
|
|
48
|
+
// Display label (defaults to root basename — Orchestrator never
|
|
49
|
+
// renames externally-created sessions).
|
|
50
|
+
label: string;
|
|
51
|
+
// Absolute filesystem root.
|
|
52
|
+
root: string;
|
|
53
|
+
// The terminal id Orchestrator spawned in this session, if any.
|
|
54
|
+
terminalId: number | null;
|
|
55
|
+
// Last parsed agent state. "active" is computed at render
|
|
56
|
+
// time from `editor.activeWindow()`, not stored.
|
|
57
|
+
state: AgentState;
|
|
58
|
+
// Wall-clock ms when orchestrator.new fired createWindow.
|
|
59
|
+
createdAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// Module state — editor-global, survives every dive.
|
|
64
|
+
// =============================================================================
|
|
65
|
+
|
|
66
|
+
const orchestratorSessions = new Map<number, AgentSession>();
|
|
67
|
+
|
|
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
|
+
// New-session form state. `null` ⇒ the floating form isn't
|
|
78
|
+
// open. Each field's `value` + `cursor` mirrors what the host
|
|
79
|
+
// renders inside the panel's TextInput widgets; the `submitting`
|
|
80
|
+
// flag debounces double-Enter on the Create button; `lastError`
|
|
81
|
+
// is rendered as a styled error row inside the form when the
|
|
82
|
+
// most recent submit failed (status bar would get clobbered —
|
|
83
|
+
// see MEMORY.md).
|
|
84
|
+
interface NewSessionForm {
|
|
85
|
+
name: { value: string; cursor: number };
|
|
86
|
+
cmd: { value: string; cursor: number };
|
|
87
|
+
branch: { value: string; cursor: number };
|
|
88
|
+
submitting: boolean;
|
|
89
|
+
lastError: string | null;
|
|
90
|
+
// Display-only "my_org/project_name" rendered in the
|
|
91
|
+
// dialog's subtitle. Computed once at openForm time from the
|
|
92
|
+
// current cwd so we don't subprocess on every render.
|
|
93
|
+
projectLabel: string;
|
|
94
|
+
// Resolved default branch (e.g. "origin/main"). Empty while
|
|
95
|
+
// the async `git fetch + symbolic-ref` probe is in flight;
|
|
96
|
+
// the branch input's placeholder reads this so the user sees
|
|
97
|
+
// the exact base ref the worktree will fork off if they
|
|
98
|
+
// leave the field blank.
|
|
99
|
+
defaultBranch: string;
|
|
100
|
+
}
|
|
101
|
+
let form: NewSessionForm | null = null;
|
|
102
|
+
let formPanel: FloatingWidgetPanel | null = null;
|
|
103
|
+
|
|
104
|
+
const NEW_SESSION_MODE = "orchestrator-new-form";
|
|
105
|
+
|
|
106
|
+
// Open dialog state. `null` ⇒ the picker isn't mounted. Lives
|
|
107
|
+
// alongside the new-session form state but is independent of
|
|
108
|
+
// it — the two dialogs share the orchestrator mode plumbing but
|
|
109
|
+
// not their data.
|
|
110
|
+
interface OpenDialogState {
|
|
111
|
+
// Filter input value + cursor byte. Mirrors what the host
|
|
112
|
+
// renders inside the panel's filter TextInput.
|
|
113
|
+
filter: { value: string; cursor: number };
|
|
114
|
+
// Subset of `orchestratorSessions` keys that pass the filter,
|
|
115
|
+
// in display order. Recomputed on every filter change.
|
|
116
|
+
filteredIds: number[];
|
|
117
|
+
// The selection inside the list widget. The host owns the
|
|
118
|
+
// authoritative copy as instance state; this mirror lets
|
|
119
|
+
// `buildOpenSpec` render the matching preview pane without a
|
|
120
|
+
// round-trip.
|
|
121
|
+
selectedIndex: number;
|
|
122
|
+
// Active session at the moment the dialog opened. Recorded
|
|
123
|
+
// so a future "Esc restores active" affordance has the
|
|
124
|
+
// anchor it needs.
|
|
125
|
+
originalActiveSession: number;
|
|
126
|
+
// When non-null, the preview pane swaps to a confirmation
|
|
127
|
+
// panel for the named action against the named session id.
|
|
128
|
+
// Cleared on Cancel or after the action completes.
|
|
129
|
+
pendingConfirm: { action: "delete"; sessionId: number } | null;
|
|
130
|
+
// Rows the embed reserves and rows the sessions list shows.
|
|
131
|
+
// Captured once at dialog-open from the editor's viewport so
|
|
132
|
+
// the layout stays constant across re-renders — recomputing
|
|
133
|
+
// mid-dialog would let the size jitter when the active
|
|
134
|
+
// window's viewport changes (e.g. terminal buffer's shorter
|
|
135
|
+
// height vs. a file buffer's).
|
|
136
|
+
listVisibleRows: number;
|
|
137
|
+
embedRows: number;
|
|
138
|
+
}
|
|
139
|
+
let openDialog: OpenDialogState | null = null;
|
|
140
|
+
let openPanel: FloatingWidgetPanel | null = null;
|
|
141
|
+
const OPEN_MODE = "orchestrator-open";
|
|
142
|
+
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Session-list reconciliation
|
|
145
|
+
// =============================================================================
|
|
146
|
+
|
|
147
|
+
function reconcileSessions(): void {
|
|
148
|
+
const editorSessions = editor.listWindows();
|
|
149
|
+
const seen = new Set<number>();
|
|
150
|
+
for (const s of editorSessions) {
|
|
151
|
+
seen.add(s.id);
|
|
152
|
+
const existing = orchestratorSessions.get(s.id);
|
|
153
|
+
if (!existing) {
|
|
154
|
+
orchestratorSessions.set(s.id, {
|
|
155
|
+
id: s.id,
|
|
156
|
+
label: s.label,
|
|
157
|
+
root: s.root,
|
|
158
|
+
terminalId: null,
|
|
159
|
+
// The base session has no agent; everything else
|
|
160
|
+
// defaults to "running" until a terminal_output /
|
|
161
|
+
// terminal_exit arrives.
|
|
162
|
+
state: "running",
|
|
163
|
+
createdAt: Date.now(),
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
existing.label = s.label;
|
|
167
|
+
existing.root = s.root;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const id of orchestratorSessions.keys()) {
|
|
171
|
+
if (!seen.has(id)) orchestratorSessions.delete(id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// Session display helpers
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
const STATE_GLYPH: Record<AgentState, string> = {
|
|
180
|
+
running: "RUN ",
|
|
181
|
+
awaiting: "WAIT",
|
|
182
|
+
ready: "DONE",
|
|
183
|
+
errored: "ERR ",
|
|
184
|
+
killed: "KILL",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function ageString(createdAt: number): string {
|
|
188
|
+
const sec = Math.max(0, Math.floor((Date.now() - createdAt) / 1000));
|
|
189
|
+
if (sec < 60) return `${sec}s`;
|
|
190
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
191
|
+
return `${Math.floor(sec / 3600)}h`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// Open dialog — widget-based session picker (Phase 1 of the
|
|
196
|
+
// open-dialog redesign; see docs/internal/
|
|
197
|
+
// orchestrator-open-dialog-and-lifecycle.md).
|
|
198
|
+
//
|
|
199
|
+
// Dive is the only action the dialog wires up directly. Other
|
|
200
|
+
// lifecycle commands (Stop / Archive / Delete / New) ship in
|
|
201
|
+
// later phases. New session is still reachable through the
|
|
202
|
+
// "Orchestrator: New Session" palette command in the meantime.
|
|
203
|
+
// =============================================================================
|
|
204
|
+
|
|
205
|
+
// Case-insensitive substring match over a session's label and
|
|
206
|
+
// root path. Ordering: prefix-of-label hits beat substring hits,
|
|
207
|
+
// then ties broken by label length so shorter matches surface
|
|
208
|
+
// first. Empty needle returns the full list in numeric-id order.
|
|
209
|
+
function filterSessions(needle: string): number[] {
|
|
210
|
+
reconcileSessions();
|
|
211
|
+
const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
|
|
212
|
+
if (!needle) return ids;
|
|
213
|
+
const n = needle.toLowerCase();
|
|
214
|
+
type Scored = { id: number; score: number; len: number };
|
|
215
|
+
const matches: Scored[] = [];
|
|
216
|
+
for (const id of ids) {
|
|
217
|
+
const s = orchestratorSessions.get(id)!;
|
|
218
|
+
const label = s.label.toLowerCase();
|
|
219
|
+
const root = s.root.toLowerCase();
|
|
220
|
+
if (label.startsWith(n)) {
|
|
221
|
+
matches.push({ id, score: 0, len: label.length });
|
|
222
|
+
} else if (label.includes(n)) {
|
|
223
|
+
matches.push({ id, score: 1, len: label.length });
|
|
224
|
+
} else if (root.includes(n)) {
|
|
225
|
+
matches.push({ id, score: 2, len: label.length });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
matches.sort((a, b) => a.score - b.score || a.len - b.len || a.id - b.id);
|
|
229
|
+
return matches.map((m) => m.id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build one rendered list-item row for `id`. Style cues:
|
|
233
|
+
// * `[id]` in `ui.help_key_fg`
|
|
234
|
+
// * `ACT` (active session) in `ui.tab_active_fg` + bold
|
|
235
|
+
// * other states use the default fg
|
|
236
|
+
// * label in default fg
|
|
237
|
+
function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
238
|
+
const s = orchestratorSessions.get(id);
|
|
239
|
+
if (!s) {
|
|
240
|
+
return styledRow([{ text: `[${id}] (unknown)` }]);
|
|
241
|
+
}
|
|
242
|
+
const isActive = id === activeId;
|
|
243
|
+
const stateText = isActive ? "ACT " : STATE_GLYPH[s.state];
|
|
244
|
+
return styledRow([
|
|
245
|
+
{ text: `[${id}] `, style: { fg: "ui.help_key_fg" } },
|
|
246
|
+
{
|
|
247
|
+
text: stateText,
|
|
248
|
+
style: isActive
|
|
249
|
+
? { fg: "ui.tab_active_fg", bold: true }
|
|
250
|
+
: { fg: "ui.menu_disabled_fg" },
|
|
251
|
+
},
|
|
252
|
+
{ text: ` ${s.label}` },
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Preview-pane content for the currently selected session.
|
|
257
|
+
// Plain info for Phase 1; later phases append pgid/pids + the
|
|
258
|
+
// last terminal lines.
|
|
259
|
+
function buildPreviewEntries(
|
|
260
|
+
s: AgentSession | undefined,
|
|
261
|
+
): TextPropertyEntry[] {
|
|
262
|
+
if (!s) {
|
|
263
|
+
return [
|
|
264
|
+
styledRow([
|
|
265
|
+
{
|
|
266
|
+
text: "No session selected",
|
|
267
|
+
style: { fg: "editor.whitespace_indicator_fg", italic: true },
|
|
268
|
+
},
|
|
269
|
+
]),
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
const activeId = editor.activeWindow();
|
|
273
|
+
const isActive = s.id === activeId;
|
|
274
|
+
const stateText = isActive ? "ACT" : STATE_GLYPH[s.state].trim();
|
|
275
|
+
return [
|
|
276
|
+
styledRow([
|
|
277
|
+
{
|
|
278
|
+
text: stateText,
|
|
279
|
+
style: isActive
|
|
280
|
+
? { fg: "ui.tab_active_fg", bold: true }
|
|
281
|
+
: { fg: "ui.menu_disabled_fg" },
|
|
282
|
+
},
|
|
283
|
+
{ text: " " },
|
|
284
|
+
{ text: ageString(s.createdAt), style: { fg: "ui.menu_disabled_fg" } },
|
|
285
|
+
]),
|
|
286
|
+
styledRow([
|
|
287
|
+
{ text: s.root, style: { fg: "ui.menu_disabled_fg" } },
|
|
288
|
+
]),
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Approximate number of session rows the picker's list pane
|
|
293
|
+
// should show. Derived from the active buffer's viewport so the
|
|
294
|
+
// picker's row(list, preview) fills the panel and the hint bar
|
|
295
|
+
// sits flush at the panel's last row. Conservative — leaves
|
|
296
|
+
// room for header, filter input, footer, and section borders.
|
|
297
|
+
function openListVisibleRows(): number {
|
|
298
|
+
const vp = editor.getViewport();
|
|
299
|
+
const h = vp ? vp.height : 30;
|
|
300
|
+
const panelH = Math.floor(h * 0.9);
|
|
301
|
+
// header (1) + spacer (1) + filter section (3) + sessions
|
|
302
|
+
// section borders (2) + hint bar (1) = 8 rows of chrome.
|
|
303
|
+
// Floor at 4 so a tiny terminal still shows something.
|
|
304
|
+
return Math.max(4, panelH - 8);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Compose the right-hand preview pane. Normally it shows info
|
|
308
|
+
// + action buttons (Stop, Archive, Delete); when a destructive
|
|
309
|
+
// action is pending confirmation it swaps to a "Confirm
|
|
310
|
+
// <action>?" panel with [ Confirm <action> ] / [ Cancel ]
|
|
311
|
+
// buttons. Cancel is default-focused for safety.
|
|
312
|
+
function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
313
|
+
if (openDialog?.pendingConfirm && s && openDialog.pendingConfirm.sessionId === s.id) {
|
|
314
|
+
const action = openDialog.pendingConfirm.action;
|
|
315
|
+
if (action === "delete") {
|
|
316
|
+
return labeledSection({
|
|
317
|
+
label: "Confirm Delete",
|
|
318
|
+
child: col(
|
|
319
|
+
{
|
|
320
|
+
kind: "raw",
|
|
321
|
+
entries: [
|
|
322
|
+
styledRow([
|
|
323
|
+
{
|
|
324
|
+
text: `Delete session [${s.id}] ${s.label}?`,
|
|
325
|
+
style: { bold: true },
|
|
326
|
+
},
|
|
327
|
+
]),
|
|
328
|
+
styledRow([{ text: "" }]),
|
|
329
|
+
styledRow([{ text: "This will:" }]),
|
|
330
|
+
styledRow([{ text: " • stop all session processes" }]),
|
|
331
|
+
styledRow([{ text: " • run `git worktree remove`" }]),
|
|
332
|
+
styledRow([{ text: " • drop the session record" }]),
|
|
333
|
+
styledRow([{ text: "" }]),
|
|
334
|
+
styledRow([
|
|
335
|
+
{
|
|
336
|
+
text: "Uncommitted changes will be lost.",
|
|
337
|
+
style: {
|
|
338
|
+
fg: "ui.status_error_indicator_fg",
|
|
339
|
+
bold: true,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
]),
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
spacer(0),
|
|
346
|
+
row(
|
|
347
|
+
flexSpacer(),
|
|
348
|
+
button("Cancel", { key: "confirm-cancel" }),
|
|
349
|
+
spacer(2),
|
|
350
|
+
button("Confirm Delete", {
|
|
351
|
+
intent: "danger",
|
|
352
|
+
key: "confirm-delete",
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// The embed reserves the bulk of the preview pane so the host
|
|
360
|
+
// can paint the selected window's live UI (splits, terminals,
|
|
361
|
+
// syntax highlighting) underneath the action button row. The
|
|
362
|
+
// row count is captured at dialog-open into `openDialog` and
|
|
363
|
+
// used verbatim across re-renders, so the preview pane stays
|
|
364
|
+
// a constant height regardless of which window's viewport the
|
|
365
|
+
// host happens to be reporting through `getViewport()`.
|
|
366
|
+
const embedRows = openDialog?.embedRows ?? 4;
|
|
367
|
+
return labeledSection({
|
|
368
|
+
label: s ? `[${s.id}] ${s.label}` : "Preview",
|
|
369
|
+
child: col(
|
|
370
|
+
row(
|
|
371
|
+
flexSpacer(),
|
|
372
|
+
button("Stop", { key: "stop" }),
|
|
373
|
+
spacer(2),
|
|
374
|
+
button("Archive", { key: "archive" }),
|
|
375
|
+
spacer(2),
|
|
376
|
+
button("Delete", { intent: "danger", key: "delete" }),
|
|
377
|
+
),
|
|
378
|
+
spacer(0),
|
|
379
|
+
{ kind: "raw", entries: buildPreviewEntries(s) },
|
|
380
|
+
spacer(0),
|
|
381
|
+
// Live window render. `windowId: 0` (no session selected)
|
|
382
|
+
// is a no-op on the host side — the embed reserves the
|
|
383
|
+
// rows but no paint happens.
|
|
384
|
+
windowEmbed({
|
|
385
|
+
windowId: s ? s.id : 0,
|
|
386
|
+
rows: embedRows,
|
|
387
|
+
key: "live-preview",
|
|
388
|
+
}),
|
|
389
|
+
),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildOpenSpec(): WidgetSpec {
|
|
394
|
+
if (!openDialog) return col();
|
|
395
|
+
const filtered = openDialog.filteredIds;
|
|
396
|
+
const activeId = editor.activeWindow();
|
|
397
|
+
const items = filtered.map((id) => renderListItem(id, activeId));
|
|
398
|
+
const itemKeys = filtered.map(String);
|
|
399
|
+
const selIdx = filtered.length === 0
|
|
400
|
+
? -1
|
|
401
|
+
: Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
|
|
402
|
+
const selectedId = selIdx >= 0 ? filtered[selIdx] : -1;
|
|
403
|
+
const selectedSession = selectedId > 0
|
|
404
|
+
? orchestratorSessions.get(selectedId)
|
|
405
|
+
: undefined;
|
|
406
|
+
|
|
407
|
+
return col(
|
|
408
|
+
{
|
|
409
|
+
kind: "raw",
|
|
410
|
+
entries: [
|
|
411
|
+
styledRow([
|
|
412
|
+
{
|
|
413
|
+
text: "ORCHESTRATOR :: Sessions",
|
|
414
|
+
style: { fg: "ui.popup_border_fg", bold: true },
|
|
415
|
+
},
|
|
416
|
+
]),
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
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
|
+
// Two-pane: sessions list | preview. Renderer's `row()`
|
|
431
|
+
// horizontally zips multi-line children so this composes
|
|
432
|
+
// the wireframed shape directly. Width split 25 / 75 —
|
|
433
|
+
// the preview pane carries the action buttons and the
|
|
434
|
+
// (Phase 7) live-window render, so it earns the bulk of
|
|
435
|
+
// the dialog.
|
|
436
|
+
row(
|
|
437
|
+
labeledSection({
|
|
438
|
+
label: `Sessions (${filtered.length})`,
|
|
439
|
+
widthPct: 25,
|
|
440
|
+
child: list({
|
|
441
|
+
items,
|
|
442
|
+
itemKeys,
|
|
443
|
+
selectedIndex: selIdx,
|
|
444
|
+
visibleRows: openDialog.listVisibleRows,
|
|
445
|
+
// Excluded from the Tab cycle — Up/Down on the
|
|
446
|
+
// filter input forwards to this list via host
|
|
447
|
+
// smart-keys, so Tab jumps straight to the action
|
|
448
|
+
// buttons instead of stopping here.
|
|
449
|
+
focusable: false,
|
|
450
|
+
key: "sessions",
|
|
451
|
+
}),
|
|
452
|
+
}),
|
|
453
|
+
// Preview pane has no explicit width — picks up the
|
|
454
|
+
// remaining 75% by default since the sessions list took
|
|
455
|
+
// 25%.
|
|
456
|
+
buildPreviewPane(selectedSession),
|
|
457
|
+
),
|
|
458
|
+
row(
|
|
459
|
+
flexSpacer(),
|
|
460
|
+
hintBar([
|
|
461
|
+
{ keys: "↑↓", label: "nav" },
|
|
462
|
+
{ keys: "Enter", label: "dive" },
|
|
463
|
+
{ keys: "Tab", label: "focus" },
|
|
464
|
+
{ keys: "Esc", label: "close" },
|
|
465
|
+
]),
|
|
466
|
+
flexSpacer(),
|
|
467
|
+
syncIndicator(),
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Tiny status glyph rendered at the trailing edge of the
|
|
473
|
+
// footer. `↻` while a push is in flight, `⤒` when the last
|
|
474
|
+
// push failed (with the error in the tooltip — for now, just a
|
|
475
|
+
// status-bar setStatus on focus), and an empty entry otherwise
|
|
476
|
+
// so the layout stays put.
|
|
477
|
+
function syncIndicator(): WidgetSpec {
|
|
478
|
+
let glyph = "";
|
|
479
|
+
let style: { fg?: string; italic?: boolean } | undefined;
|
|
480
|
+
switch (syncStatus) {
|
|
481
|
+
case "syncing":
|
|
482
|
+
glyph = " ↻ ";
|
|
483
|
+
style = { fg: "editor.whitespace_indicator_fg" };
|
|
484
|
+
break;
|
|
485
|
+
case "error":
|
|
486
|
+
glyph = " ⤒ ";
|
|
487
|
+
style = { fg: "ui.status_error_indicator_fg" };
|
|
488
|
+
break;
|
|
489
|
+
default:
|
|
490
|
+
glyph = " ";
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
kind: "raw",
|
|
494
|
+
entries: [styledRow([{ text: glyph, style }])],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function refreshOpenDialog(): void {
|
|
499
|
+
if (!openPanel || !openDialog) return;
|
|
500
|
+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
501
|
+
// Clamp the selection into range so a fresh filter or a
|
|
502
|
+
// session vanishing under us doesn't leave us pointing past
|
|
503
|
+
// the end of the list.
|
|
504
|
+
if (openDialog.filteredIds.length === 0) {
|
|
505
|
+
openDialog.selectedIndex = 0;
|
|
506
|
+
} else if (openDialog.selectedIndex >= openDialog.filteredIds.length) {
|
|
507
|
+
openDialog.selectedIndex = openDialog.filteredIds.length - 1;
|
|
508
|
+
} else if (openDialog.selectedIndex < 0) {
|
|
509
|
+
openDialog.selectedIndex = 0;
|
|
510
|
+
}
|
|
511
|
+
openPanel.update(buildOpenSpec());
|
|
512
|
+
// The list widget's `selectedIndex` in the spec is initial-only;
|
|
513
|
+
// pin it via mutation so re-renders don't snap back to 0.
|
|
514
|
+
if (openDialog.filteredIds.length > 0) {
|
|
515
|
+
openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function openControlRoom(): void {
|
|
520
|
+
if (openPanel) return;
|
|
521
|
+
reconcileSessions();
|
|
522
|
+
const activeId = editor.activeWindow();
|
|
523
|
+
const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
|
|
524
|
+
const activeIdx = ids.indexOf(activeId);
|
|
525
|
+
const listVisibleRows = openListVisibleRows();
|
|
526
|
+
openDialog = {
|
|
527
|
+
filter: { value: "", cursor: 0 },
|
|
528
|
+
filteredIds: ids,
|
|
529
|
+
selectedIndex: activeIdx >= 0 ? activeIdx : 0,
|
|
530
|
+
originalActiveSession: activeId,
|
|
531
|
+
pendingConfirm: null,
|
|
532
|
+
listVisibleRows,
|
|
533
|
+
// Mirror buildPreviewPane's chrome: 1 button row + 1 spacer
|
|
534
|
+
// + 2 info rows + 1 spacer = 4 rows reserved above the embed.
|
|
535
|
+
embedRows: Math.max(4, listVisibleRows - 4),
|
|
536
|
+
};
|
|
537
|
+
openPanel = new FloatingWidgetPanel();
|
|
538
|
+
// 90% × 90% of the terminal — the open dialog wants room for
|
|
539
|
+
// a real session list + preview pane, unlike the new-session
|
|
540
|
+
// form which stays compact.
|
|
541
|
+
openPanel.mount(buildOpenSpec(), { widthPct: 90, heightPct: 90 });
|
|
542
|
+
if (openDialog.filteredIds.length > 0) {
|
|
543
|
+
openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
|
|
544
|
+
}
|
|
545
|
+
editor.setEditorMode(OPEN_MODE);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function closeOpenDialog(): void {
|
|
549
|
+
if (openPanel) {
|
|
550
|
+
openPanel.unmount();
|
|
551
|
+
openPanel = null;
|
|
552
|
+
}
|
|
553
|
+
openDialog = null;
|
|
554
|
+
editor.setEditorMode(null);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Stop every process the highlighted session owns. Sends
|
|
558
|
+
// SIGTERM first via the host's `signalWindow` (which fans
|
|
559
|
+
// out through the window's process-group tracker), then
|
|
560
|
+
// follows up with SIGKILL after a short grace period so
|
|
561
|
+
// ill-behaved agents that ignore SIGTERM still get reaped.
|
|
562
|
+
// The session record stays put — Stop only kills processes,
|
|
563
|
+
// it doesn't touch the worktree or the editor session.
|
|
564
|
+
function stopSelectedSession(): void {
|
|
565
|
+
if (!openDialog) return;
|
|
566
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
567
|
+
if (typeof id !== "number" || id <= 0) return;
|
|
568
|
+
if (id === 1) {
|
|
569
|
+
editor.setStatus("Orchestrator: cannot stop the base session");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
editor.signalWindow(id, "SIGTERM");
|
|
573
|
+
// SIGKILL fallback for agents that ignore SIGTERM. The
|
|
574
|
+
// host's signalWindow is idempotent on already-exited
|
|
575
|
+
// process groups, so the second call is safe whether or
|
|
576
|
+
// not the first one took.
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
editor.signalWindow(id, "SIGKILL");
|
|
579
|
+
}, 2000);
|
|
580
|
+
editor.setStatus(`Orchestrator: stop signal sent to session [${id}]`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------
|
|
584
|
+
// Archive manifest — `<XDG>/orchestrator/<repo-slug>/archived.json`.
|
|
585
|
+
// Records sessions that have been archived (stopped + worktree moved
|
|
586
|
+
// to `.archived/`). Used today by the Archive action; Unarchive and
|
|
587
|
+
// "Show archived" surface in a follow-up phase.
|
|
588
|
+
// ---------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
interface ArchivedSession {
|
|
591
|
+
label: string;
|
|
592
|
+
/** Current path of the moved worktree, under `.archived/`. */
|
|
593
|
+
root: string;
|
|
594
|
+
/** Path the worktree lived at before archiving. */
|
|
595
|
+
original_root: string;
|
|
596
|
+
/** Branch the worktree was on. */
|
|
597
|
+
branch: string;
|
|
598
|
+
/** ISO 8601 timestamp of when the session was archived. */
|
|
599
|
+
archived_at: string;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
interface ArchiveManifest {
|
|
603
|
+
version: number;
|
|
604
|
+
sessions: ArchivedSession[];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function archiveManifestPath(repoRoot: string): string {
|
|
608
|
+
return editor.pathJoin(
|
|
609
|
+
editor.getDataDir(),
|
|
610
|
+
"orchestrator",
|
|
611
|
+
slugify(repoRoot),
|
|
612
|
+
"archived.json",
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function loadArchiveManifest(repoRoot: string): ArchiveManifest {
|
|
617
|
+
const path = archiveManifestPath(repoRoot);
|
|
618
|
+
const raw = editor.readFile(path);
|
|
619
|
+
if (!raw) return { version: 1, sessions: [] };
|
|
620
|
+
try {
|
|
621
|
+
const parsed = JSON.parse(raw);
|
|
622
|
+
if (
|
|
623
|
+
parsed && typeof parsed === "object" &&
|
|
624
|
+
Array.isArray(parsed.sessions)
|
|
625
|
+
) {
|
|
626
|
+
return parsed as ArchiveManifest;
|
|
627
|
+
}
|
|
628
|
+
} catch (_) {
|
|
629
|
+
// Fall through to fresh manifest — bad data shouldn't
|
|
630
|
+
// brick the dialog.
|
|
631
|
+
}
|
|
632
|
+
return { version: 1, sessions: [] };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function saveArchiveManifest(repoRoot: string, m: ArchiveManifest): boolean {
|
|
636
|
+
const path = archiveManifestPath(repoRoot);
|
|
637
|
+
const dir = editor.pathDirname(path);
|
|
638
|
+
if (!editor.createDir(dir)) return false;
|
|
639
|
+
return editor.writeFile(path, JSON.stringify(m, null, 2));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Archive flow: stop all processes (SIGKILL — archive is a
|
|
643
|
+
// "I'm done with this for now" action, no graceful teardown
|
|
644
|
+
// needed since the worktree stays on disk), close the editor
|
|
645
|
+
// session, move the worktree to the `.archived/` graveyard,
|
|
646
|
+
// and append a manifest entry so a future Unarchive flow can
|
|
647
|
+
// reverse it.
|
|
648
|
+
async function archiveSelectedSession(): Promise<void> {
|
|
649
|
+
if (!openDialog) return;
|
|
650
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
651
|
+
if (typeof id !== "number" || id <= 0) return;
|
|
652
|
+
if (id === 1) {
|
|
653
|
+
editor.setStatus("Orchestrator: cannot archive the base session");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (id === editor.activeWindow()) {
|
|
657
|
+
editor.setStatus(
|
|
658
|
+
"Orchestrator: dive elsewhere first, then archive this session",
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const session = orchestratorSessions.get(id);
|
|
663
|
+
if (!session) return;
|
|
664
|
+
|
|
665
|
+
// Resolve the repo root from cwd (the user is in the
|
|
666
|
+
// umbrella session's tree).
|
|
667
|
+
const cwd = editor.getCwd();
|
|
668
|
+
const top = await spawnCollect(
|
|
669
|
+
"git",
|
|
670
|
+
["rev-parse", "--show-toplevel"],
|
|
671
|
+
cwd,
|
|
672
|
+
);
|
|
673
|
+
if (top.exit_code !== 0) {
|
|
674
|
+
editor.setStatus("Orchestrator: archive failed — not a git repository");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const repoRoot = (top.stdout || "").trim();
|
|
678
|
+
|
|
679
|
+
// SIGKILL the session's process group so the pty children
|
|
680
|
+
// release any locks on the worktree, then close the editor
|
|
681
|
+
// session. closeWindow already kills the pty via the child
|
|
682
|
+
// killer; signaling first via the window-level pg tracker
|
|
683
|
+
// catches stray subprocesses outside the pty.
|
|
684
|
+
editor.signalWindow(id, "SIGKILL");
|
|
685
|
+
editor.closeWindow(id);
|
|
686
|
+
|
|
687
|
+
// Brief settle so the filesystem reflects the pty's exit
|
|
688
|
+
// before we move the worktree out from under it.
|
|
689
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
690
|
+
|
|
691
|
+
// git worktree move keeps git's internal bookkeeping
|
|
692
|
+
// consistent (the new path stays registered as a worktree).
|
|
693
|
+
const archivedRoot = editor.pathJoin(
|
|
694
|
+
editor.getDataDir(),
|
|
695
|
+
"orchestrator",
|
|
696
|
+
slugify(repoRoot),
|
|
697
|
+
".archived",
|
|
698
|
+
session.label,
|
|
699
|
+
);
|
|
700
|
+
const parent = editor.pathDirname(archivedRoot);
|
|
701
|
+
if (!editor.createDir(parent)) {
|
|
702
|
+
editor.setStatus(
|
|
703
|
+
`Orchestrator: archive failed — could not create ${parent}`,
|
|
704
|
+
);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const moveRes = await spawnCollect(
|
|
708
|
+
"git",
|
|
709
|
+
["-C", repoRoot, "worktree", "move", session.root, archivedRoot],
|
|
710
|
+
repoRoot,
|
|
711
|
+
);
|
|
712
|
+
if (moveRes.exit_code !== 0) {
|
|
713
|
+
editor.setStatus(
|
|
714
|
+
`Orchestrator: worktree move failed: ${
|
|
715
|
+
lastNonEmptyLine(moveRes.stderr) || "unknown error"
|
|
716
|
+
}`,
|
|
717
|
+
);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Append manifest entry. The branch info is best-effort:
|
|
722
|
+
// we assume Orchestrator's convention of branch==label (set in
|
|
723
|
+
// the new-session form) until a session knows its branch
|
|
724
|
+
// separately.
|
|
725
|
+
const manifest = loadArchiveManifest(repoRoot);
|
|
726
|
+
manifest.sessions.push({
|
|
727
|
+
label: session.label,
|
|
728
|
+
root: archivedRoot,
|
|
729
|
+
original_root: session.root,
|
|
730
|
+
branch: session.label,
|
|
731
|
+
archived_at: new Date().toISOString(),
|
|
732
|
+
});
|
|
733
|
+
if (!saveArchiveManifest(repoRoot, manifest)) {
|
|
734
|
+
editor.setStatus(
|
|
735
|
+
"Orchestrator: archived, but failed to write archived.json",
|
|
736
|
+
);
|
|
737
|
+
} else {
|
|
738
|
+
editor.setStatus(`Orchestrator: archived [${id}] ${session.label}`);
|
|
739
|
+
}
|
|
740
|
+
triggerSyncAsync(repoRoot);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------
|
|
744
|
+
// Cross-machine recovery (Phase 6)
|
|
745
|
+
//
|
|
746
|
+
// Every lifecycle action that mutates the local archive manifest also
|
|
747
|
+
// fires an asynchronous push to `refs/heads/<user>/fresh-sessions` on
|
|
748
|
+
// origin so the same sessions can be recovered on another machine.
|
|
749
|
+
// The push runs in the background and never blocks the user-visible
|
|
750
|
+
// action; failures get surfaced through `syncStatus` (and a small ⤒
|
|
751
|
+
// glyph in the dialog footer when the error is fresh).
|
|
752
|
+
//
|
|
753
|
+
// The branch is orphan-style: a single root file `sessions.json` and
|
|
754
|
+
// commits with the sessions snapshot. We maintain it through a
|
|
755
|
+
// dedicated worktree at `<XDG>/orchestrator/.sync-workspace` so we don't
|
|
756
|
+
// disturb the user's normal `git worktree` set.
|
|
757
|
+
// ---------------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
type SyncStatus = "idle" | "syncing" | "error";
|
|
760
|
+
let syncStatus: SyncStatus = "idle";
|
|
761
|
+
let syncError: string | null = null;
|
|
762
|
+
|
|
763
|
+
function deriveSyncUser(): string {
|
|
764
|
+
// Priority order documented in
|
|
765
|
+
// docs/internal/orchestrator-open-dialog-and-lifecycle.md.
|
|
766
|
+
const envOverride = editor.getEnv("FRESH_SESSIONS_USER");
|
|
767
|
+
if (envOverride && envOverride.trim()) return envOverride.trim();
|
|
768
|
+
const localPart = (envEmailLocalPart() || "").trim();
|
|
769
|
+
if (localPart) return localPart;
|
|
770
|
+
const u = editor.getEnv("USER");
|
|
771
|
+
if (u && u.trim()) return u.trim();
|
|
772
|
+
return "fresh";
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function envEmailLocalPart(): string | null {
|
|
776
|
+
// Best-effort sync read of git config user.email's local-part.
|
|
777
|
+
// Reading from env first (since spawnProcess is async) keeps
|
|
778
|
+
// deriveSyncUser synchronous; users with no env override will
|
|
779
|
+
// probably have `$USER` available as fallback.
|
|
780
|
+
const email = editor.getEnv("GIT_AUTHOR_EMAIL") ||
|
|
781
|
+
editor.getEnv("EMAIL");
|
|
782
|
+
if (!email) return null;
|
|
783
|
+
const at = email.indexOf("@");
|
|
784
|
+
return at > 0 ? email.slice(0, at) : null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function syncWorkspacePath(): string {
|
|
788
|
+
return editor.pathJoin(editor.getDataDir(), "orchestrator", ".sync-workspace");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Fire-and-forget sync. Never blocks the caller; updates
|
|
792
|
+
// `syncStatus`/`syncError` and refreshes the dialog (if open)
|
|
793
|
+
// so the footer indicator can reflect the result.
|
|
794
|
+
function triggerSyncAsync(repoRoot: string): void {
|
|
795
|
+
void (async () => {
|
|
796
|
+
syncStatus = "syncing";
|
|
797
|
+
if (openPanel) refreshOpenDialog();
|
|
798
|
+
const result = await syncSessions(repoRoot);
|
|
799
|
+
if (result.ok) {
|
|
800
|
+
syncStatus = "idle";
|
|
801
|
+
syncError = null;
|
|
802
|
+
} else {
|
|
803
|
+
syncStatus = "error";
|
|
804
|
+
syncError = result.err ?? "unknown error";
|
|
805
|
+
}
|
|
806
|
+
if (openPanel) refreshOpenDialog();
|
|
807
|
+
})();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
interface SyncResult {
|
|
811
|
+
ok: boolean;
|
|
812
|
+
err?: string;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function syncSessions(repoRoot: string): Promise<SyncResult> {
|
|
816
|
+
const user = deriveSyncUser();
|
|
817
|
+
const branch = `${user}/fresh-sessions`;
|
|
818
|
+
const wt = syncWorkspacePath();
|
|
819
|
+
|
|
820
|
+
// Ensure the sync worktree exists and is on the right branch.
|
|
821
|
+
// First-time setup creates the worktree as an orphan branch
|
|
822
|
+
// with no parent commit (cleanest history; no leftover files
|
|
823
|
+
// from the original tree).
|
|
824
|
+
if (!editor.createDir(editor.pathDirname(wt))) {
|
|
825
|
+
return { ok: false, err: "createDir failed for sync workspace parent" };
|
|
826
|
+
}
|
|
827
|
+
const branchExists = await spawnCollect(
|
|
828
|
+
"git",
|
|
829
|
+
["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
830
|
+
repoRoot,
|
|
831
|
+
);
|
|
832
|
+
const wtExists = await spawnCollect(
|
|
833
|
+
"git",
|
|
834
|
+
["-C", repoRoot, "worktree", "list", "--porcelain"],
|
|
835
|
+
repoRoot,
|
|
836
|
+
);
|
|
837
|
+
const wtAlreadyTracked = wtExists.exit_code === 0 &&
|
|
838
|
+
wtExists.stdout.includes(wt);
|
|
839
|
+
|
|
840
|
+
if (!wtAlreadyTracked) {
|
|
841
|
+
if (branchExists.exit_code === 0) {
|
|
842
|
+
const addRes = await spawnCollect(
|
|
843
|
+
"git",
|
|
844
|
+
["-C", repoRoot, "worktree", "add", wt, branch],
|
|
845
|
+
repoRoot,
|
|
846
|
+
);
|
|
847
|
+
if (addRes.exit_code !== 0) {
|
|
848
|
+
return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
// Create an orphan worktree by adding detached then
|
|
852
|
+
// switching to a new orphan branch.
|
|
853
|
+
const addRes = await spawnCollect(
|
|
854
|
+
"git",
|
|
855
|
+
["-C", repoRoot, "worktree", "add", "--detach", wt, "HEAD"],
|
|
856
|
+
repoRoot,
|
|
857
|
+
);
|
|
858
|
+
if (addRes.exit_code !== 0) {
|
|
859
|
+
return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
|
|
860
|
+
}
|
|
861
|
+
const orphanRes = await spawnCollect(
|
|
862
|
+
"git",
|
|
863
|
+
["-C", wt, "checkout", "--orphan", branch],
|
|
864
|
+
wt,
|
|
865
|
+
);
|
|
866
|
+
if (orphanRes.exit_code !== 0) {
|
|
867
|
+
return { ok: false, err: lastNonEmptyLine(orphanRes.stderr) };
|
|
868
|
+
}
|
|
869
|
+
// Strip everything inherited from HEAD's tree so the
|
|
870
|
+
// orphan branch starts clean.
|
|
871
|
+
await spawnCollect("git", ["-C", wt, "rm", "-rf", "."], wt);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Snapshot active + archived sessions into the JSON that
|
|
876
|
+
// lives at the root of the sync branch.
|
|
877
|
+
const snapshot = await buildSyncSnapshot(repoRoot);
|
|
878
|
+
const sessionsPath = editor.pathJoin(wt, "sessions.json");
|
|
879
|
+
if (!editor.writeFile(sessionsPath, JSON.stringify(snapshot, null, 2))) {
|
|
880
|
+
return { ok: false, err: "writeFile sessions.json failed" };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const addRes = await spawnCollect(
|
|
884
|
+
"git",
|
|
885
|
+
["-C", wt, "add", "sessions.json"],
|
|
886
|
+
wt,
|
|
887
|
+
);
|
|
888
|
+
if (addRes.exit_code !== 0) {
|
|
889
|
+
return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
|
|
890
|
+
}
|
|
891
|
+
// The commit may noop when nothing changed — git exits with
|
|
892
|
+
// 1 in that case, which we treat as success rather than an
|
|
893
|
+
// error.
|
|
894
|
+
const commitRes = await spawnCollect(
|
|
895
|
+
"git",
|
|
896
|
+
[
|
|
897
|
+
"-C",
|
|
898
|
+
wt,
|
|
899
|
+
"commit",
|
|
900
|
+
"--allow-empty-message",
|
|
901
|
+
"-m",
|
|
902
|
+
"Update sessions",
|
|
903
|
+
],
|
|
904
|
+
wt,
|
|
905
|
+
);
|
|
906
|
+
if (commitRes.exit_code !== 0 && !commitRes.stdout.includes("nothing to commit")) {
|
|
907
|
+
// Permissive: stderr "nothing to commit" / "working tree clean"
|
|
908
|
+
// means there was nothing new to push. Skip the push and
|
|
909
|
+
// report success.
|
|
910
|
+
if (!commitRes.stderr.includes("nothing to commit")) {
|
|
911
|
+
// Other commit failures: report.
|
|
912
|
+
return { ok: false, err: lastNonEmptyLine(commitRes.stderr) };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const pushRes = await spawnCollect(
|
|
917
|
+
"git",
|
|
918
|
+
["-C", wt, "push", "origin", branch],
|
|
919
|
+
wt,
|
|
920
|
+
);
|
|
921
|
+
if (pushRes.exit_code !== 0) {
|
|
922
|
+
return { ok: false, err: lastNonEmptyLine(pushRes.stderr) };
|
|
923
|
+
}
|
|
924
|
+
return { ok: true };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
|
|
928
|
+
const manifest = loadArchiveManifest(repoRoot);
|
|
929
|
+
return {
|
|
930
|
+
version: 1,
|
|
931
|
+
machine_id: editor.getEnv("HOSTNAME") || "unknown",
|
|
932
|
+
updated_at: new Date().toISOString(),
|
|
933
|
+
active: Array.from(orchestratorSessions.values()).map((s) => ({
|
|
934
|
+
label: s.label,
|
|
935
|
+
branch: s.label,
|
|
936
|
+
base_ref: "origin/master",
|
|
937
|
+
created_at: new Date(s.createdAt).toISOString(),
|
|
938
|
+
})),
|
|
939
|
+
archived: manifest.sessions,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Delete flow: stop processes (SIGKILL), close the editor
|
|
944
|
+
// session, then `git worktree remove --force` to drop the
|
|
945
|
+
// worktree from disk. If the session was archived (manifest
|
|
946
|
+
// entry exists), the manifest entry is dropped too. No
|
|
947
|
+
// recovery after this point.
|
|
948
|
+
async function deleteConfirmedSession(): Promise<void> {
|
|
949
|
+
if (!openDialog || !openDialog.pendingConfirm) return;
|
|
950
|
+
const { sessionId: id } = openDialog.pendingConfirm;
|
|
951
|
+
openDialog.pendingConfirm = null;
|
|
952
|
+
const session = orchestratorSessions.get(id);
|
|
953
|
+
if (!session) {
|
|
954
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (id === editor.activeWindow()) {
|
|
958
|
+
editor.setStatus(
|
|
959
|
+
"Orchestrator: dive elsewhere first, then delete this session",
|
|
960
|
+
);
|
|
961
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const cwd = editor.getCwd();
|
|
966
|
+
const top = await spawnCollect(
|
|
967
|
+
"git",
|
|
968
|
+
["rev-parse", "--show-toplevel"],
|
|
969
|
+
cwd,
|
|
970
|
+
);
|
|
971
|
+
if (top.exit_code !== 0) {
|
|
972
|
+
editor.setStatus("Orchestrator: delete failed — not a git repository");
|
|
973
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const repoRoot = (top.stdout || "").trim();
|
|
977
|
+
|
|
978
|
+
editor.signalWindow(id, "SIGKILL");
|
|
979
|
+
editor.closeWindow(id);
|
|
980
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
981
|
+
|
|
982
|
+
// `--force` because the worktree may have unstaged changes
|
|
983
|
+
// the user explicitly chose to discard via the confirm step.
|
|
984
|
+
const removeRes = await spawnCollect(
|
|
985
|
+
"git",
|
|
986
|
+
["-C", repoRoot, "worktree", "remove", "--force", session.root],
|
|
987
|
+
repoRoot,
|
|
988
|
+
);
|
|
989
|
+
if (removeRes.exit_code !== 0) {
|
|
990
|
+
editor.setStatus(
|
|
991
|
+
`Orchestrator: worktree remove failed: ${
|
|
992
|
+
lastNonEmptyLine(removeRes.stderr) || "unknown error"
|
|
993
|
+
}`,
|
|
994
|
+
);
|
|
995
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Drop the matching manifest entry too, in case the session
|
|
1000
|
+
// was already archived (delete-from-archived is the natural
|
|
1001
|
+
// way to drop dormant sessions).
|
|
1002
|
+
const manifest = loadArchiveManifest(repoRoot);
|
|
1003
|
+
const before = manifest.sessions.length;
|
|
1004
|
+
manifest.sessions = manifest.sessions.filter(
|
|
1005
|
+
(e) => e.label !== session.label,
|
|
1006
|
+
);
|
|
1007
|
+
if (manifest.sessions.length !== before) {
|
|
1008
|
+
saveArchiveManifest(repoRoot, manifest);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
editor.setStatus(`Orchestrator: deleted [${id}] ${session.label}`);
|
|
1012
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
1013
|
+
triggerSyncAsync(repoRoot);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
editor.defineMode(OPEN_MODE, [], true, true);
|
|
1017
|
+
|
|
1018
|
+
// =============================================================================
|
|
1019
|
+
// New-session floating form
|
|
1020
|
+
// =============================================================================
|
|
1021
|
+
|
|
1022
|
+
function slugify(p: string): string {
|
|
1023
|
+
// Drop any leading separator so the slug isn't anchored to the
|
|
1024
|
+
// filesystem root; replace remaining separators with underscores.
|
|
1025
|
+
return p.replace(/^[\\\/]+/, "").replace(/[\\\/]+/g, "_");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function lastNonEmptyLine(s: string): string {
|
|
1029
|
+
const lines = (s || "").split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
1030
|
+
return lines.length ? lines[lines.length - 1].trim() : "";
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function spawnCollect(
|
|
1034
|
+
command: string,
|
|
1035
|
+
args: string[],
|
|
1036
|
+
cwd: string,
|
|
1037
|
+
): Promise<SpawnResult> {
|
|
1038
|
+
return await editor.spawnProcess(command, args, cwd);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/// Resolve the origin's default branch as `"origin/<name>"` from
|
|
1042
|
+
/// the locally-cached symbolic-ref. Returns `"HEAD"` when there's
|
|
1043
|
+
/// no `origin` remote (purely-local repos) or the symbolic ref is
|
|
1044
|
+
/// missing — the caller treats that as the silent fallback.
|
|
1045
|
+
///
|
|
1046
|
+
/// Deliberately does NOT fetch: `refs/remotes/origin/HEAD` is set
|
|
1047
|
+
/// at clone time and only changes when the remote renames its
|
|
1048
|
+
/// default branch (rare). A network round-trip per dialog open
|
|
1049
|
+
/// is too high a cost for that case.
|
|
1050
|
+
async function detectDefaultBranch(repoRoot: string): Promise<string> {
|
|
1051
|
+
const res = await spawnCollect(
|
|
1052
|
+
"git",
|
|
1053
|
+
["-C", repoRoot, "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
1054
|
+
repoRoot,
|
|
1055
|
+
);
|
|
1056
|
+
if (res.exit_code === 0) {
|
|
1057
|
+
const trimmed = (res.stdout || "").trim();
|
|
1058
|
+
const prefix = "refs/remotes/";
|
|
1059
|
+
if (trimmed.startsWith(prefix)) {
|
|
1060
|
+
// e.g. "refs/remotes/origin/main" → "origin/main". This is
|
|
1061
|
+
// what the new worktree is forked off, so the user sees the
|
|
1062
|
+
// exact ref name they'd otherwise have to type by hand.
|
|
1063
|
+
return trimmed.slice(prefix.length);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return "HEAD";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function nextAutoSessionName(): string {
|
|
1070
|
+
// Persisted counter so consecutive empty submits produce
|
|
1071
|
+
// session-1, session-2, … even across plugin reloads.
|
|
1072
|
+
const counter = (editor.getGlobalState("orchestrator.session_counter") as
|
|
1073
|
+
| number
|
|
1074
|
+
| undefined) ?? 0;
|
|
1075
|
+
const next = counter + 1;
|
|
1076
|
+
editor.setGlobalState("orchestrator.session_counter", next);
|
|
1077
|
+
return `session-${next}`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Three distinct styles for the header line: section keyword
|
|
1081
|
+
// ("ORCHESTRATOR"), structural separators ("::"), and step label. The
|
|
1082
|
+
// border-fg key picks up the same accent the floating panel border
|
|
1083
|
+
// uses, so the title visually anchors to the dialog chrome.
|
|
1084
|
+
const HEADER_KEYWORD_STYLE = {
|
|
1085
|
+
fg: "ui.popup_border_fg",
|
|
1086
|
+
bold: true,
|
|
1087
|
+
} as const;
|
|
1088
|
+
const HEADER_SEP_STYLE = { fg: "ui.menu_disabled_fg" } as const;
|
|
1089
|
+
const HEADER_LABEL_STYLE = { fg: "ui.menu_active_fg", bold: true } as const;
|
|
1090
|
+
|
|
1091
|
+
// Subtitle splits the static prefix "Project:" from the project
|
|
1092
|
+
// path so each gets its own foreground — matching the three-tier
|
|
1093
|
+
// (label / label-value / input) palette the design calls for.
|
|
1094
|
+
const SUBTITLE_LABEL_STYLE = { fg: "ui.menu_disabled_fg" } as const;
|
|
1095
|
+
const SUBTITLE_VALUE_STYLE = { fg: "ui.help_key_fg", bold: true } as const;
|
|
1096
|
+
|
|
1097
|
+
function buildFormSpec(): WidgetSpec {
|
|
1098
|
+
if (!form) return col();
|
|
1099
|
+
const children: WidgetSpec[] = [
|
|
1100
|
+
// === Header: title flanked by separators, centered. ==========
|
|
1101
|
+
row(
|
|
1102
|
+
flexSpacer(),
|
|
1103
|
+
{
|
|
1104
|
+
kind: "raw",
|
|
1105
|
+
entries: [
|
|
1106
|
+
styledRow([
|
|
1107
|
+
{ text: "ORCHESTRATOR", style: HEADER_KEYWORD_STYLE },
|
|
1108
|
+
{ text: " :: ", style: HEADER_SEP_STYLE },
|
|
1109
|
+
{ text: "New Session Dialog", style: HEADER_LABEL_STYLE },
|
|
1110
|
+
{ text: " :: ", style: HEADER_SEP_STYLE },
|
|
1111
|
+
{ text: "Review Synthesized", style: HEADER_LABEL_STYLE },
|
|
1112
|
+
]),
|
|
1113
|
+
],
|
|
1114
|
+
},
|
|
1115
|
+
flexSpacer(),
|
|
1116
|
+
),
|
|
1117
|
+
// === Subtitle: centered project identifier. ==================
|
|
1118
|
+
row(
|
|
1119
|
+
flexSpacer(),
|
|
1120
|
+
{
|
|
1121
|
+
kind: "raw",
|
|
1122
|
+
entries: [
|
|
1123
|
+
styledRow([
|
|
1124
|
+
{ text: "Project: ", style: SUBTITLE_LABEL_STYLE },
|
|
1125
|
+
{ text: form.projectLabel, style: SUBTITLE_VALUE_STYLE },
|
|
1126
|
+
]),
|
|
1127
|
+
],
|
|
1128
|
+
},
|
|
1129
|
+
flexSpacer(),
|
|
1130
|
+
),
|
|
1131
|
+
spacer(0),
|
|
1132
|
+
// === Form body: three labeled, full-width inputs. ============
|
|
1133
|
+
labeledSection({
|
|
1134
|
+
label: "▸ Session Name",
|
|
1135
|
+
child: text({
|
|
1136
|
+
value: form.name.value,
|
|
1137
|
+
cursorByte: form.name.cursor,
|
|
1138
|
+
placeholder: "(auto-generated)",
|
|
1139
|
+
fullWidth: true,
|
|
1140
|
+
key: "name",
|
|
1141
|
+
}),
|
|
1142
|
+
}),
|
|
1143
|
+
labeledSection({
|
|
1144
|
+
label: "▸ Agent Command",
|
|
1145
|
+
child: text({
|
|
1146
|
+
value: form.cmd.value,
|
|
1147
|
+
cursorByte: form.cmd.cursor,
|
|
1148
|
+
// Empty submission spawns a bare terminal — the host
|
|
1149
|
+
// picks the shell with the same logic it uses for any
|
|
1150
|
+
// other embedded terminal, so the plugin doesn't have
|
|
1151
|
+
// to second-guess `$SHELL` resolution.
|
|
1152
|
+
placeholder: "terminal",
|
|
1153
|
+
fullWidth: true,
|
|
1154
|
+
key: "cmd",
|
|
1155
|
+
}),
|
|
1156
|
+
}),
|
|
1157
|
+
labeledSection({
|
|
1158
|
+
label: "▸ Branch",
|
|
1159
|
+
child: text({
|
|
1160
|
+
value: form.branch.value,
|
|
1161
|
+
cursorByte: form.branch.cursor,
|
|
1162
|
+
// Show the literal base ref the empty submission will
|
|
1163
|
+
// fork off (e.g. `origin/main`). While the probe runs
|
|
1164
|
+
// we still print a hint so the field isn't blank.
|
|
1165
|
+
placeholder: form.defaultBranch || "detecting default branch…",
|
|
1166
|
+
fullWidth: true,
|
|
1167
|
+
key: "branch",
|
|
1168
|
+
}),
|
|
1169
|
+
}),
|
|
1170
|
+
];
|
|
1171
|
+
if (form.lastError) {
|
|
1172
|
+
children.push(spacer(0));
|
|
1173
|
+
children.push({
|
|
1174
|
+
kind: "raw",
|
|
1175
|
+
entries: [
|
|
1176
|
+
styledRow([
|
|
1177
|
+
{
|
|
1178
|
+
text: "Error: ",
|
|
1179
|
+
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
1180
|
+
},
|
|
1181
|
+
{ text: form.lastError },
|
|
1182
|
+
]),
|
|
1183
|
+
],
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
children.push(
|
|
1187
|
+
spacer(0),
|
|
1188
|
+
// === Button row: bottom-right aligned. =======================
|
|
1189
|
+
row(
|
|
1190
|
+
flexSpacer(),
|
|
1191
|
+
button("Cancel", { intent: "danger", key: "cancel" }),
|
|
1192
|
+
spacer(2),
|
|
1193
|
+
button("Create Session", { intent: "primary", key: "create" }),
|
|
1194
|
+
),
|
|
1195
|
+
spacer(0),
|
|
1196
|
+
// === Footer: keybinding helper, centered. ====================
|
|
1197
|
+
row(
|
|
1198
|
+
flexSpacer(),
|
|
1199
|
+
hintBar([
|
|
1200
|
+
{ keys: "Tab", label: "next" },
|
|
1201
|
+
{ keys: "S-Tab", label: "prev" },
|
|
1202
|
+
{ keys: "Enter", label: "submit" },
|
|
1203
|
+
{ keys: "Esc", label: "cancel" },
|
|
1204
|
+
]),
|
|
1205
|
+
flexSpacer(),
|
|
1206
|
+
),
|
|
1207
|
+
);
|
|
1208
|
+
return col(...children);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Derive a "my_org/project_name" style label from the current
|
|
1212
|
+
// working directory's tail. Orchestrator never opens this dialog
|
|
1213
|
+
// outside of a workspace; if the cwd has fewer than two
|
|
1214
|
+
// components we fall back to whatever's there.
|
|
1215
|
+
function deriveProjectLabel(): string {
|
|
1216
|
+
const cwd = editor.getCwd();
|
|
1217
|
+
const base = editor.pathBasename(cwd);
|
|
1218
|
+
const parent = editor.pathBasename(editor.pathDirname(cwd));
|
|
1219
|
+
if (parent && parent !== base) return `${parent}/${base}`;
|
|
1220
|
+
return base || cwd;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
function renderForm(): void {
|
|
1225
|
+
if (!form || !formPanel) return;
|
|
1226
|
+
formPanel.update(buildFormSpec());
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function openForm(): void {
|
|
1230
|
+
pendingNewSession = null;
|
|
1231
|
+
const lastCmd =
|
|
1232
|
+
(editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
|
|
1233
|
+
form = {
|
|
1234
|
+
name: { value: "", cursor: 0 },
|
|
1235
|
+
cmd: { value: lastCmd, cursor: lastCmd.length },
|
|
1236
|
+
branch: { value: "", cursor: 0 },
|
|
1237
|
+
submitting: false,
|
|
1238
|
+
lastError: null,
|
|
1239
|
+
projectLabel: deriveProjectLabel(),
|
|
1240
|
+
defaultBranch: "",
|
|
1241
|
+
};
|
|
1242
|
+
formPanel = new FloatingWidgetPanel();
|
|
1243
|
+
formPanel.mount(buildFormSpec(), { widthPct: 60, heightPct: 50 });
|
|
1244
|
+
editor.setEditorMode(NEW_SESSION_MODE);
|
|
1245
|
+
|
|
1246
|
+
// Probe origin's default branch in the background and update
|
|
1247
|
+
// the branch field's placeholder once we know it. The dialog
|
|
1248
|
+
// is interactive immediately — the probe just refines the hint
|
|
1249
|
+
// from "(detecting…)" to the concrete ref name.
|
|
1250
|
+
void (async () => {
|
|
1251
|
+
const cwd = editor.getCwd();
|
|
1252
|
+
const top = await spawnCollect(
|
|
1253
|
+
"git",
|
|
1254
|
+
["rev-parse", "--show-toplevel"],
|
|
1255
|
+
cwd,
|
|
1256
|
+
);
|
|
1257
|
+
if (top.exit_code !== 0 || !form) return;
|
|
1258
|
+
const repoRoot = (top.stdout || "").trim();
|
|
1259
|
+
const branch = await detectDefaultBranch(repoRoot);
|
|
1260
|
+
if (!form) return;
|
|
1261
|
+
form.defaultBranch = branch;
|
|
1262
|
+
renderForm();
|
|
1263
|
+
})();
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function closeForm(): void {
|
|
1267
|
+
if (formPanel) {
|
|
1268
|
+
formPanel.unmount();
|
|
1269
|
+
formPanel = null;
|
|
1270
|
+
}
|
|
1271
|
+
form = null;
|
|
1272
|
+
editor.setEditorMode(null);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function submitForm(): Promise<void> {
|
|
1276
|
+
if (!form || form.submitting) return;
|
|
1277
|
+
form.submitting = true;
|
|
1278
|
+
form.lastError = null;
|
|
1279
|
+
renderForm();
|
|
1280
|
+
|
|
1281
|
+
const sessionName = form.name.value.trim() || nextAutoSessionName();
|
|
1282
|
+
const cmd = form.cmd.value.trim();
|
|
1283
|
+
const branchInput = form.branch.value.trim();
|
|
1284
|
+
|
|
1285
|
+
const cwd = editor.getCwd();
|
|
1286
|
+
const top = await spawnCollect("git", ["rev-parse", "--show-toplevel"], cwd);
|
|
1287
|
+
if (top.exit_code !== 0) {
|
|
1288
|
+
if (!form) return;
|
|
1289
|
+
form.submitting = false;
|
|
1290
|
+
form.lastError = lastNonEmptyLine(top.stderr) || "not a git repository";
|
|
1291
|
+
renderForm();
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const repoRoot = (top.stdout || "").trim();
|
|
1295
|
+
|
|
1296
|
+
const root = editor.pathJoin(
|
|
1297
|
+
editor.getDataDir(),
|
|
1298
|
+
"orchestrator",
|
|
1299
|
+
slugify(repoRoot),
|
|
1300
|
+
sessionName,
|
|
1301
|
+
);
|
|
1302
|
+
const parent = editor.pathDirname(root);
|
|
1303
|
+
if (!editor.createDir(parent)) {
|
|
1304
|
+
if (!form) return;
|
|
1305
|
+
form.submitting = false;
|
|
1306
|
+
form.lastError = `mkdir failed: ${parent}`;
|
|
1307
|
+
renderForm();
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const defaultBranch = await detectDefaultBranch(repoRoot);
|
|
1312
|
+
const branchName = branchInput || sessionName;
|
|
1313
|
+
// Try `-b <new>` first; if it fails because the branch already
|
|
1314
|
+
// exists, fall back to checking out the existing branch into a
|
|
1315
|
+
// new worktree.
|
|
1316
|
+
let addRes = await spawnCollect(
|
|
1317
|
+
"git",
|
|
1318
|
+
["-C", repoRoot, "worktree", "add", root, "-b", branchName, defaultBranch],
|
|
1319
|
+
repoRoot,
|
|
1320
|
+
);
|
|
1321
|
+
if (addRes.exit_code !== 0) {
|
|
1322
|
+
const fallback = await spawnCollect(
|
|
1323
|
+
"git",
|
|
1324
|
+
["-C", repoRoot, "worktree", "add", root, branchName],
|
|
1325
|
+
repoRoot,
|
|
1326
|
+
);
|
|
1327
|
+
if (fallback.exit_code !== 0) {
|
|
1328
|
+
if (!form) return;
|
|
1329
|
+
form.submitting = false;
|
|
1330
|
+
form.lastError = lastNonEmptyLine(addRes.stderr) ||
|
|
1331
|
+
lastNonEmptyLine(fallback.stderr) ||
|
|
1332
|
+
"git worktree add failed";
|
|
1333
|
+
renderForm();
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
addRes = fallback;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (cmd) {
|
|
1340
|
+
editor.setGlobalState("orchestrator.last_cmd", cmd);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
pendingNewSession = { label: sessionName, branch: branchName, cmd, root };
|
|
1344
|
+
closeForm();
|
|
1345
|
+
editor.createWindow(root, sessionName);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function startNewSession(): void {
|
|
1349
|
+
if (form) return; // already open
|
|
1350
|
+
openForm();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Form key bindings — each delegates to smart-key dispatch on the
|
|
1354
|
+
// panel, which routes to the focused widget. `mode_text_input`
|
|
1355
|
+
// handles printable input outside this list.
|
|
1356
|
+
const FORM_MODE_BINDINGS: [string, string][] = [
|
|
1357
|
+
["Tab", "orchestrator_form_key_tab"],
|
|
1358
|
+
["S-Tab", "orchestrator_form_key_shift_tab"],
|
|
1359
|
+
["Return", "orchestrator_form_key_enter"],
|
|
1360
|
+
["Escape", "orchestrator_form_key_escape"],
|
|
1361
|
+
["Backspace", "orchestrator_form_key_backspace"],
|
|
1362
|
+
["Delete", "orchestrator_form_key_delete"],
|
|
1363
|
+
["Home", "orchestrator_form_key_home"],
|
|
1364
|
+
["End", "orchestrator_form_key_end"],
|
|
1365
|
+
["Left", "orchestrator_form_key_left"],
|
|
1366
|
+
["Right", "orchestrator_form_key_right"],
|
|
1367
|
+
["Up", "orchestrator_form_key_up"],
|
|
1368
|
+
["Down", "orchestrator_form_key_down"],
|
|
1369
|
+
];
|
|
1370
|
+
|
|
1371
|
+
editor.defineMode(NEW_SESSION_MODE, FORM_MODE_BINDINGS, true, true);
|
|
1372
|
+
|
|
1373
|
+
function dispatchFormKey(name: string): void {
|
|
1374
|
+
if (!form || !formPanel) return;
|
|
1375
|
+
formPanel.command(widgetKey(name));
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
registerHandler("orchestrator_form_key_tab", () => dispatchFormKey("Tab"));
|
|
1379
|
+
registerHandler(
|
|
1380
|
+
"orchestrator_form_key_shift_tab",
|
|
1381
|
+
() => dispatchFormKey("Shift+Tab"),
|
|
1382
|
+
);
|
|
1383
|
+
registerHandler("orchestrator_form_key_enter", () => dispatchFormKey("Enter"));
|
|
1384
|
+
registerHandler("orchestrator_form_key_escape", () => {
|
|
1385
|
+
if (form) closeForm();
|
|
1386
|
+
});
|
|
1387
|
+
registerHandler(
|
|
1388
|
+
"orchestrator_form_key_backspace",
|
|
1389
|
+
() => dispatchFormKey("Backspace"),
|
|
1390
|
+
);
|
|
1391
|
+
registerHandler("orchestrator_form_key_delete", () => dispatchFormKey("Delete"));
|
|
1392
|
+
registerHandler("orchestrator_form_key_home", () => dispatchFormKey("Home"));
|
|
1393
|
+
registerHandler("orchestrator_form_key_end", () => dispatchFormKey("End"));
|
|
1394
|
+
registerHandler("orchestrator_form_key_left", () => dispatchFormKey("Left"));
|
|
1395
|
+
registerHandler("orchestrator_form_key_right", () => dispatchFormKey("Right"));
|
|
1396
|
+
registerHandler("orchestrator_form_key_up", () => dispatchFormKey("Up"));
|
|
1397
|
+
registerHandler("orchestrator_form_key_down", () => dispatchFormKey("Down"));
|
|
1398
|
+
|
|
1399
|
+
// Printable input arrives via the global `mode_text_input` action.
|
|
1400
|
+
// Other plugins may also register a `mode_text_input` handler;
|
|
1401
|
+
// guard on `form` so this handler is a no-op outside the form.
|
|
1402
|
+
function orchestrator_mode_text_input(args: { text: string }): void {
|
|
1403
|
+
if (!form || !formPanel || !args?.text) return;
|
|
1404
|
+
formPanel.command(textInputChar(args.text));
|
|
1405
|
+
}
|
|
1406
|
+
registerHandler("mode_text_input", orchestrator_mode_text_input);
|
|
1407
|
+
|
|
1408
|
+
editor.on("widget_event", (e) => {
|
|
1409
|
+
// ---------------------------------------------------------------------
|
|
1410
|
+
// New-session form
|
|
1411
|
+
// ---------------------------------------------------------------------
|
|
1412
|
+
if (form && formPanel && e.panel_id === formPanel.id()) {
|
|
1413
|
+
if (e.event_type === "change") {
|
|
1414
|
+
const field = e.widget_key;
|
|
1415
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
1416
|
+
const value = payload.value;
|
|
1417
|
+
const cursor = payload.cursorByte;
|
|
1418
|
+
if (typeof value !== "string") return;
|
|
1419
|
+
const slot = field === "name"
|
|
1420
|
+
? form.name
|
|
1421
|
+
: field === "cmd"
|
|
1422
|
+
? form.cmd
|
|
1423
|
+
: field === "branch"
|
|
1424
|
+
? form.branch
|
|
1425
|
+
: null;
|
|
1426
|
+
if (slot) {
|
|
1427
|
+
slot.value = value;
|
|
1428
|
+
if (typeof cursor === "number") slot.cursor = cursor;
|
|
1429
|
+
}
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (e.event_type === "activate") {
|
|
1433
|
+
if (e.widget_key === "create") {
|
|
1434
|
+
void submitForm();
|
|
1435
|
+
} else if (e.widget_key === "cancel") {
|
|
1436
|
+
closeForm();
|
|
1437
|
+
}
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (e.event_type === "cancel") {
|
|
1441
|
+
// Host fires this when Esc unmounts the floating panel —
|
|
1442
|
+
// clean up our own state to match.
|
|
1443
|
+
form = null;
|
|
1444
|
+
formPanel = null;
|
|
1445
|
+
editor.setEditorMode(null);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// ---------------------------------------------------------------------
|
|
1452
|
+
// Open dialog (session picker)
|
|
1453
|
+
// ---------------------------------------------------------------------
|
|
1454
|
+
if (openPanel && openDialog && e.panel_id === openPanel.id()) {
|
|
1455
|
+
if (e.event_type === "change" && e.widget_key === "filter") {
|
|
1456
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
1457
|
+
const value = payload.value;
|
|
1458
|
+
const cursor = payload.cursorByte;
|
|
1459
|
+
if (typeof value !== "string") return;
|
|
1460
|
+
openDialog.filter.value = value;
|
|
1461
|
+
if (typeof cursor === "number") openDialog.filter.cursor = cursor;
|
|
1462
|
+
// Preserve highlighted session across the filter narrowing
|
|
1463
|
+
// when possible — if the previously selected id is still in
|
|
1464
|
+
// the new filtered set, keep it; otherwise reset to 0.
|
|
1465
|
+
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
|
|
1466
|
+
const next = filterSessions(value);
|
|
1467
|
+
openDialog.filteredIds = next;
|
|
1468
|
+
const nextIdx = prevId !== undefined ? next.indexOf(prevId) : -1;
|
|
1469
|
+
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
|
|
1470
|
+
refreshOpenDialog();
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (e.event_type === "select" && e.widget_key === "sessions") {
|
|
1474
|
+
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
1475
|
+
const idx = payload.index;
|
|
1476
|
+
if (typeof idx === "number") {
|
|
1477
|
+
openDialog.selectedIndex = idx;
|
|
1478
|
+
// Update preview pane.
|
|
1479
|
+
openPanel.update(buildOpenSpec());
|
|
1480
|
+
// Re-pin the list selection so the spec re-emit doesn't
|
|
1481
|
+
// snap it back to 0.
|
|
1482
|
+
openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
|
|
1483
|
+
}
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
if (e.event_type === "activate" && e.widget_key === "sessions") {
|
|
1487
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
1488
|
+
if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
|
|
1489
|
+
editor.setActiveWindow(id);
|
|
1490
|
+
}
|
|
1491
|
+
closeOpenDialog();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (e.event_type === "activate" && e.widget_key === "stop") {
|
|
1495
|
+
stopSelectedSession();
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (e.event_type === "activate" && e.widget_key === "archive") {
|
|
1499
|
+
void archiveSelectedSession();
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
if (e.event_type === "activate" && e.widget_key === "delete") {
|
|
1503
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
1504
|
+
if (typeof id === "number" && id > 0) {
|
|
1505
|
+
openDialog.pendingConfirm = { action: "delete", sessionId: id };
|
|
1506
|
+
openPanel.update(buildOpenSpec());
|
|
1507
|
+
}
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
|
|
1511
|
+
openDialog.pendingConfirm = null;
|
|
1512
|
+
openPanel.update(buildOpenSpec());
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-delete") {
|
|
1516
|
+
void deleteConfirmedSession();
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (e.event_type === "cancel") {
|
|
1520
|
+
// Esc unmounted the panel — sync our own state.
|
|
1521
|
+
openDialog = null;
|
|
1522
|
+
openPanel = null;
|
|
1523
|
+
editor.setEditorMode(null);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
// Legacy kill helper retained for the `Orchestrator: Kill Selected`
|
|
1531
|
+
// command-palette command. In the widget-based picker (Phase 1)
|
|
1532
|
+
// the open dialog has no kill action — Phase 3-5 will replace
|
|
1533
|
+
// this with Stop / Archive / Delete. When invoked while the
|
|
1534
|
+
// open dialog is up, it targets that dialog's selection; when
|
|
1535
|
+
// invoked from the palette outside the dialog, it status-bars
|
|
1536
|
+
// with guidance.
|
|
1537
|
+
function killSelected(): void {
|
|
1538
|
+
if (!openDialog) {
|
|
1539
|
+
editor.setStatus(
|
|
1540
|
+
"Orchestrator: open the session list (Ctrl+P → Orchestrator: Open) first",
|
|
1541
|
+
);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
const ids = openDialog.filteredIds;
|
|
1545
|
+
if (ids.length === 0) {
|
|
1546
|
+
editor.setStatus("Orchestrator: no session selected");
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const id = ids[Math.max(0, Math.min(openDialog.selectedIndex, ids.length - 1))];
|
|
1550
|
+
if (id <= 0) {
|
|
1551
|
+
editor.setStatus("Orchestrator: select a session row first");
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (id === 1) {
|
|
1555
|
+
editor.setStatus("Orchestrator: cannot kill the base session");
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (id === editor.activeWindow()) {
|
|
1559
|
+
editor.setStatus(
|
|
1560
|
+
"Orchestrator: dive elsewhere first, then kill this session",
|
|
1561
|
+
);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const s = orchestratorSessions.get(id);
|
|
1565
|
+
if (s && s.terminalId !== null) {
|
|
1566
|
+
editor.closeTerminal(s.terminalId);
|
|
1567
|
+
}
|
|
1568
|
+
editor.closeWindow(id);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// =============================================================================
|
|
1572
|
+
// Lifecycle hook handlers
|
|
1573
|
+
// =============================================================================
|
|
1574
|
+
|
|
1575
|
+
editor.on("window_created", async (payload) => {
|
|
1576
|
+
const id = payload.id;
|
|
1577
|
+
if (
|
|
1578
|
+
pendingNewSession &&
|
|
1579
|
+
payload.label === pendingNewSession.label
|
|
1580
|
+
) {
|
|
1581
|
+
const intent = pendingNewSession;
|
|
1582
|
+
pendingNewSession = null;
|
|
1583
|
+
// windowId attaches the terminal to the new session's split
|
|
1584
|
+
// tree; we then dive so the user sees the shell/agent
|
|
1585
|
+
// immediately — creating a session is a visit-now action.
|
|
1586
|
+
const term = await editor.createTerminal({
|
|
1587
|
+
cwd: intent.root,
|
|
1588
|
+
focus: false,
|
|
1589
|
+
windowId: id,
|
|
1590
|
+
});
|
|
1591
|
+
const tracked: AgentSession = {
|
|
1592
|
+
id,
|
|
1593
|
+
label: intent.label,
|
|
1594
|
+
root: intent.root,
|
|
1595
|
+
terminalId: term.terminalId,
|
|
1596
|
+
state: "running",
|
|
1597
|
+
createdAt: Date.now(),
|
|
1598
|
+
};
|
|
1599
|
+
orchestratorSessions.set(id, tracked);
|
|
1600
|
+
if (intent.cmd) {
|
|
1601
|
+
editor.sendTerminalInput(term.terminalId, intent.cmd + "\n");
|
|
1602
|
+
}
|
|
1603
|
+
editor.setActiveWindow(id);
|
|
1604
|
+
}
|
|
1605
|
+
refreshOpenDialog();
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
editor.on("window_closed", () => {
|
|
1609
|
+
refreshOpenDialog();
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
editor.on("active_window_changed", () => {
|
|
1613
|
+
refreshOpenDialog();
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
// =============================================================================
|
|
1617
|
+
// Agent state inference from terminal output / exit
|
|
1618
|
+
// =============================================================================
|
|
1619
|
+
|
|
1620
|
+
// Match common AI-agent prompts: "(Y/n)", "(y/N)", "Press <key>",
|
|
1621
|
+
// or a trailing question mark followed by optional whitespace.
|
|
1622
|
+
// Conservative — false positives mistakenly classify a busy
|
|
1623
|
+
// agent as "awaiting", which is recoverable by next output;
|
|
1624
|
+
// false negatives are worse (user thinks agent is busy when
|
|
1625
|
+
// it's actually waiting), so we err on the side of detecting.
|
|
1626
|
+
const AWAITING_RX = /(\(\s*[YyNn]\s*\/\s*[YyNn]\s*\):?\s*$)|(Press\s+(?:enter|return|any\s+key)[^\n]*$)|(\?\s*$)/i;
|
|
1627
|
+
|
|
1628
|
+
editor.on("terminal_output", (payload) => {
|
|
1629
|
+
const last = payload.last_line || "";
|
|
1630
|
+
for (const s of orchestratorSessions.values()) {
|
|
1631
|
+
if (s.terminalId === payload.terminal_id) {
|
|
1632
|
+
// RUNNING is the default; flip to AWAITING only when the
|
|
1633
|
+
// last visible line matches a prompt pattern. New output
|
|
1634
|
+
// that doesn't match restores RUNNING — agents usually
|
|
1635
|
+
// print their next chunk over the prompt line, so this
|
|
1636
|
+
// gives the right transition even for chatty agents.
|
|
1637
|
+
s.state = AWAITING_RX.test(last) ? "awaiting" : "running";
|
|
1638
|
+
break;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
refreshOpenDialog();
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
editor.on("terminal_exit", (payload) => {
|
|
1645
|
+
for (const s of orchestratorSessions.values()) {
|
|
1646
|
+
if (s.terminalId === payload.terminal_id) {
|
|
1647
|
+
const code = payload.exit_code;
|
|
1648
|
+
// exit_code is currently always null (the editor's
|
|
1649
|
+
// wait-status capture is a follow-up). Treat unknown as
|
|
1650
|
+
// ready — Orchestrator doesn't have a better heuristic and
|
|
1651
|
+
// mis-marking a real error as "ready" is recoverable
|
|
1652
|
+
// (the user opens the dive and sees the failure).
|
|
1653
|
+
s.state = code === null || code === 0 ? "ready" : "errored";
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
refreshOpenDialog();
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
// =============================================================================
|
|
1661
|
+
// Commands
|
|
1662
|
+
// =============================================================================
|
|
1663
|
+
|
|
1664
|
+
registerHandler("orchestrator_open", openControlRoom);
|
|
1665
|
+
registerHandler("orchestrator_new", startNewSession);
|
|
1666
|
+
registerHandler("orchestrator_kill", killSelected);
|
|
1667
|
+
|
|
1668
|
+
editor.registerCommand(
|
|
1669
|
+
"Orchestrator: Open",
|
|
1670
|
+
"Show all editor sessions in a floating selector",
|
|
1671
|
+
"orchestrator_open",
|
|
1672
|
+
null,
|
|
1673
|
+
);
|
|
1674
|
+
editor.registerCommand(
|
|
1675
|
+
"Orchestrator: New Session",
|
|
1676
|
+
"Spawn a new editor session in a worktree",
|
|
1677
|
+
"orchestrator_new",
|
|
1678
|
+
null,
|
|
1679
|
+
);
|
|
1680
|
+
editor.registerCommand(
|
|
1681
|
+
"Orchestrator: Kill Selected",
|
|
1682
|
+
"Close the session highlighted in the open Orchestrator prompt",
|
|
1683
|
+
"orchestrator_kill",
|
|
1684
|
+
null,
|
|
1685
|
+
);
|