@nanhara/hara 0.110.0 → 0.112.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 +24 -0
- package/dist/index.js +53 -17
- package/dist/providers/models.js +21 -0
- package/dist/tui/App.js +23 -4
- package/dist/tui/model-picker.js +62 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.112.0 — /jobs: see (and manage) what's running in the background
|
|
9
|
+
|
|
10
|
+
- **`/jobs` — a user-facing view of the agent's background shell jobs** (dev servers, watchers, long
|
|
11
|
+
builds started via `bash {background:true}`). hara already tracked these for the *agent* (the `job`
|
|
12
|
+
tool), but the *user* had no way to glance and see "what's running back there" — the way codex and
|
|
13
|
+
Claude Code surface it. Now: `/jobs` lists them (id · status · age · command), `/jobs tail <id>`
|
|
14
|
+
shows recent output, `/jobs kill <id>` stops one. Works in the TUI and the readline REPL.
|
|
15
|
+
- **Status row shows a `⚙ N bg` indicator** when background jobs are running, so a preview server /
|
|
16
|
+
watcher humming along is visible at a glance (live while working; `/jobs` is the on-demand truth).
|
|
17
|
+
|
|
18
|
+
## 0.111.0 — an interactive /model picker: ↑↓ a model, ←→ its thinking
|
|
19
|
+
|
|
20
|
+
- **`/model` (no argument) now opens an interactive picker** built on the provider registry — the
|
|
21
|
+
"one key, many models" flow. It pulls the endpoint's **live model list** (`GET /models`; a coding-plan
|
|
22
|
+
key exposes ~10: Qwen / GLM / Kimi / MiniMax / …), and you drive it with the arrow keys:
|
|
23
|
+
- **↑↓** move through the models,
|
|
24
|
+
- **←→** set the **thinking level** for this endpoint — the levels come from the registry's reasoning
|
|
25
|
+
style, so a DashScope/Ollama endpoint shows `off / on` (the real speedup toggle) while an
|
|
26
|
+
OpenAI/Anthropic one shows `off / low / medium / high`,
|
|
27
|
+
- **⏎** applies (switches the model, sets the dial, rebuilds the provider, persists to the session),
|
|
28
|
+
**esc** cancels.
|
|
29
|
+
Endpoints that don't enumerate models still let you set the thinking level (and `/model <id>` still
|
|
30
|
+
switches directly, as before). TUI only; the readline REPL keeps the text form.
|
|
31
|
+
|
|
8
32
|
## 0.110.0 — a provider registry: one key, many platforms, the right wire + thinking control for each
|
|
9
33
|
|
|
10
34
|
- **hara now speaks each platform its own way, chosen from a data-driven registry (a dictionary), not
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,22 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
|
43
43
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
44
44
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
45
45
|
import { resolvePlatform } from "./providers/registry.js";
|
|
46
|
+
import { listModels } from "./providers/models.js";
|
|
47
|
+
import { listJobs, tailJob, killJob } from "./exec/jobs.js";
|
|
48
|
+
/** Render the background-job list for /jobs (user-facing view of what the agent has running in the
|
|
49
|
+
* background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
|
|
50
|
+
function renderBgJobs() {
|
|
51
|
+
const js = listJobs();
|
|
52
|
+
if (!js.length)
|
|
53
|
+
return "(no background jobs — the agent starts them with bash {background:true})";
|
|
54
|
+
const age = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`);
|
|
55
|
+
const rows = js.map((j) => {
|
|
56
|
+
const st = j.status === "running" ? "▶ running" : j.status === "killed" ? "✕ killed" : `● exited${j.code != null ? ` (${j.code})` : ""}`;
|
|
57
|
+
const cmd = j.command.length > 64 ? j.command.slice(0, 64) + "…" : j.command;
|
|
58
|
+
return ` ${j.id} ${st} ${age(j.ageMs).padStart(4)} ${cmd}`;
|
|
59
|
+
});
|
|
60
|
+
return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
|
|
61
|
+
}
|
|
46
62
|
import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
|
|
47
63
|
import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
48
64
|
import { getEmbedder } from "./search/embed.js";
|
|
@@ -2516,6 +2532,14 @@ program.action(async (opts) => {
|
|
|
2516
2532
|
},
|
|
2517
2533
|
},
|
|
2518
2534
|
{ name: "usage", desc: "show token usage this session", run: () => void out(statusLine(cfg.model, stats.input, stats.output) + "\n") },
|
|
2535
|
+
{ name: "jobs", desc: "list/tail/kill background shell jobs (dev servers, watchers)", run: (a) => {
|
|
2536
|
+
const [sub, jid] = (a || "").trim().split(/\s+/);
|
|
2537
|
+
if (sub === "kill" && jid)
|
|
2538
|
+
return void out((killJob(jid) ? `✕ killed ${jid}` : `no running job ${jid}`) + "\n");
|
|
2539
|
+
if (sub === "tail" && jid)
|
|
2540
|
+
return void out((tailJob(jid) ?? `no job ${jid}`) + "\n");
|
|
2541
|
+
out(renderBgJobs() + "\n");
|
|
2542
|
+
} },
|
|
2519
2543
|
{ name: "doctor", desc: "check your hara setup", run: () => void out(runDoctor(cfg) + "\n") },
|
|
2520
2544
|
{
|
|
2521
2545
|
name: "roles",
|
|
@@ -2877,24 +2901,28 @@ program.action(async (opts) => {
|
|
|
2877
2901
|
const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
|
|
2878
2902
|
const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
|
|
2879
2903
|
if (!id) {
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2904
|
+
// Bare /model → the interactive picker: the endpoint's live model list (↑↓) + its thinking
|
|
2905
|
+
// level (←→, per the registry's reasoning style). Falls back to typing an id if the endpoint
|
|
2906
|
+
// doesn't enumerate models.
|
|
2907
|
+
const bURL = cfg.baseURL ?? providerDefaultBaseURL(cfg.provider);
|
|
2908
|
+
const models = await listModels(bURL, cfg.apiKey ?? "");
|
|
2909
|
+
const style = resolvePlatform(cfg.provider, bURL).reasoning;
|
|
2910
|
+
const chosen = await h.pickModel({ models, style, current: cfg.model, effort: cfg.reasoningEffort });
|
|
2911
|
+
if (!chosen)
|
|
2912
|
+
return; // esc — no change
|
|
2913
|
+
if (chosen.model) {
|
|
2914
|
+
cfg.model = chosen.model;
|
|
2915
|
+
meta.model = chosen.model;
|
|
2887
2916
|
}
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
}
|
|
2897
|
-
return void h.sink.notice(__lines.join("\n"));
|
|
2917
|
+
cfg.reasoningEffort = chosen.effort;
|
|
2918
|
+
visionProvider = undefined;
|
|
2919
|
+
remindedVision = false;
|
|
2920
|
+
const p2 = await buildProvider(cfg);
|
|
2921
|
+
if (!p2)
|
|
2922
|
+
return void h.sink.notice("(could not rebuild provider)");
|
|
2923
|
+
provider = p2;
|
|
2924
|
+
saveSession(meta, history);
|
|
2925
|
+
return void h.sink.notice(`(model → ${cfg.provider}:${cfg.model} · thinking ${chosen.effort ?? "default"})`);
|
|
2898
2926
|
}
|
|
2899
2927
|
cfg.model = id;
|
|
2900
2928
|
meta.model = id;
|
|
@@ -2985,6 +3013,14 @@ program.action(async (opts) => {
|
|
|
2985
3013
|
}
|
|
2986
3014
|
if (nm === "usage")
|
|
2987
3015
|
return void h.sink.notice(`tokens — ↑${stats.input} ↓${stats.output}`);
|
|
3016
|
+
if (nm === "jobs") {
|
|
3017
|
+
const [sub, jid] = (arg || "").trim().split(/\s+/);
|
|
3018
|
+
if (sub === "kill" && jid)
|
|
3019
|
+
return void h.sink.notice(killJob(jid) ? `✕ killed ${jid}` : `no running job ${jid}`);
|
|
3020
|
+
if (sub === "tail" && jid)
|
|
3021
|
+
return void h.sink.notice(tailJob(jid) ?? `no job ${jid}`);
|
|
3022
|
+
return void h.sink.notice(renderBgJobs());
|
|
3023
|
+
}
|
|
2988
3024
|
if (nm === "doctor")
|
|
2989
3025
|
return void h.sink.notice(runDoctor(cfg).replace(/\[[0-9;]*m/g, ""));
|
|
2990
3026
|
if (nm === "vision")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Model discovery — "what can this key run?" A coding-plan / OpenAI-compatible key usually exposes many
|
|
2
|
+
// models (Qwen, GLM, Kimi, …) via `GET {baseURL}/models`; the /model picker lists them so you switch by
|
|
3
|
+
// arrow keys, not by memorizing ids. Best-effort: many endpoints don't implement it → [] and the picker
|
|
4
|
+
// falls back to typing an id. `fetchImpl` is injected so this stays pure/testable.
|
|
5
|
+
export async function listModels(baseURL, apiKey, fetchImpl = fetch) {
|
|
6
|
+
if (!baseURL)
|
|
7
|
+
return []; // SDK-default hosts (anthropic/openai) — no custom endpoint to enumerate
|
|
8
|
+
try {
|
|
9
|
+
const url = baseURL.replace(/\/+$/, "") + "/models";
|
|
10
|
+
const r = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
11
|
+
if (!r.ok)
|
|
12
|
+
return [];
|
|
13
|
+
const j = (await r.json());
|
|
14
|
+
const ids = (j?.data ?? []).map((m) => m?.id).filter((x) => typeof x === "string" && x.length > 0);
|
|
15
|
+
// Stable order + de-dup so the picker list doesn't jump around between opens.
|
|
16
|
+
return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -18,6 +18,8 @@ import { accent } from "./theme.js";
|
|
|
18
18
|
import { renderMarkdown } from "../md.js";
|
|
19
19
|
import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
|
|
20
20
|
import { onTurnPhase, turnPhase } from "../agent/phase.js";
|
|
21
|
+
import { listJobs } from "../exec/jobs.js";
|
|
22
|
+
import { ModelPicker } from "./model-picker.js";
|
|
21
23
|
let _id = 0;
|
|
22
24
|
const nid = () => ++_id;
|
|
23
25
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -267,14 +269,19 @@ function StatusRow({ working, todos, queued }) {
|
|
|
267
269
|
const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
|
|
268
270
|
return () => clearInterval(id);
|
|
269
271
|
}, [working]);
|
|
272
|
+
// Background-job indicator — so the user can SEE what's running in the background (a preview server, a
|
|
273
|
+
// watcher) without asking. Live while working (spinner ticks re-render); best-effort at idle (/jobs is
|
|
274
|
+
// the authoritative on-demand view). Re-read each render.
|
|
275
|
+
const bg = listJobs().filter((j) => j.status === "running").length;
|
|
276
|
+
const bgTag = bg ? ` · ⚙ ${bg} bg (/jobs)` : "";
|
|
270
277
|
if (!working) {
|
|
271
|
-
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}` }) }));
|
|
278
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}${bgTag}` }) }));
|
|
272
279
|
}
|
|
273
280
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
274
281
|
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
275
282
|
// Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
|
|
276
283
|
// generic "working" when the network is slow — the user knows the request is out, not dead.
|
|
277
|
-
const verb = phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec);
|
|
284
|
+
const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
|
|
278
285
|
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
|
|
279
286
|
}
|
|
280
287
|
// Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
|
|
@@ -344,6 +351,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
344
351
|
const [askText, setAskText] = useState(null);
|
|
345
352
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
346
353
|
const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
|
|
354
|
+
const [picker, setPicker] = useState(null); // /model picker overlay
|
|
347
355
|
const [modeSelector, setModeSelector] = useState(false); // transient approval selector: shift+tab pops it, auto-hides
|
|
348
356
|
const modeSelectorTimerRef = useRef(null);
|
|
349
357
|
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
@@ -515,13 +523,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
515
523
|
return askTextFn(question);
|
|
516
524
|
};
|
|
517
525
|
const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
|
|
526
|
+
const pickModelFn = (o) => new Promise((resolve) => setPicker({ ...o, resolve }));
|
|
518
527
|
// Enter the conversation flow INSTANTLY: yield one macrotask so ink paints the committed message +
|
|
519
528
|
// cleared input + spinner BEFORE the turn's synchronous prep runs (reading @-files, base64-encoding
|
|
520
529
|
// images) and before the model's slow first token. Without this, that sync prep blocks ink's flush,
|
|
521
530
|
// so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
|
|
522
531
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
523
532
|
try {
|
|
524
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
533
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
525
534
|
}
|
|
526
535
|
catch (e) {
|
|
527
536
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -563,6 +572,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
563
572
|
return setShowTranscript((x) => !x); // open/close the full-transcript overlay
|
|
564
573
|
if (showTranscript)
|
|
565
574
|
return; // while open, the overlay's own useInput owns every key (scroll / esc)
|
|
575
|
+
if (picker)
|
|
576
|
+
return; // the /model picker overlay owns input (↑↓ model, ←→ thinking, ⏎, esc) while open
|
|
566
577
|
// Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
|
|
567
578
|
if (askText) {
|
|
568
579
|
if (key.escape) {
|
|
@@ -624,5 +635,13 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
624
635
|
});
|
|
625
636
|
if (showTranscript)
|
|
626
637
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
627
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }),
|
|
638
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }), picker && (_jsx(ModelPicker, { models: picker.models, style: picker.style, current: picker.current, effort: picker.effort, onSelect: (model, effort) => {
|
|
639
|
+
const r = picker.resolve;
|
|
640
|
+
setPicker(null);
|
|
641
|
+
r({ model, effort });
|
|
642
|
+
}, onCancel: () => {
|
|
643
|
+
const r = picker.resolve;
|
|
644
|
+
setPicker(null);
|
|
645
|
+
r(null);
|
|
646
|
+
} })), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector ? _jsx(ModeLine, { approval: status.approval }) : _jsx(StatusRow, { working: working, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
628
647
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The /model picker: ↑↓ choose a model (from the key's live /models list), ←→ set the thinking level for
|
|
3
|
+
// this endpoint, ⏎ apply, esc cancel. Built on the provider registry — the reasoning STYLE (from the
|
|
4
|
+
// endpoint) decides which levels ←→ offers. The nav math is pure + exported for tests; the component is a
|
|
5
|
+
// thin ink shell over it (like the Transcript overlay).
|
|
6
|
+
import { Box, Text, useInput } from "ink";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
/** The levels ←→ cycles for a style. Binary thinking toggles (DashScope enable_thinking, Ollama think)
|
|
9
|
+
* show off/on; graded styles (OpenAI/Anthropic effort/budget) show the full dial; `none` → no control. */
|
|
10
|
+
export function levelsFor(style) {
|
|
11
|
+
if (style === "none")
|
|
12
|
+
return [];
|
|
13
|
+
if (style === "enable_thinking" || style === "ollama_think")
|
|
14
|
+
return ["off", "high"]; // "high" renders as "on"
|
|
15
|
+
return ["off", "low", "medium", "high"];
|
|
16
|
+
}
|
|
17
|
+
/** Label a level for display — binary styles read as on/off, graded ones as the level name. */
|
|
18
|
+
export function levelLabel(style, e) {
|
|
19
|
+
if (style === "enable_thinking" || style === "ollama_think")
|
|
20
|
+
return e === "off" ? "off" : "on";
|
|
21
|
+
return String(e);
|
|
22
|
+
}
|
|
23
|
+
/** Pure navigation: ↑↓ moves through models (wrapping); ←→ cycles the thinking level for the endpoint's
|
|
24
|
+
* style. No-ops when there are no models / the style has no levels. Exported for tests. */
|
|
25
|
+
export function movePicker(s, key, modelCount, style) {
|
|
26
|
+
if (key === "up" || key === "down") {
|
|
27
|
+
if (modelCount <= 0)
|
|
28
|
+
return s;
|
|
29
|
+
const d = key === "down" ? 1 : -1;
|
|
30
|
+
return { ...s, modelIdx: (s.modelIdx + d + modelCount) % modelCount };
|
|
31
|
+
}
|
|
32
|
+
const levels = levelsFor(style);
|
|
33
|
+
if (!levels.length)
|
|
34
|
+
return s;
|
|
35
|
+
const cur = Math.max(0, levels.indexOf(s.effort));
|
|
36
|
+
const d = key === "right" ? 1 : -1;
|
|
37
|
+
return { ...s, effort: levels[(cur + d + levels.length) % levels.length] };
|
|
38
|
+
}
|
|
39
|
+
export function ModelPicker({ models, style, current, effort, onSelect, onCancel, }) {
|
|
40
|
+
const start = Math.max(0, models.indexOf(current ?? ""));
|
|
41
|
+
const [s, setS] = useState({ modelIdx: start, effort });
|
|
42
|
+
useInput((_input, key) => {
|
|
43
|
+
if (key.escape)
|
|
44
|
+
return onCancel();
|
|
45
|
+
if (key.return)
|
|
46
|
+
return onSelect(models[s.modelIdx] ?? current ?? "", s.effort);
|
|
47
|
+
if (key.upArrow)
|
|
48
|
+
setS((p) => movePicker(p, "up", models.length, style));
|
|
49
|
+
else if (key.downArrow)
|
|
50
|
+
setS((p) => movePicker(p, "down", models.length, style));
|
|
51
|
+
else if (key.leftArrow)
|
|
52
|
+
setS((p) => movePicker(p, "left", models.length, style));
|
|
53
|
+
else if (key.rightArrow)
|
|
54
|
+
setS((p) => movePicker(p, "right", models.length, style));
|
|
55
|
+
});
|
|
56
|
+
const hasLevels = levelsFor(style).length > 0;
|
|
57
|
+
const dial = hasLevels ? `thinking ◀ ${levelLabel(style, s.effort)} ▶` : "";
|
|
58
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` pick a model · ↑↓ model · ${hasLevels ? "←→ thinking · " : ""}⏎ apply · esc` }), models.length === 0 ? (_jsx(Text, { dimColor: true, children: " (this endpoint doesn't list models — use /model <id> to set one directly)" })) : (models.map((m, i) => {
|
|
59
|
+
const on = i === s.modelIdx;
|
|
60
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: on ? "cyan" : undefined, bold: on, children: (on ? " ❯ " : " ") + m }), on && hasLevels ? _jsx(Text, { dimColor: true, children: " " + dial }) : null] }, m));
|
|
61
|
+
}))] }));
|
|
62
|
+
}
|