@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
@@ -0,0 +1,189 @@
1
+ import { MODAL_TONES } from './modal-theme.ts';
2
+ import { infoRow } from './modal-surface-helpers.ts';
3
+ import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, sep } from 'node:path';
6
+ import type { ConfigModalActionContext, ConfigModalRow, ConfigModalSurface, ConfigModalView } from '../../input/config-modal-types.ts';
7
+ import type { SkillOrigin, SkillRecord } from '../skills-panel.ts';
8
+ import { listInstalledEcosystemEntries, type EcosystemCatalogPathOptions, type ShellPathService } from '@/runtime/index.ts';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Skills → config-modal surface (W6.1 group-B port). Project-local and global
12
+ // skill-pack discovery. Disk scans happen only in refresh() (never in
13
+ // buildView), mirroring the panel's onActivate()-triggered load. Browse-only:
14
+ // the panel never had a command-routable mutation verb (no /skills delete/remove
15
+ // subcommand exists), so the only action is refresh/navigate. Selection-blind
16
+ // port: the panel's selected-skill path/deps/includes detail is folded into
17
+ // each row label.
18
+ //
19
+ // discoverSkills() in the panel was async; the sync re-implementation below
20
+ // mirrors the same directory/frontmatter/marketplace-provenance conventions
21
+ // (the two are independent implementations of the same on-disk contract).
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface SkillsModalDeps {
25
+ readonly shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
26
+ readonly ecosystemPaths?: EcosystemCatalogPathOptions;
27
+ }
28
+
29
+ function parseFrontmatter(content: string): Record<string, string> {
30
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
31
+ if (!match) return {};
32
+ const result: Record<string, string> = {};
33
+ for (const line of match[1].split('\n')) {
34
+ const [key, ...rest] = line.split(':');
35
+ if (key && rest.length > 0) result[key.trim()] = rest.join(':').trim();
36
+ }
37
+ return result;
38
+ }
39
+
40
+ function getSkillDirectories(cwd: string, homeDir: string): Array<{ root: string; origin: SkillOrigin }> {
41
+ return [
42
+ { root: join(cwd, '.goodvibes', 'skills'), origin: 'project-local' },
43
+ { root: join(cwd, '.goodvibes', 'tui', 'skills'), origin: 'project-local' },
44
+ { root: join(homeDir, '.goodvibes', 'skills'), origin: 'global' },
45
+ { root: join(homeDir, '.goodvibes', 'tui', 'skills'), origin: 'global' },
46
+ ];
47
+ }
48
+
49
+ function readSkillFileSync(path: string, origin: SkillOrigin): SkillRecord | null {
50
+ let content = '';
51
+ try { content = readFileSync(path, 'utf-8'); } catch { return null; }
52
+ const frontmatter = parseFrontmatter(content);
53
+ const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
54
+ const name = frontmatter.name ?? path.split(/[\\/]/).pop()?.replace(/\.md$/, '') ?? 'skill';
55
+ const description = frontmatter.description ?? frontmatter.summary ?? '';
56
+ const dependencies = frontmatter.depends_on
57
+ ? frontmatter.depends_on.split(',').map((item) => item.trim()).filter(Boolean)
58
+ : [];
59
+ const includes: string[] = [];
60
+ const includeRegex = /^@([\w/-]+)/gm;
61
+ let match: RegExpExecArray | null;
62
+ while ((match = includeRegex.exec(body)) !== null) includes.push(match[1]);
63
+ return { name, description, path, origin, dependencies, includes, frontmatter };
64
+ }
65
+
66
+ function scanSkillDirectorySync(root: string, origin: SkillOrigin): SkillRecord[] {
67
+ let entries: string[] = [];
68
+ try { entries = readdirSync(root); } catch { return []; }
69
+ const records: SkillRecord[] = [];
70
+ for (const entry of entries.sort((a, b) => a.localeCompare(b))) {
71
+ if (entry.endsWith('.md')) {
72
+ const record = readSkillFileSync(join(root, entry), origin);
73
+ if (record) records.push(record);
74
+ continue;
75
+ }
76
+ const record = readSkillFileSync(join(root, entry, 'SKILL.md'), origin);
77
+ if (record) records.push(record);
78
+ }
79
+ return records;
80
+ }
81
+
82
+ function applyMarketplaceProvenance(records: SkillRecord[], ecosystemPaths: EcosystemCatalogPathOptions): SkillRecord[] {
83
+ let receipts: ReturnType<typeof listInstalledEcosystemEntries>;
84
+ try { receipts = listInstalledEcosystemEntries('skill', ecosystemPaths); } catch { return records; }
85
+ if (receipts.length === 0) return records;
86
+ return records.map((record) => {
87
+ const receipt = receipts.find((candidate) => record.path === candidate.targetPath || record.path.startsWith(`${candidate.targetPath}${sep}`));
88
+ if (!receipt) return record;
89
+ return { ...record, marketplaceProvenance: receipt.provenanceSummary };
90
+ });
91
+ }
92
+
93
+ function discoverSkillsSync(
94
+ shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>,
95
+ ecosystemPaths?: EcosystemCatalogPathOptions,
96
+ ): SkillRecord[] {
97
+ const seen = new Set<string>();
98
+ const records: SkillRecord[] = [];
99
+ for (const { root, origin } of getSkillDirectories(shellPaths.workingDirectory, shellPaths.homeDirectory)) {
100
+ for (const record of scanSkillDirectorySync(root, origin)) {
101
+ if (seen.has(record.name.toLowerCase())) continue;
102
+ seen.add(record.name.toLowerCase());
103
+ records.push(record);
104
+ }
105
+ }
106
+ const tagged = ecosystemPaths ? applyMarketplaceProvenance(records, ecosystemPaths) : records;
107
+ return tagged.sort((a, b) => {
108
+ const originRank = a.origin === b.origin ? 0 : a.origin === 'project-local' ? -1 : 1;
109
+ return originRank || a.name.localeCompare(b.name);
110
+ });
111
+ }
112
+
113
+ function originLabel(origin: SkillOrigin): string {
114
+ switch (origin) {
115
+ case 'project-local': return 'project';
116
+ case 'global': return 'global';
117
+ case 'custom': return 'custom';
118
+ }
119
+ }
120
+
121
+ class SkillsModalSurface implements ConfigModalSurface {
122
+ readonly name = 'skills-modal';
123
+ readonly title = 'Skills';
124
+ private cached: SkillRecord[] = [];
125
+ private requestRender: () => void = () => {};
126
+
127
+ constructor(private readonly deps: SkillsModalDeps) {}
128
+
129
+ readonly actions = [{ key: 'r', id: 'refresh', label: 'refresh' }];
130
+
131
+ onOpen(requestRender: () => void): void { this.requestRender = requestRender; this.refresh(); }
132
+
133
+ private refresh(): void { this.cached = discoverSkillsSync(this.deps.shellPaths, this.deps.ecosystemPaths); }
134
+
135
+ buildView(): ConfigModalView {
136
+ const rows: ConfigModalRow[] = [];
137
+ if (this.cached.length === 0) {
138
+ rows.push(infoRow('empty:0', 'No skills discovered.'));
139
+ rows.push(infoRow('empty:title', 'Next steps'));
140
+ rows.push(infoRow('empty:dir', '.goodvibes/skills — place skill .md files here (project-local) or ~/.goodvibes/skills (global)', { dim: true }));
141
+ rows.push(infoRow('empty:registry', '/registry search skills — inspect the same skill directories from the shell', { dim: true }));
142
+ return { title: 'Skills', tabs: [{ id: 'skills', label: 'Skills', rows, emptyText: '' }] };
143
+ }
144
+
145
+ const projectCount = this.cached.filter((s) => s.origin === 'project-local').length;
146
+ const globalCount = this.cached.filter((s) => s.origin === 'global').length;
147
+ const header = [`skills ${this.cached.length} project ${projectCount} global ${globalCount}`];
148
+
149
+ for (const skill of this.cached) {
150
+ const badge = skill.marketplaceProvenance ? '★' : ' ';
151
+ const dot = skill.origin === 'project-local' ? '◆' : '•';
152
+ const detail = [
153
+ `[${originLabel(skill.origin)}]`,
154
+ skill.description || 'No description provided.',
155
+ skill.dependencies.length > 0 ? `deps ${skill.dependencies.length}` : null,
156
+ skill.includes.length > 0 ? `inc ${skill.includes.length}` : null,
157
+ skill.marketplaceProvenance ?? null,
158
+ ].filter((s): s is string => s !== null).join(' · ');
159
+ rows.push({
160
+ id: skill.path,
161
+ label: `${dot} ${badge} ${skill.name.padEnd(24)} ${detail}`,
162
+ ...(skill.marketplaceProvenance ? { style: { fg: MODAL_TONES.info } } : {}),
163
+ });
164
+ }
165
+
166
+ return { title: 'Skills', tabs: [{ id: 'skills', label: 'Skills', header, rows }] };
167
+ }
168
+
169
+ onAction(id: string, ctx: ConfigModalActionContext): void {
170
+ if (id === 'refresh') { this.refresh(); ctx.setStatus('Rescanned skill directories.'); }
171
+ }
172
+ }
173
+
174
+ export function createSkillsModalSurface(deps: SkillsModalDeps): ConfigModalSurface {
175
+ return new SkillsModalSurface(deps);
176
+ }
177
+
178
+ /**
179
+ * Deterministic golden fixture: a fresh tmp directory tree wired as both cwd and
180
+ * homeDir and removed immediately (scanSkillDirectorySync catches missing dirs →
181
+ * []), so refresh() finds no `.goodvibes/skills` directories and renders the
182
+ * static empty-state copy. The random tmp path never appears in the output.
183
+ */
184
+ export function skillsModalGoldenSurface(): ConfigModalSurface {
185
+ const root = mkdtempSync(join(tmpdir(), 'gv-skills-golden-'));
186
+ const surface = createSkillsModalSurface({ shellPaths: { workingDirectory: root, homeDirectory: root } });
187
+ rmSync(root, { recursive: true, force: true });
188
+ return surface;
189
+ }
@@ -0,0 +1,172 @@
1
+ import type { ConfigModalActionContext, ConfigModalSurface, ConfigModalView } from '../../input/config-modal-types.ts';
2
+ import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../../runtime/ui-service-queries.ts';
3
+ import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
4
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
5
+ import { formatElapsed } from '../../utils/format-elapsed.ts';
6
+ import { toneStyle, statusGlyph, pad, postureLine, kv, type Tone } from './modal-surface-helpers.ts';
7
+
8
+ interface Row {
9
+ readonly provider: string;
10
+ readonly hasOAuthConfig: boolean;
11
+ readonly subscription: ProviderSubscription | null;
12
+ readonly pending: PendingSubscriptionLogin | null;
13
+ }
14
+
15
+ type Status = 'active' | 'pending' | 'available' | 'unconfigured';
16
+
17
+ function statusOf(row: Row): Status {
18
+ if (row.subscription) return 'active';
19
+ if (row.pending) return 'pending';
20
+ return row.hasOAuthConfig ? 'available' : 'unconfigured';
21
+ }
22
+
23
+ function statusTone(status: Status): Tone {
24
+ switch (status) {
25
+ case 'active': return 'good';
26
+ case 'pending': return 'warn';
27
+ case 'available': return 'info';
28
+ case 'unconfigured': return 'dim';
29
+ }
30
+ }
31
+
32
+ const EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
33
+
34
+ function isExpiringSoon(sub: ProviderSubscription | null): boolean {
35
+ if (!sub?.expiresAt) return false;
36
+ const remaining = sub.expiresAt - Date.now();
37
+ return remaining > 0 && remaining <= EXPIRING_SOON_MS;
38
+ }
39
+
40
+ /**
41
+ * Subscription config-modal surface (migrated from the `subscription` panel).
42
+ * All reads are synchronous (serviceRegistry.getAll + subscription-manager
43
+ * queries) so buildView reads them live each tick; a 5s timer keeps an
44
+ * externally-completed browser OAuth handshake visible without a keypress, the
45
+ * same cadence the panel used. Enter signs out (surface-managed confirm,
46
+ * matching the panel's ConfirmState) on an active row, or starts login via the
47
+ * existing `/subscription login <p> start` command on an available row.
48
+ */
49
+ class SubscriptionModalSurface implements ConfigModalSurface {
50
+ readonly name = 'subscription-modal';
51
+ readonly title = 'Subscriptions';
52
+ private requestRender: () => void = () => {};
53
+ private timer: ReturnType<typeof setInterval> | null = null;
54
+ /** Provider awaiting a second Enter to confirm sign-out (surface-managed so
55
+ * only a destructive sign-out is confirm-gated, not a login start). */
56
+ private pendingLogout: string | null = null;
57
+
58
+ constructor(
59
+ private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
60
+ private readonly manager: SubscriptionAccessQuery,
61
+ ) {}
62
+
63
+ readonly actions = [
64
+ { key: 'enter', id: 'primary', label: 'sign in / out' },
65
+ { key: 'r', id: 'refresh', label: 'refresh' },
66
+ ];
67
+
68
+ onOpen(requestRender: () => void): void {
69
+ this.requestRender = requestRender;
70
+ this.pendingLogout = null;
71
+ if (this.timer === null) this.timer = setInterval(() => this.requestRender(), 5_000);
72
+ }
73
+
74
+ onClose(): void {
75
+ if (this.timer !== null) { clearInterval(this.timer); this.timer = null; }
76
+ this.pendingLogout = null;
77
+ }
78
+
79
+ private buildRows(): Row[] {
80
+ const services = this.serviceRegistry.getAll();
81
+ const builtin = listBuiltinSubscriptionProviders();
82
+ const providers = new Set<string>([
83
+ ...builtin.map((p) => p.provider),
84
+ ...Object.values(services).filter((s) => s.authType === 'oauth' && s.oauth).map((s) => s.providerId ?? s.name),
85
+ ...this.manager.list().map((e) => e.provider),
86
+ ...this.manager.listPending().map((e) => e.provider),
87
+ ]);
88
+ return [...providers].sort((a, b) => a.localeCompare(b)).map((provider) => {
89
+ const service = Object.values(services).find((s) => (s.providerId ?? s.name) === provider && s.authType === 'oauth' && s.oauth);
90
+ const builtinEntry = builtin.find((e) => e.provider === provider);
91
+ return {
92
+ provider,
93
+ hasOAuthConfig: Boolean(service?.oauth) || Boolean(builtinEntry),
94
+ subscription: this.manager.get(provider),
95
+ pending: this.manager.getPending(provider),
96
+ } satisfies Row;
97
+ });
98
+ }
99
+
100
+ buildView(): ConfigModalView {
101
+ const rows = this.buildRows();
102
+ const active = rows.filter((r) => r.subscription).length;
103
+ const pending = rows.filter((r) => r.pending).length;
104
+ const configured = rows.filter((r) => r.hasOAuthConfig).length;
105
+ const header = [postureLine([
106
+ kv('configured', configured),
107
+ kv('active', active),
108
+ kv('pending', pending),
109
+ kv('providers', rows.length),
110
+ ])];
111
+
112
+ return {
113
+ title: 'Subscriptions',
114
+ tabs: [{
115
+ id: 'subscriptions',
116
+ label: 'Subscriptions',
117
+ header,
118
+ rows: rows.map((row) => {
119
+ const status = statusOf(row);
120
+ const expiring = isExpiringSoon(row.subscription);
121
+ const marker = expiring ? ' expiring soon' : '';
122
+ return {
123
+ id: `sub:${row.provider}`,
124
+ label: `${statusGlyph(statusTone(status))} ${pad(row.provider, 16)} ${pad(status.toUpperCase(), 12)} oauth=${row.hasOAuthConfig ? 'yes' : 'no'} override=${row.subscription ? 'active' : 'off'}${marker}`,
125
+ style: toneStyle(expiring ? 'warn' : statusTone(status)),
126
+ };
127
+ }),
128
+ emptyText: 'No provider subscriptions yet. Try /subscription login openai start.',
129
+ hints: ['Enter sign in/out', 'r refresh'],
130
+ }],
131
+ };
132
+ }
133
+
134
+ onAction(id: string, ctx: ConfigModalActionContext): void {
135
+ if (id === 'refresh') { this.pendingLogout = null; ctx.setStatus('refreshed'); this.requestRender(); return; }
136
+ if (id !== 'primary') return;
137
+ const provider = ctx.row?.id.startsWith('sub:') ? ctx.row.id.slice('sub:'.length) : null;
138
+ if (!provider) return;
139
+ const rows = this.buildRows();
140
+ const row = rows.find((r) => r.provider === provider);
141
+ if (!row) return;
142
+ const status = statusOf(row);
143
+ if (status === 'active') {
144
+ // Surface-managed confirm, guarded by provider match so a stale confirm
145
+ // can never sign out the wrong row.
146
+ if (this.pendingLogout === provider) {
147
+ this.pendingLogout = null;
148
+ this.manager.logout(provider);
149
+ ctx.setStatus(`Signed out ${provider}.`);
150
+ } else {
151
+ this.pendingLogout = provider;
152
+ ctx.setStatus(`Sign out ${provider}? Press Enter again to confirm.`);
153
+ }
154
+ } else if (status === 'available') {
155
+ this.pendingLogout = null;
156
+ void ctx.executeCommand?.('subscription', ['login', provider, 'start']);
157
+ ctx.setStatus(`Starting login for ${provider}…`);
158
+ } else if (status === 'pending' && row.pending) {
159
+ ctx.setStatus(`Login pending (${formatElapsed(Date.now() - row.pending.createdAt)} ago). Finish with /subscription login ${provider} finish <code>.`);
160
+ } else {
161
+ ctx.setStatus(`${provider} has no OAuth config. Add one or enable a built-in subscription provider.`);
162
+ }
163
+ this.requestRender();
164
+ }
165
+ }
166
+
167
+ export function createSubscriptionModalSurface(
168
+ serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
169
+ manager: SubscriptionAccessQuery,
170
+ ): ConfigModalSurface {
171
+ return new SubscriptionModalSurface(serviceRegistry, manager);
172
+ }
@@ -0,0 +1,177 @@
1
+ import type {
2
+ ConfigModalActionContext,
3
+ ConfigModalRow,
4
+ ConfigModalSurface,
5
+ ConfigModalView,
6
+ } from '../../input/config-modal-types.ts';
7
+ import type { WorkPlanItemStatus } from '../../work-plans/work-plan-store.ts';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Work Plan → config-modal surface (W6.1 group-B port). Lists the active
11
+ // persistent plan's checklist items with a progress summary + status counts.
12
+ // All mutations (add/edit item, cycle/set status, remove, clear-completed)
13
+ // route to the existing `/work-plan` command path (charter: no
14
+ // destructive/interactive mutation direct-called from a modal; the panel's
15
+ // inline add/edit draft form has no place in a read/navigate modal — the bare
16
+ // `/work-plan add` subcommand is the honest stand-in). Selection-blind port:
17
+ // the panel's selected-item owner/source/notes/linked detail is folded into
18
+ // each row label.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const STATUS_LABEL: Record<WorkPlanItemStatus, string> = {
22
+ pending: '[ ]', in_progress: '[>]', blocked: '[!]', done: '[x]', failed: '[x]', cancelled: '[-]',
23
+ };
24
+
25
+ /** `/work-plan <subcommand> <id>` for each status. */
26
+ const STATUS_COMMAND: Record<WorkPlanItemStatus, string> = {
27
+ pending: 'pending', in_progress: 'start', blocked: 'block', done: 'done', failed: 'fail', cancelled: 'cancel',
28
+ };
29
+
30
+ const STATUS_ORDER: readonly WorkPlanItemStatus[] = ['pending', 'in_progress', 'blocked', 'done', 'failed', 'cancelled'];
31
+
32
+ interface WorkPlanLinkTargetsLike { readonly agentId?: string | undefined; readonly wrfcId?: string | undefined; readonly taskId?: string | undefined; readonly sessionId?: string | undefined; }
33
+ interface WorkPlanItemLike { readonly id: string; readonly title: string; readonly status: WorkPlanItemStatus; readonly owner?: string | undefined; readonly source?: string | undefined; readonly notes?: string | undefined; readonly linked?: WorkPlanLinkTargetsLike | undefined; readonly updatedAt: number; }
34
+ interface WorkPlanLike { readonly projectRoot: string; readonly items: readonly WorkPlanItemLike[]; }
35
+
36
+ export interface WorkPlanModalDeps {
37
+ readonly workPlanStore: { getActivePlan(): WorkPlanLike };
38
+ }
39
+
40
+ function compactDate(value: number): string { return new Date(value).toISOString().replace('T', ' ').slice(0, 16); }
41
+
42
+ function progressBar(pct: number, width = 20): string {
43
+ const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
44
+ return `[${'#'.repeat(filled)}${'-'.repeat(width - filled)}] ${pct}%`;
45
+ }
46
+
47
+ function linkedSegments(linked: WorkPlanLinkTargetsLike | undefined): string {
48
+ if (!linked) return '';
49
+ return [
50
+ linked.agentId ? `agent:${linked.agentId}` : null,
51
+ linked.wrfcId ? `wrfc:${linked.wrfcId}` : null,
52
+ linked.taskId ? `task:${linked.taskId}` : null,
53
+ linked.sessionId ? `session:${linked.sessionId}` : null,
54
+ ].filter((s): s is string => s !== null).join(' ');
55
+ }
56
+
57
+ class WorkPlanModalSurface implements ConfigModalSurface {
58
+ readonly name = 'work-plan-modal';
59
+ readonly title = 'Work Plan';
60
+ private projectRoot = '';
61
+ private items: WorkPlanItemLike[] = [];
62
+
63
+ constructor(private readonly deps: WorkPlanModalDeps) {}
64
+
65
+ private readonly hasRow = (row: ConfigModalRow | null): boolean => row !== null;
66
+
67
+ /** 'i'/'w' jump to the selected item's linked agent/WRFC chain in Fleet
68
+ * (DEBT-5 item 4 — restores the retired WorkPlanPanel's per-item deep-link,
69
+ * lost when W6.1 migrated this surface to a modal without it). Gated on
70
+ * the link actually being present, same as the retired panel gated the key
71
+ * on `item?.linked?.agentId`/`wrfcId` before ever reaching the jump. */
72
+ private readonly hasAgentLink = (row: ConfigModalRow | null): boolean =>
73
+ !!(row && this.itemFrom(row.id)?.linked?.agentId);
74
+ private readonly hasWrfcLink = (row: ConfigModalRow | null): boolean =>
75
+ !!(row && this.itemFrom(row.id)?.linked?.wrfcId);
76
+
77
+ readonly actions = [
78
+ { key: 'enter', id: 'cycleStatus', label: 'cycle status', enabledFor: this.hasRow },
79
+ { key: '1', id: 'setPending', label: 'pending', enabledFor: this.hasRow },
80
+ { key: '2', id: 'setInProgress', label: 'start', enabledFor: this.hasRow },
81
+ { key: '3', id: 'setBlocked', label: 'block', enabledFor: this.hasRow },
82
+ { key: '4', id: 'setDone', label: 'done', enabledFor: this.hasRow },
83
+ { key: '5', id: 'setFailed', label: 'fail', enabledFor: this.hasRow },
84
+ { key: '6', id: 'setCancelled', label: 'cancel', enabledFor: this.hasRow },
85
+ { key: 'd', id: 'remove', label: 'delete', enabledFor: this.hasRow },
86
+ { key: 'c', id: 'clearCompleted', label: 'clear done' },
87
+ { key: 'a', id: 'add', label: 'add' },
88
+ { key: 'i', id: 'jumpAgent', label: 'jump agent', enabledFor: this.hasAgentLink },
89
+ { key: 'w', id: 'jumpWrfc', label: 'jump wrfc', enabledFor: this.hasWrfcLink },
90
+ { key: 'r', id: 'refresh', label: 'refresh' },
91
+ ];
92
+
93
+ onOpen(): void { this.refresh(); }
94
+
95
+ private refresh(): void {
96
+ const plan = this.deps.workPlanStore.getActivePlan();
97
+ this.projectRoot = plan.projectRoot;
98
+ this.items = [...plan.items];
99
+ }
100
+
101
+ private itemFrom(id: string): WorkPlanItemLike | undefined { return this.items.find((i) => i.id === id); }
102
+
103
+ buildView(): ConfigModalView {
104
+ const counts = new Map<WorkPlanItemStatus, number>();
105
+ for (const status of STATUS_ORDER) counts.set(status, 0);
106
+ for (const item of this.items) counts.set(item.status, (counts.get(item.status) ?? 0) + 1);
107
+ const total = this.items.length;
108
+ const done = (counts.get('done') ?? 0) + (counts.get('cancelled') ?? 0);
109
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
110
+
111
+ const header = [
112
+ `Project ${this.projectRoot}`,
113
+ progressBar(pct),
114
+ `pending ${counts.get('pending') ?? 0} active ${counts.get('in_progress') ?? 0} blocked ${counts.get('blocked') ?? 0} done ${counts.get('done') ?? 0} failed ${counts.get('failed') ?? 0} cancelled ${counts.get('cancelled') ?? 0}`,
115
+ ];
116
+
117
+ const rows: ConfigModalRow[] = this.items.map((item) => {
118
+ const owner = item.owner ? ` @${item.owner}` : '';
119
+ const source = item.source ? ` (${item.source})` : '';
120
+ const notes = item.notes ? ` · ${item.notes}` : '';
121
+ const linked = linkedSegments(item.linked);
122
+ const linkPart = linked ? ` · ${linked}` : '';
123
+ return { id: item.id, label: `${STATUS_LABEL[item.status]} ${item.title}${owner}${source} updated ${compactDate(item.updatedAt)}${notes}${linkPart}` };
124
+ });
125
+
126
+ return { title: 'Work Plan', tabs: [{ id: 'items', label: 'Items', header, rows, emptyText: 'No work plan items yet.', hints: ['enter cycle status', '1-6 set status', 'd delete', 'c clear done', 'a add'] }] };
127
+ }
128
+
129
+ onAction(id: string, ctx: ConfigModalActionContext): void {
130
+ if (id === 'refresh') { this.refresh(); ctx.setStatus('Reloaded work plan.'); return; }
131
+ if (id === 'clearCompleted') { void ctx.executeCommand?.('work-plan', ['clear-done']); ctx.setStatus('Dispatched /work-plan clear-done.'); return; }
132
+ if (id === 'add') { void ctx.executeCommand?.('work-plan', ['add']); ctx.setStatus('Type the rest on the command line: /work-plan add <title>.'); return; }
133
+ const item = ctx.row ? this.itemFrom(ctx.row.id) : undefined;
134
+ if (!item) return;
135
+ if (id === 'cycleStatus') { void ctx.executeCommand?.('work-plan', ['cycle', item.id]); ctx.setStatus(`Dispatched /work-plan cycle ${item.id}.`); return; }
136
+ if (id === 'remove') { void ctx.executeCommand?.('work-plan', ['remove', item.id]); ctx.setStatus(`Dispatched /work-plan remove ${item.id}.`); return; }
137
+ if (id === 'jumpAgent') {
138
+ const agentId = item.linked?.agentId;
139
+ if (!agentId) return;
140
+ // 'agent' matches the SDK's ProcessKind for a live agent node — see
141
+ // FleetPanel.receiveDeepLink's id+kind match.
142
+ void ctx.executeCommand?.('panel', ['open', 'fleet', '--target', `${agentId}:agent`]);
143
+ ctx.setStatus(`Opened Fleet on agent ${agentId}.`);
144
+ return;
145
+ }
146
+ if (id === 'jumpWrfc') {
147
+ const wrfcId = item.linked?.wrfcId;
148
+ if (!wrfcId) return;
149
+ void ctx.executeCommand?.('panel', ['open', 'fleet', '--target', `${wrfcId}:wrfc-chain`]);
150
+ ctx.setStatus(`Opened Fleet on WRFC chain ${wrfcId}.`);
151
+ return;
152
+ }
153
+ const status: WorkPlanItemStatus | null =
154
+ id === 'setPending' ? 'pending' : id === 'setInProgress' ? 'in_progress' : id === 'setBlocked' ? 'blocked'
155
+ : id === 'setDone' ? 'done' : id === 'setFailed' ? 'failed' : id === 'setCancelled' ? 'cancelled' : null;
156
+ if (!status) return;
157
+ void ctx.executeCommand?.('work-plan', [STATUS_COMMAND[status], item.id]);
158
+ ctx.setStatus(`Dispatched /work-plan ${STATUS_COMMAND[status]} ${item.id}.`);
159
+ }
160
+ }
161
+
162
+ export function createWorkPlanModalSurface(deps: WorkPlanModalDeps): ConfigModalSurface {
163
+ return new WorkPlanModalSurface(deps);
164
+ }
165
+
166
+ /**
167
+ * Deterministic golden fixture: fixed work-plan items with frozen updatedAt
168
+ * epoch values — no live store, no wall-clock, no random ids.
169
+ */
170
+ export function workPlanModalGoldenSurface(): ConfigModalSurface {
171
+ const FIXED_UPDATED_AT = 1735693200000; // 2025-01-01T01:00:00.000Z
172
+ const items: readonly WorkPlanItemLike[] = [
173
+ { id: 'wpi-fixed001', title: 'Migrate KNOWLEDGE/MEMORY/WORK-PLAN panels to modals', status: 'in_progress', owner: 'wo-b', source: 'tui-panel', notes: 'Charter: read/navigate only; mutations route to the command path.', linked: { agentId: 'agent-fixed-1' }, updatedAt: FIXED_UPDATED_AT },
174
+ { id: 'wpi-fixed002', title: 'Wire modal host redirects', status: 'pending', updatedAt: FIXED_UPDATED_AT },
175
+ ];
176
+ return createWorkPlanModalSurface({ workPlanStore: { getActivePlan: () => ({ projectRoot: '/fixed/project', items }) } });
177
+ }
@@ -0,0 +1,72 @@
1
+ // ---------------------------------------------------------------------------
2
+ // PanelConfirmOverlay — arm a confirm from outside, resolve it inside
3
+ // handleInput().
4
+ //
5
+ // Why this exists: SlashCommand.handler(args, ctx) is a single-shot call —
6
+ // there is no "await next keypress" primitive in the command layer. A command
7
+ // that needs a destructive confirm (e.g. /rewind — checkpoint-runtime.ts) can
8
+ // only resolve its target, open+focus a panel, and arm a pending confirm;
9
+ // the actual y/n/Enter/Esc handling has to live in that panel's
10
+ // handleInput() loop. This wraps the project's canonical ConfirmState<T>
11
+ // contract (confirm-state.ts, used inline by GitPanel) so panels opened this
12
+ // way don't each hand-roll the same field + dispatch + render wiring.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import type { Line } from '../types/grid.ts';
16
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
17
+
18
+ export interface PanelConfirmOverlayHandlers {
19
+ readonly onConfirm: () => void | Promise<void>;
20
+ readonly onCancel?: () => void;
21
+ }
22
+
23
+ export class PanelConfirmOverlay {
24
+ private state: ConfirmState<{ id: string }> | null = null;
25
+ private handlers: PanelConfirmOverlayHandlers | null = null;
26
+
27
+ /** @param onChange Called after arm() and after a confirmed/cancelled resolution (not on an absorbed keypress) — wire to the host panel's protected markDirty(). */
28
+ constructor(private readonly onChange: () => void = () => {}) {}
29
+
30
+ /** Arm a confirm/cancel prompt. Caller's handlers run only from handleInput(), never eagerly. */
31
+ arm(opts: { id: string; label: string; verb?: string } & PanelConfirmOverlayHandlers): void {
32
+ this.state = { subject: { id: opts.id }, label: opts.label, verb: opts.verb };
33
+ this.handlers = { onConfirm: opts.onConfirm, onCancel: opts.onCancel };
34
+ this.onChange();
35
+ }
36
+
37
+ get pending(): boolean {
38
+ return this.state !== null;
39
+ }
40
+
41
+ /**
42
+ * Call FIRST, before any other key dispatch. Returns true if the key was
43
+ * consumed by the overlay (confirmed/cancelled/absorbed); false if no
44
+ * confirm is pending (caller should continue normal dispatch).
45
+ */
46
+ handleInput(key: string): boolean {
47
+ const result = handleConfirmInput(this.state, key);
48
+ if (result === 'inactive') return false;
49
+ if (result === 'confirmed') {
50
+ const handlers = this.handlers;
51
+ this.state = null;
52
+ this.handlers = null;
53
+ this.onChange();
54
+ if (handlers) void handlers.onConfirm();
55
+ return true;
56
+ }
57
+ if (result === 'cancelled') {
58
+ const handlers = this.handlers;
59
+ this.state = null;
60
+ this.handlers = null;
61
+ this.onChange();
62
+ handlers?.onCancel?.();
63
+ return true;
64
+ }
65
+ return true; // absorbed
66
+ }
67
+
68
+ /** The two-line confirm overlay, or null when nothing is pending. */
69
+ renderLines(width: number): Line[] | null {
70
+ return this.state ? renderConfirmLines(width, this.state) : null;
71
+ }
72
+ }