@pellux/goodvibes-tui 0.19.32 → 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/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/audio/spoken-turn-model-routing.ts +117 -0
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/tts-runtime.ts +205 -7
- package/src/input/model-picker.ts +2 -1
- package/src/main.ts +12 -1
- package/src/runtime/bootstrap-command-parts.ts +4 -0
- package/src/shell/ui-openers.ts +11 -1
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.19.33] — 2026-04-26
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- `/config-tts` now opens an interactive TTS configuration modal instead of only printing provider and voice lists.
|
|
11
|
+
- `/config-tts providers` and `/config-tts voices [provider]` open selectable provider/voice pickers in the TUI and persist the selected SDK config keys.
|
|
12
|
+
- `/config-tts llm` opens the model picker for a separate `/tts` response-model override; `/tts` still defaults to the current chat provider/model when no override is configured.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `/tts` now honors configured `tts.llmProvider`/`tts.llmModel` for that spoken turn without changing the main chat model or global provider settings.
|
|
16
|
+
- Changing the TTS provider clears the provider-specific voice selection so a stale voice id is not reused against a different provider.
|
|
17
|
+
|
|
7
18
|
## [0.19.32] — 2026-04-25
|
|
8
19
|
|
|
9
20
|
### Added
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
|
|
8
8
|
|
|
@@ -1269,7 +1269,7 @@ Those pieces cover conversation-noise routing, panel-health/performance budgets,
|
|
|
1269
1269
|
| `/notify [action]` | `/ntf` | Manage webhook notifications (ntfy.sh): add, remove, list, clear, test |
|
|
1270
1270
|
| `/voice [action]` | — | Review optional voice posture and export/inspect voice bundles |
|
|
1271
1271
|
| `/tts <prompt>` | — | Submit a normal prompt and play the assistant response through live TTS |
|
|
1272
|
-
| `/config-tts [action]` | `/tts-config` |
|
|
1272
|
+
| `/config-tts [action]` | `/tts-config` | Open TTS configuration for provider, voice, and optional `/tts` response-model override |
|
|
1273
1273
|
| `/diff [target]` | `/d` | Show unified diff: session, head, working, staged, or a git ref |
|
|
1274
1274
|
| `/mcp [tools]` | — | List connected MCP servers and their tools |
|
|
1275
1275
|
| `/help [command]` | `/h`, `/?` | Show available commands and keyboard shortcuts |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.33",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
|
|
2
|
+
import type { ModelDefinition, ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers/registry';
|
|
3
|
+
import type { ContentPart } from '@pellux/goodvibes-sdk/platform/providers/interface';
|
|
4
|
+
import type { Orchestrator, OrchestratorUserInputOptions } from '../core/orchestrator.ts';
|
|
5
|
+
|
|
6
|
+
const SPOKEN_TURN_SOURCE = 'tts';
|
|
7
|
+
|
|
8
|
+
type RunTurn = (
|
|
9
|
+
text: string,
|
|
10
|
+
content?: ContentPart[],
|
|
11
|
+
options?: OrchestratorUserInputOptions,
|
|
12
|
+
) => Promise<void>;
|
|
13
|
+
|
|
14
|
+
type PatchableOrchestrator = {
|
|
15
|
+
runTurn?: RunTurn;
|
|
16
|
+
setCoreServices: (services: { providerRegistry?: ProviderRegistry }) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface SpokenTurnModelRoutingOptions {
|
|
20
|
+
readonly orchestrator: Orchestrator;
|
|
21
|
+
readonly providerRegistry: ProviderRegistry;
|
|
22
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
23
|
+
readonly notify?: (message: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSpokenTurnInputOptions(): OrchestratorUserInputOptions {
|
|
27
|
+
return {
|
|
28
|
+
origin: {
|
|
29
|
+
source: SPOKEN_TURN_SOURCE,
|
|
30
|
+
surface: 'tui',
|
|
31
|
+
metadata: { spokenOutput: true },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function attachSpokenTurnModelRouting(options: SpokenTurnModelRoutingOptions): () => void {
|
|
37
|
+
const target = options.orchestrator as unknown as PatchableOrchestrator;
|
|
38
|
+
const originalRunTurn = target.runTurn?.bind(options.orchestrator);
|
|
39
|
+
if (!originalRunTurn) return () => {};
|
|
40
|
+
|
|
41
|
+
target.runTurn = async (text, content, inputOptions) => {
|
|
42
|
+
if (!isSpokenTurn(inputOptions)) {
|
|
43
|
+
await originalRunTurn(text, content, inputOptions);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const override = resolveSpokenTurnModelOverride({
|
|
48
|
+
providerRegistry: options.providerRegistry,
|
|
49
|
+
configManager: options.configManager,
|
|
50
|
+
notify: options.notify,
|
|
51
|
+
});
|
|
52
|
+
if (!override) {
|
|
53
|
+
await originalRunTurn(text, content, inputOptions);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const routedRegistry = createRoutedProviderRegistry(options.providerRegistry, override);
|
|
58
|
+
target.setCoreServices({ providerRegistry: routedRegistry });
|
|
59
|
+
try {
|
|
60
|
+
await originalRunTurn(text, content, inputOptions);
|
|
61
|
+
} finally {
|
|
62
|
+
target.setCoreServices({ providerRegistry: options.providerRegistry });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
target.runTurn = originalRunTurn;
|
|
68
|
+
target.setCoreServices({ providerRegistry: options.providerRegistry });
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveSpokenTurnModelOverride(options: {
|
|
73
|
+
readonly providerRegistry: Pick<ProviderRegistry, 'listModels' | 'getCurrentModel'>;
|
|
74
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
75
|
+
readonly notify?: (message: string) => void;
|
|
76
|
+
}): ModelDefinition | null {
|
|
77
|
+
const modelRef = readConfigString(options.configManager, 'tts.llmModel');
|
|
78
|
+
if (!modelRef) return null;
|
|
79
|
+
|
|
80
|
+
const providerId = readConfigString(options.configManager, 'tts.llmProvider');
|
|
81
|
+
const current = options.providerRegistry.getCurrentModel();
|
|
82
|
+
const model = options.providerRegistry.listModels().find((candidate) => {
|
|
83
|
+
const refMatches = candidate.registryKey === modelRef || candidate.id === modelRef;
|
|
84
|
+
const providerMatches = !providerId || candidate.provider === providerId;
|
|
85
|
+
return refMatches && providerMatches;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!model) {
|
|
89
|
+
options.notify?.(`[TTS] Configured TTS LLM '${modelRef}' was not found; using current chat model.`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (model.selectable === false) {
|
|
93
|
+
options.notify?.(`[TTS] Configured TTS LLM '${modelRef}' is not selectable; using current chat model.`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if ((model.registryKey ?? model.id) === (current.registryKey ?? current.id)) return null;
|
|
97
|
+
return model;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSpokenTurn(options: OrchestratorUserInputOptions | undefined): boolean {
|
|
101
|
+
return options?.origin?.source === SPOKEN_TURN_SOURCE
|
|
102
|
+
|| options?.origin?.metadata?.['spokenOutput'] === true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createRoutedProviderRegistry(providerRegistry: ProviderRegistry, model: ModelDefinition): ProviderRegistry {
|
|
106
|
+
return new Proxy(providerRegistry, {
|
|
107
|
+
get(target, prop, receiver) {
|
|
108
|
+
if (prop === 'getCurrentModel') return () => model;
|
|
109
|
+
const value = Reflect.get(target, prop, receiver);
|
|
110
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readConfigString(configManager: Pick<ConfigManager, 'get'>, key: 'tts.llmProvider' | 'tts.llmModel'): string {
|
|
116
|
+
return String(configManager.get(key) ?? '').trim();
|
|
117
|
+
}
|
|
@@ -79,6 +79,7 @@ export interface CommandShellUiOpeners {
|
|
|
79
79
|
reloadSystemPrompt?: () => string;
|
|
80
80
|
openOnboardingWizard?: (modeOrOptions?: OnboardingWizardMode | OpenOnboardingWizardOptions) => void;
|
|
81
81
|
openModelPicker?: () => void;
|
|
82
|
+
openModelPickerWithTarget?: (target: import('./model-picker.ts').ModelPickerTarget) => boolean;
|
|
82
83
|
openProviderPicker?: () => void;
|
|
83
84
|
openContextInspector?: () => void;
|
|
84
85
|
openBookmarkModal?: () => void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config/schema';
|
|
2
2
|
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
3
|
+
import type { SelectionItem } from '../selection-modal.ts';
|
|
3
4
|
|
|
4
5
|
const TTS_CONFIG_KEYS = new Set(['provider', 'voice', 'llm-provider', 'llm-model']);
|
|
5
6
|
|
|
@@ -33,21 +34,36 @@ export function registerTtsRuntimeCommands(registry: CommandRegistry): void {
|
|
|
33
34
|
name: 'config-tts',
|
|
34
35
|
aliases: ['tts-config'],
|
|
35
36
|
description: 'Configure live TTS provider, voice, and optional spoken-turn LLM overrides',
|
|
36
|
-
usage: '[show|providers|voices [provider]|provider <id|clear>|voice <id|clear>|llm-provider <id|clear>|llm-model <id|clear>]',
|
|
37
|
+
usage: '[show|providers|voices [provider]|provider <id|clear>|voice <id|clear>|llm|llm clear|llm-provider <id|clear>|llm-model <id|clear>]',
|
|
37
38
|
async handler(args, ctx) {
|
|
38
39
|
const sub = (args[0] ?? 'show').toLowerCase();
|
|
39
40
|
if (sub === 'show') {
|
|
41
|
+
if (args.length === 0 && openTtsConfigModal(ctx)) return;
|
|
40
42
|
ctx.print(formatTtsConfig(ctx));
|
|
41
43
|
return;
|
|
42
44
|
}
|
|
43
45
|
if (sub === 'providers') {
|
|
44
|
-
|
|
46
|
+
if (openTtsProviderPicker(ctx)) return;
|
|
47
|
+
printTtsProviders(ctx);
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
47
50
|
if (sub === 'voices') {
|
|
51
|
+
if (await openTtsVoicePicker(ctx, args[1])) return;
|
|
48
52
|
await printTtsVoices(ctx, args[1]);
|
|
49
53
|
return;
|
|
50
54
|
}
|
|
55
|
+
if (sub === 'llm' || sub === 'model') {
|
|
56
|
+
const action = (args[1] ?? '').toLowerCase();
|
|
57
|
+
if (action === 'clear' || action === 'default') {
|
|
58
|
+
setTtsConfigValue(ctx, 'tts.llmProvider', '');
|
|
59
|
+
setTtsConfigValue(ctx, 'tts.llmModel', '');
|
|
60
|
+
ctx.print('TTS LLM override cleared. /tts will use the current chat model.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (ctx.openModelPickerWithTarget?.('tts')) return;
|
|
64
|
+
ctx.print('TTS LLM picker is not available in this runtime. Use /config-tts llm-provider <id> and /config-tts llm-model <model>.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
51
67
|
if (TTS_CONFIG_KEYS.has(sub)) {
|
|
52
68
|
const value = args.slice(1).join(' ').trim();
|
|
53
69
|
if (!value) {
|
|
@@ -56,11 +72,17 @@ export function registerTtsRuntimeCommands(registry: CommandRegistry): void {
|
|
|
56
72
|
}
|
|
57
73
|
const key = ttsConfigKeyForSubcommand(sub);
|
|
58
74
|
const nextValue = value.toLowerCase() === 'clear' ? '' : value;
|
|
59
|
-
|
|
75
|
+
const previousProvider = key === 'tts.provider'
|
|
76
|
+
? String(ctx.platform.configManager.get('tts.provider') ?? '').trim()
|
|
77
|
+
: '';
|
|
78
|
+
setTtsConfigValue(ctx, key, nextValue);
|
|
79
|
+
if (key === 'tts.provider' && previousProvider && previousProvider !== nextValue) {
|
|
80
|
+
setTtsConfigValue(ctx, 'tts.voice', '');
|
|
81
|
+
}
|
|
60
82
|
ctx.print(`${key} ${nextValue ? `set to ${nextValue}` : 'cleared'}.`);
|
|
61
83
|
return;
|
|
62
84
|
}
|
|
63
|
-
ctx.print('Usage: /config-tts [show|providers|voices [provider]|provider <id|clear>|voice <id|clear>|llm-provider <id|clear>|llm-model <id|clear>]');
|
|
85
|
+
ctx.print('Usage: /config-tts [show|providers|voices [provider]|provider <id|clear>|voice <id|clear>|llm|llm clear|llm-provider <id|clear>|llm-model <id|clear>]');
|
|
64
86
|
},
|
|
65
87
|
});
|
|
66
88
|
}
|
|
@@ -86,17 +108,139 @@ function formatTtsConfig(ctx: CommandContext): string {
|
|
|
86
108
|
` spoken-turn llm provider override: ${llmProvider || '(current chat provider)'}`,
|
|
87
109
|
` spoken-turn llm model override: ${llmModel || '(current chat model)'}`,
|
|
88
110
|
' playback: live streaming through local mpv or ffplay',
|
|
89
|
-
' commands: /tts <prompt>, /tts stop, /config-tts providers, /config-tts voices [provider]',
|
|
111
|
+
' commands: /tts <prompt>, /tts stop, /config-tts, /config-tts providers, /config-tts voices [provider], /config-tts llm',
|
|
90
112
|
].join('\n');
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
|
|
115
|
+
function openTtsConfigModal(ctx: CommandContext): boolean {
|
|
116
|
+
if (!ctx.openSelection) return false;
|
|
117
|
+
const cm = ctx.platform.configManager;
|
|
118
|
+
const provider = String(cm.get('tts.provider') ?? '').trim() || '(default)';
|
|
119
|
+
const voice = String(cm.get('tts.voice') ?? '').trim() || '(provider default)';
|
|
120
|
+
const llmProvider = String(cm.get('tts.llmProvider') ?? '').trim();
|
|
121
|
+
const llmModel = String(cm.get('tts.llmModel') ?? '').trim();
|
|
122
|
+
const llm = llmProvider || llmModel ? `${llmProvider || 'provider'} / ${llmModel || 'model'}` : '(current chat model)';
|
|
123
|
+
const items: SelectionItem[] = [
|
|
124
|
+
{
|
|
125
|
+
id: 'provider',
|
|
126
|
+
label: 'TTS provider',
|
|
127
|
+
detail: provider,
|
|
128
|
+
category: 'speech output',
|
|
129
|
+
primaryAction: 'select',
|
|
130
|
+
actions: '[Enter] choose',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'voice',
|
|
134
|
+
label: 'TTS voice',
|
|
135
|
+
detail: voice,
|
|
136
|
+
category: 'speech output',
|
|
137
|
+
primaryAction: 'select',
|
|
138
|
+
actions: '[Enter] choose',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'llm',
|
|
142
|
+
label: 'TTS response model override',
|
|
143
|
+
detail: llm,
|
|
144
|
+
category: 'response generation',
|
|
145
|
+
primaryAction: 'select',
|
|
146
|
+
actions: '[Enter] choose model',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'clear-voice',
|
|
150
|
+
label: 'Use provider default voice',
|
|
151
|
+
detail: 'clears tts.voice',
|
|
152
|
+
category: 'clear values',
|
|
153
|
+
primaryAction: 'select',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'clear-llm',
|
|
157
|
+
label: 'Use current chat model for /tts',
|
|
158
|
+
detail: 'clears tts.llmProvider and tts.llmModel',
|
|
159
|
+
category: 'clear values',
|
|
160
|
+
primaryAction: 'select',
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
ctx.openSelection('TTS Configuration', items, { allowSearch: true }, (result) => {
|
|
165
|
+
if (!result) return;
|
|
166
|
+
if (result.item.id === 'provider') {
|
|
167
|
+
openTtsProviderPicker(ctx);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (result.item.id === 'voice') {
|
|
171
|
+
void openTtsVoicePicker(ctx);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (result.item.id === 'llm') {
|
|
175
|
+
if (!ctx.openModelPickerWithTarget?.('tts')) {
|
|
176
|
+
ctx.print('TTS LLM picker is not available in this runtime.');
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (result.item.id === 'clear-voice') {
|
|
181
|
+
setTtsConfigValue(ctx, 'tts.voice', '');
|
|
182
|
+
ctx.print('TTS voice cleared. The provider default voice will be used.');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (result.item.id === 'clear-llm') {
|
|
186
|
+
setTtsConfigValue(ctx, 'tts.llmProvider', '');
|
|
187
|
+
setTtsConfigValue(ctx, 'tts.llmModel', '');
|
|
188
|
+
ctx.print('TTS LLM override cleared. /tts will use the current chat model.');
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getStreamingTtsProviders(ctx: CommandContext): Array<{ id: string; label: string; capabilities: readonly string[] }> {
|
|
195
|
+
const registry = ctx.platform.voiceProviderRegistry;
|
|
196
|
+
if (!registry) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
return registry.list().filter((provider) => provider.capabilities.includes('tts-stream'));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function openTtsProviderPicker(ctx: CommandContext): boolean {
|
|
203
|
+
if (!ctx.openSelection) return false;
|
|
204
|
+
const registry = ctx.platform.voiceProviderRegistry;
|
|
205
|
+
if (!registry) {
|
|
206
|
+
ctx.print('Voice provider registry is not available in this runtime.');
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
const providers = getStreamingTtsProviders(ctx);
|
|
210
|
+
if (providers.length === 0) {
|
|
211
|
+
ctx.print('No streaming TTS providers are registered.');
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const current = String(ctx.platform.configManager.get('tts.provider') ?? '').trim();
|
|
215
|
+
const items: SelectionItem[] = providers.map((provider) => ({
|
|
216
|
+
id: provider.id,
|
|
217
|
+
label: provider.label,
|
|
218
|
+
detail: provider.id === current ? `${provider.id} (current)` : provider.id,
|
|
219
|
+
category: 'streaming TTS providers',
|
|
220
|
+
primaryAction: 'select',
|
|
221
|
+
actions: '[Enter] set provider',
|
|
222
|
+
}));
|
|
223
|
+
ctx.openSelection('Choose TTS Provider', items, { preSelectId: current, allowSearch: true }, (result) => {
|
|
224
|
+
if (!result) return;
|
|
225
|
+
const previous = String(ctx.platform.configManager.get('tts.provider') ?? '').trim();
|
|
226
|
+
setTtsConfigValue(ctx, 'tts.provider', result.item.id);
|
|
227
|
+
if (previous && previous !== result.item.id) {
|
|
228
|
+
setTtsConfigValue(ctx, 'tts.voice', '');
|
|
229
|
+
ctx.print(`TTS provider set to ${result.item.id}. TTS voice was cleared because voices are provider-specific.`);
|
|
230
|
+
} else {
|
|
231
|
+
ctx.print(`TTS provider set to ${result.item.id}.`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function printTtsProviders(ctx: CommandContext): void {
|
|
94
238
|
const registry = ctx.platform.voiceProviderRegistry;
|
|
95
239
|
if (!registry) {
|
|
96
240
|
ctx.print('Voice provider registry is not available in this runtime.');
|
|
97
241
|
return;
|
|
98
242
|
}
|
|
99
|
-
const providers =
|
|
243
|
+
const providers = getStreamingTtsProviders(ctx);
|
|
100
244
|
if (providers.length === 0) {
|
|
101
245
|
ctx.print('No streaming TTS providers are registered.');
|
|
102
246
|
return;
|
|
@@ -104,9 +248,56 @@ async function printTtsProviders(ctx: CommandContext): Promise<void> {
|
|
|
104
248
|
ctx.print([
|
|
105
249
|
'Streaming TTS Providers',
|
|
106
250
|
...providers.map((provider) => ` ${provider.id}: ${provider.label}`),
|
|
251
|
+
'',
|
|
252
|
+
'Set provider: /config-tts provider <provider-id>',
|
|
107
253
|
].join('\n'));
|
|
108
254
|
}
|
|
109
255
|
|
|
256
|
+
async function openTtsVoicePicker(ctx: CommandContext, providerArg?: string): Promise<boolean> {
|
|
257
|
+
if (!ctx.openSelection) return false;
|
|
258
|
+
const service = ctx.platform.voiceService;
|
|
259
|
+
if (!service) {
|
|
260
|
+
ctx.print('Voice service is not available in this runtime.');
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
const providerId = (providerArg ?? String(ctx.platform.configManager.get('tts.provider') ?? '')).trim() || undefined;
|
|
264
|
+
try {
|
|
265
|
+
const voices = await service.listVoices(providerId);
|
|
266
|
+
if (voices.length === 0) {
|
|
267
|
+
ctx.print(providerId ? `No voices returned for ${providerId}.` : 'No TTS voices returned.');
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
const current = String(ctx.platform.configManager.get('tts.voice') ?? '').trim();
|
|
271
|
+
const items: SelectionItem[] = [
|
|
272
|
+
{
|
|
273
|
+
id: '__default__',
|
|
274
|
+
label: 'Use provider default voice',
|
|
275
|
+
detail: current ? 'clears tts.voice' : '(current)',
|
|
276
|
+
category: 'voice',
|
|
277
|
+
primaryAction: 'select',
|
|
278
|
+
},
|
|
279
|
+
...voices.map((voice) => ({
|
|
280
|
+
id: voice.id,
|
|
281
|
+
label: voice.label || voice.id,
|
|
282
|
+
detail: voice.id === current ? `${voice.id} (current)` : voice.id,
|
|
283
|
+
category: providerId ?? 'voices',
|
|
284
|
+
primaryAction: 'select' as const,
|
|
285
|
+
actions: '[Enter] set voice',
|
|
286
|
+
})),
|
|
287
|
+
];
|
|
288
|
+
ctx.openSelection(`Choose TTS Voice${providerId ? ` (${providerId})` : ''}`, items, { preSelectId: current || '__default__', allowSearch: true }, (result) => {
|
|
289
|
+
if (!result) return;
|
|
290
|
+
const nextVoice = result.item.id === '__default__' ? '' : result.item.id;
|
|
291
|
+
setTtsConfigValue(ctx, 'tts.voice', nextVoice);
|
|
292
|
+
ctx.print(nextVoice ? `TTS voice set to ${nextVoice}.` : 'TTS voice cleared. The provider default voice will be used.');
|
|
293
|
+
});
|
|
294
|
+
return true;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
ctx.print(`Unable to list TTS voices: ${error instanceof Error ? error.message : String(error)}`);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
110
301
|
async function printTtsVoices(ctx: CommandContext, providerArg?: string): Promise<void> {
|
|
111
302
|
const service = ctx.platform.voiceService;
|
|
112
303
|
if (!service) {
|
|
@@ -124,12 +315,19 @@ async function printTtsVoices(ctx: CommandContext, providerArg?: string): Promis
|
|
|
124
315
|
`TTS Voices${providerId ? ` (${providerId})` : ''}`,
|
|
125
316
|
...voices.slice(0, 60).map((voice) => ` ${voice.id}: ${voice.label}`),
|
|
126
317
|
...(voices.length > 60 ? [` ... ${voices.length - 60} more`] : []),
|
|
318
|
+
'',
|
|
319
|
+
'Set voice: /config-tts voice <voice-id>',
|
|
320
|
+
'Use provider default voice: /config-tts voice clear',
|
|
127
321
|
].join('\n'));
|
|
128
322
|
} catch (error) {
|
|
129
323
|
ctx.print(`Unable to list TTS voices: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
324
|
}
|
|
131
325
|
}
|
|
132
326
|
|
|
327
|
+
function setTtsConfigValue(ctx: CommandContext, key: ConfigKey, value: string): void {
|
|
328
|
+
ctx.platform.configManager.setDynamic(key, value);
|
|
329
|
+
}
|
|
330
|
+
|
|
133
331
|
function formatValue(value: unknown): string {
|
|
134
332
|
const text = String(value ?? '').trim();
|
|
135
333
|
return text || '(default)';
|
|
@@ -12,8 +12,9 @@ export type PickerMode = 'model' | 'provider' | 'effort' | 'contextCap';
|
|
|
12
12
|
* 'main' → provider.provider + provider.model (default)
|
|
13
13
|
* 'helper' → helper.globalProvider + helper.globalModel (+ helper.enabled: true)
|
|
14
14
|
* 'tool' → tools.llmProvider + tools.llmModel (+ tools.llmEnabled: true)
|
|
15
|
+
* 'tts' → tts.llmProvider + tts.llmModel
|
|
15
16
|
*/
|
|
16
|
-
export type ModelPickerTarget = 'main' | 'helper' | 'tool';
|
|
17
|
+
export type ModelPickerTarget = 'main' | 'helper' | 'tool' | 'tts';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Pricing tier filter.
|
package/src/main.ts
CHANGED
|
@@ -52,6 +52,10 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-displ
|
|
|
52
52
|
import { prepareShellCliRuntime } from './cli/entrypoint.ts';
|
|
53
53
|
import { applyInitialTuiCliState } from './cli/tui-startup.ts';
|
|
54
54
|
import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
|
|
55
|
+
import {
|
|
56
|
+
attachSpokenTurnModelRouting,
|
|
57
|
+
createSpokenTurnInputOptions,
|
|
58
|
+
} from './audio/spoken-turn-model-routing.ts';
|
|
55
59
|
|
|
56
60
|
const ALT_SCREEN_ENTER = '\x1b[?1049h';
|
|
57
61
|
const ALT_SCREEN_EXIT = '\x1b[?1049l';
|
|
@@ -251,6 +255,12 @@ async function main() {
|
|
|
251
255
|
});
|
|
252
256
|
stopSpokenOutputForExit = () => spokenTurns.stop();
|
|
253
257
|
unsubs.push(...spokenTurns.unsubs);
|
|
258
|
+
unsubs.push(attachSpokenTurnModelRouting({
|
|
259
|
+
orchestrator,
|
|
260
|
+
providerRegistry,
|
|
261
|
+
configManager,
|
|
262
|
+
notify: (message) => { systemMessageRouter.high(message); render(); },
|
|
263
|
+
}));
|
|
254
264
|
|
|
255
265
|
const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
|
|
256
266
|
input.clearModalStack();
|
|
@@ -289,7 +299,8 @@ async function main() {
|
|
|
289
299
|
if (options.spokenOutput && processedText) {
|
|
290
300
|
spokenTurns.submitNextTurn(processedText);
|
|
291
301
|
}
|
|
292
|
-
|
|
302
|
+
const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
|
|
303
|
+
orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
|
|
293
304
|
logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
|
|
294
305
|
});
|
|
295
306
|
} else {
|
|
@@ -203,6 +203,10 @@ export function createBootstrapCommandActions(
|
|
|
203
203
|
configManager.set('tools.llmModel', key);
|
|
204
204
|
configManager.setDynamic('tools.llmEnabled' as never, true);
|
|
205
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' });
|
|
206
210
|
} else {
|
|
207
211
|
// Default: main provider/model
|
|
208
212
|
if (contextCap != null && contextCap > 0) {
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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;
|