@nanhara/hara 0.110.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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.111.0 — an interactive /model picker: ↑↓ a model, ←→ its thinking
9
+
10
+ - **`/model` (no argument) now opens an interactive picker** built on the provider registry — the
11
+ "one key, many models" flow. It pulls the endpoint's **live model list** (`GET /models`; a coding-plan
12
+ key exposes ~10: Qwen / GLM / Kimi / MiniMax / …), and you drive it with the arrow keys:
13
+ - **↑↓** move through the models,
14
+ - **←→** set the **thinking level** for this endpoint — the levels come from the registry's reasoning
15
+ style, so a DashScope/Ollama endpoint shows `off / on` (the real speedup toggle) while an
16
+ OpenAI/Anthropic one shows `off / low / medium / high`,
17
+ - **⏎** applies (switches the model, sets the dial, rebuilds the provider, persists to the session),
18
+ **esc** cancels.
19
+ Endpoints that don't enumerate models still let you set the thinking level (and `/model <id>` still
20
+ switches directly, as before). TUI only; the readline REPL keeps the text form.
21
+
8
22
  ## 0.110.0 — a provider registry: one key, many platforms, the right wire + thinking control for each
9
23
 
10
24
  - **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,7 @@ 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";
46
47
  import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
47
48
  import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
48
49
  import { getEmbedder } from "./search/embed.js";
@@ -2877,24 +2878,28 @@ program.action(async (opts) => {
2877
2878
  const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
2878
2879
  const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
2879
2880
  if (!id) {
2880
- const __force = isSessionForceModel();
2881
- const __lines = [`model: ${cfg.provider}:${cfg.model}`];
2882
- if (meta.model && meta.model !== cfg.model) {
2883
- __lines.push(`session pinned: ${meta.model} (cfg drift — /model ${meta.model} to re-pin)`);
2881
+ // Bare /model → the interactive picker: the endpoint's live model list (↑↓) + its thinking
2882
+ // level (←→, per the registry's reasoning style). Falls back to typing an id if the endpoint
2883
+ // doesn't enumerate models.
2884
+ const bURL = cfg.baseURL ?? providerDefaultBaseURL(cfg.provider);
2885
+ const models = await listModels(bURL, cfg.apiKey ?? "");
2886
+ const style = resolvePlatform(cfg.provider, bURL).reasoning;
2887
+ const chosen = await h.pickModel({ models, style, current: cfg.model, effort: cfg.reasoningEffort });
2888
+ if (!chosen)
2889
+ return; // esc — no change
2890
+ if (chosen.model) {
2891
+ cfg.model = chosen.model;
2892
+ meta.model = chosen.model;
2884
2893
  }
2885
- else {
2886
- __lines.push(`session pinned: ${meta.model || "(none)"}${__force ? " · forced (all roles use session model)" : ""}`);
2887
- }
2888
- const __roles = loadRoles(cwd);
2889
- if (__roles.length) {
2890
- __lines.push("roles:");
2891
- for (const r of __roles) {
2892
- const eff = __force ? cfg.model : (r.model || cfg.model);
2893
- const tag = __force && r.model && r.model !== cfg.model ? " (overridden by --force)" : r.model ? " (role pin)" : " (session)";
2894
- __lines.push(` ${r.id}: ${eff}${tag}`);
2895
- }
2896
- }
2897
- return void h.sink.notice(__lines.join("\n"));
2894
+ cfg.reasoningEffort = chosen.effort;
2895
+ visionProvider = undefined;
2896
+ remindedVision = false;
2897
+ const p2 = await buildProvider(cfg);
2898
+ if (!p2)
2899
+ return void h.sink.notice("(could not rebuild provider)");
2900
+ provider = p2;
2901
+ saveSession(meta, history);
2902
+ return void h.sink.notice(`(model ${cfg.provider}:${cfg.model} · thinking ${chosen.effort ?? "default"})`);
2898
2903
  }
2899
2904
  cfg.model = id;
2900
2905
  meta.model = id;
@@ -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,7 @@ 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 { ModelPicker } from "./model-picker.js";
21
22
  let _id = 0;
22
23
  const nid = () => ++_id;
23
24
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -344,6 +345,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
344
345
  const [askText, setAskText] = useState(null);
345
346
  const [reasoningOpen, setReasoningOpen] = useState(false);
346
347
  const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
348
+ const [picker, setPicker] = useState(null); // /model picker overlay
347
349
  const [modeSelector, setModeSelector] = useState(false); // transient approval selector: shift+tab pops it, auto-hides
348
350
  const modeSelectorTimerRef = useRef(null);
349
351
  // Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
@@ -515,13 +517,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
515
517
  return askTextFn(question);
516
518
  };
517
519
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
520
+ const pickModelFn = (o) => new Promise((resolve) => setPicker({ ...o, resolve }));
518
521
  // Enter the conversation flow INSTANTLY: yield one macrotask so ink paints the committed message +
519
522
  // cleared input + spinner BEFORE the turn's synchronous prep runs (reading @-files, base64-encoding
520
523
  // images) and before the model's slow first token. Without this, that sync prep blocks ink's flush,
521
524
  // so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
522
525
  await new Promise((resolve) => setTimeout(resolve, 0));
523
526
  try {
524
- await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
527
+ 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
528
  }
526
529
  catch (e) {
527
530
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
@@ -563,6 +566,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
563
566
  return setShowTranscript((x) => !x); // open/close the full-transcript overlay
564
567
  if (showTranscript)
565
568
  return; // while open, the overlay's own useInput owns every key (scroll / esc)
569
+ if (picker)
570
+ return; // the /model picker overlay owns input (↑↓ model, ←→ thinking, ⏎, esc) while open
566
571
  // Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
567
572
  if (askText) {
568
573
  if (key.escape) {
@@ -624,5 +629,13 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
624
629
  });
625
630
  if (showTranscript)
626
631
  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 }), 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 })] }));
632
+ 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) => {
633
+ const r = picker.resolve;
634
+ setPicker(null);
635
+ r({ model, effort });
636
+ }, onCancel: () => {
637
+ const r = picker.resolve;
638
+ setPicker(null);
639
+ r(null);
640
+ } })), 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
641
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.110.0",
3
+ "version": "0.111.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"