@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SettingsModal — state management for the /settings config browser modal.
2
+ * SettingsModal — state management for the /settings and /config fullscreen workspace.
3
3
  *
4
4
  * Loads CONFIG_SCHEMA, groups settings by category, and tracks UI state:
5
5
  * - Active category (Tab to cycle)
@@ -14,11 +14,9 @@ import { CONFIG_SCHEMA, type ConfigKey, type PersistedFlagState } from '@pellux/
14
14
  import type { ModelPickerTarget } from './model-picker.ts';
15
15
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
16
16
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
17
- import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
18
- import type { ProviderAuthFreshness, ProviderAuthRoute } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
19
17
  import { getResolvedSettingLookup } from '@pellux/goodvibes-sdk/platform/runtime/settings/control-plane';
20
18
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
21
- import { isSecretConfigKey } from '../config/secret-config.ts';
19
+ import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
22
20
  import {
23
21
  getNumericAdjustmentMeta,
24
22
  modelPickerLaunchForKey,
@@ -28,6 +26,7 @@ import {
28
26
  setSecretBackedSettingValue,
29
27
  type SettingsSecretsManager,
30
28
  } from './settings-modal-secrets.ts';
29
+ import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
31
30
  import type { FeatureFlagManager } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/index';
32
31
  import type { FeatureFlag, FlagState } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/types';
33
32
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp/registry';
@@ -39,6 +38,7 @@ import {
39
38
  type McpEntry,
40
39
  type SettingEntry,
41
40
  type SettingsCategory,
41
+ type SettingsFocusPane,
42
42
  type SubscriptionEntry,
43
43
  } from './settings-modal-types.ts';
44
44
 
@@ -48,6 +48,7 @@ export {
48
48
  type McpEntry,
49
49
  type SettingEntry,
50
50
  type SettingsCategory,
51
+ type SettingsFocusPane,
51
52
  type SubscriptionEntry,
52
53
  } from './settings-modal-types.ts';
53
54
 
@@ -64,6 +65,9 @@ export class SettingsModal {
64
65
  /** Selected setting index within the current category. */
65
66
  public selectedIndex = 0;
66
67
 
68
+ /** Which pane receives up/down navigation and Enter/Space actions. */
69
+ public focusPane: SettingsFocusPane = 'settings';
70
+
67
71
  /** Whether we're in inline edit mode for the selected string/number setting. */
68
72
  public editingMode = false;
69
73
 
@@ -79,6 +83,8 @@ export class SettingsModal {
79
83
  public pendingModelPickerTarget: ModelPickerTarget | null = null;
80
84
  /** Set when the highlighted setting should open provider selection before model selection. */
81
85
  public pendingProviderModelPickerTarget: ModelPickerTarget | null = null;
86
+ /** Set when a highlighted setting needs an external picker owned by the shell route. */
87
+ public pendingSettingsPickerAction: 'tts-provider' | 'tts-voice' | null = null;
82
88
  /** Provider awaiting explicit logout confirmation, if any. */
83
89
  public subscriptionLogoutConfirmationTarget: string | null = null;
84
90
 
@@ -132,10 +138,12 @@ export class SettingsModal {
132
138
  this._loadSubscriptionEntries();
133
139
  this.categoryIndex = 0;
134
140
  this.selectedIndex = 0;
141
+ this.focusPane = 'settings';
135
142
  this.editingMode = false;
136
143
  this.editBuffer = '';
137
144
  this.pendingModelPickerTarget = null;
138
145
  this.pendingProviderModelPickerTarget = null;
146
+ this.pendingSettingsPickerAction = null;
139
147
  this.mcpAllowAllConfirmationTarget = null;
140
148
  this.subscriptionLogoutConfirmationTarget = null;
141
149
  this.lastSaveTriggeredRestart = null;
@@ -148,11 +156,13 @@ export class SettingsModal {
148
156
  this.editBuffer = '';
149
157
  this.pendingModelPickerTarget = null;
150
158
  this.pendingProviderModelPickerTarget = null;
159
+ this.pendingSettingsPickerAction = null;
151
160
  this.mcpAllowAllConfirmationTarget = null;
152
161
  this.subscriptionLogoutConfirmationTarget = null;
153
162
  this.lastSaveTriggeredRestart = null;
154
163
  this.serviceRegistry = null;
155
164
  this.secretsManager = null;
165
+ this.focusPane = 'settings';
156
166
  }
157
167
 
158
168
  /** Cycle to the next category (Tab). */
@@ -185,6 +195,31 @@ export class SettingsModal {
185
195
  }
186
196
  }
187
197
 
198
+ focusCategories(): void {
199
+ if (this.editingMode) return;
200
+ this.focusPane = 'categories';
201
+ }
202
+
203
+ focusSettings(): void {
204
+ if (this.editingMode) return;
205
+ this.focusPane = 'settings';
206
+ }
207
+
208
+ toggleFocusPane(): void {
209
+ if (this.editingMode) return;
210
+ this.focusPane = this.focusPane === 'settings' ? 'categories' : 'settings';
211
+ }
212
+
213
+ moveFocusedUp(): void {
214
+ if (this.focusPane === 'categories') this.prevCategory();
215
+ else this.moveUp();
216
+ }
217
+
218
+ moveFocusedDown(): void {
219
+ if (this.focusPane === 'categories') this.nextCategory();
220
+ else this.moveDown();
221
+ }
222
+
188
223
  moveUp(): void {
189
224
  if (this.editingMode) return;
190
225
  const items = this._currentItems();
@@ -222,23 +257,28 @@ export class SettingsModal {
222
257
  }
223
258
 
224
259
  getSelected(): SettingEntry | null {
225
- return this._currentItems()[this.selectedIndex] ?? null;
260
+ const items = this._currentItems();
261
+ if (items.length === 0) return null;
262
+ return items[Math.max(0, Math.min(items.length - 1, this.selectedIndex))] ?? null;
226
263
  }
227
264
 
228
265
  /** Get the currently selected flag entry (flags tab only). */
229
266
  getSelectedFlag(): FlagEntry | null {
230
267
  if (this.currentCategory !== 'flags') return null;
231
- return this.flagEntries[this.selectedIndex] ?? null;
268
+ if (this.flagEntries.length === 0) return null;
269
+ return this.flagEntries[Math.max(0, Math.min(this.flagEntries.length - 1, this.selectedIndex))] ?? null;
232
270
  }
233
271
 
234
272
  getSelectedMcp(): McpEntry | null {
235
273
  if (this.currentCategory !== 'mcp') return null;
236
- return this.mcpEntries[this.selectedIndex] ?? null;
274
+ if (this.mcpEntries.length === 0) return null;
275
+ return this.mcpEntries[Math.max(0, Math.min(this.mcpEntries.length - 1, this.selectedIndex))] ?? null;
237
276
  }
238
277
 
239
278
  getSelectedSubscription(): SubscriptionEntry | null {
240
279
  if (this.currentCategory !== 'subscriptions') return null;
241
- return this.subscriptionEntries[this.selectedIndex] ?? null;
280
+ if (this.subscriptionEntries.length === 0) return null;
281
+ return this.subscriptionEntries[Math.max(0, Math.min(this.subscriptionEntries.length - 1, this.selectedIndex))] ?? null;
242
282
  }
243
283
 
244
284
  get currentCategory(): SettingsCategory {
@@ -249,6 +289,31 @@ export class SettingsModal {
249
289
  return this._currentItems();
250
290
  }
251
291
 
292
+ selectTarget(target?: string): void {
293
+ const normalized = target?.trim();
294
+ if (!normalized) return;
295
+
296
+ const categoryIndex = SETTINGS_CATEGORIES.indexOf(normalized as SettingsCategory);
297
+ if (categoryIndex >= 0) {
298
+ this.categoryIndex = categoryIndex;
299
+ this.selectedIndex = 0;
300
+ this.focusPane = 'settings';
301
+ return;
302
+ }
303
+
304
+ for (let index = 0; index < SETTINGS_CATEGORIES.length; index += 1) {
305
+ const category = SETTINGS_CATEGORIES[index]!;
306
+ const entries = this.groups.get(category) ?? [];
307
+ const entryIndex = entries.findIndex((entry) => entry.setting.key === normalized);
308
+ if (entryIndex >= 0) {
309
+ this.categoryIndex = index;
310
+ this.selectedIndex = entryIndex;
311
+ this.focusPane = 'settings';
312
+ return;
313
+ }
314
+ }
315
+ }
316
+
252
317
  /**
253
318
  * Toggle boolean or begin cycling enum values, or enter edit mode for string/number.
254
319
  */
@@ -283,6 +348,15 @@ export class SettingsModal {
283
348
  const { setting } = entry;
284
349
 
285
350
  // Delegate provider/model picker settings to the model picker UI
351
+ if (setting.key === 'tts.provider') {
352
+ this.pendingSettingsPickerAction = 'tts-provider';
353
+ return;
354
+ }
355
+ if (setting.key === 'tts.voice') {
356
+ this.pendingSettingsPickerAction = 'tts-voice';
357
+ return;
358
+ }
359
+
286
360
  const pickerLaunch = modelPickerLaunchForKey(setting.key);
287
361
  if (pickerLaunch !== null) {
288
362
  if (pickerLaunch.flow === 'providerModel') {
@@ -497,6 +571,20 @@ export class SettingsModal {
497
571
  this.mcpAllowAllConfirmationTarget = null;
498
572
  }
499
573
 
574
+ resetSelected(): { key: ConfigKey; value: unknown } | null {
575
+ if (this.editingMode || !this.configManager) return null;
576
+ const entry = this.getSelected();
577
+ if (!entry) return null;
578
+ const key = entry.setting.key as ConfigKey;
579
+ this._setValue(key, entry.setting.default);
580
+ if (isSecretConfigKey(key) && this.secretsManager) {
581
+ void this.secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
582
+ logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
583
+ });
584
+ }
585
+ return { key, value: entry.setting.default };
586
+ }
587
+
500
588
  /** Handle a keystroke in edit mode: regular chars appended, Backspace removes last char. */
501
589
  editChar(char: string): void {
502
590
  if (!this.editingMode) return;
@@ -519,21 +607,7 @@ export class SettingsModal {
519
607
 
520
608
  for (const setting of CONFIG_SCHEMA) {
521
609
  const rawCat = setting.key.split('.')[0] as string;
522
- // Route helper.* settings into the tools group for unified display
523
- // Route controlPlane.* and httpListener.* into the network group
524
- let cat: SettingsCategory;
525
- if (rawCat === 'helper') {
526
- cat = 'tools';
527
- } else if (rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') {
528
- cat = 'network';
529
- } else if (rawCat === 'surfaces') {
530
- cat = 'surfaces';
531
- } else if (rawCat === 'cloudflare' || rawCat === 'batch') {
532
- cat = 'cloudflare';
533
- } else {
534
- cat = rawCat as SettingsCategory;
535
- }
536
- if (!this.groups.has(cat)) continue;
610
+ const cat = rawCat as SettingsCategory;
537
611
  const currentValue = configManager.get(setting.key as ConfigKey);
538
612
  const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
539
613
  const entry: SettingEntry = {
@@ -546,7 +620,10 @@ export class SettingsModal {
546
620
  sourceLabel: resolved?.sourceLabel,
547
621
  lockReason: resolved?.lockReason,
548
622
  };
549
- this.groups.get(cat)!.push(entry);
623
+ if (this.groups.has(cat)) this.groups.get(cat)!.push(entry);
624
+ if ((rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') && this.groups.has('network')) {
625
+ this.groups.get('network')!.push(entry);
626
+ }
550
627
  }
551
628
 
552
629
  const uiEntries = this.groups.get('ui');
@@ -589,94 +666,7 @@ export class SettingsModal {
589
666
  }
590
667
 
591
668
  private _loadSubscriptionEntries(): void {
592
- const manager = this.subscriptionManager;
593
- if (!manager) {
594
- this.subscriptionEntries = [];
595
- return;
596
- }
597
- const services = this.serviceRegistry?.getAll() ?? {};
598
- const providers = new Map<string, SubscriptionEntry>();
599
- const builtinProviders = new Set(listBuiltinSubscriptionProviders().map((builtin) => builtin.provider));
600
-
601
- const determineFreshness = (expiresAt?: number): ProviderAuthFreshness => {
602
- if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return 'healthy';
603
- if (expiresAt <= Date.now()) return 'expired';
604
- if (expiresAt <= Date.now() + 24 * 60 * 60 * 1000) return 'expiring';
605
- return 'healthy';
606
- };
607
-
608
- for (const provider of builtinProviders) {
609
- providers.set(provider, {
610
- provider,
611
- state: 'available',
612
- oauthConfigured: true,
613
- preferredRoute: 'subscription',
614
- activeRoute: 'unconfigured',
615
- authFreshness: 'unconfigured',
616
- routeReason: 'Built-in subscription adapter is available, but no active subscription session is stored yet.',
617
- nextActions: [`Use /subscription login ${provider} start to begin browser sign-in.`],
618
- });
619
- }
620
-
621
- for (const service of Object.values(services)) {
622
- if (service.authType === 'oauth' && service.oauth) {
623
- const provider = service.providerId ?? service.name;
624
- providers.set(provider, {
625
- provider,
626
- state: 'available',
627
- oauthConfigured: true,
628
- preferredRoute: 'subscription',
629
- activeRoute: providers.get(provider)?.activeRoute ?? 'unconfigured',
630
- authFreshness: providers.get(provider)?.authFreshness ?? 'unconfigured',
631
- routeReason: providers.get(provider)?.routeReason ?? 'OAuth metadata is configured for this provider.',
632
- nextActions: providers.get(provider)?.nextActions ?? [`Use /subscription login ${provider} start to begin browser sign-in.`],
633
- });
634
- }
635
- }
636
-
637
- for (const pending of manager.listPending()) {
638
- providers.set(pending.provider, {
639
- provider: pending.provider,
640
- state: 'pending',
641
- oauthConfigured: providers.get(pending.provider)?.oauthConfigured ?? false,
642
- preferredRoute: 'subscription',
643
- activeRoute: 'unconfigured',
644
- authFreshness: 'pending',
645
- routeReason: 'OAuth login is pending completion for this provider.',
646
- nextActions: [`Finish /subscription login ${pending.provider} finish <code> to activate this session.`],
647
- });
648
- }
649
-
650
- for (const subscription of manager.list()) {
651
- const freshness = determineFreshness(subscription.expiresAt);
652
- const issues = freshness === 'expired'
653
- ? ['Stored subscription session is expired and needs refresh.']
654
- : freshness === 'expiring'
655
- ? ['Stored subscription session expires within 24 hours.']
656
- : [];
657
- const nextActions = freshness === 'expired'
658
- ? [`Refresh or replace the ${subscription.provider} subscription session.`]
659
- : freshness === 'expiring'
660
- ? [`Verify or renew the ${subscription.provider} subscription session soon.`]
661
- : [];
662
- providers.set(subscription.provider, {
663
- provider: subscription.provider,
664
- state: 'active',
665
- tokenType: subscription.tokenType,
666
- expiresAt: subscription.expiresAt,
667
- oauthConfigured: providers.get(subscription.provider)?.oauthConfigured ?? builtinProviders.has(subscription.provider),
668
- activeRoute: freshness === 'expired' ? 'unconfigured' : 'subscription',
669
- preferredRoute: 'subscription',
670
- authFreshness: freshness,
671
- routeReason: subscription.overrideAmbientApiKeys
672
- ? 'Subscription route overrides ambient API-key resolution for this provider.'
673
- : 'Subscription route is stored for supported flows without automatically replacing ambient API-key resolution.',
674
- issues,
675
- nextActions,
676
- });
677
- }
678
-
679
- this.subscriptionEntries = [...providers.values()].sort((a, b) => a.provider.localeCompare(b.provider));
669
+ this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
680
670
  }
681
671
 
682
672
  /**
@@ -706,7 +696,6 @@ export class SettingsModal {
706
696
  if (this.currentCategory === 'flags' || this.currentCategory === 'mcp' || this.currentCategory === 'subscriptions') return [];
707
697
  const items = this.groups.get(this.currentCategory) ?? [];
708
698
  if (this.currentCategory === 'network') {
709
- // Hide host fields when the corresponding hostMode is not 'custom'
710
699
  return items.filter(entry => {
711
700
  if (entry.setting.key === 'controlPlane.host') {
712
701
  const hostMode = this.configManager?.get('controlPlane.hostMode');
@@ -733,40 +722,23 @@ export class SettingsModal {
733
722
  const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
734
723
  try {
735
724
  this.configManager.setDynamic(key, value);
736
- // Update the cached entry in-place — avoids full schema re-scan on each edit
737
725
  const rawCat = key.split('.')[0] as string;
738
- // Resolve the display category from the key prefix
739
- let cat: SettingsCategory;
740
- if (rawCat === 'helper') {
741
- cat = 'tools';
742
- } else if (rawCat === 'controlPlane') {
743
- cat = 'network';
744
- // SDK auto-restarts the daemon server on controlPlane binding changes
726
+ if (rawCat === 'controlPlane') {
745
727
  if (isRestartKey && previousValue !== value) {
746
728
  this.lastSaveTriggeredRestart = 'control-plane';
747
729
  }
748
730
  } else if (rawCat === 'httpListener') {
749
- cat = 'network';
750
- // SDK auto-restarts the HTTP listener on binding changes
751
731
  if (isRestartKey && previousValue !== value) {
752
732
  this.lastSaveTriggeredRestart = 'http-listener';
753
733
  }
754
734
  } else if (rawCat === 'web') {
755
- cat = 'network';
756
- // SDK auto-restarts the web server on binding changes
757
735
  if (isRestartKey && previousValue !== value) {
758
736
  this.lastSaveTriggeredRestart = 'web';
759
737
  }
760
- } else if (rawCat === 'surfaces') {
761
- cat = 'surfaces';
762
- } else if (rawCat === 'cloudflare' || rawCat === 'batch') {
763
- cat = 'cloudflare';
764
- } else {
765
- cat = rawCat as SettingsCategory;
766
738
  }
767
- const entries = this.groups.get(cat);
768
- if (entries) {
769
- const entry = entries.find(e => e.setting.key === key);
739
+
740
+ for (const entries of this.groups.values()) {
741
+ const entry = entries.find((candidate) => candidate.setting.key === key);
770
742
  if (entry) {
771
743
  entry.currentValue = this.configManager!.get(key);
772
744
  entry.isDefault = entry.currentValue === entry.setting.default;
@@ -0,0 +1,100 @@
1
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config/schema';
2
+ import type { CommandContext } from './command-registry.ts';
3
+ import type { SelectionItem } from './selection-modal.ts';
4
+
5
+ function getStreamingTtsProviders(ctx: CommandContext): Array<{ id: string; label: string; capabilities: readonly string[] }> {
6
+ const registry = ctx.platform.voiceProviderRegistry;
7
+ if (!registry) return [];
8
+ return registry.list().filter((provider) => provider.capabilities.includes('tts-stream'));
9
+ }
10
+
11
+ function setTtsConfigValue(ctx: CommandContext, key: ConfigKey, value: string): void {
12
+ ctx.platform.configManager.setDynamic(key, value);
13
+ }
14
+
15
+ export function openTtsProviderPicker(ctx: CommandContext): boolean {
16
+ if (!ctx.openSelection) return false;
17
+ const registry = ctx.platform.voiceProviderRegistry;
18
+ if (!registry) {
19
+ ctx.print('Voice provider registry is not available in this runtime.');
20
+ return true;
21
+ }
22
+
23
+ const providers = getStreamingTtsProviders(ctx);
24
+ if (providers.length === 0) {
25
+ ctx.print('No streaming TTS providers are registered.');
26
+ return true;
27
+ }
28
+
29
+ const current = String(ctx.platform.configManager.get('tts.provider') ?? '').trim();
30
+ const items: SelectionItem[] = providers.map((provider) => ({
31
+ id: provider.id,
32
+ label: provider.label,
33
+ detail: provider.id === current ? `${provider.id} (current)` : provider.id,
34
+ category: 'streaming TTS providers',
35
+ primaryAction: 'select',
36
+ actions: '[Enter] set provider',
37
+ }));
38
+
39
+ ctx.openSelection('Choose TTS Provider', items, { preSelectId: current, allowSearch: true }, (result) => {
40
+ if (!result) return;
41
+ const previous = String(ctx.platform.configManager.get('tts.provider') ?? '').trim();
42
+ setTtsConfigValue(ctx, 'tts.provider', result.item.id);
43
+ if (previous && previous !== result.item.id) {
44
+ setTtsConfigValue(ctx, 'tts.voice', '');
45
+ ctx.print(`TTS provider set to ${result.item.id}. TTS voice was cleared because voices are provider-specific.`);
46
+ } else {
47
+ ctx.print(`TTS provider set to ${result.item.id}.`);
48
+ }
49
+ ctx.renderRequest();
50
+ });
51
+ return true;
52
+ }
53
+
54
+ export async function openTtsVoicePicker(ctx: CommandContext, providerArg?: string): Promise<boolean> {
55
+ if (!ctx.openSelection) return false;
56
+ const service = ctx.platform.voiceService;
57
+ if (!service) {
58
+ ctx.print('Voice service is not available in this runtime.');
59
+ return true;
60
+ }
61
+
62
+ const providerId = (providerArg ?? String(ctx.platform.configManager.get('tts.provider') ?? '')).trim() || undefined;
63
+ try {
64
+ const voices = await service.listVoices(providerId);
65
+ if (voices.length === 0) {
66
+ ctx.print(providerId ? `No voices returned for ${providerId}.` : 'No TTS voices returned.');
67
+ return true;
68
+ }
69
+ const current = String(ctx.platform.configManager.get('tts.voice') ?? '').trim();
70
+ const items: SelectionItem[] = [
71
+ {
72
+ id: '__default__',
73
+ label: 'Use provider default voice',
74
+ detail: current ? 'clears tts.voice' : '(current)',
75
+ category: 'voice',
76
+ primaryAction: 'select',
77
+ },
78
+ ...voices.map((voice) => ({
79
+ id: voice.id,
80
+ label: voice.label || voice.id,
81
+ detail: voice.id === current ? `${voice.id} (current)` : voice.id,
82
+ category: providerId ?? 'voices',
83
+ primaryAction: 'select' as const,
84
+ actions: '[Enter] set voice',
85
+ })),
86
+ ];
87
+
88
+ ctx.openSelection(`Choose TTS Voice${providerId ? ` (${providerId})` : ''}`, items, { preSelectId: current || '__default__', allowSearch: true }, (result) => {
89
+ if (!result) return;
90
+ const nextVoice = result.item.id === '__default__' ? '' : result.item.id;
91
+ setTtsConfigValue(ctx, 'tts.voice', nextVoice);
92
+ ctx.print(nextVoice ? `TTS voice set to ${nextVoice}.` : 'TTS voice cleared. The provider default voice will be used.');
93
+ ctx.renderRequest();
94
+ });
95
+ return true;
96
+ } catch (error) {
97
+ ctx.print(`Unable to list TTS voices: ${error instanceof Error ? error.message : String(error)}`);
98
+ return true;
99
+ }
100
+ }
package/src/main.ts CHANGED
@@ -56,6 +56,9 @@ import {
56
56
  attachSpokenTurnModelRouting,
57
57
  createSpokenTurnInputOptions,
58
58
  } from './audio/spoken-turn-model-routing.ts';
59
+ import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
60
+ import { ProjectPlanningCoordinator } from './planning/project-planning-coordinator.ts';
61
+ import { buildCommandArgsHint } from './input/command-args-hint.ts';
59
62
 
60
63
  const ALT_SCREEN_ENTER = '\x1b[?1049h';
61
64
  const ALT_SCREEN_EXIT = '\x1b[?1049l';
@@ -240,7 +243,8 @@ async function main() {
240
243
  process.removeListener('SIGINT', sigintHandler);
241
244
  process.removeListener('unhandledRejection', unhandledRejectionHandler);
242
245
  const exitScreen = cli.flags.noAltScreen ? CLEAR_SCREEN : CLEAR_SCREEN + ALT_SCREEN_EXIT;
243
- stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + CURSOR_SHOW + exitScreen);
246
+ allowTerminalWrite(() => stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + CURSOR_SHOW + exitScreen));
247
+ terminalOutputGuard.dispose();
244
248
  stdin.setRawMode(false);
245
249
  process.exit(0);
246
250
  };
@@ -261,6 +265,17 @@ async function main() {
261
265
  configManager,
262
266
  notify: (message) => { systemMessageRouter.high(message); render(); },
263
267
  }));
268
+ const projectPlanningCoordinator = new ProjectPlanningCoordinator({
269
+ service: ctx.services.projectPlanningService,
270
+ projectId: ctx.services.projectPlanningProjectId,
271
+ workingDirectory: workingDir,
272
+ notify: (message) => { systemMessageRouter.high(message); render(); },
273
+ openPanel: () => {
274
+ panelManager.open('project-planning');
275
+ panelManager.show();
276
+ render();
277
+ },
278
+ });
264
279
 
265
280
  const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
266
281
  input.clearModalStack();
@@ -296,13 +311,36 @@ async function main() {
296
311
  }
297
312
  }
298
313
  if (processedText || content) {
299
- if (options.spokenOutput && processedText) {
300
- spokenTurns.submitNextTurn(processedText);
301
- }
302
- const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
303
- orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
304
- logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
305
- });
314
+ void (async () => {
315
+ let inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
316
+ if (!options.spokenOutput && processedText) {
317
+ try {
318
+ const planning = await projectPlanningCoordinator.prepareTurn(processedText);
319
+ if (planning) {
320
+ conversation.addSystemMessage(planning.systemMessage);
321
+ inputOptions = {
322
+ origin: {
323
+ source: 'project-planning',
324
+ surface: 'tui',
325
+ metadata: {
326
+ projectId: ctx.services.projectPlanningProjectId,
327
+ knowledgeSpaceId: planning.state.knowledgeSpaceId,
328
+ readiness: planning.evaluation.readiness,
329
+ },
330
+ },
331
+ };
332
+ }
333
+ } catch (err) {
334
+ systemMessageRouter.high(`[Planning] ${summarizeError(err)}`);
335
+ }
336
+ }
337
+ if (options.spokenOutput && processedText) {
338
+ spokenTurns.submitNextTurn(processedText);
339
+ }
340
+ orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
341
+ logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
342
+ });
343
+ })();
306
344
  } else {
307
345
  render();
308
346
  }
@@ -345,7 +383,7 @@ async function main() {
345
383
  commandContext.scrollToLine = scrollToLine;
346
384
  commandContext.clearScreen = () => {
347
385
  compositor.resetDiff();
348
- stdout.write(CLEAR_SCREEN);
386
+ allowTerminalWrite(() => stdout.write(CLEAR_SCREEN));
349
387
  render();
350
388
  };
351
389
  permissionPromptRef.requestPermission = (request) =>
@@ -469,41 +507,7 @@ async function main() {
469
507
  const runningProcessCount = processManager.list().filter((p) => !p.status.startsWith('done')).length;
470
508
  const cw = getPromptContentWidth();
471
509
  const promptInfo = input.getWrappedPromptInfo(cw);
472
- // Compute args hint for slash commands — shown in dim grey after cursor
473
- const commandArgsHint = (() => {
474
- const p = input.prompt;
475
- if (!p.startsWith('/')) return undefined;
476
- // Extract the command name (everything up to first space)
477
- const spaceIdx = p.indexOf(' ');
478
- if (spaceIdx !== -1) {
479
- // User has already typed args — check for subcommand hints
480
- const cmdName = p.slice(1, spaceIdx);
481
- const cmd = commandRegistry.get(cmdName);
482
- if (!cmd) return undefined;
483
- // Sub-command awareness: check if there's a matching sub-hint pattern
484
- const afterCmd = p.slice(spaceIdx + 1);
485
- const subSpaceIdx = afterCmd.indexOf(' ');
486
- if (subSpaceIdx !== -1) return undefined; // deeper args, no hint
487
- // User typed one subcommand word, check for known subcommand hints
488
- const subHints: Record<string, Record<string, string>> = {
489
- session: { rename: '<name>', resume: '<id|name>', info: '<id>', export: '<id> [format]', search: '<query>', delete: '<id>' },
490
- template: { save: '<name>', use: '<name> [args]', edit: '<name>', delete: '<name>' },
491
- secrets: { set: '<KEY> <value>', get: '<KEY>', delete: '<KEY>' },
492
- permissions: { tool: '<name> allow|prompt|deny' },
493
- config: { reset: '<key>' },
494
- danger: {},
495
- plugin: { enable: '<name>', disable: '<name>', reload: '' },
496
- };
497
- const subMap = subHints[cmdName];
498
- if (subMap && afterCmd in subMap) return subMap[afterCmd];
499
- return undefined;
500
- }
501
- // No space yet — user is still typing the command name
502
- const cmdName = p.slice(1);
503
- const cmd = commandRegistry.get(cmdName);
504
- if (!cmd) return undefined;
505
- return cmd.argsHint ?? cmd.usage;
506
- })();
510
+ const commandArgsHint = buildCommandArgsHint(input.prompt, commandRegistry);
507
511
  const composerState = deriveComposerState({
508
512
  text: input.prompt,
509
513
  commandMode: input.commandMode,
@@ -661,6 +665,7 @@ async function main() {
661
665
  panelWidth: panelComposite.panelWidth,
662
666
  });
663
667
  };
668
+ const terminalOutputGuard = installTuiTerminalOutputGuard({ stdout, stderr: process.stderr, notify: (message) => { systemMessageRouter.low(message); render(); } });
664
669
 
665
670
  setRenderRequest(render);
666
671
  orchestratorRefs.requestRender = render;
@@ -726,7 +731,7 @@ async function main() {
726
731
  stdin.setRawMode(true);
727
732
  stdin.resume();
728
733
  stdin.setEncoding('utf8');
729
- stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE);
734
+ allowTerminalWrite(() => stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE));
730
735
 
731
736
  applyInitialTuiCliState({
732
737
  cli,
@@ -5,6 +5,7 @@ import { ThinkingPanel } from '../thinking-panel.ts';
5
5
  import { ToolInspectorPanel } from '../tool-inspector-panel.ts';
6
6
  import { WrfcPanel } from '../wrfc-panel.ts';
7
7
  import { SchedulePanel } from '../schedule-panel.ts';
8
+ import { ProjectPlanningPanel } from '../project-planning-panel.ts';
8
9
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
9
10
  import { requireAutomationManager, requireUiServices } from './shared.ts';
10
11
 
@@ -78,6 +79,20 @@ export function registerAgentPanels(manager: PanelManager, deps: ResolvedBuiltin
78
79
  },
79
80
  });
80
81
 
82
+ manager.registerType({
83
+ id: 'project-planning',
84
+ name: 'Planning',
85
+ icon: 'P',
86
+ category: 'agent',
87
+ description: 'Passive project planning artifacts: readiness, questions, decisions, language, task graph, and agent handoff metadata',
88
+ preload: true,
89
+ factory: () => new ProjectPlanningPanel({
90
+ service: deps.projectPlanningService,
91
+ projectId: deps.projectPlanningProjectId,
92
+ requestRender: deps.requestRender,
93
+ }),
94
+ });
95
+
81
96
  manager.registerType({
82
97
  id: 'schedule',
83
98
  name: 'Schedule',