@pellux/goodvibes-tui 1.7.0 → 1.9.1

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 (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. 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.1",
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 };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * credential-availability.ts — W6-C1 client-side credential-status read.
3
+ *
4
+ * When the TUI acts as a CLIENT of an adopted external daemon (the host-service
5
+ * `mode === 'external'` topology — see runtime/bootstrap.ts), provider/model/secret
6
+ * STATUS is read from that daemon's `credentials.get` wire method rather than from
7
+ * the TUI's own surfaceRoot ('tui') store. This module folds a `credentials.get`
8
+ * outcome into an honest availability value, mirroring the goodvibes-webui v1.0.1
9
+ * `deriveCredentialAvailability` contract exactly:
10
+ *
11
+ * - a 503 CREDENTIAL_STORE_UNAVAILABLE (matched by machine code), a METHOD_NOT_FOUND
12
+ * from an older daemon, or ANY transport failure -> { available: false, reason }
13
+ * — an honest, reason-carrying "unavailable" state.
14
+ * - NEVER a fabricated "configured"; NEVER a secret byte. Only the boolean status
15
+ * metadata surface (key / configured / usable / source / secure) is carried.
16
+ *
17
+ * STATUS ONLY moves to the daemon path. Secret RESOLUTION — the value-reads provider
18
+ * auth needs, plus the env-only API-key posture — stays local and is untouched by
19
+ * this module. The daemon's `credentials.get` never returns raw key bytes over the
20
+ * wire (see the SDK decision record 2026-07-06-config-sharing-shared-tier-and-secret-read),
21
+ * so no plaintext can reach a caller through this path by construction.
22
+ */
23
+
24
+ /** One credential's status metadata from the daemon's shared store — never bytes. */
25
+ export interface CredentialStatusEntry {
26
+ readonly key: string;
27
+ readonly configured: boolean;
28
+ readonly usable: boolean;
29
+ readonly source?: string;
30
+ readonly secure?: boolean;
31
+ }
32
+
33
+ /** Honest availability: either a status list, or a reason we could not read it. */
34
+ export type CredentialAvailability =
35
+ | { readonly available: true; readonly credentials: readonly CredentialStatusEntry[] }
36
+ | { readonly available: false; readonly reason: string };
37
+
38
+ /** A `credentials.get` invocation outcome — success value OR a thrown error. */
39
+ export type CredentialStatusOutcome =
40
+ | { readonly ok: true; readonly value: unknown }
41
+ | { readonly ok: false; readonly error: unknown };
42
+
43
+ /** Credential-read posture: read the daemon's shared store, or the local own store. */
44
+ export type CredentialReadMode = 'host' | 'client';
45
+
46
+ function asRecord(value: unknown): Record<string, unknown> | null {
47
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
48
+ }
49
+
50
+ function readString(record: Record<string, unknown> | null, key: string): string {
51
+ if (!record) return '';
52
+ const value = record[key];
53
+ return typeof value === 'string' ? value : '';
54
+ }
55
+
56
+ /**
57
+ * Map the authoritative host-service mode string to the credential-read posture.
58
+ * Only 'external' (a separately-running daemon this TUI adopted) reads credential
59
+ * STATUS over the wire; every other mode ('embedded'/'disabled'/'blocked'/
60
+ * 'incompatible'/'unavailable') is the local host reading its own store.
61
+ */
62
+ export function credentialReadModeFromHostMode(hostMode: string): CredentialReadMode {
63
+ return hostMode === 'external' ? 'client' : 'host';
64
+ }
65
+
66
+ /**
67
+ * Fold a `credentials.get` outcome into an honest availability value. Mirrors
68
+ * goodvibes-webui v1.0.1 `deriveCredentialAvailability`: any failure becomes a
69
+ * reason-carrying `available: false`; a malformed success (no credentials array)
70
+ * is treated as unavailable rather than fabricated as configured; on success only
71
+ * the boolean status surface is carried, never a secret value.
72
+ */
73
+ export function deriveCredentialAvailability(outcome: CredentialStatusOutcome): CredentialAvailability {
74
+ if (!outcome.ok) {
75
+ const err = asRecord(outcome.error);
76
+ const code = readString(err, 'code');
77
+ if (code === 'CREDENTIAL_STORE_UNAVAILABLE') {
78
+ return { available: false, reason: 'The daemon has no shared credential store wired.' };
79
+ }
80
+ if (code === 'METHOD_NOT_FOUND' || code === 'NOT_INVOKABLE') {
81
+ return { available: false, reason: 'This daemon does not serve credential status yet.' };
82
+ }
83
+ return { available: false, reason: 'Credential status unavailable right now.' };
84
+ }
85
+ const value = asRecord(outcome.value);
86
+ const raw = value?.credentials;
87
+ if (!Array.isArray(raw)) return { available: false, reason: 'Credential status unavailable right now.' };
88
+ const credentials: CredentialStatusEntry[] = [];
89
+ for (const item of raw) {
90
+ const rec = asRecord(item);
91
+ const key = readString(rec, 'key');
92
+ if (!rec || !key) continue;
93
+ credentials.push({
94
+ key,
95
+ configured: rec.configured === true,
96
+ usable: rec.usable === true,
97
+ source: readString(rec, 'source') || undefined,
98
+ secure: rec.secure === true ? true : rec.secure === false ? false : undefined,
99
+ });
100
+ }
101
+ return { available: true, credentials };
102
+ }
103
+
104
+ /**
105
+ * Client-side credential-status read. Invokes the adopted external daemon's
106
+ * `credentials.get` and folds success OR any thrown transport/daemon error into an
107
+ * honest availability. The daemon's `credentials.get` transport error carries the
108
+ * machine `code` at the top level (e.g. an HttpStatusError whose body supplied
109
+ * `CREDENTIAL_STORE_UNAVAILABLE` / `METHOD_NOT_FOUND`), which `deriveCredentialAvailability`
110
+ * classifies. Only the boolean status surface is returned; a raw secret value can
111
+ * never reach a caller through this path.
112
+ */
113
+ export async function readClientCredentialStatus(
114
+ invokeCredentialsGet: () => Promise<unknown>,
115
+ ): Promise<CredentialAvailability> {
116
+ try {
117
+ const value = await invokeCredentialsGet();
118
+ return deriveCredentialAvailability({ ok: true, value });
119
+ } catch (error) {
120
+ return deriveCredentialAvailability({ ok: false, error });
121
+ }
122
+ }
@@ -55,3 +55,18 @@ export function getConfiguredSystemPrompt(configManager: Pick<ConfigManager, 'ge
55
55
  }
56
56
 
57
57
  export { getConfiguredApiKeys, resolveApiKeys } from '@pellux/goodvibes-sdk/platform/config';
58
+
59
+ // W6-C1: client-side credential-status read (honest-degrade, status-only). When the
60
+ // TUI acts as a client of an adopted external daemon it reads provider/model/secret
61
+ // STATUS from that daemon's `credentials.get`, never from its own surfaceRoot store.
62
+ export {
63
+ credentialReadModeFromHostMode,
64
+ deriveCredentialAvailability,
65
+ readClientCredentialStatus,
66
+ } from './credential-availability.ts';
67
+ export type {
68
+ CredentialAvailability,
69
+ CredentialReadMode,
70
+ CredentialStatusEntry,
71
+ CredentialStatusOutcome,
72
+ } from './credential-availability.ts';
@@ -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