@namzu/cli 26.2.0 → 27.0.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 (342) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/README.md +49 -34
  3. package/dist/bin.js +7 -0
  4. package/dist/bin.js.map +1 -1
  5. package/dist/checkpoints/store.d.ts +17 -4
  6. package/dist/checkpoints/store.d.ts.map +1 -1
  7. package/dist/checkpoints/store.js +20 -5
  8. package/dist/checkpoints/store.js.map +1 -1
  9. package/dist/cli.js +2 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/acp.d.ts +23 -1
  12. package/dist/commands/acp.d.ts.map +1 -1
  13. package/dist/commands/acp.js +41 -2
  14. package/dist/commands/acp.js.map +1 -1
  15. package/dist/commands/drain.d.ts +69 -2
  16. package/dist/commands/drain.d.ts.map +1 -1
  17. package/dist/commands/drain.js +214 -134
  18. package/dist/commands/drain.js.map +1 -1
  19. package/dist/commands/eval.js +1 -1
  20. package/dist/commands/eval.js.map +1 -1
  21. package/dist/commands/provider-wait.d.ts +5 -5
  22. package/dist/commands/provider-wait.d.ts.map +1 -1
  23. package/dist/commands/provider-wait.js +2 -2
  24. package/dist/commands/resident.d.ts.map +1 -1
  25. package/dist/commands/resident.js +2 -1
  26. package/dist/commands/resident.js.map +1 -1
  27. package/dist/commands/run-flags.d.ts +7 -7
  28. package/dist/commands/run-flags.js +4 -4
  29. package/dist/commands/run-stream.d.ts +16 -12
  30. package/dist/commands/run-stream.d.ts.map +1 -1
  31. package/dist/commands/run-stream.js +142 -98
  32. package/dist/commands/run-stream.js.map +1 -1
  33. package/dist/commands/run.d.ts +6 -4
  34. package/dist/commands/run.d.ts.map +1 -1
  35. package/dist/commands/run.js +129 -45
  36. package/dist/commands/run.js.map +1 -1
  37. package/dist/commands/serve.js +2 -2
  38. package/dist/commands/serve.js.map +1 -1
  39. package/dist/commands/state.js +8 -8
  40. package/dist/commands/state.js.map +1 -1
  41. package/dist/config/load.d.ts +1 -1
  42. package/dist/config/load.d.ts.map +1 -1
  43. package/dist/config/load.js +17 -5
  44. package/dist/config/load.js.map +1 -1
  45. package/dist/config/run-limits.d.ts +20 -8
  46. package/dist/config/run-limits.d.ts.map +1 -1
  47. package/dist/config/run-limits.js +28 -46
  48. package/dist/config/run-limits.js.map +1 -1
  49. package/dist/config/schema.d.ts +44 -19
  50. package/dist/config/schema.d.ts.map +1 -1
  51. package/dist/config/schema.js.map +1 -1
  52. package/dist/config/tool-result-screens.js +1 -1
  53. package/dist/context/directories.d.ts +37 -0
  54. package/dist/context/directories.d.ts.map +1 -0
  55. package/dist/context/directories.js +69 -0
  56. package/dist/context/directories.js.map +1 -0
  57. package/dist/context/environment.d.ts +55 -0
  58. package/dist/context/environment.d.ts.map +1 -1
  59. package/dist/context/environment.js +96 -0
  60. package/dist/context/environment.js.map +1 -1
  61. package/dist/context/project-tracker.d.ts +3 -3
  62. package/dist/context/project-tracker.d.ts.map +1 -1
  63. package/dist/context/project-tracker.js +3 -3
  64. package/dist/context/project-tracker.js.map +1 -1
  65. package/dist/context/project.d.ts +1 -1
  66. package/dist/context/project.js +1 -1
  67. package/dist/context/sandbox.d.ts +22 -3
  68. package/dist/context/sandbox.d.ts.map +1 -1
  69. package/dist/context/sandbox.js +42 -4
  70. package/dist/context/sandbox.js.map +1 -1
  71. package/dist/doctor/checks/chain.d.ts +1 -1
  72. package/dist/doctor/checks/chain.js +5 -5
  73. package/dist/doctor/checks/chain.js.map +1 -1
  74. package/dist/doctor/checks/invariants.d.ts +2 -2
  75. package/dist/doctor/checks/invariants.js +5 -5
  76. package/dist/doctor/checks/invariants.js.map +1 -1
  77. package/dist/doctor/checks/logging.js +1 -1
  78. package/dist/doctor/checks/logging.js.map +1 -1
  79. package/dist/doctor/checks/sandbox.js +1 -1
  80. package/dist/doctor/checks/sandbox.js.map +1 -1
  81. package/dist/doctor/checks/session-export.d.ts +1 -1
  82. package/dist/doctor/checks/session-export.js +2 -2
  83. package/dist/doctor/checks/session-export.js.map +1 -1
  84. package/dist/doctor/checks/telemetry.d.ts +1 -1
  85. package/dist/doctor/checks/telemetry.js +1 -1
  86. package/dist/doctor/registry.js +2 -2
  87. package/dist/doctor/registry.js.map +1 -1
  88. package/dist/exit-codes.d.ts +2 -2
  89. package/dist/exit-codes.js +2 -2
  90. package/dist/integrations/mcp/servers.d.ts +1 -1
  91. package/dist/integrations/providers/chain-capabilities.d.ts +6 -6
  92. package/dist/integrations/providers/chain-capabilities.js +8 -8
  93. package/dist/integrations/providers/chain-capabilities.js.map +1 -1
  94. package/dist/integrations/providers/index.d.ts +1 -1
  95. package/dist/integrations/providers/index.d.ts.map +1 -1
  96. package/dist/integrations/providers/index.js +1 -1
  97. package/dist/integrations/providers/index.js.map +1 -1
  98. package/dist/integrations/providers/register.d.ts +1 -1
  99. package/dist/integrations/providers/register.js +1 -1
  100. package/dist/integrations/providers/registry.d.ts +74 -1
  101. package/dist/integrations/providers/registry.d.ts.map +1 -1
  102. package/dist/integrations/providers/registry.js +75 -0
  103. package/dist/integrations/providers/registry.js.map +1 -1
  104. package/dist/integrations/resident/inspection.d.ts +4 -1
  105. package/dist/integrations/resident/inspection.d.ts.map +1 -1
  106. package/dist/integrations/resident/inspection.js +53 -34
  107. package/dist/integrations/resident/inspection.js.map +1 -1
  108. package/dist/integrations/resident/learning-storage.d.ts +7 -2
  109. package/dist/integrations/resident/learning-storage.d.ts.map +1 -1
  110. package/dist/integrations/resident/learning-storage.js +12 -8
  111. package/dist/integrations/resident/learning-storage.js.map +1 -1
  112. package/dist/integrations/resident/runner-store.d.ts +1 -1
  113. package/dist/integrations/resident/runner-store.d.ts.map +1 -1
  114. package/dist/integrations/resident/runner-store.js +5 -2
  115. package/dist/integrations/resident/runner-store.js.map +1 -1
  116. package/dist/integrations/resident/runner-worker.js +1 -0
  117. package/dist/integrations/resident/runner-worker.js.map +1 -1
  118. package/dist/integrations/resident/session-log-reads.d.ts +37 -0
  119. package/dist/integrations/resident/session-log-reads.d.ts.map +1 -0
  120. package/dist/integrations/resident/session-log-reads.js +125 -0
  121. package/dist/integrations/resident/session-log-reads.js.map +1 -0
  122. package/dist/integrations/resident/session-step.d.ts +2 -0
  123. package/dist/integrations/resident/session-step.d.ts.map +1 -1
  124. package/dist/integrations/resident/session-step.js +15 -13
  125. package/dist/integrations/resident/session-step.js.map +1 -1
  126. package/dist/integrations/resident/storage.d.ts +23 -1
  127. package/dist/integrations/resident/storage.d.ts.map +1 -1
  128. package/dist/integrations/resident/storage.js +60 -17
  129. package/dist/integrations/resident/storage.js.map +1 -1
  130. package/dist/integrations/resident/tool-evidence.d.ts +4 -2
  131. package/dist/integrations/resident/tool-evidence.d.ts.map +1 -1
  132. package/dist/integrations/resident/tool-evidence.js +36 -30
  133. package/dist/integrations/resident/tool-evidence.js.map +1 -1
  134. package/dist/integrations/resident/verification.d.ts +2 -2
  135. package/dist/integrations/resident/verification.d.ts.map +1 -1
  136. package/dist/integrations/resident/verification.js +4 -4
  137. package/dist/integrations/resident/verification.js.map +1 -1
  138. package/dist/integrations/sessions/checkpoint-view.d.ts +13 -0
  139. package/dist/integrations/sessions/checkpoint-view.d.ts.map +1 -0
  140. package/dist/integrations/sessions/checkpoint-view.js +48 -0
  141. package/dist/integrations/sessions/checkpoint-view.js.map +1 -0
  142. package/dist/integrations/sessions/context-inventory.js +1 -1
  143. package/dist/integrations/sessions/context-inventory.js.map +1 -1
  144. package/dist/integrations/sessions/conversation-search.d.ts +16 -20
  145. package/dist/integrations/sessions/conversation-search.d.ts.map +1 -1
  146. package/dist/integrations/sessions/conversation-search.js +305 -731
  147. package/dist/integrations/sessions/conversation-search.js.map +1 -1
  148. package/dist/integrations/sessions/evidence-page-validation.d.ts +4 -4
  149. package/dist/integrations/sessions/evidence-page-validation.d.ts.map +1 -1
  150. package/dist/integrations/sessions/evidence-page-validation.js +1 -1
  151. package/dist/integrations/sessions/evidence-page-validation.js.map +1 -1
  152. package/dist/integrations/sessions/evidence-recall.d.ts +40 -2
  153. package/dist/integrations/sessions/evidence-recall.d.ts.map +1 -1
  154. package/dist/integrations/sessions/evidence-recall.js +41 -97
  155. package/dist/integrations/sessions/evidence-recall.js.map +1 -1
  156. package/dist/integrations/sessions/store.d.ts +153 -85
  157. package/dist/integrations/sessions/store.d.ts.map +1 -1
  158. package/dist/integrations/sessions/store.js +414 -484
  159. package/dist/integrations/sessions/store.js.map +1 -1
  160. package/dist/integrations/sessions/task-context.d.ts +15 -2
  161. package/dist/integrations/sessions/task-context.d.ts.map +1 -1
  162. package/dist/integrations/sessions/task-context.js +34 -11
  163. package/dist/integrations/sessions/task-context.js.map +1 -1
  164. package/dist/integrations/sessions/transcript-export.d.ts +13 -6
  165. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  166. package/dist/integrations/sessions/transcript-export.js +148 -263
  167. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  168. package/dist/integrations/state/home.d.ts +4 -16
  169. package/dist/integrations/state/home.d.ts.map +1 -1
  170. package/dist/integrations/state/home.js +6 -42
  171. package/dist/integrations/state/home.js.map +1 -1
  172. package/dist/integrations/state/project.d.ts +6 -7
  173. package/dist/integrations/state/project.d.ts.map +1 -1
  174. package/dist/integrations/state/project.js +7 -9
  175. package/dist/integrations/state/project.js.map +1 -1
  176. package/dist/integrations/state/report.d.ts +27 -41
  177. package/dist/integrations/state/report.d.ts.map +1 -1
  178. package/dist/integrations/state/report.js +172 -464
  179. package/dist/integrations/state/report.js.map +1 -1
  180. package/dist/integrations/state/retention.d.ts +18 -0
  181. package/dist/integrations/state/retention.d.ts.map +1 -0
  182. package/dist/integrations/state/retention.js +18 -0
  183. package/dist/integrations/state/retention.js.map +1 -0
  184. package/dist/integrations/subagents/activity.d.ts +36 -31
  185. package/dist/integrations/subagents/activity.d.ts.map +1 -1
  186. package/dist/integrations/subagents/activity.js +27 -23
  187. package/dist/integrations/subagents/activity.js.map +1 -1
  188. package/dist/integrations/subagents/batches.d.ts +91 -0
  189. package/dist/integrations/subagents/batches.d.ts.map +1 -0
  190. package/dist/integrations/subagents/batches.js +172 -0
  191. package/dist/integrations/subagents/batches.js.map +1 -0
  192. package/dist/integrations/subagents/parent.d.ts +26 -11
  193. package/dist/integrations/subagents/parent.d.ts.map +1 -1
  194. package/dist/integrations/subagents/parent.js +13 -28
  195. package/dist/integrations/subagents/parent.js.map +1 -1
  196. package/dist/integrations/subagents/policy.d.ts +2 -2
  197. package/dist/integrations/subagents/policy.d.ts.map +1 -1
  198. package/dist/integrations/subagents/policy.js +2 -2
  199. package/dist/integrations/subagents/policy.js.map +1 -1
  200. package/dist/integrations/subagents/replay.d.ts +91 -26
  201. package/dist/integrations/subagents/replay.d.ts.map +1 -1
  202. package/dist/integrations/subagents/replay.js +216 -114
  203. package/dist/integrations/subagents/replay.js.map +1 -1
  204. package/dist/integrations/subagents/runtime.d.ts +50 -26
  205. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  206. package/dist/integrations/subagents/runtime.js +132 -126
  207. package/dist/integrations/subagents/runtime.js.map +1 -1
  208. package/dist/integrations/subagents/saved-agents.d.ts +57 -0
  209. package/dist/integrations/subagents/saved-agents.d.ts.map +1 -0
  210. package/dist/integrations/subagents/saved-agents.js +102 -0
  211. package/dist/integrations/subagents/saved-agents.js.map +1 -0
  212. package/dist/integrations/subagents/slash.d.ts +55 -0
  213. package/dist/integrations/subagents/slash.d.ts.map +1 -0
  214. package/dist/integrations/subagents/slash.js +45 -0
  215. package/dist/integrations/subagents/slash.js.map +1 -0
  216. package/dist/integrations/telemetry/session-export.d.ts +6 -6
  217. package/dist/integrations/telemetry/session-export.d.ts.map +1 -1
  218. package/dist/integrations/telemetry/session-export.js +2 -2
  219. package/dist/memory/presentation.d.ts +11 -0
  220. package/dist/memory/presentation.d.ts.map +1 -1
  221. package/dist/memory/presentation.js +25 -0
  222. package/dist/memory/presentation.js.map +1 -1
  223. package/dist/memory/store.d.ts +13 -7
  224. package/dist/memory/store.d.ts.map +1 -1
  225. package/dist/memory/store.js +37 -11
  226. package/dist/memory/store.js.map +1 -1
  227. package/dist/memory/typed.d.ts +116 -0
  228. package/dist/memory/typed.d.ts.map +1 -0
  229. package/dist/memory/typed.js +336 -0
  230. package/dist/memory/typed.js.map +1 -0
  231. package/dist/permissions/checks.d.ts +1 -1
  232. package/dist/permissions/checks.js +1 -1
  233. package/dist/permissions/headless-trust.d.ts +2 -2
  234. package/dist/permissions/headless-trust.d.ts.map +1 -1
  235. package/dist/permissions/headless-trust.js +3 -3
  236. package/dist/permissions/headless-trust.js.map +1 -1
  237. package/dist/permissions/mode.d.ts +4 -4
  238. package/dist/permissions/mode.js +1 -1
  239. package/dist/permissions/rules.d.ts +1 -1
  240. package/dist/permissions/rules.js +1 -1
  241. package/dist/termination.d.ts +75 -0
  242. package/dist/termination.d.ts.map +1 -0
  243. package/dist/termination.js +143 -0
  244. package/dist/termination.js.map +1 -0
  245. package/dist/tui/AgentExplorer.d.ts +2 -2
  246. package/dist/tui/AgentExplorer.js +4 -4
  247. package/dist/tui/AgentExplorer.js.map +1 -1
  248. package/dist/tui/App.d.ts +9 -1
  249. package/dist/tui/App.d.ts.map +1 -1
  250. package/dist/tui/App.js +458 -304
  251. package/dist/tui/App.js.map +1 -1
  252. package/dist/tui/LiveActivity.d.ts +1 -1
  253. package/dist/tui/LiveActivity.d.ts.map +1 -1
  254. package/dist/tui/PermissionOverlay.d.ts +8 -0
  255. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  256. package/dist/tui/PermissionOverlay.js +25 -2
  257. package/dist/tui/PermissionOverlay.js.map +1 -1
  258. package/dist/tui/Picker.d.ts +14 -1
  259. package/dist/tui/Picker.d.ts.map +1 -1
  260. package/dist/tui/Picker.js +598 -132
  261. package/dist/tui/Picker.js.map +1 -1
  262. package/dist/tui/agent.d.ts +208 -132
  263. package/dist/tui/agent.d.ts.map +1 -1
  264. package/dist/tui/agent.js +523 -313
  265. package/dist/tui/agent.js.map +1 -1
  266. package/dist/tui/conversation-evidence-view.js +3 -3
  267. package/dist/tui/conversation-evidence-view.js.map +1 -1
  268. package/dist/tui/conversation-history.d.ts +4 -21
  269. package/dist/tui/conversation-history.d.ts.map +1 -1
  270. package/dist/tui/conversation-history.js +4 -28
  271. package/dist/tui/conversation-history.js.map +1 -1
  272. package/dist/tui/index.d.ts.map +1 -1
  273. package/dist/tui/index.js +27 -1
  274. package/dist/tui/index.js.map +1 -1
  275. package/dist/tui/log-pane.js +1 -1
  276. package/dist/tui/markdownParser.d.ts +1 -1
  277. package/dist/tui/markdownParser.js +1 -1
  278. package/dist/tui/model-choices.d.ts.map +1 -1
  279. package/dist/tui/model-choices.js +20 -1
  280. package/dist/tui/model-choices.js.map +1 -1
  281. package/dist/tui/permission-review.d.ts +16 -0
  282. package/dist/tui/permission-review.d.ts.map +1 -1
  283. package/dist/tui/permission-review.js +25 -2
  284. package/dist/tui/permission-review.js.map +1 -1
  285. package/dist/tui/provider-list.d.ts +206 -41
  286. package/dist/tui/provider-list.d.ts.map +1 -1
  287. package/dist/tui/provider-list.js +248 -52
  288. package/dist/tui/provider-list.js.map +1 -1
  289. package/dist/tui/slashCommands.d.ts +29 -40
  290. package/dist/tui/slashCommands.d.ts.map +1 -1
  291. package/dist/tui/slashCommands.js +70 -61
  292. package/dist/tui/slashCommands.js.map +1 -1
  293. package/dist/tui/terminal-choice-text.d.ts +22 -0
  294. package/dist/tui/terminal-choice-text.d.ts.map +1 -1
  295. package/dist/tui/terminal-choice-text.js +81 -0
  296. package/dist/tui/terminal-choice-text.js.map +1 -1
  297. package/dist/tui/{run-interruption.d.ts → turn-interruption.d.ts} +4 -4
  298. package/dist/tui/turn-interruption.d.ts.map +1 -0
  299. package/dist/tui/{run-interruption.js → turn-interruption.js} +20 -20
  300. package/dist/tui/turn-interruption.js.map +1 -0
  301. package/dist/tui/turn-limits-settings.d.ts +39 -0
  302. package/dist/tui/turn-limits-settings.d.ts.map +1 -0
  303. package/dist/tui/{run-limits-settings.js → turn-limits-settings.js} +18 -18
  304. package/dist/tui/turn-limits-settings.js.map +1 -0
  305. package/dist/tui/types.d.ts +3 -3
  306. package/dist/tui/types.d.ts.map +1 -1
  307. package/dist/user-commands/store.d.ts +1 -1
  308. package/dist/user-commands/store.js +1 -1
  309. package/package.json +8 -8
  310. package/dist/integrations/sessions/compaction-evidence.d.ts +0 -5
  311. package/dist/integrations/sessions/compaction-evidence.d.ts.map +0 -1
  312. package/dist/integrations/sessions/compaction-evidence.js +0 -59
  313. package/dist/integrations/sessions/compaction-evidence.js.map +0 -1
  314. package/dist/integrations/sessions/database.d.ts +0 -5
  315. package/dist/integrations/sessions/database.d.ts.map +0 -1
  316. package/dist/integrations/sessions/database.js +0 -34
  317. package/dist/integrations/sessions/database.js.map +0 -1
  318. package/dist/integrations/sessions/paths.d.ts +0 -7
  319. package/dist/integrations/sessions/paths.d.ts.map +0 -1
  320. package/dist/integrations/sessions/paths.js +0 -14
  321. package/dist/integrations/sessions/paths.js.map +0 -1
  322. package/dist/integrations/sessions/run-discovery.d.ts +0 -24
  323. package/dist/integrations/sessions/run-discovery.d.ts.map +0 -1
  324. package/dist/integrations/sessions/run-discovery.js +0 -142
  325. package/dist/integrations/sessions/run-discovery.js.map +0 -1
  326. package/dist/integrations/sessions/turn-evidence.d.ts +0 -129
  327. package/dist/integrations/sessions/turn-evidence.d.ts.map +0 -1
  328. package/dist/integrations/sessions/turn-evidence.js +0 -517
  329. package/dist/integrations/sessions/turn-evidence.js.map +0 -1
  330. package/dist/integrations/subagents/history.d.ts +0 -26
  331. package/dist/integrations/subagents/history.d.ts.map +0 -1
  332. package/dist/integrations/subagents/history.js +0 -76
  333. package/dist/integrations/subagents/history.js.map +0 -1
  334. package/dist/integrations/subagents/runs.d.ts +0 -72
  335. package/dist/integrations/subagents/runs.d.ts.map +0 -1
  336. package/dist/integrations/subagents/runs.js +0 -155
  337. package/dist/integrations/subagents/runs.js.map +0 -1
  338. package/dist/tui/run-interruption.d.ts.map +0 -1
  339. package/dist/tui/run-interruption.js.map +0 -1
  340. package/dist/tui/run-limits-settings.d.ts +0 -39
  341. package/dist/tui/run-limits-settings.d.ts.map +0 -1
  342. package/dist/tui/run-limits-settings.js.map +0 -1
@@ -8,19 +8,121 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  * live agent session lives in `agent.ts`.
9
9
  */
10
10
  import { Box, Text, useInput, useWindowSize } from 'ink';
11
- import { useCallback, useEffect, useRef, useState } from 'react';
11
+ import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
12
12
  import { canSelectModel } from '../integrations/providers/access.js';
13
13
  import { ALL_PROVIDER_IDS, PROVIDER_REGISTRY, signedInSubscriptionProviders, unsupportedProviderMessage, } from '../integrations/providers/index.js';
14
14
  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';
19
- import { moveSelection, selectionWindow } from './selection-window.js';
20
- import { choiceDisplayWidth, eraseLastChoiceGrapheme, truncateChoiceText, } from './terminal-choice-text.js';
18
+ import { credentialNeed, initialProviderRow, pathProviderId, providerListRows, rowCredentialNeed, rowHasProvider, rowIndexOfProvider, rowIsUsable, rowNeedsChoice, typedCredentialEntry, } from './provider-list.js';
19
+ import { moveSelection, selectionWindow, } from './selection-window.js';
20
+ import { capChoiceLines, choiceDisplayWidth, eraseLastChoiceGrapheme, truncateChoiceText, wrapChoiceText, } from './terminal-choice-text.js';
21
21
  import { terminalDisplayText } from './terminal-display.js';
22
22
  import { theme } from './theme.js';
23
23
  import { useSelectionIndex } from './use-selection-index.js';
24
+ /**
25
+ * Columns this screen's text area has, given the terminal's width.
26
+ *
27
+ * Derived rather than written down, because the number this replaces was a
28
+ * literal that counted two of the three things that take columns and forgot the
29
+ * third. In order:
30
+ *
31
+ * - `HOST_INSET_X` — the app draws every screen inside a box with one column
32
+ * of padding on each side (`App.tsx`'s root), so this picker's own box starts
33
+ * one column in and ends one column short of the terminal.
34
+ * - `BORDER_X` — this box's round border, one column per side.
35
+ * - `BOX_PADDING_X` — this box's `paddingX={1}`, one column per side.
36
+ *
37
+ * The old arithmetic was `columns - 4`: the border and the padding, minus the
38
+ * host's inset. At 60 columns that budgeted 56 columns of text for an area of
39
+ * 54 — measured from a 60-column capture, where the two borders sit at columns
40
+ * 1 and 58, leaving 56 between them and 54 inside the padding. Every row the
41
+ * screen measured as fitting therefore wrapped, and the text it wrapped was the
42
+ * row the cursor was on.
43
+ */
44
+ const HOST_INSET_X = 2;
45
+ const BORDER_X = 2;
46
+ const BOX_PADDING_X = 2;
47
+ /** Columns a row of this screen's content may occupy. */
48
+ export function pickerContentWidth(columns) {
49
+ return Math.max(1, columns - HOST_INSET_X - BORDER_X - BOX_PADDING_X);
50
+ }
51
+ /**
52
+ * Columns a row spends before its source: the marker, the number and the
53
+ * vendor's name, each written over its own width.
54
+ *
55
+ * Derived from the three, because 32 was written down here once and was the
56
+ * width of the BROKEN row — the one whose marker had lost the space beside it.
57
+ * A source measured against a row one column narrower than it is a source that
58
+ * wraps in the renderer after the screen has counted it as one line, which is
59
+ * the defect this whole window exists to prevent.
60
+ */
61
+ const ROW_MARKER_WIDTH = 2;
62
+ const ROW_NUMBER_WIDTH = 3;
63
+ const ROW_LABEL_WIDTH = 28;
64
+ const ROW_PREFIX_WIDTH = ROW_MARKER_WIDTH + ROW_NUMBER_WIDTH + ROW_LABEL_WIDTH;
65
+ /** What a wrapped source line is indented by, so it lines up under the name. */
66
+ const ROW_INDENT = ' '.repeat(ROW_PREFIX_WIDTH);
67
+ /**
68
+ * Lines a row's source column may take before the rest is cut.
69
+ *
70
+ * Three is what a variable name needs at the width this was measured against
71
+ * (54 columns leaves 22 for the source, so `env · OPENROUTER_API_KEY` wraps to
72
+ * two). It is a cap rather than a measurement because a row's height is part of
73
+ * the box's height: without one, a forty-column terminal could make a single
74
+ * source a dozen lines and no window could keep the header on screen.
75
+ */
76
+ const MAX_SOURCE_LINES = 3;
77
+ /** The words above the not-detected block, which the row window reserves lines for. */
78
+ const NOT_DETECTED_HEADING = 'Not detected — enter a credential to use these:';
79
+ /**
80
+ * The line above the box that is not the box's.
81
+ *
82
+ * Ink draws this screen below whatever the app has already written — the boot
83
+ * banner, on the turn that shows this picker first — and a terminal keeps the
84
+ * last `rows` lines of all of it. A box that fills the terminal exactly
85
+ * therefore loses its own top border, and under that border is the notice the
86
+ * screen exists to deliver.
87
+ */
88
+ const TRANSCRIPT_LINE_ABOVE = 1;
89
+ /** The mark a row carries when it is the provider in force. */
90
+ const CURRENT_MARK = ' ← current';
91
+ /**
92
+ * Columns a row's source must keep before it shares its line with that mark.
93
+ *
94
+ * Twenty is the shortest this column can be and still be read: an environment
95
+ * variable name (`DEEPSEEK_API_KEY` is sixteen, with the word `needs` before it
96
+ * and the `←` mark is twelve columns of its own. Below twenty the mark moves
97
+ * down a line rather than cutting the answer in half.
98
+ */
99
+ const MIN_SOURCE_COLUMNS = 20;
100
+ /** What `s` does, printed above the list on the screens that offer it. */
101
+ const SETUP_HINT = 's provider setup · check installations and access';
102
+ /**
103
+ * What the screen that found nothing says, and the reason it says it.
104
+ *
105
+ * Here rather than inline because the row window counts these lines before it
106
+ * draws a single row: they are the operator's only way off that screen, so they
107
+ * are reserved first and a row is drawn with what is left over.
108
+ */
109
+ const EMPTY_SCREEN_INTRO = 'namzu scans these sources, in order, for an LLM credential:';
110
+ const EMPTY_SCREEN_HINTS = {
111
+ signIn: 'to sign in with a subscription — no API key, and namzu keeps it for next time.',
112
+ paste: 'to paste a credential now and use it for this session.',
113
+ durable: 'You can also set one of the env vars above (or start a local server) and restart.',
114
+ };
115
+ /** The sources the empty screen lists — the same set the summary line names. */
116
+ function emptyScreenSourceLines() {
117
+ return [
118
+ ` · existing ${subscriptionProviders()
119
+ .map((entry) => entry.label)
120
+ .join(' and ')} sessions on this device`,
121
+ ' · subscriptions signed in to from namzu (~/.namzu/credentials.json)',
122
+ ' · env vars / API keys (optional alternatives: ANTHROPIC_API_KEY, OPENAI_API_KEY, …)',
123
+ ' · local servers (Ollama localhost:11434, LM Studio localhost:1234)',
124
+ ];
125
+ }
24
126
  function subscriptionProviders() {
25
127
  return ALL_PROVIDER_IDS.map((id) => PROVIDER_REGISTRY[id]).filter((entry) => entry.subscriptionLogin !== undefined);
26
128
  }
@@ -48,26 +150,25 @@ function signInChoiceIndex(choices, provider) {
48
150
  /**
49
151
  * The provider `k` opens entry for.
50
152
  *
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
153
+ * The highlighted row's own provider whenever the vendor takes a typed
154
+ * credential. This screen used to answer the question from the registry
155
+ * instead: the saved provider if the picker was open because of one, and
156
+ * otherwise the first key-capable entry, which is a provider the operator may
157
+ * not have been looking at. A list you can move through is a list the key
56
158
  * beside it has to follow.
57
159
  *
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.
160
+ * A row whose vendor takes none — a local server — falls through to the saved
161
+ * provider this picker was opened for: that route is why the notice above says
162
+ * `k`, and it must keep working when the cursor happens to sit on a row that
163
+ * cannot use it.
62
164
  *
63
165
  * Returns null when there is nothing here to enter a credential for, which the
64
166
  * caller says out loud rather than presenting a field that leads nowhere.
65
167
  */
66
168
  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;
169
+ const onThisRow = typedCredentialEntry(row);
170
+ if (onThisRow)
171
+ return onThisRow;
71
172
  if (keyEntryFor) {
72
173
  const entry = PROVIDER_REGISTRY[keyEntryFor];
73
174
  if (entry?.acceptsTypedCredential)
@@ -75,11 +176,7 @@ function keyEntryTarget(row, keyEntryFor) {
75
176
  }
76
177
  return null;
77
178
  }
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;
81
- }
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, }) {
179
+ export function Picker({ detected, currentProvider, selectionKind = 'provider-and-model', currentModel, initialView = 'providers', onSubmit, onCancel, onSetup, describeModels = describeProviderModels, onCredential, onLogin, onLoginComplete, verify = verifyCredential, keyEntryFor, notice, cancelExits = false, }) {
83
180
  /**
84
181
  * The one foreign operation still allowed to publish into this picker.
85
182
  *
@@ -138,6 +235,11 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
138
235
  // of a three-row sign-in list.
139
236
  const initialSelection = initialView === 'subscriptions' ? 0 : initialProviderRow(rows, currentProvider, keyEntryFor);
140
237
  const { selection: cursor, selectionRef: cursorRef, setSelection: setCursor, } = useSelectionIndex(initialSelection);
238
+ // How wide this screen's text is, and how many lines its notice takes, both
239
+ // of which the row window is sized against — see the list below.
240
+ const terminal = useWindowSize();
241
+ const contentWidth = pickerContentWidth(terminal.columns ?? 80);
242
+ const noticeLines = notice ? wrapChoiceText(notice, contentWidth).length : 0;
141
243
  const [errorHint, setErrorHint] = useState(null);
142
244
  // The ref makes a pasted query followed immediately by Enter use the new
143
245
  // filtered list, even before React has drawn another frame. Null keeps the
@@ -162,6 +264,15 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
162
264
  // provider, because nothing on this screen changed it — so a credential typed
163
265
  // for a saved provider would have been built for a different one.
164
266
  const [keyEntry, setKeyEntry] = useState(null);
267
+ // The vendor whose ways in are being chosen, while that screen is up.
268
+ //
269
+ // Held as the vendor's id and resolved against `rows` on every render, not as
270
+ // a row object: `rows` is derived from the props on each render, and a stored
271
+ // row would be the list as it looked on the keystroke that opened this screen.
272
+ // The screen is only ever reached from a row, so the lookup cannot come back
273
+ // empty while it is up.
274
+ const [pathVendor, setPathVendor] = useState(null);
275
+ const pathRow = pathVendor === null ? null : (rows.find((row) => row.vendor === pathVendor) ?? null);
165
276
  const openModels = useCallback((current, returnToProviders = true) => {
166
277
  const operation = beginOperation();
167
278
  modelQueryRef.current = null;
@@ -294,6 +405,81 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
294
405
  });
295
406
  }
296
407
  };
408
+ /**
409
+ * Start a subscription sign-in for one provider.
410
+ *
411
+ * Written once and reached from two places — the sign-in screen's own Enter,
412
+ * and the sign-in path inside a vendor row — because two copies of this would
413
+ * be two places for the device-code wording, the operation's ownership and the
414
+ * failure sentence to drift apart. It is one operation either way, so an esc
415
+ * from either leaves nothing running behind the screen.
416
+ */
417
+ const startSignIn = (entry) => {
418
+ if (!entry.subscriptionLogin)
419
+ return;
420
+ if (!onLogin) {
421
+ setErrorHint('Subscription sign-in is not available on this screen.');
422
+ return;
423
+ }
424
+ const operation = beginOperation();
425
+ setPathVendor(null);
426
+ setLoginEntry({ entry, value: '', status: 'starting' });
427
+ void onLogin(entry.id, operation.controller.signal)
428
+ .then((disposition) => {
429
+ if (!ownsOperation(operation))
430
+ return;
431
+ if (disposition === 'awaiting-input') {
432
+ setLoginEntry({
433
+ entry,
434
+ value: '',
435
+ status: 'typing',
436
+ });
437
+ return;
438
+ }
439
+ finishOperation(operation);
440
+ setLoginEntry(null);
441
+ })
442
+ .catch((error) => {
443
+ if (!ownsOperation(operation))
444
+ return;
445
+ finishOperation(operation);
446
+ setLoginEntry(null);
447
+ setErrorHint(`Could not start sign-in: ${error instanceof Error ? error.message : String(error)}`);
448
+ });
449
+ };
450
+ /**
451
+ * Follow one way in to the flow that already exists for it.
452
+ *
453
+ * The three arms are the three flows this screen already had; nothing here
454
+ * builds a credential. A detected path goes on to the model step exactly as
455
+ * Enter on a detected row always has. A credential path opens the paste field
456
+ * for its provider, so the value still travels `setKeyEntry` → `acceptKey` →
457
+ * the session credential. A sign-in path starts the operation `l` starts.
458
+ */
459
+ const takePath = (path) => {
460
+ if (path.kind === 'detected') {
461
+ const current = path.detected;
462
+ if (selectionKind === 'signed-in-subscription') {
463
+ const operation = beginOperation();
464
+ onSubmit({ provider: current.entry.id }, operation.controller.signal);
465
+ return;
466
+ }
467
+ setPathVendor(null);
468
+ openModels(current);
469
+ return;
470
+ }
471
+ if (path.kind === 'credential') {
472
+ if (!onCredential) {
473
+ setErrorHint(`No credential can be entered for ${path.entry.label} on this screen.`);
474
+ return;
475
+ }
476
+ invalidateOperation();
477
+ setPathVendor(null);
478
+ setKeyEntry({ entry: path.entry, value: '', status: 'typing' });
479
+ return;
480
+ }
481
+ startSignIn(path.entry);
482
+ };
297
483
  useInput((input, key) => {
298
484
  if (loginEntry) {
299
485
  if (key.escape) {
@@ -345,6 +531,43 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
345
531
  }
346
532
  return;
347
533
  }
534
+ // One vendor's ways in. Modal like the two screens above: while it is up it
535
+ // owns the keyboard, so `k`, `l` and `s` cannot act on a list that is not
536
+ // the one on screen. Escape returns to the row that opened it rather than
537
+ // to the top, because the cursor here is an index into this vendor's paths
538
+ // and would otherwise be read as a position in the list.
539
+ if (pathRow) {
540
+ if (key.escape) {
541
+ invalidateOperation();
542
+ setPathVendor(null);
543
+ setCursor(Math.max(0, rows.findIndex((row) => row.vendor === pathRow.vendor)));
544
+ return;
545
+ }
546
+ if (key.home || key.end || key.pageUp || key.pageDown) {
547
+ setCursor((current) => moveSelection(current, pathRow.paths.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
548
+ return;
549
+ }
550
+ if (key.upArrow) {
551
+ setCursor((current) => moveSelection(current, pathRow.paths.length, 'previous'));
552
+ return;
553
+ }
554
+ if (key.downArrow) {
555
+ setCursor((current) => moveSelection(current, pathRow.paths.length, 'next'));
556
+ return;
557
+ }
558
+ if (key.return) {
559
+ const chosen = pathRow.paths[cursorRef.current];
560
+ if (!chosen)
561
+ return;
562
+ takePath(chosen);
563
+ return;
564
+ }
565
+ const selected = Number.parseInt(input, 10);
566
+ if (Number.isFinite(selected) && selected >= 1 && selected <= pathRow.paths.length) {
567
+ setCursor(selected - 1);
568
+ }
569
+ return;
570
+ }
348
571
  // `k` opens credential entry for the highlighted row — see
349
572
  // `keyEntryTarget` for which provider that resolves to when the row takes
350
573
  // no typed credential. The empty screen has always offered it (there,
@@ -397,7 +620,7 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
397
620
  }
398
621
  else {
399
622
  setErrorHint(highlighted
400
- ? `${rowLabel(highlighted)} does not take a typed credential — choose a provider that does.`
623
+ ? `${highlighted.label} does not take a typed credential — choose a provider that does.`
401
624
  : 'No provider here takes a typed credential.');
402
625
  }
403
626
  return;
@@ -405,7 +628,7 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
405
628
  if (modelPhase &&
406
629
  (key.leftArrow || (modelQueryRef.current === null && (input === 'p' || input === 'P')))) {
407
630
  invalidateOperation();
408
- setCursor(Math.max(0, rows.findIndex((row) => rowProviderId(row) === modelPhase.provider.entry.id)));
631
+ setCursor(Math.max(0, rowIndexOfProvider(rows, modelPhase.provider.entry.id)));
409
632
  setModelPhase(null);
410
633
  return;
411
634
  }
@@ -424,7 +647,7 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
424
647
  onCancel();
425
648
  return;
426
649
  }
427
- setCursor(Math.max(0, rows.findIndex((row) => rowProviderId(row) === modelPhase.provider.entry.id)));
650
+ setCursor(Math.max(0, rowIndexOfProvider(rows, modelPhase.provider.entry.id)));
428
651
  setModelPhase(null);
429
652
  return;
430
653
  }
@@ -455,36 +678,7 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
455
678
  onSubmit({ provider: chosen.detected.entry.id }, operation.controller.signal);
456
679
  return;
457
680
  }
458
- if (!chosen.entry.subscriptionLogin)
459
- return;
460
- if (!onLogin) {
461
- setErrorHint('Subscription sign-in is not available on this screen.');
462
- return;
463
- }
464
- const operation = beginOperation();
465
- setLoginEntry({ entry: chosen.entry, value: '', status: 'starting' });
466
- void onLogin(chosen.entry.id, operation.controller.signal)
467
- .then((disposition) => {
468
- if (!ownsOperation(operation))
469
- return;
470
- if (disposition === 'awaiting-input') {
471
- setLoginEntry({
472
- entry: chosen.entry,
473
- value: '',
474
- status: 'typing',
475
- });
476
- return;
477
- }
478
- finishOperation(operation);
479
- setLoginEntry(null);
480
- })
481
- .catch((error) => {
482
- if (!ownsOperation(operation))
483
- return;
484
- finishOperation(operation);
485
- setLoginEntry(null);
486
- setErrorHint(`Could not start sign-in: ${error instanceof Error ? error.message : String(error)}`);
487
- });
681
+ startSignIn(chosen.entry);
488
682
  return;
489
683
  }
490
684
  const selected = Number.parseInt(input, 10);
@@ -571,39 +765,30 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
571
765
  setErrorHint('No provider available.');
572
766
  return;
573
767
  }
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' });
768
+ // Nothing on this row can be constructed, so there is no session to open
769
+ // and no catalogue to ask. The row stays visible on purpose — see the
770
+ // list below — so this is the only place that can decline it, and
771
+ // declining with the reason is the point: accepting would write the
772
+ // choice to preferences and hand the operator a session that refuses to
773
+ // start.
774
+ if (!rowIsUsable(row)) {
775
+ setErrorHint(unsupportedProviderMessage(row.detected[0]?.entry.id ?? row.vendor));
586
776
  return;
587
777
  }
588
- const current = row.detected;
589
- // Detected, and still not choosable. The row stays visible on purpose
590
- // — see the list below — so this is the only place that can decline
591
- // it, and declining with the reason is the point: accepting would
592
- // write the choice to preferences and hand the operator a session
593
- // that refuses to start.
594
- if (!current.entry.constructible) {
595
- setErrorHint(unsupportedProviderMessage(current.entry.id));
596
- return;
597
- }
598
- if (selectionKind === 'signed-in-subscription') {
599
- const operation = beginOperation();
600
- onSubmit({ provider: current.entry.id }, operation.controller.signal);
778
+ // More than one provider behind this row: which of them the operator
779
+ // means is theirs to say. Skipping the question was the defect this
780
+ // screen was reported with — one row asked for a key while the session
781
+ // it could have used sat above it — so the row asks instead of picking.
782
+ if (rowNeedsChoice(row)) {
783
+ invalidateOperation();
784
+ setPathVendor(row.vendor);
785
+ setCursor(0);
601
786
  return;
602
787
  }
603
- // Ask the provider what it has, then show the model step. The list is
604
- // raced against 3s inside `describeProviderModels`, so this resolves
605
- // either way and the step always has at least the default.
606
- openModels(current);
788
+ // One provider, so Enter means exactly what it has always meant: the
789
+ // detected session's models, or the field that takes the credential this
790
+ // row is missing. `takePath` is the same call the choice screen makes.
791
+ takePath(row.paths[0]);
607
792
  return;
608
793
  }
609
794
  // Numeric quick-select, one keystroke per row and therefore the first nine.
@@ -618,14 +803,16 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
618
803
  }
619
804
  });
620
805
  // Above every screen this picker draws, so the reason is on the same frame as
621
- // the choice it is asking for.
622
- const noticeBox = notice || onSetup ? (_jsx(Box, { paddingBottom: 1, children: _jsxs(Box, { flexDirection: "column", children: [notice ? _jsx(Text, { color: theme.status.warn, children: notice }) : null, onSetup && !modelPhase && !loginPhase && !loginEntry && !keyEntry ? _jsx(Text, { dimColor: true, children: "s provider setup \u00B7 check installations and access" }) : null] }) })) : null;
806
+ // the choice it is asking for. Whether the setup hint fits beside it is
807
+ // decided below, where the screen's line budget is; the screens that return
808
+ // early cannot be showing it in any case, so they ask for it unconditionally.
809
+ const buildNoticeBox = (withSetupHint) => notice || onSetup ? (_jsx(Box, { paddingBottom: 1, children: _jsxs(Box, { flexDirection: "column", children: [notice ? (_jsx(WrappedLines, { text: notice, width: contentWidth, color: theme.status.warn })) : null, onSetup && withSetupHint && !modelPhase && !loginPhase && !loginEntry && !keyEntry ? (_jsx(Text, { dimColor: true, children: SETUP_HINT })) : null] }) })) : null;
623
810
  if (loginEntry) {
624
811
  const waitsForDeviceApproval = loginEntry.entry.subscriptionLogin === 'device';
625
812
  const painted = loginEntry.value
626
813
  ? `${'•'.repeat(Math.min(loginEntry.value.length, 32))}${loginEntry.value.length > 32 ? '…' : ''}`
627
814
  : '(nothing pasted yet)';
628
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [noticeBox, _jsxs(Text, { color: theme.accent.system, bold: true, children: ["Complete ", loginEntry.entry.label, " sign-in"] }), waitsForDeviceApproval ? (_jsx(Text, { color: theme.text.muted, children: "Approve the device code shown above in your browser. esc cancels." })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: "Paste the authorization code, or the finished browser address, then press enter. esc cancels." }), _jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: loginEntry.value ? theme.text.primary : theme.text.muted, children: painted }) })] })), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [loginEntry.status === 'starting' ? (_jsx(Text, { color: theme.text.muted, children: waitsForDeviceApproval
815
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [buildNoticeBox(true), _jsxs(Text, { color: theme.accent.system, bold: true, children: ["Complete ", loginEntry.entry.label, " sign-in"] }), waitsForDeviceApproval ? (_jsx(Text, { color: theme.text.muted, children: "Approve the device code shown above in your browser. esc cancels." })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: "Paste the authorization code, or the finished browser address, then press enter. esc cancels." }), _jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: loginEntry.value ? theme.text.primary : theme.text.muted, children: painted }) })] })), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [loginEntry.status === 'starting' ? (_jsx(Text, { color: theme.text.muted, children: waitsForDeviceApproval
629
816
  ? 'Waiting for browser approval…'
630
817
  : 'Starting browser sign-in…' })) : loginEntry.status === 'checking' ? (_jsx(Text, { color: theme.text.muted, children: "Finishing sign-in\u2026" })) : null, loginEntry.problem ? _jsx(Text, { color: theme.status.warn, children: loginEntry.problem }) : null] })] }));
631
818
  }
@@ -636,19 +823,27 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
636
823
  // line below is a preview of the sentence they will get, not a second
637
824
  // opinion about it.
638
825
  const kind = classifyCredential(entry, keyEntry.value);
639
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [noticeBox, _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsxs(Text, { color: theme.accent.system, bold: true, children: ["Paste a credential for ", entry.label] }), _jsx(Text, { color: theme.text.muted, children: "An API key or a subscription token. Type or paste, then enter. esc cancels." })] }), _jsx(Text, { color: theme.text.primary, children: maskKey(keyEntry.value) || _jsx(Text, { color: theme.text.muted, children: "(nothing typed yet)" }) }), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [keyEntry.status === 'checking' ? (_jsxs(Text, { color: theme.text.muted, children: ["Checking it with ", entry.label, "\u2026"] })) : (_jsx(Text, { color: theme.text.secondary, children: "Used for this session only \u2014 it is not written anywhere." })), keyEntry.value.length > 0 && kind === 'subscription-token' ? (_jsx(Text, { color: theme.status.warn, children: "Reads as a subscription token \u2014 it expires in a few hours and namzu has no refresh data for a pasted one." })) : null, keyEntry.problem ? _jsx(Text, { color: theme.status.warn, children: keyEntry.problem }) : null] })] }));
826
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [buildNoticeBox(true), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsxs(Text, { color: theme.accent.system, bold: true, children: ["Paste a credential for ", entry.label] }), _jsx(Text, { color: theme.text.muted, children: "An API key or a subscription token. Type or paste, then enter. esc cancels." })] }), _jsx(Text, { color: theme.text.primary, children: maskKey(keyEntry.value) || _jsx(Text, { color: theme.text.muted, children: "(nothing typed yet)" }) }), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [keyEntry.status === 'checking' ? (_jsxs(Text, { color: theme.text.muted, children: ["Checking it with ", entry.label, "\u2026"] })) : (_jsx(Text, { color: theme.text.secondary, children: "Used for this session only \u2014 it is not written anywhere." })), keyEntry.value.length > 0 && kind === 'subscription-token' ? (_jsx(Text, { color: theme.status.warn, children: "Reads as a subscription token \u2014 it expires in a few hours and namzu has no refresh data for a pasted one." })) : null, keyEntry.problem ? _jsx(Text, { color: theme.status.warn, children: keyEntry.problem }) : null] })] }));
640
827
  }
641
828
  if (modelPhase) {
642
- return (_jsxs(Box, { flexDirection: "column", children: [noticeBox, _jsx(ModelStepView, { providerLabel: modelPhase.provider.entry.label, otherProviders: detected
829
+ return (_jsxs(Box, { flexDirection: "column", children: [buildNoticeBox(true), _jsx(ModelStepView, { providerLabel: modelPhase.provider.entry.label, otherProviders: detected
643
830
  .filter((provider) => provider.entry.constructible && provider.entry.id !== modelPhase.provider.entry.id)
644
831
  .map((provider) => provider.entry.label), step: modelPhase.step, query: modelQuery, currentModel: currentProvider == null || currentProvider === modelPhase.provider.entry.id
645
832
  ? currentModel
646
833
  : undefined, cursor: cursor, errorHint: errorHint, returnToProviders: modelPhase.returnToProviders, sessionOnly: modelPhase.provider.source.kind === 'session' })] }));
647
834
  }
835
+ // One vendor's ways in. Drawn only where the row's paths name more than one
836
+ // provider — every other row is answered by Enter as it always was — so this
837
+ // is the one screen this change adds, and it is reached one way.
838
+ if (pathRow) {
839
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [buildNoticeBox(true), _jsxs(Text, { color: theme.accent.system, bold: true, children: ["Choose a way to use ", pathRow.label] }), _jsx(Text, { color: theme.text.muted, children: pathRow.detected.length > 0
840
+ ? `Already usable here — ${rowSourceText(pathRow)}.`
841
+ : 'Nothing on this device is set up for it yet.' }), _jsx(Box, { flexDirection: "column", paddingTop: 1, children: pathRow.paths.map((path, index) => (_jsxs(Text, { color: index === cursor ? theme.text.primary : theme.text.muted, children: [index === cursor ? '❯' : ' ', " ", index + 1, ". ", describePath(path)] }, `${path.kind}-${pathProviderId(path)}`))) }), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.text.muted, children: ["\u2191\u2193 or 1-", pathRow.paths.length, " navigate \u00B7 enter use \u00B7 esc back"] }), errorHint ? _jsx(Text, { color: theme.status.warn, children: errorHint }) : null] })] }));
842
+ }
648
843
  if (loginPhase) {
649
844
  const choices = subscriptionChoices(detected);
650
845
  const existingCount = choices.filter((choice) => choice.kind === 'existing').length;
651
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [noticeBox, _jsx(Text, { color: theme.accent.system, bold: true, children: "Choose a subscription session" }), _jsx(Text, { color: theme.text.muted, children: existingCount > 0
846
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [buildNoticeBox(true), _jsx(Text, { color: theme.accent.system, bold: true, children: "Choose a subscription session" }), _jsx(Text, { color: theme.text.muted, children: existingCount > 0
652
847
  ? 'Reuse a signed-in device session, or start a separate Namzu-owned sign-in.'
653
848
  : 'No usable model session was found on this device. Start a new subscription sign-in.' }), _jsx(Box, { flexDirection: "column", paddingTop: 1, children: choices.map((choice, index) => (_jsxs(Text, { color: index === cursor ? theme.text.primary : theme.text.muted, children: [index === cursor ? '❯' : ' ', " ", index + 1, ".", ' ', choice.kind === 'existing'
654
849
  ? `Use existing ${choice.detected.entry.label} · ${describeSource(choice.detected)}`
@@ -667,26 +862,107 @@ export function Picker({ detected, currentProvider, selectionKind = 'provider-an
667
862
  // the cursor is the answer, and saying so again costs the line its tail: the
668
863
  // footer is one row, and the sentence with a label in it wraps.
669
864
  const namesAnotherRow = entryTarget !== null &&
670
- (highlightedRow === undefined || rowProviderId(highlightedRow) !== entryTarget.id);
865
+ (highlightedRow === undefined || !rowHasProvider(highlightedRow, entryTarget.id));
866
+ // The lines this screen spends on everything that is not a row, counted from
867
+ // the very words it is about to draw rather than estimated, then the rows get
868
+ // what is left.
869
+ //
870
+ // Two defects met here. The box used to be drawn at whatever height its rows
871
+ // needed, and on a 60x20 terminal it was taller than the terminal: Ink
872
+ // scrolled it, so the top border, the title, the count and the notice — the
873
+ // sentence telling the operator what to do about a missing credential — were
874
+ // above the top of the screen. And counting rows rather than lines was not
875
+ // enough either, because a row whose source column wraps is two lines: the
876
+ // count has to be the height the row actually draws, which is why the sources
877
+ // are wrapped here rather than by the renderer.
878
+ const oneLine = (text) => wrapChoiceText(text, contentWidth).length;
879
+ const emptyScreenFooter = `${onLogin ? 'l: sign in · ' : ''}↑↓ or 1-9 navigate · enter or k: enter a credential · ${cancelExits ? 'esc: exit namzu' : 'esc: exit picker'}`;
880
+ // Everything the screen draws that is not a row, in the order it is drawn:
881
+ // the box's two borders, the notice and its blank, the title, the blank and
882
+ // the intro, the sources, then the two or three ways off the screen with the
883
+ // blank each one sits under, the footer with its blank, the app's status bar
884
+ // underneath, and the line above the box that belongs to what the app has
885
+ // already written — Ink draws this box below that, and a terminal shows the
886
+ // last `rows` of it, so a box that fills the screen exactly loses its own top
887
+ // border to the transcript above it. Measured at 60x20, where the boot banner
888
+ // is two lines and the reserved line is what keeps the notice on screen.
889
+ const emptyScreenChromeRows = 2 +
890
+ (notice ? noticeLines : 0) +
891
+ (notice || onSetup ? 1 : 0) +
892
+ (onSetup ? oneLine(SETUP_HINT) : 0) +
893
+ 1 +
894
+ 1 +
895
+ oneLine(EMPTY_SCREEN_INTRO) +
896
+ emptyScreenSourceLines().reduce((rows, line) => rows + oneLine(line), 0) +
897
+ (onLogin ? 1 + oneLine(`Press l ${EMPTY_SCREEN_HINTS.signIn}`) : 0) +
898
+ 1 +
899
+ oneLine(`Or press k ${EMPTY_SCREEN_HINTS.paste}`) +
900
+ 1 +
901
+ oneLine(EMPTY_SCREEN_HINTS.durable) +
902
+ 1 +
903
+ oneLine(emptyScreenFooter) +
904
+ 1 +
905
+ TRANSCRIPT_LINE_ABOVE;
906
+ // The same, for the screen that found something: two borders, the notice and
907
+ // its blank, the title, the subtitle and the blank under the header, the
908
+ // heading above the not-detected block and the blank above that, the footer's
909
+ // blank and its line, and the status bar. The heading is reserved whenever
910
+ // the list has a not-detected block, even if the window does not reach it: a
911
+ // row too few visible is a scroll the arrows fix, and a line too many is the
912
+ // header scrolled away.
913
+ const listSubtitle = selectionKind === 'signed-in-subscription'
914
+ ? `${detected.length} already usable · no API key required`
915
+ : `${detected.length} detected · device sessions / Namzu sign-ins / optional keys / local probes`;
916
+ const listFooter = `↑↓ or 1-9 navigate · enter ${selectionKind === 'signed-in-subscription' ? 'use' : 'accept'}${entryTarget
917
+ ? ` · k enter a credential${namesAnotherRow ? ` for ${entryTarget.label}` : ''}`
918
+ : ''}${onLogin && selectionKind !== 'signed-in-subscription' ? ' · l create a Namzu sign-in' : ''} · ${cancelExits ? 'esc exit namzu' : 'esc cancel'}`;
919
+ // What the screen keeps, in the order it gives things up.
920
+ //
921
+ // Overflow cuts the TOP of the box, and the top is where the notice sits —
922
+ // the sentence that says why this screen is open and what to do about it.
923
+ // Everything below is ranked against that: the title and the rows are the
924
+ // screen, the footer is how it is left, and the two blocks that only describe
925
+ // the screen are what go when the terminal is too short to hold them all.
926
+ // `5 detected · device sessions / Namzu sign-ins / optional keys / local
927
+ // probes` is a summary of what is already listed beneath it, and
928
+ // `s provider setup` advertises a key that works from other screens too.
929
+ const headingRows = rows.some((row) => row.detected.length === 0) ? 1 + oneLine(NOT_DETECTED_HEADING) : 0;
930
+ // Everything above and below the rows except those two blocks.
931
+ const listCoreRows = 2 +
932
+ (notice ? noticeLines : 0) +
933
+ (notice || onSetup ? 1 : 0) +
934
+ 1 +
935
+ headingRows +
936
+ 1 +
937
+ oneLine(listFooter) +
938
+ 1 +
939
+ TRANSCRIPT_LINE_ABOVE;
940
+ const subtitleRows = 1 + oneLine(listSubtitle);
941
+ const setupHintRows = onSetup ? oneLine(SETUP_HINT) : 0;
942
+ const screenRows = terminal.rows ?? 24;
943
+ // The row under the cursor is the one the window will not drop, so it is the
944
+ // height the gives-way tests are measured against — a row is one line only
945
+ // while its source column fits, and at 50 columns a variable name is three.
946
+ const cursorRow = rows[Math.max(0, Math.min(cursor, rows.length - 1))];
947
+ const mustKeep = cursorRow ? rowHeight(cursorRow, contentWidth, CURRENT_MARK) : 1;
948
+ const showSubtitle = screenRows - listCoreRows - setupHintRows - subtitleRows >= mustKeep;
949
+ const showSetupHint = screenRows - listCoreRows - (showSubtitle ? subtitleRows : 0) - setupHintRows >= mustKeep;
950
+ const listChromeRows = listCoreRows + (showSubtitle ? subtitleRows : 0) + (showSetupHint ? setupHintRows : 0);
951
+ const rowBudget = Math.max(1, screenRows - (detected.length === 0 ? emptyScreenChromeRows : listChromeRows));
952
+ const listWindow = rowWindow(rows, cursor, rowBudget, contentWidth);
671
953
  if (detected.length === 0) {
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()
673
- .map((entry) => entry.label)
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"] }) })] }));
954
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.status.warn, paddingX: 1, children: [buildNoticeBox(showSetupHint), _jsx(Text, { color: theme.status.warn, bold: true, children: "No providers detected" }), _jsxs(Box, { paddingTop: 1, flexDirection: "column", children: [_jsx(WrappedLines, { text: EMPTY_SCREEN_INTRO, width: contentWidth, color: theme.text.primary }), emptyScreenSourceLines().map((line) => (_jsx(WrappedLines, { text: line, width: contentWidth, color: theme.text.muted }, line)))] }), onLogin ? (_jsx(Box, { paddingTop: 1, flexDirection: "column", children: _jsx(HintLines, { text: `Press l ${EMPTY_SCREEN_HINTS.signIn}`, keyLetter: "l", width: contentWidth, color: theme.text.primary }) })) : null, _jsx(Box, { paddingTop: 1, flexDirection: "column", children: _jsx(HintLines, { text: `Or press k ${EMPTY_SCREEN_HINTS.paste}`, keyLetter: "k", width: contentWidth, color: theme.text.primary }) }), _jsx(Box, { paddingTop: 1, children: _jsx(WrappedLines, { text: EMPTY_SCREEN_HINTS.durable, width: contentWidth, color: theme.text.secondary }) }), _jsx(ProviderSetupRows, { rows: rows, cursor: cursor, currentProvider: currentProvider, visible: listWindow, contentWidth: contentWidth }), _jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: theme.text.muted, children: emptyScreenFooter }) })] }));
675
955
  }
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'
956
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border.focus, paddingX: 1, children: [buildNoticeBox(showSetupHint), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(Text, { color: theme.accent.system, bold: true, children: selectionKind === 'signed-in-subscription'
677
957
  ? 'Choose a signed-in subscription'
678
- : 'Choose a provider' }), _jsx(Text, { color: theme.text.muted, children: selectionKind === 'signed-in-subscription'
679
- ? `${detected.length} already usable · no API key required`
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'
683
- ? ' · l create a Namzu sign-in'
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] })] }));
958
+ : 'Choose a provider' }), showSubtitle ? (_jsx(WrappedLines, { text: listSubtitle, width: contentWidth, color: theme.text.muted })) : null] }), _jsx(ProviderSetupRows, { rows: rows, cursor: cursor, currentProvider: currentProvider, visible: listWindow, contentWidth: contentWidth }), _jsxs(Box, { flexDirection: "column", paddingTop: 1, children: [_jsx(WrappedLines, { text: listFooter, width: contentWidth, color: theme.text.muted }), 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] })] }));
685
959
  }
686
960
  function ModelStepView({ providerLabel, otherProviders, step, query, currentModel, cursor, errorHint, returnToProviders, sessionOnly, }) {
687
961
  const terminal = useWindowSize();
688
962
  const columns = terminal.columns ?? 80;
689
- const width = Math.max(1, columns - 4);
963
+ // The same area the provider list measures against — see `pickerContentWidth`
964
+ // for the three deductions and what counting only two of them cost.
965
+ const width = pickerContentWidth(columns);
690
966
  const choices = step ? filterModelChoices(step.choices, query ?? '') : [];
691
967
  const noticeRows = step?.notice ? Math.ceil(choiceDisplayWidth(step.notice) / width) : 0;
692
968
  const window = selectionWindow(choices, cursor, Math.max(1, Math.min(7, (terminal.rows ?? 24) - 7 - noticeRows)));
@@ -739,47 +1015,237 @@ function ModelStepView({ providerLabel, otherProviders, step, query, currentMode
739
1015
  * The appended rows carry no blank line between them. They are a work list
740
1016
  * rather than a catalogue, and a machine with six of them would otherwise push
741
1017
  * the box past a 24-row terminal and scroll the top of it — the detected rows —
742
- * out of sight.
1018
+ * out of sight. That argument is now the behaviour: `visible` is the slice that
1019
+ * fits, and when the whole list does not, the block boundary travels with the
1020
+ * window so the heading stays on screen wherever the operator has scrolled to.
1021
+ *
1022
+ * Grouping by vendor only shortened this list, and it shortened it by exactly
1023
+ * the duplicates: with one vendor drawn once instead of twice, nine
1024
+ * vendors is the most this screen can ever draw, and the digit shortcut's
1025
+ * nine-row limit is now a bound the list cannot pass rather than one it can
1026
+ * reach and overshoot. The sentence below still guards it, because a tenth
1027
+ * vendor in the registry would put the boundary back.
743
1028
  */
744
- function ProviderSetupRows({ rows, cursor, currentProvider, }) {
745
- // The two arms are contiguous by construction (`providerListRows` appends),
1029
+ function ProviderSetupRows({ rows, cursor, currentProvider, visible, contentWidth, }) {
1030
+ // The two blocks are contiguous by construction (`providerListRows` appends),
746
1031
  // 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)] }))] }));
1032
+ // whole list, whichever block it is drawn in. The boundary is a row's own
1033
+ // state now — whether this machine has anything for it — rather than which
1034
+ // arm of a union it is in.
1035
+ const firstUndetected = rows.findIndex((row) => row.detected.length === 0);
1036
+ return (_jsx(Box, { flexDirection: "column", children: visible.items.map((row, offset) => {
1037
+ const index = visible.start + offset;
1038
+ // The heading is drawn above the first not-detected row, or at the
1039
+ // top when the window has scrolled past that boundary — so the words
1040
+ // that explain the block below are never scrolled away from it.
1041
+ const heading = firstUndetected !== -1 &&
1042
+ (index === firstUndetected || (offset === 0 && index > firstUndetected)) ? (_jsx(Box, { marginTop: offset === 0 ? 0 : 1, children: _jsx(Text, { color: theme.text.muted, children: NOT_DETECTED_HEADING }) }, "heading")) : null;
1043
+ const detected = row.detected.length > 0;
1044
+ return (_jsxs(Fragment, { children: [heading, detected ? (_jsx(DetectedVendorRow, { row: row, index: index, selected: index === cursor, isCurrent: currentProvider != null && rowHasProvider(row, currentProvider), contentWidth: contentWidth })) : (_jsx(UnconfiguredVendorRow, { row: row, index: index, selected: index === cursor, isCurrent: currentProvider != null && rowHasProvider(row, currentProvider), contentWidth: contentWidth }))] }, row.vendor));
1045
+ }) }));
751
1046
  }
752
1047
  /**
753
- * A row for a provider nothing on this machine can serve yet.
1048
+ * A row for a vendor nothing on this machine can serve yet.
754
1049
  *
755
- * Deliberately a separate component from `ProviderRow` rather than one
1050
+ * Deliberately a separate component from `DetectedVendorRow` rather than one
756
1051
  * component with two modes: the detected row's shape is what operators read
757
1052
  * every day, and a shared body is a body where a change for the new rows moves
758
1053
  * the old ones. The columns line up because both pad the label to the same
759
1054
  * width; what differs is the third one, which says what is missing instead of
760
1055
  * where the credential came from.
761
1056
  */
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] }));
1057
+ function UnconfiguredVendorRow({ row, index, selected, isCurrent, contentWidth, }) {
1058
+ // The variable, not "not configured": it is the one thing the operator has
1059
+ // to act on, and it is what makes the credential durable. The colour marks
1060
+ // the work still to do on a screen that is otherwise all done.
1061
+ const mark = isCurrent ? CURRENT_MARK : '';
1062
+ const source = rowSourceLines(row, contentWidth, mark);
1063
+ const markInline = rowMarkPlacement(contentWidth, mark).inline;
1064
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(RowLine, { cursor: selected ? '›' : ' ', number: `${index + 1}.`, label: row.label, labelColor: selected ? theme.border.focus : theme.text.primary, labelBold: selected, numberColor: selected ? theme.border.focus : theme.text.muted, source: source[0] ?? '', sourceColor: theme.status.warn, currentMark: mark, markInline: markInline }), source.slice(1).map((line, at) => (
1065
+ // biome-ignore lint/suspicious/noArrayIndexKey: wrapped lines have no identity beyond their order.
1066
+ _jsx(Text, { color: theme.status.warn, children: `${ROW_INDENT}${line}` }, at))), mark !== '' && !markInline ? (_jsx(Text, { color: theme.accent.system, children: `${ROW_INDENT}${mark.trimStart()}` })) : null] }));
767
1067
  }
768
- function ProviderRow({ detected, index, selected, isCurrent, }) {
769
- const cursor = selected ? '›' : ' ';
770
- const number = `${index + 1}.`;
771
- const label = detected.entry.label;
772
- // What was found stays what is shown. A provider this build cannot construct
773
- // is still genuinely on the machine, and replacing "local · localhost:1234"
774
- // with the refusal would hide the discovery that makes the refusal make
775
- // sense. The reason goes in the source column, where the row says what namzu
776
- // knows about it.
777
- const usable = detected.entry.constructible;
778
- const sourceLabel = usable
779
- ? describeSource(detected)
780
- : `${describeSource(detected)} · unavailable in this build`;
781
- const currentMark = isCurrent ? ' ← current' : '';
782
- 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: usable ? (selected ? theme.border.focus : theme.text.primary) : theme.text.muted, bold: usable && selected, dimColor: !usable, children: label.padEnd(28) }), _jsx(Text, { color: usable ? theme.text.muted : theme.status.warn, dimColor: !usable, children: sourceLabel }), isCurrent ? _jsx(Text, { color: theme.accent.system, children: currentMark }) : null] }));
1068
+ /**
1069
+ * One row's first line: marker, number, name and source.
1070
+ *
1071
+ * The segments are children of ONE `<Text>` rather than siblings in a `<Box>`,
1072
+ * and that is load-bearing rather than stylistic. A row that reaches the edge of
1073
+ * the box is compressed, and a compressed row loses the trailing whitespace of
1074
+ * the nodes it is built from — so the cursor row, which is one column wider
1075
+ * than every other row because of its marker, came out as `› 6.DeepSeek` while
1076
+ * the rows below kept their `6. `. Children of a text node are squashed into
1077
+ * one string before anything measures or wraps them, so the space between the
1078
+ * marker and the number is not trailing anything, and neither is the gap the
1079
+ * padded name leaves before the source.
1080
+ */
1081
+ function RowLine({ cursor, number, label, labelColor, labelBold, numberColor, source, sourceColor, currentMark, markInline = true, dim = false, }) {
1082
+ return (_jsxs(Text, { children: [_jsx(Text, { color: numberColor, children: cursor }), " ", _jsx(Text, { color: numberColor, children: number }), ' ', _jsx(Text, { color: labelColor, bold: labelBold, dimColor: dim, children: label.padEnd(ROW_LABEL_WIDTH) }), _jsx(Text, { color: sourceColor, dimColor: dim, children: source }), currentMark && markInline ? (_jsx(Text, { color: theme.accent.system, children: currentMark })) : null] }));
1083
+ }
1084
+ /**
1085
+ * A row for a vendor something on this machine can already serve.
1086
+ *
1087
+ * What was found stays what is shown. A provider this build cannot construct is
1088
+ * still genuinely on the machine, and replacing "local · localhost:1234" with
1089
+ * the refusal would hide the discovery that makes the refusal make sense. The
1090
+ * reason goes in the source column, where the row says what namzu knows about
1091
+ * it.
1092
+ *
1093
+ * The row's name is the vendor's, which is the same word the operator used to
1094
+ * reach it — and the only place the two could differ is where one vendor is two
1095
+ * registry ids, which is the case this screen was changed for.
1096
+ */
1097
+ function DetectedVendorRow({ row, index, selected, isCurrent, contentWidth, }) {
1098
+ const usable = rowIsUsable(row);
1099
+ const mark = isCurrent ? CURRENT_MARK : '';
1100
+ const source = rowSourceLines(row, contentWidth, mark);
1101
+ const markInline = rowMarkPlacement(contentWidth, mark).inline;
1102
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(RowLine, { cursor: selected ? '›' : ' ', number: `${index + 1}.`, label: row.label, labelColor: usable ? (selected ? theme.border.focus : theme.text.primary) : theme.text.muted, labelBold: usable && selected, numberColor: selected ? theme.border.focus : theme.text.muted, source: source[0] ?? '', sourceColor: usable ? theme.text.muted : theme.status.warn, currentMark: mark, markInline: markInline, dim: !usable }), source.slice(1).map((line, at) => (
1103
+ // biome-ignore lint/suspicious/noArrayIndexKey: wrapped lines have no identity beyond their order.
1104
+ _jsx(Text, { color: usable ? theme.text.muted : theme.status.warn, dimColor: !usable, children: `${ROW_INDENT}${line}` }, at))), mark !== '' && !markInline ? (_jsx(Text, { color: theme.accent.system, dimColor: !usable, children: `${ROW_INDENT}${mark.trimStart()}` })) : null] }));
1105
+ }
1106
+ /**
1107
+ * Text this screen wraps before the renderer sees it.
1108
+ *
1109
+ * Every sentence whose line count the box is sized against goes through here,
1110
+ * for two reasons that are the same reason. Ink's wrapping keeps the space it
1111
+ * broke at, so a sentence that ran onto a second line began it one column to
1112
+ * the right of the first — inside a box whose whole point is that its left edge
1113
+ * is straight. And a line count the screen reserved has to be the line count
1114
+ * the screen draws, which it is only if the screen is the one wrapping.
1115
+ */
1116
+ function WrappedLines({ text, width, color, }) {
1117
+ return (_jsx(_Fragment, { children: wrapChoiceText(text, width).map((line, at) => (
1118
+ // biome-ignore lint/suspicious/noArrayIndexKey: wrapped lines have no identity beyond their order.
1119
+ _jsx(Text, { color: color, children: line || ' ' }, at))) }));
1120
+ }
1121
+ /**
1122
+ * The same, for a sentence that names a key the operator presses.
1123
+ *
1124
+ * The key is picked out in the accent colour wherever the wrap puts it, which
1125
+ * is why this splits the line it wrapped rather than the text it was given: a
1126
+ * span placed before wrapping is a span the wrap can move.
1127
+ */
1128
+ function HintLines({ text, keyLetter, width, color, }) {
1129
+ return (_jsx(_Fragment, { children: wrapChoiceText(text, width).map((line, at) => {
1130
+ const words = line.split(' ');
1131
+ const key = words.indexOf(keyLetter);
1132
+ if (key < 0) {
1133
+ // biome-ignore lint/suspicious/noArrayIndexKey: wrapped lines have no identity beyond their order.
1134
+ return (_jsx(Text, { color: color, children: line }, at));
1135
+ }
1136
+ // biome-ignore lint/suspicious/noArrayIndexKey: wrapped lines have no identity beyond their order.
1137
+ return (_jsxs(Text, { color: color, children: [`${words.slice(0, key).join(' ')}${key > 0 ? ' ' : ''}`, _jsx(Text, { color: theme.accent.system, children: keyLetter }), key < words.length - 1 ? ` ${words.slice(key + 1).join(' ')}` : ''] }, at));
1138
+ }) }));
1139
+ }
1140
+ /**
1141
+ * A row's third column, wrapped to the columns the row has left for it.
1142
+ *
1143
+ * Wrapped here rather than by the renderer because a row's height is part of
1144
+ * the box's height, and the row window budgets in lines: Ink wrapping a row the
1145
+ * screen had measured as one line tall is exactly how the box grew past the
1146
+ * terminal and pushed its own header off the top. The count below draws the
1147
+ * same lines it measures, so the two cannot disagree.
1148
+ */
1149
+ function rowSourceLines(row, contentWidth, currentMark = '') {
1150
+ const text = row.detected.length > 0 ? rowSourceText(row) : (rowCredentialNeed(row) ?? '');
1151
+ const available = Math.max(1, rowMarkPlacement(contentWidth, currentMark).sourceColumns);
1152
+ return capChoiceLines(wrapChoiceText(text, available), MAX_SOURCE_LINES, available);
1153
+ }
1154
+ /**
1155
+ * Where a row's `← current` mark goes at this width, and what it leaves the
1156
+ * source.
1157
+ *
1158
+ * Inline while the source keeps enough columns to be readable, and on a line of
1159
+ * its own when it does not. The mark is twelve columns, and at 60 columns
1160
+ * sharing the line with it left the source nine — which broke
1161
+ * `DEEPSEEK_API_KEY` across three lines to say what the operator already knew
1162
+ * from choosing this row.
1163
+ */
1164
+ function rowMarkPlacement(contentWidth, currentMark) {
1165
+ const width = contentWidth - ROW_PREFIX_WIDTH;
1166
+ if (currentMark === '')
1167
+ return { inline: false, sourceColumns: width };
1168
+ const shares = width - choiceDisplayWidth(currentMark) >= MIN_SOURCE_COLUMNS;
1169
+ return {
1170
+ inline: shares,
1171
+ sourceColumns: Math.max(1, width - (shares ? choiceDisplayWidth(currentMark) : 0)),
1172
+ };
1173
+ }
1174
+ /**
1175
+ * The lines one row occupies at this width.
1176
+ *
1177
+ * A row that carries the mark on a line below its source is one line taller,
1178
+ * which is why the placement is decided by a function both the measurement and
1179
+ * the renderer call: a height the box did not reserve is the header scrolled off
1180
+ * the top of the terminal.
1181
+ */
1182
+ function rowHeight(row, contentWidth, currentMark = '') {
1183
+ const lines = rowSourceLines(row, contentWidth, currentMark).length;
1184
+ const below = currentMark !== '' && !rowMarkPlacement(contentWidth, currentMark).inline;
1185
+ return below ? lines + 1 : lines;
1186
+ }
1187
+ /**
1188
+ * The rows that fit a line budget, with the row under the cursor among them.
1189
+ *
1190
+ * A window over ROWS and a budget in LINES, because a row is one line only
1191
+ * while its source column fits beside the label. The cursor's row is always
1192
+ * drawn whole — a cursor scrolled out of its own window is a screen the arrows
1193
+ * cannot explain — and the rows around it fill what is left, half the budget
1194
+ * above it when there is that much to show.
1195
+ */
1196
+ function rowWindow(rows, cursor, budget, contentWidth) {
1197
+ if (rows.length === 0)
1198
+ return { start: 0, items: [] };
1199
+ // The row under the cursor is the marked one when a provider is in force, and
1200
+ // a marked row may be a line taller: see `rowHeight`.
1201
+ const marked = rows[Math.max(0, Math.min(cursor, rows.length - 1))];
1202
+ const heights = rows.map((row) => rowHeight(row, contentWidth, row === marked ? CURRENT_MARK : ''));
1203
+ const at = Math.max(0, Math.min(cursor, rows.length - 1));
1204
+ let start = at;
1205
+ let used = heights[at] ?? 1;
1206
+ const half = Math.max(1, Math.floor(budget / 2));
1207
+ while (start > 0 && used < half && used + (heights[start - 1] ?? 1) <= budget) {
1208
+ start -= 1;
1209
+ used += heights[start] ?? 1;
1210
+ }
1211
+ let end = at + 1;
1212
+ while (end < rows.length && used + (heights[end] ?? 1) <= budget) {
1213
+ used += heights[end] ?? 1;
1214
+ end += 1;
1215
+ }
1216
+ return { start, items: rows.slice(start, end) };
1217
+ }
1218
+ /**
1219
+ * What a detected row's third column says: where what it found came from.
1220
+ *
1221
+ * Every source is named, not the first one. A vendor reached two ways at once —
1222
+ * an exported key and a signed-in session — has two usable credentials, and
1223
+ * naming one of them would be the row choosing for an operator who has not
1224
+ * chosen anything yet. `+` joins them because the sources are joined by ` · `
1225
+ * internally, and a list of four items reads as four discoveries.
1226
+ */
1227
+ function rowSourceText(row) {
1228
+ const text = row.detected.map(describeSource).join(' + ');
1229
+ return rowIsUsable(row) ? text : `${text} · unavailable in this build`;
1230
+ }
1231
+ /**
1232
+ * One way in, as the line that offers it.
1233
+ *
1234
+ * Each line borrows the wording of the screen it leads to — "Use existing …" is
1235
+ * the sign-in screen's phrase for a session already on the device, "Sign in to …
1236
+ * · device code" is its phrase for a new one, "a credential for …" is the paste
1237
+ * field's title — so the operator recognises where a line goes before pressing
1238
+ * enter on it.
1239
+ */
1240
+ function describePath(path) {
1241
+ switch (path.kind) {
1242
+ case 'detected':
1243
+ return `Use existing ${path.detected.entry.label} · ${describeSource(path.detected)}`;
1244
+ case 'credential':
1245
+ return `Enter a credential for ${path.entry.label} · ${credentialNeed(path.entry)}`;
1246
+ case 'sign-in':
1247
+ return `Sign in to ${path.entry.label} · ${path.entry.subscriptionLogin === 'device' ? 'device code' : 'browser'}`;
1248
+ }
783
1249
  }
784
1250
  function describeSource(d) {
785
1251
  switch (d.source.kind) {