@pellux/goodvibes-tui 0.27.0 → 0.29.0

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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,666 +0,0 @@
1
- import { promises as fsPromises, watch, type FSWatcher } from 'fs';
2
- import type { Line } from '../types/grid.ts';
3
- import { createEmptyLine, createStyledCell } from '../types/grid.ts';
4
- import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
- import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
6
- import type { AgentEvent } from '@/runtime/index.ts';
7
- import type { UiEventFeed } from '../runtime/ui-events.ts';
8
- import {
9
- buildEmptyState,
10
- buildPanelLine,
11
- buildStyledPanelLine,
12
- buildPanelWorkspace,
13
- resolveScrollablePanelSection,
14
- DEFAULT_PANEL_PALETTE,
15
- } from './polish.ts';
16
- import {
17
- type AgentLogEntry as LogEntry,
18
- type AgentLogFilterType as FilterType,
19
- AGENT_LOG_COLORS as COLOR,
20
- AGENT_LOG_FILTER_CYCLE as FILTER_CYCLE,
21
- AGENT_LOG_FILTER_LABELS as FILTER_LABELS,
22
- parseAgentJsonl,
23
- } from './agent-logs-shared.ts';
24
-
25
- // ---------------------------------------------------------------------------
26
- // Constants
27
- // ---------------------------------------------------------------------------
28
-
29
- const POLL_INTERVAL_MS = 500;
30
-
31
- export interface AgentLogsPanelDeps {
32
- readonly agentManager: Pick<AgentManager, 'list'> & Partial<Pick<AgentManager, 'getStatus'>>;
33
- readonly workingDirectory: string;
34
- /**
35
- * Request a compositor repaint. The 500ms poll timer, fs.watch callback and
36
- * other async update paths call this (via markDirty) so new log lines and
37
- * live streaming output are painted while the main thread is idle — render()
38
- * is otherwise only invoked on input/turn/resize, which makes a running
39
- * agent's log tail look frozen. Optional: when omitted the panel still marks
40
- * dirty and repaints on the next event.
41
- */
42
- readonly requestRender?: () => void;
43
- }
44
-
45
-
46
- // ---------------------------------------------------------------------------
47
- // AgentLogsPanel
48
- // ---------------------------------------------------------------------------
49
-
50
- export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
51
- // ── Agent state ─────────────────────────────────────────────────────────
52
- private agents: AgentRecord[] = [];
53
- private selectedAgentIndex = 0;
54
-
55
- // ── Log state ────────────────────────────────────────────────────────────
56
- private allEntries: LogEntry[] = []; // raw parsed JSONL for selected agent
57
- private filteredEntries: LogEntry[] = []; // after filter applied
58
- private lastFileSize = 0;
59
- /**
60
- * Length of the selected agent's live streamingContent buffer at the last
61
- * poll. During an LLM turn the record's streamingContent grows token-by-token
62
- * with NO JSONL append, so the file-size gate alone never repaints the live
63
- * tail; we also watch this length and markDirty when it advances.
64
- */
65
- private lastStreamLen = 0;
66
-
67
- // ── Modes ────────────────────────────────────────────────────────────────
68
- private autoFollow = true;
69
- private paused = false;
70
- private filter: FilterType = 'all';
71
-
72
- /** True while this panel is the active view — gates async repaint requests. */
73
- private _active = false;
74
-
75
- // ── Infrastructure ───────────────────────────────────────────────────────
76
- private pollTimer: ReturnType<typeof setInterval> | null = null;
77
- private fsWatcher: FSWatcher | null = null;
78
- private unsubs: Array<() => void> = [];
79
- private readonly agentEvents: UiEventFeed<AgentEvent>;
80
-
81
- constructor(agentEvents: UiEventFeed<AgentEvent>, private readonly deps: AgentLogsPanelDeps) {
82
- super('agent-logs', 'Agents', 'A', 'agent');
83
- this.showSelectionGutter = true; // I5: non-color selection affordance
84
- this.agentEvents = agentEvents;
85
- this._refreshAgents();
86
- this._startPolling();
87
- this._subscribeEvents();
88
- }
89
-
90
- // ── ScrollableListPanel<LogEntry> contract ────────────────────────────────
91
-
92
- protected getItems(): readonly LogEntry[] {
93
- return this.filteredEntries;
94
- }
95
-
96
- protected renderItem(entry: LogEntry, _index: number, _selected: boolean, width: number): Line {
97
- return this._renderEntry(entry, width);
98
- }
99
-
100
- // ── Lifecycle ─────────────────────────────────────────────────────────────
101
-
102
- override onActivate(): void {
103
- this._active = true;
104
- super.onActivate();
105
- this._refreshAgents();
106
- this._pollCurrentAgent();
107
- }
108
-
109
- override onDeactivate(): void {
110
- this._active = false;
111
- super.onDeactivate();
112
- }
113
-
114
- override onDestroy(): void {
115
- this._stopPolling();
116
- this._unsubscribeEvents();
117
- }
118
-
119
- /**
120
- * T17: a bare markDirty() does not repaint — render() only runs on
121
- * input/turn/resize. While active, ask the compositor for a frame so the
122
- * background poll / fs.watch updates (new log lines, live streaming output)
123
- * are not stuck off-screen. Gated on active state so the always-on poller
124
- * does not force full-frame rebuilds while this panel is hidden.
125
- */
126
- protected override markDirty(): void {
127
- super.markDirty();
128
- if (this._active) this.deps.requestRender?.();
129
- }
130
-
131
- // ── Input ─────────────────────────────────────────────────────────────────
132
-
133
- handleInput(key: string): boolean {
134
- switch (key) {
135
- case 'tab':
136
- case '\t': // Tab — cycle to next agent
137
- this._selectNextAgent();
138
- return true;
139
- case ' ': // Space — pause/resume
140
- this._togglePause();
141
- return true;
142
- case 'f': // f — cycle filter
143
- this._cycleFilter();
144
- return true;
145
- case 'g': // g — jump to top
146
- this.selectedIndex = 0;
147
- this.autoFollow = false;
148
- this.markDirty();
149
- return true;
150
- case 'G': // G — jump to bottom / re-enable auto-follow
151
- this.autoFollow = true;
152
- this._clampScroll();
153
- this.markDirty();
154
- return true;
155
- default:
156
- return super.handleInput(key);
157
- }
158
- }
159
-
160
- // ── Render ────────────────────────────────────────────────────────────────
161
-
162
- render(width: number, height: number): Line[] {
163
- this.needsRender = false;
164
- const footerLines = [
165
- buildPanelLine(width, [
166
- [' Tab', DEFAULT_PANEL_PALETTE.info], [' next agent', DEFAULT_PANEL_PALETTE.dim],
167
- [' Space', DEFAULT_PANEL_PALETTE.info], [' pause', DEFAULT_PANEL_PALETTE.dim],
168
- [' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
169
- [' g/G', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim],
170
- ]),
171
- ];
172
-
173
- const summaryLines = [
174
- buildPanelLine(width, [
175
- [' Agents ', DEFAULT_PANEL_PALETTE.label],
176
- [String(this.agents.length), DEFAULT_PANEL_PALETTE.value],
177
- [' Filter ', DEFAULT_PANEL_PALETTE.label],
178
- [FILTER_LABELS[this.filter], DEFAULT_PANEL_PALETTE.info],
179
- [' Mode ', DEFAULT_PANEL_PALETTE.label],
180
- [this.paused ? 'paused' : this.autoFollow ? 'auto-follow' : 'manual', this.paused ? DEFAULT_PANEL_PALETTE.warn : this.autoFollow ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
181
- ]),
182
- ];
183
-
184
- if (this.agents.length === 0) {
185
- return buildPanelWorkspace(width, height, {
186
- title: ' Agents',
187
- intro: 'View-only live session stream for running agents, with per-agent switching and filtered event tails.',
188
- sections: [
189
- { title: 'Summary', lines: summaryLines },
190
- {
191
- lines: buildEmptyState(
192
- width,
193
- ' No agents running',
194
- 'Spawn or attach to an agent session and its structured logs will appear here.',
195
- [],
196
- DEFAULT_PANEL_PALETTE,
197
- ),
198
- },
199
- ],
200
- footerLines,
201
- palette: DEFAULT_PANEL_PALETTE,
202
- });
203
- }
204
-
205
- const selectedAgent = this._selectedAgent();
206
- const selectorLine = this._renderAgentSelector(width);
207
- if (selectedAgent) {
208
- summaryLines.push(buildPanelLine(width, [
209
- [' Selected ', DEFAULT_PANEL_PALETTE.label],
210
- [selectedAgent.id, DEFAULT_PANEL_PALETTE.info],
211
- [' Status ', DEFAULT_PANEL_PALETTE.label],
212
- [selectedAgent.status, selectedAgent.status === 'running' ? DEFAULT_PANEL_PALETTE.good : selectedAgent.status === 'failed' ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.dim],
213
- ]));
214
- }
215
-
216
- // T13: surface the selected agent's live streaming output (running) or
217
- // final output (terminal) — the per-turn JSONL tail never carries it. Pull
218
- // the freshest record via getStatus so the streaming buffer reflects the
219
- // current turn; the list() snapshot in this.agents only refreshes on
220
- // spawn/complete/fail events, never mid-stream.
221
- const liveAgent = selectedAgent
222
- ? (this.deps.agentManager.getStatus?.(selectedAgent.id) ?? selectedAgent)
223
- : null;
224
- const liveLines = liveAgent ? this._buildLiveOutputLines(width, liveAgent) : [];
225
-
226
- if (this.filteredEntries.length === 0 && liveLines.length === 0) {
227
- return buildPanelWorkspace(width, height, {
228
- title: ' Agents',
229
- intro: 'View-only live session stream for running agents, with per-agent switching and filtered event tails.',
230
- sections: [
231
- { title: 'Summary', lines: summaryLines },
232
- { title: 'Agents', lines: [selectorLine] },
233
- {
234
- lines: buildEmptyState(
235
- width,
236
- ` No ${this.filter === 'all' ? '' : `${this.filter} `}log entries yet`,
237
- 'Once the selected agent writes session events, they will appear here and can be filtered by type.',
238
- [],
239
- DEFAULT_PANEL_PALETTE,
240
- ),
241
- },
242
- ],
243
- footerLines,
244
- palette: DEFAULT_PANEL_PALETTE,
245
- });
246
- }
247
-
248
- const scrollableLines = [
249
- ...this.filteredEntries.map((entry) => this._renderEntry(entry, width)),
250
- ...liveLines,
251
- ];
252
- const focusIndex = this.autoFollow
253
- ? Math.max(0, scrollableLines.length - 1)
254
- : Math.min(this.selectedIndex, Math.max(0, scrollableLines.length - 1));
255
- const summarySection = { title: 'Summary', lines: summaryLines } as const;
256
- const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
257
- const logStreamSection = resolveScrollablePanelSection(width, height, {
258
- intro: 'Tail per-agent JSONL session logs, filter entries, and switch between running or completed agents.',
259
- footerLines,
260
- palette: DEFAULT_PANEL_PALETTE,
261
- beforeSections: [summarySection, agentsSection],
262
- section: {
263
- title: 'Log Stream',
264
- scrollableLines,
265
- selectedIndex: focusIndex,
266
- scrollOffset: this.scrollStart,
267
- minRows: 8,
268
- },
269
- });
270
- this.scrollStart = logStreamSection.scrollOffset;
271
-
272
- return buildPanelWorkspace(width, height, {
273
- title: ' Agents',
274
- intro: 'View-only live session stream for running agents, with per-agent switching and filtered event tails.',
275
- sections: [
276
- summarySection,
277
- agentsSection,
278
- logStreamSection.section,
279
- ],
280
- footerLines,
281
- palette: DEFAULT_PANEL_PALETTE,
282
- });
283
- }
284
-
285
- // ── Private: polling ─────────────────────────────────────────────────────
286
-
287
- private _startPolling(): void {
288
- if (this.pollTimer !== null) return;
289
- this.pollTimer = setInterval(() => {
290
- if (!this.paused) {
291
- this._pollCurrentAgent();
292
- }
293
- }, POLL_INTERVAL_MS);
294
- // Also do an immediate read
295
- this._pollCurrentAgent();
296
- }
297
-
298
- private _stopPolling(): void {
299
- if (this.pollTimer !== null) {
300
- clearInterval(this.pollTimer);
301
- this.pollTimer = null;
302
- }
303
- this._stopWatcher();
304
- }
305
-
306
- private _pollCurrentAgent(): void {
307
- void this._pollCurrentAgentAsync();
308
- }
309
-
310
- private async _pollCurrentAgentAsync(): Promise<void> {
311
- const agent = this._selectedAgent();
312
- if (!agent) return;
313
-
314
- // Detect live streaming growth even when no JSONL is written this tick.
315
- // During an LLM turn the record's streamingContent grows token-by-token
316
- // with no session-log append, so the file-size gate below would never fire
317
- // and the live tail (_buildLiveOutputLines) would never repaint.
318
- let streamChanged = false;
319
- const live = this.deps.agentManager.getStatus?.(agent.id);
320
- if (live) {
321
- const streamLen = (live.streamingContent ?? '').length;
322
- if (streamLen !== this.lastStreamLen) {
323
- this.lastStreamLen = streamLen;
324
- streamChanged = true;
325
- }
326
- }
327
-
328
- const sessionFile = this._sessionFilePath(agent.id);
329
- let fileExists = true;
330
- try {
331
- await fsPromises.access(sessionFile);
332
- } catch {
333
- fileExists = false;
334
- }
335
-
336
- if (fileExists) {
337
- try {
338
- const content = await fsPromises.readFile(sessionFile, 'utf-8');
339
- if (content.length !== this.lastFileSize) {
340
- this.lastFileSize = content.length;
341
-
342
- // Re-parse all lines (simple: no partial-line tracking needed at 500ms)
343
- this.allEntries = parseAgentJsonl(content);
344
- this._applyFilter();
345
- if (this.autoFollow) {
346
- this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
347
- }
348
- this.markDirty();
349
- return;
350
- }
351
- } catch {
352
- // Non-fatal: file may be mid-write
353
- }
354
- }
355
-
356
- // File unchanged or absent, but the live stream advanced — repaint the tail.
357
- if (streamChanged) this.markDirty();
358
- }
359
-
360
- // ── Private: fs.watch (supplemental) ─────────────────────────────────────
361
-
362
- private _watchAgent(agentId: string): void {
363
- this._stopWatcher();
364
- const sessionFile = this._sessionFilePath(agentId);
365
- // Start watching immediately; the watcher setup itself is synchronous,
366
- // the file-existence check is skipped to avoid blocking — if the file
367
- // does not yet exist watch() will throw and we catch it below.
368
- try {
369
- this.fsWatcher = watch(sessionFile, () => {
370
- if (!this.paused) {
371
- this._pollCurrentAgent();
372
- }
373
- });
374
- } catch {
375
- // Non-fatal: polling covers us
376
- }
377
- }
378
-
379
- private _stopWatcher(): void {
380
- if (this.fsWatcher) {
381
- try { this.fsWatcher.close(); } catch { /* ignore */ }
382
- this.fsWatcher = null;
383
- }
384
- }
385
-
386
- // ── Private: event subscriptions ─────────────────────────────────────────
387
-
388
- private _subscribeEvents(): void {
389
- const onSpawned = (data: { id: string; task: string }) => {
390
- void data;
391
- this._refreshAgents();
392
- // Auto-select the newest agent if none selected or all done
393
- const running = this.agents.filter(a => a.status === 'running' || a.status === 'pending');
394
- if (running.length === 1) {
395
- const idx = this.agents.findIndex(a => a.id === running[0]!.id);
396
- if (idx >= 0) this._selectAgent(idx);
397
- }
398
- this.markDirty();
399
- };
400
-
401
- const onComplete = (data: { id: string }) => {
402
- void data;
403
- this._refreshAgents();
404
- this.markDirty();
405
- };
406
-
407
- const onError = (data: { id: string; error: Error }) => {
408
- void data;
409
- this._refreshAgents();
410
- this.markDirty();
411
- };
412
-
413
- this.unsubs.push(
414
- this.agentEvents.on('AGENT_SPAWNING', (payload) => onSpawned({ id: payload.agentId, task: payload.task })),
415
- this.agentEvents.on('AGENT_COMPLETED', (payload) => onComplete({ id: payload.agentId })),
416
- this.agentEvents.on('AGENT_FAILED', (payload) => onError({ id: payload.agentId, error: new Error(payload.error) })),
417
- );
418
- }
419
-
420
- private _unsubscribeEvents(): void {
421
- for (const unsub of this.unsubs) unsub();
422
- this.unsubs = [];
423
- }
424
-
425
- // ── Private: agent management ─────────────────────────────────────────────
426
-
427
- private _refreshAgents(): void {
428
- const prev = this._selectedAgent();
429
- this.agents = this.deps.agentManager.list()
430
- .sort((a, b) => b.startedAt - a.startedAt); // newest first
431
-
432
- // Try to keep same agent selected
433
- if (prev) {
434
- const idx = this.agents.findIndex(a => a.id === prev.id);
435
- this.selectedAgentIndex = idx >= 0 ? idx : 0;
436
- } else {
437
- this.selectedAgentIndex = 0;
438
- }
439
-
440
- // Re-watch if agent changed
441
- const current = this._selectedAgent();
442
- if (current) {
443
- this._watchAgent(current.id);
444
- this._reloadAgent(current);
445
- }
446
- }
447
-
448
- private _selectAgent(index: number): void {
449
- if (index < 0 || index >= this.agents.length) return;
450
- this.selectedAgentIndex = index;
451
- this.allEntries = [];
452
- this.filteredEntries = [];
453
- this.lastFileSize = 0;
454
- this.lastStreamLen = 0;
455
- this.selectedIndex = 0;
456
- this.scrollStart = 0;
457
- this.autoFollow = true;
458
- const agent = this._selectedAgent();
459
- if (agent) {
460
- this._watchAgent(agent.id);
461
- this._reloadAgent(agent);
462
- }
463
- this.markDirty();
464
- }
465
-
466
- private _selectNextAgent(): void {
467
- if (this.agents.length === 0) return;
468
- this._selectAgent((this.selectedAgentIndex + 1) % this.agents.length);
469
- }
470
-
471
- private _selectedAgent(): AgentRecord | null {
472
- return this.agents[this.selectedAgentIndex] ?? null;
473
- }
474
-
475
- private _reloadAgent(agent: AgentRecord): void {
476
- void this._reloadAgentAsync(agent);
477
- }
478
-
479
- private async _reloadAgentAsync(agent: AgentRecord): Promise<void> {
480
- const sessionFile = this._sessionFilePath(agent.id);
481
- try {
482
- await fsPromises.access(sessionFile);
483
- } catch {
484
- this.allEntries = [];
485
- this.filteredEntries = [];
486
- this.lastFileSize = 0;
487
- this.lastStreamLen = 0;
488
- this.markDirty();
489
- return;
490
- }
491
- try {
492
- const content = await fsPromises.readFile(sessionFile, 'utf-8');
493
- this.lastFileSize = content.length;
494
- this.allEntries = parseAgentJsonl(content);
495
- this._applyFilter();
496
- if (this.autoFollow) {
497
- this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
498
- }
499
- this.markDirty();
500
- } catch {
501
- this.allEntries = [];
502
- this.filteredEntries = [];
503
- this.markDirty();
504
- }
505
- }
506
-
507
- private _sessionFilePath(agentId: string): string {
508
- return `${this.deps.workingDirectory}/.goodvibes/tui/sessions/${agentId}.jsonl`;
509
- }
510
-
511
- // ── Private: filter ───────────────────────────────────────────────────────
512
-
513
- private _applyFilter(): void {
514
- if (this.filter === 'all') {
515
- this.filteredEntries = [...this.allEntries];
516
- return;
517
- }
518
- this.filteredEntries = this.allEntries.filter(e => {
519
- if (this.filter === 'assistant') return e.type === 'assistant';
520
- if (this.filter === 'tool') return e.type === 'tool';
521
- if (this.filter === 'error') return e.type === 'error';
522
- return true;
523
- });
524
- }
525
-
526
- private _cycleFilter(): void {
527
- const idx = FILTER_CYCLE.indexOf(this.filter);
528
- this.filter = FILTER_CYCLE[(idx + 1) % FILTER_CYCLE.length]!;
529
- this._applyFilter();
530
- if (this.autoFollow) {
531
- this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
532
- }
533
- this.markDirty();
534
- }
535
-
536
- private _togglePause(): void {
537
- this.paused = !this.paused;
538
- this.markDirty();
539
- }
540
-
541
- private _clampScroll(): void {
542
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
543
- }
544
-
545
- // ── Private: rendering helpers ─────────────────────────────────────────────
546
-
547
- /** Top header bar: title + filter label + mode indicators */
548
- private _renderHeader(width: number): Line {
549
- const title = ' Agent Logs ';
550
- const filterLabel = `[${FILTER_LABELS[this.filter]}] `;
551
- const pause = this.paused ? ' PAUSED ' : '';
552
- const follow = this.autoFollow ? ' AUTO-FOLLOW ' : '';
553
- const keyhints = ' Tab:next Space:pause f:filter g/G:scroll ';
554
- return buildStyledPanelLine(width, [
555
- { text: title, fg: COLOR.header_accent, bold: true },
556
- { text: filterLabel, fg: COLOR.filter_active },
557
- { text: pause, fg: COLOR.paused },
558
- { text: follow, fg: COLOR.auto_follow },
559
- { text: keyhints, fg: COLOR.header_label },
560
- ]);
561
- }
562
-
563
- /** Agent selector bar: shows running agents with cycle indicator */
564
- private _renderAgentSelector(width: number): Line {
565
- const prefix = ' Agents: ';
566
- const segments: Array<{ text: string; fg: string; bold?: boolean }> = [
567
- { text: prefix, fg: COLOR.header_label },
568
- ];
569
- for (let i = 0; i < this.agents.length; i++) {
570
- const agent = this.agents[i]!;
571
- const isSelected = i === this.selectedAgentIndex;
572
- const statusColor = this._agentStatusColor(agent.status);
573
- const shortId = agent.id.replace('agent-', '');
574
- const label = isSelected
575
- ? `[${shortId}:${agent.status}] `
576
- : `${shortId}:${agent.status} `;
577
- segments.push({
578
- text: label,
579
- fg: isSelected ? COLOR.agent_selected : statusColor,
580
- bold: isSelected,
581
- });
582
- }
583
- return buildStyledPanelLine(width, segments);
584
- }
585
-
586
- private _renderNoAgents(width: number): Line {
587
- const msg = ' No agents running. ';
588
- return buildStyledPanelLine(width, [{ text: msg, fg: COLOR.dim }]);
589
- }
590
-
591
- private _renderSeparator(width: number): Line {
592
- return buildStyledPanelLine(width, [{ text: '─'.repeat(width), fg: COLOR.separator }]);
593
- }
594
-
595
- private _renderEmpty(width: number, bodyHeight: number): Line[] {
596
- const lines: Line[] = [];
597
- const msg = this.agents.length === 0
598
- ? ' No agents running '
599
- : ` No ${this.filter === 'all' ? '' : this.filter + ' '}log entries yet `;
600
- const offset = Math.max(0, Math.floor((width - msg.length) / 2));
601
- const textLine = buildStyledPanelLine(width, [
602
- { text: ' '.repeat(offset), fg: COLOR.dim },
603
- { text: msg, fg: COLOR.dim },
604
- ]);
605
- lines.push(textLine);
606
- while (lines.length < bodyHeight) {
607
- lines.push(createEmptyLine(width));
608
- }
609
- return lines;
610
- }
611
-
612
- private _renderEntry(entry: LogEntry, width: number): Line {
613
- // Indent non-session entries
614
- const prefix = entry.type === 'session_start' ? '' : ' ';
615
- const fullText = prefix + entry.text;
616
- return buildStyledPanelLine(width, [{ text: fullText, fg: entry.color, bold: entry.bold }]);
617
- }
618
-
619
- /**
620
- * Build trailing lines exposing the selected agent's live model output. The
621
- * JSONL tail is written per-turn, so while an agent is mid-turn its actual
622
- * work is invisible here; this surfaces the streaming token buffer
623
- * (tail-truncated like AgentDetailModal) for a running agent, and the final
624
- * assistant text once it terminates. Empty when there is nothing live to show.
625
- */
626
- private _buildLiveOutputLines(width: number, agent: AgentRecord): Line[] {
627
- const STREAM_MAX_CHARS = 500;
628
- if (agent.status === 'running' && agent.streamingContent) {
629
- const sc = agent.streamingContent;
630
- const truncated = sc.length > STREAM_MAX_CHARS;
631
- const display = truncated ? sc.slice(-STREAM_MAX_CHARS) : sc;
632
- const header = truncated
633
- ? ` streaming (last ${STREAM_MAX_CHARS} of ${sc.length} chars):`
634
- : ' streaming:';
635
- const lines: Line[] = [
636
- buildStyledPanelLine(width, [{ text: header, fg: COLOR.auto_follow, bold: true }]),
637
- ];
638
- for (const raw of display.split('\n')) {
639
- lines.push(buildStyledPanelLine(width, [{ text: ` ${raw}`, fg: COLOR.assistant }]));
640
- }
641
- return lines;
642
- }
643
- if (agent.status !== 'running' && agent.status !== 'pending' && agent.fullOutput) {
644
- const fo = agent.fullOutput;
645
- const lines: Line[] = [
646
- buildStyledPanelLine(width, [{ text: ' output:', fg: COLOR.header_accent, bold: true }]),
647
- ];
648
- for (const raw of fo.split('\n')) {
649
- lines.push(buildStyledPanelLine(width, [{ text: ` ${raw}`, fg: COLOR.assistant }]));
650
- }
651
- return lines;
652
- }
653
- return [];
654
- }
655
-
656
- private _agentStatusColor(status: AgentRecord['status']): string {
657
- switch (status) {
658
- case 'running': return COLOR.agent_running;
659
- case 'pending': return COLOR.agent_pending;
660
- case 'completed': return COLOR.agent_done;
661
- case 'failed': return COLOR.agent_error;
662
- case 'cancelled': return COLOR.agent_done;
663
- default: return COLOR.dim;
664
- }
665
- }
666
- }