@alexeiled/pi-model-router 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,677 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import type { AutocompleteItem } from '@earendil-works/pi-tui';
7
+ import {
8
+ getUnsupportedTiers,
9
+ parseCanonicalModelRef,
10
+ profileNames,
11
+ ROUTER_PIN_VALUES,
12
+ ROUTER_TIERS,
13
+ THINKING_LEVELS,
14
+ } from './config';
15
+ import type {
16
+ RouterConfig,
17
+ RouterPinByProfile,
18
+ RouterThinkingByProfile,
19
+ RouterTier,
20
+ RoutingDecision,
21
+ } from './types';
22
+ import {
23
+ formatDecision,
24
+ formatModelRef,
25
+ formatPinSummary,
26
+ formatThinkingSummary,
27
+ } from './ui';
28
+
29
+ export const registerCommands = (
30
+ pi: ExtensionAPI,
31
+ state: {
32
+ readonly currentConfig: RouterConfig;
33
+ routerEnabled: boolean;
34
+ selectedProfile: string | undefined;
35
+ readonly pinnedTierByProfile: RouterPinByProfile;
36
+ readonly thinkingByProfile: RouterThinkingByProfile;
37
+ readonly lastDecision: RoutingDecision | undefined;
38
+ lastNonRouterModel: string | undefined;
39
+ readonly accumulatedCost: number;
40
+ debugEnabled: boolean;
41
+ widgetEnabled: boolean;
42
+ readonly debugHistory: RoutingDecision[];
43
+ readonly lastConfigWarnings: string[];
44
+ },
45
+ actions: {
46
+ persistState: () => void;
47
+ updateStatus: (ctx: ExtensionContext) => void;
48
+ reloadConfig: (
49
+ ctx?: ExtensionContext,
50
+ options?: { preserveDebug?: boolean },
51
+ ) => void;
52
+ ensureValidActiveRouterProfile: (ctx: ExtensionContext) => Promise<void>;
53
+ switchToRouterProfile: (
54
+ profileName: string,
55
+ ctx: ExtensionContext,
56
+ strict?: boolean,
57
+ ) => Promise<boolean>;
58
+ syncPiThinkingLevel: (level: ThinkingLevel) => void;
59
+ },
60
+ ) => {
61
+ const SUBCOMMAND_DETAILS = [
62
+ { name: 'status', desc: 'Show current router status' },
63
+ { name: 'profile', desc: 'Switch to a different router profile' },
64
+ { name: 'pin', desc: 'Pin routing for a profile to a specific tier' },
65
+ { name: 'thinking', desc: 'Override thinking level for a tier or profile' },
66
+ { name: 'disable', desc: 'Disable the router and restore last model' },
67
+ {
68
+ name: 'fix',
69
+ desc: 'Correct the last routing decision and pin that tier',
70
+ },
71
+ { name: 'widget', desc: 'Toggle the router status widget' },
72
+ { name: 'debug', desc: 'Toggle or clear router debug history' },
73
+ { name: 'reload', desc: 'Reload the model router configuration' },
74
+ { name: 'help', desc: 'Show usage help for subcommands' },
75
+ ];
76
+
77
+ const getSubcommandCompletions = (
78
+ prefix: string,
79
+ ): AutocompleteItem[] | null => {
80
+ const items = SUBCOMMAND_DETAILS.filter((s) =>
81
+ s.name.startsWith(prefix),
82
+ ).map((s) => ({
83
+ value: s.name,
84
+ label: s.name,
85
+ description: s.desc,
86
+ }));
87
+ return items.length > 0 ? items : null;
88
+ };
89
+
90
+ const getPinCompletions = (args: string[]): AutocompleteItem[] | null => {
91
+ // pin <tier|auto>
92
+ if (args.length <= 1) {
93
+ const token = args[0] ?? '';
94
+ const items = ROUTER_PIN_VALUES.filter((value) =>
95
+ value.startsWith(token),
96
+ ).map((value) => ({
97
+ value,
98
+ label: value,
99
+ description:
100
+ value === 'auto'
101
+ ? 'Restore auto-routing (clear pin) for the active profile'
102
+ : `Pin active profile to ${value} tier`,
103
+ }));
104
+ return items.length > 0 ? items : null;
105
+ }
106
+ return null;
107
+ };
108
+
109
+ const getThinkingCompletions = (
110
+ args: string[],
111
+ ): AutocompleteItem[] | null => {
112
+ // thinking [tier] <level|auto>
113
+ const tierValues = [...ROUTER_TIERS];
114
+ const levelValues = ['auto', ...THINKING_LEVELS];
115
+
116
+ if (args.length <= 1) {
117
+ const token = args[0] ?? '';
118
+ return [
119
+ ...levelValues
120
+ .filter((v) => v.startsWith(token))
121
+ .map((v) => ({
122
+ value: v,
123
+ label: v,
124
+ description:
125
+ v === 'auto'
126
+ ? 'Restore default thinking level'
127
+ : `Set thinking level to ${v}`,
128
+ })),
129
+ ...tierValues
130
+ .filter((v) => v.startsWith(token))
131
+ .map((v) => ({
132
+ value: v,
133
+ label: v,
134
+ description: `Override thinking for ${v} tier`,
135
+ })),
136
+ ];
137
+ }
138
+
139
+ if (levelValues.includes(args[0])) {
140
+ return null;
141
+ }
142
+
143
+ if ((tierValues as string[]).includes(args[0])) {
144
+ const tier = args[0];
145
+ const levelPrefix = args[1] ?? '';
146
+ return levelValues
147
+ .filter((v) => v.startsWith(levelPrefix))
148
+ .map((v) => ({
149
+ value: `${tier} ${v}`,
150
+ label: `${tier} ${v}`,
151
+ description:
152
+ v === 'auto'
153
+ ? `Restore default thinking level for ${tier} tier`
154
+ : `Set thinking level to ${v} for ${tier} tier`,
155
+ }));
156
+ }
157
+
158
+ return null;
159
+ };
160
+
161
+ const handleStatus = async (args: string[], ctx: ExtensionContext) => {
162
+ if (args.length > 0) {
163
+ ctx.ui.notify('Usage: /router status (no arguments)', 'error');
164
+ return;
165
+ }
166
+ const names = profileNames(state.currentConfig).join(', ');
167
+ const lines = [
168
+ 'Model Router Status:',
169
+ `Router enabled: ${state.routerEnabled ? 'yes' : 'off'}`,
170
+ `Selected profile: ${state.selectedProfile ?? 'none'}`,
171
+ `Selected profile pin: ${state.selectedProfile ? (state.pinnedTierByProfile[state.selectedProfile] ?? 'auto') : 'none'}`,
172
+ `Pins by profile: ${formatPinSummary(state.pinnedTierByProfile)}`,
173
+ `Thinking overrides: ${formatThinkingSummary(state.thinkingByProfile)}`,
174
+ `Widget: ${state.widgetEnabled ? 'on' : 'off'}`,
175
+ `Phase bias: ${state.currentConfig.phaseBias}`,
176
+ `Session cost: $${state.accumulatedCost.toFixed(4)}` +
177
+ (state.currentConfig.maxSessionBudget
178
+ ? ` / $${state.currentConfig.maxSessionBudget.toFixed(2)}`
179
+ : ''),
180
+ `Available profiles: ${names}`,
181
+ `Last non-router model: ${formatModelRef(state.lastNonRouterModel)}`,
182
+ `Debug: ${state.debugEnabled ? 'on' : 'off'}`,
183
+ `Debug history: ${state.debugHistory.length} decisions`,
184
+ ];
185
+ if (state.lastDecision) {
186
+ lines.push(
187
+ `Last routed tier: ${state.lastDecision.tier}`,
188
+ `Last phase: ${state.lastDecision.phase}`,
189
+ `Last model: ${state.lastDecision.targetProvider}/${state.lastDecision.targetModelId} (${state.lastDecision.thinking})`,
190
+ `Reason: ${state.lastDecision.reasoning}`,
191
+ );
192
+ }
193
+ if (state.lastConfigWarnings && state.lastConfigWarnings.length > 0) {
194
+ lines.push(
195
+ '',
196
+ '⚠️ Configuration Warnings:',
197
+ ...state.lastConfigWarnings.map((w) => ` - ${w}`),
198
+ );
199
+ }
200
+ ctx.ui.notify(lines.join('\n'), 'info');
201
+ actions.updateStatus(ctx);
202
+ };
203
+
204
+ const handleProfile = async (args: string[], ctx: ExtensionContext) => {
205
+ if (args.length > 1) {
206
+ ctx.ui.notify('Usage: /router profile [name]', 'error');
207
+ return;
208
+ }
209
+ const profileName = args[0];
210
+ if (!profileName) {
211
+ ctx.ui.notify(
212
+ `Current profile: ${state.selectedProfile}. Available: ${profileNames(state.currentConfig).join(', ')}`,
213
+ 'info',
214
+ );
215
+ return;
216
+ }
217
+ const success = await actions.switchToRouterProfile(profileName, ctx);
218
+ if (success) {
219
+ ctx.ui.notify(
220
+ `Switched to router profile: ${state.selectedProfile}`,
221
+ 'info',
222
+ );
223
+ }
224
+ };
225
+
226
+ const handlePin = async (args: string[], ctx: ExtensionContext) => {
227
+ const currentProfile = state.selectedProfile;
228
+ if (!currentProfile) {
229
+ ctx.ui.notify(
230
+ 'No router profile is active. Select a router model first.',
231
+ 'error',
232
+ );
233
+ return;
234
+ }
235
+ if (args.length === 0) {
236
+ ctx.ui.notify(
237
+ [
238
+ `Profile: ${currentProfile}`,
239
+ `Pinned tier: ${state.pinnedTierByProfile[currentProfile] ?? 'auto'}`,
240
+ `Usage: /router pin <high|medium|low|auto>`,
241
+ ].join('\n'),
242
+ 'info',
243
+ );
244
+ actions.updateStatus(ctx);
245
+ return;
246
+ }
247
+
248
+ if (args.length > 1) {
249
+ ctx.ui.notify('Usage: /router pin <high|medium|low|auto>', 'error');
250
+ return;
251
+ }
252
+
253
+ const pinValue = args[0];
254
+
255
+ if (!ROUTER_PIN_VALUES.some((value) => value === pinValue)) {
256
+ ctx.ui.notify(
257
+ `Invalid router pin: ${pinValue}. Use one of: ${ROUTER_PIN_VALUES.join(', ')}`,
258
+ 'error',
259
+ );
260
+ return;
261
+ }
262
+
263
+ const nextTier = pinValue === 'auto' ? undefined : (pinValue as RouterTier);
264
+ if (nextTier) {
265
+ state.pinnedTierByProfile[currentProfile] = nextTier;
266
+ } else {
267
+ delete state.pinnedTierByProfile[currentProfile];
268
+ }
269
+ actions.persistState();
270
+ actions.updateStatus(ctx);
271
+ ctx.ui.notify(
272
+ nextTier
273
+ ? `Router pinned to ${nextTier}`
274
+ : `Router pin cleared; heuristic routing restored`,
275
+ 'info',
276
+ );
277
+ };
278
+
279
+ const handleThinking = async (args: string[], ctx: ExtensionContext) => {
280
+ const currentProfile = state.selectedProfile;
281
+ if (!currentProfile) {
282
+ ctx.ui.notify(
283
+ 'No router profile is active. Select a router model first.',
284
+ 'error',
285
+ );
286
+ return;
287
+ }
288
+ if (args.length === 0) {
289
+ ctx.ui.notify(
290
+ [
291
+ `Profile: ${currentProfile}`,
292
+ `Thinking overrides: ${JSON.stringify(state.thinkingByProfile[currentProfile] ?? {})}`,
293
+ 'Usage: /router thinking <level|auto> (applies to all tiers)',
294
+ ' or: /router thinking <tier> <level|auto> (applies to one tier)',
295
+ 'Note: not all tier models may support every thinking level.',
296
+ ].join('\n'),
297
+ 'info',
298
+ );
299
+ return;
300
+ }
301
+
302
+ if (args.length > 2) {
303
+ ctx.ui.notify('Too many arguments for /router thinking.', 'error');
304
+ return;
305
+ }
306
+
307
+ let tier: RouterTier | 'all' | undefined;
308
+ let levelValue = '';
309
+
310
+ const tierValues = ['high', 'medium', 'low'];
311
+ const levelValues = ['auto', ...THINKING_LEVELS];
312
+
313
+ if (args.length === 1) {
314
+ levelValue = args[0];
315
+ tier = 'all';
316
+ } else if (args.length === 2) {
317
+ if (tierValues.includes(args[0]) || args[0] === 'all') {
318
+ tier = args[0] as RouterTier | 'all';
319
+ levelValue = args[1];
320
+ } else {
321
+ ctx.ui.notify(
322
+ `Invalid tier: ${args[0]}. Use high, medium, or low.`,
323
+ 'error',
324
+ );
325
+ return;
326
+ }
327
+ }
328
+
329
+ if (tier !== 'all' && !tierValues.includes(tier as string)) {
330
+ ctx.ui.notify(
331
+ `Invalid tier: ${tier}. Use high, medium, or low.`,
332
+ 'error',
333
+ );
334
+ return;
335
+ }
336
+ if (!levelValues.includes(levelValue)) {
337
+ ctx.ui.notify(
338
+ `Invalid thinking level: ${levelValue}. Use auto or: ${THINKING_LEVELS.join(', ')}`,
339
+ 'error',
340
+ );
341
+ return;
342
+ }
343
+
344
+ const nextLevel =
345
+ levelValue === 'auto' ? undefined : (levelValue as ThinkingLevel);
346
+ state.thinkingByProfile[currentProfile] ??= {};
347
+ const overrides = state.thinkingByProfile[currentProfile];
348
+ const tiers = tier === 'all' ? ROUTER_TIERS : [tier as RouterTier];
349
+ for (const targetTier of tiers) {
350
+ if (nextLevel) overrides[targetTier] = nextLevel;
351
+ else delete overrides[targetTier];
352
+ }
353
+ if (Object.keys(overrides).length === 0) {
354
+ delete state.thinkingByProfile[currentProfile];
355
+ }
356
+
357
+ actions.persistState();
358
+ actions.updateStatus(ctx);
359
+ if (nextLevel) {
360
+ actions.syncPiThinkingLevel(nextLevel);
361
+ } else if (state.lastDecision) {
362
+ actions.syncPiThinkingLevel(state.lastDecision.thinking);
363
+ }
364
+ // Only warn when the level isn't supported by some tiers; skip for 'off' and 'auto'
365
+ if (nextLevel && nextLevel !== 'off') {
366
+ const unsupported = getUnsupportedTiers(
367
+ state.currentConfig.profiles[currentProfile],
368
+ nextLevel,
369
+ );
370
+ if (unsupported.length > 0) {
371
+ ctx.ui.notify(
372
+ `Router thinking (${tier}) set to ${nextLevel}. ` +
373
+ `${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${nextLevel}'.`,
374
+ 'warning',
375
+ );
376
+ }
377
+ }
378
+ };
379
+
380
+ const handleDisable = async (args: string[], ctx: ExtensionContext) => {
381
+ if (args.length > 0) {
382
+ ctx.ui.notify('Usage: /router disable (no arguments)', 'error');
383
+ return;
384
+ }
385
+ if (!state.lastNonRouterModel) {
386
+ ctx.ui.notify(
387
+ 'No previous non-router model recorded. Use /model to pick a concrete model.',
388
+ 'warning',
389
+ );
390
+ return;
391
+ }
392
+ const { provider, modelId } = parseCanonicalModelRef(
393
+ state.lastNonRouterModel,
394
+ );
395
+ const targetModel = ctx.modelRegistry.find(provider, modelId);
396
+ if (!targetModel) {
397
+ ctx.ui.notify(
398
+ `Recorded non-router model is unavailable: ${state.lastNonRouterModel}`,
399
+ 'error',
400
+ );
401
+ return;
402
+ }
403
+ const success = await pi.setModel(targetModel);
404
+ if (!success) {
405
+ ctx.ui.notify(`Failed to switch to ${state.lastNonRouterModel}`, 'error');
406
+ return;
407
+ }
408
+ state.routerEnabled = false;
409
+ actions.persistState();
410
+ actions.updateStatus(ctx);
411
+ ctx.ui.notify(
412
+ `Router disabled. Restored ${state.lastNonRouterModel}`,
413
+ 'info',
414
+ );
415
+ };
416
+
417
+ const handleFix = async (args: string[], ctx: ExtensionContext) => {
418
+ if (args.length !== 1) {
419
+ ctx.ui.notify('Usage: /router fix <high|medium|low>', 'error');
420
+ return;
421
+ }
422
+ const tier = args[0]?.toLowerCase();
423
+ if (!ROUTER_TIERS.includes(tier as RouterTier)) {
424
+ ctx.ui.notify('Usage: /router fix <high|medium|low>', 'error');
425
+ return;
426
+ }
427
+ if (!state.lastDecision) {
428
+ ctx.ui.notify('No recent routing decision to fix.', 'warning');
429
+ return;
430
+ }
431
+ state.pinnedTierByProfile[state.lastDecision.profile] = tier as RouterTier;
432
+ actions.persistState();
433
+ actions.updateStatus(ctx);
434
+ ctx.ui.notify(
435
+ `Router decision corrected. ${state.lastDecision.profile} is now pinned to ${tier}.`,
436
+ 'info',
437
+ );
438
+ };
439
+
440
+ const handleWidget = async (args: string[], ctx: ExtensionContext) => {
441
+ if (args.length > 1) {
442
+ ctx.ui.notify('Usage: /router widget <on|off|toggle>', 'error');
443
+ return;
444
+ }
445
+ const cmd = args[0]?.toLowerCase();
446
+ if (cmd === 'on') state.widgetEnabled = true;
447
+ else if (cmd === 'off') state.widgetEnabled = false;
448
+ else state.widgetEnabled = !state.widgetEnabled;
449
+ actions.persistState();
450
+ actions.updateStatus(ctx);
451
+ ctx.ui.notify(
452
+ `Router widget ${state.widgetEnabled ? 'enabled' : 'disabled'}.`,
453
+ 'info',
454
+ );
455
+ };
456
+
457
+ const handleDebug = async (args: string[], ctx: ExtensionContext) => {
458
+ if (args.length > 1) {
459
+ ctx.ui.notify('Usage: /router debug <on|off|show|clear>', 'error');
460
+ return;
461
+ }
462
+ const cmd = args[0]?.toLowerCase();
463
+ if (cmd === 'on') state.debugEnabled = true;
464
+ else if (cmd === 'off') state.debugEnabled = false;
465
+ else if (cmd === 'clear') state.debugHistory.length = 0;
466
+ else if (cmd === 'show') {
467
+ if (state.debugHistory.length === 0) {
468
+ ctx.ui.notify('No recent routing decisions.', 'info');
469
+ } else {
470
+ const history = state.debugHistory
471
+ .map(
472
+ (d) =>
473
+ `[${new Date(d.timestamp).toLocaleTimeString()}] ${formatDecision(d)}`,
474
+ )
475
+ .join('\n');
476
+ ctx.ui.notify(`Recent Routing Decisions:\n${history}`, 'info');
477
+ }
478
+ return;
479
+ } else {
480
+ state.debugEnabled = !state.debugEnabled;
481
+ }
482
+ actions.persistState();
483
+ ctx.ui.notify(
484
+ `Router debug ${state.debugEnabled ? 'enabled' : 'disabled'}.`,
485
+ 'info',
486
+ );
487
+ };
488
+
489
+ const handleReload = async (args: string[], ctx: ExtensionContext) => {
490
+ if (args.length > 0) {
491
+ ctx.ui.notify('Usage: /router reload (no arguments)', 'error');
492
+ return;
493
+ }
494
+ actions.reloadConfig(ctx, { preserveDebug: true });
495
+ await actions.ensureValidActiveRouterProfile(ctx);
496
+ ctx.ui.notify(
497
+ `Router config reloaded. Profiles: ${profileNames(state.currentConfig).join(', ')}`,
498
+ 'info',
499
+ );
500
+ };
501
+
502
+ pi.registerCommand('router', {
503
+ description: 'Model router control center',
504
+ getArgumentCompletions: (prefix) => {
505
+ const trimmedLeft = prefix.trimStart();
506
+ const hasTrailingSpace = /\s$/.test(prefix);
507
+ const parts = trimmedLeft.length > 0 ? trimmedLeft.split(/\s+/) : [];
508
+
509
+ if (parts.length === 0) {
510
+ return getSubcommandCompletions('');
511
+ }
512
+
513
+ if (parts.length === 1 && !hasTrailingSpace) {
514
+ return getSubcommandCompletions(parts[0]);
515
+ }
516
+
517
+ const subcommand = parts[0];
518
+ const subArgs = parts.slice(1);
519
+ if (hasTrailingSpace && parts.length === 1) {
520
+ subArgs.push('');
521
+ }
522
+
523
+ switch (subcommand) {
524
+ case 'profile': {
525
+ const profilePrefix = subArgs[0] ?? '';
526
+ const items = profileNames(state.currentConfig)
527
+ .filter((name) => name.startsWith(profilePrefix))
528
+ .map((name) => ({
529
+ value: `profile ${name}`,
530
+ label: `router/${name}`,
531
+ description: `Switch to router profile "${name}"`,
532
+ }));
533
+ return items.length > 0 ? items : null;
534
+ }
535
+ case 'pin': {
536
+ const completions = getPinCompletions(subArgs);
537
+ return (
538
+ completions?.map((c) => ({
539
+ ...c,
540
+ value: `pin ${c.value}`,
541
+ description: c.description ?? `Pin routing to ${c.label}`,
542
+ })) ?? null
543
+ );
544
+ }
545
+ case 'thinking': {
546
+ const completions = getThinkingCompletions(subArgs);
547
+ return (
548
+ completions?.map((c) => ({
549
+ ...c,
550
+ value: `thinking ${c.value}`,
551
+ description: c.description ?? `Set thinking level to ${c.label}`,
552
+ })) ?? null
553
+ );
554
+ }
555
+ case 'fix': {
556
+ const fixPrefix = subArgs[0] ?? '';
557
+ const items = ['high', 'medium', 'low']
558
+ .filter((t) => t.startsWith(fixPrefix.toLowerCase()))
559
+ .map((t) => ({
560
+ value: `fix ${t}`,
561
+ label: t,
562
+ description: `Correct decision and pin to ${t} tier`,
563
+ }));
564
+ return items.length > 0 ? items : null;
565
+ }
566
+ case 'widget': {
567
+ const widgetPrefix = subArgs[0] ?? '';
568
+ const items = ['on', 'off', 'toggle']
569
+ .filter((v) => v.startsWith(widgetPrefix))
570
+ .map((v) => ({
571
+ value: `widget ${v}`,
572
+ label: v,
573
+ description: `Set widget to ${v}`,
574
+ }));
575
+ return items.length > 0 ? items : null;
576
+ }
577
+ case 'debug': {
578
+ const debugPrefix = subArgs[0] ?? '';
579
+ const items = ['on', 'off', 'toggle', 'clear', 'show']
580
+ .filter((v) => v.startsWith(debugPrefix))
581
+ .map((v) => ({
582
+ value: `debug ${v}`,
583
+ label: v,
584
+ description: `Router debug: ${v}`,
585
+ }));
586
+ return items.length > 0 ? items : null;
587
+ }
588
+ }
589
+
590
+ return null;
591
+ },
592
+ handler: async (args, ctx) => {
593
+ const parts = args?.trim().split(/\s+/) ?? [];
594
+ const subcommand = parts[0];
595
+ const subArgs = parts.slice(1);
596
+
597
+ switch (subcommand) {
598
+ case 'profile':
599
+ await handleProfile(subArgs, ctx);
600
+ break;
601
+ case 'pin':
602
+ await handlePin(subArgs, ctx);
603
+ break;
604
+ case 'thinking':
605
+ await handleThinking(subArgs, ctx);
606
+ break;
607
+ case 'disable':
608
+ await handleDisable(subArgs, ctx);
609
+ break;
610
+ case 'fix':
611
+ await handleFix(subArgs, ctx);
612
+ break;
613
+ case 'widget':
614
+ await handleWidget(subArgs, ctx);
615
+ break;
616
+ case 'debug':
617
+ await handleDebug(subArgs, ctx);
618
+ break;
619
+ case 'reload':
620
+ await handleReload(subArgs, ctx);
621
+ break;
622
+ case 'status':
623
+ await handleStatus(subArgs, ctx);
624
+ break;
625
+ case 'help':
626
+ case '?':
627
+ if (subArgs.length > 0) {
628
+ ctx.ui.notify('Usage: /router help (no arguments)', 'error');
629
+ return;
630
+ }
631
+ ctx.ui.notify(
632
+ [
633
+ 'Router Subcommands:',
634
+ ' status Show current status, profile, pin, cost, and last decision.',
635
+ ' profile [name] Switch to a profile (enables router if off). Lists available if no name.',
636
+ ' pin <tier|auto> Force a tier (high|medium|low) or set to auto.',
637
+ ' thinking [tier] <level> Override thinking level (off|minimal|...|max|auto). Not all tier models may support every level.',
638
+ ' disable Disable the router and restore the last used non-router model.',
639
+ ' fix <tier> Correct the last routing decision and pin that tier for the current profile.',
640
+ ' widget <on|off|toggle> Control the persistent status widget visibility.',
641
+ ' debug <on|off|show|clear> Control routing debug logging to notifications and history.',
642
+ ' reload Hot-reload the configuration JSON from .pi/model-router.json.',
643
+ ' help, ? Show this help message.',
644
+ ].join('\n'),
645
+ 'info',
646
+ );
647
+ break;
648
+ default:
649
+ if (subcommand) {
650
+ // Check if subcommand is actually a profile name (backwards compatible-ish with /router-on)
651
+ if (state.currentConfig.profiles[subcommand]) {
652
+ if (subArgs.length > 0) {
653
+ ctx.ui.notify(
654
+ `Usage: /router ${subcommand} (no extra arguments allowed)`,
655
+ 'error',
656
+ );
657
+ return;
658
+ }
659
+ await actions.switchToRouterProfile(subcommand, ctx);
660
+ ctx.ui.notify(
661
+ `Router enabled with profile: ${state.selectedProfile}`,
662
+ 'info',
663
+ );
664
+ } else {
665
+ ctx.ui.notify(
666
+ `Unknown router subcommand: ${subcommand}. Try /router help`,
667
+ 'error',
668
+ );
669
+ }
670
+ } else {
671
+ await handleStatus(subArgs, ctx);
672
+ }
673
+ break;
674
+ }
675
+ },
676
+ });
677
+ };