@pellux/goodvibes-agent 0.1.2 → 0.1.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +12 -1
  4. package/docs/README.md +2 -0
  5. package/docs/getting-started.md +19 -1
  6. package/docs/release-and-publishing.md +3 -1
  7. package/package.json +10 -1
  8. package/src/agent/persona-registry.ts +379 -0
  9. package/src/agent/skill-registry.ts +360 -0
  10. package/src/audio/spoken-turn-model-routing.ts +2 -1
  11. package/src/cli/management-commands.ts +3 -1
  12. package/src/config/surface.ts +1 -0
  13. package/src/input/agent-workspace.ts +32 -2
  14. package/src/input/command-registry.ts +4 -1
  15. package/src/input/commands/agent-skills-runtime.ts +216 -0
  16. package/src/input/commands/knowledge.ts +18 -18
  17. package/src/input/commands/personas-runtime.ts +219 -0
  18. package/src/input/commands/skills-runtime.ts +7 -2
  19. package/src/input/commands.ts +4 -0
  20. package/src/input/panel-integration-actions.ts +0 -52
  21. package/src/main.ts +2 -1
  22. package/src/panels/builtin/session.ts +4 -3
  23. package/src/panels/index.ts +0 -5
  24. package/src/panels/orchestration-panel.ts +4 -5
  25. package/src/panels/qr-panel.ts +3 -2
  26. package/src/panels/tasks-panel.ts +4 -4
  27. package/src/renderer/agent-workspace.ts +2 -0
  28. package/src/runtime/bootstrap-command-context.ts +3 -0
  29. package/src/runtime/bootstrap-command-parts.ts +6 -2
  30. package/src/runtime/bootstrap-core.ts +8 -4
  31. package/src/runtime/bootstrap-shell.ts +3 -1
  32. package/src/runtime/bootstrap.ts +10 -2
  33. package/src/runtime/cloudflare-control-plane.ts +2 -1
  34. package/src/version.ts +1 -1
  35. package/src/daemon/cli.ts +0 -55
  36. package/src/daemon/safe-serve.ts +0 -61
  37. package/src/panels/diff-panel.ts +0 -520
  38. package/src/panels/file-explorer-panel.ts +0 -584
  39. package/src/panels/file-preview-panel.ts +0 -434
  40. package/src/panels/git-panel.ts +0 -638
  41. package/src/panels/sandbox-panel.ts +0 -283
  42. package/src/panels/symbol-outline-panel.ts +0 -486
  43. package/src/panels/worktree-panel.ts +0 -182
  44. package/src/panels/wrfc-panel.ts +0 -609
@@ -1,486 +0,0 @@
1
- import { BasePanel } from './base-panel.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import type { Line } from '../types/grid.ts';
4
- import {
5
- buildEmptyState,
6
- buildPanelLine,
7
- buildSelectablePanelLine,
8
- buildPanelWorkspace,
9
- resolveScrollablePanelSection,
10
- DEFAULT_PANEL_PALETTE,
11
- } from './polish.ts';
12
- import { getDisplayWidth } from '../utils/terminal-width.ts';
13
-
14
- // ── Symbol types ────────────────────────────────────────────────────────────
15
-
16
- export type SymbolKind = 'function' | 'class' | 'interface' | 'type' | 'const' | 'method' | 'namespace';
17
-
18
- export interface SymbolEntry {
19
- kind: SymbolKind;
20
- name: string;
21
- line: number;
22
- /** If set, this symbol is a child of a parent container (class/namespace). */
23
- parentName?: string;
24
- }
25
-
26
- // ── Rendering constants ──────────────────────────────────────────────────────
27
-
28
- /** ANSI 256-color fg codes per symbol kind. */
29
- const KIND_COLORS: Record<SymbolKind, string> = {
30
- function: '87', // cyan
31
- method: '87', // cyan
32
- class: '141', // purple
33
- namespace: '141', // purple
34
- interface: '219', // pink
35
- type: '228', // yellow
36
- const: '245', // grey
37
- };
38
-
39
- /** Short type indicator labels. */
40
- const KIND_LABELS: Record<SymbolKind, string> = {
41
- function: 'fn ',
42
- method: 'fn ',
43
- class: 'cls',
44
- namespace: 'ns ',
45
- interface: 'int',
46
- type: 'typ',
47
- const: 'cst',
48
- };
49
-
50
- /** Regex patterns to extract symbols. Each produces named groups: kind, name, line. */
51
- const SYMBOL_PATTERNS: Array<{ re: RegExp; kind: SymbolKind; isContainer?: boolean }> = [
52
- // export class Foo / abstract class Foo
53
- { re: /^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/, kind: 'class', isContainer: true },
54
- // export namespace Foo
55
- { re: /^(?:export\s+)?namespace\s+(\w+)/, kind: 'namespace', isContainer: true },
56
- // export interface Foo
57
- { re: /^(?:export\s+)?interface\s+(\w+)/, kind: 'interface' },
58
- // export type Foo =
59
- { re: /^(?:export\s+)?type\s+(\w+)\s*[=<{]/, kind: 'type' },
60
- // export function foo / export async function foo
61
- { re: /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, kind: 'function' },
62
- // export const foo = ... (function expressions / arrow fns / values)
63
- { re: /^(?:export\s+)?const\s+(\w+)\s*(?::[^=]*)?=\s*(?:async\s+)?(?:function|\(|\w+\s*=>)/, kind: 'function' },
64
- // export const foo = <non-function>
65
- { re: /^(?:export\s+)?const\s+(\w+)\s*(?::[^=]*)?=/, kind: 'const' },
66
- // methods inside class: indented methodName(...)
67
- { re: /^\s{2,}(?:(?:public|private|protected|static|async|override|readonly|abstract)\s+)*(\w+)\s*\(/, kind: 'method' },
68
- ];
69
-
70
- // ── Panel ────────────────────────────────────────────────────────────────────
71
-
72
- /**
73
- * SymbolOutlinePanel — renders a hierarchical symbol outline of the current
74
- * file. Symbols are parsed from source text using lightweight regex heuristics
75
- * (no tree-sitter or LSP required).
76
- */
77
- export class SymbolOutlinePanel extends BasePanel {
78
- /** Flat list of parsed symbols (methods nested after their parent class). */
79
- private symbols: SymbolEntry[] = [];
80
-
81
- /** Index of the currently highlighted row in the visible flat list. */
82
- private selectedIndex: number = 0;
83
-
84
- /** Set of container names (class/namespace) that are collapsed. */
85
- private collapsed: Set<string> = new Set();
86
-
87
- /** Scroll offset (top-visible row index in the flat rendered list). */
88
- private scrollOffset: number = 0;
89
-
90
- /** Path of the file currently loaded. */
91
- private currentPath: string = '';
92
-
93
- constructor() {
94
- super('symbols', 'Symbols', 'S', 'development');
95
- }
96
-
97
- // ── Public API ─────────────────────────────────────────────────────────────
98
-
99
- /**
100
- * Load and parse symbols from the given file source text.
101
- * Call this when the active file changes in the file-preview panel.
102
- */
103
- loadFile(path: string, source: string): void {
104
- this.currentPath = path;
105
- this.symbols = parseSymbols(source);
106
- this.selectedIndex = 0;
107
- this.scrollOffset = 0;
108
- this.collapsed.clear();
109
- this.markDirty();
110
- }
111
-
112
- /**
113
- * Returns the { path, line } for the currently selected symbol so the
114
- * caller can jump to it in the file-preview panel.
115
- */
116
- getSelectedLocation(): { path: string; line: number } | null {
117
- const visible = this._visibleRows();
118
- const row = visible[this.selectedIndex];
119
- if (!row || row.kind === 'header') return null;
120
- return { path: this.currentPath, line: row.symbol.line };
121
- }
122
-
123
- // ── Input ──────────────────────────────────────────────────────────────────
124
-
125
- handleInput(key: string): boolean {
126
- const visible = this._visibleRows();
127
-
128
- if (key === 'up' || key === 'k') {
129
- if (this.selectedIndex > 0) {
130
- this.selectedIndex--;
131
- this._clampScroll(visible.length);
132
- this.markDirty();
133
- }
134
- return true;
135
- }
136
-
137
- if (key === 'down' || key === 'j') {
138
- if (this.selectedIndex < visible.length - 1) {
139
- this.selectedIndex++;
140
- this._clampScroll(visible.length);
141
- this.markDirty();
142
- }
143
- return true;
144
- }
145
-
146
- if (key === 'return' || key === 'enter') {
147
- // Caller should call getSelectedLocation() after this returns true.
148
- return true;
149
- }
150
-
151
- if (key === 'space' || key === 'right' || key === 'left') {
152
- const row = visible[this.selectedIndex];
153
- if (row?.kind === 'header') {
154
- const name = row.name;
155
- if (this.collapsed.has(name)) {
156
- this.collapsed.delete(name);
157
- } else {
158
- this.collapsed.add(name);
159
- }
160
- // Clamp selection so it doesn't point into a now-hidden row
161
- const newVisible = this._visibleRows();
162
- if (this.selectedIndex >= newVisible.length) {
163
- this.selectedIndex = Math.max(0, newVisible.length - 1);
164
- }
165
- this._clampScroll(newVisible.length);
166
- this.markDirty();
167
- }
168
- return true;
169
- }
170
-
171
- return false;
172
- }
173
-
174
- // ── Rendering ──────────────────────────────────────────────────────────────
175
-
176
- render(width: number, height: number): Line[] {
177
- this.needsRender = false;
178
-
179
- if (this.symbols.length === 0) {
180
- return buildPanelWorkspace(width, height, {
181
- title: ' Symbols',
182
- intro: 'Outline the current file into navigable symbols and lightweight parent/child structure.',
183
- sections: [
184
- {
185
- lines: buildEmptyState(
186
- width,
187
- this.currentPath ? ' No symbols found' : ' No file loaded',
188
- this.currentPath
189
- ? 'The current file did not produce outline entries with the lightweight parser heuristics.'
190
- : 'Load a file in the preview panel to populate its outline here.',
191
- [],
192
- DEFAULT_PANEL_PALETTE,
193
- ),
194
- },
195
- ],
196
- palette: DEFAULT_PANEL_PALETTE,
197
- });
198
- }
199
-
200
- const visible = this._visibleRows();
201
- const outlineSection = resolveScrollablePanelSection(width, height, {
202
- intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
203
- footerLines: [
204
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' jump target', DEFAULT_PANEL_PALETTE.dim]]),
205
- ],
206
- palette: DEFAULT_PANEL_PALETTE,
207
- beforeSections: [
208
- {
209
- title: 'Summary',
210
- lines: [
211
- buildPanelLine(width, [
212
- [' Symbols ', DEFAULT_PANEL_PALETTE.label],
213
- [String(this.symbols.length), DEFAULT_PANEL_PALETTE.value],
214
- [' Collapsed ', DEFAULT_PANEL_PALETTE.label],
215
- [String(this.collapsed.size), this.collapsed.size > 0 ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.dim],
216
- ]),
217
- ],
218
- },
219
- ],
220
- section: {
221
- title: 'Outline',
222
- scrollableLines: visible.map((row, i) => {
223
- const isSelected = i === this.selectedIndex;
224
- const bgColor = isSelected ? '236' : '';
225
- return row.kind === 'header'
226
- ? _renderHeader(width, row, isSelected, bgColor, this.collapsed)
227
- : _renderSymbol(width, row, isSelected, bgColor);
228
- }),
229
- selectedIndex: this.selectedIndex,
230
- scrollOffset: this.scrollOffset,
231
- minRows: 8,
232
- },
233
- afterSections: [
234
- {
235
- title: 'Selected',
236
- lines: (() => {
237
- const selected = visible[this.selectedIndex];
238
- return selected
239
- ? [
240
- buildPanelLine(width, [
241
- [' Kind ', DEFAULT_PANEL_PALETTE.label],
242
- [selected.kind === 'header' ? selected.symbolKind : selected.symbol.kind, DEFAULT_PANEL_PALETTE.info],
243
- [' Line ', DEFAULT_PANEL_PALETTE.label],
244
- [String(selected.kind === 'header' ? selected.line : selected.symbol.line), DEFAULT_PANEL_PALETTE.value],
245
- ]),
246
- buildPanelLine(width, [
247
- [' Name ', DEFAULT_PANEL_PALETTE.label],
248
- [selected.kind === 'header' ? selected.name : selected.symbol.name, DEFAULT_PANEL_PALETTE.value],
249
- ]),
250
- ]
251
- : [];
252
- })(),
253
- },
254
- ],
255
- });
256
- this.scrollOffset = outlineSection.scrollOffset;
257
-
258
- const selected = visible[this.selectedIndex];
259
- return buildPanelWorkspace(width, height, {
260
- title: ' Symbols',
261
- intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
262
- sections: [
263
- {
264
- title: 'Summary',
265
- lines: [
266
- buildPanelLine(width, [
267
- [' Symbols ', DEFAULT_PANEL_PALETTE.label],
268
- [String(this.symbols.length), DEFAULT_PANEL_PALETTE.value],
269
- [' Collapsed ', DEFAULT_PANEL_PALETTE.label],
270
- [String(this.collapsed.size), this.collapsed.size > 0 ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.dim],
271
- ]),
272
- ],
273
- },
274
- outlineSection.section,
275
- {
276
- title: 'Selected',
277
- lines: selected
278
- ? [
279
- buildPanelLine(width, [
280
- [' Kind ', DEFAULT_PANEL_PALETTE.label],
281
- [selected.kind === 'header' ? selected.symbolKind : selected.symbol.kind, DEFAULT_PANEL_PALETTE.info],
282
- [' Line ', DEFAULT_PANEL_PALETTE.label],
283
- [String(selected.kind === 'header' ? selected.line : selected.symbol.line), DEFAULT_PANEL_PALETTE.value],
284
- ]),
285
- buildPanelLine(width, [
286
- [' Name ', DEFAULT_PANEL_PALETTE.label],
287
- [selected.kind === 'header' ? selected.name : selected.symbol.name, DEFAULT_PANEL_PALETTE.value],
288
- ]),
289
- ]
290
- : [],
291
- },
292
- ],
293
- footerLines: [
294
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' jump target', DEFAULT_PANEL_PALETTE.dim]]),
295
- ],
296
- palette: DEFAULT_PANEL_PALETTE,
297
- });
298
- }
299
-
300
- // ── Private helpers ────────────────────────────────────────────────────────
301
-
302
- private _clampScroll(totalRows: number): void {
303
- // Ensure selected is within scroll view (assumes last known height ~ 20)
304
- // We keep a conservative viewport window; render() uses this.scrollOffset.
305
- const GUARD = 3;
306
- if (this.selectedIndex < this.scrollOffset + GUARD) {
307
- this.scrollOffset = Math.max(0, this.selectedIndex - GUARD);
308
- }
309
- // We don't know height here, so we defer bottom-clamp to render().
310
- }
311
-
312
- private _visibleRows(): VisibleRow[] {
313
- return buildVisibleRows(this.symbols, this.collapsed);
314
- }
315
- }
316
-
317
- // ── Row types for rendering ──────────────────────────────────────────────────
318
-
319
- type VisibleRow =
320
- | { kind: 'header'; name: string; symbolKind: SymbolKind; line: number; hasChildren: boolean }
321
- | { kind: 'symbol'; symbol: SymbolEntry; depth: number };
322
-
323
- // ── Pure helpers ─────────────────────────────────────────────────────────────
324
-
325
- /**
326
- * Parse symbols from source text. Returns a flat list ordered by line number.
327
- * Methods are tagged with their parent class name so the renderer can group them.
328
- */
329
- function parseSymbols(source: string): SymbolEntry[] {
330
- const lines = source.split('\n');
331
- const result: SymbolEntry[] = [];
332
- let currentContainer: string | undefined;
333
- let containerKind: SymbolKind | undefined;
334
- let containerBraceDepth = 0;
335
- let braceDepth = 0;
336
-
337
- for (let i = 0; i < lines.length; i++) {
338
- const raw = lines[i];
339
- const trimmed = raw.trimStart();
340
- const lineNo = i + 1; // 1-based
341
-
342
- // Strip string literals before counting braces to avoid false positives
343
- const rawNoBraceStrings = raw.replace(/("|\'|\`)(?:(?!\1|\\).|\\[\s\S])*\1/g, '');
344
-
345
- // Track brace depth for container scoping
346
- for (const ch of rawNoBraceStrings) {
347
- if (ch === '{') braceDepth++;
348
- else if (ch === '}') {
349
- braceDepth--;
350
- if (currentContainer !== undefined && braceDepth <= containerBraceDepth) {
351
- currentContainer = undefined;
352
- containerKind = undefined;
353
- containerBraceDepth = 0;
354
- }
355
- }
356
- }
357
-
358
- // Skip comment lines and blank lines
359
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed === '') continue;
360
-
361
- // Check container-level patterns first (class/namespace)
362
- let matched = false;
363
- for (const { re, kind, isContainer } of SYMBOL_PATTERNS) {
364
- const m = trimmed.match(re);
365
- if (!m) continue;
366
- const name = m[1];
367
- if (!name) continue;
368
-
369
- const entry: SymbolEntry = { kind, name, line: lineNo };
370
-
371
- if (currentContainer && kind === 'method') {
372
- entry.parentName = currentContainer;
373
- }
374
-
375
- // Don't add methods as top-level if they're inside a container
376
- if (kind === 'method' && !currentContainer) {
377
- // standalone function-like at wrong indent — skip
378
- matched = true;
379
- break;
380
- }
381
-
382
- result.push(entry);
383
-
384
- if (isContainer) {
385
- currentContainer = name;
386
- containerKind = kind;
387
- // Opening brace may be on this line or a subsequent line — track from current depth
388
- containerBraceDepth = braceDepth - (raw.includes('{') ? 1 : 0);
389
- }
390
-
391
- matched = true;
392
- break;
393
- }
394
- }
395
-
396
- return result;
397
- }
398
-
399
- /**
400
- * Build the flat list of rows to render, respecting collapse state.
401
- * Container symbols become header rows; their children are indented below.
402
- */
403
- function buildVisibleRows(symbols: SymbolEntry[], collapsed: Set<string>): VisibleRow[] {
404
- const rows: VisibleRow[] = [];
405
-
406
- // Pre-compute children map to avoid O(n^2) scans inside the loop
407
- const childrenByParent = new Map<string, SymbolEntry[]>();
408
- for (const sym of symbols) {
409
- if (sym.parentName) {
410
- const arr = childrenByParent.get(sym.parentName);
411
- if (arr) arr.push(sym);
412
- else childrenByParent.set(sym.parentName, [sym]);
413
- }
414
- }
415
-
416
- for (const sym of symbols) {
417
- if (sym.kind === 'class' || sym.kind === 'namespace') {
418
- const children = childrenByParent.get(sym.name) ?? [];
419
- const hasChildren = children.length > 0;
420
- rows.push({ kind: 'header', name: sym.name, symbolKind: sym.kind, line: sym.line, hasChildren });
421
- // If collapsed, skip children
422
- if (collapsed.has(sym.name)) continue;
423
- // Add children immediately after header
424
- for (const child of children) {
425
- rows.push({ kind: 'symbol', symbol: child, depth: 1 });
426
- }
427
- } else if (!sym.parentName) {
428
- // Top-level non-container symbol
429
- rows.push({ kind: 'symbol', symbol: sym, depth: 0 });
430
- }
431
- // Children with parentName are rendered under their header above
432
- }
433
-
434
- return rows;
435
- }
436
-
437
- /**
438
- * Write a string into a Line starting at column x, applying fg/bg/style.
439
- */
440
- /** Render a container header row (class / namespace). */
441
- function _renderHeader(
442
- width: number,
443
- row: Extract<VisibleRow, { kind: 'header' }>,
444
- isSelected: boolean,
445
- bgColor: string,
446
- collapsed: Set<string>,
447
- ): Line {
448
- // Collapse indicator
449
- const isCollapsed = collapsed.has(row.name);
450
- const chevron = row.hasChildren ? (isCollapsed ? '▸ ' : '▾ ') : ' ';
451
- const lineNumStr = `:${row.line}`;
452
- const kindLabel = KIND_LABELS[row.symbolKind];
453
- const leadingWidth = 1 + getDisplayWidth(chevron) + getDisplayWidth(kindLabel) + 1 + getDisplayWidth(row.name);
454
- const gap = Math.max(1, width - leadingWidth - getDisplayWidth(lineNumStr) - 1);
455
- return buildSelectablePanelLine(width, [
456
- { text: ` ${chevron}`, fg: '245' },
457
- { text: kindLabel, fg: KIND_COLORS[row.symbolKind], bold: true },
458
- { text: ' ', fg: isSelected ? '255' : '252' },
459
- { text: row.name, fg: isSelected ? '255' : '252', bold: isSelected },
460
- { text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
461
- { text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
462
- ], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
463
- }
464
-
465
- /** Render a regular symbol row. */
466
- function _renderSymbol(
467
- width: number,
468
- row: Extract<VisibleRow, { kind: 'symbol' }>,
469
- isSelected: boolean,
470
- bgColor: string,
471
- ): Line {
472
- const { symbol, depth } = row;
473
- const indent = depth === 0 ? 1 : 3; // children indented by 3 (chevron + space)
474
- const kindLabel = KIND_LABELS[symbol.kind];
475
- const lineNumStr = `:${symbol.line}`;
476
- const leadingWidth = indent + getDisplayWidth(kindLabel) + 1 + getDisplayWidth(symbol.name);
477
- const gap = Math.max(1, width - leadingWidth - getDisplayWidth(lineNumStr) - 1);
478
- return buildSelectablePanelLine(width, [
479
- { text: ' '.repeat(indent), fg: DEFAULT_PANEL_PALETTE.dim },
480
- { text: kindLabel, fg: KIND_COLORS[symbol.kind] },
481
- { text: ' ', fg: isSelected ? '255' : '251' },
482
- { text: symbol.name, fg: isSelected ? '255' : '251', bold: isSelected },
483
- { text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
484
- { text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
485
- ], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
486
- }
@@ -1,182 +0,0 @@
1
- import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
- import { buildKeyValueLine, buildPanelLine, buildPanelWorkspace, DEFAULT_PANEL_PALETTE, resolvePrimaryScrollableSection, type PanelWorkspaceSection } from './polish.ts';
5
- import { summarizeWorktreeOwnership, type WorktreeRegistry, type WorktreeStatusRecord } from '@/runtime/index.ts';
6
-
7
- const C = {
8
- ...DEFAULT_PANEL_PALETTE,
9
- dim: '#475569',
10
- info: '#38bdf8',
11
- ok: '#22c55e',
12
- warn: '#eab308',
13
- headerBg: '#1e293b',
14
- } as const;
15
-
16
- function stateColor(state: WorktreeStatusRecord['state']): string {
17
- switch (state) {
18
- case 'active': return C.ok;
19
- case 'paused':
20
- case 'kept': return C.warn;
21
- default: return C.dim;
22
- }
23
- }
24
-
25
- export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
26
- private rows: WorktreeStatusRecord[] = [];
27
- private loading = false;
28
- private readonly worktreeRegistry: WorktreeRegistry;
29
-
30
- public constructor(worktreeRegistry: WorktreeRegistry) {
31
- super('worktrees', 'Worktrees', 'W', 'monitoring');
32
- this.showSelectionGutter = true; // I5: non-color selection affordance
33
- this.worktreeRegistry = worktreeRegistry;
34
- void this.refresh();
35
- }
36
-
37
- public override onActivate(): void {
38
- super.onActivate();
39
- if (!this.loading) void this.refresh();
40
- }
41
-
42
- public handleInput(key: string): boolean {
43
- if (key === 'r') {
44
- void this.refresh();
45
- return true;
46
- }
47
- return super.handleInput(key);
48
- }
49
-
50
- protected getItems(): readonly WorktreeStatusRecord[] {
51
- return this.rows;
52
- }
53
-
54
- protected renderItem(row: WorktreeStatusRecord, index: number, _selected: boolean, width: number): Line {
55
- const bg = index === this.selectedIndex ? C.headerBg : undefined;
56
- return buildPanelLine(width, [
57
- [` ${row.kind}`.padEnd(14), C.info, bg],
58
- [` ${row.state}`.padEnd(16), stateColor(row.state), bg],
59
- [` ${row.branch}`.padEnd(24), C.value, bg],
60
- [` ${row.path}`.slice(0, Math.max(0, width - 56)), C.dim, bg],
61
- ]);
62
- }
63
-
64
- private async refresh(): Promise<void> {
65
- this.loading = true;
66
- this.markDirty();
67
- try {
68
- this.rows = await this.worktreeRegistry.list();
69
- this.clampSelection();
70
- } finally {
71
- this.loading = false;
72
- this.markDirty();
73
- }
74
- }
75
-
76
- public render(width: number, height: number): Line[] {
77
- this.needsRender = false;
78
- const sections: PanelWorkspaceSection[] = [];
79
-
80
- if (this.loading && this.rows.length === 0) {
81
- sections.push({ title: 'Worktrees', lines: [buildPanelLine(width, [[' Loading worktree state...', C.info]])] });
82
- } else if (this.rows.length === 0) {
83
- sections.push({ title: 'Worktrees', lines: [buildPanelLine(width, [[' No git worktrees discovered for this project.', C.dim]])] });
84
- } else {
85
- const summary = summarizeWorktreeOwnership(this.rows);
86
- sections.push({
87
- title: 'Worktree posture',
88
- lines: [
89
- buildKeyValueLine(width, [
90
- { label: 'total', value: String(summary.total), valueColor: C.value },
91
- { label: 'active', value: String(summary.active), valueColor: C.ok },
92
- { label: 'paused', value: String(summary.paused), valueColor: summary.paused > 0 ? C.warn : C.dim },
93
- { label: 'cleanup', value: String(summary.pendingCleanup), valueColor: summary.pendingCleanup > 0 ? C.warn : C.dim },
94
- ], C),
95
- buildKeyValueLine(width, [
96
- { label: 'session attached', value: String(summary.sessionAttached), valueColor: summary.sessionAttached > 0 ? C.info : C.dim },
97
- { label: 'task attached', value: String(summary.taskAttached), valueColor: summary.taskAttached > 0 ? C.info : C.dim },
98
- { label: 'agent owned', value: String(summary.agentOwned), valueColor: summary.agentOwned > 0 ? C.value : C.dim },
99
- { label: 'orchestrator', value: String(summary.orchestratorOwned), valueColor: summary.orchestratorOwned > 0 ? C.value : C.dim },
100
- ], C),
101
- ],
102
- });
103
- sections.push({
104
- title: 'Next Actions',
105
- lines: [
106
- buildPanelLine(width, [[
107
- summary.pendingCleanup > 0 || summary.discard > 0
108
- ? ' Review pending-cleanup and discard-marked worktrees before they drift from orchestrator ownership.'
109
- : ' Worktree ownership is healthy. Use the task and session links below for restore, merge, or cleanup review.',
110
- summary.pendingCleanup > 0 || summary.discard > 0 ? C.warn : C.dim,
111
- ]]),
112
- buildPanelLine(width, [[' /worktree task <task-id> /worktree session <session-id> /worktree inspect <path>', C.info]]),
113
- ],
114
- });
115
- const selected = this.rows[this.selectedIndex]!;
116
- const detailSection: PanelWorkspaceSection = {
117
- title: 'Details',
118
- lines: [
119
- buildPanelLine(width, [[' path ', C.label], [selected.path, C.dim]]),
120
- buildPanelLine(width, [[' branch ', C.label], [selected.branch, C.value], [' head ', C.label], [selected.head.slice(0, 12), C.info], [' state ', C.label], [selected.state, stateColor(selected.state)]]),
121
- buildPanelLine(width, [[' kind ', C.label], [selected.kind, C.info], [' owner ', C.label], [selected.ownerId ?? 'n/a', C.dim], [' session ', C.label], [selected.sessionId ?? 'n/a', C.dim]]),
122
- buildPanelLine(width, [[' task ', C.label], [selected.taskId ?? 'n/a', C.dim], [' updated ', C.label], [new Date(selected.updatedAt).toLocaleString(), C.dim]]),
123
- buildPanelLine(width, [[
124
- selected.sessionId || selected.taskId
125
- ? ' Attached worktree can be resumed from session/task flows and should be merged or cleaned up by the orchestrator.'
126
- : ' Unattached worktree detected. Review whether it should be attached, kept, discarded, or cleaned up.',
127
- selected.sessionId || selected.taskId ? C.info : C.warn,
128
- ]]),
129
- buildPanelLine(width, [[
130
- selected.state === 'paused'
131
- ? ` Next: /worktree resume ${selected.path}`
132
- : selected.state === 'discard' || selected.state === 'pending-cleanup'
133
- ? ` Next: /worktree cleanup ${selected.path}`
134
- : selected.taskId
135
- ? ` Next: /worktree task ${selected.taskId}`
136
- : selected.sessionId
137
- ? ` Next: /worktree session ${selected.sessionId}`
138
- : ` Next: /worktree inspect ${selected.path}`,
139
- C.dim,
140
- ]]),
141
- ],
142
- };
143
- const resolvedWorktreesSection = resolvePrimaryScrollableSection(width, height, {
144
- intro: 'Orchestrator-owned worktree lifecycle, attachments, pause/resume posture, and cleanup state.',
145
- footerLines: [buildPanelLine(width, [[' r refresh /worktree inspect <path> /worktree attach|pause|resume|keep|discard|cleanup ', C.dim]])],
146
- palette: C,
147
- beforeSections: sections,
148
- section: {
149
- title: 'Worktrees',
150
- scrollableLines: this.rows.map((row, absolute) => {
151
- const bg = absolute === this.selectedIndex ? C.headerBg : undefined;
152
- return buildPanelLine(width, [
153
- [` ${row.kind}`.padEnd(14), C.info, bg],
154
- [` ${row.state}`.padEnd(16), stateColor(row.state), bg],
155
- [` ${row.branch}`.padEnd(24), C.value, bg],
156
- [` ${row.path}`.slice(0, Math.max(0, width - 56)), C.dim, bg],
157
- ]);
158
- }),
159
- selectedIndex: this.selectedIndex,
160
- scrollOffset: this.scrollStart,
161
- guardRows: 1,
162
- minRows: 4,
163
- appendWindowSummary: { dimColor: C.dim },
164
- },
165
- afterSections: [detailSection],
166
- });
167
- this.scrollStart = resolvedWorktreesSection.scrollOffset;
168
- sections.push(resolvedWorktreesSection.section);
169
- sections.push(detailSection);
170
- }
171
-
172
- const lines = buildPanelWorkspace(width, height, {
173
- title: 'Worktree Control Room',
174
- intro: 'Orchestrator-owned worktree lifecycle, attachments, pause/resume posture, and cleanup state.',
175
- sections,
176
- footerLines: [buildPanelLine(width, [[' r refresh /worktree inspect <path> /worktree attach|pause|resume|keep|discard|cleanup ', C.dim]])],
177
- palette: C,
178
- });
179
- while (lines.length < height) lines.push(createEmptyLine(width));
180
- return lines.slice(0, height);
181
- }
182
- }