@fresh-editor/fresh-editor 0.3.7 → 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 +48 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +28 -0
- package/plugins/audit_mode.ts +34 -3
- package/plugins/config-schema.json +7 -0
- package/plugins/env-manager.ts +168 -0
- package/plugins/git_log.ts +58 -75
- package/plugins/lib/fresh.d.ts +109 -2
- package/plugins/live_diff.ts +12 -17
- package/plugins/orchestrator.ts +320 -182
- package/plugins/pkg.ts +168 -3
- package/plugins/schemas/theme.schema.json +53 -14
- package/plugins/theme_editor.i18n.json +84 -84
- package/plugins/tsconfig.json +1 -0
- package/themes/light.json +1 -1
- package/themes/terminal.json +3 -3
package/plugins/orchestrator.ts
CHANGED
|
@@ -84,27 +84,6 @@ interface AgentSession {
|
|
|
84
84
|
|
|
85
85
|
const orchestratorSessions = new Map<number, AgentSession>();
|
|
86
86
|
|
|
87
|
-
// Pending session-creation intent. Stashed across the
|
|
88
|
-
// async `createWindow → window_created hook` handoff so the
|
|
89
|
-
// hook handler can attach the spawned terminal. (Internally
|
|
90
|
-
// the editor calls these "windows"; Orchestrator still presents
|
|
91
|
-
// them as "sessions" in its UX.)
|
|
92
|
-
let pendingNewSession:
|
|
93
|
-
| {
|
|
94
|
-
label: string;
|
|
95
|
-
branch: string;
|
|
96
|
-
cmd: string;
|
|
97
|
-
root: string;
|
|
98
|
-
// Recorded for `setWindowState` after `window_created`.
|
|
99
|
-
// `projectPath` is the canonical project root the user
|
|
100
|
-
// pointed the form at; `sharedWorktree` is `true` when the
|
|
101
|
-
// session shares its working tree (no dedicated `git
|
|
102
|
-
// worktree add`).
|
|
103
|
-
projectPath: string;
|
|
104
|
-
sharedWorktree: boolean;
|
|
105
|
-
}
|
|
106
|
-
| null = null;
|
|
107
|
-
|
|
108
87
|
// New-session form state. `null` ⇒ the floating form isn't
|
|
109
88
|
// open. Each field's `value` + `cursor` mirrors what the host
|
|
110
89
|
// renders inside the panel's TextInput widgets; the `submitting`
|
|
@@ -267,9 +246,25 @@ interface OpenDialogState {
|
|
|
267
246
|
// too easy to skip over when the user's eyes are on the dialog.
|
|
268
247
|
// Cleared on the next nav / filter change.
|
|
269
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";
|
|
270
260
|
}
|
|
271
261
|
let openDialog: OpenDialogState | null = null;
|
|
272
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";
|
|
273
268
|
const OPEN_MODE = "orchestrator-open";
|
|
274
269
|
|
|
275
270
|
// =============================================================================
|
|
@@ -346,14 +341,76 @@ function ageString(createdAt: number): string {
|
|
|
346
341
|
// The picker is cross-project by design — every session is a
|
|
347
342
|
// candidate regardless of which project the active window
|
|
348
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.
|
|
349
381
|
function filterSessions(needle: string): number[] {
|
|
350
382
|
reconcileSessions();
|
|
351
|
-
const
|
|
352
|
-
|
|
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
|
+
|
|
353
410
|
const n = needle.toLowerCase();
|
|
354
411
|
type Scored = { id: number; score: number; len: number };
|
|
355
412
|
const matches: Scored[] = [];
|
|
356
|
-
for (const id of
|
|
413
|
+
for (const id of allIds) {
|
|
357
414
|
const s = orchestratorSessions.get(id)!;
|
|
358
415
|
const label = s.label.toLowerCase();
|
|
359
416
|
const root = s.root.toLowerCase();
|
|
@@ -369,11 +426,33 @@ function filterSessions(needle: string): number[] {
|
|
|
369
426
|
return matches.map((m) => m.id);
|
|
370
427
|
}
|
|
371
428
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
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.
|
|
377
456
|
function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
378
457
|
const s = orchestratorSessions.get(id);
|
|
379
458
|
if (!s) {
|
|
@@ -381,37 +460,38 @@ function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
|
381
460
|
}
|
|
382
461
|
const isActive = id === activeId;
|
|
383
462
|
const isBase = id === 1;
|
|
384
|
-
|
|
463
|
+
|
|
464
|
+
const idText = `[${id}]`.padEnd(LIST_ID_W);
|
|
385
465
|
const entries: { text: string; style?: Record<string, unknown> }[] = [
|
|
386
|
-
{ text: `[${id}] `, style: { fg: "ui.help_key_fg" } },
|
|
387
466
|
{
|
|
388
|
-
text:
|
|
467
|
+
text: idText,
|
|
389
468
|
style: isActive
|
|
390
469
|
? { fg: "ui.tab_active_fg", bold: true }
|
|
391
|
-
: { fg: "ui.
|
|
470
|
+
: { fg: "ui.help_key_fg" },
|
|
392
471
|
},
|
|
393
|
-
{ text:
|
|
472
|
+
{ text: s.label, style: isActive ? { bold: true } : undefined },
|
|
394
473
|
];
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
// badge makes that special status visible up-front instead of
|
|
399
|
-
// surfacing as "Archive disabled, why?" after a Tab.
|
|
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;
|
|
400
477
|
if (isBase) {
|
|
401
|
-
entries.push({
|
|
402
|
-
|
|
403
|
-
style: { fg: "ui.help_key_fg", bold: true },
|
|
404
|
-
});
|
|
478
|
+
entries.push({ text: " BASE", style: { fg: "ui.help_key_fg", bold: true } });
|
|
479
|
+
nameWidth += 5;
|
|
405
480
|
}
|
|
406
|
-
// SHARED badge for the row when this session shares its
|
|
407
|
-
// worktree (with another session or with the project root
|
|
408
|
-
// directly). Mirrors the preview pane's badge so the
|
|
409
|
-
// shared-worktree status is visible at-a-glance from the
|
|
410
|
-
// list too.
|
|
411
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) });
|
|
412
492
|
entries.push({
|
|
413
|
-
text:
|
|
414
|
-
style: { fg: "ui.menu_disabled_fg" },
|
|
493
|
+
text: editor.pathBasename(proj),
|
|
494
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
415
495
|
});
|
|
416
496
|
}
|
|
417
497
|
return styledRow(entries as Parameters<typeof styledRow>[0]);
|
|
@@ -514,20 +594,26 @@ function sessionsSeparator(): WidgetSpec {
|
|
|
514
594
|
return spacer(0);
|
|
515
595
|
}
|
|
516
596
|
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
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 {
|
|
522
608
|
const screen = editor.getScreenSize();
|
|
523
609
|
const h = screen.height > 0 ? screen.height : 30;
|
|
524
610
|
const panelH = Math.floor(h * 0.9);
|
|
525
|
-
// panel borders (2) +
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// shows something.
|
|
530
|
-
return Math.max(
|
|
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);
|
|
531
617
|
}
|
|
532
618
|
|
|
533
619
|
// Compose the right-hand preview pane. Normally it shows info
|
|
@@ -688,14 +774,14 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
688
774
|
}
|
|
689
775
|
}
|
|
690
776
|
// Match the sessions column's content height so the two panes'
|
|
691
|
-
// bottom borders land on the same row. Sessions column inside
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
const totalEmbedBase = (openDialog?.listVisibleRows ??
|
|
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;
|
|
699
785
|
const detailsOn = openDialog?.showDetails ?? false;
|
|
700
786
|
const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
|
|
701
787
|
const embedRows = Math.max(
|
|
@@ -793,24 +879,11 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
793
879
|
|
|
794
880
|
function buildOpenSpec(): WidgetSpec {
|
|
795
881
|
if (!openDialog) return col();
|
|
796
|
-
// Re-derive row counts on every spec build as a fallback for the
|
|
797
|
-
// resize hook not always firing reliably through tmux's SIGWINCH
|
|
798
|
-
// propagation (Finding I). **One-way ratchet**: only adopt the
|
|
799
|
-
// new value when it's *larger* than the current one. The
|
|
800
|
-
// `editor.getViewport()` height shrinks while the picker is
|
|
801
|
-
// mounted (the floating panel covers part of the buffer area),
|
|
802
|
-
// and naively re-reading it on every refresh fed that shrink
|
|
803
|
-
// back into the dialog size — pressing Up/Down caused the
|
|
804
|
-
// picker to oscillate smaller on every keystroke. A real
|
|
805
|
-
// terminal-grow event still flows through because the new
|
|
806
|
-
// viewport height exceeds the cached value; a spurious shrink
|
|
807
|
-
// (because the panel itself is up) is ignored.
|
|
808
|
-
const liveListVisibleRows = openListVisibleRows();
|
|
809
|
-
if (liveListVisibleRows > openDialog.listVisibleRows) {
|
|
810
|
-
openDialog.listVisibleRows = liveListVisibleRows;
|
|
811
|
-
openDialog.embedRows = Math.max(3, liveListVisibleRows - 5);
|
|
812
|
-
}
|
|
813
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();
|
|
814
887
|
const activeId = editor.activeWindow();
|
|
815
888
|
const items = filtered.map((id) => renderListItem(id, activeId));
|
|
816
889
|
const itemKeys = filtered.map(String);
|
|
@@ -838,9 +911,7 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
838
911
|
"orchestrator_open_new_from_picker",
|
|
839
912
|
OPEN_MODE,
|
|
840
913
|
);
|
|
841
|
-
const newLabel = newKey
|
|
842
|
-
? `+ New Session ${newKey}`
|
|
843
|
-
: "+ New Session";
|
|
914
|
+
const newLabel = newKey ? `+ New ${newKey}` : "+ New";
|
|
844
915
|
const inConfirm = openDialog.pendingConfirm !== null;
|
|
845
916
|
// While a confirmation prompt is up the filter is rendered
|
|
846
917
|
// without a `key`. The host's `collect_tabbable` only adds
|
|
@@ -853,7 +924,8 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
853
924
|
const filterInput = text({
|
|
854
925
|
value: openDialog.filter.value,
|
|
855
926
|
cursorByte: openDialog.filter.cursor,
|
|
856
|
-
|
|
927
|
+
label: "Filter",
|
|
928
|
+
placeholder: "type to search… ( / )",
|
|
857
929
|
fullWidth: true,
|
|
858
930
|
key: inConfirm ? undefined : "filter",
|
|
859
931
|
});
|
|
@@ -874,6 +946,35 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
874
946
|
],
|
|
875
947
|
}
|
|
876
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
|
+
|
|
877
978
|
return col(
|
|
878
979
|
{
|
|
879
980
|
kind: "raw",
|
|
@@ -883,6 +984,10 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
883
984
|
text: "ORCHESTRATOR :: Sessions",
|
|
884
985
|
style: { fg: "ui.popup_border_fg", bold: true },
|
|
885
986
|
},
|
|
987
|
+
{
|
|
988
|
+
text: titleSuffix,
|
|
989
|
+
style: { fg: "ui.menu_disabled_fg" },
|
|
990
|
+
},
|
|
886
991
|
]),
|
|
887
992
|
],
|
|
888
993
|
},
|
|
@@ -896,14 +1001,18 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
896
1001
|
// the dialog.
|
|
897
1002
|
row(
|
|
898
1003
|
labeledSection({
|
|
899
|
-
label:
|
|
900
|
-
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
//
|
|
906
|
-
//
|
|
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.
|
|
907
1016
|
child: col(
|
|
908
1017
|
row(
|
|
909
1018
|
button(newLabel, {
|
|
@@ -918,13 +1027,19 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
918
1027
|
}),
|
|
919
1028
|
flexSpacer(),
|
|
920
1029
|
),
|
|
921
|
-
|
|
1030
|
+
projectControlRow,
|
|
922
1031
|
filterInput,
|
|
923
1032
|
sessionsSeparator(),
|
|
1033
|
+
sessionsColumnHeader(),
|
|
924
1034
|
list({
|
|
925
1035
|
items,
|
|
926
1036
|
itemKeys,
|
|
927
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.
|
|
928
1043
|
visibleRows: openDialog.listVisibleRows,
|
|
929
1044
|
// Excluded from the Tab cycle — Up/Down on the
|
|
930
1045
|
// filter input forwards to this list via host
|
|
@@ -944,8 +1059,7 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
944
1059
|
),
|
|
945
1060
|
}),
|
|
946
1061
|
// Preview pane has no explicit width — picks up the
|
|
947
|
-
// remaining
|
|
948
|
-
// 25%.
|
|
1062
|
+
// remaining width by default since the sessions list took 34%.
|
|
949
1063
|
buildPreviewPane(selectedSession),
|
|
950
1064
|
),
|
|
951
1065
|
row(
|
|
@@ -953,6 +1067,10 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
953
1067
|
hintBar([
|
|
954
1068
|
{ keys: "↑↓", label: "nav" },
|
|
955
1069
|
{ keys: "Enter", label: "dive" },
|
|
1070
|
+
{
|
|
1071
|
+
keys: scopeKey || "⌥P",
|
|
1072
|
+
label: scope === "current" ? "all projects" : "current only",
|
|
1073
|
+
},
|
|
956
1074
|
{ keys: "Tab", label: "focus" },
|
|
957
1075
|
{ keys: "Esc", label: "close" },
|
|
958
1076
|
]),
|
|
@@ -1032,7 +1150,9 @@ function openControlRoom(): void {
|
|
|
1032
1150
|
if (openPanel) return;
|
|
1033
1151
|
reconcileSessions();
|
|
1034
1152
|
const activeId = editor.activeWindow();
|
|
1035
|
-
|
|
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();
|
|
1036
1156
|
openDialog = {
|
|
1037
1157
|
filter: { value: "", cursor: 0 },
|
|
1038
1158
|
filteredIds: [],
|
|
@@ -1040,19 +1160,13 @@ function openControlRoom(): void {
|
|
|
1040
1160
|
originalActiveSession: activeId,
|
|
1041
1161
|
pendingConfirm: null,
|
|
1042
1162
|
listVisibleRows,
|
|
1043
|
-
|
|
1044
|
-
// + 2 info rows + 1 spacer = 4 rows reserved above the embed.
|
|
1045
|
-
// Preview chrome above the embed: 1 button row + 1 spacer + 2
|
|
1046
|
-
// info rows + 1 spacer = 5 rows. The labeledSection's top/bottom
|
|
1047
|
-
// borders match the sessions list's, so subtracting just the
|
|
1048
|
-
// chrome makes the preview pane's apparent height match the
|
|
1049
|
-
// list pane's (`visible_rows + 2 borders`) exactly. Floored at
|
|
1050
|
-
// 3 so a tiny terminal still leaves enough rows for the embed
|
|
1051
|
-
// to paint something meaningful.
|
|
1052
|
-
embedRows: Math.max(3, listVisibleRows - 5),
|
|
1163
|
+
embedRows: Math.max(3, listVisibleRows + 3),
|
|
1053
1164
|
showDetails: false,
|
|
1054
1165
|
inFlight: null,
|
|
1055
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,
|
|
1056
1170
|
};
|
|
1057
1171
|
openDialog.filteredIds = filterSessions("");
|
|
1058
1172
|
const activeIdx = openDialog.filteredIds.indexOf(activeId);
|
|
@@ -1616,7 +1730,19 @@ async function deleteConfirmedSession(): Promise<void> {
|
|
|
1616
1730
|
// since OPEN_MODE doesn't claim them here.
|
|
1617
1731
|
editor.defineMode(
|
|
1618
1732
|
OPEN_MODE,
|
|
1619
|
-
[
|
|
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
|
+
],
|
|
1620
1746
|
true,
|
|
1621
1747
|
true,
|
|
1622
1748
|
);
|
|
@@ -1627,6 +1753,29 @@ registerHandler("orchestrator_open_new_from_picker", () => {
|
|
|
1627
1753
|
openForm({ fromPicker: true });
|
|
1628
1754
|
});
|
|
1629
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);
|
|
1778
|
+
|
|
1630
1779
|
// =============================================================================
|
|
1631
1780
|
// New-session floating form
|
|
1632
1781
|
// =============================================================================
|
|
@@ -2210,7 +2359,6 @@ function renderForm(): void {
|
|
|
2210
2359
|
}
|
|
2211
2360
|
|
|
2212
2361
|
function openForm(options?: { fromPicker?: boolean }): void {
|
|
2213
|
-
pendingNewSession = null;
|
|
2214
2362
|
const lastCmd =
|
|
2215
2363
|
(editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
|
|
2216
2364
|
form = {
|
|
@@ -2713,11 +2861,11 @@ async function submitForm(): Promise<void> {
|
|
|
2713
2861
|
editor.setGlobalState("orchestrator.last_cmd", cmd);
|
|
2714
2862
|
}
|
|
2715
2863
|
|
|
2716
|
-
// Branch / cmd values used for
|
|
2717
|
-
// only exists in the worktree-create flow above;
|
|
2718
|
-
// shared-worktree / non-git case we report whatever's
|
|
2719
|
-
// checked out (best-effort) so the new session record
|
|
2720
|
-
// the situation on disk.
|
|
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.
|
|
2721
2869
|
const reportedBranch = createWorktree
|
|
2722
2870
|
? (branchInput || sessionName)
|
|
2723
2871
|
: "";
|
|
@@ -2729,16 +2877,47 @@ async function submitForm(): Promise<void> {
|
|
|
2729
2877
|
if (cmd) appendHistory("cmd", cmd);
|
|
2730
2878
|
if (createWorktree) appendHistory("branch", reportedBranch);
|
|
2731
2879
|
|
|
2732
|
-
pendingNewSession = {
|
|
2733
|
-
label: sessionName,
|
|
2734
|
-
branch: reportedBranch,
|
|
2735
|
-
cmd,
|
|
2736
|
-
root,
|
|
2737
|
-
projectPath,
|
|
2738
|
-
sharedWorktree: !createWorktree,
|
|
2739
|
-
};
|
|
2740
2880
|
closeForm();
|
|
2741
|
-
|
|
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
|
+
}
|
|
2742
2921
|
}
|
|
2743
2922
|
|
|
2744
2923
|
function startNewSession(): void {
|
|
@@ -3149,6 +3328,10 @@ editor.on("widget_event", (e) => {
|
|
|
3149
3328
|
openForm({ fromPicker: true });
|
|
3150
3329
|
return;
|
|
3151
3330
|
}
|
|
3331
|
+
if (e.event_type === "activate" && e.widget_key === "scope-toggle") {
|
|
3332
|
+
toggleScope();
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3152
3335
|
if (e.event_type === "activate" && e.widget_key === "toggle-details") {
|
|
3153
3336
|
openDialog.showDetails = !openDialog.showDetails;
|
|
3154
3337
|
refreshOpenDialog();
|
|
@@ -3262,57 +3445,13 @@ function killSelected(): void {
|
|
|
3262
3445
|
// Lifecycle hook handlers
|
|
3263
3446
|
// =============================================================================
|
|
3264
3447
|
|
|
3265
|
-
editor.on("window_created",
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
pendingNewSession = null;
|
|
3273
|
-
// Dive into the new session FIRST so its terminal_manager is
|
|
3274
|
-
// the editor-active one. Subsequent `createTerminal` /
|
|
3275
|
-
// `sendTerminalInput` calls then resolve against the new
|
|
3276
|
-
// session's window without needing a cross-window terminal
|
|
3277
|
-
// lookup. Creating a session is a visit-now action anyway —
|
|
3278
|
-
// the dive isn't user-visible flicker, it's the desired
|
|
3279
|
-
// landing state.
|
|
3280
|
-
editor.setActiveWindow(id);
|
|
3281
|
-
// Record the new session's project_path / shared_worktree
|
|
3282
|
-
// into per-window plugin state — these survive editor
|
|
3283
|
-
// restarts via `orchestrator_persistence.rs`, and feed the
|
|
3284
|
-
// Open dialog's "this project" filter on the next launch.
|
|
3285
|
-
// `setWindowState` writes to the active window, which we
|
|
3286
|
-
// just set above.
|
|
3287
|
-
editor.setWindowState("project_path", intent.projectPath);
|
|
3288
|
-
editor.setWindowState("shared_worktree", intent.sharedWorktree);
|
|
3289
|
-
// When the user provided a non-empty agent command, spawn it as
|
|
3290
|
-
// the PTY child directly (no shell middleman). Tab title reads
|
|
3291
|
-
// the command name ("python3", "claude", ...) instead of the
|
|
3292
|
-
// generic "*Terminal N*". When `cmd` is empty the host picks
|
|
3293
|
-
// the user's shell as before.
|
|
3294
|
-
const argv = splitAgentCmd(intent.cmd);
|
|
3295
|
-
const term = await editor.createTerminal({
|
|
3296
|
-
cwd: intent.root,
|
|
3297
|
-
focus: false,
|
|
3298
|
-
command: argv.length > 0 ? argv : undefined,
|
|
3299
|
-
title: argv.length > 0 ? argv[0] : undefined,
|
|
3300
|
-
});
|
|
3301
|
-
const tracked: AgentSession = {
|
|
3302
|
-
id,
|
|
3303
|
-
label: intent.label,
|
|
3304
|
-
root: intent.root,
|
|
3305
|
-
terminalId: term.terminalId,
|
|
3306
|
-
state: "running",
|
|
3307
|
-
createdAt: Date.now(),
|
|
3308
|
-
};
|
|
3309
|
-
orchestratorSessions.set(id, tracked);
|
|
3310
|
-
// Legacy `sendTerminalInput` path is no longer needed when the
|
|
3311
|
-
// command is spawned directly. Kept for the shell-only case
|
|
3312
|
-
// would be `editor.sendTerminalInput(term.terminalId, "\n")` to
|
|
3313
|
-
// wake up the prompt, but that's unnecessary — the shell prints
|
|
3314
|
-
// its own prompt on startup.
|
|
3315
|
-
}
|
|
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.
|
|
3316
3455
|
refreshOpenDialog();
|
|
3317
3456
|
});
|
|
3318
3457
|
|
|
@@ -3336,9 +3475,8 @@ editor.on("active_window_changed", () => {
|
|
|
3336
3475
|
// viewport at the same time.
|
|
3337
3476
|
editor.on("resize", () => {
|
|
3338
3477
|
if (openDialog && openPanel) {
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
openDialog.embedRows = Math.max(3, listVisibleRows - 5);
|
|
3478
|
+
// buildOpenSpec refits `listVisibleRows` to the session count
|
|
3479
|
+
// (bounded by the new screen budget) on the refresh below.
|
|
3342
3480
|
refreshOpenDialog();
|
|
3343
3481
|
}
|
|
3344
3482
|
});
|