@tianmucreations/jeeves 0.2.1 → 0.3.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.
Files changed (55) hide show
  1. package/README.md +79 -18
  2. package/bin/jeeves +8 -1
  3. package/dist/agent/auto-ids.js +66 -0
  4. package/dist/agent/auto.js +178 -0
  5. package/dist/agent/context.js +55 -13
  6. package/dist/agent/errors.js +83 -22
  7. package/dist/agent/expert-chat.js +33 -0
  8. package/dist/agent/housekeeping.js +55 -0
  9. package/dist/agent/loop.js +168 -12
  10. package/dist/agent/permissions.js +167 -0
  11. package/dist/agent/research-gate.js +267 -0
  12. package/dist/agent/review.js +135 -0
  13. package/dist/agent/spending.js +73 -0
  14. package/dist/agent/systemPrompt.js +112 -0
  15. package/dist/app.js +25 -10
  16. package/dist/checkpoints/index.js +103 -0
  17. package/dist/checkpoints/store.js +239 -0
  18. package/dist/commands/address.js +5 -0
  19. package/dist/commands/clear.js +2 -0
  20. package/dist/commands/help.js +7 -4
  21. package/dist/commands/keys.js +1 -1
  22. package/dist/commands/verbose.js +1 -1
  23. package/dist/components/AddressPrompt.js +31 -0
  24. package/dist/components/Footer.js +74 -102
  25. package/dist/components/Input.js +76 -25
  26. package/dist/components/KeysManager.js +65 -20
  27. package/dist/components/ModelPicker.js +348 -75
  28. package/dist/components/ProjectPicker.js +4 -1
  29. package/dist/components/Transcript.js +29 -14
  30. package/dist/components/input-layout.js +34 -0
  31. package/dist/components/transcript-layout.js +13 -17
  32. package/dist/index.js +25 -7
  33. package/dist/ink/AlternateScreen.js +33 -16
  34. package/dist/ink/cursor.js +18 -0
  35. package/dist/ink/mouse.js +48 -0
  36. package/dist/keys/store.js +2 -1
  37. package/dist/models/registry.js +18 -2
  38. package/dist/platform/config.js +63 -7
  39. package/dist/providers/catalogue.js +293 -0
  40. package/dist/providers/direct-services.js +65 -0
  41. package/dist/providers/direct.js +145 -0
  42. package/dist/providers/index.js +123 -13
  43. package/dist/providers/models-snapshot.js +1037 -0
  44. package/dist/providers/ollama.js +21 -4
  45. package/dist/providers/openrouter.js +39 -4
  46. package/dist/providers/step-control.js +28 -0
  47. package/dist/providers/zai.js +31 -11
  48. package/dist/state/session.js +90 -36
  49. package/dist/state/today-spend.js +26 -0
  50. package/dist/tools/index.js +118 -10
  51. package/dist/tools/runBash.js +58 -11
  52. package/dist/tools/web/htmlToText.js +32 -0
  53. package/dist/tools/web/openrouterChat.js +31 -0
  54. package/dist/tools/web/research.js +191 -0
  55. package/package.json +32 -6
@@ -5,19 +5,24 @@ import Spinner from 'ink-spinner';
5
5
  import Fuse from 'fuse.js';
6
6
  import { session, useSession } from '../state/session.js';
7
7
  import { isToolCapable } from '../models/filter.js';
8
- import { compactContext, compactPrice, isFastModel, resolveCurated, cleanModelName, } from '../models/registry.js';
9
- import { setFavorites, setRecents, setDefaultModel, setDefaultProvider } from '../platform/config.js';
10
- import { summariseHistory } from '../agent/context.js';
11
- import { hasCredentialsFor, PROVIDER_ROWS } from '../providers/index.js';
8
+ import { compactContext, compactPrice, isFastModel, resolveCurated, isFreeModel, cleanModelName, } from '../models/registry.js';
9
+ import { setFavorites, setRecents, setDefaultModel, setDefaultProvider, setDailyLimit, hasSavedDailyLimit, getDefaultModel } from '../platform/config.js';
10
+ import { hasCredentialsFor, storeZaiKey, PROVIDER_ROWS, storeOpenRouterKey, refreshCredit, storeDirectKey, storeCustomService, serviceKey, customServiceName, } from '../providers/index.js';
11
+ import { directService, isDirectService, CUSTOM_SERVICE_ID } from '../providers/direct-services.js';
12
+ import { loadDirectModels, checkCustomService } from '../providers/catalogue.js';
13
+ import { autoRowFor, noAutoNote } from '../agent/auto.js';
14
+ import { getCustomService } from '../platform/config.js';
15
+ import { keyLooksValid } from '../commands/keys.js';
12
16
  import { listLocalOllamaModels, isOllamaOnline } from '../providers/ollama.js';
13
17
  import { ZAI_MODELS } from '../providers/zai.js';
14
- import { resetStickySession } from '../providers/openrouter.js';
15
- const TABS = ['favorites', 'recent', 'all', 'tools'];
18
+ import { isMouseSequence } from '../ink/mouse.js';
19
+ const TABS = ['favorites', 'recent', 'all', 'tools', 'free'];
16
20
  const TAB_LABELS = {
17
21
  favorites: 'Favorites',
18
22
  recent: 'Recent',
19
23
  all: 'All',
20
24
  tools: 'Tool-capable',
25
+ free: 'Free',
21
26
  };
22
27
  // The calm first step uses the shared provider list; keys live in the OS keychain (Phase 7).
23
28
  const PROVIDER_ORDER = ['openrouter', 'zai', 'anthropic', 'openai', 'google', 'x-ai', 'groq', 'mistral', 'ollama'];
@@ -28,11 +33,14 @@ const PROVIDER_LABELS = {
28
33
  openai: 'OpenAI',
29
34
  google: 'Google',
30
35
  'x-ai': 'xAI',
36
+ xai: 'xAI (Grok)',
31
37
  groq: 'Groq',
32
38
  mistral: 'Mistral',
33
39
  ollama: 'Ollama',
34
40
  };
35
41
  function providerLabel(provider) {
42
+ if (provider === CUSTOM_SERVICE_ID)
43
+ return customServiceName();
36
44
  return PROVIDER_LABELS[provider] ?? provider;
37
45
  }
38
46
  function groupModels(models) {
@@ -71,6 +79,9 @@ function poolFor(tab, models, favorites, recents) {
71
79
  return recents.map((id) => byId.get(id)).filter((m) => m !== undefined);
72
80
  if (tab === 'tools')
73
81
  return models.filter(isToolCapable);
82
+ // Free models that can do tasks - a free model that can only chat is no use here.
83
+ if (tab === 'free')
84
+ return models.filter((model) => isFreeModel(model) && isToolCapable(model));
74
85
  return models;
75
86
  }
76
87
  function fuzzyMatch(pool, query) {
@@ -115,6 +126,18 @@ export function ModelPicker({ rows, columns }) {
115
126
  const [cursor, setCursor] = useState(0);
116
127
  const [phase, setPhase] = useState('browse');
117
128
  const [pending, setPending] = useState(null);
129
+ const [keyValue, setKeyValue] = useState('');
130
+ // Which service the key prompt is for.
131
+ const [keyFor, setKeyFor] = useState('zai');
132
+ // A direct connection's or the compatible service's models (null while loading).
133
+ const [directModels, setDirectModels] = useState(null);
134
+ const [addressValue, setAddressValue] = useState('');
135
+ // True while a key is being checked with its company.
136
+ const [checking, setChecking] = useState(false);
137
+ const [limitValue, setLimitValue] = useState('');
138
+ const [limitNote, setLimitNote] = useState('');
139
+ const [limitReturn, setLimitReturn] = useState('providers');
140
+ const [keyNote, setKeyNote] = useState('');
118
141
  useEffect(() => {
119
142
  let cancelled = false;
120
143
  void isOllamaOnline().then((online) => {
@@ -125,8 +148,16 @@ export function ModelPicker({ rows, columns }) {
125
148
  cancelled = true;
126
149
  };
127
150
  }, []);
128
- const catalog = providerChoice === 'ollama' ? (ollamaModels ?? []) : providerChoice === 'zai' ? ZAI_MODELS : s.models;
129
- const listHeight = Math.max(1, rows - 5);
151
+ const catalog = providerChoice === 'ollama'
152
+ ? (ollamaModels ?? [])
153
+ : providerChoice === 'zai'
154
+ ? ZAI_MODELS
155
+ : providerChoice === 'openrouter'
156
+ ? s.models
157
+ : (directModels ?? []);
158
+ // A service without Auto says so in one line above its list.
159
+ const autoNote = step === 'full' ? noAutoNote(providerChoice, providerLabel(providerChoice)) : null;
160
+ const listHeight = Math.max(1, rows - 5 - (autoNote ? 1 : 0));
130
161
  const items = useMemo(() => {
131
162
  if (step === 'curated') {
132
163
  const picks = resolveCurated(catalog);
@@ -135,6 +166,9 @@ export function ModelPicker({ rows, columns }) {
135
166
  flat.push({ kind: 'model', model: pick.model, blurb: pick.blurb });
136
167
  }
137
168
  flat.push({ kind: 'header', label: '──────────' }, { kind: 'show-all' });
169
+ const freeCount = catalog.filter((model) => isFreeModel(model) && isToolCapable(model)).length;
170
+ if (freeCount > 0)
171
+ flat.push({ kind: 'show-free', count: freeCount });
138
172
  return flat;
139
173
  }
140
174
  if (step === 'full') {
@@ -142,7 +176,7 @@ export function ModelPicker({ rows, columns }) {
142
176
  const matched = fuzzyMatch(pool, query);
143
177
  const flat = [{ kind: 'back' }];
144
178
  if (providerChoice === 'ollama') {
145
- flat.push({ kind: 'header', label: 'Ollama (local)' });
179
+ flat.push({ kind: 'header', label: 'Ollama (on this computer)' });
146
180
  for (const model of matched)
147
181
  flat.push({ kind: 'model', model });
148
182
  }
@@ -164,20 +198,30 @@ export function ModelPicker({ rows, columns }) {
164
198
  const start = Math.max(0, Math.min(items.length - listHeight, resolved - half));
165
199
  const visible = items.slice(start, start + listHeight);
166
200
  const highlighted = resolved >= 0 && items[resolved]?.kind === 'model' ? items[resolved].model : null;
201
+ // Every row does something when chosen: each service asks for its key right here
202
+ // if it is missing (the compatible service for its address first).
167
203
  function providerEnabled(rowId) {
168
- if (rowId === 'openrouter')
169
- return hasCredentialsFor('openrouter');
170
- if (rowId === 'zai')
171
- return hasCredentialsFor('zai');
172
204
  if (rowId === 'ollama')
173
205
  return ollamaOnline === true;
174
- return false;
206
+ return true;
175
207
  }
176
208
  function providerHint(rowId) {
177
209
  if (rowId === 'ollama') {
178
210
  return ollamaOnline === null ? 'checking…' : 'not running - start the Ollama app';
179
211
  }
180
- return 'add key with /keys';
212
+ return '';
213
+ }
214
+ // The compatible service's row names it once it has been added.
215
+ function rowDescription(row) {
216
+ if (row.id === CUSTOM_SERVICE_ID && hasCredentialsFor(CUSTOM_SERVICE_ID))
217
+ return `${customServiceName()} - your compatible service`;
218
+ return row.description;
219
+ }
220
+ function askForKey(service) {
221
+ setKeyFor(service);
222
+ setKeyValue('');
223
+ setKeyNote('');
224
+ setStep('key');
181
225
  }
182
226
  function enterModelStep(forProvider) {
183
227
  setProviderChoice(forProvider);
@@ -196,20 +240,61 @@ export function ModelPicker({ rows, columns }) {
196
240
  }
197
241
  setStep(big ? 'curated' : 'full');
198
242
  }
243
+ // A direct connection or the compatible service: its models load from the company.
244
+ function enterDirectStep(forProvider) {
245
+ setProviderChoice(forProvider);
246
+ setQuery('');
247
+ setTab('all');
248
+ setCursor(1);
249
+ setDirectModels(null);
250
+ setStep('full');
251
+ const loading = forProvider === CUSTOM_SERVICE_ID
252
+ ? checkCustomService(getCustomService()?.baseURL ?? '', serviceKey(CUSTOM_SERVICE_ID) ?? '').then((check) => (check.ok ? check.models : []))
253
+ : isDirectService(forProvider)
254
+ ? loadDirectModels(forProvider, serviceKey(forProvider) ?? '').then((models) => {
255
+ // Auto heads the list where the company has it (OpenAI).
256
+ const auto = autoRowFor(forProvider, models);
257
+ return auto ? [auto, ...models] : models;
258
+ })
259
+ : Promise.resolve([]);
260
+ void loading.then(setDirectModels).catch(() => setDirectModels([]));
261
+ }
199
262
  function chooseProvider() {
263
+ // The row under the services: the daily spending limit.
264
+ if (providerCursor === PROVIDER_ROWS.length) {
265
+ setLimitReturn('providers');
266
+ setLimitValue('');
267
+ setLimitNote('');
268
+ setStep('limit');
269
+ return;
270
+ }
200
271
  const row = PROVIDER_ROWS[providerCursor];
201
272
  if (!row)
202
273
  return;
203
274
  if (row.id === 'openrouter') {
204
275
  if (!hasCredentialsFor('openrouter'))
205
- return;
276
+ return askForKey('openrouter');
206
277
  enterModelStep('openrouter');
207
278
  }
208
279
  else if (row.id === 'zai') {
209
280
  if (!hasCredentialsFor('zai'))
210
- return;
281
+ return askForKey('zai');
211
282
  enterModelStep('zai');
212
283
  }
284
+ else if (isDirectService(row.id)) {
285
+ if (!hasCredentialsFor(row.id))
286
+ return askForKey(row.id);
287
+ enterDirectStep(row.id);
288
+ }
289
+ else if (row.id === CUSTOM_SERVICE_ID) {
290
+ if (!hasCredentialsFor(CUSTOM_SERVICE_ID)) {
291
+ setAddressValue('');
292
+ setKeyNote('');
293
+ setStep('address');
294
+ return;
295
+ }
296
+ enterDirectStep(CUSTOM_SERVICE_ID);
297
+ }
213
298
  else if (row.id === 'ollama') {
214
299
  if (ollamaOnline !== true)
215
300
  return;
@@ -220,6 +305,12 @@ export function ModelPicker({ rows, columns }) {
220
305
  }
221
306
  }
222
307
  function goBack() {
308
+ if (step === 'key' || step === 'address') {
309
+ setKeyValue('');
310
+ setKeyNote('');
311
+ setStep(step === 'key' && keyFor === CUSTOM_SERVICE_ID ? 'address' : 'providers');
312
+ return;
313
+ }
223
314
  if (step === 'full' && providerChoice === 'openrouter') {
224
315
  setQuery('');
225
316
  setCursor(1);
@@ -261,15 +352,17 @@ export function ModelPicker({ rows, columns }) {
261
352
  goBack();
262
353
  return;
263
354
  }
264
- if (current.kind === 'show-all') {
355
+ if (current.kind === 'show-all' || current.kind === 'show-free') {
265
356
  setStep('full');
266
357
  setQuery('');
267
- setTab('all');
358
+ setTab(current.kind === 'show-free' ? 'free' : 'all');
268
359
  setCursor(1);
269
360
  return;
270
361
  }
271
362
  const model = current.model;
272
- if (model.id === s.model && providerChoice === s.providerId) {
363
+ // Choosing the model already in use just closes - unless nothing was ever chosen,
364
+ // so a first choice of the starting model (Auto) still saves it and sets the daily limit.
365
+ if (model.id === s.model && providerChoice === s.providerId && getDefaultModel() !== null) {
273
366
  s.closePicker();
274
367
  return;
275
368
  }
@@ -278,56 +371,198 @@ export function ModelPicker({ rows, columns }) {
278
371
  setPhase('tool-warning');
279
372
  return;
280
373
  }
281
- if (s.history.length > 0) {
282
- setPending(model);
283
- setPhase('switch-confirm');
374
+ // A switch mid-conversation simply carries the conversation on (context
375
+ // housekeeping keeps it lean; /clear starts afresh). No "Switched to" line:
376
+ // the info bar already names the model.
377
+ applyModel(model);
378
+ finishChoice(model);
379
+ }
380
+ // The first time a model that costs money is chosen, the daily limit is set, so
381
+ // spending is visible in the bar from the first message.
382
+ function finishChoice(model) {
383
+ const paid = providerChoice === 'openrouter' || isDirectService(providerChoice);
384
+ if (paid && !isFreeModel(model) && !hasSavedDailyLimit()) {
385
+ setLimitReturn('close');
386
+ setLimitValue('');
387
+ setLimitNote('');
388
+ setStep('limit');
284
389
  return;
285
390
  }
286
- applyModel(model);
287
- s.addNotice(`Switched to ${model.name}.`);
288
391
  s.closePicker();
289
392
  }
290
393
  useInput((input, key) => {
394
+ if (isMouseSequence(input))
395
+ return;
291
396
  if (phase === 'tool-warning') {
292
397
  if (key.return) {
293
398
  if (pending) {
294
399
  applyModel(pending);
295
- s.addNotice('Chat-only mode - this model cannot call tools.');
400
+ finishChoice(pending);
401
+ }
402
+ else {
403
+ s.closePicker();
296
404
  }
297
- s.closePicker();
298
405
  }
299
406
  else if (key.escape) {
300
407
  setPhase('browse');
301
408
  }
302
409
  return;
303
410
  }
304
- if (phase === 'switch-confirm') {
305
- if (input === 'k') {
306
- if (pending) {
307
- applyModel(pending);
308
- s.addNotice(`Switched to ${pending.name} - conversation kept.`);
411
+ if (step === 'address') {
412
+ if (key.escape) {
413
+ goBack();
414
+ return;
415
+ }
416
+ if (key.return) {
417
+ if (addressValue.trim().length < 4) {
418
+ setKeyNote("Paste the service's web address - its documentation gives it - or Esc");
419
+ return;
309
420
  }
310
- s.closePicker();
421
+ askForKey(CUSTOM_SERVICE_ID);
422
+ return;
311
423
  }
312
- else if (input === 's') {
313
- if (pending) {
314
- applyModel(pending);
315
- s.addNotice(`Switched to ${pending.name} - summarising the conversation.`);
424
+ if (key.backspace || key.delete) {
425
+ setAddressValue((v) => v.slice(0, -1));
426
+ return;
427
+ }
428
+ if (!input || key.ctrl || key.meta)
429
+ return;
430
+ setKeyNote('');
431
+ setAddressValue((v) => v + input);
432
+ return;
433
+ }
434
+ if (step === 'key') {
435
+ if (checking)
436
+ return;
437
+ if (key.escape) {
438
+ goBack();
439
+ return;
440
+ }
441
+ if (key.return && keyFor === CUSTOM_SERVICE_ID) {
442
+ // A service on this computer may need no key, so an empty key is allowed here.
443
+ setChecking(true);
444
+ setKeyNote(`Checking ${addressValue.trim()}…`);
445
+ void storeCustomService(addressValue, keyValue.trim()).then((result) => {
446
+ setChecking(false);
447
+ setKeyValue('');
448
+ if (result === 'saved' || result === 'saved-unchecked') {
449
+ if (result === 'saved-unchecked') {
450
+ s.addNotice(`${customServiceName()} shows its models to anyone, so the key couldn't be checked yet. If it's wrong, you'll be told on your first message.`);
451
+ }
452
+ setKeyNote('');
453
+ enterDirectStep(CUSTOM_SERVICE_ID);
454
+ }
455
+ else if (result === 'rejected') {
456
+ setKeyNote("The service didn't accept that key - paste it again, or Esc");
457
+ }
458
+ else if (result === 'keychain') {
459
+ setKeyNote('The Mac keychain was not reachable - press Enter and try again.');
460
+ }
461
+ else {
462
+ setStep('address');
463
+ setKeyNote("Couldn't find a compatible service at that address - check it, or Esc");
464
+ }
465
+ });
466
+ return;
467
+ }
468
+ if (key.return && isDirectService(keyFor)) {
469
+ const trimmed = keyValue.trim();
470
+ const label = directService(keyFor).label;
471
+ if (!keyLooksValid(trimmed, keyFor)) {
472
+ setKeyNote('That looks too short to be a key - paste it again, or Esc');
473
+ setKeyValue('');
474
+ return;
316
475
  }
317
- s.closePicker();
318
- void summariseHistory();
476
+ setChecking(true);
477
+ setKeyNote(`Checking the key with ${label}…`);
478
+ void storeDirectKey(keyFor, trimmed).then((result) => {
479
+ setChecking(false);
480
+ setKeyValue('');
481
+ if (result === 'rejected') {
482
+ setKeyNote(`${label} didn't accept that key - paste it again, or Esc`);
483
+ return;
484
+ }
485
+ if (result === 'keychain') {
486
+ setKeyNote('The Mac keychain was not reachable - press Enter and try again.');
487
+ return;
488
+ }
489
+ if (result === 'saved-unchecked') {
490
+ s.addNotice(`Your ${label} key is saved, but ${label} couldn't be reached to check it. If it's wrong, you'll be told on your first message.`);
491
+ }
492
+ setKeyNote('');
493
+ if (s.status === 'disconnected')
494
+ s.setStatus('idle');
495
+ enterDirectStep(keyFor);
496
+ });
497
+ return;
319
498
  }
320
- else if (input === 'f') {
321
- if (pending) {
322
- applyModel(pending);
323
- s.addNotice(`Switched to ${pending.name} - starting fresh.`);
499
+ if (key.return) {
500
+ const trimmed = keyValue.trim();
501
+ if (!keyLooksValid(trimmed, keyFor)) {
502
+ setKeyNote(keyFor === 'openrouter'
503
+ ? 'Not an OpenRouter key (they start with sk-or-) - paste it again, or Esc'
504
+ : 'That looks too short to be a key - paste it again, or Esc');
505
+ setKeyValue('');
506
+ return;
324
507
  }
325
- s.setHistory([]);
326
- resetStickySession();
327
- s.closePicker();
508
+ const store = keyFor === 'openrouter' ? storeOpenRouterKey : storeZaiKey;
509
+ void store(trimmed).then((saved) => {
510
+ if (!saved) {
511
+ setKeyNote('The Mac keychain was not reachable - press Enter and try again.');
512
+ return;
513
+ }
514
+ if (keyFor === 'zai') {
515
+ enterModelStep('zai');
516
+ }
517
+ else {
518
+ void refreshCredit();
519
+ if (s.status === 'disconnected')
520
+ s.setStatus('idle');
521
+ enterModelStep('openrouter');
522
+ }
523
+ });
524
+ return;
328
525
  }
329
- else if (key.escape) {
330
- setPhase('browse');
526
+ if (key.backspace || key.delete) {
527
+ setKeyValue((v) => v.slice(0, -1));
528
+ return;
529
+ }
530
+ if (!input || key.ctrl || key.meta)
531
+ return;
532
+ setKeyNote('');
533
+ setKeyValue((v) => v + input);
534
+ return;
535
+ }
536
+ if (step === 'limit') {
537
+ const done = () => (limitReturn === 'close' ? s.closePicker() : setStep('providers'));
538
+ // From a model choice, Esc or an empty Enter keeps the suggested limit.
539
+ if (key.escape || (key.return && limitValue === '' && limitReturn === 'close')) {
540
+ if (limitReturn === 'close') {
541
+ setDailyLimit(s.dailyLimit);
542
+ }
543
+ done();
544
+ return;
545
+ }
546
+ if (key.return) {
547
+ const amount = Number(limitValue.replace(/^\$/, ''));
548
+ if (!Number.isFinite(amount) || amount <= 0 || amount > 1000) {
549
+ setLimitNote('Type an amount in dollars, like 3 or 7.50');
550
+ setLimitValue('');
551
+ return;
552
+ }
553
+ const rounded = Math.round(amount * 100) / 100;
554
+ setDailyLimit(rounded);
555
+ s.setDailyLimit(rounded);
556
+ done();
557
+ return;
558
+ }
559
+ if (key.backspace || key.delete) {
560
+ setLimitValue((v) => v.slice(0, -1));
561
+ return;
562
+ }
563
+ if (/^[0-9.$]$/.test(input)) {
564
+ setLimitNote('');
565
+ setLimitValue((v) => (v + input).slice(0, 8));
331
566
  }
332
567
  return;
333
568
  }
@@ -341,7 +576,7 @@ export function ModelPicker({ rows, columns }) {
341
576
  return;
342
577
  }
343
578
  if (key.downArrow) {
344
- setProviderCursor((current) => Math.min(PROVIDER_ROWS.length - 1, current + 1));
579
+ setProviderCursor((current) => Math.min(PROVIDER_ROWS.length, current + 1));
345
580
  return;
346
581
  }
347
582
  if (key.return) {
@@ -390,11 +625,36 @@ export function ModelPicker({ rows, columns }) {
390
625
  }
391
626
  });
392
627
  if (step === 'providers') {
393
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Choose an AI provider" }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: PROVIDER_ROWS.map((row, index) => {
394
- const enabled = providerEnabled(row.id);
395
- const selected = index === providerCursor;
396
- return (_jsxs(Text, { inverse: selected, dimColor: !enabled && !selected, children: [column(' ' + row.label, 16), enabled ? _jsx(Text, { dimColor: true, children: row.description }) : _jsx(Text, { children: providerHint(row.id) })] }, row.id));
397
- }) }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 Enter choose \u00B7 Esc close" })] }));
628
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Choose an AI service" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [PROVIDER_ROWS.map((row, index) => {
629
+ const enabled = providerEnabled(row.id);
630
+ const selected = index === providerCursor;
631
+ return (_jsxs(Text, { inverse: selected, dimColor: !enabled && !selected, children: [column(' ' + row.label, 16), enabled ? _jsx(Text, { dimColor: true, children: rowDescription(row) }) : _jsx(Text, { children: providerHint(row.id) })] }, row.id));
632
+ }), _jsx(Text, { children: " " }), _jsxs(Text, { inverse: providerCursor === PROVIDER_ROWS.length, children: [column(' Daily limit', 16), _jsxs(Text, { dimColor: true, children: ["$", s.dailyLimit.toFixed(2), " a day - Enter to change"] })] })] }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 Enter choose \u00B7 Esc close" })] }));
633
+ }
634
+ if (step === 'limit') {
635
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Daily spending limit" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsxs(Text, { children: [_jsxs(Text, { children: ["Most to spend in a day, in dollars (now $", s.dailyLimit.toFixed(2), "): "] }), _jsx(Text, { children: limitValue }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: "When today's spending reaches it, Jeeves stops and asks before spending more." })] }), limitNote ? (_jsx(Text, { color: "yellow", children: limitNote })) : (_jsx(Text, { dimColor: true, children: limitReturn === 'close' ? `Enter keeps $${s.dailyLimit.toFixed(2)} · or type an amount, then Enter` : 'type an amount · Enter save · Esc back' }))] }));
636
+ }
637
+ if (step === 'address') {
638
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Other service \u2014 any service that accepts the OpenAI request format" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsxs(Text, { children: [_jsx(Text, { children: "Paste the service's web address: " }), _jsx(Text, { children: addressValue }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: "Its documentation gives it, for example https://api.together.xyz/v1" })] }), keyNote ? _jsx(Text, { color: "yellow", children: keyNote }) : _jsx(Text, { dimColor: true, children: "paste the address \u00B7 Enter next \u00B7 Esc back" })] }));
639
+ }
640
+ if (step === 'key') {
641
+ const direct = directService(keyFor);
642
+ const service = keyFor === 'openrouter' ? 'OpenRouter' : keyFor === 'zai' ? 'Z.ai' : keyFor === CUSTOM_SERVICE_ID ? 'service' : (direct?.label ?? keyFor);
643
+ const title = keyFor === 'openrouter'
644
+ ? 'OpenRouter — one key unlocks 400+ models'
645
+ : keyFor === 'zai'
646
+ ? 'Z.ai — GLM Coding Plan'
647
+ : keyFor === CUSTOM_SERVICE_ID
648
+ ? `Other service — ${addressValue.trim()}`
649
+ : `${service} — a direct connection with your own key`;
650
+ const where = keyFor === 'openrouter'
651
+ ? 'Get one at openrouter.ai/settings/keys. '
652
+ : direct
653
+ ? `Get one at ${direct.keyPage}. `
654
+ : keyFor === CUSTOM_SERVICE_ID
655
+ ? 'No key needed for a service on this computer - just press Enter. '
656
+ : '';
657
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: title }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsxs(Text, { children: [_jsxs(Text, { children: ["Paste your ", service, " API key (it stays hidden): "] }), _jsx(Text, { dimColor: true, children: keyValue ? `${keyValue.length} characters ` : '' }), _jsx(Text, { inverse: true, children: " " })] }), _jsxs(Text, { dimColor: true, children: [where, "It is stored in your Mac keychain and never shown again."] })] }), keyNote ? (_jsx(Text, { color: "yellow", children: keyNote })) : (_jsx(Text, { dimColor: true, children: "paste the key \u00B7 Enter save \u00B7 Esc back" }))] }));
398
658
  }
399
659
  if (step === 'curated') {
400
660
  const picks = resolveCurated(catalog);
@@ -411,35 +671,48 @@ export function ModelPicker({ rows, columns }) {
411
671
  if (item.kind === 'show-all') {
412
672
  return (_jsxs(Text, { inverse: selected, dimColor: !selected, children: ["Show all ", catalog.length, " models \u2192"] }, "showall"));
413
673
  }
674
+ if (item.kind === 'show-free') {
675
+ return (_jsxs(Text, { inverse: selected, dimColor: !selected, children: ["Free models (", item.count, ") - no charge, a daily limit on requests \u2192"] }, "showfree"));
676
+ }
414
677
  const tools = isToolCapable(item.model) ? '✓' : '✗';
415
678
  return (_jsxs(Text, { inverse: selected, children: [' ', cleanModelName(item.model.name), ' ', _jsxs(Text, { dimColor: true, children: ["\u2014 ", compactContext(item.model.contextLength), " ctx \u2014 ", item.blurb ?? '', " \u2014 ", tools, " tools"] })] }, `m${item.model.id}`));
416
- })) }), _jsx(Text, { dimColor: true, children: hint })] }));
679
+ })) }), _jsx(Text, { color: phase === 'tool-warning' ? 'yellow' : undefined, dimColor: phase !== 'tool-warning', children: hint })] }));
417
680
  }
418
681
  const emptyMessage = providerChoice === 'ollama'
419
682
  ? ollamaModels === null
420
- ? 'Loading local models…'
683
+ ? 'Loading the models on this computer…'
421
684
  : ollamaModels.length === 0
422
685
  ? 'Could not reach Ollama - is the app still running?'
423
686
  : ''
424
- : s.models.length === 0 && s.modelsNote
425
- ? s.modelsNote
426
- : s.models.length === 0
427
- ? 'Loading the model list…'
428
- : items.length <= 1
429
- ? query
430
- ? `No models match "${query}".`
431
- : tab === 'favorites'
432
- ? 'No favorites yet - highlight a model and press +'
433
- : tab === 'recent'
434
- ? 'No recently used models yet.'
435
- : 'No models.'
436
- : '';
687
+ : providerChoice !== 'openrouter' && providerChoice !== 'zai'
688
+ ? directModels === null
689
+ ? `Loading the ${providerLabel(providerChoice)} models…`
690
+ : directModels.length === 0
691
+ ? `Couldn't load the ${providerLabel(providerChoice)} models - check the internet connection, then open /model again.`
692
+ : items.length <= 1
693
+ ? query
694
+ ? `No models match "${query}".`
695
+ : 'No models in this list.'
696
+ : ''
697
+ : s.models.length === 0 && s.modelsNote
698
+ ? s.modelsNote
699
+ : s.models.length === 0
700
+ ? 'Loading the model list…'
701
+ : items.length <= 1
702
+ ? query
703
+ ? `No models match "${query}".`
704
+ : tab === 'favorites'
705
+ ? 'No favorites yet - highlight a model and press +'
706
+ : tab === 'recent'
707
+ ? 'No recently used models yet.'
708
+ : 'No models.'
709
+ : '';
437
710
  const hint = phase === 'tool-warning'
438
- ? `This model can't call tools, so reading files and running commands won't work. Enter: continue in chat-only mode · Esc: pick another`
439
- : phase === 'switch-confirm'
440
- ? `Keep this conversation (k) · Summarise it first (s) · Start fresh (f) · Esc: cancel`
441
- : `Tab list · ↑↓ move · type to search · + favorite · Enter select · Esc back`;
442
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Text, { dimColor: true, children: [providerLabel(providerChoice), " \u2014 all models"] }), _jsx(Box, { children: TABS.map((name) => (_jsxs(React.Fragment, { children: [_jsxs(Text, { inverse: name === tab, dimColor: name !== tab, children: [' ', TAB_LABELS[name], " (", catalog.length && name === 'tools' ? catalog.filter(isToolCapable).length : name === 'all' ? catalog.length : poolFor(name, catalog, s.favorites, s.recents).length, ")", ' '] }), _jsx(Text, { children: " " })] }, name))) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "search: " }), _jsx(Text, { children: query }), _jsx(Text, { dimColor: true, children: query ? '' : 'type to filter' })] }), _jsx(Text, { dimColor: true, children: "context in tokens \u00B7 prices per million tokens \u00B7 \u2713 tools \u00B7 \u00BB fast" }), _jsx(Box, { flexDirection: "column", height: listHeight, children: emptyMessage ? (_jsxs(Text, { dimColor: true, children: [_jsx(Spinner, { type: "dots" }), " ", emptyMessage] })) : (visible.map((item, index) => {
711
+ ? `This model can only chat, not do tasks · Enter use anyway · Esc pick another`
712
+ : `Tab list · ↑↓ move · type to search · + favorite · Enter select · Esc back`;
713
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Text, { dimColor: true, children: [providerLabel(providerChoice), " \u2014 all models"] }), _jsx(Box, { children: TABS.map((name) => (_jsxs(React.Fragment, { children: [_jsxs(Text, { inverse: name === tab, dimColor: name !== tab, children: [' ', TAB_LABELS[name], " (", catalog.length && name === 'tools' ? catalog.filter(isToolCapable).length : name === 'all' ? catalog.length : poolFor(name, catalog, s.favorites, s.recents).length, ")", ' '] }), _jsx(Text, { children: " " })] }, name))) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "search: " }), _jsx(Text, { children: query }), _jsx(Text, { dimColor: true, children: query ? '' : 'type to filter' })] }), _jsx(Text, { dimColor: true, children: tab === 'free'
714
+ ? 'free models: no charge, but OpenRouter limits requests per day · » fast'
715
+ : 'memory in tokens (word pieces) · price per million tokens · tools · » fast' }), autoNote ? _jsx(Text, { color: "yellow", children: autoNote }) : null, _jsx(Box, { flexDirection: "column", height: listHeight, children: emptyMessage ? (_jsxs(Text, { dimColor: true, children: [_jsx(Spinner, { type: "dots" }), " ", emptyMessage] })) : (visible.map((item, index) => {
443
716
  const absoluteIndex = start + index;
444
717
  if (item.kind === 'header') {
445
718
  return (_jsx(Text, { dimColor: true, children: `── ${item.label} ──` }, `h${absoluteIndex}`));
@@ -448,7 +721,7 @@ export function ModelPicker({ rows, columns }) {
448
721
  const selected = absoluteIndex === resolved;
449
722
  return (_jsx(Text, { inverse: selected, dimColor: !selected, children: backLabel() }, "back"));
450
723
  }
451
- if (item.kind === 'show-all') {
724
+ if (item.kind === 'show-all' || item.kind === 'show-free') {
452
725
  return null;
453
726
  }
454
727
  const selected = absoluteIndex === resolved;
@@ -9,6 +9,7 @@ import { session, useSession } from '../state/session.js';
9
9
  import { setRecentProjects } from '../platform/config.js';
10
10
  import { homeLocations, listSubfolders, displayPath, projectNameProblem } from '../platform/paths.js';
11
11
  import { hasCredentials } from '../providers/index.js';
12
+ import { isMouseSequence } from '../ink/mouse.js';
12
13
  // The cursor skips header lines; every other row is selectable.
13
14
  function resolveIndex(items, cursor) {
14
15
  const start = cursor < 0 ? 0 : cursor;
@@ -159,6 +160,8 @@ export function ProjectPicker({ rows, columns }) {
159
160
  return text.length > width ? text.slice(0, width - 1) + '…' : text;
160
161
  }
161
162
  useInput((input, key) => {
163
+ if (isMouseSequence(input))
164
+ return;
162
165
  if (mode === 'create-name') {
163
166
  if (key.escape) {
164
167
  setMode('list');
@@ -309,7 +312,7 @@ export function ProjectPicker({ rows, columns }) {
309
312
  }
310
313
  if (item.kind === 'recent') {
311
314
  const name = path.basename(item.folder);
312
- return (_jsxs(Text, { inverse: selected, children: [` ${name}`.padEnd(26), _jsx(Text, { dimColor: true, children: displayPath(item.folder) })] }, `r${item.folder}`));
315
+ return (_jsxs(Text, { inverse: selected, wrap: "truncate-middle", children: [` ${name}`.padEnd(26), _jsx(Text, { dimColor: true, children: displayPath(item.folder) })] }, `r${item.folder}`));
313
316
  }
314
317
  if (item.kind === 'browse') {
315
318
  return (_jsx(Text, { inverse: selected, children: ' Browse for a folder →' }, `b${absoluteIndex}`));