@nanhara/hara 0.125.0 → 0.125.3

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.
@@ -3,6 +3,9 @@ export const TASK_SCHEMA_VERSION = 1;
3
3
  export const MAX_TASK_OBJECTIVE_CHARS = 4096;
4
4
  export const MAX_TASK_STEERING_CHARS = 24_000;
5
5
  export const MAX_TASK_STEERING_ENTRIES = 24;
6
+ export const MAX_TASK_BRIEF_GOAL_CHARS = 2_000;
7
+ export const MAX_TASK_BRIEF_LIST_ENTRIES = 12;
8
+ export const MAX_TASK_BRIEF_ITEM_CHARS = 800;
6
9
  function iso(at = new Date()) {
7
10
  return typeof at === "string" ? at : at.toISOString();
8
11
  }
@@ -16,12 +19,42 @@ function validId(value) {
16
19
  function validTimestamp(value) {
17
20
  return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
18
21
  }
22
+ function boundedList(value, fallback) {
23
+ if (!Array.isArray(value))
24
+ return [fallback];
25
+ const out = value
26
+ .filter((item) => typeof item === "string")
27
+ .map((item) => boundedText(item, MAX_TASK_BRIEF_ITEM_CHARS))
28
+ .filter(Boolean)
29
+ .slice(0, MAX_TASK_BRIEF_LIST_ENTRIES);
30
+ return out.length ? out : [fallback];
31
+ }
32
+ function validBriefList(value) {
33
+ return Array.isArray(value) &&
34
+ value.length > 0 &&
35
+ value.length <= MAX_TASK_BRIEF_LIST_ENTRIES &&
36
+ value.every((item) => typeof item === "string" && item.length > 0 && item.length <= MAX_TASK_BRIEF_ITEM_CHARS);
37
+ }
19
38
  export function newTurnInteraction() {
20
39
  return { kind: "turn", turnId: randomUUID() };
21
40
  }
22
41
  export function newSteerInteraction(expectedTurnId) {
23
42
  return { kind: "steer", expectedTurnId, turnId: randomUUID() };
24
43
  }
44
+ /** Resolve a UI-delivery hint against authoritative task state. `steer` is never itself proof that an
45
+ * executable task is running: controls also occupy the composer briefly, and a real turn may finish between
46
+ * enqueue and dequeue. Preserve the submitted turn id but promote late input to a new turn. Only an explicit
47
+ * continuation path may opt into reopening a paused/completed task; stale live-turn ids remain hard errors
48
+ * in `continueTaskExecution`. */
49
+ export function routeTaskInteraction(task, interaction, options = {}) {
50
+ const steerable = !!task && (task.status === "running" || options.allowInactive === true);
51
+ if (interaction.kind !== "steer" || steerable)
52
+ return { interaction, recoveredMissingTask: false };
53
+ return {
54
+ interaction: { kind: "turn", turnId: interaction.turnId },
55
+ recoveredMissingTask: true,
56
+ };
57
+ }
25
58
  export function createTaskExecution(objective, turnId, at = new Date()) {
26
59
  const now = iso(at);
27
60
  return {
@@ -35,6 +68,35 @@ export function createTaskExecution(objective, turnId, at = new Date()) {
35
68
  startedAt: now,
36
69
  };
37
70
  }
71
+ /** Attach or revise the explicit understanding checkpoint. Revision is intentional: steering may add a
72
+ * constraint or convert an investigation into an approved change, while the original request remains intact. */
73
+ export function applyTaskBrief(task, input, at = new Date()) {
74
+ if (!task)
75
+ return { ok: false, reason: "there is no task to brief" };
76
+ if (task.status !== "running")
77
+ return { ok: false, reason: `task ${task.id} is ${task.status}, not running` };
78
+ const intent = input.intent;
79
+ if (intent !== "answer" && intent !== "investigate" && intent !== "change") {
80
+ return { ok: false, reason: "intent must be answer, investigate, or change" };
81
+ }
82
+ if (typeof input.goal !== "string" || !input.goal.trim()) {
83
+ return { ok: false, reason: "goal must be a non-empty string" };
84
+ }
85
+ const now = iso(at);
86
+ const brief = {
87
+ intent,
88
+ goal: boundedText(input.goal, MAX_TASK_BRIEF_GOAL_CHARS),
89
+ constraints: boundedList(input.constraints, "preserve unrelated user work and stated boundaries"),
90
+ acceptance: boundedList(input.acceptance, intent === "change" ? "the requested change is verified" : "the answer is supported by relevant evidence"),
91
+ steps: boundedList(input.steps, intent === "change" ? "inspect, change, and verify" : "inspect and report"),
92
+ createdAt: now,
93
+ };
94
+ return {
95
+ ok: true,
96
+ brief,
97
+ task: { ...task, brief, updatedAt: now },
98
+ };
99
+ }
38
100
  export function continueTaskExecution(task, interaction, at = new Date()) {
39
101
  if (!task)
40
102
  return { ok: false, reason: "there is no task to steer" };
@@ -171,6 +233,9 @@ export function taskExecutionContext(task, interaction, todos = []) {
171
233
  steeringNote,
172
234
  "Conversation messages provide evidence and refinements, but the task objective above remains authoritative until an explicit new task starts.",
173
235
  ];
236
+ // The accepted brief is deliberately absent here. `taskExecutionContext` is the stable per-interaction
237
+ // identity/recovery snapshot, while runAgent composes the current brief dynamically on every model round.
238
+ // Duplicating it here would leave the pre-run version in the prompt after a mid-run task_intake revision.
174
239
  if (todos.length) {
175
240
  lines.push("## Persisted execution checkpoint", ...todos.slice(0, 24).map((todo) => {
176
241
  const mark = todo.status === "done" ? "done" : todo.status === "in_progress" ? "in progress" : "pending";
@@ -189,6 +254,7 @@ export function formatTaskExecution(task) {
189
254
  `task ${task.id.slice(0, 8)} · ${task.status}`,
190
255
  `turn ${task.turnId.slice(0, 8)} · outcome ${task.lastOutcome ?? "running"}`,
191
256
  `objective: ${task.objective}`,
257
+ `brief: ${task.brief ? `${task.brief.intent} · ${task.brief.goal}` : "(not accepted yet)"}`,
192
258
  `steering: ${task.steering?.length ?? 0}`,
193
259
  ].join("\n");
194
260
  }
@@ -205,6 +271,18 @@ export function isTaskExecution(value) {
205
271
  (task.endedAt !== undefined && !validTimestamp(task.endedAt)) ||
206
272
  (task.lastOutcome !== undefined && task.lastOutcome !== "completed" && task.lastOutcome !== "error" && task.lastOutcome !== "empty" && task.lastOutcome !== "halted" && task.lastOutcome !== "interrupted"))
207
273
  return false;
274
+ if (task.brief !== undefined) {
275
+ if (!task.brief || typeof task.brief !== "object" || Array.isArray(task.brief))
276
+ return false;
277
+ const brief = task.brief;
278
+ if ((brief.intent !== "answer" && brief.intent !== "investigate" && brief.intent !== "change") ||
279
+ typeof brief.goal !== "string" || brief.goal.length === 0 || brief.goal.length > MAX_TASK_BRIEF_GOAL_CHARS ||
280
+ !validBriefList(brief.constraints) ||
281
+ !validBriefList(brief.acceptance) ||
282
+ !validBriefList(brief.steps) ||
283
+ !validTimestamp(brief.createdAt))
284
+ return false;
285
+ }
208
286
  if (task.steering === undefined)
209
287
  return true;
210
288
  if (!Array.isArray(task.steering) || task.steering.length > MAX_TASK_STEERING_ENTRIES)
package/dist/statusbar.js CHANGED
@@ -13,10 +13,23 @@ const fmtTok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
13
13
  const truncate = (s, max) => (s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…");
14
14
  const rule = (n) => c.dim("─".repeat(Math.max(0, n)));
15
15
  export function contextWindow(model) {
16
- const m = model.toLowerCase();
16
+ // Provider prefixes are common (`qwen/qwen3.7-plus`). Match the actual model id and prefer exact
17
+ // documented Coding Plan families over broad words such as "coder"/"max": those previously labeled
18
+ // qwen3-coder-next and qwen3-max as 1M, causing the context guard to overfeed their 262k windows.
19
+ const m = model.toLowerCase().split("/").at(-1) ?? model.toLowerCase();
17
20
  if (/haiku/.test(m))
18
21
  return 200_000;
19
- if (/(opus|sonnet|fable)|claude-4|qwen3|glm-[45]|max-2026|coder|1m/.test(m))
22
+ if (/^qwen3\.[567]-plus(?:-|$)/.test(m) || /^qwen3-coder-plus(?:-|$)/.test(m))
23
+ return 1_000_000;
24
+ if (/^(?:qwen3-max-2026-01-23|qwen3-coder-next)(?:-|$)/.test(m) || /^kimi-k2\.5(?:-|$)/.test(m))
25
+ return 262_144;
26
+ if (/^glm-(?:5|4\.7)(?:-|$)/.test(m))
27
+ return 202_752;
28
+ if (/^minimax-m2\.5(?:-|$)/.test(m))
29
+ return 196_608;
30
+ if (/qwen3\.6[-:]27b/.test(m))
31
+ return 262_144;
32
+ if (/(opus|sonnet|fable)|claude-4|1m/.test(m))
20
33
  return 1_000_000;
21
34
  return 200_000;
22
35
  }
@@ -213,6 +213,9 @@ registerTool({
213
213
  kind: "exec",
214
214
  requiresProjectWorkspace: true,
215
215
  async run(input, ctx) {
216
+ if (input.background !== undefined && typeof input.background !== "boolean") {
217
+ return "Error: `background` must be a boolean (true or false), not a string or another truthy value.";
218
+ }
216
219
  const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
217
220
  if (protectedReason) {
218
221
  return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
package/dist/tui/App.js CHANGED
@@ -21,6 +21,16 @@ import { onTurnPhase, turnPhase } from "../agent/phase.js";
21
21
  import { listJobs, onJobsChange } from "../exec/jobs.js";
22
22
  import { ModelPicker } from "./model-picker.js";
23
23
  import { newSteerInteraction, newTurnInteraction, requestsTaskContinuation } from "../session/task.js";
24
+ /** Composer work and executable task work are different states. Slash controls (for example `/model`)
25
+ * may keep the TUI busy while opening a picker or doing local bookkeeping, but there is no model task to
26
+ * steer. `/continue` and user-invocable skill commands are executable Agent turns. */
27
+ export function submissionCanBeSteered(line, agentSlashCommands = []) {
28
+ const value = line.trim();
29
+ const slash = /^\/([a-z][\w-]*)(?:\s|$)/i.exec(value);
30
+ if (!slash)
31
+ return true;
32
+ return slash[1].toLowerCase() === "continue" || agentSlashCommands.includes(slash[1]);
33
+ }
24
34
  let _id = 0;
25
35
  const nid = () => ++_id;
26
36
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -265,7 +275,7 @@ export function spinnerVerb(list, elapsedSec) {
265
275
  // wall-clock start, so the "Ns" text stays exact and stable.
266
276
  const SPINNER_FRAME_MS = 125;
267
277
  const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
268
- function StatusRow({ working, todos, queued }) {
278
+ function StatusRow({ working, steerable, todos, queued }) {
269
279
  const [frame, setFrame] = useState(0);
270
280
  const [phase, setPhase] = useState(() => turnPhase());
271
281
  const startRef = useRef(Date.now());
@@ -290,7 +300,7 @@ function StatusRow({ working, todos, queued }) {
290
300
  // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
291
301
  // generic "working" when the network is slow — the user knows the request is out, not dead.
292
302
  const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
293
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ steers · /next queues${queued ? ` (${queued})` : ""}` })] }));
303
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ${steerable ? "⏎ steers · /next queues" : "⏎ queues next"}${queued ? ` (${queued})` : ""}` })] }));
294
304
  }
295
305
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
296
306
  // don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
@@ -341,7 +351,7 @@ const TodoPanel = memo(function TodoPanel({ todos }) {
341
351
  return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
342
352
  }), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
343
353
  });
344
- export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
354
+ export function App({ initialStatus, model, cwd, header, onSubmit, agentSlashCommands = [], cycleApproval, onClipboardImage, vim, visionNotice, }) {
345
355
  const { exit } = useApp();
346
356
  const { stdout: termOut } = useStdout();
347
357
  // Live tail budget: terminal rows minus the rest of the dynamic chrome (todo panel ≤10, status slot,
@@ -462,12 +472,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
462
472
  const drainQueue = useCallback(() => {
463
473
  if (!queueRef.current.length)
464
474
  return [];
465
- const barrier = queueRef.current.findIndex((item) => item.mode === "next");
475
+ const barrier = queueRef.current.findIndex((item) => item.mode !== "steer");
466
476
  const batch = (barrier < 0 ? queueRef.current : queueRef.current.slice(0, barrier));
467
477
  if (!batch.length)
468
478
  return [];
469
479
  queueRef.current = barrier < 0 ? [] : queueRef.current.slice(barrier);
470
- setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : ""}${item.line.trim() || "🖼 (image)"}`));
480
+ setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : item.mode === "control" ? "control: " : ""}${item.line.trim() || "🖼 (image)"}`));
471
481
  if (batch.some((b) => b.images?.length))
472
482
  noteVisionIfNeeded();
473
483
  for (const b of batch)
@@ -498,25 +508,37 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
498
508
  // Enter steers the live turn; `/next …` creates an explicit queue barrier for a separate next task.
499
509
  // Once such a barrier exists, later type-ahead stays behind it instead of leaking backward.
500
510
  const expectedTurnId = activeTurnRef.current;
501
- if (!expectedTurnId)
502
- return;
503
511
  const next = /^\/(?:next|queue)(?:\s+([\s\S]+))?$/.exec(t);
504
512
  if (next && !next[1]?.trim() && !images?.length) {
505
513
  pushCurrent("notice", "usage: /next <message> (queues a separate task after the current turn)");
506
514
  return;
507
515
  }
508
- const behindBarrier = queueRef.current.some((item) => item.mode === "next");
509
- if (next || behindBarrier) {
510
- queueRef.current.push({ mode: "next", line: next?.[1]?.trim() || line, images });
516
+ // A control command can be "working" without owning a TaskExecution. Input typed during that UI
517
+ // operation is a real next turn, never a steer and never disposable. The same next barrier keeps
518
+ // later messages ordered behind it.
519
+ const queuedLine = next?.[1]?.trim() || line;
520
+ const queuedControl = !submissionCanBeSteered(queuedLine, agentSlashCommands);
521
+ const behindBarrier = !expectedTurnId ||
522
+ queueRef.current.some((item) => item.mode !== "steer");
523
+ if (queuedControl) {
524
+ queueRef.current.push({ mode: "control", line: queuedLine, images });
525
+ }
526
+ else if (next || behindBarrier) {
527
+ queueRef.current.push({ mode: "next", line: queuedLine, images });
511
528
  }
512
529
  else {
513
530
  queueRef.current.push({ mode: "steer", line, images, expectedTurnId });
514
531
  }
515
- setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : ""}${item.line.trim() || "🖼 (image)"}`));
532
+ setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : item.mode === "control" ? "control: " : ""}${item.line.trim() || "🖼 (image)"}`));
516
533
  return;
517
534
  }
518
535
  const interaction = forcedInteraction ?? newTurnInteraction();
519
- activeTurnRef.current = interaction.turnId;
536
+ // Only a model task may receive type-ahead steering. A slash control still sets `working` so its
537
+ // picker/output is serialized, but leaves this ref empty; submissions then queue as ordinary turns.
538
+ activeTurnRef.current =
539
+ interaction.kind === "steer" || submissionCanBeSteered(t, agentSlashCommands)
540
+ ? interaction.turnId
541
+ : null;
520
542
  // Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
521
543
  // 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
522
544
  // (anti-bob); folding on submit means the shrink coincides with the user's own action.
@@ -655,7 +677,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
655
677
  ctrlRef.current = null;
656
678
  if (activeTurnRef.current === interaction.turnId)
657
679
  activeTurnRef.current = null;
658
- }, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
680
+ }, [working, prompt, askText, onSubmit, agentSlashCommands, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
659
681
  // A message can arrive after runAgent's final pending-input drain but before the view flips to idle. Late
660
682
  // steer groups retain expectedTurnId; explicit `/next` groups start ordinary task turns in FIFO order.
661
683
  useEffect(() => {
@@ -669,8 +691,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
669
691
  for (let at = 0; at < batch.length;) {
670
692
  const mode = batch[at].mode;
671
693
  let end = at + 1;
672
- while (end < batch.length && batch[end].mode === mode)
673
- end++;
694
+ // Controls are barriers and always execute one-at-a-time. Task steering/next messages may still
695
+ // coalesce within their own adjacent group to preserve the established type-ahead behavior.
696
+ if (mode !== "control") {
697
+ while (end < batch.length && batch[end].mode === mode)
698
+ end++;
699
+ }
674
700
  const group = batch.slice(at, end);
675
701
  const line = group.map((item) => item.line).join("\n\n");
676
702
  const images = group.flatMap((item) => item.images ?? []);
@@ -755,5 +781,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
755
781
  const r = picker.resolve;
756
782
  setPicker(null);
757
783
  r(null);
758
- } })), 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 })] }));
784
+ } })), 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
785
+ ? _jsx(ModeLine, { approval: status.approval })
786
+ : _jsx(StatusRow, { working: working, steerable: !!activeTurnRef.current, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
759
787
  }
@@ -5,10 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // thin ink shell over it (like the Transcript overlay).
6
6
  import { Box, Text, useInput } from "ink";
7
7
  import { useState } from "react";
8
+ import { supportsReasoningStyle } from "../providers/reasoning.js";
8
9
  /** The levels ←→ cycles for a style. Binary thinking toggles (DashScope enable_thinking, Ollama think)
9
10
  * 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")
11
+ export function levelsFor(style, model = "") {
12
+ if (!supportsReasoningStyle(style, model))
12
13
  return [];
13
14
  if (style === "enable_thinking" || style === "ollama_think")
14
15
  return ["off", "high"]; // "high" renders as "on"
@@ -24,14 +25,14 @@ export function levelLabel(style, e) {
24
25
  }
25
26
  /** Pure navigation: ↑↓ moves through models (wrapping); ←→ cycles the thinking level for the endpoint's
26
27
  * style. No-ops when there are no models / the style has no levels. Exported for tests. */
27
- export function movePicker(s, key, modelCount, style) {
28
+ export function movePicker(s, key, modelCount, style, model = "") {
28
29
  if (key === "up" || key === "down") {
29
30
  if (modelCount <= 0)
30
31
  return s;
31
32
  const d = key === "down" ? 1 : -1;
32
33
  return { ...s, modelIdx: (s.modelIdx + d + modelCount) % modelCount };
33
34
  }
34
- const levels = levelsFor(style);
35
+ const levels = levelsFor(style, model);
35
36
  if (!levels.length)
36
37
  return s;
37
38
  const cur = Math.max(0, levels.indexOf(s.effort));
@@ -41,21 +42,23 @@ export function movePicker(s, key, modelCount, style) {
41
42
  export function ModelPicker({ models, style, current, effort, onSelect, onCancel, }) {
42
43
  const start = Math.max(0, models.indexOf(current ?? ""));
43
44
  const [s, setS] = useState({ modelIdx: start, effort });
45
+ const selectedModel = models[s.modelIdx] ?? current ?? "";
46
+ const levels = levelsFor(style, selectedModel);
44
47
  useInput((_input, key) => {
45
48
  if (key.escape)
46
49
  return onCancel();
47
50
  if (key.return)
48
- return onSelect(models[s.modelIdx] ?? current ?? "", s.effort);
51
+ return onSelect(selectedModel, levels.length ? s.effort : undefined);
49
52
  if (key.upArrow)
50
53
  setS((p) => movePicker(p, "up", models.length, style));
51
54
  else if (key.downArrow)
52
55
  setS((p) => movePicker(p, "down", models.length, style));
53
56
  else if (key.leftArrow)
54
- setS((p) => movePicker(p, "left", models.length, style));
57
+ setS((p) => movePicker(p, "left", models.length, style, selectedModel));
55
58
  else if (key.rightArrow)
56
- setS((p) => movePicker(p, "right", models.length, style));
59
+ setS((p) => movePicker(p, "right", models.length, style, selectedModel));
57
60
  });
58
- const hasLevels = levelsFor(style).length > 0;
61
+ const hasLevels = levels.length > 0;
59
62
  const dial = hasLevels ? `thinking ◀ ${levelLabel(style, s.effort)} ▶` : "";
60
63
  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) => {
61
64
  const on = i === s.modelIdx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.125.0",
3
+ "version": "0.125.3",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"