@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.
- package/CHANGELOG.md +47 -0
- package/README.md +16 -5
- package/SECURITY.md +6 -4
- package/dist/agent/loop.js +175 -11
- package/dist/index.js +87 -79
- package/dist/mcp/client.js +72 -6
- package/dist/providers/models.js +39 -6
- package/dist/providers/reasoning.js +12 -0
- package/dist/security/subprocess-env.js +30 -0
- package/dist/serve/server.js +18 -5
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- package/dist/tools/builtin.js +3 -0
- package/dist/tui/App.js +44 -16
- package/dist/tui/model-picker.js +11 -8
- package/package.json +1 -1
package/dist/session/task.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
}
|
package/dist/tools/builtin.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
|
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
|
}
|
package/dist/tui/model-picker.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 =
|
|
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;
|