@pellux/goodvibes-tui 1.1.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. 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,149 @@
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
+ readonly actions = [
68
+ { key: 'enter', id: 'cycleStatus', label: 'cycle status', enabledFor: this.hasRow },
69
+ { key: '1', id: 'setPending', label: 'pending', enabledFor: this.hasRow },
70
+ { key: '2', id: 'setInProgress', label: 'start', enabledFor: this.hasRow },
71
+ { key: '3', id: 'setBlocked', label: 'block', enabledFor: this.hasRow },
72
+ { key: '4', id: 'setDone', label: 'done', enabledFor: this.hasRow },
73
+ { key: '5', id: 'setFailed', label: 'fail', enabledFor: this.hasRow },
74
+ { key: '6', id: 'setCancelled', label: 'cancel', enabledFor: this.hasRow },
75
+ { key: 'd', id: 'remove', label: 'delete', enabledFor: this.hasRow },
76
+ { key: 'c', id: 'clearCompleted', label: 'clear done' },
77
+ { key: 'a', id: 'add', label: 'add' },
78
+ { key: 'r', id: 'refresh', label: 'refresh' },
79
+ ];
80
+
81
+ onOpen(): void { this.refresh(); }
82
+
83
+ private refresh(): void {
84
+ const plan = this.deps.workPlanStore.getActivePlan();
85
+ this.projectRoot = plan.projectRoot;
86
+ this.items = [...plan.items];
87
+ }
88
+
89
+ private itemFrom(id: string): WorkPlanItemLike | undefined { return this.items.find((i) => i.id === id); }
90
+
91
+ buildView(): ConfigModalView {
92
+ const counts = new Map<WorkPlanItemStatus, number>();
93
+ for (const status of STATUS_ORDER) counts.set(status, 0);
94
+ for (const item of this.items) counts.set(item.status, (counts.get(item.status) ?? 0) + 1);
95
+ const total = this.items.length;
96
+ const done = (counts.get('done') ?? 0) + (counts.get('cancelled') ?? 0);
97
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
98
+
99
+ const header = [
100
+ `Project ${this.projectRoot}`,
101
+ progressBar(pct),
102
+ `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}`,
103
+ ];
104
+
105
+ const rows: ConfigModalRow[] = this.items.map((item) => {
106
+ const owner = item.owner ? ` @${item.owner}` : '';
107
+ const source = item.source ? ` (${item.source})` : '';
108
+ const notes = item.notes ? ` · ${item.notes}` : '';
109
+ const linked = linkedSegments(item.linked);
110
+ const linkPart = linked ? ` · ${linked}` : '';
111
+ return { id: item.id, label: `${STATUS_LABEL[item.status]} ${item.title}${owner}${source} updated ${compactDate(item.updatedAt)}${notes}${linkPart}` };
112
+ });
113
+
114
+ 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'] }] };
115
+ }
116
+
117
+ onAction(id: string, ctx: ConfigModalActionContext): void {
118
+ if (id === 'refresh') { this.refresh(); ctx.setStatus('Reloaded work plan.'); return; }
119
+ if (id === 'clearCompleted') { void ctx.executeCommand?.('work-plan', ['clear-done']); ctx.setStatus('Dispatched /work-plan clear-done.'); return; }
120
+ if (id === 'add') { void ctx.executeCommand?.('work-plan', ['add']); ctx.setStatus('Type the rest on the command line: /work-plan add <title>.'); return; }
121
+ const item = ctx.row ? this.itemFrom(ctx.row.id) : undefined;
122
+ if (!item) return;
123
+ if (id === 'cycleStatus') { void ctx.executeCommand?.('work-plan', ['cycle', item.id]); ctx.setStatus(`Dispatched /work-plan cycle ${item.id}.`); return; }
124
+ if (id === 'remove') { void ctx.executeCommand?.('work-plan', ['remove', item.id]); ctx.setStatus(`Dispatched /work-plan remove ${item.id}.`); return; }
125
+ const status: WorkPlanItemStatus | null =
126
+ id === 'setPending' ? 'pending' : id === 'setInProgress' ? 'in_progress' : id === 'setBlocked' ? 'blocked'
127
+ : id === 'setDone' ? 'done' : id === 'setFailed' ? 'failed' : id === 'setCancelled' ? 'cancelled' : null;
128
+ if (!status) return;
129
+ void ctx.executeCommand?.('work-plan', [STATUS_COMMAND[status], item.id]);
130
+ ctx.setStatus(`Dispatched /work-plan ${STATUS_COMMAND[status]} ${item.id}.`);
131
+ }
132
+ }
133
+
134
+ export function createWorkPlanModalSurface(deps: WorkPlanModalDeps): ConfigModalSurface {
135
+ return new WorkPlanModalSurface(deps);
136
+ }
137
+
138
+ /**
139
+ * Deterministic golden fixture: fixed work-plan items with frozen updatedAt
140
+ * epoch values — no live store, no wall-clock, no random ids.
141
+ */
142
+ export function workPlanModalGoldenSurface(): ConfigModalSurface {
143
+ const FIXED_UPDATED_AT = 1735693200000; // 2025-01-01T01:00:00.000Z
144
+ const items: readonly WorkPlanItemLike[] = [
145
+ { 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 },
146
+ { id: 'wpi-fixed002', title: 'Wire modal host redirects', status: 'pending', updatedAt: FIXED_UPDATED_AT },
147
+ ];
148
+ return createWorkPlanModalSurface({ workPlanStore: { getActivePlan: () => ({ projectRoot: '/fixed/project', items }) } });
149
+ }
@@ -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
+ }