@pellux/goodvibes-tui 1.7.0 → 1.9.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 (137) hide show
  1. package/README.md +10 -9
  2. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  3. package/package.json +2 -2
  4. package/src/audio/spoken-turn-controller.ts +12 -2
  5. package/src/audio/spoken-turn-wiring.ts +2 -1
  6. package/src/cli/help.ts +8 -1
  7. package/src/cli/service-posture.ts +2 -1
  8. package/src/cli/status.ts +2 -1
  9. package/src/cli/surface-command.ts +2 -2
  10. package/src/core/composer-state.ts +11 -3
  11. package/src/core/conversation-line-cache.ts +27 -3
  12. package/src/core/conversation-rendering.ts +71 -14
  13. package/src/core/stream-event-wiring.ts +20 -1
  14. package/src/core/system-message-noise.ts +87 -0
  15. package/src/core/system-message-router.ts +68 -1
  16. package/src/core/turn-cancellation.ts +7 -2
  17. package/src/core/turn-event-wiring.ts +10 -2
  18. package/src/daemon/cli.ts +29 -2
  19. package/src/daemon/handlers/register.ts +8 -1
  20. package/src/daemon/service-commands.ts +329 -0
  21. package/src/input/autocomplete.ts +27 -1
  22. package/src/input/command-registry.ts +46 -4
  23. package/src/input/commands/codebase-runtime.ts +46 -6
  24. package/src/input/commands/config.ts +43 -3
  25. package/src/input/commands/health-runtime.ts +9 -1
  26. package/src/input/commands/memory.ts +68 -35
  27. package/src/input/commands/operator-panel-runtime.ts +31 -7
  28. package/src/input/commands/planning-runtime.ts +95 -6
  29. package/src/input/commands/qrcode-runtime.ts +25 -5
  30. package/src/input/commands/remote-runtime-setup.ts +5 -3
  31. package/src/input/commands/session-content.ts +20 -9
  32. package/src/input/commands/settings-sync-runtime.ts +15 -3
  33. package/src/input/commands/shell-core.ts +10 -1
  34. package/src/input/commands/workstream-runtime.ts +168 -18
  35. package/src/input/config-modal-types.ts +15 -1
  36. package/src/input/config-modal.ts +227 -12
  37. package/src/input/feed-context-factory.ts +3 -0
  38. package/src/input/handler-command-route.ts +10 -3
  39. package/src/input/handler-content-actions.ts +17 -2
  40. package/src/input/handler-feed-routes.ts +43 -121
  41. package/src/input/handler-feed.ts +32 -4
  42. package/src/input/handler-modal-routes.ts +78 -13
  43. package/src/input/handler-modal-stack.ts +11 -2
  44. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  45. package/src/input/handler-onboarding.ts +71 -59
  46. package/src/input/handler-picker-routes.ts +15 -8
  47. package/src/input/handler-shortcuts.ts +59 -9
  48. package/src/input/handler.ts +6 -1
  49. package/src/input/keybindings.ts +6 -5
  50. package/src/input/model-picker.ts +19 -2
  51. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  52. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  53. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  54. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  55. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  56. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  57. package/src/input/panel-mouse-geometry.ts +97 -0
  58. package/src/input/panel-paste-flood-guard.ts +86 -0
  59. package/src/input/selection-modal.ts +11 -0
  60. package/src/input/session-picker-modal.ts +44 -4
  61. package/src/input/settings-modal-data.ts +51 -2
  62. package/src/input/settings-modal-types.ts +4 -2
  63. package/src/main.ts +28 -27
  64. package/src/panels/builtin/shared.ts +9 -3
  65. package/src/panels/fleet-deep-link.ts +31 -0
  66. package/src/panels/fleet-panel-format.ts +62 -0
  67. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  68. package/src/panels/fleet-panel.ts +89 -131
  69. package/src/panels/fleet-read-model.ts +43 -0
  70. package/src/panels/fleet-steer.ts +35 -2
  71. package/src/panels/fleet-stop.ts +29 -1
  72. package/src/panels/modals/keybindings-modal.ts +16 -1
  73. package/src/panels/modals/modal-theme.ts +35 -29
  74. package/src/panels/modals/pairing-modal.ts +25 -6
  75. package/src/panels/modals/planning-modal.ts +43 -21
  76. package/src/panels/modals/work-plan-modal.ts +28 -0
  77. package/src/panels/panel-manager.ts +15 -4
  78. package/src/panels/polish-core.ts +38 -25
  79. package/src/panels/project-planning-answer-actions.ts +8 -13
  80. package/src/panels/types.ts +24 -0
  81. package/src/permissions/prompt.ts +160 -13
  82. package/src/renderer/autocomplete-overlay.ts +28 -2
  83. package/src/renderer/compositor.ts +2 -3
  84. package/src/renderer/config-modal.ts +19 -5
  85. package/src/renderer/footer-tips.ts +5 -1
  86. package/src/renderer/fullscreen-primitives.ts +32 -22
  87. package/src/renderer/git-status.ts +3 -1
  88. package/src/renderer/layout.ts +0 -4
  89. package/src/renderer/markdown.ts +7 -3
  90. package/src/renderer/modal-factory.ts +25 -20
  91. package/src/renderer/model-workspace.ts +18 -3
  92. package/src/renderer/overlay-box.ts +21 -17
  93. package/src/renderer/process-indicator.ts +14 -3
  94. package/src/renderer/selection-modal-overlay.ts +6 -1
  95. package/src/renderer/session-picker-modal.ts +196 -3
  96. package/src/renderer/settings-modal-helpers.ts +2 -0
  97. package/src/renderer/settings-modal.ts +7 -0
  98. package/src/renderer/shell-surface.ts +8 -1
  99. package/src/renderer/status-glyphs.ts +14 -15
  100. package/src/renderer/system-message.ts +15 -3
  101. package/src/renderer/terminal-bg-probe.ts +339 -0
  102. package/src/renderer/terminal-escapes.ts +20 -0
  103. package/src/renderer/theme-mode-config.ts +67 -0
  104. package/src/renderer/theme.ts +91 -1
  105. package/src/renderer/thinking.ts +11 -3
  106. package/src/renderer/tool-call.ts +15 -9
  107. package/src/renderer/tool-result-summary.ts +148 -0
  108. package/src/renderer/turn-injection.ts +22 -3
  109. package/src/renderer/ui-factory.ts +154 -85
  110. package/src/renderer/ui-primitives.ts +30 -129
  111. package/src/runtime/bootstrap-command-context.ts +6 -0
  112. package/src/runtime/bootstrap-command-parts.ts +8 -4
  113. package/src/runtime/bootstrap-core.ts +33 -11
  114. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  115. package/src/runtime/bootstrap-shell.ts +19 -1
  116. package/src/runtime/bootstrap.ts +118 -5
  117. package/src/runtime/code-index-services.ts +25 -2
  118. package/src/runtime/legacy-daemon-migration.ts +516 -0
  119. package/src/runtime/memory-fold.ts +26 -0
  120. package/src/runtime/onboarding/derivation.ts +7 -2
  121. package/src/runtime/onboarding/snapshot.ts +27 -1
  122. package/src/runtime/onboarding/types.ts +31 -1
  123. package/src/runtime/operator-token-cleanup.ts +82 -1
  124. package/src/runtime/orchestrator-core-services.ts +10 -0
  125. package/src/runtime/resume-notice.ts +209 -0
  126. package/src/runtime/services.ts +12 -8
  127. package/src/runtime/session-inbound-inputs.ts +252 -0
  128. package/src/runtime/session-spine-transport.ts +64 -0
  129. package/src/runtime/terminal-output-guard.ts +15 -8
  130. package/src/runtime/ui-services.ts +19 -3
  131. package/src/runtime/workstream-services.ts +160 -28
  132. package/src/runtime/wrfc-persistence.ts +124 -17
  133. package/src/shell/blocking-input.ts +46 -3
  134. package/src/shell/recovery-input-helpers.ts +170 -1
  135. package/src/shell/ui-openers.ts +42 -9
  136. package/src/utils/terminal-width.ts +52 -0
  137. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "0.38.0",
102
+ "@pellux/goodvibes-sdk": "1.0.0",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -61,7 +61,16 @@ export class SpokenTurnController {
61
61
  return true;
62
62
  }
63
63
 
64
- stop(message?: string): void {
64
+ /**
65
+ * Returns whether speech was actually ACTIVE when stopped. The notice only
66
+ * prints in that case — stop() on an idle controller used to notify anyway,
67
+ * spamming "[TTS] Spoken output stopped." on every Ctrl+C (batch replay
68
+ * D5/N-noise); callers use the return to decide whether the press "did a
69
+ * job" (see handleCtrlC's consume-on-speech-stop).
70
+ */
71
+ stop(message?: string): boolean {
72
+ const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
73
+ || this.chunker !== null || this.abortControllers.size > 0;
65
74
  this.pendingPrompt = null;
66
75
  this.activeTurnId = null;
67
76
  this.chunker?.reset();
@@ -72,7 +81,8 @@ export class SpokenTurnController {
72
81
  this.player.stop();
73
82
  this.playbackChain = Promise.resolve();
74
83
  this.errorReportedForTurn = false;
75
- if (message) this.notify?.(`[TTS] ${message}`);
84
+ if (message && wasActive) this.notify?.(`[TTS] ${message}`);
85
+ return wasActive;
76
86
  }
77
87
 
78
88
  handleTurnEvent(event: TurnEvent): void {
@@ -8,7 +8,8 @@ import { SpokenTurnController } from './spoken-turn-controller.ts';
8
8
  export interface SpokenTurnRuntime {
9
9
  readonly unsubs: readonly (() => void)[];
10
10
  submitNextTurn(prompt: string): boolean;
11
- stop(message?: string): void;
11
+ /** Returns whether speech was actually active (see controller.stop). */
12
+ stop(message?: string): boolean;
12
13
  }
13
14
 
14
15
  export interface WireSpokenTurnRuntimeOptions {
package/src/cli/help.ts CHANGED
@@ -283,10 +283,17 @@ export function renderGoodVibesCommandHelp(topic: string, binary = 'goodvibes'):
283
283
 
284
284
  export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
285
285
  return [
286
- `Usage: ${binary} [OPTIONS]`,
286
+ `Usage: ${binary} [COMMAND] [OPTIONS]`,
287
287
  '',
288
288
  'Starts the headless GoodVibes daemon/API host.',
289
289
  '',
290
+ 'Commands:',
291
+ ' install-service Install + enable the daemon as a systemd user service (survives reboots)',
292
+ ' uninstall-service Disable + remove the daemon systemd user service',
293
+ ' service-status Show whether the daemon service is installed / enabled / active',
294
+ ' migrate-service Guided takeover of a legacy goodvibes-daemon.service unit; prints a plan',
295
+ ' unless run with -y/--yes (never auto-migrates)',
296
+ '',
290
297
  'Options:',
291
298
  ' --daemon-home <dir> Override daemon home',
292
299
  ' --working-dir <dir> Override working directory',
@@ -5,6 +5,7 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:pa
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { PlatformServiceManager } from '@pellux/goodvibes-sdk/platform/daemon';
7
7
  import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
8
+ import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
8
9
  import type { ConfigManager } from '../config/index.ts';
9
10
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
10
11
  import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
@@ -392,7 +393,7 @@ export async function buildCliServicePosture(
392
393
  enabled: runtime.configManager.get('service.enabled') === true,
393
394
  autostart: runtime.configManager.get('service.autostart') === true,
394
395
  restartOnFailure: runtime.configManager.get('service.restartOnFailure') === true,
395
- daemonEnabled: runtime.configManager.get('danger.daemon') === true,
396
+ daemonEnabled: resolveDaemonEnabled(runtime.configManager),
396
397
  };
397
398
  const serverBackedEnabled = config.daemonEnabled || endpoints.some((endpoint) => endpoint.enabled);
398
399
  const issues: string[] = [];
package/src/cli/status.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
+ import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
2
3
  import type { OnboardingCheckMarkersState } from '../runtime/onboarding/index.ts';
3
4
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
4
5
  import { isNetworkFacing } from './network-posture.ts';
@@ -98,7 +99,7 @@ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliD
98
99
  const serviceEnabled = config.get('service.enabled') === true;
99
100
  const serviceAutostart = config.get('service.autostart') === true;
100
101
  const restartOnFailure = config.get('service.restartOnFailure') === true;
101
- const daemonEnabled = config.get('danger.daemon') === true;
102
+ const daemonEnabled = resolveDaemonEnabled(config);
102
103
  const listenerEnabled = config.get('danger.httpListener') === true;
103
104
  const webEnabled = config.get('web.enabled') === true;
104
105
  const controlPlaneEnabled = config.get('controlPlane.enabled') === true;
@@ -47,7 +47,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
47
47
  runtime.configManager.setDynamic('web.enabled', enabled);
48
48
  if (enabled) {
49
49
  enableFeatureFlags(runtime.configManager, getServerSurfaceFeatureFlags({ serverBacked: true, web: true }));
50
- runtime.configManager.setDynamic('danger.daemon', true);
50
+ runtime.configManager.setDynamic('daemon.enabled', true);
51
51
  runtime.configManager.setDynamic('controlPlane.enabled', true);
52
52
  const webError = applyTargetEndpointFlagsOrDefault(runtime, 'web');
53
53
  if (webError) return { output: webError, exitCode: 2 };
@@ -70,7 +70,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
70
70
  }
71
71
  else if (target === 'control-plane' || target === 'controlPlane') {
72
72
  runtime.configManager.setDynamic('controlPlane.enabled', enabled);
73
- runtime.configManager.setDynamic('danger.daemon', enabled);
73
+ runtime.configManager.setDynamic('daemon.enabled', enabled);
74
74
  if (enabled) {
75
75
  const controlPlaneError = applyTargetEndpointFlagsOrDefault(runtime, 'controlPlane');
76
76
  if (controlPlaneError) return { output: controlPlaneError, exitCode: 2 };
@@ -20,8 +20,13 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
20
20
  const flags: string[] = [];
21
21
  let pendingRisk: ComposerState['pendingRisk'] = 'none';
22
22
 
23
- if (input.pendingApproval) {
24
- flags.push('approval');
23
+ // An approval wait is the dominant composer state and owns the single honest
24
+ // status tag: `risk:approval-wait`. It is therefore NOT also duplicated as a
25
+ // `approval` flag, and (below) it suppresses the competing `state:` turn tag —
26
+ // the turn is blocked on the user, not really streaming. Precedence:
27
+ // approval-wait > live turn state. (UX-B item 4.)
28
+ const approvalWait = input.pendingApproval === true;
29
+ if (approvalWait) {
25
30
  pendingRisk = 'approval-wait';
26
31
  }
27
32
  if (intent.kind === 'shell') {
@@ -35,7 +40,7 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
35
40
  }
36
41
  if (input.hasAttachments) flags.push('attachments');
37
42
 
38
- const statusLabel = (() => {
43
+ const turnStatus = (() => {
39
44
  switch (input.turnState) {
40
45
  case 'preflight': return 'preflight';
41
46
  case 'streaming': return 'streaming';
@@ -47,6 +52,9 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
47
52
  default: return 'idle';
48
53
  }
49
54
  })();
55
+ // 'idle' is suppressed by the footer renderer, so an approval wait shows only
56
+ // `risk:approval-wait` — one honest tag, not three spellings of the wait.
57
+ const statusLabel = approvalWait ? 'idle' : turnStatus;
50
58
 
51
59
  return {
52
60
  intent,
@@ -49,6 +49,7 @@ import {
49
49
  renderConversationSystemMessage,
50
50
  renderConversationToolMessage,
51
51
  renderConversationUserMessage,
52
+ collectCompletedToolCallIds,
52
53
  } from './conversation-rendering.ts';
53
54
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
54
55
  // SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
@@ -94,6 +95,18 @@ interface KeyMeta {
94
95
  readonly showReasoningSummary: boolean;
95
96
  readonly blockBase: number;
96
97
  readonly kind: SystemMessageKind | undefined;
98
+ /**
99
+ * True when this (assistant) message has a tool call with no result yet — it
100
+ * renders as pending rather than done. Keyed so the entry invalidates and
101
+ * re-renders ✓ once the tool result arrives. (UX-B item 2c.)
102
+ */
103
+ readonly hasPendingTool: boolean;
104
+ }
105
+
106
+ /** Whether an assistant message has any tool call still awaiting a result. */
107
+ function hasPendingToolCall(m: Message, completed: ReadonlySet<string>): boolean {
108
+ if (m.role !== 'assistant' || !m.toolCalls) return false;
109
+ return m.toolCalls.some((tc) => tc.id === undefined || !completed.has(tc.id));
97
110
  }
98
111
 
99
112
  interface CacheEntry {
@@ -272,6 +285,11 @@ export class MessageLineCache {
272
285
  showReasoningSummary: context.configManager?.get('display.showReasoningSummary') ?? false,
273
286
  };
274
287
 
288
+ // Tool calls with no matching tool-result message are still pending; the
289
+ // render context and the cache key both depend on this. (UX-B item 2c.)
290
+ const completedToolCallIds = collectCompletedToolCallIds(messages);
291
+ const renderContext: ConversationRenderContext = { ...context, completedToolCallIds };
292
+
275
293
  const touched = new Set<number>();
276
294
 
277
295
  for (let i = 0; i < messages.length; i++) {
@@ -283,17 +301,18 @@ export class MessageLineCache {
283
301
 
284
302
  const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
285
303
  const kind = context.messageKindRegistry.get(absoluteIdx);
304
+ const hasPendingTool = hasPendingToolCall(message, completedToolCallIds);
286
305
 
287
306
  if (!uncacheable) {
288
307
  const existing = this.entries.get(absoluteIdx);
289
- if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, context.collapseState)) {
308
+ if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, hasPendingTool, context.collapseState)) {
290
309
  this.applyEntry(context, existing, base);
291
310
  touched.add(absoluteIdx);
292
311
  continue;
293
312
  }
294
313
  }
295
314
 
296
- const entry = this.renderScratch(context, message, width, absoluteIdx, cfg, blockBase, kind);
315
+ const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, hasPendingTool);
297
316
  this.applyEntry(context, entry, base);
298
317
  if (!uncacheable) {
299
318
  this.entries.set(absoluteIdx, entry);
@@ -319,6 +338,7 @@ export class MessageLineCache {
319
338
  cfg: RenderConfig,
320
339
  blockBase: number,
321
340
  kind: SystemMessageKind | undefined,
341
+ hasPendingTool: boolean,
322
342
  collapseState: Map<string, boolean>,
323
343
  ): boolean {
324
344
  const k = entry.keyMeta;
@@ -330,7 +350,8 @@ export class MessageLineCache {
330
350
  k.showThinking !== cfg.showThinking ||
331
351
  k.showReasoningSummary !== cfg.showReasoningSummary ||
332
352
  k.blockBase !== blockBase ||
333
- k.kind !== kind
353
+ k.kind !== kind ||
354
+ k.hasPendingTool !== hasPendingTool
334
355
  ) {
335
356
  return false;
336
357
  }
@@ -355,6 +376,7 @@ export class MessageLineCache {
355
376
  cfg: RenderConfig,
356
377
  blockBase: number,
357
378
  kind: SystemMessageKind | undefined,
379
+ hasPendingTool: boolean,
358
380
  ): CacheEntry {
359
381
  const scratchLines: Line[] = [];
360
382
  const scratchHistory = {
@@ -378,6 +400,7 @@ export class MessageLineCache {
378
400
  messageKindRegistry: context.messageKindRegistry,
379
401
  configManager: context.configManager,
380
402
  splashOptions: context.splashOptions,
403
+ completedToolCallIds: context.completedToolCallIds,
381
404
  };
382
405
 
383
406
  renderOne(scratchCtx, message, width, absoluteIdx, cfg);
@@ -397,6 +420,7 @@ export class MessageLineCache {
397
420
  showReasoningSummary: cfg.showReasoningSummary,
398
421
  blockBase,
399
422
  kind,
423
+ hasPendingTool,
400
424
  },
401
425
  contentSig: contentSigOf(message),
402
426
  lines: scratchLines,
@@ -1,8 +1,9 @@
1
1
  import { UIFactory } from '../renderer/ui-factory.ts';
2
2
  import { renderMarkdownTracked } from '../renderer/markdown.ts';
3
3
  import { renderDiffView } from '../renderer/diff-view.ts';
4
- import { DARK_THEME } from '../renderer/theme.ts';
4
+ import { activeTheme } from '../renderer/theme.ts';
5
5
  import { renderToolCallBlock } from '../renderer/tool-call.ts';
6
+ import { summarizeToolResult } from '../renderer/tool-result-summary.ts';
6
7
  import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
7
8
  import { renderThinkingBlock } from '../renderer/thinking.ts';
8
9
  import { renderSystemMessage } from '../renderer/system-message.ts';
@@ -21,7 +22,9 @@ import { extractUserDisplayText } from '@pellux/goodvibes-sdk/platform/core';
21
22
  // conversation-rendering.ts → system-message-router.ts → conversation.ts → conversation-rendering.ts
22
23
  import type { SystemMessageKind } from '@/runtime/index.ts';
23
24
 
24
- const T = DARK_THEME;
25
+ // Transcript tokens are read live per render (const T = activeTheme() at the top
26
+ // of each render function that styles content) so a dark→light repaint
27
+ // re-resolves with no module reload. See theme.ts's active-mode runtime note.
25
28
 
26
29
  /**
27
30
  * Navigable system message kinds for error-navigation (nextErrorLine/prevErrorLine).
@@ -39,6 +42,20 @@ const NAVIGABLE_KINDS: ReadonlySet<SystemMessageKind> = new Set(['system', 'wrfc
39
42
 
40
43
  type Message = ConversationMessageSnapshot;
41
44
 
45
+ /**
46
+ * Collect the ids of tool calls that have a matching tool-result message in the
47
+ * given slice — i.e. the tools that actually ran. Used to decide whether an
48
+ * assistant tool call still shows as pending (awaiting approval) or done.
49
+ * (UX-B item 2c.)
50
+ */
51
+ export function collectCompletedToolCallIds(messages: readonly Message[]): Set<string> {
52
+ const ids = new Set<string>();
53
+ for (const message of messages) {
54
+ if (message.role === 'tool' && message.callId) ids.add(message.callId);
55
+ }
56
+ return ids;
57
+ }
58
+
42
59
  function summarizeCallId(callId: string, maxLength = 24): string {
43
60
  return callId.length <= maxLength ? callId : `${callId.slice(0, maxLength - 1)}…`;
44
61
  }
@@ -56,6 +73,15 @@ export interface ConversationRenderContext {
56
73
  readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
57
74
  readonly configManager: ConfigManager | null;
58
75
  readonly splashOptions: SplashOptions;
76
+ /**
77
+ * Tool-call ids that have a corresponding tool-result message (i.e. the tool
78
+ * actually ran). An assistant tool call whose id is NOT in this set is still
79
+ * awaiting a decision (e.g. an approval prompt) and renders with a pending
80
+ * glyph instead of the completed ✓. When undefined (single-message callers
81
+ * without sibling context), every tool call renders as done — the prior
82
+ * behaviour. (UX-B item 2c.)
83
+ */
84
+ readonly completedToolCallIds?: ReadonlySet<string>;
59
85
  }
60
86
 
61
87
  export function renderConversationUserMessage(
@@ -63,6 +89,7 @@ export function renderConversationUserMessage(
63
89
  message: Extract<Message, { role: 'user' }>,
64
90
  width: number,
65
91
  ): void {
92
+ const T = activeTheme();
66
93
  const displayText = extractUserDisplayText(message.content);
67
94
  if (message.cancelled) {
68
95
  context.history.addLines(UIFactory.createMessageBar(width, displayText, T.errorBarBg, '196', ' x ', true));
@@ -79,6 +106,7 @@ export function renderConversationAssistantMessage(
79
106
  collapseThreshold: number,
80
107
  msgIdx: number,
81
108
  ): void {
109
+ const T = activeTheme();
82
110
  const assistantHeaderDetails = [];
83
111
  if (message.model) {
84
112
  assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: T.modelNameDim, dim: true });
@@ -196,7 +224,11 @@ export function renderConversationAssistantMessage(
196
224
 
197
225
  if (message.toolCalls && message.toolCalls.length > 0) {
198
226
  for (const tc of message.toolCalls) {
199
- context.history.addLines(renderToolCallBlock(tc, 'done', undefined, width));
227
+ // A tool call with no result message yet is still awaiting a decision
228
+ // (e.g. approval) — show a pending glyph, not the completed ✓.
229
+ const ran = context.completedToolCallIds === undefined
230
+ || (tc.id !== undefined && context.completedToolCallIds.has(tc.id));
231
+ context.history.addLines(renderToolCallBlock(tc, ran ? 'done' : 'pending', undefined, width));
200
232
  }
201
233
  }
202
234
  }
@@ -224,6 +256,7 @@ export function renderConversationToolMessage(
224
256
  width: number,
225
257
  msgIdx: number,
226
258
  ): void {
259
+ const T = activeTheme();
227
260
  const collapseKey = `msg_${msgIdx}`;
228
261
  const blockIdx = context.blockRegistry.length;
229
262
  const startLine = context.history.getLineCount();
@@ -237,7 +270,17 @@ export function renderConversationToolMessage(
237
270
  // block-registry meta merge below regardless of collapsed/expanded state.
238
271
  const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
239
272
 
240
- const isShort = message.content.length <= 200;
273
+ // Human one-line summary for tool results (write/read/exec/edit): shown as the
274
+ // collapsed line so the transcript reads "wrote foo.txt (532 B)" instead of a
275
+ // raw JSON blob; the full payload stays behind the expand toggle. Only for
276
+ // 'tool' blocks (diffs render their own view). (UX-B item 3.)
277
+ const resultSummary = blockType === 'tool'
278
+ ? summarizeToolResult(message.toolName, message.content)
279
+ : null;
280
+
281
+ // A summarizable result is kept collapsed-by-default even when short, so the
282
+ // one-line summary wins and the raw JSON is tucked behind the expand toggle.
283
+ const isShort = message.content.length <= 200 && resultSummary === null;
241
284
  const isCollapsed = isShort
242
285
  ? false
243
286
  : context.collapseState.has(collapseKey)
@@ -263,11 +306,20 @@ export function renderConversationToolMessage(
263
306
 
264
307
  if (isCollapsed) {
265
308
  const collapseSuffixReserve = 30;
266
- const preview = contentLines[0].slice(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - collapseSuffixReserve);
309
+ const avail = Math.max(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - collapseSuffixReserve);
267
310
  const hiddenCount = lineCount - 1;
268
- const collapsedText = hiddenCount > 0
269
- ? `${preview}... [${GLYPHS.navigation.collapsed} ${hiddenCount} hidden]`
270
- : preview;
311
+ let collapsedText: string;
312
+ if (resultSummary !== null) {
313
+ // Summary line — the header already shows "▸ N lines", so no hidden badge.
314
+ collapsedText = resultSummary.length > avail
315
+ ? `${resultSummary.slice(0, Math.max(0, avail - 1))}…`
316
+ : resultSummary;
317
+ } else {
318
+ const preview = contentLines[0].slice(0, avail);
319
+ collapsedText = hiddenCount > 0
320
+ ? `${preview}... [${GLYPHS.navigation.collapsed} ${hiddenCount} hidden]`
321
+ : preview;
322
+ }
271
323
  const rendered = renderConversationCollapsedFragment(collapsedText, width, {
272
324
  prefix: blockType === 'diff' ? ` ${GLYPHS.status.dualPane} ` : ` ${GLYPHS.navigation.collapsed} `,
273
325
  prefixFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
@@ -326,23 +378,28 @@ export function appendConversationMessages(
326
378
  ): void {
327
379
  const lineNumberMode = context.configManager?.get('display.lineNumbers') ?? 'off';
328
380
  const collapseThreshold = context.configManager?.get('display.collapseThreshold') ?? 30;
381
+ // Derive pending vs done for tool calls from sibling tool-result messages,
382
+ // unless the caller already supplied the set. (UX-B item 2c.)
383
+ const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
384
+ ? context
385
+ : { ...context, completedToolCallIds: collectCompletedToolCallIds(messages) };
329
386
 
330
387
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
331
388
  const message = messages[msgIdx];
332
389
  // absoluteIdx aligns the slice-relative loop counter with the absolute
333
390
  // message index used as the key in messageKindRegistry and messageLineRegistry.
334
391
  const absoluteIdx = msgIndexOffset + msgIdx;
335
- messageLineRegistry[absoluteIdx] = context.history.getLineCount();
392
+ messageLineRegistry[absoluteIdx] = renderContext.history.getLineCount();
336
393
  if (message.role === 'user') {
337
- renderConversationUserMessage(context, message, width);
394
+ renderConversationUserMessage(renderContext, message, width);
338
395
  } else if (message.role === 'assistant') {
339
- renderConversationAssistantMessage(context, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
396
+ renderConversationAssistantMessage(renderContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
340
397
  } else if (message.role === 'system') {
341
- renderConversationSystemMessage(context, message, width, absoluteIdx);
398
+ renderConversationSystemMessage(renderContext, message, width, absoluteIdx);
342
399
  } else if (message.role === 'tool') {
343
- renderConversationToolMessage(context, message, width, absoluteIdx);
400
+ renderConversationToolMessage(renderContext, message, width, absoluteIdx);
344
401
  }
345
- context.history.addLine(createEmptyLine(width));
402
+ renderContext.history.addLine(createEmptyLine(width));
346
403
  }
347
404
  }
348
405
 
@@ -155,6 +155,20 @@ export interface WireStreamEventMetricsOptions {
155
155
  * unavailable for either model, the notice honestly states "cost data unavailable".
156
156
  */
157
157
  readonly costLookup?: FailoverCostLookup;
158
+ /**
159
+ * Optional accessor for whether an approval card is currently waiting on the USER. When it
160
+ * returns true, the stall watchdog does NOT emit its "Still waiting on <provider>… Ctrl+C to
161
+ * cancel" hint: the stream is legitimately silent because we asked the user a question, so
162
+ * blaming the provider (or framing it as a stall) would be dishonest. The approval surface owns
163
+ * the honest "Waiting for your approval" label instead.
164
+ */
165
+ readonly isApprovalPending?: (() => boolean) | undefined;
166
+ /**
167
+ * Stall watchdog threshold in ms. Defaults to the watchdog's own default (STALL_THRESHOLD_MS,
168
+ * 30 000). Exposed only so unit tests can drive the stall path without waiting 30s — production
169
+ * callers omit it.
170
+ */
171
+ readonly stallThresholdMs?: number | undefined;
158
172
  }
159
173
 
160
174
  /** Result of wireStreamEventMetrics. */
@@ -237,6 +251,7 @@ export function wireStreamEventMetrics(
237
251
  const {
238
252
  events, metrics, orchestrator, providerRegistry,
239
253
  systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
254
+ isApprovalPending, stallThresholdMs,
240
255
  } = options;
241
256
 
242
257
  const unsubs: Array<() => void> = [];
@@ -364,12 +379,16 @@ export function wireStreamEventMetrics(
364
379
  const stallWatchdog = createStreamStallWatchdog({
365
380
  events: events.turns,
366
381
  onStall: (providerName, episode) => {
382
+ // Suppress the provider-blaming stall hint while an approval card is waiting on the user: the
383
+ // stream is silent because WE asked the user a question, not because the provider stalled.
384
+ if (isApprovalPending?.()) return;
367
385
  metrics.stallEpisode = episode;
368
386
  systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
369
387
  render();
370
388
  },
371
389
  getProviderName: () => providerRegistry.getCurrentModel().provider,
372
- // thresholdMs uses the default 30 000
390
+ // thresholdMs defaults to 30 000 in the watchdog; only unit tests override it.
391
+ ...(stallThresholdMs !== undefined ? { thresholdMs: stallThresholdMs } : {}),
373
392
  });
374
393
  unsubs.push(() => stallWatchdog.dispose());
375
394
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * System-message noise policy — decides whether an operational status message
3
+ * should reach the transcript unchanged, be dropped, or be folded. Every noisy
4
+ * source these rules catch is emitted (from the SDK) through
5
+ * SystemMessageRouter, so this one funnel is where first-run plumbing is kept
6
+ * out of the user's stream while the information stays reachable elsewhere
7
+ * (fleet panel, /health, /model, activity log). (UX-B item 1.)
8
+ */
9
+
10
+ export interface NoiseGateDeps {
11
+ /**
12
+ * True when a WRFC chain is terminal (passed/failed) or gone (killed/removed).
13
+ * Used to suppress stale "[Replay] … transitioned … waiting for action"
14
+ * re-notifications for chains that can no longer act. Absent → never suppress.
15
+ */
16
+ readonly isChainTerminal?: (chainId: string) => boolean;
17
+ }
18
+
19
+ export type NoiseVerdict =
20
+ /** Pass through to the transcript unchanged. */
21
+ | { readonly action: 'emit' }
22
+ /** Suppress entirely — reachable via another live surface. */
23
+ | { readonly action: 'drop' }
24
+ /** Provider "from last session" replay line — buffer and fold to one line. */
25
+ | { readonly action: 'foldProviderReplay' };
26
+
27
+ const EMIT: NoiseVerdict = { action: 'emit' };
28
+ const DROP: NoiseVerdict = { action: 'drop' };
29
+ const FOLD: NoiseVerdict = { action: 'foldProviderReplay' };
30
+
31
+ /** Matches the SDK's per-provider persisted-replay line (1b). */
32
+ const PROVIDER_REPLAY_RE = / — from last session$/;
33
+
34
+ /** Matches the SDK's 30s periodic running-agents snapshot (1d). */
35
+ const AGENTS_RUNNING_SNAPSHOT_RE = /^\[Agents\] \d+ running:/;
36
+
37
+ /** Extracts the chain id from a "[Replay]… WRFC chain <id> transitioned …" line (1c). */
38
+ const REPLAY_CHAIN_RE = /WRFC chain (\S+) transitioned .* waiting for action/;
39
+
40
+ /**
41
+ * Classify one system message. Pure aside from the injected `isChainTerminal`
42
+ * lookup, so it is trivially testable.
43
+ */
44
+ export function classifyNoise(message: string, deps: NoiseGateDeps): NoiseVerdict {
45
+ // 1b — provider-discovery replay burst ("[Local] … — from last session").
46
+ if (message.startsWith('[Local]') && PROVIDER_REPLAY_RE.test(message)) {
47
+ return FOLD;
48
+ }
49
+
50
+ // 1d — periodic "[Agents] N running:" status snapshot. The same live detail
51
+ // is shown in the fleet panel (per-agent activity) and the footer count, so
52
+ // the 30-second transcript churn is dropped. Meaningful lifecycle lines
53
+ // ("[Agents] ✓ …", "[Agents] Cohort …", "[Agents] ✗ …") do not match.
54
+ if (AGENTS_RUNNING_SNAPSHOT_RE.test(message)) {
55
+ return DROP;
56
+ }
57
+
58
+ // 1c — stale "[Replay] … waiting for action" for a terminal/killed chain.
59
+ if (message.startsWith('[Replay]') && deps.isChainTerminal) {
60
+ const match = REPLAY_CHAIN_RE.exec(message);
61
+ if (match && deps.isChainTerminal(match[1])) return DROP;
62
+ }
63
+
64
+ return EMIT;
65
+ }
66
+
67
+ /** Provider name from a "[Local] <name> at host:port (…) — from last session" line. */
68
+ export function providerNameFromReplay(message: string): string {
69
+ const match = /^\[Local\]\s+(.+?)\s+at\s/.exec(message);
70
+ if (match) return match[1];
71
+ // Fallback: first token after the tag.
72
+ const bare = /^\[Local\]\s+(\S+)/.exec(message);
73
+ return bare ? bare[1] : 'provider';
74
+ }
75
+
76
+ /**
77
+ * Build the single folded summary line for a burst of provider-replay lines.
78
+ * Names a few providers; the full list stays reachable via /health and /model.
79
+ */
80
+ export function foldProviderReplayLines(messages: readonly string[]): string {
81
+ const names = messages.map(providerNameFromReplay);
82
+ const count = names.length;
83
+ const noun = count === 1 ? 'provider' : 'providers';
84
+ const shown = names.slice(0, 3).join(', ');
85
+ const extra = count > 3 ? `, +${count - 3} more` : '';
86
+ return `[Local] Restored ${count} ${noun} from last session (${shown}${extra})`;
87
+ }