@pellux/goodvibes-tui 0.26.0 → 0.28.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -77,6 +77,8 @@ const COLOR = {
77
77
  selected: '#ffee88',
78
78
  dimmed: '#555566',
79
79
  expandHint: '#445566',
80
+ stalled: '#f59e0b', // amber — stalled-agent warning
81
+ cursorBg: '#1a2233', // timeline cursor row background
80
82
  } as const;
81
83
 
82
84
  // ---------------------------------------------------------------------------
@@ -91,6 +93,15 @@ export interface AgentInspectorPanelDeps {
91
93
  readonly workingDirectory: string;
92
94
  /** Cancel the agent by id. Uses the same orphan-free path as WRFC. Returns true if cancelled. */
93
95
  readonly cancelAgent: (agentId: string) => boolean;
96
+ /**
97
+ * Request a compositor repaint. The 500ms refresh timer and other async
98
+ * update paths call this (via markDirty) so live agent output is painted
99
+ * while the main thread is idle — render() is otherwise only invoked on
100
+ * input/turn/resize, which makes a running agent's timeline look frozen.
101
+ * Optional: when omitted the panel still marks dirty and repaints on the
102
+ * next event.
103
+ */
104
+ readonly requestRender?: () => void;
94
105
  }
95
106
 
96
107
  export class AgentInspectorPanel extends BasePanel {
@@ -115,6 +126,9 @@ export class AgentInspectorPanel extends BasePanel {
115
126
  /** Pending cancel confirmation — subject is the agent id to cancel. */
116
127
  private confirmCancel: ConfirmState<string> | null = null;
117
128
 
129
+ /** True while this panel is the active view — gates async repaint requests. */
130
+ private _active = false;
131
+
118
132
  constructor(private readonly deps: AgentInspectorPanelDeps) {
119
133
  super('inspector', 'Inspector', 'I', 'agent');
120
134
  }
@@ -131,6 +145,10 @@ export class AgentInspectorPanel extends BasePanel {
131
145
  override markDirty(): void {
132
146
  this._cachedRows = null;
133
147
  super.markDirty();
148
+ // T17: a bare markDirty() does not repaint — render() only runs on
149
+ // input/turn/resize. While active, ask the compositor for a frame so
150
+ // timer/async refreshes (live streaming output) are not stuck off-screen.
151
+ if (this._active) this.deps.requestRender?.();
134
152
  }
135
153
 
136
154
  // -------------------------------------------------------------------------
@@ -152,11 +170,13 @@ export class AgentInspectorPanel extends BasePanel {
152
170
  // -------------------------------------------------------------------------
153
171
 
154
172
  override onActivate(): void {
173
+ this._active = true;
155
174
  this.needsRender = true;
156
175
  this._startRefresh();
157
176
  }
158
177
 
159
178
  override onDeactivate(): void {
179
+ this._active = false;
160
180
  this._stopRefresh();
161
181
  }
162
182
 
@@ -238,9 +258,11 @@ export class AgentInspectorPanel extends BasePanel {
238
258
  width,
239
259
  agents.length === 0 ? ' No agents running' : ' No agent selected',
240
260
  agents.length === 0
241
- ? 'Spawn an agent to inspect its live and historical timeline.'
261
+ ? 'Spawn an agent to inspect its live and historical timeline, tool calls, and output.'
242
262
  : 'Press Tab to cycle through available agents and open one in the inspector.',
243
- [],
263
+ agents.length === 0
264
+ ? [{ command: '/spawn <task>', summary: 'launch an agent, then Tab here to inspect its timeline' }]
265
+ : [{ command: 'Tab', summary: 'select the first available agent' }],
244
266
  DEFAULT_PANEL_PALETTE,
245
267
  ),
246
268
  },
@@ -256,7 +278,7 @@ export class AgentInspectorPanel extends BasePanel {
256
278
  const now = Date.now();
257
279
  const isStalled = this._isAgentStalled(rec, now);
258
280
  if (isStalled) {
259
- summaryLines.push(buildPanelLine(width, [[' STALLED', '#f59e0b'], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
281
+ summaryLines.push(buildPanelLine(width, [[' STALLED', COLOR.stalled], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
260
282
  }
261
283
  const allRows = this._getCachedRows();
262
284
  if (allRows.length === 0) {
@@ -270,8 +292,12 @@ export class AgentInspectorPanel extends BasePanel {
270
292
  lines: buildEmptyState(
271
293
  width,
272
294
  ' No messages yet',
273
- 'The selected agent has not emitted any visible timeline entries yet.',
274
- [],
295
+ rec.status === 'running'
296
+ ? 'The selected agent is running but has not emitted visible timeline entries yet — live output streams in as it works.'
297
+ : 'The selected agent has not emitted any visible timeline entries.',
298
+ rec.status === 'running'
299
+ ? [{ command: 'c', summary: 'cancel this running agent if it appears stalled' }]
300
+ : [{ command: 'Tab', summary: 'cycle to another agent with timeline activity' }],
275
301
  DEFAULT_PANEL_PALETTE,
276
302
  ),
277
303
  },
@@ -379,9 +405,7 @@ export class AgentInspectorPanel extends BasePanel {
379
405
  const elapsed = (rec.completedAt ?? now) - rec.startedAt;
380
406
  const taskPreview = rec.task.split('\n')[0] ?? '';
381
407
  const maxTask = Math.max(0, width - 40);
382
- const taskDisplay = taskPreview.length > maxTask
383
- ? taskPreview.slice(0, maxTask - 1) + '\u2026'
384
- : taskPreview;
408
+ const taskDisplay = truncateDisplay(taskPreview, maxTask);
385
409
  return buildPanelLine(width, [
386
410
  [' Status ', DEFAULT_PANEL_PALETTE.label],
387
411
  [rec.status.toUpperCase(), rec.status === 'running' ? DEFAULT_PANEL_PALETTE.good : rec.status === 'failed' ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.dim],
@@ -407,7 +431,7 @@ export class AgentInspectorPanel extends BasePanel {
407
431
  row: DisplayRow,
408
432
  isCursor: boolean,
409
433
  ): Line {
410
- const bg = isCursor ? '#1a2233' : '';
434
+ const bg = isCursor ? COLOR.cursorBg : '';
411
435
  const ts = shortTime(row.timestamp);
412
436
  const { fg, prefix } = agentKindStyle(row.kind, COLOR);
413
437
  const hint = row.hasDetail ? (row.expanded ? ' ▾' : ' ▸') : '';
@@ -441,8 +465,21 @@ export class AgentInspectorPanel extends BasePanel {
441
465
 
442
466
  // Merge bus messages (live) + JSONL (historical), sorted by timestamp
443
467
  const busEntries = this._busToTimeline();
444
- const merged = [...this.timeline, ...busEntries]
445
- .sort((a, b) => a.timestamp - b.timestamp);
468
+ const merged = [...this.timeline, ...busEntries];
469
+
470
+ // T13: the JSONL timeline only records coarse per-turn summaries
471
+ // ([assistant] N chars, M tool calls) and never the model's actual text,
472
+ // so a mid-turn agent looks idle here. Surface the selected agent's live
473
+ // streaming output while it runs, and its final output once terminal.
474
+ const liveRec = this.selectedAgentId
475
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
476
+ : null;
477
+ if (liveRec) {
478
+ const liveEntry = this._buildLiveOutputEntry(liveRec);
479
+ if (liveEntry) merged.push(liveEntry);
480
+ }
481
+
482
+ merged.sort((a, b) => a.timestamp - b.timestamp);
446
483
 
447
484
  // Deduplicate: bus messages that already appear in JSONL will have
448
485
  // approximate timestamps. We just show all — bus msgs tend to have
@@ -496,15 +533,52 @@ export class AgentInspectorPanel extends BasePanel {
496
533
  kind,
497
534
  timestamp: msg.timestamp,
498
535
  label: msg.from,
499
- content: msg.content.length > 200
500
- ? msg.content.slice(0, 197) + '\u2026'
501
- : msg.content,
536
+ content: truncateDisplay(msg.content, 200),
502
537
  expanded: false,
503
538
  } satisfies TimelineEntry);
504
539
  }
505
540
  return result;
506
541
  }
507
542
 
543
+ /**
544
+ * Build a synthetic timeline entry exposing the selected agent's live model
545
+ * output. While the agent runs this is the streaming token buffer
546
+ * (tail-truncated like AgentDetailModal); once it terminates it is the final
547
+ * assistant text (expandable). Exactly one is produced — the SDK clears
548
+ * streamingContent on completion — so live and final rows never coexist.
549
+ * Returns null when there is nothing to show.
550
+ */
551
+ private _buildLiveOutputEntry(rec: AgentRecord): TimelineEntry | null {
552
+ const STREAM_MAX_CHARS = 500;
553
+ if (rec.status === 'running' && rec.streamingContent) {
554
+ const sc = rec.streamingContent;
555
+ const truncated = sc.length > STREAM_MAX_CHARS;
556
+ const tail = (truncated ? sc.slice(-STREAM_MAX_CHARS) : sc)
557
+ .replace(/\s+/g, ' ')
558
+ .trim();
559
+ return {
560
+ kind: 'assistant',
561
+ timestamp: Date.now(),
562
+ label: 'streaming',
563
+ content: `(live) ${truncated ? '…' : ''}${tail}`,
564
+ expanded: false,
565
+ } satisfies TimelineEntry;
566
+ }
567
+ if (AGENT_TERMINAL_STATUSES.has(rec.status) && rec.fullOutput) {
568
+ const fo = rec.fullOutput;
569
+ const firstLine = (fo.split('\n').find((l) => l.trim().length > 0) ?? '').trim();
570
+ return {
571
+ kind: 'assistant',
572
+ timestamp: rec.completedAt ?? Date.now(),
573
+ label: 'output',
574
+ content: `(final) ${firstLine}`,
575
+ detail: fo,
576
+ expanded: false,
577
+ } satisfies TimelineEntry;
578
+ }
579
+ return null;
580
+ }
581
+
508
582
  // -------------------------------------------------------------------------
509
583
  // Private — refresh
510
584
  // -------------------------------------------------------------------------
@@ -599,7 +673,8 @@ export class AgentInspectorPanel extends BasePanel {
599
673
  if (!this.selectedAgentId) return;
600
674
  const rec = this.deps.agentManager.getStatus(this.selectedAgentId);
601
675
  if (!rec || AGENT_TERMINAL_STATUSES.has(rec.status)) return;
602
- const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
676
+ const firstLine = rec.task.split('\n')[0];
677
+ const label = firstLine ? truncateDisplay(firstLine, 40) : rec.id.slice(-8);
603
678
  this.confirmCancel = { subject: rec.id, label };
604
679
  this.markDirty();
605
680
  }
@@ -1,3 +1,5 @@
1
+ import { formatDuration } from '../utils/format-duration.ts';
2
+
1
3
  export type AgentInspectorEntryKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'session' | 'error';
2
4
 
3
5
  // ---------------------------------------------------------------------------
@@ -61,9 +63,7 @@ export function agentStatusColor(status: string, colors: Record<string, string>)
61
63
  }
62
64
 
63
65
  export function formatAgentDuration(ms: number): string {
64
- if (ms < 1000) return `${ms}ms`;
65
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
66
- return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
66
+ return formatDuration(ms);
67
67
  }
68
68
 
69
69
  export function formatAgentTime(ts: number): string {
@@ -1,6 +1,5 @@
1
1
  import { promises as fsPromises, watch, type FSWatcher } from 'fs';
2
2
  import type { Line } from '../types/grid.ts';
3
- import { createEmptyLine, createStyledCell } from '../types/grid.ts';
4
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
4
  import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
6
5
  import type { AgentEvent } from '@/runtime/index.ts';
@@ -29,8 +28,17 @@ import {
29
28
  const POLL_INTERVAL_MS = 500;
30
29
 
31
30
  export interface AgentLogsPanelDeps {
32
- readonly agentManager: Pick<AgentManager, 'list'>;
31
+ readonly agentManager: Pick<AgentManager, 'list'> & Partial<Pick<AgentManager, 'getStatus'>>;
33
32
  readonly workingDirectory: string;
33
+ /**
34
+ * Request a compositor repaint. The 500ms poll timer, fs.watch callback and
35
+ * other async update paths call this (via markDirty) so new log lines and
36
+ * live streaming output are painted while the main thread is idle — render()
37
+ * is otherwise only invoked on input/turn/resize, which makes a running
38
+ * agent's log tail look frozen. Optional: when omitted the panel still marks
39
+ * dirty and repaints on the next event.
40
+ */
41
+ readonly requestRender?: () => void;
34
42
  }
35
43
 
36
44
 
@@ -47,12 +55,22 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
47
55
  private allEntries: LogEntry[] = []; // raw parsed JSONL for selected agent
48
56
  private filteredEntries: LogEntry[] = []; // after filter applied
49
57
  private lastFileSize = 0;
58
+ /**
59
+ * Length of the selected agent's live streamingContent buffer at the last
60
+ * poll. During an LLM turn the record's streamingContent grows token-by-token
61
+ * with NO JSONL append, so the file-size gate alone never repaints the live
62
+ * tail; we also watch this length and markDirty when it advances.
63
+ */
64
+ private lastStreamLen = 0;
50
65
 
51
66
  // ── Modes ────────────────────────────────────────────────────────────────
52
67
  private autoFollow = true;
53
68
  private paused = false;
54
69
  private filter: FilterType = 'all';
55
70
 
71
+ /** True while this panel is the active view — gates async repaint requests. */
72
+ private _active = false;
73
+
56
74
  // ── Infrastructure ───────────────────────────────────────────────────────
57
75
  private pollTimer: ReturnType<typeof setInterval> | null = null;
58
76
  private fsWatcher: FSWatcher | null = null;
@@ -81,12 +99,14 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
81
99
  // ── Lifecycle ─────────────────────────────────────────────────────────────
82
100
 
83
101
  override onActivate(): void {
102
+ this._active = true;
84
103
  super.onActivate();
85
104
  this._refreshAgents();
86
105
  this._pollCurrentAgent();
87
106
  }
88
107
 
89
108
  override onDeactivate(): void {
109
+ this._active = false;
90
110
  super.onDeactivate();
91
111
  }
92
112
 
@@ -95,6 +115,18 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
95
115
  this._unsubscribeEvents();
96
116
  }
97
117
 
118
+ /**
119
+ * T17: a bare markDirty() does not repaint — render() only runs on
120
+ * input/turn/resize. While active, ask the compositor for a frame so the
121
+ * background poll / fs.watch updates (new log lines, live streaming output)
122
+ * are not stuck off-screen. Gated on active state so the always-on poller
123
+ * does not force full-frame rebuilds while this panel is hidden.
124
+ */
125
+ protected override markDirty(): void {
126
+ super.markDirty();
127
+ if (this._active) this.deps.requestRender?.();
128
+ }
129
+
98
130
  // ── Input ─────────────────────────────────────────────────────────────────
99
131
 
100
132
  handleInput(key: string): boolean {
@@ -128,19 +160,28 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
128
160
 
129
161
  render(width: number, height: number): Line[] {
130
162
  this.needsRender = false;
131
- const footerLines = [
132
- buildPanelLine(width, [
133
- [' Tab', DEFAULT_PANEL_PALETTE.info], [' next agent', DEFAULT_PANEL_PALETTE.dim],
134
- [' Space', DEFAULT_PANEL_PALETTE.info], [' pause', DEFAULT_PANEL_PALETTE.dim],
135
- [' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
136
- [' g/G', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim],
137
- ]),
138
- ];
163
+ // Context-aware footer: when there are no agents only the Tab cycle is
164
+ // meaningless, so hide the per-stream keys; otherwise reflect live state
165
+ // (Space toggles pause→resume, g/G only matter once there is a tail).
166
+ const runningCount = this.agents.filter((a) => a.status === 'running' || a.status === 'pending').length;
167
+ const footerLines = this.agents.length === 0
168
+ ? [buildPanelLine(width, [[' Waiting for an agent session to start...', DEFAULT_PANEL_PALETTE.dim]])]
169
+ : [
170
+ buildPanelLine(width, [
171
+ [` ${this.selectedAgentIndex + 1}/${this.agents.length}`, DEFAULT_PANEL_PALETTE.dim],
172
+ [' Tab', DEFAULT_PANEL_PALETTE.info], [' next agent', DEFAULT_PANEL_PALETTE.dim],
173
+ [' Space', DEFAULT_PANEL_PALETTE.info], [this.paused ? ' resume' : ' pause', DEFAULT_PANEL_PALETTE.dim],
174
+ [' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
175
+ [' g/G', DEFAULT_PANEL_PALETTE.info], [this.autoFollow ? ' top/follow' : ' top/bottom', DEFAULT_PANEL_PALETTE.dim],
176
+ ]),
177
+ ];
139
178
 
140
179
  const summaryLines = [
141
180
  buildPanelLine(width, [
142
181
  [' Agents ', DEFAULT_PANEL_PALETTE.label],
143
182
  [String(this.agents.length), DEFAULT_PANEL_PALETTE.value],
183
+ [' Running ', DEFAULT_PANEL_PALETTE.label],
184
+ [String(runningCount), runningCount > 0 ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
144
185
  [' Filter ', DEFAULT_PANEL_PALETTE.label],
145
186
  [FILTER_LABELS[this.filter], DEFAULT_PANEL_PALETTE.info],
146
187
  [' Mode ', DEFAULT_PANEL_PALETTE.label],
@@ -158,8 +199,11 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
158
199
  lines: buildEmptyState(
159
200
  width,
160
201
  ' No agents running',
161
- 'Spawn or attach to an agent session and its structured logs will appear here.',
162
- [],
202
+ 'Spawn or attach to an agent session and its structured logs stream here in real time.',
203
+ [
204
+ { command: '/spawn <task>', summary: 'launch an agent and watch its live session log here' },
205
+ { command: '/inspector', summary: 'open the inspector for a per-agent timeline and tool activity' },
206
+ ],
163
207
  DEFAULT_PANEL_PALETTE,
164
208
  ),
165
209
  },
@@ -180,7 +224,17 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
180
224
  ]));
181
225
  }
182
226
 
183
- if (this.filteredEntries.length === 0) {
227
+ // T13: surface the selected agent's live streaming output (running) or
228
+ // final output (terminal) — the per-turn JSONL tail never carries it. Pull
229
+ // the freshest record via getStatus so the streaming buffer reflects the
230
+ // current turn; the list() snapshot in this.agents only refreshes on
231
+ // spawn/complete/fail events, never mid-stream.
232
+ const liveAgent = selectedAgent
233
+ ? (this.deps.agentManager.getStatus?.(selectedAgent.id) ?? selectedAgent)
234
+ : null;
235
+ const liveLines = liveAgent ? this._buildLiveOutputLines(width, liveAgent) : [];
236
+
237
+ if (this.filteredEntries.length === 0 && liveLines.length === 0) {
184
238
  return buildPanelWorkspace(width, height, {
185
239
  title: ' Agents',
186
240
  intro: 'View-only live session stream for running agents, with per-agent switching and filtered event tails.',
@@ -202,9 +256,13 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
202
256
  });
203
257
  }
204
258
 
259
+ const scrollableLines = [
260
+ ...this.filteredEntries.map((entry) => this._renderEntry(entry, width)),
261
+ ...liveLines,
262
+ ];
205
263
  const focusIndex = this.autoFollow
206
- ? Math.max(0, this.filteredEntries.length - 1)
207
- : Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
264
+ ? Math.max(0, scrollableLines.length - 1)
265
+ : Math.min(this.selectedIndex, Math.max(0, scrollableLines.length - 1));
208
266
  const summarySection = { title: 'Summary', lines: summaryLines } as const;
209
267
  const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
210
268
  const logStreamSection = resolveScrollablePanelSection(width, height, {
@@ -214,7 +272,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
214
272
  beforeSections: [summarySection, agentsSection],
215
273
  section: {
216
274
  title: 'Log Stream',
217
- scrollableLines: this.filteredEntries.map((entry) => this._renderEntry(entry, width)),
275
+ scrollableLines,
218
276
  selectedIndex: focusIndex,
219
277
  scrollOffset: this.scrollStart,
220
278
  minRows: 8,
@@ -264,28 +322,50 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
264
322
  const agent = this._selectedAgent();
265
323
  if (!agent) return;
266
324
 
325
+ // Detect live streaming growth even when no JSONL is written this tick.
326
+ // During an LLM turn the record's streamingContent grows token-by-token
327
+ // with no session-log append, so the file-size gate below would never fire
328
+ // and the live tail (_buildLiveOutputLines) would never repaint.
329
+ let streamChanged = false;
330
+ const live = this.deps.agentManager.getStatus?.(agent.id);
331
+ if (live) {
332
+ const streamLen = (live.streamingContent ?? '').length;
333
+ if (streamLen !== this.lastStreamLen) {
334
+ this.lastStreamLen = streamLen;
335
+ streamChanged = true;
336
+ }
337
+ }
338
+
267
339
  const sessionFile = this._sessionFilePath(agent.id);
340
+ let fileExists = true;
268
341
  try {
269
342
  await fsPromises.access(sessionFile);
270
343
  } catch {
271
- return;
344
+ fileExists = false;
272
345
  }
273
346
 
274
- try {
275
- const content = await fsPromises.readFile(sessionFile, 'utf-8');
276
- if (content.length === this.lastFileSize) return;
277
- this.lastFileSize = content.length;
278
-
279
- // Re-parse all lines (simple: no partial-line tracking needed at 500ms)
280
- this.allEntries = parseAgentJsonl(content);
281
- this._applyFilter();
282
- if (this.autoFollow) {
283
- this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
347
+ if (fileExists) {
348
+ try {
349
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
350
+ if (content.length !== this.lastFileSize) {
351
+ this.lastFileSize = content.length;
352
+
353
+ // Re-parse all lines (simple: no partial-line tracking needed at 500ms)
354
+ this.allEntries = parseAgentJsonl(content);
355
+ this._applyFilter();
356
+ if (this.autoFollow) {
357
+ this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
358
+ }
359
+ this.markDirty();
360
+ return;
361
+ }
362
+ } catch {
363
+ // Non-fatal: file may be mid-write
284
364
  }
285
- this.markDirty();
286
- } catch {
287
- // Non-fatal: file may be mid-write
288
365
  }
366
+
367
+ // File unchanged or absent, but the live stream advanced — repaint the tail.
368
+ if (streamChanged) this.markDirty();
289
369
  }
290
370
 
291
371
  // ── Private: fs.watch (supplemental) ─────────────────────────────────────
@@ -382,6 +462,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
382
462
  this.allEntries = [];
383
463
  this.filteredEntries = [];
384
464
  this.lastFileSize = 0;
465
+ this.lastStreamLen = 0;
385
466
  this.selectedIndex = 0;
386
467
  this.scrollStart = 0;
387
468
  this.autoFollow = true;
@@ -414,6 +495,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
414
495
  this.allEntries = [];
415
496
  this.filteredEntries = [];
416
497
  this.lastFileSize = 0;
498
+ this.lastStreamLen = 0;
417
499
  this.markDirty();
418
500
  return;
419
501
  }
@@ -473,22 +555,6 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
473
555
 
474
556
  // ── Private: rendering helpers ─────────────────────────────────────────────
475
557
 
476
- /** Top header bar: title + filter label + mode indicators */
477
- private _renderHeader(width: number): Line {
478
- const title = ' Agent Logs ';
479
- const filterLabel = `[${FILTER_LABELS[this.filter]}] `;
480
- const pause = this.paused ? ' PAUSED ' : '';
481
- const follow = this.autoFollow ? ' AUTO-FOLLOW ' : '';
482
- const keyhints = ' Tab:next Space:pause f:filter g/G:scroll ';
483
- return buildStyledPanelLine(width, [
484
- { text: title, fg: COLOR.header_accent, bold: true },
485
- { text: filterLabel, fg: COLOR.filter_active },
486
- { text: pause, fg: COLOR.paused },
487
- { text: follow, fg: COLOR.auto_follow },
488
- { text: keyhints, fg: COLOR.header_label },
489
- ]);
490
- }
491
-
492
558
  /** Agent selector bar: shows running agents with cycle indicator */
493
559
  private _renderAgentSelector(width: number): Line {
494
560
  const prefix = ' Agents: ';
@@ -512,32 +578,6 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
512
578
  return buildStyledPanelLine(width, segments);
513
579
  }
514
580
 
515
- private _renderNoAgents(width: number): Line {
516
- const msg = ' No agents running. ';
517
- return buildStyledPanelLine(width, [{ text: msg, fg: COLOR.dim }]);
518
- }
519
-
520
- private _renderSeparator(width: number): Line {
521
- return buildStyledPanelLine(width, [{ text: '─'.repeat(width), fg: COLOR.separator }]);
522
- }
523
-
524
- private _renderEmpty(width: number, bodyHeight: number): Line[] {
525
- const lines: Line[] = [];
526
- const msg = this.agents.length === 0
527
- ? ' No agents running '
528
- : ` No ${this.filter === 'all' ? '' : this.filter + ' '}log entries yet `;
529
- const offset = Math.max(0, Math.floor((width - msg.length) / 2));
530
- const textLine = buildStyledPanelLine(width, [
531
- { text: ' '.repeat(offset), fg: COLOR.dim },
532
- { text: msg, fg: COLOR.dim },
533
- ]);
534
- lines.push(textLine);
535
- while (lines.length < bodyHeight) {
536
- lines.push(createEmptyLine(width));
537
- }
538
- return lines;
539
- }
540
-
541
581
  private _renderEntry(entry: LogEntry, width: number): Line {
542
582
  // Indent non-session entries
543
583
  const prefix = entry.type === 'session_start' ? '' : ' ';
@@ -545,6 +585,43 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
545
585
  return buildStyledPanelLine(width, [{ text: fullText, fg: entry.color, bold: entry.bold }]);
546
586
  }
547
587
 
588
+ /**
589
+ * Build trailing lines exposing the selected agent's live model output. The
590
+ * JSONL tail is written per-turn, so while an agent is mid-turn its actual
591
+ * work is invisible here; this surfaces the streaming token buffer
592
+ * (tail-truncated like AgentDetailModal) for a running agent, and the final
593
+ * assistant text once it terminates. Empty when there is nothing live to show.
594
+ */
595
+ private _buildLiveOutputLines(width: number, agent: AgentRecord): Line[] {
596
+ const STREAM_MAX_CHARS = 500;
597
+ if (agent.status === 'running' && agent.streamingContent) {
598
+ const sc = agent.streamingContent;
599
+ const truncated = sc.length > STREAM_MAX_CHARS;
600
+ const display = truncated ? sc.slice(-STREAM_MAX_CHARS) : sc;
601
+ const header = truncated
602
+ ? ` streaming (last ${STREAM_MAX_CHARS} of ${sc.length} chars):`
603
+ : ' streaming:';
604
+ const lines: Line[] = [
605
+ buildStyledPanelLine(width, [{ text: header, fg: COLOR.auto_follow, bold: true }]),
606
+ ];
607
+ for (const raw of display.split('\n')) {
608
+ lines.push(buildStyledPanelLine(width, [{ text: ` ${raw}`, fg: COLOR.assistant }]));
609
+ }
610
+ return lines;
611
+ }
612
+ if (agent.status !== 'running' && agent.status !== 'pending' && agent.fullOutput) {
613
+ const fo = agent.fullOutput;
614
+ const lines: Line[] = [
615
+ buildStyledPanelLine(width, [{ text: ' output:', fg: COLOR.header_accent, bold: true }]),
616
+ ];
617
+ for (const raw of fo.split('\n')) {
618
+ lines.push(buildStyledPanelLine(width, [{ text: ` ${raw}`, fg: COLOR.assistant }]));
619
+ }
620
+ return lines;
621
+ }
622
+ return [];
623
+ }
624
+
548
625
  private _agentStatusColor(status: AgentRecord['status']): string {
549
626
  switch (status) {
550
627
  case 'running': return COLOR.agent_running;