@pellux/goodvibes-tui 1.1.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 (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  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/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
@@ -1,8 +1,10 @@
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';
7
+ import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
6
8
  import { renderThinkingBlock } from '../renderer/thinking.ts';
7
9
  import { renderSystemMessage } from '../renderer/system-message.ts';
8
10
  import { createEmptyLine, type Line, type Cell } from '../types/grid.ts';
@@ -20,7 +22,9 @@ import { extractUserDisplayText } from '@pellux/goodvibes-sdk/platform/core';
20
22
  // conversation-rendering.ts → system-message-router.ts → conversation.ts → conversation-rendering.ts
21
23
  import type { SystemMessageKind } from '@/runtime/index.ts';
22
24
 
23
- 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.
24
28
 
25
29
  /**
26
30
  * Navigable system message kinds for error-navigation (nextErrorLine/prevErrorLine).
@@ -38,6 +42,20 @@ const NAVIGABLE_KINDS: ReadonlySet<SystemMessageKind> = new Set(['system', 'wrfc
38
42
 
39
43
  type Message = ConversationMessageSnapshot;
40
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
+
41
59
  function summarizeCallId(callId: string, maxLength = 24): string {
42
60
  return callId.length <= maxLength ? callId : `${callId.slice(0, maxLength - 1)}…`;
43
61
  }
@@ -55,6 +73,15 @@ export interface ConversationRenderContext {
55
73
  readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
56
74
  readonly configManager: ConfigManager | null;
57
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>;
58
85
  }
59
86
 
60
87
  export function renderConversationUserMessage(
@@ -62,6 +89,7 @@ export function renderConversationUserMessage(
62
89
  message: Extract<Message, { role: 'user' }>,
63
90
  width: number,
64
91
  ): void {
92
+ const T = activeTheme();
65
93
  const displayText = extractUserDisplayText(message.content);
66
94
  if (message.cancelled) {
67
95
  context.history.addLines(UIFactory.createMessageBar(width, displayText, T.errorBarBg, '196', ' x ', true));
@@ -78,6 +106,7 @@ export function renderConversationAssistantMessage(
78
106
  collapseThreshold: number,
79
107
  msgIdx: number,
80
108
  ): void {
109
+ const T = activeTheme();
81
110
  const assistantHeaderDetails = [];
82
111
  if (message.model) {
83
112
  assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: T.modelNameDim, dim: true });
@@ -195,7 +224,11 @@ export function renderConversationAssistantMessage(
195
224
 
196
225
  if (message.toolCalls && message.toolCalls.length > 0) {
197
226
  for (const tc of message.toolCalls) {
198
- 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));
199
232
  }
200
233
  }
201
234
  }
@@ -223,6 +256,7 @@ export function renderConversationToolMessage(
223
256
  width: number,
224
257
  msgIdx: number,
225
258
  ): void {
259
+ const T = activeTheme();
226
260
  const collapseKey = `msg_${msgIdx}`;
227
261
  const blockIdx = context.blockRegistry.length;
228
262
  const startLine = context.history.getLineCount();
@@ -236,7 +270,17 @@ export function renderConversationToolMessage(
236
270
  // block-registry meta merge below regardless of collapsed/expanded state.
237
271
  const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
238
272
 
239
- 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;
240
284
  const isCollapsed = isShort
241
285
  ? false
242
286
  : context.collapseState.has(collapseKey)
@@ -262,11 +306,20 @@ export function renderConversationToolMessage(
262
306
 
263
307
  if (isCollapsed) {
264
308
  const collapseSuffixReserve = 30;
265
- 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);
266
310
  const hiddenCount = lineCount - 1;
267
- const collapsedText = hiddenCount > 0
268
- ? `${preview}... [${GLYPHS.navigation.collapsed} ${hiddenCount} hidden]`
269
- : 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
+ }
270
323
  const rendered = renderConversationCollapsedFragment(collapsedText, width, {
271
324
  prefix: blockType === 'diff' ? ` ${GLYPHS.status.dualPane} ` : ` ${GLYPHS.navigation.collapsed} `,
272
325
  prefixFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
@@ -325,23 +378,28 @@ export function appendConversationMessages(
325
378
  ): void {
326
379
  const lineNumberMode = context.configManager?.get('display.lineNumbers') ?? 'off';
327
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) };
328
386
 
329
387
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
330
388
  const message = messages[msgIdx];
331
389
  // absoluteIdx aligns the slice-relative loop counter with the absolute
332
390
  // message index used as the key in messageKindRegistry and messageLineRegistry.
333
391
  const absoluteIdx = msgIndexOffset + msgIdx;
334
- messageLineRegistry[absoluteIdx] = context.history.getLineCount();
392
+ messageLineRegistry[absoluteIdx] = renderContext.history.getLineCount();
335
393
  if (message.role === 'user') {
336
- renderConversationUserMessage(context, message, width);
394
+ renderConversationUserMessage(renderContext, message, width);
337
395
  } else if (message.role === 'assistant') {
338
- renderConversationAssistantMessage(context, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
396
+ renderConversationAssistantMessage(renderContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
339
397
  } else if (message.role === 'system') {
340
- renderConversationSystemMessage(context, message, width, absoluteIdx);
398
+ renderConversationSystemMessage(renderContext, message, width, absoluteIdx);
341
399
  } else if (message.role === 'tool') {
342
- renderConversationToolMessage(context, message, width, absoluteIdx);
400
+ renderConversationToolMessage(renderContext, message, width, absoluteIdx);
343
401
  }
344
- context.history.addLine(createEmptyLine(width));
402
+ renderContext.history.addLine(createEmptyLine(width));
345
403
  }
346
404
  }
347
405
 
@@ -402,3 +460,21 @@ export function logConversationText(
402
460
  const lines = text.split('\n').map((line) => UIFactory.stringToLine(indent + line, width, style));
403
461
  context.history.addLines(lines);
404
462
  }
463
+
464
+ /**
465
+ * logConversationToolResult - Append a single tool-call-style result line to the
466
+ * display history only, reusing renderToolCallBlock so a display-only render (e.g.
467
+ * a slash command's subprocess result) is visually indistinguishable from the
468
+ * model's own tool-call results. Display-only: never touches message history.
469
+ */
470
+ export function logConversationToolResult(
471
+ context: Pick<ConversationRenderContext, 'history'>,
472
+ width: number,
473
+ toolCall: ToolCall,
474
+ status: 'done' | 'error',
475
+ resultSummary: string,
476
+ durationMs: number,
477
+ errorMsg?: string,
478
+ ): void {
479
+ context.history.addLines(renderToolCallBlock(toolCall, status, resultSummary, width, durationMs, errorMsg));
480
+ }
@@ -18,6 +18,7 @@ import {
18
18
  addConversationSplashScreen,
19
19
  conversationTextToLines,
20
20
  logConversationText,
21
+ logConversationToolResult,
21
22
  renderConversationAssistantMessage,
22
23
  renderConversationSystemMessage,
23
24
  renderConversationToolMessage,
@@ -727,6 +728,23 @@ export class ConversationManager extends SdkConversationManager {
727
728
  logConversationText(this.renderingContext(), this._getWidth(), text, style, indent);
728
729
  }
729
730
 
731
+ /**
732
+ * logToolResultBlock - Append a single tool-call-styled result line to the display
733
+ * history only (never touches the LLM message list), so a slash command's subprocess
734
+ * result (e.g. /test) renders visually identical to a real tool-call result without
735
+ * leaking into model context.
736
+ */
737
+ public logToolResultBlock(
738
+ toolCall: ToolCall,
739
+ status: 'done' | 'error',
740
+ resultSummary: string,
741
+ durationMs: number,
742
+ errorMsg?: string,
743
+ ): void {
744
+ logConversationToolResult(this.renderingContext(), this._getWidth(), toolCall, status, resultSummary, durationMs, errorMsg);
745
+ this.markDirty();
746
+ }
747
+
730
748
  /**
731
749
  * clearDisplay - Clear the visual history buffer without touching the LLM context messages.
732
750
  * The next render will show a blank conversation area. Subsequent message additions
@@ -0,0 +1,41 @@
1
+ /**
2
+ * focus-tracker — tracks whether the terminal window currently has OS-level
3
+ * input focus, using the terminal focus-reporting protocol (DECSET ?1004h,
4
+ * enabled at terminal setup in main.ts). The SDK's InputTokenizer already
5
+ * parses the resulting `\x1b[I` (focus-in) / `\x1b[O` (focus-out) sequences
6
+ * into `{ type: 'focus', action }` tokens (platform/core/tokenizer.ts); this
7
+ * module is the single place those tokens land (consumed in
8
+ * src/input/handler-feed.ts's feedInputTokens()).
9
+ *
10
+ * Honest degradation: a terminal that does not implement focus reporting
11
+ * (or a multiplexer that swallows the sequences) never sends focus tokens.
12
+ * In that case `isFocused()` stays `null` forever — "unknown", never a
13
+ * guessed `true`/`false`. Callers that gate a user-facing behavior on focus
14
+ * (e.g. the unfocused-alert notifiers in this directory) must treat `null`
15
+ * the same as "not focused" per the fallback rule: alerts fire when the
16
+ * terminal is definitely unfocused OR when focus state was never observed.
17
+ */
18
+ export class FocusTracker {
19
+ private focused: boolean | null = null;
20
+
21
+ /** True/false once at least one focus token has arrived; null if none ever has. */
22
+ isFocused(): boolean | null {
23
+ return this.focused;
24
+ }
25
+
26
+ /** Called from the input pipeline on every focus token. */
27
+ setFocused(value: boolean): void {
28
+ this.focused = value;
29
+ }
30
+
31
+ /**
32
+ * The alert-gating rule shared by every unfocused-alert notifier: fire
33
+ * when the terminal is known to be unfocused, or when focus was never
34
+ * observed (honest fallback — never silently suppress on an unknown
35
+ * terminal). Only `isFocused() === true` (a real, observed focus-in with
36
+ * no observed focus-out since) suppresses an alert.
37
+ */
38
+ shouldAlertWhenUnfocused(): boolean {
39
+ return this.focused !== true;
40
+ }
41
+ }
@@ -18,15 +18,21 @@
18
18
  * When neither target is available the function is an honest no-op (debug log
19
19
  * only; no user-facing error spam).
20
20
  *
21
- * Focus tracking: terminal focus state is not tracked anywhere in the TUI.
22
- * Notifications therefore fire regardless of whether the terminal window is
23
- * focused. A future implementation may suppress notifications when the TUI is
24
- * in the foreground by reading a focus-state ref. // seam: wire focus ref here.
21
+ * Focus tracking (W2.3): when `focusTracker` is supplied, notifications are
22
+ * gated the same way as the other unfocused-user alert classes (see
23
+ * alert-gating.ts) they fire when the terminal is unfocused or focus state
24
+ * was never observed, and are suppressed when it's known to be focused,
25
+ * unless `behavior.notifyOnlyWhenUnfocused` is turned off. `focusTracker` is
26
+ * optional and defaults to "always fire" (the pre-W2.3 behavior) when
27
+ * omitted, so existing callers that don't have a FocusTracker in scope are
28
+ * unaffected.
25
29
  */
26
30
 
27
31
  import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
28
32
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
29
33
  import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
34
+ import type { FocusTracker } from './focus-tracker.ts';
35
+ import { readNotifyOnlyWhenUnfocused, type ConfigGet } from './alert-gating.ts';
30
36
 
31
37
  /** Default threshold in seconds. Turns shorter than this do not notify. */
32
38
  export const NOTIFY_AFTER_SECONDS_DEFAULT = 60;
@@ -74,6 +80,18 @@ export interface MaybeNotifyLongTaskOptions {
74
80
  * skipped silently.
75
81
  */
76
82
  readonly webhookNotifier?: WebhookNotifier | null;
83
+
84
+ /**
85
+ * Terminal focus tracker (W2.3). When supplied together with `configGet`,
86
+ * the notification additionally respects behavior.notifyOnlyWhenUnfocused
87
+ * (default on): suppressed when the terminal is known to be focused, fires
88
+ * when unfocused or when focus was never observed. Omit both to preserve
89
+ * pre-W2.3 behavior (always fire once the threshold is met).
90
+ */
91
+ readonly focusTracker?: Pick<FocusTracker, 'shouldAlertWhenUnfocused'> | null;
92
+
93
+ /** Config reader used only for the notifyOnlyWhenUnfocused gate above. */
94
+ readonly configGet?: ConfigGet;
77
95
  }
78
96
 
79
97
  /**
@@ -81,13 +99,14 @@ export interface MaybeNotifyLongTaskOptions {
81
99
  * exceeds the configured threshold.
82
100
  *
83
101
  * Returns true when at least one delivery was attempted, false when the
84
- * call was a no-op (threshold not reached, or off-state).
102
+ * call was a no-op (threshold not reached, off-state, or focus-gated off
103
+ * see `focusTracker`/`configGet` above).
85
104
  *
86
105
  * PRIVACY: builds message text from structural metadata only (kind, elapsed,
87
106
  * status, sessionId). Never includes conversation content.
88
107
  */
89
108
  export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
90
- const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier } = opts;
109
+ const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier, focusTracker, configGet } = opts;
91
110
 
92
111
  // Off-state: 0 disables notifications entirely.
93
112
  if (thresholdSeconds === NOTIFY_AFTER_SECONDS_OFF) {
@@ -102,6 +121,14 @@ export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
102
121
  return false;
103
122
  }
104
123
 
124
+ // Focus gate (W2.3): only applied when both a tracker and a config reader
125
+ // are supplied. Absent either one, behavior is unchanged from pre-W2.3
126
+ // (always fire once the threshold is met).
127
+ if (focusTracker && configGet && readNotifyOnlyWhenUnfocused(configGet) && !focusTracker.shouldAlertWhenUnfocused()) {
128
+ logger.debug('long-task-notifier: suppressed — terminal focused');
129
+ return false;
130
+ }
131
+
105
132
  // Build concise, metadata-only message. No conversation text.
106
133
  const statusLabel = status === 'ok' ? 'completed' : 'failed';
107
134
  const title = `GoodVibes — ${kind} ${statusLabel}`;
@@ -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
+ }