@pellux/goodvibes-tui 1.0.0 → 1.7.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 (209) hide show
  1. package/CHANGELOG.md +130 -127
  2. package/README.md +32 -14
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/alert-gating.ts +67 -0
  8. package/src/core/approval-alert.ts +72 -0
  9. package/src/core/budget-breach-notifier.ts +106 -0
  10. package/src/core/conversation-rendering.ts +19 -0
  11. package/src/core/conversation.ts +58 -0
  12. package/src/core/focus-tracker.ts +41 -0
  13. package/src/core/long-task-notifier.ts +33 -6
  14. package/src/core/stream-event-wiring.ts +104 -2
  15. package/src/core/stream-stall-watchdog.ts +41 -17
  16. package/src/core/system-message-router.ts +37 -51
  17. package/src/core/turn-cancellation.ts +20 -0
  18. package/src/core/turn-event-wiring.ts +64 -3
  19. package/src/export/cost-utils.ts +126 -8
  20. package/src/input/command-registry.ts +54 -1
  21. package/src/input/commands/checkpoint-runtime.ts +280 -0
  22. package/src/input/commands/codebase-runtime.ts +192 -0
  23. package/src/input/commands/diff-runtime.ts +61 -30
  24. package/src/input/commands/eval.ts +1 -1
  25. package/src/input/commands/health-runtime.ts +1 -1
  26. package/src/input/commands/image-runtime.ts +112 -0
  27. package/src/input/commands/intelligence-runtime.ts +11 -1
  28. package/src/input/commands/local-auth-runtime.ts +4 -1
  29. package/src/input/commands/local-runtime.ts +9 -3
  30. package/src/input/commands/marketplace-runtime.ts +2 -2
  31. package/src/input/commands/memory.ts +6 -1
  32. package/src/input/commands/operator-panel-runtime.ts +40 -24
  33. package/src/input/commands/planning-runtime.ts +26 -3
  34. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  35. package/src/input/commands/plugin-runtime.ts +2 -2
  36. package/src/input/commands/policy-dispatch.ts +21 -0
  37. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  38. package/src/input/commands/qrcode-runtime.ts +3 -3
  39. package/src/input/commands/recall-review.ts +35 -0
  40. package/src/input/commands/remote-runtime.ts +3 -3
  41. package/src/input/commands/runtime-services.ts +54 -13
  42. package/src/input/commands/services-runtime.ts +1 -1
  43. package/src/input/commands/session-content.ts +12 -0
  44. package/src/input/commands/session-workflow.ts +19 -1
  45. package/src/input/commands/settings-sync-runtime.ts +1 -1
  46. package/src/input/commands/share-runtime.ts +9 -2
  47. package/src/input/commands/shell-core.ts +15 -7
  48. package/src/input/commands/skills-runtime.ts +2 -8
  49. package/src/input/commands/subscription-runtime.ts +2 -2
  50. package/src/input/commands/test-runtime.ts +277 -0
  51. package/src/input/commands/websearch-runtime.ts +101 -0
  52. package/src/input/commands/work-plan-runtime.ts +36 -10
  53. package/src/input/commands/workstream-runtime.ts +338 -0
  54. package/src/input/commands.ts +12 -0
  55. package/src/input/config-modal-types.ts +161 -0
  56. package/src/input/config-modal.ts +377 -0
  57. package/src/input/feed-context-factory.ts +7 -8
  58. package/src/input/handler-command-route.ts +27 -17
  59. package/src/input/handler-content-actions.ts +22 -8
  60. package/src/input/handler-feed-routes.ts +107 -7
  61. package/src/input/handler-feed.ts +49 -13
  62. package/src/input/handler-interactions.ts +3 -3
  63. package/src/input/handler-modal-routes.ts +65 -0
  64. package/src/input/handler-modal-stack.ts +3 -5
  65. package/src/input/handler-modal-token-routes.ts +16 -49
  66. package/src/input/handler-picker-routes.ts +11 -94
  67. package/src/input/handler-shortcuts.ts +24 -14
  68. package/src/input/handler-types.ts +3 -6
  69. package/src/input/handler-ui-state.ts +10 -22
  70. package/src/input/handler.ts +10 -28
  71. package/src/input/keybindings.ts +22 -8
  72. package/src/input/model-picker-types.ts +24 -6
  73. package/src/input/model-picker.ts +58 -0
  74. package/src/input/panel-integration-actions.ts +9 -170
  75. package/src/input/settings-modal-data.ts +95 -0
  76. package/src/input/settings-modal-mutations.ts +6 -4
  77. package/src/main.ts +41 -44
  78. package/src/panels/agent-inspector-shared.ts +35 -11
  79. package/src/panels/base-panel.ts +22 -1
  80. package/src/panels/builtin/agent.ts +21 -107
  81. package/src/panels/builtin/development.ts +16 -62
  82. package/src/panels/builtin/knowledge.ts +8 -32
  83. package/src/panels/builtin/operations.ts +84 -428
  84. package/src/panels/builtin/session.ts +21 -112
  85. package/src/panels/builtin/shared.ts +9 -43
  86. package/src/panels/builtin-modals.ts +218 -0
  87. package/src/panels/builtin-panels.ts +5 -0
  88. package/src/panels/cost-tracker-panel.ts +63 -14
  89. package/src/panels/diff-panel.ts +123 -60
  90. package/src/panels/eval-registry.ts +60 -0
  91. package/src/panels/fleet-panel.ts +800 -0
  92. package/src/panels/fleet-read-model.ts +477 -0
  93. package/src/panels/fleet-steer.ts +92 -0
  94. package/src/panels/fleet-stop.ts +125 -0
  95. package/src/panels/fleet-tabs.ts +230 -0
  96. package/src/panels/fleet-transcript.ts +356 -0
  97. package/src/panels/git-panel.ts +9 -9
  98. package/src/panels/index.ts +7 -31
  99. package/src/panels/local-auth-panel.ts +10 -0
  100. package/src/panels/modals/hooks-modal.ts +187 -0
  101. package/src/panels/modals/keybindings-modal.ts +151 -0
  102. package/src/panels/modals/knowledge-modal.ts +158 -0
  103. package/src/panels/modals/local-auth-modal.ts +132 -0
  104. package/src/panels/modals/marketplace-modal.ts +218 -0
  105. package/src/panels/modals/memory-modal.ts +180 -0
  106. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  107. package/src/panels/modals/modal-theme.ts +36 -0
  108. package/src/panels/modals/pairing-modal.ts +144 -0
  109. package/src/panels/modals/planning-modal.ts +282 -0
  110. package/src/panels/modals/plugins-modal.ts +174 -0
  111. package/src/panels/modals/policy-modal.ts +256 -0
  112. package/src/panels/modals/provider-health-modal.ts +136 -0
  113. package/src/panels/modals/remote-modal.ts +115 -0
  114. package/src/panels/modals/sandbox-modal.ts +150 -0
  115. package/src/panels/modals/security-modal.ts +217 -0
  116. package/src/panels/modals/services-modal.ts +179 -0
  117. package/src/panels/modals/settings-sync-modal.ts +142 -0
  118. package/src/panels/modals/skills-modal.ts +189 -0
  119. package/src/panels/modals/subscription-modal.ts +172 -0
  120. package/src/panels/modals/work-plan-modal.ts +149 -0
  121. package/src/panels/panel-confirm-overlay.ts +72 -0
  122. package/src/panels/panel-manager.ts +108 -5
  123. package/src/panels/project-planning-answer-actions.ts +4 -2
  124. package/src/panels/scrollable-list-panel.ts +9 -0
  125. package/src/panels/skills-panel.ts +7 -51
  126. package/src/panels/token-budget-panel.ts +5 -3
  127. package/src/panels/types.ts +26 -0
  128. package/src/permissions/hunk-selection.ts +179 -0
  129. package/src/permissions/prompt.ts +60 -4
  130. package/src/renderer/compaction-history-modal.ts +19 -3
  131. package/src/renderer/compaction-preview.ts +13 -2
  132. package/src/renderer/compaction-quality.ts +100 -0
  133. package/src/renderer/config-modal.ts +81 -0
  134. package/src/renderer/conversation-overlays.ts +10 -17
  135. package/src/renderer/fleet-tab-strip.ts +56 -0
  136. package/src/renderer/footer-tips.ts +10 -2
  137. package/src/renderer/git-status.ts +40 -0
  138. package/src/renderer/help-overlay.ts +2 -2
  139. package/src/renderer/model-workspace.ts +44 -1
  140. package/src/renderer/panel-workspace-bar.ts +8 -2
  141. package/src/renderer/shell-surface.ts +8 -1
  142. package/src/renderer/turn-injection.ts +114 -0
  143. package/src/renderer/ui-factory.ts +91 -7
  144. package/src/runtime/bootstrap-command-context.ts +24 -1
  145. package/src/runtime/bootstrap-command-parts.ts +36 -7
  146. package/src/runtime/bootstrap-core.ts +19 -4
  147. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  148. package/src/runtime/bootstrap-shell.ts +41 -17
  149. package/src/runtime/bootstrap.ts +11 -17
  150. package/src/runtime/code-index-services.ts +112 -0
  151. package/src/runtime/orchestrator-core-services.ts +49 -0
  152. package/src/runtime/process-lifecycle.ts +3 -1
  153. package/src/runtime/services.ts +91 -43
  154. package/src/runtime/ui-services.ts +8 -0
  155. package/src/runtime/workstream-services.ts +195 -0
  156. package/src/shell/blocking-input.ts +22 -1
  157. package/src/shell/ui-openers.ts +129 -17
  158. package/src/utils/format-duration.ts +8 -18
  159. package/src/utils/splash-lines.ts +1 -1
  160. package/src/version.ts +1 -1
  161. package/src/panels/agent-inspector-panel.ts +0 -786
  162. package/src/panels/approval-panel.ts +0 -252
  163. package/src/panels/automation-control-panel.ts +0 -479
  164. package/src/panels/cockpit-panel.ts +0 -481
  165. package/src/panels/cockpit-read-model.ts +0 -233
  166. package/src/panels/communication-panel.ts +0 -256
  167. package/src/panels/control-plane-panel.ts +0 -470
  168. package/src/panels/debug-panel.ts +0 -609
  169. package/src/panels/docs-panel.ts +0 -375
  170. package/src/panels/eval-panel.ts +0 -627
  171. package/src/panels/file-explorer-panel.ts +0 -664
  172. package/src/panels/file-preview-panel.ts +0 -517
  173. package/src/panels/hooks-panel.ts +0 -401
  174. package/src/panels/incident-review-panel.ts +0 -406
  175. package/src/panels/intelligence-panel.ts +0 -383
  176. package/src/panels/knowledge-graph-panel.ts +0 -506
  177. package/src/panels/marketplace-panel.ts +0 -399
  178. package/src/panels/memory-panel.ts +0 -558
  179. package/src/panels/ops-control-panel.ts +0 -329
  180. package/src/panels/ops-strategy-panel.ts +0 -321
  181. package/src/panels/orchestration-panel.ts +0 -465
  182. package/src/panels/panel-list-panel.ts +0 -557
  183. package/src/panels/plan-dashboard-panel.ts +0 -707
  184. package/src/panels/policy-panel.ts +0 -517
  185. package/src/panels/project-planning-panel.ts +0 -721
  186. package/src/panels/provider-health-panel.ts +0 -678
  187. package/src/panels/provider-health-tracker.ts +0 -306
  188. package/src/panels/provider-health-views.ts +0 -560
  189. package/src/panels/qr-panel.ts +0 -280
  190. package/src/panels/remote-panel.ts +0 -534
  191. package/src/panels/routes-panel.ts +0 -241
  192. package/src/panels/sandbox-panel.ts +0 -456
  193. package/src/panels/security-panel.ts +0 -447
  194. package/src/panels/services-panel.ts +0 -329
  195. package/src/panels/session-browser-panel.ts +0 -487
  196. package/src/panels/settings-sync-panel.ts +0 -398
  197. package/src/panels/subscription-panel.ts +0 -342
  198. package/src/panels/symbol-outline-panel.ts +0 -619
  199. package/src/panels/system-messages-panel.ts +0 -364
  200. package/src/panels/tasks-panel.ts +0 -608
  201. package/src/panels/thinking-panel.ts +0 -333
  202. package/src/panels/tool-inspector-panel.ts +0 -537
  203. package/src/panels/work-plan-panel.ts +0 -530
  204. package/src/panels/worktree-panel.ts +0 -360
  205. package/src/panels/wrfc-panel.ts +0 -790
  206. package/src/renderer/agent-detail-modal.ts +0 -465
  207. package/src/renderer/live-tail-modal.ts +0 -156
  208. package/src/renderer/process-modal.ts +0 -656
  209. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,72 @@
1
+ /**
2
+ * approval-alert — fires an unfocused-user alert the moment a tool call
3
+ * becomes a real, user-blocking permission prompt.
4
+ *
5
+ * Anchor: `PERMISSION_REQUESTED` (SDK events/permissions.ts) is the wrong
6
+ * signal — it fires for every policy-evaluation step, including ones
7
+ * auto-resolved by allow-lists/yolo-mode in milliseconds. The only place a
8
+ * request becomes an actual user-facing prompt is where main.ts assigns
9
+ * `permissionPromptRef.requestPermission`. There is no `permissions` feed on
10
+ * UiRuntimeEvents to subscribe to instead, so this wraps the handler at that
11
+ * assignment site.
12
+ *
13
+ * wrapRequestPermissionWithAlert fires the alert synchronously (before the
14
+ * wrapped promise settles) so it reflects "a prompt just appeared", not
15
+ * "a prompt was just resolved".
16
+ *
17
+ * PRIVACY: message content is tool name + permission category only — no
18
+ * args, no file contents, no command strings (mirrors long-task-notifier's
19
+ * privacy guarantee).
20
+ */
21
+ import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
22
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
23
+ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
24
+ import type { PermissionRequestHandler, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
25
+ import type { FocusTracker } from './focus-tracker.ts';
26
+ import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
27
+
28
+ export interface ApprovalAlertDeps {
29
+ readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
30
+ readonly configGet: ConfigGet;
31
+ readonly webhookNotifier?: WebhookNotifier | null;
32
+ }
33
+
34
+ /**
35
+ * Wrap a PermissionRequestHandler so every call also fires an unfocused-user
36
+ * alert (subject to the behavior.notifyOnApprovalPending / notifyOnlyWhenUnfocused
37
+ * gates). The wrapped handler's behavior (what it resolves to) is completely
38
+ * unchanged — this only adds a side effect at call time.
39
+ */
40
+ export function wrapRequestPermissionWithAlert(
41
+ original: PermissionRequestHandler,
42
+ deps: ApprovalAlertDeps,
43
+ ): PermissionRequestHandler {
44
+ return (request: PermissionPromptRequest) => {
45
+ fireApprovalAlert(request, deps);
46
+ return original(request);
47
+ };
48
+ }
49
+
50
+ function fireApprovalAlert(request: PermissionPromptRequest, deps: ApprovalAlertDeps): void {
51
+ if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnApprovalPending')) return;
52
+
53
+ const title = 'GoodVibes — approval needed';
54
+ // PRIVACY: tool name + category only. Never request.args.
55
+ const message = `${request.tool} (${request.category}) is waiting for approval`;
56
+
57
+ try {
58
+ notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
59
+ } catch (err) {
60
+ logger.debug('approval-alert: desktop notify error', { error: String(err) });
61
+ }
62
+
63
+ const webhookNotifier = deps.webhookNotifier;
64
+ if (webhookNotifier) {
65
+ const urls = webhookNotifier.getUrls();
66
+ if (urls.length > 0) {
67
+ webhookNotifier.send(message).catch((err: unknown) => {
68
+ logger.debug('approval-alert: webhook send error', { error: String(err) });
69
+ });
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * budget-breach-notifier — fires an unfocused-user alert the moment session
3
+ * cost crosses the configured budget threshold (behavior.budgetAlertUsd).
4
+ *
5
+ * One-shot semantics: fires exactly once on the false→true edge (crossing
6
+ * into breach), never again while still over budget on every subsequent
7
+ * turn. The "already notified" latch resets when the session drops back
8
+ * under budget (so a later re-breach can fire again) or when the threshold
9
+ * itself changes (raising, lowering, or clearing the budget re-arms it
10
+ * against the new value).
11
+ *
12
+ * Delivery mirrors long-task-notifier.ts: desktop notification
13
+ * (notifyCompletion) + outbound webhook (WebhookNotifier), both gated by
14
+ * the shared focus/config gating in alert-gating.ts. Skips evaluation
15
+ * entirely when the session model is unpriced — a $0 placeholder cost must
16
+ * never be reported as a real breach (mirrors CostTrackerPanel's "unpriced"
17
+ * display convention).
18
+ *
19
+ * PRIVACY: message text is built from cost/threshold numbers and the
20
+ * session id prefix only — no conversation content.
21
+ */
22
+ import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
23
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
24
+ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
25
+ import { calcSessionCost, computeBudgetBreach, isModelPriced } from '../export/cost-utils.ts';
26
+ import type { FocusTracker } from './focus-tracker.ts';
27
+ import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
28
+
29
+ export interface BudgetBreachUsageSnapshot {
30
+ readonly input: number;
31
+ readonly output: number;
32
+ readonly cacheRead: number;
33
+ readonly cacheWrite: number;
34
+ }
35
+
36
+ export interface BudgetBreachNotifierDeps {
37
+ readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
38
+ readonly configGet: ConfigGet;
39
+ readonly webhookNotifier?: WebhookNotifier | null;
40
+ readonly sessionId: string;
41
+ }
42
+
43
+ /** Stateful edge-trigger checker — construct once per session, call on every TURN_COMPLETED. */
44
+ export interface BudgetBreachNotifier {
45
+ /**
46
+ * Evaluate current usage against the configured threshold and fire an
47
+ * alert on the false→true breach edge. Returns true when an alert was
48
+ * fired this call (for tests / observability), false otherwise.
49
+ */
50
+ check(usage: BudgetBreachUsageSnapshot, sessionModel: string, budgetThresholdUsd: number): boolean;
51
+ }
52
+
53
+ export function createBudgetBreachNotifier(deps: BudgetBreachNotifierDeps): BudgetBreachNotifier {
54
+ let lastThreshold: number | null = null;
55
+ let notified = false;
56
+
57
+ return {
58
+ check(usage, sessionModel, budgetThresholdUsd) {
59
+ if (budgetThresholdUsd !== lastThreshold) {
60
+ // Threshold raised, lowered, or cleared since the last check — re-arm
61
+ // the latch so a breach against the new threshold can fire again.
62
+ lastThreshold = budgetThresholdUsd;
63
+ notified = false;
64
+ }
65
+
66
+ if (budgetThresholdUsd <= 0) return false; // disabled
67
+ if (!isModelPriced(sessionModel)) return false; // cost would be a placeholder, not real
68
+
69
+ const sessionCost = calcSessionCost(usage.input, usage.output, usage.cacheRead, usage.cacheWrite, sessionModel);
70
+ const breached = computeBudgetBreach(sessionCost, budgetThresholdUsd);
71
+
72
+ if (!breached) {
73
+ notified = false; // dropped back under budget — can re-fire on the next crossing
74
+ return false;
75
+ }
76
+ if (notified) return false; // already alerted for this crossing
77
+
78
+ notified = true;
79
+ fireBudgetBreachAlert(deps, sessionCost, budgetThresholdUsd);
80
+ return true;
81
+ },
82
+ };
83
+ }
84
+
85
+ function fireBudgetBreachAlert(deps: BudgetBreachNotifierDeps, sessionCost: number, budgetThresholdUsd: number): void {
86
+ if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnBudgetBreach')) return;
87
+
88
+ const title = 'GoodVibes — budget breach';
89
+ const message = `session cost $${sessionCost.toFixed(2)} exceeded budget $${budgetThresholdUsd.toFixed(2)} · session ${deps.sessionId.slice(0, 8)}`;
90
+
91
+ try {
92
+ notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
93
+ } catch (err) {
94
+ logger.debug('budget-breach-notifier: desktop notify error', { error: String(err) });
95
+ }
96
+
97
+ const webhookNotifier = deps.webhookNotifier;
98
+ if (webhookNotifier) {
99
+ const urls = webhookNotifier.getUrls();
100
+ if (urls.length > 0) {
101
+ webhookNotifier.send(message).catch((err: unknown) => {
102
+ logger.debug('budget-breach-notifier: webhook send error', { error: String(err) });
103
+ });
104
+ }
105
+ }
106
+ }
@@ -3,6 +3,7 @@ import { renderMarkdownTracked } from '../renderer/markdown.ts';
3
3
  import { renderDiffView } from '../renderer/diff-view.ts';
4
4
  import { DARK_THEME } from '../renderer/theme.ts';
5
5
  import { renderToolCallBlock } from '../renderer/tool-call.ts';
6
+ import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
6
7
  import { renderThinkingBlock } from '../renderer/thinking.ts';
7
8
  import { renderSystemMessage } from '../renderer/system-message.ts';
8
9
  import { createEmptyLine, type Line, type Cell } from '../types/grid.ts';
@@ -402,3 +403,21 @@ export function logConversationText(
402
403
  const lines = text.split('\n').map((line) => UIFactory.stringToLine(indent + line, width, style));
403
404
  context.history.addLines(lines);
404
405
  }
406
+
407
+ /**
408
+ * logConversationToolResult - Append a single tool-call-style result line to the
409
+ * display history only, reusing renderToolCallBlock so a display-only render (e.g.
410
+ * a slash command's subprocess result) is visually indistinguishable from the
411
+ * model's own tool-call results. Display-only: never touches message history.
412
+ */
413
+ export function logConversationToolResult(
414
+ context: Pick<ConversationRenderContext, 'history'>,
415
+ width: number,
416
+ toolCall: ToolCall,
417
+ status: 'done' | 'error',
418
+ resultSummary: string,
419
+ durationMs: number,
420
+ errorMsg?: string,
421
+ ): void {
422
+ context.history.addLines(renderToolCallBlock(toolCall, status, resultSummary, width, durationMs, errorMsg));
423
+ }
@@ -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,
@@ -49,6 +50,46 @@ export type { BlockMeta };
49
50
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
50
51
  type Message = ConversationMessageSnapshot;
51
52
 
53
+ /** Matches Orchestrator.usage's shape (SDK platform/core/orchestrator.ts). Not
54
+ * exported by the SDK as a standalone type, so mirrored here. */
55
+ export interface OrchestratorUsageTotals {
56
+ input: number;
57
+ output: number;
58
+ cacheRead: number;
59
+ cacheWrite: number;
60
+ }
61
+
62
+ /**
63
+ * sumConversationUsage - Fold every assistant message's per-turn usage into
64
+ * running totals, plus the *last* assistant message's own input-token figure
65
+ * (context-window occupancy — not a running sum).
66
+ *
67
+ * TUI-side counterpart to SDK Orchestrator.usage: after a session resume
68
+ * replays historical messages into a freshly-constructed Orchestrator (whose
69
+ * `usage` starts at all zeros — bootstrap.ts always constructs a fresh
70
+ * instance, see W0.9), this lets the caller hydrate the footer's token
71
+ * counters from messages that already carry real usage data instead of
72
+ * waiting for the first new turn to populate them.
73
+ */
74
+ export function sumConversationUsage(
75
+ messages: readonly ConversationMessageSnapshot[],
76
+ ): { usage: OrchestratorUsageTotals; lastInputTokens: number } {
77
+ const usage: OrchestratorUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
78
+ let lastInputTokens = 0;
79
+ for (const message of messages) {
80
+ if (message.role !== 'assistant' || !message.usage) continue;
81
+ usage.input += message.usage.inputTokens;
82
+ usage.output += message.usage.outputTokens;
83
+ usage.cacheRead += message.usage.cacheReadTokens ?? 0;
84
+ usage.cacheWrite += message.usage.cacheWriteTokens ?? 0;
85
+ // Context-window occupancy tracks the most recent turn only, not a sum.
86
+ lastInputTokens = message.usage.inputTokens
87
+ + (message.usage.cacheReadTokens ?? 0)
88
+ + (message.usage.cacheWriteTokens ?? 0);
89
+ }
90
+ return { usage, lastInputTokens };
91
+ }
92
+
52
93
  export class ConversationManager extends SdkConversationManager {
53
94
  public history = new InfiniteBuffer();
54
95
  private _getWidth: () => number;
@@ -687,6 +728,23 @@ export class ConversationManager extends SdkConversationManager {
687
728
  logConversationText(this.renderingContext(), this._getWidth(), text, style, indent);
688
729
  }
689
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
+
690
748
  /**
691
749
  * clearDisplay - Clear the visual history buffer without touching the LLM context messages.
692
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}`;
@@ -23,6 +23,29 @@ export interface StreamMetrics {
23
23
  activeToolStartedAtMs: number | undefined;
24
24
  /** Name of the currently executing tool; cleared when execution completes. */
25
25
  activeToolName: string | undefined;
26
+ /**
27
+ * Epoch ms of the most recent STREAM_START or STREAM_DELTA; undefined when
28
+ * idle (no turn in flight). Read every render frame — not just on the
29
+ * watchdog's one-shot hint — so "ms since last byte" can be computed even
30
+ * when no new SDK event has arrived at all (a no-delta stall watchdog for
31
+ * the render loop itself, independent of the low-priority system message).
32
+ */
33
+ lastDeltaAtMs: number | undefined;
34
+ /**
35
+ * 1-based count of stall episodes the watchdog has fired for the current
36
+ * turn; 0 = no stall yet. Increments each time a fresh silence (after a
37
+ * recovery) crosses the stall threshold.
38
+ */
39
+ stallEpisode: number;
40
+ /**
41
+ * Populated from the SDK's STREAM_RETRY event when present. SDK 0.35.0 (the
42
+ * pinned dependency) has no such event on the TurnEvent union yet — these
43
+ * fields are consumed structurally (see looseTurnsFeed below) and stay
44
+ * undefined until a future SDK version emits it. Cleared on STREAM_DELTA
45
+ * (a byte arriving means the reconnect, if any, succeeded).
46
+ */
47
+ reconnectAttempt: number | undefined;
48
+ reconnectMaxAttempts: number | undefined;
26
49
  }
27
50
 
28
51
  /** Minimal orchestrator surface required for stream token-speed calculation. */
@@ -60,6 +83,33 @@ interface StreamSystemMessageRouter {
60
83
  low(message: string): void;
61
84
  }
62
85
 
86
+ /**
87
+ * Loosely-typed variant of the turns event feed, used only to subscribe to
88
+ * event names not yet present in the SDK's TurnEvent union (STREAM_RETRY,
89
+ * STREAM_STALL — see the structural-consumption comment at the subscription
90
+ * site below). `events.turns.on` itself stays fully typed against the real
91
+ * TurnEvent union everywhere else in this file.
92
+ */
93
+ interface LooseTurnEventFeed {
94
+ on(type: string, listener: (payload: unknown) => void): () => void;
95
+ }
96
+
97
+ /** Payload shape expected from a future SDK STREAM_RETRY event. */
98
+ interface StreamRetryLikePayload {
99
+ readonly attempt: number;
100
+ readonly maxAttempts: number;
101
+ }
102
+
103
+ /** Runtime guard validating an unknown STREAM_RETRY-like payload's shape. */
104
+ function isStreamRetryLikePayload(payload: unknown): payload is StreamRetryLikePayload {
105
+ return (
106
+ typeof payload === 'object' &&
107
+ payload !== null &&
108
+ typeof (payload as Record<string, unknown>).attempt === 'number' &&
109
+ typeof (payload as Record<string, unknown>).maxAttempts === 'number'
110
+ );
111
+ }
112
+
63
113
  /**
64
114
  * Minimal cost lookup surface for attaching cost-delta information to failover notices.
65
115
  * Returns USD-per-1M-token pricing for the given model ID.
@@ -197,10 +247,19 @@ export function wireStreamEventMetrics(
197
247
  metrics.tokenSpeed = 0;
198
248
  metrics.ttftMs = undefined;
199
249
  metrics.ttftRecorded = false;
250
+ metrics.lastDeltaAtMs = Date.now();
251
+ metrics.stallEpisode = 0;
252
+ metrics.reconnectAttempt = undefined;
253
+ metrics.reconnectMaxAttempts = undefined;
200
254
  }));
201
255
 
202
256
  unsubs.push(events.turns.on('STREAM_DELTA', () => {
203
257
  metrics.deltaCount++;
258
+ metrics.lastDeltaAtMs = Date.now();
259
+ // A byte arrived: any in-flight reconnect (if the SDK reported one)
260
+ // has succeeded.
261
+ metrics.reconnectAttempt = undefined;
262
+ metrics.reconnectMaxAttempts = undefined;
204
263
  const elapsed = (Date.now() - metrics.startTime) / 1000;
205
264
  // Record TTFT on the first delta of each turn.
206
265
  if (!metrics.ttftRecorded) {
@@ -296,10 +355,16 @@ export function wireStreamEventMetrics(
296
355
  render();
297
356
  }));
298
357
 
299
- // --- Stream stall watchdog: emit one low hint if STREAM_START has no delta within 30s ---
358
+ // --- Stream stall watchdog: emit a low hint each time a no-delta gap (at
359
+ // start of turn OR mid-stream, after a re-arm) crosses 30s. The transient
360
+ // system message below is the initial alert; streamMetrics.stallEpisode
361
+ // (read every render frame) drives the persistent "stalled Ns" indicator
362
+ // in the thinking fragment, so the two coexist rather than conflict — the
363
+ // message announces the stall, the indicator tracks it ongoing.
300
364
  const stallWatchdog = createStreamStallWatchdog({
301
365
  events: events.turns,
302
- onStall: (providerName) => {
366
+ onStall: (providerName, episode) => {
367
+ metrics.stallEpisode = episode;
303
368
  systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
304
369
  render();
305
370
  },
@@ -308,22 +373,59 @@ export function wireStreamEventMetrics(
308
373
  });
309
374
  unsubs.push(() => stallWatchdog.dispose());
310
375
 
376
+ // --- Structural consumption of STREAM_RETRY / STREAM_STALL ---
377
+ // Neither event exists on the pinned SDK's (0.35.0) TurnEvent union today —
378
+ // both are proposed additions for the transport-level withRetry() callback
379
+ // (see the stall-honesty audit brief). The typed `events.turns.on` feed
380
+ // rejects unknown event names at compile time, so this casts the feed to a
381
+ // loosely-typed variant and validates each payload with a runtime guard
382
+ // instead of importing a type name that doesn't exist yet — same pattern
383
+ // used elsewhere in this codebase for settings pending SDK schema additions
384
+ // (see src/input/settings-modal-data.ts). Compiles today against 0.35.0;
385
+ // lights up automatically once the SDK adds the real event.
386
+ const looseTurnsFeed = events.turns as unknown as LooseTurnEventFeed;
387
+ unsubs.push(looseTurnsFeed.on('STREAM_RETRY', (payload) => {
388
+ if (!isStreamRetryLikePayload(payload)) return;
389
+ metrics.reconnectAttempt = payload.attempt;
390
+ metrics.reconnectMaxAttempts = payload.maxAttempts;
391
+ render();
392
+ }));
393
+ unsubs.push(looseTurnsFeed.on('STREAM_STALL', () => {
394
+ // Informational only: the TUI's own createStreamStallWatchdog above is
395
+ // the authoritative no-delta detector and already drives the visible
396
+ // indicator via streamMetrics.stallEpisode. This subscription exists so
397
+ // the SDK's own signal (once it lands) is observed rather than silently
398
+ // dropped, without duplicating or fighting the local watchdog.
399
+ render();
400
+ }));
401
+
311
402
  unsubs.push(events.tools.on('TOOL_EXECUTING', (ev) => {
312
403
  metrics.activeToolStartedAtMs = ev.startedAt;
313
404
  metrics.activeToolName = ev.tool;
314
405
  render();
315
406
  }));
407
+ // On every tool-completion path, reset lastDeltaAtMs to "now" so the
408
+ // post-tool silence window starts fresh. Tool execution suppresses stall
409
+ // detection at the render call site (see main.ts), but lastDeltaAtMs itself
410
+ // keeps its pre-tool value the whole time the tool runs — without this
411
+ // reset, the instant a tool completes (potentially long after the last real
412
+ // delta), the very next frame would read msSinceLastDelta as the full
413
+ // tool-execution duration and immediately report a stall, even though the
414
+ // model hasn't had a chance to resume producing tokens yet.
316
415
  unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
317
416
  metrics.activeToolStartedAtMs = undefined;
318
417
  metrics.activeToolName = undefined;
418
+ metrics.lastDeltaAtMs = Date.now();
319
419
  }));
320
420
  unsubs.push(events.tools.on('TOOL_FAILED', () => {
321
421
  metrics.activeToolStartedAtMs = undefined;
322
422
  metrics.activeToolName = undefined;
423
+ metrics.lastDeltaAtMs = Date.now();
323
424
  }));
324
425
  unsubs.push(events.tools.on('TOOL_CANCELLED', () => {
325
426
  metrics.activeToolStartedAtMs = undefined;
326
427
  metrics.activeToolName = undefined;
428
+ metrics.lastDeltaAtMs = Date.now();
327
429
  }));
328
430
 
329
431
  let _errorSurfacedCb: ((exhausted: boolean) => void) | undefined;
@@ -1,15 +1,26 @@
1
1
  /**
2
- * StreamStallWatchdog — detects STREAM_START events where no STREAM_DELTA
3
- * arrives within a configurable threshold, and emits a single low-priority
4
- * hint to the user.
2
+ * StreamStallWatchdog — detects gaps of silence (no STREAM_DELTA) longer than
3
+ * a configurable threshold, whether the gap is at the start of a turn or in
4
+ * the middle of an otherwise-flowing stream, and emits a low-priority hint
5
+ * each time a gap crosses the threshold.
5
6
  *
6
7
  * Design:
7
- * - Arm on STREAM_START: set a timeout for STALL_THRESHOLD_MS.
8
- * - Disarm on STREAM_DELTA: clear the pending timeout (stream is alive).
8
+ * - Arm on STREAM_START: reset the episode counter, set a timeout for
9
+ * STALL_THRESHOLD_MS.
10
+ * - Re-arm on STREAM_DELTA: every byte received resets the no-delta clock,
11
+ * so a stall that begins mid-stream (after tokens were already flowing)
12
+ * is caught just as reliably as a stall at turn start. This is the fix
13
+ * for the "silence after the first delta never re-triggers" gap: the
14
+ * previous behaviour disarmed permanently on the first STREAM_DELTA,
15
+ * which meant a multi-minute stall after output had already started
16
+ * produced zero indication.
9
17
  * - Disarm on STREAM_END / TURN_COMPLETED / TURN_ERROR / TURN_CANCEL:
10
18
  * clear the timeout (turn finished normally or with error).
11
- * - If the timeout fires: emit ONE hint, do NOT repeat until the next turn.
12
- * - Re-arm only on the next STREAM_START (next turn).
19
+ * - If the timeout fires: emit ONE hint for that episode (do not repeat
20
+ * while the same gap continues), then wait for the gap to close (another
21
+ * STREAM_DELTA/STREAM_START) before a further silence can fire again —
22
+ * each such re-arm-then-timeout cycle is a new "stall episode" and the
23
+ * episode counter passed to onStall increments each time.
13
24
  * - dispose(): clears all subscriptions and any pending timeout.
14
25
  *
15
26
  * @module
@@ -32,10 +43,13 @@ export interface StreamStallWatchdogOptions {
32
43
  /** The turns event surface to subscribe on. */
33
44
  events: WatchdogTurnEvents;
34
45
  /**
35
- * Called once per turn when the stall threshold is exceeded.
36
- * Receives the provider display name for the hint message.
46
+ * Called once per stall episode when the no-delta threshold is exceeded.
47
+ * Receives the provider display name and a 1-based episode counter that
48
+ * increments each time a new silence (after a re-arm) crosses the
49
+ * threshold within the same turn — so a second mid-stream stall after a
50
+ * recovery is distinguishable from the first.
37
51
  */
38
- onStall: (providerName: string) => void;
52
+ onStall: (providerName: string, episode: number) => void;
39
53
  /**
40
54
  * Provides the current provider display name at the moment the hint fires.
41
55
  * Optional — defaults to 'provider' when not supplied.
@@ -63,17 +77,19 @@ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): Str
63
77
  const { events, onStall, getProviderName, thresholdMs = STALL_THRESHOLD_MS } = opts;
64
78
 
65
79
  let stallTimer: ReturnType<typeof setTimeout> | null = null;
66
- let hintFiredForTurn = false;
80
+ let hintFiredForEpisode = false;
81
+ let episodeCount = 0;
67
82
 
68
83
  function arm(): void {
69
84
  disarm();
70
- hintFiredForTurn = false;
85
+ hintFiredForEpisode = false;
71
86
  stallTimer = setTimeout(() => {
72
87
  stallTimer = null;
73
- if (!hintFiredForTurn) {
74
- hintFiredForTurn = true;
88
+ if (!hintFiredForEpisode) {
89
+ hintFiredForEpisode = true;
90
+ episodeCount++;
75
91
  const provider = getProviderName ? getProviderName() : 'provider';
76
- onStall(provider);
92
+ onStall(provider, episodeCount);
77
93
  }
78
94
  }, thresholdMs);
79
95
  }
@@ -85,9 +101,17 @@ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): Str
85
101
  }
86
102
  }
87
103
 
104
+ function onStreamStart(): void {
105
+ episodeCount = 0;
106
+ arm();
107
+ }
108
+
88
109
  const unsubs: Array<() => void> = [
89
- events.on('STREAM_START', arm),
90
- events.on('STREAM_DELTA', disarm),
110
+ events.on('STREAM_START', onStreamStart),
111
+ // Re-arm (not disarm) on every delta: the no-delta clock must reset on
112
+ // each byte so a stall that starts mid-stream is still caught, not just
113
+ // a stall before the first byte.
114
+ events.on('STREAM_DELTA', arm),
91
115
  events.on('STREAM_END', disarm),
92
116
  events.on('TURN_COMPLETED', disarm),
93
117
  events.on('TURN_ERROR', disarm),