@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
@@ -2,10 +2,95 @@ import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from '../renderer/ui-factory.ts';
3
3
  import type { PermissionCategory, PermissionRequestAnalysis } from '@pellux/goodvibes-sdk/platform/permissions';
4
4
  import { buildPermissionApprovalBrief, getDisplayArg } from '@pellux/goodvibes-sdk/platform/permissions';
5
+ import { DIFF_TONES, UI_TONES } from '../renderer/ui-primitives.ts';
6
+ import type { HunkSelectionState } from './hunk-selection.ts';
7
+ export { buildPendingPermissionExtras } from './hunk-selection.ts';
5
8
 
6
9
  import type { PermissionPromptRequest, PermissionPromptDecision, PermissionRequestHandler, PermissionRequest } from '@pellux/goodvibes-sdk/platform/permissions';
7
10
  export type { PermissionPromptRequest, PermissionPromptDecision, PermissionRequestHandler, PermissionRequest };
8
11
 
12
+ /** Visible hunk rows before a "+N more" trailer kicks in (Risk 3). */
13
+ const MAX_VISIBLE_HUNKS = 8;
14
+
15
+ /** Path rows shown before collapsing to a "N files: a, b, +K more" line (UX-B 2a). */
16
+ const MAX_PATH_ROWS = 3;
17
+
18
+ /** Trailing filename for compact display of a long/absolute path. */
19
+ function baseName(p: string): string {
20
+ const cleaned = p.replace(/[/\\]+$/, '');
21
+ const idx = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'));
22
+ return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
23
+ }
24
+
25
+ /** Make an absolute path relative to the working directory when it sits under it. */
26
+ function relativize(p: string, cwd?: string): string {
27
+ if (!cwd || !p.startsWith('/')) return p;
28
+ if (p === cwd) return '.';
29
+ const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`;
30
+ return p.startsWith(prefix) ? p.slice(prefix.length) : p;
31
+ }
32
+
33
+ /** Collect string elements or `el[field]` strings from an array arg. */
34
+ function collectField(arr: unknown, field: string): string[] {
35
+ if (!Array.isArray(arr)) return [];
36
+ const out: string[] = [];
37
+ for (const el of arr) {
38
+ if (typeof el === 'string') out.push(el);
39
+ else if (el && typeof el === 'object' && typeof (el as Record<string, unknown>)[field] === 'string') {
40
+ out.push((el as Record<string, unknown>)[field] as string);
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * The real display target(s) of a tool invocation — the paths/commands/urls a
48
+ * user needs to see, extracted from the actual arg shapes so a nested
49
+ * `{files:[{path}]}` no longer falls through to a raw JSON blob. (UX-B 2a.)
50
+ * Returns [] when nothing recognisable is present (caller falls back).
51
+ */
52
+ function cardTargets(args: Record<string, unknown>): string[] {
53
+ const files = collectField(args.files, 'path');
54
+ if (files.length) return files;
55
+ if (typeof args.path === 'string') return [args.path];
56
+ if (typeof args.file === 'string') return [args.file];
57
+ const commands = collectField(args.commands, 'cmd');
58
+ if (commands.length) return commands;
59
+ if (typeof args.command === 'string') return [args.command];
60
+ if (typeof args.cmd === 'string') return [args.cmd];
61
+ if (typeof args.pattern === 'string') return [args.pattern];
62
+ const urls = collectField(args.urls, 'url');
63
+ if (urls.length) return urls;
64
+ const queries = collectField(args.queries, 'query');
65
+ if (queries.length) return queries;
66
+ if (typeof args.query === 'string') return [args.query];
67
+ if (typeof args.task === 'string') return [args.task];
68
+ return [];
69
+ }
70
+
71
+ /** Short human verb for a permission category, for the condensed summary line. */
72
+ function categoryVerb(category: PermissionCategory): string {
73
+ switch (category) {
74
+ case 'write': return 'write';
75
+ case 'execute': return 'run';
76
+ case 'delegate': return 'delegate';
77
+ default: return 'read';
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Single source of truth for how many Line rows the hunk-list section
83
+ * occupies: 1 header row + up to MAX_VISIBLE_HUNKS checkbox rows + 1 trailer
84
+ * row (rendered as "+N more" when truncated, blank otherwise — always
85
+ * present so the row count never depends on which branch fires). Both
86
+ * getPromptHeight and createPromptLines call this SAME function, so they
87
+ * cannot drift apart (Risk 2 — main.ts's render loop reserves viewport
88
+ * space from getPromptHeight *before* the real render happens).
89
+ */
90
+ function hunkListRowCount(hunkState: HunkSelectionState): number {
91
+ return Math.min(hunkState.hunks.length, MAX_VISIBLE_HUNKS) + 2;
92
+ }
93
+
9
94
  /**
10
95
  * PermissionPromptUI - Renders a permission prompt as Line[] fragments.
11
96
  *
@@ -30,11 +115,62 @@ export class PermissionPromptUI {
30
115
  };
31
116
  }
32
117
 
33
- static getPromptHeight(request: PermissionPromptRequest): number {
118
+ /** The relativised display target(s) for the Path field, with a raw fallback. */
119
+ private static resolvedTargets(request: PermissionPromptRequest): string[] {
120
+ const cwd = request.workingDirectory;
121
+ const targets = cardTargets(request.args).map((t) => relativize(t, cwd));
122
+ return targets.length > 0 ? targets : [getDisplayArg(request.tool, request.args)];
123
+ }
124
+
125
+ /** Path-field row texts — one per target when few, a "N files:" summary when many. */
126
+ private static pathRowTexts(targets: string[], maxLen: number): string[] {
127
+ const clamp = (s: string): string => (s.length > maxLen ? `...${s.slice(-(maxLen - 3))}` : s);
128
+ if (targets.length <= 1) return [clamp(targets[0] ?? '(unknown)')];
129
+ if (targets.length <= MAX_PATH_ROWS) return targets.map(clamp);
130
+ const shown = targets.slice(0, 2).map(baseName).join(', ');
131
+ return [clamp(`${targets.length} files: ${shown}, +${targets.length - 2} more`)];
132
+ }
133
+
134
+ /**
135
+ * A low-risk, project-local request is shown condensed (one summary line +
136
+ * choices) unless the user expanded it with `d`. High/critical risk, external
137
+ * scope, and hunk-selectable edits always show the full block. (UX-B 2b.)
138
+ */
139
+ private static isCondensed(
140
+ request: PermissionPromptRequest,
141
+ hunkState: HunkSelectionState | undefined,
142
+ detailsExpanded: boolean,
143
+ ): boolean {
144
+ if (hunkState || detailsExpanded) return false;
145
+ // Shell execution, delegation and network requests always show the full
146
+ // card — their action semantics (command, side effects, host, checklist)
147
+ // warrant scrutiny even when the risk model rates them low. Only mundane
148
+ // low-risk filesystem reads/writes condense. (UX-B 2b.)
149
+ if (request.category === 'execute' || request.category === 'delegate') return false;
150
+ const analysis = this.fallbackAnalysis(request);
151
+ if (analysis.riskLevel !== 'low') return false;
152
+ const scope = analysis.blastRadius;
153
+ return scope === undefined || scope === 'local' || scope === 'project';
154
+ }
155
+
156
+ static getPromptHeight(
157
+ request: PermissionPromptRequest,
158
+ hunkState?: HunkSelectionState,
159
+ detailsExpanded = false,
160
+ ): number {
161
+ // Condensed low-risk card: top separator, title, summary, choices, bottom
162
+ // separator — see createPromptLines' condensed branch (must stay in sync).
163
+ if (this.isCondensed(request, hunkState, detailsExpanded)) return 5;
34
164
  const analysis = this.fallbackAnalysis(request);
35
165
  const reasonLines = Math.min(2, Math.max(1, analysis.reasons.length));
36
166
  const extraLines = (analysis.host ? 1 : 0) + (analysis.surface ? 1 : 0) + (analysis.sideEffects && analysis.sideEffects.length > 0 ? 1 : 0);
37
- return 12 + reasonLines + extraLines;
167
+ const hunkLines = hunkState ? hunkListRowCount(hunkState) : 0;
168
+ // Base 12 counted a single arg line; the Path field now spans `pathRows`
169
+ // lines and the full card adds one raw-args row (2a reachability), so the
170
+ // arg allotment becomes pathRows + 1 → base 12 + pathRows (12 already
171
+ // included one of those). See createPromptLines' full branch.
172
+ const pathRows = this.pathRowTexts(this.resolvedTargets(request), 999).length;
173
+ return 12 + pathRows + reasonLines + extraLines + hunkLines;
38
174
  }
39
175
 
40
176
  /** Returns the key argument to display for a given tool invocation. */
@@ -64,12 +200,16 @@ export class PermissionPromptUI {
64
200
  * createPromptLines - Renders the permission prompt as an array of Lines.
65
201
  * Injected into the viewport by the render function when a request is pending.
66
202
  */
67
- static createPromptLines(width: number, request: PermissionRequest): Line[] {
203
+ static createPromptLines(
204
+ width: number,
205
+ request: PermissionRequest,
206
+ hunkState?: HunkSelectionState,
207
+ detailsExpanded = false,
208
+ ): Line[] {
68
209
  const lines: Line[] = [];
69
210
  const { tool, args, category } = request;
70
211
  const analysis = this.fallbackAnalysis(request);
71
212
  const brief = buildPermissionApprovalBrief(request);
72
- const displayArg = this.getDisplayArg(tool, args);
73
213
  const { label, color } = this.getCategoryLabel(category);
74
214
 
75
215
  const ACCENT = '135'; // purple
@@ -77,6 +217,24 @@ export class PermissionPromptUI {
77
217
  const TEXT = '252';
78
218
  const DIM = '244';
79
219
 
220
+ const maxArgLen = Math.max(10, width - 16);
221
+ const pathRows = this.pathRowTexts(this.resolvedTargets(request), maxArgLen);
222
+
223
+ // Condensed low-risk / project-local card: one summary line (verb → target
224
+ // (scope)) plus the choices. Full block is one `d` away. (UX-B 2b.)
225
+ if (this.isCondensed(request, hunkState, detailsExpanded)) {
226
+ lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
227
+ lines.push(UIFactory.stringToLine(` [${label}] ${brief.title} `.padEnd(width), width, { fg: WARN, bold: true }));
228
+ const scopeText = analysis.blastRadius ? ` (${analysis.blastRadius})` : '';
229
+ const summaryLine = ` ${categoryVerb(category)} → ${pathRows[0]}${scopeText}`;
230
+ lines.push(UIFactory.stringToLine(summaryLine.padEnd(width), width, { fg: TEXT }));
231
+ lines.push(UIFactory.stringToLine(
232
+ ` [Y] Allow once [A] Allow always (session) [N] Deny [d] details`.padEnd(width),
233
+ width, { fg: ACCENT, bold: true }));
234
+ lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
235
+ return lines;
236
+ }
237
+
80
238
  // Top separator
81
239
  lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
82
240
 
@@ -89,13 +247,13 @@ export class PermissionPromptUI {
89
247
  const toolLine = ` Tool : ${tool}`;
90
248
  lines.push(UIFactory.stringToLine(toolLine.padEnd(width), width, { fg: TEXT }));
91
249
 
92
- // Key argument row - truncate if too long
93
- const maxArgLen = Math.max(10, width - 16);
94
- const truncatedArg = displayArg.length > maxArgLen
95
- ? '...' + displayArg.slice(-(maxArgLen - 3))
96
- : displayArg;
97
- const argLine = ` ${brief.subjectLabel.padEnd(9)}: ${truncatedArg}`;
98
- lines.push(UIFactory.stringToLine(argLine.padEnd(width), width, { fg: TEXT }));
250
+ // Path/subject row(s): actual target path(s), one per line when few, a
251
+ // "N files: a, b, +K more" summary when many. Raw args move to the Args row
252
+ // below so this field never shows a truncated JSON blob. (UX-B 2a.)
253
+ pathRows.forEach((rowText, i) => {
254
+ const labelCol = i === 0 ? brief.subjectLabel.padEnd(9) : ' '.repeat(9);
255
+ lines.push(UIFactory.stringToLine(` ${labelCol}: ${rowText}`.padEnd(width), width, { fg: TEXT }));
256
+ });
99
257
 
100
258
  // Working directory row
101
259
  const cwd = request.workingDirectory ?? '(unknown)';
@@ -150,11 +308,56 @@ export class PermissionPromptUI {
150
308
  const checklistLine = ` Checklist : ${truncatedChecklist}`;
151
309
  lines.push(UIFactory.stringToLine(checklistLine.padEnd(width), width, { fg: DIM }));
152
310
 
311
+ // Raw args row — the full tool arguments live here in the details view so
312
+ // the Path field above can render clean path(s), never a JSON blob. (2a.)
313
+ const rawArgs = JSON.stringify(args);
314
+ const maxRawLen = Math.max(10, width - 16);
315
+ const truncatedRaw = rawArgs.length > maxRawLen ? `${rawArgs.slice(0, maxRawLen - 3)}...` : rawArgs;
316
+ lines.push(UIFactory.stringToLine(` Args : ${truncatedRaw}`.padEnd(width), width, { fg: DIM }));
317
+
153
318
  // Blank spacer
154
319
  lines.push(UIFactory.stringToLine(' '.repeat(width), width));
155
320
 
156
- // Choices row
157
- const choicesLine = ` [Y] Allow once [A] Allow always (session) [N] Deny`;
321
+ if (hunkState) {
322
+ // Hunk list see hunkListRowCount() for the row-count contract this
323
+ // block must match exactly (Risk 2: getPromptHeight/createPromptLines
324
+ // parity is what keeps main.ts's render loop from clipping the
325
+ // viewport).
326
+ const { hunks, cursor, selected } = hunkState;
327
+ const headerLine = ` Hunks (${selected.size}/${hunks.length} selected):`;
328
+ lines.push(UIFactory.stringToLine(headerLine.padEnd(width), width, { fg: TEXT, bold: true }));
329
+
330
+ const visible = hunks.slice(0, MAX_VISIBLE_HUNKS);
331
+ const maxPreviewLen = Math.max(6, Math.floor((width - 20) / 2));
332
+ for (let i = 0; i < visible.length; i++) {
333
+ const hunk = visible[i]!;
334
+ const box = selected.has(i) ? '[x]' : '[ ]';
335
+ const findPreview = hunk.find.replace(/\n/g, '⏎').slice(0, maxPreviewLen);
336
+ const replacePreview = hunk.replace.replace(/\n/g, '⏎').slice(0, maxPreviewLen);
337
+ const rowText = ` ${box} ${i + 1}. ${hunk.path} — "${findPreview}" -> "${replacePreview}"`;
338
+ const isCursor = i === cursor;
339
+ lines.push(UIFactory.stringToLine(
340
+ rowText.padEnd(width),
341
+ width,
342
+ {
343
+ fg: selected.has(i) ? DIFF_TONES.add : DIFF_TONES.del,
344
+ bg: isCursor ? UI_TONES.bg.selected : undefined,
345
+ bold: isCursor,
346
+ },
347
+ ));
348
+ }
349
+
350
+ const hiddenCount = hunks.length - visible.length;
351
+ const trailerLine = hiddenCount > 0 ? ` +${hiddenCount} more` : '';
352
+ lines.push(UIFactory.stringToLine(trailerLine.padEnd(width), width, { fg: DIM }));
353
+ }
354
+
355
+ // Choices row. A condensable card shown in full is the expanded form, so it
356
+ // offers `[d]` to collapse back; other full cards omit the details toggle.
357
+ const collapseHint = this.isCondensed(request, hunkState, false) ? ' [d] hide details' : '';
358
+ const choicesLine = hunkState
359
+ ? ` [j/k] Navigate [Space] Toggle [A] All [Enter] Apply selected [N] Deny`
360
+ : ` [Y] Allow once [A] Allow always (session) [N] Deny${collapseHint}`;
158
361
  lines.push(UIFactory.stringToLine(choicesLine.padEnd(width), width, { fg: ACCENT, bold: true }));
159
362
 
160
363
  // Bottom separator
@@ -81,12 +81,35 @@ export function renderAutocompleteOverlay(
81
81
  }
82
82
  const endIdx = Math.min(startIdx + maxVisible, total);
83
83
 
84
+ // UX-C palette curation (item 4): on a bare '/' (query === ''), the results
85
+ // list is "common tier" (score 2) followed by "alphabetical rest" (score
86
+ // 1) — see CommandRegistry.fuzzyMatch. commonCount marks that boundary;
87
+ // draw a one-row separator there when it falls inside the visible window,
88
+ // consuming one of the maxVisible slots so the box height never changes.
89
+ const hasCommonSeparator = state.query === '' && state.commonCount > 0 && state.commonCount < total;
90
+ type DisplayRow = { type: 'item'; index: number } | { type: 'separator' };
91
+ const displayRows: DisplayRow[] = [];
92
+ for (let i = startIdx; i < endIdx && displayRows.length < maxVisible; i++) {
93
+ if (hasCommonSeparator && i === state.commonCount) {
94
+ displayRows.push({ type: 'separator' });
95
+ if (displayRows.length >= maxVisible) break;
96
+ }
97
+ displayRows.push({ type: 'item', index: i });
98
+ }
99
+
84
100
  const indicatorWidth = 2;
85
101
  const maxCommandWidth = Math.min(18, Math.max(10, Math.floor(layout.innerWidth * 0.28)));
86
102
  const gapWidth = 2;
87
103
  const descWidth = Math.max(0, layout.innerWidth - indicatorWidth - maxCommandWidth - gapWidth);
88
104
 
89
- for (let i = startIdx; i < endIdx; i++) {
105
+ for (const row of displayRows) {
106
+ if (row.type === 'separator') {
107
+ const sepLine = createOverlayContentLine(width, layout);
108
+ putText(sepLine, layout.margin + 2, layout.innerWidth, '─'.repeat(layout.innerWidth), { fg: BORDER_FG, dim: true });
109
+ lines.push(sepLine);
110
+ continue;
111
+ }
112
+ const i = row.index;
90
113
  const { command } = results[i];
91
114
  const isSelected = i === state.selectedIndex;
92
115
  const line = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : '');
@@ -139,7 +162,10 @@ export function renderAutocompleteOverlay(
139
162
  }
140
163
 
141
164
  const footerLine = createOverlayContentLine(width, layout);
142
- const hints = '[Tab] Complete [Up/Down] Navigate [Enter] Execute [Esc] Cancel';
165
+ // UX-C vocab unification: "Run"/"Close" match the /help selection modal's
166
+ // hint bar (selection-modal-overlay.ts) — both surfaces execute a command
167
+ // on Enter, so they now say so the same way.
168
+ const hints = '[Tab] Complete [Up/Down] Navigate [Enter] Run [Esc] Close';
143
169
  putText(
144
170
  footerLine,
145
171
  layout.margin + 2,
@@ -10,11 +10,22 @@
10
10
  */
11
11
 
12
12
  import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
13
+ import { getCompactionQualityScore } from './compaction-quality.ts';
13
14
  import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
15
+ import type { CompactionQualityScore } from './compaction-quality.ts';
14
16
 
15
17
  // ─── formatCompactionEvent ────────────────────────────────────────────────────
16
18
 
17
- function formatCompactionEvent(ev: CompactionEvent, n: number): string {
19
+ /**
20
+ * Exported for direct unit testing (W5.4/B28) — the grade suffix is a pure
21
+ * function of the event and an optional score, independent of whichever
22
+ * runtime lookup (getCompactionQualityScore) supplies that score.
23
+ */
24
+ export function formatCompactionEvent(
25
+ ev: CompactionEvent,
26
+ n: number,
27
+ qualityScore?: CompactionQualityScore | null,
28
+ ): string {
18
29
  const date = new Date(ev.timestamp);
19
30
  const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
20
31
  const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
@@ -22,11 +33,15 @@ function formatCompactionEvent(ev: CompactionEvent, n: number): string {
22
33
  ? Math.round((savings / ev.tokensBeforeEstimate) * 100)
23
34
  : 0;
24
35
  const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
36
+ // Honest omission: no score means none was computed for this event (e.g. it
37
+ // pre-dates this feature, or ran through the small-window path, which has
38
+ // no CompactionEvent to key a score by) — never fabricate a grade.
39
+ const qualityStr = qualityScore ? ` quality=${qualityScore.grade} (${qualityScore.score.toFixed(2)})` : '';
25
40
  return (
26
41
  `#${n} ${timeStr} [${trigger}] ` +
27
42
  `${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
28
43
  `~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
29
- `saved ${savingsPct}%`
44
+ `saved ${savingsPct}%${qualityStr}`
30
45
  );
31
46
  }
32
47
 
@@ -48,7 +63,8 @@ export function buildCompactionHistoryText(): string {
48
63
  ];
49
64
  const ordered = [...events].reverse();
50
65
  for (let i = 0; i < ordered.length; i++) {
51
- lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
66
+ const ev = ordered[i]!;
67
+ lines.push(' ' + formatCompactionEvent(ev, ordered.length - i, getCompactionQualityScore(ev.timestamp)));
52
68
  }
53
69
  lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
54
70
  return lines.join('\n');
@@ -18,8 +18,10 @@
18
18
 
19
19
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
20
20
  import { computeContextUsage } from '../core/context-usage.ts';
21
+ import { formatQualityScoreLine } from './compaction-quality.ts';
21
22
  import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
22
23
  import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
24
+ import type { CompactionQualityScore } from './compaction-quality.ts';
23
25
 
24
26
  /**
25
27
  * Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
@@ -44,6 +46,12 @@ export interface CompactionAfterOptions {
44
46
  readonly event: CompactionEvent;
45
47
  /** Number of session memories that survived compaction. */
46
48
  readonly pinnedMemoryCount: number;
49
+ /**
50
+ * Out-of-band quality-score grade for this run (W5.4/B28). Omitted or null
51
+ * when no score was computed (e.g. the small-window compaction path, which
52
+ * has no CompactionEvent to key a score by) — no line is rendered then.
53
+ */
54
+ readonly qualityScore?: CompactionQualityScore | null | undefined;
47
55
  }
48
56
 
49
57
  /**
@@ -82,7 +90,7 @@ export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
82
90
  * after token counts. The trigger field controls wording.
83
91
  */
84
92
  export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
85
- const { event, pinnedMemoryCount } = opts;
93
+ const { event, pinnedMemoryCount, qualityScore } = opts;
86
94
  const {
87
95
  messagesBeforeCompaction,
88
96
  messagesAfterCompaction,
@@ -102,11 +110,14 @@ export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string
102
110
 
103
111
  const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
104
112
 
113
+ const qualityStr = qualityScore ? `\n ${formatQualityScoreLine(qualityScore)}` : '';
114
+
105
115
  return (
106
116
  `[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
107
117
  ` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
108
118
  ` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
109
- (pinStr ? ` ${pinStr.trim()}` : '')
119
+ (pinStr ? ` ${pinStr.trim()}` : '') +
120
+ qualityStr
110
121
  );
111
122
  }
112
123
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Compaction quality-score bridging (Wave-5 W5.4 / B28).
3
+ *
4
+ * Reality check (wo803): the SDK's `computeQualityScore()`
5
+ * (platform/runtime/compaction/quality-score.ts) is only ever called from a
6
+ * SEPARATE pipeline, `CompactionManager` (platform/runtime/compaction/manager.ts).
7
+ * The TUI's actual `/compact` path never instantiates `CompactionManager` — it
8
+ * goes through `platform/core/context-compaction.ts` via
9
+ * `ConversationManager.compact()` (see `compactConversation()` in
10
+ * runtime-services.ts), which has no scoring of its own. Adopting the whole
11
+ * `CompactionManager` pipeline just to get the score would be a far bigger
12
+ * change than this item calls for.
13
+ *
14
+ * Instead, this module calls `computeQualityScore()` directly and out-of-band,
15
+ * fed by the before/after message sets and token estimates
16
+ * `compactConversation()` already holds. That's enough to close the gap:
17
+ * quality scoring reaches `/compact` and `/compact-history` without adopting
18
+ * `CompactionManager`.
19
+ *
20
+ * Honesty note: the `strategy` value passed to `computeQualityScore()` is a
21
+ * BORROWED rubric label describing what `compactConversation()`'s structured
22
+ * multi-section extraction actually does (collapse the whole conversation
23
+ * into one handoff message) — it is NOT a real `CompactionStrategy`
24
+ * escalation result, and no strategy switch is triggered from it. Every
25
+ * rendering of the score says so explicitly (see `formatQualityScoreLine`).
26
+ *
27
+ * Storage: scores are kept in a TUI-local, timestamp-keyed store, never added
28
+ * to the SDK's public `CompactionEvent` type (that would force an
29
+ * api-extractor regen for a value the SDK's own compaction log never
30
+ * populates). Keyed by `CompactionEvent.timestamp` rather than array index,
31
+ * because `compactSmallWindow`'s null-event path (small-context-window
32
+ * models — see runtime-services.ts) can create index gaps.
33
+ */
34
+
35
+ import { computeQualityScore, describeScore } from '@/runtime/index.ts';
36
+ import type { CompactionQualityScore, StrategyInput, StrategyOutput } from '@/runtime/index.ts';
37
+ import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
38
+
39
+ export type { CompactionQualityScore } from '@/runtime/index.ts';
40
+
41
+ /**
42
+ * Bound the TUI-local score store the same way the SDK bounds its own
43
+ * compaction event log (context-compaction.ts caps at 50 and shifts the
44
+ * oldest entry) — a long-running session should not accumulate this forever.
45
+ */
46
+ const MAX_TRACKED_SCORES = 50;
47
+
48
+ const scoresByTimestamp = new Map<number, CompactionQualityScore>();
49
+
50
+ /** Record a computed quality score, keyed by the CompactionEvent's timestamp. */
51
+ export function recordCompactionQualityScore(timestamp: number, score: CompactionQualityScore): void {
52
+ scoresByTimestamp.set(timestamp, score);
53
+ if (scoresByTimestamp.size > MAX_TRACKED_SCORES) {
54
+ const oldestKey = scoresByTimestamp.keys().next().value;
55
+ if (oldestKey !== undefined) scoresByTimestamp.delete(oldestKey);
56
+ }
57
+ }
58
+
59
+ /** Look up a previously-recorded quality score for a CompactionEvent, by timestamp. */
60
+ export function getCompactionQualityScore(timestamp: number): CompactionQualityScore | undefined {
61
+ return scoresByTimestamp.get(timestamp);
62
+ }
63
+
64
+ export interface ScoreCompactionRunInput {
65
+ readonly sessionId: string;
66
+ readonly contextWindow: number;
67
+ readonly messagesBefore: readonly ProviderMessage[];
68
+ readonly messagesAfter: readonly ProviderMessage[];
69
+ readonly tokensBefore: number;
70
+ readonly tokensAfter: number;
71
+ }
72
+
73
+ /**
74
+ * Compute a CompactionQualityScore for one compaction run, out-of-band from
75
+ * the SDK's own (unused, in this TUI) CompactionManager pipeline.
76
+ */
77
+ export function scoreCompactionRun(input: ScoreCompactionRunInput): CompactionQualityScore {
78
+ const strategyInput: StrategyInput = {
79
+ sessionId: input.sessionId,
80
+ messages: input.messagesBefore,
81
+ tokensBefore: input.tokensBefore,
82
+ contextWindow: input.contextWindow,
83
+ // Borrowed rubric label, not a real strategy escalation — see module doc.
84
+ strategy: 'collapse',
85
+ };
86
+ const strategyOutput: StrategyOutput = {
87
+ messages: [...input.messagesAfter],
88
+ tokensAfter: input.tokensAfter,
89
+ summary: '',
90
+ strategy: 'collapse',
91
+ durationMs: 0,
92
+ warnings: [],
93
+ };
94
+ return computeQualityScore(strategyInput, strategyOutput);
95
+ }
96
+
97
+ /** One-line grade summary for the /compact after-notice and /compact-history entries. */
98
+ export function formatQualityScoreLine(score: CompactionQualityScore): string {
99
+ return `Quality: ${describeScore(score)} (rubric applied out-of-band; no strategy escalation ran)`;
100
+ }
@@ -5,11 +5,9 @@ import { getDisplayWidth } from '../utils/terminal-width.ts';
5
5
  import type { SearchManager } from '../input/search.ts';
6
6
  import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
7
7
  import { probeTermCaps, type TermColorCaps } from './term-caps.ts';
8
- import { DARK_THEME } from './theme.ts';
8
+ import { activeTheme } from './theme.ts';
9
9
  import { UI_TONES } from './ui-primitives.ts';
10
10
 
11
- const T = DARK_THEME;
12
-
13
11
  // Accent / dim colors for the panel focus border. The focused pane's left
14
12
  // border column is drawn in the accent tone; the unfocused pane stays dim.
15
13
  const PANEL_FOCUS_ACCENT = UI_TONES.state.active; // bright blue
@@ -252,6 +250,7 @@ export class Compositor {
252
250
 
253
251
  // Apply Search Match Highlighting Overlay (left side only)
254
252
  if (search && search.manager.active && search.manager.query.length > 0 && i >= offset) {
253
+ const T = activeTheme();
255
254
  const absoluteRow = search.scrollTop + (i - offset);
256
255
  const lineMatches = search.manager.getMatchesOnLine(absoluteRow);
257
256
  for (const match of lineMatches) {
@@ -0,0 +1,95 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { ModalFactory, type ModalSection, type ModalTab } from './modal-factory.ts';
3
+ import { getOverlayMaxWidth } from './overlay-viewport.ts';
4
+ import { activeUiTones } from './theme.ts';
5
+ import type { ConfigModal, ConfigModalRenderModel } from '../input/config-modal.ts';
6
+
7
+ // Matches ModalFactory.createModal's own box-width computation (margin 4,
8
+ // requested max width 76 — see renderConfigModalModel below) so the
9
+ // wrap-clamp in ConfigModal.getRenderModel() measures against the SAME width
10
+ // the list section will actually wrap at, at the current terminal size.
11
+ const MODAL_MARGIN = 4;
12
+ const MODAL_MAX_WIDTH = 76;
13
+ /**
14
+ * renderConfigModal — the single render path for every ConfigModalSurface.
15
+ * Reads the host's frozen-structure-plus-live-values render model and maps it
16
+ * onto ModalFactory.createModal (the same renderer settings-modal uses), so the
17
+ * chrome, tab strip, list, and footer are one dialect across all migrated
18
+ * surfaces. Pure: takes the modal's render model, returns Line[].
19
+ */
20
+ export function renderConfigModal(modal: ConfigModal, width: number, height: number): Line[] {
21
+ // Size the visible row window to the available height so the list scrolls
22
+ // rather than overflowing (posture header + separator + chrome ≈ 8 rows).
23
+ const visible = Math.max(3, Math.min(16, height - 8));
24
+ modal.setViewportRows(visible);
25
+ // DEBT-5 item 2: the list section wraps a row label at (contentW - 2),
26
+ // where contentW = boxW - 4 (see ModalFactory._renderListSection). Compute
27
+ // that exact column here so getRenderModel's wrap-clamp measures against
28
+ // reality instead of a fixed guess.
29
+ const boxW = Math.max(24, getOverlayMaxWidth(width, MODAL_MARGIN, MODAL_MAX_WIDTH));
30
+ const labelWrapWidth = Math.max(8, boxW - 4 - 2);
31
+ const model = modal.getRenderModel(labelWrapWidth);
32
+ return renderConfigModalModel(model, width);
33
+ }
34
+
35
+ /** Pure model→Line[] mapping — the shared inner step of renderConfigModal. */
36
+ function renderConfigModalModel(model: ConfigModalRenderModel, width: number): Line[] {
37
+ // Read the active-mode chrome tones per call (this render path is not on a
38
+ // hot loop; the modal renders once per keystroke).
39
+ const TONES = activeUiTones();
40
+ const sections: ModalSection[] = [];
41
+
42
+ if (model.degraded) {
43
+ sections.push({ type: 'text', content: `⚠ ${model.degraded}`, style: { fg: TONES.state.warn, bold: true } });
44
+ sections.push({ type: 'separator' });
45
+ }
46
+
47
+ for (const line of model.header) {
48
+ sections.push({ type: 'text', content: line, style: { fg: TONES.fg.secondary } });
49
+ }
50
+ if (model.header.length > 0) sections.push({ type: 'separator' });
51
+
52
+ if (model.rows.length === 0) {
53
+ sections.push({ type: 'text', content: model.emptyText ?? 'Nothing to show.', style: { fg: TONES.fg.muted, dim: true } });
54
+ } else {
55
+ sections.push({
56
+ type: 'list',
57
+ items: model.rows.map((row) => ({
58
+ label: row.label,
59
+ selected: row.selected,
60
+ style: row.stale
61
+ ? { fg: TONES.fg.muted, dim: true }
62
+ : row.style,
63
+ })),
64
+ });
65
+ }
66
+
67
+ const tabs: ModalTab[] | undefined = model.tabs.length > 1
68
+ ? model.tabs.map((t) => ({ label: t.label, active: t.active }))
69
+ : undefined;
70
+
71
+ // Keep the content region a stable height so live-value ticks never change
72
+ // the box size: frozen header + separator + the fixed visible-row window.
73
+ const targetContentRows =
74
+ (model.degraded ? 2 : 0) +
75
+ model.header.length +
76
+ (model.header.length > 0 ? 1 : 0) +
77
+ model.scroll.visible;
78
+
79
+ const helpers = model.status
80
+ ? [{ content: model.status, accent: true }]
81
+ : undefined;
82
+
83
+ return ModalFactory.createModal(
84
+ {
85
+ title: model.title,
86
+ width: MODAL_MAX_WIDTH,
87
+ tabs,
88
+ sections,
89
+ targetContentRows,
90
+ helpers,
91
+ hints: [...model.hints],
92
+ },
93
+ width,
94
+ );
95
+ }