@nanhara/hara 0.109.5 → 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,46 @@ 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
+
22
+ ## 0.110.0 — a provider registry: one key, many platforms, the right wire + thinking control for each
23
+
24
+ - **hara now speaks each platform its own way, chosen from a data-driven registry (a dictionary), not
25
+ scattered if/else.** One row per platform declares its **wire protocol** (chat / Anthropic / Responses)
26
+ + how it expresses the **thinking dial** + how it **caches**. Point hara at a custom baseURL and it Just
27
+ Works:
28
+ - **Any vendor's `.../anthropic` endpoint** — DeepSeek, Kimi/Moonshot, Zhipu GLM, MiniMax, and Alibaba's
29
+ `…/apps/anthropic` — now routes through the **Anthropic wire** (so you get prompt caching + a native
30
+ thinking budget). Verified end-to-end against Alibaba's endpoint.
31
+ - **Local Ollama / LM Studio** (`localhost:11434` / `1234`) — `reasoning off` sends `think:false`, which
32
+ actually stops a local reasoning model's thinking phase (**measured: deepseek-r1:14b 17s → 0.6s**).
33
+ - **Alibaba DashScope** chat (coding plan / pay-as-you-go) — `reasoning off` → `enable_thinking:false`
34
+ (**qwen3.7-plus ~14s → ~1.6s**); works for a custom `qwen3.7-plus`/`glm-5` profile, keyed on the
35
+ endpoint, not the model name.
36
+ - OpenAI reasoning models keep `reasoning_effort`; Anthropic keeps its thinking budget. The dial **UNSET
37
+ leaves the request untouched** everywhere (model default, zero impact — the safe default).
38
+ - The Responses API (Alibaba Token Plan's newest models) is a distinct wire hara doesn't speak yet; a
39
+ Responses endpoint now returns a clear pointer to use the chat or `/apps/anthropic` endpoint instead
40
+ (rather than sending a body it would reject). Coming once there's a Token-Plan key to verify against.
41
+ - **Windows: hara no longer hangs at startup / on the first command.** Three SYNCHRONOUS probes ran with
42
+ no timeout, so on Windows a slow/hung one froze the whole process (main-thread block — nothing could
43
+ interrupt it): the `where bash` shell probe (added in 0.109.0), `git ls-files` (the "which files exist"
44
+ scan), and the per-turn shadow `git add -A`. All three are now bounded (3s / 5s / 10s) and fall back
45
+ gracefully (cmd.exe / filesystem walk / skip the snapshot). This is the "stuck at shell/directory
46
+ probing, never got to the actual work" hang.
47
+
8
48
  ## 0.109.5 — Enter enters the conversation instantly + reasoning off truly disables DashScope thinking
9
49
 
10
50
  - **Pressing Enter now enters the conversation flow instantly — the message no longer sits stuck in the
@@ -22,6 +22,10 @@ function git(root, gitDir, args) {
22
22
  encoding: "utf8",
23
23
  stdio: ["ignore", "pipe", "ignore"],
24
24
  maxBuffer: 64 * 1024 * 1024,
25
+ // Bound it: the per-turn shadow `git add -A` over a large tree (or a hung git) must never freeze a
26
+ // turn. On timeout execFileSync throws → checkpoint()/ensureRepo() catch it → the snapshot is just
27
+ // skipped (best-effort), the turn proceeds.
28
+ timeout: 10000,
25
29
  }).toString();
26
30
  }
27
31
  function ensureRepo(root, gitDir) {
package/dist/fs-walk.js CHANGED
@@ -53,6 +53,9 @@ export function listProjectFiles(root, cap = 8000) {
53
53
  encoding: "utf8",
54
54
  maxBuffer: 32 * 1024 * 1024,
55
55
  stdio: ["ignore", "pipe", "ignore"],
56
+ // Bound it: a hung git (credential-helper GUI, a slow/network filesystem, a giant repo) must not
57
+ // freeze the "which files exist" probe — on timeout it throws → we fall through to the fs walk.
58
+ timeout: 5000,
56
59
  })
57
60
  .split("\n")
58
61
  .map((s) => s.trim())
package/dist/index.js CHANGED
@@ -42,6 +42,8 @@ import { getTools } from "./tools/registry.js";
42
42
  import { EXPLORE_SYSTEM } from "./tools/agent.js";
43
43
  import { createAnthropicProvider } from "./providers/anthropic.js";
44
44
  import { createOpenAIProvider } from "./providers/openai.js";
45
+ import { resolvePlatform } from "./providers/registry.js";
46
+ import { listModels } from "./providers/models.js";
45
47
  import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
46
48
  import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
47
49
  import { getEmbedder } from "./search/embed.js";
@@ -127,9 +129,25 @@ async function buildProvider(cfg) {
127
129
  }
128
130
  if (!apiKey)
129
131
  return null;
130
- if (provider === "anthropic") {
132
+ // Transport is chosen from the provider REGISTRY (the dictionary) by wire protocol — so a custom
133
+ // baseURL Just Works: a vendor's `.../anthropic` endpoint (DeepSeek/Kimi/GLM/MiniMax/Aliyun) speaks the
134
+ // Anthropic wire (gets cache_control + thinking budget), DashScope/Ollama/OpenAI speak chat, etc.
135
+ const wire = resolvePlatform(provider, baseURL).wireApi;
136
+ if (wire === "anthropic") {
131
137
  return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
132
138
  }
139
+ if (wire === "responses") {
140
+ // The OpenAI Responses API (e.g. Aliyun Token Plan's newest models) — a distinct wire hara doesn't
141
+ // speak yet. Fail with guidance instead of sending a chat body it will reject. (Tracked for when a
142
+ // Token-Plan key is available to build + verify against; the chat + anthropic endpoints work today.)
143
+ return {
144
+ id: provider,
145
+ model,
146
+ async turn() {
147
+ return { text: "", toolUses: [], stop: "error", errorMsg: `This endpoint uses the OpenAI Responses API, which hara doesn't speak yet. Point hara at the plan's OpenAI-compatible chat endpoint (…/compatible-mode/v1 or …/v1) or its Anthropic-compatible endpoint (…/apps/anthropic) instead.` };
148
+ },
149
+ };
150
+ }
133
151
  return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
134
152
  }
135
153
  /** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
@@ -2860,24 +2878,28 @@ program.action(async (opts) => {
2860
2878
  const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
2861
2879
  const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
2862
2880
  if (!id) {
2863
- const __force = isSessionForceModel();
2864
- const __lines = [`model: ${cfg.provider}:${cfg.model}`];
2865
- if (meta.model && meta.model !== cfg.model) {
2866
- __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;
2867
2893
  }
2868
- else {
2869
- __lines.push(`session pinned: ${meta.model || "(none)"}${__force ? " · forced (all roles use session model)" : ""}`);
2870
- }
2871
- const __roles = loadRoles(cwd);
2872
- if (__roles.length) {
2873
- __lines.push("roles:");
2874
- for (const r of __roles) {
2875
- const eff = __force ? cfg.model : (r.model || cfg.model);
2876
- const tag = __force && r.model && r.model !== cfg.model ? " (overridden by --force)" : r.model ? " (role pin)" : " (session)";
2877
- __lines.push(` ${r.id}: ${eff}${tag}`);
2878
- }
2879
- }
2880
- 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"})`);
2881
2903
  }
2882
2904
  cfg.model = id;
2883
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
+ }
@@ -1,5 +1,9 @@
1
1
  import OpenAI from "openai";
2
2
  import { imageToBase64 } from "../images.js";
3
+ import { reasoningParams } from "./reasoning.js";
4
+ import { resolvePlatform } from "./registry.js";
5
+ // Re-exported for callers that still import it from here (the reasoning-family check now lives in reasoning.ts).
6
+ export { isReasoningModel } from "./reasoning.js";
3
7
  /** Build OpenAI chat-completions messages from neutral history. */
4
8
  export function toOpenAI(system, history) {
5
9
  const msgs = [{ role: "system", content: system }];
@@ -45,27 +49,6 @@ export function toOpenAI(system, history) {
45
49
  }
46
50
  return msgs;
47
51
  }
48
- /** Reasoning models on OpenAI (o-series + gpt-5) accept `reasoning_effort` on chat-completions.
49
- * Non-reasoning models reject it. We only attach the param when the model id matches a known
50
- * reasoning family — keeps DeepSeek/GLM/Qwen requests clean. Exported for tests. */
51
- export function isReasoningModel(model) {
52
- return /^(o1|o3|o4|gpt-5)/i.test(model);
53
- }
54
- /** DashScope (Alibaba — serves Qwen, GLM, …) runs a "thinking" phase that streams reasoning BEFORE the
55
- * answer — the main latency there (measured: qwen3.7-plus ~14s thinking vs ~1.6s without). It can be
56
- * silenced SERVER-SIDE with `enable_thinking:false` (actually stops generating the reasoning, not just
57
- * hides it). So map hara's reasoning dial onto it: an explicit **off** → thinking off (fast);
58
- * low/medium/high → on. Returns the flag to send, or `undefined` = leave the request UNTOUCHED — when
59
- * the dial is UNSET (keep the model's own default; zero impact — the safe default the user asked for)
60
- * or the endpoint isn't DashScope. Detected by the DashScope ENDPOINT (built-in qwen/qwen-oauth
61
- * providers, OR a custom baseURL on dashscope — which is how the reporter's `custom:qwen3.7-plus`
62
- * profile is set up), NOT the model name, so it covers custom Qwen/GLM profiles too. Exported for tests. */
63
- export function dashscopeThinking(effort, baseURL, label) {
64
- const isDashscope = /dashscope\.aliyuncs\.com/i.test(baseURL ?? "") || label === "qwen" || label === "qwen-oauth";
65
- if (!isDashscope || effort === undefined)
66
- return undefined;
67
- return effort !== "off";
68
- }
69
52
  /** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
70
53
  export function createOpenAIProvider(opts) {
71
54
  const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
@@ -86,18 +69,12 @@ export function createOpenAIProvider(opts) {
86
69
  };
87
70
  if (oaiTools.length)
88
71
  params.tools = oaiTools;
89
- // reasoning_effort: only attach for OpenAI reasoning models, and only when the user picked a
90
- // non-default level. "off" means "don't ask the model to reason" — for reasoning models we
91
- // pass "minimal" (gpt-5 / o-series accept it); on chat-style models that put reasoning in
92
- // the stream (DeepSeek/GLM) we can't silence it server-side, so the UI just won't render.
93
- if (opts.reasoningEffort && opts.reasoningEffort !== undefined && isReasoningModel(opts.model)) {
94
- params.reasoning_effort = opts.reasoningEffort === "off" ? "minimal" : opts.reasoningEffort;
95
- }
96
- // DashScope: reasoning "off" REALLY disables the thinking phase (big speedup), not just hides it.
97
- // Only fires on the DashScope endpoint + when the dial is set — UNSET stays untouched (zero impact).
98
- const dsThink = dashscopeThinking(opts.reasoningEffort, opts.baseURL, opts.label);
99
- if (dsThink !== undefined)
100
- params.enable_thinking = dsThink;
72
+ // Reasoning: the registry says HOW this platform expresses the thinking dial (DashScope
73
+ // enable_thinking, OpenAI reasoning models reasoning_effort, …); the applier turns hara's dial
74
+ // into the params to merge. UNSET {} (model default, zero impact). One data-driven line replaces
75
+ // the old per-platform if/else a new platform is a registry row, not code here.
76
+ const caps = resolvePlatform(opts.label, opts.baseURL);
77
+ Object.assign(params, reasoningParams(caps.reasoning, opts.reasoningEffort, opts.model));
101
78
  // Stream: emit text deltas live; accumulate tool-call args by index; grab usage from the tail chunk.
102
79
  let text = "";
103
80
  const acc = new Map();
@@ -0,0 +1,39 @@
1
+ // Reasoning control — data-driven. Every platform in the provider registry declares a reasoning STYLE
2
+ // (how it wants the thinking dial expressed on the wire); this module owns the small set of styles and
3
+ // maps hara's dial (off/low/medium/high, or UNSET) onto the request params each style needs. Adding a
4
+ // platform that reuses a style is pure data in the registry — no new code here. New code only when a
5
+ // genuinely new style appears (a new provider param shape).
6
+ /** OpenAI reasoning families that accept `reasoning_effort` / `reasoning.effort`. Others reject it, so the
7
+ * `reasoning_effort` / `reasoning_object` styles no-op on non-reasoning models. */
8
+ export function isReasoningModel(model) {
9
+ return /^(o1|o3|o4|gpt-5)/i.test(model);
10
+ }
11
+ /** Translate the dial into request params to MERGE into the wire body (chat/responses styles). Returns an
12
+ * empty object — leave the request untouched — when the dial is UNSET (keep the model's own default; zero
13
+ * impact, the safe default) or the style/model has nothing to add. Anthropic's `thinking_budget` is built
14
+ * in anthropic.ts (buildThinkingParam) and not covered here. Pure — exported for tests. */
15
+ export function reasoningParams(style, effort, model = "") {
16
+ if (effort === undefined)
17
+ return {};
18
+ switch (style) {
19
+ case "enable_thinking":
20
+ // off → false (stop the thinking phase, fast); any explicit level → true (keep it on).
21
+ return { enable_thinking: effort !== "off" };
22
+ case "ollama_think":
23
+ // Ollama's `think` boolean — off stops a local reasoning model's thinking (safe: non-thinking
24
+ // models ignore it). Same shape as enable_thinking, different param name.
25
+ return { think: effort !== "off" };
26
+ case "reasoning_effort":
27
+ if (!isReasoningModel(model))
28
+ return {};
29
+ return { reasoning_effort: effort === "off" ? "minimal" : effort };
30
+ case "reasoning_object":
31
+ if (!isReasoningModel(model))
32
+ return {};
33
+ return { reasoning: { effort: effort === "off" ? "minimal" : effort } };
34
+ case "thinking_budget": // Anthropic — applied by anthropic.ts, not on a chat/responses merge body
35
+ case "none":
36
+ default:
37
+ return {};
38
+ }
39
+ }
@@ -0,0 +1,54 @@
1
+ /** Base capabilities per wire protocol (the sensible default before per-platform overrides). */
2
+ const BY_WIRE = {
3
+ chat: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
4
+ responses: { wireApi: "responses", reasoning: "reasoning_object", cache: "auto" },
5
+ anthropic: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" },
6
+ };
7
+ /** Endpoint-shape rules, matched against the baseURL. FIRST match wins — most specific first. This is what
8
+ * makes a *custom* profile (e.g. `custom:qwen3.7-plus` pointing at coding.dashscope) resolve correctly
9
+ * without the user declaring a provider id: the DashScope host implies chat + enable_thinking. */
10
+ const BY_BASEURL = [
11
+ // Alibaba Token Plan — OpenAI-compatible; new models use the Responses API.
12
+ { test: /token-plan.*maas\.aliyuncs\.com\/compatible-mode/i, caps: { wireApi: "responses", reasoning: "reasoning_object", cache: "auto" } },
13
+ // Alibaba DashScope — OpenAI-compatible chat (coding plan /v1, pay-as-you-go /compatible-mode): the key
14
+ // difference is `enable_thinking`, which actually turns Qwen/GLM thinking off (the DashScope speedup).
15
+ { test: /dashscope\.aliyuncs\.com\/(v1|compatible-mode)|maas\.aliyuncs\.com\/compatible-mode/i, caps: { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" } },
16
+ // Local Ollama (OpenAI-compat, default port 11434): `think` toggles a local reasoning model's thinking
17
+ // (measured deepseek-r1:14b 17s → 0.6s); no cache. LM Studio (1234) is the same shape.
18
+ { test: /(localhost|127\.0\.0\.1|0\.0\.0\.0):(11434|1234)/i, caps: { wireApi: "chat", reasoning: "ollama_think", cache: "none" } },
19
+ // ANY vendor's Anthropic-compatible endpoint (path ends in /anthropic): DeepSeek, Kimi/Moonshot, Zhipu
20
+ // GLM, MiniMax, Aliyun apps/anthropic … all expose `.../anthropic` with thinking + explicit cache. One
21
+ // row covers the whole ecosystem — talk to it with the anthropic transport.
22
+ { test: /\/anthropic\/?($|\?)/i, caps: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" } },
23
+ // DeepSeek OpenAI-compatible (chat): the model id picks reasoning (deepseek-reasoner reasons, -chat
24
+ // doesn't); there's no per-request toggle on this path → leave it alone.
25
+ { test: /api\.deepseek\.com/i, caps: { wireApi: "chat", reasoning: "none", cache: "auto" } },
26
+ ];
27
+ /** Per-provider-id overrides (built-in providers whose id alone fixes the shape). */
28
+ const BY_PROVIDER = {
29
+ anthropic: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" },
30
+ qwen: { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" }, // DashScope
31
+ "qwen-oauth": { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" },
32
+ glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
33
+ deepseek: { wireApi: "chat", reasoning: "none", cache: "auto" },
34
+ ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
35
+ openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
36
+ openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
37
+ "hara-gateway": { wireApi: "chat", reasoning: "none", cache: "auto" },
38
+ };
39
+ /** Resolve a platform's capabilities from (provider id, baseURL, explicit wireApi override). Precedence:
40
+ * an explicit `wireApi` from config wins the transport; then the baseURL shape (so custom DashScope/
41
+ * token-plan/anthropic endpoints Just Work); then the provider-id override; else the wire default. */
42
+ export function resolvePlatform(providerId, baseURL, wireApiOverride) {
43
+ // baseURL shape is the strongest signal for a custom profile; else the provider-id override; else chat.
44
+ const byUrl = baseURL ? BY_BASEURL.find((r) => r.test.test(baseURL))?.caps : undefined;
45
+ const byProv = providerId ? BY_PROVIDER[providerId] : undefined;
46
+ const resolved = byUrl ?? { ...BY_WIRE.chat, ...(byProv ?? {}) };
47
+ // An explicit wireApi from config wins the transport. When it changes the wire, the reasoning style
48
+ // follows that wire's default (reasoning is wire-dependent — reasoning_effort on chat vs reasoning_object
49
+ // on responses); cache is kept from the resolved row (it tracks the endpoint, not the wire).
50
+ if (wireApiOverride && wireApiOverride !== resolved.wireApi) {
51
+ return { ...resolved, wireApi: wireApiOverride, reasoning: BY_WIRE[wireApiOverride].reasoning };
52
+ }
53
+ return resolved;
54
+ }
package/dist/sandbox.js CHANGED
@@ -17,7 +17,9 @@ function findWindowsBash() {
17
17
  if (_winBash !== undefined)
18
18
  return _winBash;
19
19
  // `where bash` finds Git Bash / WSL bash on PATH; also probe the default Git-for-Windows location.
20
- const onPath = spawnSync("where", ["bash"], { encoding: "utf8" });
20
+ // `timeout` is CRITICAL: this is a SYNCHRONOUS probe on the main thread — without it a slow `where`
21
+ // (a huge PATH, a dead network drive on PATH) hangs hara at startup with nothing able to interrupt it.
22
+ const onPath = spawnSync("where", ["bash"], { encoding: "utf8", timeout: 3000 });
21
23
  const hit = onPath.status === 0 ? String(onPath.stdout).split(/\r?\n/).find((l) => l.trim()) : "";
22
24
  _winBash = (hit && hit.trim()) || null;
23
25
  return _winBash;
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.109.5",
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"