@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 +14 -0
- package/dist/index.js +22 -17
- package/dist/providers/models.js +21 -0
- package/dist/tui/App.js +15 -2
- package/dist/tui/model-picker.js +62 -0
- package/package.json +1 -1
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
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
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
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
const
|
|
2889
|
-
if (
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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 }),
|
|
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
|
+
}
|