@pellux/goodvibes-tui 0.19.53 → 0.19.55

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 (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +10 -13
  3. package/docs/foundation-artifacts/knowledge-store.sql +27 -0
  4. package/docs/foundation-artifacts/operator-contract.json +15736 -7265
  5. package/package.json +2 -2
  6. package/src/audio/spoken-turn-controller.ts +4 -1
  7. package/src/input/command-args-hint.ts +36 -0
  8. package/src/input/command-registry.ts +3 -1
  9. package/src/input/commands/config.ts +7 -521
  10. package/src/input/commands/knowledge.ts +111 -1
  11. package/src/input/commands/local-runtime.ts +0 -80
  12. package/src/input/commands/operator-runtime.ts +3 -3
  13. package/src/input/commands/planning-runtime.ts +83 -34
  14. package/src/input/commands/shell-core.ts +2 -34
  15. package/src/input/commands/tts-runtime.ts +1 -389
  16. package/src/input/commands.ts +0 -2
  17. package/src/input/handler-modal-routes.ts +61 -7
  18. package/src/input/handler-modal-token-routes.ts +1 -0
  19. package/src/input/handler-picker-routes.ts +50 -4
  20. package/src/input/model-picker-provider-filter.ts +28 -0
  21. package/src/input/model-picker-types.ts +12 -0
  22. package/src/input/model-picker.ts +65 -23
  23. package/src/input/selection-modal.ts +1 -1
  24. package/src/input/settings-modal-behavior.ts +2 -0
  25. package/src/input/settings-modal-subscriptions.ts +95 -0
  26. package/src/input/settings-modal-types.ts +50 -3
  27. package/src/input/settings-modal.ts +106 -134
  28. package/src/input/tts-settings-actions.ts +100 -0
  29. package/src/main.ts +50 -45
  30. package/src/panels/builtin/agent.ts +15 -0
  31. package/src/panels/builtin/shared.ts +17 -0
  32. package/src/panels/project-planning-panel.ts +370 -0
  33. package/src/planning/project-planning-coordinator.ts +249 -0
  34. package/src/renderer/compositor.ts +2 -1
  35. package/src/renderer/conversation-overlays.ts +4 -5
  36. package/src/renderer/model-workspace.ts +488 -0
  37. package/src/renderer/settings-modal-helpers.ts +16 -1
  38. package/src/renderer/settings-modal.ts +616 -716
  39. package/src/runtime/bootstrap-command-context.ts +6 -0
  40. package/src/runtime/bootstrap-command-parts.ts +5 -0
  41. package/src/runtime/bootstrap-shell.ts +2 -0
  42. package/src/runtime/services.ts +33 -2
  43. package/src/runtime/terminal-output-guard.ts +228 -0
  44. package/src/runtime/ui-services.ts +4 -0
  45. package/src/shell/ui-openers.ts +59 -3
  46. package/src/utils/clipboard.ts +2 -1
  47. package/src/version.ts +1 -1
  48. package/src/input/commands/permissions-runtime.ts +0 -104
@@ -20,6 +20,7 @@ import type { UserAuthManager } from '@pellux/goodvibes-sdk/platform/security/us
20
20
  import type { SessionMemoryStore } from '@pellux/goodvibes-sdk/platform/core/session-memory';
21
21
  import type { ExecutionPlanManager } from '@pellux/goodvibes-sdk/platform/core/execution-plan';
22
22
  import type { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core/adaptive-planner';
23
+ import type { ProjectPlanningService } from '@pellux/goodvibes-sdk/platform/knowledge/index';
23
24
  import type { ApiTokenAuditor } from '@pellux/goodvibes-sdk/platform/security/token-audit';
24
25
  import type { ComponentHealthMonitor } from '../../runtime/perf/panel-health-monitor.ts';
25
26
  import type { WorktreeRegistry } from '@pellux/goodvibes-sdk/platform/runtime/worktree/registry';
@@ -89,6 +90,10 @@ export interface BuiltinPanelDeps {
89
90
  planManager?: ExecutionPlanManager;
90
91
  /** Adaptive planner for ops strategy panels. */
91
92
  adaptivePlanner?: AdaptivePlanner;
93
+ /** Passive SDK-backed project planning artifact service. */
94
+ projectPlanningService?: ProjectPlanningService;
95
+ /** Stable workspace project id for project:<projectId> planning spaces. */
96
+ projectPlanningProjectId?: string;
92
97
  /** Shared system-messages panel instance attached from boot so low-priority chatter stays out of conversation. */
93
98
  systemMessagesPanel?: import('../system-messages-panel.ts').SystemMessagesPanel;
94
99
  /** Explicit UI-facing runtime services for agent/process/WRFC/remote panels and modals. */
@@ -115,6 +120,8 @@ export type ResolvedBuiltinPanelDeps = Omit<
115
120
  | 'sessionMemoryStore'
116
121
  | 'planManager'
117
122
  | 'adaptivePlanner'
123
+ | 'projectPlanningService'
124
+ | 'projectPlanningProjectId'
118
125
  | 'policyRuntimeState'
119
126
  | 'systemMessagesPanel'
120
127
  > & {
@@ -126,6 +133,8 @@ export type ResolvedBuiltinPanelDeps = Omit<
126
133
  readonly sessionMemoryStore: SessionMemoryStore;
127
134
  readonly planManager: ExecutionPlanManager;
128
135
  readonly adaptivePlanner: AdaptivePlanner;
136
+ readonly projectPlanningService: ProjectPlanningService;
137
+ readonly projectPlanningProjectId: string;
129
138
  readonly policyRuntimeState: PolicyRuntimeState;
130
139
  readonly systemMessagesPanel: import('../system-messages-panel.ts').SystemMessagesPanel;
131
140
  };
@@ -179,6 +188,14 @@ export function resolveBuiltinPanelDeps(deps: BuiltinPanelDeps): ResolvedBuiltin
179
188
  uiServices.planning.adaptivePlanner,
180
189
  'Adaptive planner must be wired at bootstrap for builtin panels.',
181
190
  ),
191
+ projectPlanningService: requireBuiltinPanelDep(
192
+ uiServices.planning.projectPlanningService,
193
+ 'Project planning service must be wired at bootstrap for builtin panels.',
194
+ ),
195
+ projectPlanningProjectId: requireBuiltinPanelDep(
196
+ uiServices.planning.projectPlanningProjectId,
197
+ 'Project planning project id must be wired at bootstrap for builtin panels.',
198
+ ),
182
199
  policyRuntimeState: requireBuiltinPanelDep(
183
200
  uiServices.platform.policyRuntimeState,
184
201
  'Policy runtime state must be wired at bootstrap for builtin panels.',
@@ -0,0 +1,370 @@
1
+ import type {
2
+ ProjectPlanningDecision,
3
+ ProjectPlanningEvaluation,
4
+ ProjectPlanningLanguageArtifact,
5
+ ProjectPlanningService,
6
+ ProjectPlanningState,
7
+ ProjectPlanningStatus,
8
+ } from '@pellux/goodvibes-sdk/platform/knowledge/index';
9
+ import type { Line } from '../types/grid.ts';
10
+ import { BasePanel } from './base-panel.ts';
11
+ import {
12
+ buildBodyText,
13
+ buildEmptyState,
14
+ buildKeyValueLine,
15
+ buildPanelLine,
16
+ buildPanelWorkspace,
17
+ DEFAULT_PANEL_PALETTE,
18
+ extendPalette,
19
+ resolveScrollablePanelSection,
20
+ type PanelWorkspaceSection,
21
+ } from './polish.ts';
22
+
23
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
24
+ planning: '#38bdf8',
25
+ blocked: '#f97316',
26
+ approved: '#22c55e',
27
+ rejected: '#ef4444',
28
+ });
29
+
30
+ interface ProjectPlanningPanelSnapshot {
31
+ readonly status: ProjectPlanningStatus | null;
32
+ readonly state: ProjectPlanningState | null;
33
+ readonly evaluation: ProjectPlanningEvaluation | null;
34
+ readonly decisions: readonly ProjectPlanningDecision[];
35
+ readonly language: ProjectPlanningLanguageArtifact | null;
36
+ }
37
+
38
+ export interface ProjectPlanningPanelOptions {
39
+ readonly service: ProjectPlanningService;
40
+ readonly projectId: string;
41
+ readonly requestRender?: () => void;
42
+ }
43
+
44
+ export class ProjectPlanningPanel extends BasePanel {
45
+ private readonly service: ProjectPlanningService;
46
+ private readonly projectId: string;
47
+ private readonly requestRender: () => void;
48
+ private snapshot: ProjectPlanningPanelSnapshot | null = null;
49
+ private loading = false;
50
+ private scrollOffset = 0;
51
+
52
+ public constructor(options: ProjectPlanningPanelOptions) {
53
+ super('project-planning', 'Planning', 'P', 'agent');
54
+ this.service = options.service;
55
+ this.projectId = options.projectId;
56
+ this.requestRender = options.requestRender ?? (() => {});
57
+ }
58
+
59
+ public override onActivate(): void {
60
+ super.onActivate();
61
+ this.refresh();
62
+ }
63
+
64
+ public handleInput(key: string): boolean {
65
+ if (key === 'r' || key === 'R') {
66
+ this.refresh(true);
67
+ return true;
68
+ }
69
+ if (key === 'a' || key === 'A') {
70
+ this.approveExecution();
71
+ return true;
72
+ }
73
+ if (key === 'up' || key === 'k') {
74
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
75
+ this.markDirty();
76
+ return true;
77
+ }
78
+ if (key === 'down' || key === 'j') {
79
+ this.scrollOffset += 1;
80
+ this.markDirty();
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ public render(width: number, height: number): Line[] {
87
+ return this.trackedRender(() => {
88
+ if (!this.snapshot && !this.loading) this.refresh();
89
+
90
+ const sections: PanelWorkspaceSection[] = [];
91
+ const status = this.snapshot?.status;
92
+ const state = this.snapshot?.state;
93
+ const evaluation = this.snapshot?.evaluation ?? null;
94
+ const language = this.snapshot?.language ?? null;
95
+ const decisions = this.snapshot?.decisions ?? [];
96
+
97
+ sections.push({
98
+ title: 'Workspace',
99
+ lines: [
100
+ buildKeyValueLine(width, [
101
+ { label: 'project', value: this.projectId, valueColor: C.planning },
102
+ { label: 'space', value: status?.knowledgeSpaceId ?? `project:${this.projectId}`, valueColor: C.value },
103
+ { label: 'mode', value: 'TUI-owned passive backing store', valueColor: C.info },
104
+ ], C),
105
+ buildPanelLine(width, [
106
+ [' Planning never starts from daemon, webhooks, ntfy, Home Assistant, or companion surfaces.', C.dim],
107
+ ]),
108
+ ],
109
+ });
110
+
111
+ if (!state) {
112
+ sections.push({
113
+ title: 'No Active Planning State',
114
+ lines: buildEmptyState(
115
+ width,
116
+ 'No project planning state has been saved for this workspace.',
117
+ 'Describe the intended change in normal chat to let the TUI start the planning interview. The SDK only stores and evaluates artifacts.',
118
+ [],
119
+ C,
120
+ ),
121
+ });
122
+ } else {
123
+ sections.push(this.buildStateSection(width, state, evaluation));
124
+ sections.push(this.buildGapsSection(width, evaluation));
125
+ sections.push(this.buildTasksSection(width, state));
126
+ sections.push(this.buildDecisionsSection(width, state, decisions));
127
+ sections.push(this.buildLanguageSection(width, language));
128
+ }
129
+
130
+ if (this.lastError) {
131
+ const line = this.renderErrorLine(width);
132
+ if (line) sections.push({ title: 'Error', lines: [line] });
133
+ }
134
+
135
+ const flattened = sections.flatMap((section) => [
136
+ ...(section.title ? [buildPanelLine(width, [[` ${section.title}`, C.label]])] : []),
137
+ ...section.lines,
138
+ ]);
139
+ const scroll = resolveScrollablePanelSection(width, height, {
140
+ intro: 'Project planning state, readiness gaps, decisions, language, task graph, verification gates, and agent handoff metadata.',
141
+ footerLines: this.footerLines(width),
142
+ palette: C,
143
+ section: {
144
+ title: 'Project Planning',
145
+ scrollableLines: flattened,
146
+ selectedIndex: 0,
147
+ scrollOffset: this.scrollOffset,
148
+ minRows: 8,
149
+ },
150
+ });
151
+ this.scrollOffset = scroll.scrollOffset;
152
+
153
+ return buildPanelWorkspace(width, height, {
154
+ title: this.loading ? 'Project Planning - loading' : 'Project Planning',
155
+ intro: 'Passive SDK-backed planning artifacts for the current workspace. Conversation control stays inside this TUI.',
156
+ sections: [scroll.section],
157
+ footerLines: this.footerLines(width),
158
+ palette: C,
159
+ });
160
+ });
161
+ }
162
+
163
+ private footerLines(width: number): Line[] {
164
+ return [
165
+ buildPanelLine(width, [
166
+ [' Up/Down', C.info],
167
+ [' scroll ', C.dim],
168
+ ['r', C.info],
169
+ [' refresh ', C.dim],
170
+ ['a', C.info],
171
+ [' approve execution-ready plan Esc close panel focus', C.dim],
172
+ ]),
173
+ ];
174
+ }
175
+
176
+ private buildStateSection(
177
+ width: number,
178
+ state: ProjectPlanningState,
179
+ evaluation: ProjectPlanningEvaluation | null,
180
+ ): PanelWorkspaceSection {
181
+ const readiness = evaluation?.readiness ?? state.readiness;
182
+ const readinessColor = readiness === 'executable'
183
+ ? C.approved
184
+ : readiness === 'needs-user-input'
185
+ ? C.blocked
186
+ : C.dim;
187
+ const lines: Line[] = [
188
+ buildKeyValueLine(width, [
189
+ { label: 'readiness', value: readiness, valueColor: readinessColor },
190
+ { label: 'approved', value: state.executionApproved ? 'yes' : 'no', valueColor: state.executionApproved ? C.approved : C.blocked },
191
+ { label: 'questions', value: `${state.openQuestions.length} open / ${state.answeredQuestions.length} answered`, valueColor: C.value },
192
+ ], C),
193
+ ...buildBodyText(width, `Goal: ${state.goal || '(not set)'}`, C, state.goal ? C.value : C.blocked),
194
+ ];
195
+ if (state.scope) lines.push(...buildBodyText(width, `Scope: ${state.scope}`, C, C.value));
196
+ if (state.knownContext.length) {
197
+ lines.push(...buildBodyText(width, `Known context: ${state.knownContext.join(' | ')}`, C, C.dim));
198
+ }
199
+ if (evaluation?.nextQuestion) {
200
+ lines.push(...buildBodyText(width, `Next question: ${evaluation.nextQuestion.prompt}`, C, C.planning));
201
+ if (evaluation.nextQuestion.whyItMatters) {
202
+ lines.push(...buildBodyText(width, `Why it matters: ${evaluation.nextQuestion.whyItMatters}`, C, C.dim));
203
+ }
204
+ if (evaluation.nextQuestion.recommendedAnswer) {
205
+ lines.push(...buildBodyText(width, `Recommended answer: ${evaluation.nextQuestion.recommendedAnswer}`, C, C.good));
206
+ }
207
+ }
208
+ return { title: 'State', lines };
209
+ }
210
+
211
+ private buildGapsSection(width: number, evaluation: ProjectPlanningEvaluation | null): PanelWorkspaceSection {
212
+ const gaps = evaluation?.gaps ?? [];
213
+ if (gaps.length === 0) {
214
+ return {
215
+ title: 'Readiness Gaps',
216
+ lines: [buildPanelLine(width, [[' No readiness gaps.', C.good]])],
217
+ };
218
+ }
219
+ return {
220
+ title: 'Readiness Gaps',
221
+ lines: gaps.slice(0, 12).flatMap((gap) => buildBodyText(
222
+ width,
223
+ `${gap.severity.toUpperCase()} ${gap.kind}: ${gap.message}`,
224
+ C,
225
+ gap.severity === 'blocking' ? C.blocked : C.warn,
226
+ )),
227
+ };
228
+ }
229
+
230
+ private buildTasksSection(width: number, state: ProjectPlanningState): PanelWorkspaceSection {
231
+ const lines: Line[] = [];
232
+ if (state.tasks.length === 0) {
233
+ lines.push(buildPanelLine(width, [[' No decomposed tasks recorded yet.', C.dim]]));
234
+ } else {
235
+ for (const task of state.tasks) {
236
+ lines.push(...buildBodyText(
237
+ width,
238
+ `${task.id}: ${task.title} [${task.status ?? 'pending'}]${task.canRunConcurrently ? ' - concurrent' : ''}`,
239
+ C,
240
+ task.blockedOnUserInput ? C.blocked : C.value,
241
+ ));
242
+ if (task.dependencies?.length) lines.push(...buildBodyText(width, `Dependencies: ${task.dependencies.join(', ')}`, C, C.dim));
243
+ if (task.verification?.length) lines.push(...buildBodyText(width, `Verification: ${task.verification.join(' | ')}`, C, C.good));
244
+ }
245
+ }
246
+
247
+ if (state.verificationGates.length) {
248
+ lines.push(buildPanelLine(width, [[' Verification gates:', C.label]]));
249
+ for (const gate of state.verificationGates) {
250
+ lines.push(...buildBodyText(width, `${gate.id}: ${gate.description} [${gate.status ?? 'pending'}]`, C, gate.required === false ? C.dim : C.good));
251
+ }
252
+ }
253
+ if (state.agentAssignments.length) {
254
+ lines.push(buildPanelLine(width, [[' Agent handoff candidates:', C.label]]));
255
+ for (const assignment of state.agentAssignments) {
256
+ lines.push(...buildBodyText(
257
+ width,
258
+ `${assignment.taskId}: ${assignment.agentType ?? 'none'}${assignment.canRunConcurrently ? ' - can run concurrently' : ''}`,
259
+ C,
260
+ C.info,
261
+ ));
262
+ }
263
+ }
264
+ return { title: 'Task Graph', lines };
265
+ }
266
+
267
+ private buildDecisionsSection(
268
+ width: number,
269
+ state: ProjectPlanningState,
270
+ storedDecisions: readonly ProjectPlanningDecision[],
271
+ ): PanelWorkspaceSection {
272
+ const byId = new Map<string, ProjectPlanningDecision>();
273
+ for (const decision of [...storedDecisions, ...state.decisions]) byId.set(decision.id, decision);
274
+ const decisions = [...byId.values()];
275
+ if (decisions.length === 0) {
276
+ return {
277
+ title: 'Decisions',
278
+ lines: [buildPanelLine(width, [[' No durable planning decisions recorded yet.', C.dim]])],
279
+ };
280
+ }
281
+ return {
282
+ title: 'Decisions',
283
+ lines: decisions.slice(0, 12).flatMap((decision) => buildBodyText(
284
+ width,
285
+ `${decision.title}: ${decision.decision} [${decision.status ?? 'accepted'}]`,
286
+ C,
287
+ decision.status === 'rejected' ? C.rejected : C.value,
288
+ )),
289
+ };
290
+ }
291
+
292
+ private buildLanguageSection(width: number, language: ProjectPlanningLanguageArtifact | null): PanelWorkspaceSection {
293
+ if (!language || (language.terms.length === 0 && language.ambiguities.length === 0)) {
294
+ return {
295
+ title: 'Project Language',
296
+ lines: [buildPanelLine(width, [[' No project language terms or ambiguity resolutions recorded yet.', C.dim]])],
297
+ };
298
+ }
299
+ const lines: Line[] = [];
300
+ for (const term of language.terms.slice(0, 8)) {
301
+ lines.push(...buildBodyText(width, `${term.term}: ${term.definition}`, C, C.value));
302
+ if (term.avoid?.length) lines.push(...buildBodyText(width, `Avoid: ${term.avoid.join(', ')}`, C, C.blocked));
303
+ }
304
+ for (const ambiguity of language.ambiguities.slice(0, 8)) {
305
+ lines.push(...buildBodyText(width, `Resolved ambiguity - ${ambiguity.phrase}: ${ambiguity.resolution}`, C, C.info));
306
+ }
307
+ return { title: 'Project Language', lines };
308
+ }
309
+
310
+ private refresh(force = false): void {
311
+ if (this.loading && !force) return;
312
+ this.loading = true;
313
+ this.markDirty();
314
+ this.requestRender();
315
+ void (async () => {
316
+ try {
317
+ const [status, stateResult, decisionsResult, languageResult] = await Promise.all([
318
+ this.service.status({ projectId: this.projectId }),
319
+ this.service.getState({ projectId: this.projectId }),
320
+ this.service.listDecisions({ projectId: this.projectId }),
321
+ this.service.getLanguage({ projectId: this.projectId }),
322
+ ]);
323
+ const evaluation = await this.service.evaluate({ projectId: this.projectId });
324
+ this.snapshot = {
325
+ status,
326
+ state: stateResult.state,
327
+ evaluation,
328
+ decisions: decisionsResult.decisions,
329
+ language: languageResult.language,
330
+ };
331
+ this.clearError();
332
+ } catch (err) {
333
+ this.setError(err instanceof Error ? err.message : String(err));
334
+ } finally {
335
+ this.loading = false;
336
+ this.markDirty();
337
+ this.requestRender();
338
+ }
339
+ })();
340
+ }
341
+
342
+ private approveExecution(): void {
343
+ const state = this.snapshot?.state;
344
+ if (!state) {
345
+ this.setError('No planning state exists to approve.');
346
+ this.requestRender();
347
+ return;
348
+ }
349
+ void (async () => {
350
+ try {
351
+ await this.service.upsertState({
352
+ projectId: this.projectId,
353
+ state: {
354
+ ...state,
355
+ executionApproved: true,
356
+ metadata: {
357
+ ...(state.metadata ?? {}),
358
+ approvedFrom: 'project-planning-panel',
359
+ approvedAt: Date.now(),
360
+ },
361
+ },
362
+ });
363
+ this.refresh(true);
364
+ } catch (err) {
365
+ this.setError(err instanceof Error ? err.message : String(err));
366
+ this.requestRender();
367
+ }
368
+ })();
369
+ }
370
+ }
@@ -0,0 +1,249 @@
1
+ import type {
2
+ ProjectPlanningEvaluation,
3
+ ProjectPlanningQuestion,
4
+ ProjectPlanningService,
5
+ ProjectPlanningState,
6
+ } from '@pellux/goodvibes-sdk/platform/knowledge/index';
7
+
8
+ export interface ProjectPlanningCoordinatorOptions {
9
+ readonly service: ProjectPlanningService;
10
+ readonly projectId: string;
11
+ readonly workingDirectory: string;
12
+ readonly openPanel?: () => void;
13
+ readonly notify?: (message: string) => void;
14
+ readonly now?: () => number;
15
+ }
16
+
17
+ export interface ProjectPlanningTurnPreparation {
18
+ readonly systemMessage: string;
19
+ readonly state: ProjectPlanningState;
20
+ readonly evaluation: ProjectPlanningEvaluation;
21
+ }
22
+
23
+ const PLANNING_INTENT_PATTERNS: readonly RegExp[] = [
24
+ /\b(plan|planning)\b/i,
25
+ /\bimplementation (plan|strategy)\b/i,
26
+ /\bexecution (plan|strategy)\b/i,
27
+ /\bdependency graph\b/i,
28
+ /\bbreak (this|it|that|the work) down\b/i,
29
+ /\bbefore (coding|implementing|we start|execution)\b/i,
30
+ /\bagent (handoff|assignment|assignments)\b/i,
31
+ /\bverification gates?\b/i,
32
+ /\binterview (me|loop)\b/i,
33
+ ];
34
+
35
+ const CANCEL_PATTERNS: readonly RegExp[] = [
36
+ /\b(stop|cancel|pause|exit) (the )?planning\b/i,
37
+ /\bplanning (is )?(done|cancelled|canceled|paused)\b/i,
38
+ ];
39
+
40
+ const APPROVAL_PATTERNS: readonly RegExp[] = [
41
+ /\b(approve|approved|approval granted)\b/i,
42
+ /\b(go ahead|execute this plan|start execution|ready to execute)\b/i,
43
+ ];
44
+
45
+ export function hasProjectPlanningIntent(text: string): boolean {
46
+ const trimmed = text.trim();
47
+ if (!trimmed || trimmed.startsWith('/')) return false;
48
+ return PLANNING_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed));
49
+ }
50
+
51
+ export class ProjectPlanningCoordinator {
52
+ private readonly service: ProjectPlanningService;
53
+ private readonly projectId: string;
54
+ private readonly workingDirectory: string;
55
+ private readonly openPanel: () => void;
56
+ private readonly notify: (message: string) => void;
57
+ private readonly now: () => number;
58
+
59
+ public constructor(options: ProjectPlanningCoordinatorOptions) {
60
+ this.service = options.service;
61
+ this.projectId = options.projectId;
62
+ this.workingDirectory = options.workingDirectory;
63
+ this.openPanel = options.openPanel ?? (() => {});
64
+ this.notify = options.notify ?? (() => {});
65
+ this.now = options.now ?? (() => Date.now());
66
+ }
67
+
68
+ public async prepareTurn(text: string): Promise<ProjectPlanningTurnPreparation | null> {
69
+ const prompt = text.trim();
70
+ if (!prompt || prompt.startsWith('!') || prompt.startsWith('/')) return null;
71
+
72
+ const stateResult = await this.service.getState({ projectId: this.projectId });
73
+ const existing = stateResult.state;
74
+ const active = this.isActive(existing);
75
+ const startsPlanning = hasProjectPlanningIntent(prompt);
76
+
77
+ if (this.isCancel(prompt)) {
78
+ if (existing) {
79
+ await this.service.upsertState({
80
+ projectId: this.projectId,
81
+ state: {
82
+ ...existing,
83
+ metadata: {
84
+ ...(existing.metadata ?? {}),
85
+ active: false,
86
+ pausedAt: this.now(),
87
+ },
88
+ },
89
+ });
90
+ this.notify('[Planning] Paused project planning for this workspace.');
91
+ }
92
+ return null;
93
+ }
94
+
95
+ if (!active && !startsPlanning) return null;
96
+
97
+ const answeredQuestion = active ? this.firstOpenQuestion(existing) : null;
98
+ const stateDraft = this.buildStateDraft(existing, prompt, {
99
+ startsPlanning,
100
+ answeredQuestion,
101
+ approved: this.isApproval(prompt),
102
+ });
103
+
104
+ const firstEvaluation = await this.service.evaluate({
105
+ projectId: this.projectId,
106
+ state: stateDraft,
107
+ });
108
+ const withNextQuestion = this.recordNextQuestion(stateDraft, firstEvaluation.nextQuestion);
109
+ const normalized = await this.service.evaluate({
110
+ projectId: this.projectId,
111
+ state: withNextQuestion,
112
+ });
113
+ const saved = await this.service.upsertState({
114
+ projectId: this.projectId,
115
+ state: normalized.state,
116
+ });
117
+ const state = saved.state ?? normalized.state;
118
+ const evaluation = await this.service.evaluate({
119
+ projectId: this.projectId,
120
+ state,
121
+ });
122
+
123
+ this.openPanel();
124
+ return {
125
+ systemMessage: this.buildSystemMessage(state, evaluation),
126
+ state,
127
+ evaluation,
128
+ };
129
+ }
130
+
131
+ private buildStateDraft(
132
+ existing: ProjectPlanningState | null,
133
+ prompt: string,
134
+ options: {
135
+ readonly startsPlanning: boolean;
136
+ readonly answeredQuestion: ProjectPlanningQuestion | null;
137
+ readonly approved: boolean;
138
+ },
139
+ ): Partial<ProjectPlanningState> {
140
+ const now = this.now();
141
+ const openQuestions = [...(existing?.openQuestions ?? [])];
142
+ const answeredQuestions = [...(existing?.answeredQuestions ?? [])];
143
+ if (options.answeredQuestion) {
144
+ const idx = openQuestions.findIndex((question) => question.id === options.answeredQuestion?.id);
145
+ if (idx >= 0) openQuestions.splice(idx, 1);
146
+ answeredQuestions.push({
147
+ ...options.answeredQuestion,
148
+ status: 'answered',
149
+ answer: prompt,
150
+ answeredAt: now,
151
+ });
152
+ }
153
+
154
+ const knownContext = new Set(existing?.knownContext ?? []);
155
+ knownContext.add(`Workspace: ${this.workingDirectory}`);
156
+ if (options.startsPlanning && existing?.goal && prompt !== existing.goal) {
157
+ knownContext.add(`Latest planning request: ${prompt}`);
158
+ }
159
+
160
+ return {
161
+ ...(existing ?? {}),
162
+ projectId: this.projectId,
163
+ goal: existing?.goal?.trim() ? existing.goal : prompt,
164
+ knownContext: [...knownContext],
165
+ openQuestions,
166
+ answeredQuestions,
167
+ executionApproved: existing?.executionApproved === true || options.approved,
168
+ metadata: {
169
+ ...(existing?.metadata ?? {}),
170
+ active: true,
171
+ owner: 'tui',
172
+ source: 'conversation',
173
+ lastPromptAt: now,
174
+ },
175
+ };
176
+ }
177
+
178
+ private recordNextQuestion(
179
+ state: Partial<ProjectPlanningState>,
180
+ question: ProjectPlanningQuestion | undefined,
181
+ ): Partial<ProjectPlanningState> {
182
+ if (!question) return state;
183
+ const answered = new Set((state.answeredQuestions ?? []).map((entry) => entry.id));
184
+ if (answered.has(question.id)) return state;
185
+ const openQuestions = [...(state.openQuestions ?? [])];
186
+ const existingIndex = openQuestions.findIndex((entry) => entry.id === question.id);
187
+ const normalized = { ...question, status: question.status ?? 'open' } satisfies ProjectPlanningQuestion;
188
+ if (existingIndex >= 0) openQuestions[existingIndex] = normalized;
189
+ else openQuestions.unshift(normalized);
190
+ return {
191
+ ...state,
192
+ openQuestions,
193
+ };
194
+ }
195
+
196
+ private buildSystemMessage(
197
+ state: ProjectPlanningState,
198
+ evaluation: ProjectPlanningEvaluation,
199
+ ): string {
200
+ const nextQuestion = evaluation.nextQuestion;
201
+ const gaps = evaluation.gaps
202
+ .slice(0, 8)
203
+ .map((gap) => `- ${gap.severity}: ${gap.kind} - ${gap.message}`)
204
+ .join('\n') || '- none';
205
+ const tasks = state.tasks
206
+ .map((task) => `- ${task.id}: ${task.title}`)
207
+ .join('\n') || '- none recorded yet';
208
+
209
+ return [
210
+ 'TUI-owned project planning loop is active for this turn.',
211
+ 'Do not execute code changes, spawn agents, or claim implementation is complete unless the user explicitly approves execution after the plan is structurally ready.',
212
+ 'Be relentless and thorough: challenge vague wording, inspect relevant context before proposing execution, and ask exactly one focused question when information is missing.',
213
+ 'Prefer concrete examples and recommended answers so the user can answer quickly.',
214
+ '',
215
+ `Project id: ${this.projectId}`,
216
+ `Knowledge space: ${state.knowledgeSpaceId}`,
217
+ `Readiness: ${evaluation.readiness}`,
218
+ `Execution approved: ${state.executionApproved ? 'yes' : 'no'}`,
219
+ `Goal: ${state.goal || '(missing)'}`,
220
+ state.scope ? `Scope: ${state.scope}` : 'Scope: (missing)',
221
+ '',
222
+ 'Readiness gaps:',
223
+ gaps,
224
+ '',
225
+ 'Recorded tasks:',
226
+ tasks,
227
+ '',
228
+ nextQuestion
229
+ ? `Ask this exact next planning question unless the user already answered it: ${nextQuestion.prompt}`
230
+ : 'If the plan is structurally ready, summarize the plan and ask for explicit execution approval. Do not start execution yourself.',
231
+ ].join('\n');
232
+ }
233
+
234
+ private isActive(state: ProjectPlanningState | null): boolean {
235
+ return state?.metadata?.['active'] === true && state.executionApproved !== true;
236
+ }
237
+
238
+ private firstOpenQuestion(state: ProjectPlanningState | null): ProjectPlanningQuestion | null {
239
+ return state?.openQuestions.find((question) => (question.status ?? 'open') === 'open') ?? null;
240
+ }
241
+
242
+ private isCancel(text: string): boolean {
243
+ return CANCEL_PATTERNS.some((pattern) => pattern.test(text));
244
+ }
245
+
246
+ private isApproval(text: string): boolean {
247
+ return APPROVAL_PATTERNS.some((pattern) => pattern.test(text));
248
+ }
249
+ }
@@ -3,6 +3,7 @@ import { DiffEngine } from './diff.ts';
3
3
  import { type Line, createEmptyCell, createStyledCell } from '../types/grid.ts';
4
4
  import { getDisplayWidth } from '../utils/terminal-width.ts';
5
5
  import type { SearchManager } from '../input/search.ts';
6
+ import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
6
7
 
7
8
  export interface SelectionInfo {
8
9
  isCellSelected: (col: number, absoluteRow: number) => boolean;
@@ -270,7 +271,7 @@ export class Compositor {
270
271
  // R3: Diff against front-buffer (last-rendered), then swap front/back — no clone() needed
271
272
  const diff = this.diffEngine.diff(this.frontBuffer, newBuffer);
272
273
  if (diff) {
273
- this.stdout.write(diff);
274
+ allowTerminalWrite(() => this.stdout.write(diff));
274
275
  }
275
276
 
276
277
  // Swap: back (just written) becomes the new front reference; old front becomes the next back