@pellux/goodvibes-tui 0.19.31 → 0.19.33

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.
package/src/main.ts CHANGED
@@ -51,6 +51,11 @@ import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnC
51
51
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
52
52
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
53
53
  import { applyInitialTuiCliState } from './cli/tui-startup.ts';
54
+ import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
55
+ import {
56
+ attachSpokenTurnModelRouting,
57
+ createSpokenTurnInputOptions,
58
+ } from './audio/spoken-turn-model-routing.ts';
54
59
 
55
60
  const ALT_SCREEN_ENTER = '\x1b[?1049h';
56
61
  const ALT_SCREEN_EXIT = '\x1b[?1049l';
@@ -72,7 +77,6 @@ async function main() {
72
77
  homeDirectory: homedir(),
73
78
  }, 'goodvibes');
74
79
 
75
- // ── Bootstrap runtime subsystems via bootstrapRuntime.
76
80
  const ctx: BootstrapContext = await bootstrapRuntime(stdout, {
77
81
  configManager,
78
82
  workingDir: bootstrapWorkingDir,
@@ -118,7 +122,6 @@ async function main() {
118
122
  ctx.services.wrfcController.setPlanManager(ctx.services.planManager);
119
123
  let activeConversationWidth = stdout.columns || 80;
120
124
  conversation.setWidthProvider(() => activeConversationWidth);
121
- // ── HITL UX mode — read from config and apply at startup ─────────────────
122
125
  {
123
126
  const hitlMode = configManager.get('behavior.hitlMode') as HITLMode | undefined;
124
127
  if (hitlMode && (hitlMode === 'quiet' || hitlMode === 'balanced' || hitlMode === 'operator')) {
@@ -126,7 +129,6 @@ async function main() {
126
129
  }
127
130
  }
128
131
 
129
- // Use the panel manager owned by the runtime service graph.
130
132
  const panelManager = ctx.services.panelManager;
131
133
  const buildSessionContinuityHints = () => {
132
134
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
@@ -145,7 +147,6 @@ async function main() {
145
147
  };
146
148
  };
147
149
 
148
- // Permission state — set while a permission prompt is blocking the orchestrator
149
150
  let pendingPermission: PendingPermissionState | null = null;
150
151
  approvalBroker.subscribe((approval) => {
151
152
  if (!pendingPermission) return;
@@ -155,20 +156,13 @@ async function main() {
155
156
  render();
156
157
  });
157
158
 
158
- // --- Streaming speed tracking (B2) ---
159
159
  let streamStartTime = 0;
160
160
  let streamDeltaCount = 0;
161
161
  let streamTokenSpeed = 0;
162
162
 
163
163
  let scrollTop = 0;
164
- /** When true, view auto-scrolls to bottom on every render.
165
- * False when user manually scrolls up. Reset on user input. */
166
164
  let scrollLocked = true;
167
165
 
168
- // lastGitInfo is a mutable ref provided by bootstrap (updated asynchronously)
169
- // Use lastGitInfoRef.value inside render to get the current value.
170
-
171
- /** Content width inside the prompt box (box width minus padding). */
172
166
  const getPromptContentWidth = () => {
173
167
  const w = stdout.columns || 80;
174
168
  const boxMargin = 2;
@@ -195,23 +189,12 @@ async function main() {
195
189
  scrollTop = Math.max(0, conversation.history.getLineCount() - vHeight);
196
190
  };
197
191
 
198
- // main.ts-owned unsub functions for shell-owned typed runtime subscriptions
199
- // Bootstrap-owned unsubs are in ctx.bootstrapUnsubs and cleared by ctx.shutdown().
200
192
  const unsubs: Array<() => void> = [];
201
-
202
- // Crash recovery interval handle — cleared on exit
203
193
  let recoveryInterval: ReturnType<typeof setInterval> | null = null;
204
-
205
- // Recovery flow state
194
+ let stopSpokenOutputForExit: (() => void) | null = null;
206
195
  let recoveryPending = false;
207
196
 
208
- /**
209
- * Full application teardown.
210
- * Clears main.ts-owned listeners, calls ctx.shutdown() for logical teardown,
211
- * then tears down the terminal and exits the process.
212
- */
213
197
  const sigintHandler = (): void => input.feed('\x03');
214
- // Track unhandled rejections to detect cascading failures
215
198
  let _unhandledRejectionCount = 0;
216
199
  let _unhandledRejectionWindowStart = Date.now();
217
200
  const unhandledRejectionHandler = (reason: unknown): void => {
@@ -244,17 +227,14 @@ async function main() {
244
227
  };
245
228
 
246
229
  const exitApp = (): void => {
247
- // Clear main.ts-owned event subscriptions
230
+ stopSpokenOutputForExit?.();
248
231
  unsubs.forEach(fn => fn());
249
- // Clear bootstrap-owned unsubs + interval via ctx.shutdown()
250
232
  const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
251
233
  ctx.shutdown({ ...snapshot, ...buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints()) }).catch((err) => {
252
234
  logger.debug('ctx.shutdown error during exitApp (non-fatal)', { error: summarizeError(err) });
253
235
  });
254
- // Clear recovery interval
255
236
  if (recoveryInterval !== null) { clearInterval(recoveryInterval); recoveryInterval = null; }
256
237
  deleteRecoveryFile({ homeDirectory });
257
- // Terminal teardown — main.ts exclusively owns these
258
238
  stdin.removeAllListeners('data');
259
239
  stdout.removeListener('resize', resizeHandler);
260
240
  process.removeListener('SIGINT', sigintHandler);
@@ -265,10 +245,24 @@ async function main() {
265
245
  process.exit(0);
266
246
  };
267
247
 
268
- // main.ts owns terminal teardown, so it binds the shell exit bridge here.
269
248
  commandContext.exit = exitApp;
270
249
 
271
- const submitInput = (text: string, content?: ContentPart[]) => {
250
+ const spokenTurns = wireSpokenTurnRuntime({
251
+ voiceService: ctx.services.voiceService,
252
+ configManager,
253
+ events: uiServices.events,
254
+ notify: (message) => { systemMessageRouter.high(message); render(); },
255
+ });
256
+ stopSpokenOutputForExit = () => spokenTurns.stop();
257
+ unsubs.push(...spokenTurns.unsubs);
258
+ unsubs.push(attachSpokenTurnModelRouting({
259
+ orchestrator,
260
+ providerRegistry,
261
+ configManager,
262
+ notify: (message) => { systemMessageRouter.high(message); render(); },
263
+ }));
264
+
265
+ const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
272
266
  input.clearModalStack();
273
267
  scrollLocked = true; // Re-lock on any user input
274
268
  const AT_MODEL_RE = /@model:([^\s]+)/g;
@@ -302,7 +296,11 @@ async function main() {
302
296
  }
303
297
  }
304
298
  if (processedText || content) {
305
- orchestrator.handleUserInput(processedText, content).catch((err: unknown) => {
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) => {
306
304
  logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
307
305
  });
308
306
  } else {
@@ -311,6 +309,7 @@ async function main() {
311
309
  };
312
310
 
313
311
  const cancelGeneration = () => {
312
+ spokenTurns.stop('Spoken output stopped.');
314
313
  if (orchestrator.isThinking) {
315
314
  orchestrator.abort();
316
315
  }
@@ -338,6 +337,8 @@ async function main() {
338
337
  };
339
338
 
340
339
  commandContext.submitInput = submitInput;
340
+ commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
341
+ commandContext.stopSpokenOutput = () => spokenTurns.stop();
341
342
  commandContext.executeCommand = (name, args) => commandRegistry.execute(name, args, commandContext);
342
343
  commandContext.cancelGeneration = cancelGeneration;
343
344
  commandContext.jumpToBookmark = jumpToBookmark;
@@ -113,26 +113,50 @@ function buildFieldRows(
113
113
  visibleFields: number,
114
114
  capacity: number,
115
115
  ): readonly RenderedFieldRow[] {
116
- const fieldWindow = wizard.getFieldWindow(visibleFields);
116
+ wizard.ensureSelectionVisible(visibleFields);
117
+ const fields = wizard.currentStep.fields;
117
118
  const rows: RenderedFieldRow[] = [];
119
+ if (fields.length === 0 || capacity <= 0) return rows;
118
120
 
119
- if (fieldWindow.start > 0) {
120
- rows.push({
121
- kind: 'moreAbove',
122
- text: `${OVERLAY_GLYPHS.moreAbove} ${fieldWindow.start} more above`,
123
- });
121
+ const allRows: RenderedFieldRow[] = [];
122
+ fields.forEach((field, absoluteIndex) => {
123
+ const spacerRows = Math.max(0, field.spacerBeforeRows ?? 0);
124
+ for (let index = 0; index < spacerRows; index += 1) {
125
+ allRows.push({ kind: 'empty' });
126
+ }
127
+ allRows.push({ kind: 'field', field, absoluteIndex });
128
+ });
129
+
130
+ const selectedFieldIndex = wizard.getSelectedFieldIndex();
131
+ const selectedRowIndex = Math.max(0, allRows.findIndex((row) => row.kind === 'field' && row.absoluteIndex === selectedFieldIndex));
132
+ const scrollFieldIndex = wizard.scrollOffsets[wizard.stepIndex] ?? 0;
133
+ const scrollRowIndex = allRows.findIndex((row) => row.kind === 'field' && row.absoluteIndex === scrollFieldIndex);
134
+ const maxStart = Math.max(0, allRows.length - capacity);
135
+ let start = clamp(scrollRowIndex >= 0 ? scrollRowIndex : 0, 0, maxStart);
136
+
137
+ if (selectedRowIndex < start) start = selectedRowIndex;
138
+ if (selectedRowIndex >= start + capacity) start = selectedRowIndex - capacity + 1;
139
+ start = clamp(start, 0, maxStart);
140
+
141
+ if (start > 0 && selectedRowIndex === start) start = Math.max(0, start - 1);
142
+ if (start + capacity < allRows.length && selectedRowIndex === start + capacity - 1) {
143
+ start = Math.min(maxStart, start + 1);
124
144
  }
125
145
 
126
- fieldWindow.fields.forEach((field, index) => {
127
- const absoluteIndex = fieldWindow.start + index;
128
- rows.push({ kind: 'field', field, absoluteIndex });
129
- });
146
+ rows.push(...allRows.slice(start, start + capacity));
147
+ if (start > 0 && rows.length > 0) {
148
+ rows[0] = {
149
+ kind: 'moreAbove',
150
+ text: `${OVERLAY_GLYPHS.moreAbove} ${start} more above`,
151
+ };
152
+ }
130
153
 
131
- if (fieldWindow.end < fieldWindow.total) {
132
- rows.push({
154
+ const hiddenBelow = Math.max(0, allRows.length - (start + capacity));
155
+ if (hiddenBelow > 0 && rows.length > 0) {
156
+ rows[rows.length - 1] = {
133
157
  kind: 'moreBelow',
134
- text: `${OVERLAY_GLYPHS.moreBelow} ${fieldWindow.total - fieldWindow.end} more below`,
135
- });
158
+ text: `${OVERLAY_GLYPHS.moreBelow} ${hiddenBelow} more below`,
159
+ };
136
160
  }
137
161
 
138
162
  while (rows.length < capacity) rows.push({ kind: 'empty' });
@@ -154,7 +154,7 @@ export class UIFactory {
154
154
  const promptLines = prompt.split('\n');
155
155
  const TEXT_COLOR = promptFocused ? '252' : '246';
156
156
  const BG_COLOR = promptFocused ? '#2a2a2a' : '#1f2430';
157
- const BORDER_COLOR = promptFocused ? BG_COLOR : '#334155';
157
+ const BORDER_COLOR = BG_COLOR;
158
158
  const boxMargin = 2; const boxWidth = width - (boxMargin * 2); const boxStartX = boxMargin;
159
159
  const createBaseLine = () => {
160
160
  const l = createEmptyLine(width);
@@ -37,6 +37,7 @@ import { createBootstrapCommandShellServices, type PlanRuntimeService, type Remo
37
37
  import type { OperatorClient } from '@pellux/goodvibes-sdk/platform/runtime/operator-client';
38
38
  import type { PeerClient } from '@pellux/goodvibes-sdk/platform/runtime/peer-client';
39
39
  import type { DirectTransport } from '@pellux/goodvibes-sdk/platform/runtime/transports/direct';
40
+ import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice/index';
40
41
  import {
41
42
  createBootstrapCommandActions,
42
43
  createBootstrapCommandClientsSection,
@@ -58,6 +59,8 @@ export type CreateBootstrapCommandContextOptions = {
58
59
  requestPermission: PermissionRequestHandler;
59
60
  toolRegistry: ToolRegistry;
60
61
  mcpRegistry: McpRegistry;
62
+ voiceProviderRegistry?: VoiceProviderRegistry;
63
+ voiceService?: VoiceService;
61
64
  forensicsRegistry: ForensicsRegistry;
62
65
  policyRuntimeState: PolicyRuntimeState;
63
66
  readModels: UiReadModels;
@@ -122,6 +125,8 @@ export function createBootstrapCommandContext(
122
125
  requestPermission,
123
126
  toolRegistry,
124
127
  mcpRegistry,
128
+ voiceProviderRegistry,
129
+ voiceService,
125
130
  forensicsRegistry,
126
131
  policyRuntimeState,
127
132
  readModels,
@@ -223,7 +228,7 @@ export function createBootstrapCommandContext(
223
228
  profileManager,
224
229
  bookmarkManager,
225
230
  }, shellServices);
226
- const platform = createBootstrapCommandPlatformSection({ configManager }, shellServices);
231
+ const platform = createBootstrapCommandPlatformSection({ configManager, voiceProviderRegistry, voiceService }, shellServices);
227
232
  const extensions = createBootstrapCommandExtensionsSection({
228
233
  toolRegistry,
229
234
  mcpRegistry,
@@ -40,6 +40,7 @@ import type { BootstrapCommandShellServices } from '@pellux/goodvibes-sdk/platfo
40
40
  import type { OperatorClient } from '@pellux/goodvibes-sdk/platform/runtime/operator-client';
41
41
  import type { PeerClient } from '@pellux/goodvibes-sdk/platform/runtime/peer-client';
42
42
  import type { DirectTransport } from '@pellux/goodvibes-sdk/platform/runtime/transports/direct';
43
+ import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice/index';
43
44
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
44
45
 
45
46
  export type BootstrapCommandSessionSection = CommandContext['session'];
@@ -74,6 +75,8 @@ export interface BootstrapCommandSectionOptions {
74
75
  readonly requestPermission: PermissionRequestHandler;
75
76
  readonly toolRegistry: ToolRegistry;
76
77
  readonly mcpRegistry: McpRegistry;
78
+ readonly voiceProviderRegistry?: VoiceProviderRegistry;
79
+ readonly voiceService?: VoiceService;
77
80
  readonly forensicsRegistry: ForensicsRegistry;
78
81
  readonly policyRuntimeState: PolicyRuntimeState;
79
82
  readonly readModels: UiReadModels;
@@ -200,6 +203,10 @@ export function createBootstrapCommandActions(
200
203
  configManager.set('tools.llmModel', key);
201
204
  configManager.setDynamic('tools.llmEnabled' as never, true);
202
205
  conversation.log(`Tool LLM set to: ${def.displayName} (${def.provider})`, { fg: '135' });
206
+ } else if (resolvedTarget === 'tts') {
207
+ configManager.set('tts.llmProvider', def.provider);
208
+ configManager.set('tts.llmModel', key);
209
+ conversation.log(`TTS LLM set to: ${def.displayName} (${def.provider})`, { fg: '135' });
203
210
  } else {
204
211
  // Default: main provider/model
205
212
  if (contextCap != null && contextCap > 0) {
@@ -321,13 +328,15 @@ export function createBootstrapCommandWorkspaceSection(
321
328
  export function createBootstrapCommandPlatformSection(
322
329
  options: Pick<
323
330
  BootstrapCommandSectionOptions,
324
- 'configManager'
331
+ 'configManager' | 'voiceProviderRegistry' | 'voiceService'
325
332
  >,
326
333
  shellServices: BootstrapCommandShellServices,
327
334
  ): BootstrapCommandPlatformSection {
328
335
  return {
329
336
  config: getConfigSnapshot(options.configManager),
330
337
  configManager: options.configManager,
338
+ voiceProviderRegistry: options.voiceProviderRegistry,
339
+ voiceService: options.voiceService,
331
340
  ...shellServices.platform,
332
341
  };
333
342
  }
@@ -183,6 +183,8 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
183
183
  requestPermission: (request) => permissionPromptRef.requestPermission(request),
184
184
  toolRegistry,
185
185
  mcpRegistry: services.mcpRegistry,
186
+ voiceProviderRegistry: services.voiceProviders,
187
+ voiceService: services.voiceService,
186
188
  forensicsRegistry,
187
189
  policyRuntimeState,
188
190
  readModels: uiServices.readModels,
@@ -111,6 +111,14 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
111
111
  return new Set(results.filter((v): v is string => v !== null));
112
112
  }
113
113
 
114
+ const getCurrentModelForPickerTarget = (): string => {
115
+ const target = input.modelPicker.target;
116
+ if (target === 'helper') return String(configManager.get('helper.globalModel') || runtime.model);
117
+ if (target === 'tool') return String(configManager.get('tools.llmModel') || runtime.model);
118
+ if (target === 'tts') return String(configManager.get('tts.llmModel') || runtime.model);
119
+ return runtime.model;
120
+ };
121
+
114
122
  commandContext.openModelPicker = () => {
115
123
  void (async () => {
116
124
  const models = providerRegistry.getSelectableModels();
@@ -124,7 +132,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
124
132
  });
125
133
  void input.modelPicker.loadRecentModels().catch(() => {}); // best-effort: prefetch for UI, failure is non-visible
126
134
  input.modalOpened('modelPicker');
127
- input.modelPicker.openAllModels(models, runtime.model);
135
+ input.modelPicker.openAllModels(models, getCurrentModelForPickerTarget());
128
136
  render();
129
137
  })().catch((error: unknown) => {
130
138
  commandContext.print?.(`Model picker failed to open: ${error instanceof Error ? error.message : String(error)}`);
@@ -132,6 +140,8 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
132
140
  });
133
141
  };
134
142
 
143
+ commandContext.openModelPickerWithTarget = (target) => input.openModelPickerWithTarget(target);
144
+
135
145
  commandContext.openProviderPicker = () => {
136
146
  void (async () => {
137
147
  const providers = [...new Set(providerRegistry.listModels().map((model) => model.provider))];
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.31';
9
+ let _version = '0.19.33';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;