@namzu/cli 26.0.1 → 26.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,131 @@
1
1
  # @namzu/cli
2
2
 
3
+ ## 26.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ca56e24: The provider picker lists every provider you can set up, not only the ones it
8
+ detected on this machine.
9
+
10
+ The gap was reported from a session where it cost something: the saved provider
11
+ was OpenRouter, the picker drew the four providers discovery had found, and
12
+ OpenRouter was not among them — so there was no row to select and therefore no
13
+ way to type the key that was already in hand. A list assembled from `detected`
14
+ cannot name a provider `detected` does not mention, whatever the operator is
15
+ holding.
16
+
17
+ Detected rows are untouched: same order, same numbering, same source column
18
+ (`Claude session · this device`), same place at the top, and the header's count
19
+ still counts exactly what it counted before. Below them, under `Not detected —
20
+ enter a credential to use these:`, come every provider this build can construct
21
+ that takes a typed credential — `anthropic`, `google`, `openai`, `deepseek`,
22
+ `openrouter`, `zen`, `zen-go` — each marked with the environment variable that
23
+ sets it up (`needs OPENROUTER_API_KEY`), because that variable is both what
24
+ makes the provider work now and what keeps it working after a restart.
25
+
26
+ Enter on one of those rows opens the paste field for that provider, and `k`
27
+ follows the highlighted row instead of a fixed choice in the registry. Where the
28
+ row takes no typed credential — a local server, or a provider that signs in —
29
+ `k` still falls back to the provider the picker was opened for, which is the
30
+ saved one whose key is missing, so the route the notice above the list
31
+ advertises is unchanged. A typed credential is still held for the session and
32
+ written nowhere; nothing about how a credential is resolved at run time changed,
33
+ and no config key was added.
34
+
35
+ **What moves, and why it is not a patch.** The footer now names a provider only
36
+ when `k` reaches past the highlighted row to the one the picker was opened for.
37
+ With the cursor on the row the key will act on, the line reads `k enter a
38
+ credential`; the label returns as `k enter a credential for <provider>` exactly
39
+ when the two differ. That is shorter than the sentence it replaces — long enough
40
+ to wrap and lose its tail on a 100-column terminal — but it is a change to a
41
+ line a script or a reader may have matched, which is why this is `minor` rather
42
+ than `patch`. Two smaller consequences, both stated because they are visible:
43
+ the list is longer, so a machine with seven of these rows draws a taller screen,
44
+ and the digit shortcut still reaches the first nine rows only (a tenth needs two
45
+ keystrokes, and two keystrokes cannot be told from two presses without a timer),
46
+ which the screen now says out loud when the list passes nine.
47
+
48
+ Five registry entries are deliberately not offered there, because a row that
49
+ leads nowhere is worse than an absent one. `bedrock` needs an AWS credential
50
+ chain rather than one string; `http` is an endpoint whose base URL is half the
51
+ credential; `lmstudio` has no driver in this build; `ollama` needs a running
52
+ server, which is how discovery finds it; and `codex` is a device-code sign-in
53
+ this screen already offers with `l`, so it is not set up two ways.
54
+ `provider-list.test.ts` asserts that omission list by name, so flipping a
55
+ registry flag turns it red rather than quietly adding or dropping a row.
56
+
57
+ One defect was found in this screen while building it and is fixed here: the
58
+ cursor is an index into whichever list the screen is drawing, and the sign-in
59
+ screen draws three rows while the provider list draws more. With a saved
60
+ preference naming `openai` and a Codex device session detected, `/login` opened
61
+ with `openai` resolved against the provider list — row four — and read against
62
+ the sign-in list, so no row was highlighted and Enter did nothing at all.
63
+
64
+ ## 26.1.0
65
+
66
+ ### Minor Changes
67
+
68
+ - 9b920fd: The model picker marks a zero-priced model `(free)`, and its search finds the
69
+ word.
70
+
71
+ OpenRouter's public catalogue serves 445 models and 25 of them are priced at
72
+ zero for both input and output. The picker listed all 445 and could not tell
73
+ you which: the listing it built dropped the price the driver had already
74
+ parsed. A row now carries `(free)` when its listing reported `0` for both
75
+ prices, and `free` in the search box returns those rows — including the ones
76
+ whose ID and display name never spell the word, which is how
77
+ `google/lyria-3-pro-preview` becomes findable at all. Nothing is reordered or
78
+ hidden: the provider's order is the screen's order, and the marker only adds
79
+ words to a row that was already there.
80
+
81
+ **This changes what the search matches**, and that is why it is not a patch.
82
+ The filter is documented as matching a row's ID and display name; it now also
83
+ matches the note beside the row. Everything that matched before still matches
84
+ identically — the same words, the same order, the same rows returned by
85
+ identity — but a query can return more than it did. Typing `default` finds the
86
+ row marked `(namzu default)`, and `free` finds every row marked `(free)`,
87
+ where before both matched only rows whose ID or name happened to contain the
88
+ word. Nothing needs to be done about it unless you drive this screen from a
89
+ script that assumed a query's result set; if you do, the new matches are
90
+ additive, so a result you were using is still in it.
91
+
92
+ It is not a `major` because no exported symbol, CLI flag, config key, default
93
+ or wire shape moved. `ModelListing`'s `ok` arm gained two optional fields —
94
+ optional deliberately, since `ModelInfo` requires both prices and a required
95
+ price is one a driver has to invent — and the picker is a screen, not an API.
96
+
97
+ One thing to know before you trust the badge. The note reports what the
98
+ provider's own listing said about its own catalogue, and a driver that reports
99
+ zero for every model it lists gets a list marked free wholesale. Four drivers
100
+ in this repository do exactly that today (`anthropic`'s live path, `openai`,
101
+ `codex`, `deepseek`); the fix is to make the price optional in `ModelInfo` and
102
+ have those drivers omit it, the way `9d6c482c` did for `contextWindow`, and
103
+ that is an SDK change with its own bump rather than part of this one.
104
+
105
+ ### Patch Changes
106
+
107
+ - e83dfe5: The model picker marks a row `(free)` only when the provider reported both rates as zero, and says nothing about a model whose rate nobody published.
108
+
109
+ Both halves are the fix. Reading `0` as free is right, and it is now readable: `ModelInfo`'s two price fields are optional, so a driver with no rate omits them rather than writing the zero that made every paid model on four provider menus look free.
110
+
111
+ The catalogue block under `agent_models` prints a price fact: the rates when the driver published them, `Free` when it reported zero, and `Price unknown` when it published nothing. `Price unknown` is the rendering that did not exist — had absence been given the obvious one it would have printed `$0.00`, which is the same sentence as `Free` to a reader.
112
+
113
+ The `(free)` marker also survives a narrow terminal. `Picker` rebuilds a row's notes from a fixed vocabulary under 70 columns and drops any word missing from it, so `(free)` had to be added there or it would have been silently discarded on exactly the screens where the row is hardest to read.
114
+
115
+ - Updated dependencies [e83dfe5]
116
+ - Updated dependencies [e83dfe5]
117
+ - Updated dependencies [e83dfe5]
118
+ - Updated dependencies [e5411cc]
119
+ - Updated dependencies [9661d16]
120
+ - @namzu/ollama@2.2.4
121
+ - @namzu/sdk@43.0.0
122
+ - @namzu/anthropic@6.0.0
123
+ - @namzu/openai@4.0.0
124
+ - @namzu/deepseek@2.0.0
125
+ - @namzu/google@1.0.0
126
+ - @namzu/openrouter@3.0.0
127
+ - @namzu/computer-use@1.4.3
128
+
3
129
  ## 26.0.1
4
130
 
5
131
  ### Patch Changes
package/README.md CHANGED
@@ -159,7 +159,11 @@ labelled `Use existing`, while new credentials are labelled `Sign in to`. An
159
159
  expired or signed-out owner session is not offered as reusable.
160
160
  API keys remain optional alternatives through environment variables or the
161
161
  session-only credential picker, and detecting one does not hide the subscription
162
- sign-in action.
162
+ sign-in action. That picker lists every provider this build can construct that
163
+ accepts a typed credential, not only the ones found on the machine: the rest are
164
+ listed below the detected rows under `Not detected — enter a credential to use
165
+ these:`, each marked with the variable that sets it up (`needs
166
+ OPENROUTER_API_KEY`), and selecting one opens the paste field for that provider.
163
167
 
164
168
  Zen and Zen Go appear as separate providers in `/model` (press
165
169
  `p` to change providers). Zen works without an account key or an OpenCode
@@ -1 +1 @@
1
- {"version":3,"file":"Picker.d.ts","sourceRoot":"","sources":["../../src/tui/Picker.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,EAEN,KAAK,gBAAgB,EAErB,KAAK,UAAU,EAEf,KAAK,sBAAsB,EAG3B,MAAM,oCAAoC,CAAA;AAC3C,OAAO,EAAE,KAAK,YAAY,EAA0B,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAoBxF,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAA;IAC9C,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,oBAAoB,GAAG,wBAAwB,CAAA;IACxE,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,GAAG,eAAe,GAAG,QAAQ,CAAA;IAC/D,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;IACjG,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IAC7B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACzB,EAAE,EAAE,UAAU,EACd,GAAG,EAAE,gBAAgB,EACrB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,YAAY,CAAC,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CACvB,UAAU,EAAE,gBAAgB,EAC5B,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,WAAW,KACf,IAAI,CAAA;IACT;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAClB,QAAQ,EAAE,sBAAsB,EAChC,MAAM,EAAE,WAAW,KACf,OAAO,CAAC,gBAAgB,GAAG,UAAU,GAAG,QAAQ,CAAC,CAAA;IACtD,6EAA6E;IAC7E,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,GAAG,UAAU,CAAC,CAAA;IAChG;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,gBAAgB,CAAA;IACzC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,UAAU,GAAG,IAAI,CAAA;IACxC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B;AAyDD,wBAAgB,MAAM,CAAC,EACtB,QAAQ,EACR,eAAe,EACf,aAAoC,EACpC,YAAY,EACZ,WAAyB,EACzB,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,cAAuC,EACvC,YAAY,EACZ,OAAO,EACP,eAAe,EACf,MAAyB,EACzB,WAAW,EACX,MAAM,GACN,EAAE,WAAW,2CA00Bb"}
1
+ {"version":3,"file":"Picker.d.ts","sourceRoot":"","sources":["../../src/tui/Picker.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,EAEN,KAAK,gBAAgB,EAErB,KAAK,UAAU,EAEf,KAAK,sBAAsB,EAG3B,MAAM,oCAAoC,CAAA;AAC3C,OAAO,EAAE,KAAK,YAAY,EAA0B,gBAAgB,EAAE,MAAM,YAAY,CAAA;AA2BxF,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAA;IAC9C,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,oBAAoB,GAAG,wBAAwB,CAAA;IACxE,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,GAAG,eAAe,GAAG,QAAQ,CAAA;IAC/D,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;IACjG,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IAC7B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACzB,EAAE,EAAE,UAAU,EACd,GAAG,EAAE,gBAAgB,EACrB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,YAAY,CAAC,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CACvB,UAAU,EAAE,gBAAgB,EAC5B,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,WAAW,KACf,IAAI,CAAA;IACT;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAClB,QAAQ,EAAE,sBAAsB,EAChC,MAAM,EAAE,WAAW,KACf,OAAO,CAAC,gBAAgB,GAAG,UAAU,GAAG,QAAQ,CAAC,CAAA;IACtD,6EAA6E;IAC7E,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,GAAG,UAAU,CAAC,CAAA;IAChG;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,gBAAgB,CAAA;IACzC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,UAAU,GAAG,IAAI,CAAA;IACxC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B;AAyED,wBAAgB,MAAM,CAAC,EACtB,QAAQ,EACR,eAAe,EACf,aAAoC,EACpC,YAAY,EACZ,WAAyB,EACzB,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,cAAuC,EACvC,YAAY,EACZ,OAAO,EACP,eAAe,EACf,MAAyB,EACzB,WAAW,EACX,MAAM,GACN,EAAE,WAAW,2CA44Bb"}
@@ -15,15 +15,12 @@ import { describeProviderModels, verifyCredential } from './agent.js';
15
15
  import { classifyCredential, describeDisposition, keyLooksUsable, maskKey, sessionCredential, } from './credential-entry.js';
16
16
  import { modelStep } from './model-choices.js';
17
17
  import { filterModelChoices } from './model-search.js';
18
+ import { credentialNeed, initialProviderRow, providerListRows, rowProviderId, } from './provider-list.js';
18
19
  import { moveSelection, selectionWindow } from './selection-window.js';
19
20
  import { choiceDisplayWidth, eraseLastChoiceGrapheme, truncateChoiceText, } from './terminal-choice-text.js';
20
21
  import { terminalDisplayText } from './terminal-display.js';
21
22
  import { theme } from './theme.js';
22
23
  import { useSelectionIndex } from './use-selection-index.js';
23
- /** Providers that take a typed credential. Local servers do not. */
24
- function keyCapableProviders() {
25
- return ALL_PROVIDER_IDS.map((id) => PROVIDER_REGISTRY[id]).filter((e) => e.acceptsTypedCredential);
26
- }
27
24
  function subscriptionProviders() {
28
25
  return ALL_PROVIDER_IDS.map((id) => PROVIDER_REGISTRY[id]).filter((entry) => entry.subscriptionLogin !== undefined);
29
26
  }
@@ -51,17 +48,36 @@ function signInChoiceIndex(choices, provider) {
51
48
  /**
52
49
  * The provider `k` opens entry for.
53
50
  *
54
- * The saved one when this picker is open because its credential is missing;
55
- * otherwise the first key-capable provider, which is what the empty screen has
56
- * always done. Returns null when nothing here takes a typed credential.
51
+ * The highlighted row whenever it takes a typed credential — whichever row that
52
+ * is, detected or not. This screen used to answer the question from the
53
+ * registry instead: the saved provider if the picker was open because of one,
54
+ * and otherwise the first key-capable entry, which is a provider the operator
55
+ * may not have been looking at. A list you can move through is a list the key
56
+ * beside it has to follow.
57
+ *
58
+ * The rows that do not take one — a local server, a provider whose credential
59
+ * arrives by sign-in — fall through to the saved provider this picker was
60
+ * opened for: that route is why the notice above says `k`, and it must keep
61
+ * working when the cursor happens to sit on a row that cannot use it.
62
+ *
63
+ * Returns null when there is nothing here to enter a credential for, which the
64
+ * caller says out loud rather than presenting a field that leads nowhere.
57
65
  */
58
- function keyEntryTarget(keyEntryFor) {
66
+ function keyEntryTarget(row, keyEntryFor) {
67
+ if (row?.kind === 'unconfigured')
68
+ return row.entry;
69
+ if (row?.kind === 'detected' && row.detected.entry.acceptsTypedCredential)
70
+ return row.detected.entry;
59
71
  if (keyEntryFor) {
60
72
  const entry = PROVIDER_REGISTRY[keyEntryFor];
61
73
  if (entry?.acceptsTypedCredential)
62
74
  return entry;
63
75
  }
64
- return keyCapableProviders()[0] ?? null;
76
+ return null;
77
+ }
78
+ /** Name a row the way its own line does, for a sentence about it. */
79
+ function rowLabel(row) {
80
+ return row.kind === 'detected' ? row.detected.entry.label : row.entry.label;
65
81
  }
66
82
  export function Picker({ detected, currentProvider, selectionKind = 'provider-and-model', currentModel, initialView = 'providers', onSubmit, onCancel, onSetup, describeModels = describeProviderModels, onCredential, onLogin, onLoginComplete, verify = verifyCredential, keyEntryFor, notice, }) {
67
83
  /**
@@ -96,10 +112,32 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
96
112
  operationRef.current = null;
97
113
  }, []);
98
114
  useEffect(() => invalidateOperation, [invalidateOperation]);
99
- const initialIndex = (currentProvider !== null && currentProvider !== undefined
100
- ? detected.findIndex((d) => d.entry.id === currentProvider)
101
- : 0) || 0;
102
- const { selection: cursor, selectionRef: cursorRef, setSelection: setCursor, } = useSelectionIndex(Math.max(0, initialIndex));
115
+ // The list this screen draws: what was detected, then every provider this
116
+ // build could construct if the operator supplied a credential.
117
+ //
118
+ // `detected` is still the discovery result and is still what the header
119
+ // counts, what the sign-in screen offers and what a submitted choice is
120
+ // built from. This is the screen, not the machine.
121
+ //
122
+ // The signed-in-subscription screen takes the detected rows alone: it asks
123
+ // which already-usable session to use, and a row that needs a key first
124
+ // makes that sentence false.
125
+ const rows = providerListRows(detected, selectionKind !== 'signed-in-subscription');
126
+ // Whether `k` is offered at all, read once so the hint below and the key
127
+ // handler cannot disagree about it. The handler adds the phase guards,
128
+ // because it runs on every screen this picker draws and this expression is
129
+ // computed only for the list.
130
+ const keyEntryOffered = onCredential !== undefined && selectionKind !== 'signed-in-subscription';
131
+ // The cursor is an index into whichever list the screen is drawing, and the
132
+ // sign-in screen draws `subscriptionChoices` while the provider list draws
133
+ // these rows, which is longer by however many providers can be set up from
134
+ // here. An index resolved against one and read against the other selects
135
+ // nothing at all, which is how `/login` came up with no row highlighted and
136
+ // an Enter that did nothing on the machine below: a saved preference naming
137
+ // a saved provider resolved to its row in the provider list, which is past the end
138
+ // of a three-row sign-in list.
139
+ const initialSelection = initialView === 'subscriptions' ? 0 : initialProviderRow(rows, currentProvider, keyEntryFor);
140
+ const { selection: cursor, selectionRef: cursorRef, setSelection: setCursor, } = useSelectionIndex(initialSelection);
103
141
  const [errorHint, setErrorHint] = useState(null);
104
142
  // The ref makes a pasted query followed immediately by Enter use the new
105
143
  // filtered list, even before React has drawn another frame. Null keeps the
@@ -307,19 +345,22 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
307
345
  }
308
346
  return;
309
347
  }
310
- // `k` opens credential entry. Two screens offer it, for two reasons.
348
+ // `k` opens credential entry for the highlighted row — see
349
+ // `keyEntryTarget` for which provider that resolves to when the row takes
350
+ // no typed credential. The empty screen has always offered it (there,
351
+ // entering a credential is the only thing that can happen), and the
352
+ // populated one used to offer it only when `keyEntryFor` was set: someone
353
+ // with a working credential was not the person this was for. True then,
354
+ // and false for the operator this screen was opened for, who has a saved
355
+ // provider with no credential and a local server that happens to be
356
+ // running — they arrived at a list that named every provider except a way
357
+ // to fix the one they chose. The letter does not collide with anything:
358
+ // navigation is arrows and digits, and the model step owns its own keys.
311
359
  //
312
- // The empty one always has: with nothing detected, entering a credential
313
- // is the only thing that can happen here.
314
- //
315
- // The POPULATED one offers it when `keyEntryFor` is set, which was the
316
- // gap. The old rule was "empty screen only", reasoned as "someone with a
317
- // working credential is not the person this is for" — true then, and false
318
- // for the person this change routes here, who has a saved provider with no
319
- // credential and a local server that happens to be running. They arrived
320
- // at a list that named every provider except a way to fix the one they
321
- // chose. The letter does not collide with anything: navigation is arrows
322
- // and digits.
360
+ // Two phases are guarded out. The model step types into search, so `k`
361
+ // there is a character and not a shortcut. The sign-in screen numbers its
362
+ // own choices, so the cursor indexes those rather than these rows, and a
363
+ // key read through it would name a provider nobody chose.
323
364
  // `l` starts a Namzu-owned subscription sign-in from the general provider
324
365
  // screen as well as the empty one. The already-signed-in choice deliberately
325
366
  // omits it: external auth exists there, and a newly stored credential would
@@ -344,24 +385,27 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
344
385
  onSetup();
345
386
  return;
346
387
  }
347
- if (modelPhase === null &&
348
- (detected.length === 0 || keyEntryFor) &&
349
- onCredential &&
388
+ const highlighted = rows[cursorRef.current];
389
+ if (!loginPhase &&
390
+ modelPhase === null &&
391
+ keyEntryOffered &&
350
392
  (input === 'k' || input === 'K')) {
351
- const target = keyEntryTarget(keyEntryFor);
393
+ const target = keyEntryTarget(highlighted, keyEntryFor);
352
394
  if (target) {
353
395
  invalidateOperation();
354
396
  setKeyEntry({ entry: target, value: '', status: 'typing' });
355
397
  }
356
398
  else {
357
- setErrorHint('No provider here takes a typed credential.');
399
+ setErrorHint(highlighted
400
+ ? `${rowLabel(highlighted)} does not take a typed credential — choose a provider that does.`
401
+ : 'No provider here takes a typed credential.');
358
402
  }
359
403
  return;
360
404
  }
361
405
  if (modelPhase &&
362
406
  (key.leftArrow || (modelQueryRef.current === null && (input === 'p' || input === 'P')))) {
363
407
  invalidateOperation();
364
- setCursor(Math.max(0, detected.findIndex((provider) => provider.entry.id === modelPhase.provider.entry.id)));
408
+ setCursor(Math.max(0, rows.findIndex((row) => rowProviderId(row) === modelPhase.provider.entry.id)));
365
409
  setModelPhase(null);
366
410
  return;
367
411
  }
@@ -380,7 +424,7 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
380
424
  onCancel();
381
425
  return;
382
426
  }
383
- setCursor(Math.max(0, detected.findIndex((provider) => provider.entry.id === modelPhase.provider.entry.id)));
427
+ setCursor(Math.max(0, rows.findIndex((row) => rowProviderId(row) === modelPhase.provider.entry.id)));
384
428
  setModelPhase(null);
385
429
  return;
386
430
  }
@@ -510,23 +554,38 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
510
554
  return;
511
555
  }
512
556
  if (key.home || key.end || key.pageUp || key.pageDown) {
513
- setCursor((current) => moveSelection(current, detected.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
557
+ setCursor((current) => moveSelection(current, rows.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
514
558
  return;
515
559
  }
516
560
  if (key.upArrow) {
517
- setCursor((current) => moveSelection(current, detected.length, 'previous'));
561
+ setCursor((current) => moveSelection(current, rows.length, 'previous'));
518
562
  return;
519
563
  }
520
564
  if (key.downArrow) {
521
- setCursor((current) => moveSelection(current, detected.length, 'next'));
565
+ setCursor((current) => moveSelection(current, rows.length, 'next'));
522
566
  return;
523
567
  }
524
568
  if (key.return) {
525
- const current = detected[cursorRef.current];
526
- if (!current) {
569
+ const row = rows[cursorRef.current];
570
+ if (!row) {
527
571
  setErrorHint('No provider available.');
528
572
  return;
529
573
  }
574
+ // A row with no credential has no session to open and no catalogue to
575
+ // ask: accepting it would submit a provider this machine holds nothing
576
+ // for. Enter takes the operator to the field instead, which is the same
577
+ // thing `k` does on this row and the only thing that can move it
578
+ // forward.
579
+ if (row.kind === 'unconfigured') {
580
+ if (!onCredential) {
581
+ setErrorHint(`No credential can be entered for ${row.entry.label} on this screen.`);
582
+ return;
583
+ }
584
+ invalidateOperation();
585
+ setKeyEntry({ entry: row.entry, value: '', status: 'typing' });
586
+ return;
587
+ }
588
+ const current = row.detected;
530
589
  // Detected, and still not choosable. The row stays visible on purpose
531
590
  // — see the list below — so this is the only place that can decline
532
591
  // it, and declining with the reason is the point: accepting would
@@ -547,9 +606,14 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
547
606
  openModels(current);
548
607
  return;
549
608
  }
550
- // Numeric quick-select.
609
+ // Numeric quick-select, one keystroke per row and therefore the first nine.
610
+ // A tenth row would need two digits, and two digits cannot be told from
611
+ // two presses without a timer between them — a wait the screen would have
612
+ // to add to every `1` before it knew whether a `0` was coming. The list
613
+ // says so when it grows past nine rather than leaving the rest looking
614
+ // selectable and being unreachable.
551
615
  const n = Number.parseInt(input, 10);
552
- if (Number.isFinite(n) && n >= 1 && n <= detected.length) {
616
+ if (Number.isFinite(n) && n >= 1 && n <= Math.min(rows.length, 9)) {
553
617
  setCursor(n - 1);
554
618
  }
555
619
  });
@@ -590,22 +654,34 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
590
654
  ? `Use existing ${choice.detected.entry.label} · ${describeSource(choice.detected)}`
591
655
  : `Sign in to ${choice.entry.label} · ${choice.entry.subscriptionLogin === 'device' ? 'device code' : 'browser'}`] }, `${choice.kind}-${choice.kind === 'existing' ? choice.detected.entry.id : choice.entry.id}`))) }), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.text.muted, children: ["\u2191\u2193 or 1-", choices.length, " navigate \u00B7 enter use \u00B7 esc back"] }), errorHint ? _jsx(Text, { color: theme.status.warn, children: errorHint }) : null] })] }));
592
656
  }
593
- // Non-null exactly when `k` is live on the populated list — the same
594
- // condition the key handler uses, read from one place so the hint and the
595
- // keyboard cannot drift apart.
596
- const entryTarget = keyEntryFor && onCredential ? keyEntryTarget(keyEntryFor) : null;
657
+ // Non-null exactly when `k` is live on this list and has somewhere to go —
658
+ // the same resolution the key handler performs, read from one place so the
659
+ // hint and the keyboard cannot drift apart. It follows the highlighted row,
660
+ // so the sentence changes as the cursor moves: a hint that kept naming the
661
+ // saved provider while `k` acted on another row would be the defect this
662
+ // screen's other messages are written to avoid.
663
+ const highlightedRow = rows[cursor];
664
+ const entryTarget = keyEntryOffered ? keyEntryTarget(highlightedRow, keyEntryFor) : null;
665
+ // The label is named only when `k` reaches past the highlighted row to the
666
+ // provider this screen was opened for. On the ordinary path the row under
667
+ // the cursor is the answer, and saying so again costs the line its tail: the
668
+ // footer is one row, and the sentence with a label in it wraps.
669
+ const namesAnotherRow = entryTarget !== null &&
670
+ (highlightedRow === undefined || rowProviderId(highlightedRow) !== entryTarget.id);
597
671
  if (detected.length === 0) {
598
672
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.status.warn, paddingX: 1, children: [noticeBox, _jsx(Text, { color: theme.status.warn, bold: true, children: "No providers detected" }), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.text.primary, children: "namzu scans these sources, in order, for an LLM credential:" }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 existing", ' ', subscriptionProviders()
599
673
  .map((entry) => entry.label)
600
- .join(' and '), ' ', "sessions on this device"] }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 subscriptions signed in to from namzu (~/.namzu/credentials.json)"] }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 env vars / API keys (optional alternatives: ANTHROPIC_API_KEY, OPENAI_API_KEY, \u2026)"] }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 local servers (Ollama localhost:11434, LM Studio localhost:1234)"] })] }), onLogin ? (_jsx(Box, { paddingTop: 1, children: _jsxs(Text, { color: theme.text.primary, children: ["Press ", _jsx(Text, { color: theme.accent.system, children: "l" }), " to sign in with a subscription \u2014 no API key, and namzu keeps it for next time."] }) })) : null, _jsx(Box, { paddingTop: 1, children: _jsxs(Text, { color: theme.text.primary, children: ["Or press ", _jsx(Text, { color: theme.accent.system, children: "k" }), " to paste a credential now and use it for this session."] }) }), _jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: theme.text.secondary, children: "You can also set one of the env vars above (or start a local server) and restart." }) }), _jsx(Box, { paddingTop: 1, children: _jsxs(Text, { color: theme.text.muted, children: [onLogin ? 'l: sign in · ' : '', "k: enter a credential \u00B7 esc: exit picker"] }) })] }));
674
+ .join(' and '), ' ', "sessions on this device"] }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 subscriptions signed in to from namzu (~/.namzu/credentials.json)"] }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 env vars / API keys (optional alternatives: ANTHROPIC_API_KEY, OPENAI_API_KEY, \u2026)"] }), _jsxs(Text, { color: theme.text.muted, children: [' ', "\u00B7 local servers (Ollama localhost:11434, LM Studio localhost:1234)"] })] }), onLogin ? (_jsx(Box, { paddingTop: 1, children: _jsxs(Text, { color: theme.text.primary, children: ["Press ", _jsx(Text, { color: theme.accent.system, children: "l" }), " to sign in with a subscription \u2014 no API key, and namzu keeps it for next time."] }) })) : null, _jsx(Box, { paddingTop: 1, children: _jsxs(Text, { color: theme.text.primary, children: ["Or press ", _jsx(Text, { color: theme.accent.system, children: "k" }), " to paste a credential now and use it for this session."] }) }), _jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: theme.text.secondary, children: "You can also set one of the env vars above (or start a local server) and restart." }) }), _jsx(ProviderSetupRows, { rows: rows, cursor: cursor, currentProvider: currentProvider }), _jsx(Box, { paddingTop: 1, children: _jsxs(Text, { color: theme.text.muted, children: [onLogin ? 'l: sign in · ' : '', "\u2191\u2193 or 1-9 navigate \u00B7 enter or k: enter a credential \u00B7 esc: exit picker"] }) })] }));
601
675
  }
602
676
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [noticeBox, _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(Text, { color: theme.accent.system, bold: true, children: selectionKind === 'signed-in-subscription'
603
677
  ? 'Choose a signed-in subscription'
604
678
  : 'Choose a provider' }), _jsx(Text, { color: theme.text.muted, children: selectionKind === 'signed-in-subscription'
605
679
  ? `${detected.length} already usable · no API key required`
606
- : `${detected.length} detected · device sessions / Namzu sign-ins / optional keys / local probes` })] }), _jsx(Box, { flexDirection: "column", children: detected.map((d, i) => (_jsx(ProviderRow, { detected: d, index: i, selected: i === cursor, isCurrent: d.entry.id === currentProvider }, d.entry.id))) }), _jsxs(Box, { flexDirection: "column", paddingTop: 1, children: [_jsxs(Text, { color: theme.text.muted, children: ["\u2191\u2193 or 1-9 navigate \u00B7 enter ", selectionKind === 'signed-in-subscription' ? 'use' : 'accept', entryTarget ? ` · k enter a credential for ${entryTarget.label}` : '', onLogin && selectionKind !== 'signed-in-subscription'
680
+ : `${detected.length} detected · device sessions / Namzu sign-ins / optional keys / local probes` })] }), _jsx(ProviderSetupRows, { rows: rows, cursor: cursor, currentProvider: currentProvider }), _jsxs(Box, { flexDirection: "column", paddingTop: 1, children: [_jsxs(Text, { color: theme.text.muted, children: ["\u2191\u2193 or 1-9 navigate \u00B7 enter ", selectionKind === 'signed-in-subscription' ? 'use' : 'accept', entryTarget
681
+ ? ` · k enter a credential${namesAnotherRow ? ` for ${entryTarget.label}` : ''}`
682
+ : '', onLogin && selectionKind !== 'signed-in-subscription'
607
683
  ? ' · l create a Namzu sign-in'
608
- : '', ' ', "\u00B7 esc cancel"] }), errorHint ? _jsx(Text, { color: theme.status.warn, children: errorHint }) : null] })] }));
684
+ : '', ' ', "\u00B7 esc cancel"] }), rows.length > 9 ? (_jsx(Text, { color: theme.text.muted, children: "Rows past 9 are \u2191\u2193 only: two digits cannot be told from two presses." })) : null, errorHint ? _jsx(Text, { color: theme.status.warn, children: errorHint }) : null] })] }));
609
685
  }
610
686
  function ModelStepView({ providerLabel, otherProviders, step, query, currentModel, cursor, errorHint, returnToProviders, sessionOnly, }) {
611
687
  const terminal = useWindowSize();
@@ -628,6 +704,17 @@ function ModelStepView({ providerLabel, otherProviders, step, query, currentMode
628
704
  c.id === currentModel ? '(current)' : '',
629
705
  c.note?.includes('namzu default') ? '(default)' : '',
630
706
  c.note?.includes('image input') ? '(image)' : '',
707
+ // Rebuilt from a fixed vocabulary, so a word
708
+ // missing from it is DROPPED rather than
709
+ // shortened — and a `(free)` that vanishes on a
710
+ // narrow terminal is a marker the operator
711
+ // cannot rely on. Listed here for the same
712
+ // reason the other two are: the note is prose
713
+ // the model step built, and this branch has to
714
+ // know which of it still fits. `free` is read
715
+ // off `c.note` rather than off the prices,
716
+ // which the choice does not carry.
717
+ c.note?.includes('free') ? '(free)' : '',
631
718
  ]
632
719
  : [
633
720
  c.note,
@@ -640,6 +727,44 @@ function ModelStepView({ providerLabel, otherProviders, step, query, currentMode
640
727
  ? `↑↓ · enter apply · esc ${returnToProviders ? 'back' : 'cancel'}`
641
728
  : `↑↓ · PgUp/PgDn · Home/End · enter apply · esc ${returnToProviders ? 'back' : 'cancel'}${query === null ? ' · digits select' : ' · Ctrl+U clears'}` }), errorHint ? _jsx(Text, { color: theme.status.warn, children: errorHint }) : null] })] }));
642
729
  }
730
+ /**
731
+ * The list, in the two blocks it is built as.
732
+ *
733
+ * Detected first and untouched: same rows, same order, same numbering, same
734
+ * source column. The second block is appended below a heading rather than
735
+ * merged into the first, because merging is the one thing that would move a row
736
+ * the operator already knows the position of — and every row above this line is
737
+ * one they may already be using.
738
+ *
739
+ * The appended rows carry no blank line between them. They are a work list
740
+ * rather than a catalogue, and a machine with six of them would otherwise push
741
+ * the box past a 24-row terminal and scroll the top of it — the detected rows —
742
+ * out of sight.
743
+ */
744
+ function ProviderSetupRows({ rows, cursor, currentProvider, }) {
745
+ // The two arms are contiguous by construction (`providerListRows` appends),
746
+ // so one boundary splits them and every row's number is its position in the
747
+ // whole list, whichever block it is drawn in.
748
+ const firstUnconfigured = rows.findIndex((row) => row.kind === 'unconfigured');
749
+ const detectedCount = firstUnconfigured === -1 ? rows.length : firstUnconfigured;
750
+ return (_jsxs(_Fragment, { children: [_jsx(Box, { flexDirection: "column", children: rows.slice(0, detectedCount).map((row, index) => row.kind === 'detected' ? (_jsx(ProviderRow, { detected: row.detected, index: index, selected: index === cursor, isCurrent: row.detected.entry.id === currentProvider }, row.detected.entry.id)) : null) }), firstUnconfigured === -1 ? null : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.text.muted, children: "Not detected \u2014 enter a credential to use these:" }), rows.slice(firstUnconfigured).map((row, offset) => row.kind === 'unconfigured' ? (_jsx(UnconfiguredProviderRow, { entry: row.entry, index: firstUnconfigured + offset, selected: firstUnconfigured + offset === cursor, isCurrent: row.entry.id === currentProvider }, row.entry.id)) : null)] }))] }));
751
+ }
752
+ /**
753
+ * A row for a provider nothing on this machine can serve yet.
754
+ *
755
+ * Deliberately a separate component from `ProviderRow` rather than one
756
+ * component with two modes: the detected row's shape is what operators read
757
+ * every day, and a shared body is a body where a change for the new rows moves
758
+ * the old ones. The columns line up because both pad the label to the same
759
+ * width; what differs is the third one, which says what is missing instead of
760
+ * where the credential came from.
761
+ */
762
+ function UnconfiguredProviderRow({ entry, index, selected, isCurrent, }) {
763
+ const cursor = selected ? '›' : ' ';
764
+ const number = `${index + 1}.`;
765
+ const currentMark = isCurrent ? ' ← current' : '';
766
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.border.focus : theme.text.muted, children: [cursor, " "] }), _jsxs(Text, { color: theme.text.muted, children: [number, " "] }), _jsx(Text, { color: selected ? theme.border.focus : theme.text.primary, bold: selected, children: entry.label.padEnd(28) }), _jsx(Text, { color: theme.status.warn, children: credentialNeed(entry) }), isCurrent ? _jsx(Text, { color: theme.accent.system, children: currentMark }) : null] }));
767
+ }
643
768
  function ProviderRow({ detected, index, selected, isCurrent, }) {
644
769
  const cursor = selected ? '›' : ' ';
645
770
  const number = `${index + 1}.`;