agent-from-zero 0.36.0 → 0.39.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/README.md CHANGED
@@ -49,7 +49,7 @@ export DEEPSEEK_API_KEY=sk-你的key
49
49
 
50
50
  ### 配置审核模型 judge.model
51
51
 
52
- 开启 auto 模式(会话里输入 `/auto`,或启动时加 `--auto`)后,每个需要审批的工具调用会先交给一个审核模型判断:安全的直接执行,有风险或拿不准的仍然问你。审核模型只有两种选择:
52
+ 开启 auto 模式(会话里输入 `/auto`,或启动时加 `--auto`)后,每个需要审批的工具调用会先交给一个审核模型判断:安全的直接执行;看不出是你要求的,先退回给 agent,让它换个做法或用表单问你(同一个任务里连续 3 次、累计 20 次还卡住,才弹给你);你要求了但做了就回不去的(比如删掉 git 恢复不了的文件),弹出来让你确认。审核模型只有两种选择:
53
53
 
54
54
  1. **主 agent 用的模型**(默认):和主 agent 共用同一个 API key 和 base URL。没配 `judge.model` 就直接用主 agent 当前的模型。
55
55
  2. **Jev**:配好 Jev 的 key 后自动优先用它(见下一节)。
@@ -85,6 +85,18 @@ export TYPESAFE_API_KEY=你的key
85
85
  }
86
86
  ```
87
87
 
88
+ ### 信任项目外的目录
89
+
90
+ auto 模式下,改当前项目、其他 git 仓库或临时目录里的文件不用审批,其他地方的文件每次都要你确认。经常要改的目录(比如全局 skills 目录)可以写进**全局** settings(`~/.config/mini-agent/settings.json`),写在项目级 settings 里不生效:
91
+
92
+ ```json
93
+ {
94
+ "autoMode": { "trustedDirs": ["~/.config/mini-agent/skills"] }
95
+ }
96
+ ```
97
+
98
+ 也可以在确认弹窗里选 "Yes, and trust …/ for this session",只对本次会话有效。家目录本身和 `/` 不能被信任,`settings.json` 本身永远要你确认。
99
+
88
100
  ### 换别的模型
89
101
 
90
102
  和 API key 放在同一个地方,设置这三个变量就行,各家的 base URL 写在 [.env.example](.env.example) 里:
@@ -121,6 +133,21 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
121
133
 
122
134
  生成预测要多调一次模型,但请求内容和刚结束的主调用相同,只在最后加了一条指令,所以基本都能命中 DeepSeek 的前缀缓存,费用很低,也会算进 `/cost`。定时任务和其他实例发来的消息触发的回合不做预测。不想要可以在 `settings.json` 里设 `"promptSuggestions": false`,或者单次运行时设环境变量 `MINI_AGENT_PROMPT_SUGGESTIONS=0`。
123
135
 
136
+ ### 结果汇报、定时任务和长任务
137
+
138
+ 每轮结束时,agent 都会给一段一眼能看懂的结果:第一行先说结论,没别的要说就到此为止(比如“没有告警,一切正常”);有问题的话,分段列出要点,每个问题写清楚是什么、在哪、有什么影响,以及需要你决定的事。
139
+
140
+ 定时任务(直接跟 agent 说“每 30 分钟查一次 prod 告警”就能建,`/cron` 查看)每次执行完也按这个格式汇报。因为执行时你多半不在旁边,执行完终端会响一声,标签页会闪几下。
141
+
142
+ 长任务:
143
+
144
+ - `run_bash` 前台命令默认 2 分钟超时,agent 预计会跑很久时可以把超时设到最多 10 分钟。
145
+ - 更久的任务,或者不需要等它跑完的任务,交给后台(`run_bash_background`)或子 agent。它们跑完时如果 agent 已经停下来了,会自动开一轮,把结果交给 agent 接着处理,不用等你下一次发消息。`/bg` 可以查看后台任务。
146
+
147
+ ### 需要你选择时
148
+
149
+ agent 需要你做决定时会弹出一个表单,和 Claude Code 的一样:每个问题一个标签页,←/→ 或 Tab 切换,↑/↓ 选择,Enter 确定。每个选项下面有说明;可以多选的问题用 `[✔]` 勾选;选项里没有合适的,选 “Type something” 直接输入;想先聊聊再决定,选 “Chat about this”。最后一页汇总所有回答,确认后提交。表单打开时也可以直接在输入框打字,按 Enter 就作为一条新消息发出去。
150
+
124
151
  ### 多开实例互相通信
125
152
 
126
153
  在几个终端里各开一个 mini-agent(比如一个在后端仓库、一个在前端仓库),它们会自动互相发现,不用配置。
package/dist/agent.js CHANGED
@@ -21,7 +21,7 @@ import { parseCli, applyCliOptions, readPrintPrompt, CliUsageError } from "./cli
21
21
  import { reservePrintOutput, formatPrintResult, streamJsonInit, streamJsonMessage } from "./print.js";
22
22
  import { FollowUpQueue } from "./follow-up.js";
23
23
  import { reviewDebugCommand } from "./auto-debug.js";
24
- import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
24
+ import { approvalMenu, isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan; the shared approval menu
25
25
  import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
26
26
  import { clearTodos, getTodos, renderTodos } from "./todos.js"; // the agent's plan: /todos to view, cleared with the conversation
27
27
  import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
@@ -442,7 +442,7 @@ async function main() {
442
442
  // Fail closed: in a non-interactive session nobody can say yes, so the
443
443
  // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
444
444
  // denies were already enforced in permissions.ts before we get here.
445
- const confirm = async (question, toolName) => {
445
+ const confirm = async (question, toolName, trustDir) => {
446
446
  console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
447
447
  if (!autoMode.enabled && process.env.MINI_AGENT_AUTO_APPROVE === "1") {
448
448
  console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
@@ -453,15 +453,12 @@ async function main() {
453
453
  return false; // fail closed
454
454
  }
455
455
  // Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
456
- // option lets the user stop being asked about this tool for the session.
457
- const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
458
- const options = autoMode.enabled ? ["Yes, once", "No — let me tell the agent what to do instead"] : ["Yes", allowLabel, "No — let me tell the agent what to do instead"];
459
- const choice = await select(options);
460
- const approved = choice === 0 || (!autoMode.enabled && choice === 1);
461
- if (!autoMode.enabled && choice === 1 && toolName) {
462
- CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
463
- console.log(chalk.dim(` won't ask again for ${toolName} this session`));
464
- }
456
+ // option lets the user stop being asked about this tool (or, in auto mode,
457
+ // this outside directory) for the session.
458
+ const menu = approvalMenu(autoMode.enabled, toolName, trustDir);
459
+ const { approved, granted } = menu.choose(await select(menu.options));
460
+ if (granted)
461
+ console.log(chalk.dim(` ${granted}`));
465
462
  console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
466
463
  return approved;
467
464
  };
@@ -1,14 +1,21 @@
1
1
  // One object per root task, shared by descendants and interrupt/resume. An
2
2
  // exhausted budget is sticky: a late successful worker cannot reopen the gate.
3
+ // Claude Code's denial limits: 3 in a row or 20 per task. Rules-policy denials
4
+ // stop the task there; score-policy doubts returned to the agent instead turn
5
+ // back into human prompts (escalated) until an action executes again.
3
6
  export class AutoReviewBudget {
4
7
  consecutive = 0;
5
8
  total = 0;
6
9
  stopped = false;
7
10
  get exhausted() { return this.stopped; }
11
+ get escalated() { return this.consecutive >= 3 || this.total >= 20; }
8
12
  deny() {
13
+ this.block();
14
+ this.stopped ||= this.escalated;
15
+ }
16
+ block() {
9
17
  this.total++;
10
18
  this.consecutive++;
11
- this.stopped ||= this.consecutive >= 3 || this.total >= 20;
12
19
  }
13
20
  executed() { if (!this.stopped)
14
21
  this.consecutive = 0; }
@@ -51,7 +51,7 @@ export function reviewHistory(messages, inherited) {
51
51
  continue;
52
52
  const result = results.get(call.id);
53
53
  actions.push({ callId: call.id, tool: call.function.name, input: projectInput(call.function.name, call.function.arguments),
54
- outcome: /^\[permission\] Auto-denied/.test(result) ? "auto_denied" : /^\[follow-up\]/.test(result) ? "skipped" : /^\[(?:permission|hook)\]/.test(result) ? "denied" : /^\[error\]/.test(result) ? "failed" : "returned", ...resultEvidence(result) });
54
+ outcome: /^\[permission\] Auto-(?:denied|blocked)/.test(result) ? "auto_denied" : /^\[follow-up\]/.test(result) ? "skipped" : /^\[(?:permission|hook)\]/.test(result) ? "denied" : /^\[error\]/.test(result) ? "failed" : "returned", ...resultEvidence(result) });
55
55
  }
56
56
  }
57
57
  let bytes = 0;
@@ -50,14 +50,19 @@ export function needsAuthorizationReview(assessment) {
50
50
  }
51
51
  // The ONLY semantic allow/ask decision. Boolean answers are categorical and keep
52
52
  // that representation in the UI. An invalid provider result can never approve.
53
+ // Which dimension failed decides who hears the ask: a doubt about authorization
54
+ // goes back to the agent (it can drop the step or ask the user), while risk with
55
+ // clear authorization is irreversible work the user requested, so the human
56
+ // confirms it.
53
57
  export function decideReview(assessment) {
54
58
  const { authorized, risky } = assessment;
55
59
  if (!valid(authorized) || !valid(risky))
56
60
  return { decision: "ask", reason: "Invalid review assessment" };
57
- const allow = (typeof authorized === "boolean" ? authorized : authorized >= MIN_AUTHORIZATION)
58
- && (typeof risky === "boolean" ? !risky : risky <= MAX_RISK);
61
+ const requested = typeof authorized === "boolean" ? authorized : authorized >= MIN_AUTHORIZATION;
62
+ const allow = requested && (typeof risky === "boolean" ? !risky : risky <= MAX_RISK);
59
63
  const format = (value) => typeof value === "boolean" ? (value ? "yes" : "no") : value.toFixed(3);
60
- return { decision: allow ? "allow" : "ask", reason: `authorization=${format(authorized)}, risk=${format(risky)}${allow ? " — auto-approved" : " — human review required"}` };
64
+ const outcome = allow ? " — auto-approved" : requested ? " — human review required" : " — not clearly requested";
65
+ return { decision: allow ? "allow" : "ask", ...(requested ? {} : { unauthorized: true }), reason: `authorization=${format(authorized)}, risk=${format(risky)}${outcome}` };
61
66
  }
62
67
  // The score policy above remains a deliberate rollout fallback. Rules v1 has
63
68
  // no authorization/risk cutoffs: providers report facts, this resolver owns the
package/dist/auto.js CHANGED
@@ -107,7 +107,8 @@ export class AutoMode {
107
107
  status() {
108
108
  if (CONFIG.bypassPermissions)
109
109
  return "(bypassPermissions — approvals and AI review skipped for this run; hard denies, plan restrictions and hooks still apply)";
110
- return `(auto mode ${this.enabled ? "ON" : "OFF"} — ${this.backend}; ${this.enabled ? "uncertain or risky actions require approval" : "use /auto to enable"})`;
110
+ const enabled = this.policy === "rules" ? "uncertain or risky actions require approval" : "irreversible actions require approval; unrequested ones go back to the agent first";
111
+ return `(auto mode ${this.enabled ? "ON" : "OFF"} — ${this.backend}; ${this.enabled ? enabled : "use /auto to enable"})`;
111
112
  }
112
113
  async classify(tool, argsJson, userRequests, signal, options = {}) {
113
114
  if (this.policy === "rules")
@@ -1,9 +1,9 @@
1
1
  // ============ Background tasks (Day 37) ============
2
- // The problem: run_bash blocks. A command that takes longer than its 30s
3
- // watchdog (npm install, a full test run, a build) is SIGKILLed before it
4
- // finishes, and even a 25s one freezes the agent — we sit idle, paying for
5
- // tokens we aren't using, while a child process churns. The model cannot think
6
- // and wait at the same time.
2
+ // The problem: run_bash blocks. A command that takes longer than its watchdog
3
+ // (2 minutes by default, 10 at most) is SIGKILLed before it finishes, and even
4
+ // a shorter slow one (npm install, a full test run, a build) freezes the agent:
5
+ // we sit idle, paying for tokens we aren't using, while a child process churns.
6
+ // The model cannot think and wait at the same time.
7
7
  //
8
8
  // The fix, mirroring Claude Code: a command can be sent to the BACKGROUND. We
9
9
  // spawn it, hand the model a task id immediately, and let the loop keep going.
@@ -34,7 +34,7 @@ function append(task, chunk) {
34
34
  }
35
35
  // Start a command in the background. Returns its task id immediately — the
36
36
  // command keeps running after this returns. Note the deliberate omissions vs
37
- // run_bash: NO 30s timeout (the whole point is to outlive it) and NO turn
37
+ // run_bash: NO timeout (the whole point is to outlive it) and NO turn
38
38
  // AbortSignal (Ctrl+C interrupts the current turn, but a background job is meant
39
39
  // to survive into later turns; only killAll, on session exit, stops them).
40
40
  export function startBackground(command) {
@@ -107,6 +107,11 @@ export function pendingNotifications() {
107
107
  // something it must echo. Tell it plainly that a job it started has finished.
108
108
  return `A background task you started has finished. Read the notification, then continue.\n\n${done.join("\n\n")}`;
109
109
  }
110
+ // Has a task finished whose notification has not been delivered yet? Lets an
111
+ // idle frontend wake the agent without consuming the notification itself.
112
+ export function hasPendingNotifications() {
113
+ return [...tasks.values()].some((task) => task.status !== "running" && !task.notified);
114
+ }
110
115
  // Read a background task's current state for the bash_output tool. Returns only
111
116
  // the output PRODUCED SINCE THE LAST POLL (like Claude Code's BashOutput), so
112
117
  // the model can watch a long job without re-reading the whole log each time.
package/dist/config.js CHANGED
@@ -111,6 +111,7 @@ export const CONFIG = {
111
111
  // the test suite injects rules here instead of writing temp files.
112
112
  allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
113
113
  deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
114
+ ask: [...(globalSettings.permissions?.ask ?? []), ...(projectSettings.permissions?.ask ?? [])],
114
115
  },
115
116
  // Hooks for every event, merged across layers (global first, then project) by
116
117
  // a generic union so adding a new event needs no edit here.
@@ -133,6 +134,11 @@ export const CONFIG = {
133
134
  policy: (process.env.MINI_AGENT_AUTO_POLICY ?? projectSettings.autoMode?.policy ?? globalSettings.autoMode?.policy ?? "scores") === "rules" ? "rules" : "scores",
134
135
  enabled: process.env.MINI_AGENT_AUTO_MODE !== undefined ? process.env.MINI_AGENT_AUTO_MODE === "1" : (projectSettings.autoMode?.enabled ?? globalSettings.autoMode?.enabled ?? false),
135
136
  model: projectSettings.autoMode?.model || globalSettings.autoMode?.model || "jev-1.13.0",
137
+ // GLOBAL settings only: a cloned repo's settings must never extend auto
138
+ // mode's write reach into your home. "Trust this directory" in the approval
139
+ // menu appends here for the rest of the session.
140
+ trustedDirs: (globalSettings.autoMode?.trustedDirs ?? []).filter((dir) => typeof dir === "string" && dir.trim())
141
+ .map((dir) => path.resolve(dir.replace(/^~(?=$|\/)/, os.homedir()))),
136
142
  },
137
143
  // Token prices for the /cost estimate (defaults applied in cost.ts). Project
138
144
  // overrides global; either may set just the fields it cares about.
package/dist/cron.js CHANGED
@@ -162,15 +162,19 @@ export function listJobs() {
162
162
  // Shared by both consumers — the in-turn one (injectCronMessages in loop.ts, for
163
163
  // a job that fires mid-task) and the idle one (the REPL's idle processor, for a
164
164
  // job that fires while the user is at the prompt) — so they behave identically.
165
+ // The report is the run's summary: the user was not watching, so a quiet run is
166
+ // one line and anything wrong is laid out point by point (a prod-alert monitor:
167
+ // "No alerts — all normal.", or each alert with what, where and impact).
165
168
  export function cronTriggerContent(job) {
166
169
  return [
167
170
  `[Cron job "${job.id}" triggered — ${job.cron}]`,
168
171
  ``,
169
172
  `${job.prompt}`,
170
173
  ``,
171
- `Execute this now. When done, report the result directly to the user — make it visible and clear.`,
172
- `If the task produces output (e.g. a shell command result, a file change, a value), show it.`,
173
- `If there's nothing to show, confirm success with one line.`,
174
+ `Execute this now, then report to the user, who was not watching. Write the report in the language of the task above:`,
175
+ `- Nothing needs attention: ONE line that says so, e.g. "没有告警,一切正常。" / "No alerts — all normal."`,
176
+ `- Otherwise: first line the outcome, then bullet points under short bold labels — each problem with what, where and impact, then anything the user must decide or do.`,
177
+ `- Quote exact values (counts, names, error lines), never a raw log dump.`,
174
178
  ``,
175
179
  `After this, return to idle — do NOT start additional work unless the user asked for a chain of follow-ups.`,
176
180
  ].join("\n");
package/dist/form.js CHANGED
@@ -1,80 +1,155 @@
1
1
  import chalk from "chalk"; // rendering only
2
- // The cursor walks a flat list of options. Multiple questions add a submit row;
3
- // with one question, explicitly choosing an option submits it immediately.
4
- export function formRowCount(questions) {
5
- return questions.reduce((n, q) => n + q.options.length, 0) + (questions.length > 1 ? 1 : 0);
2
+ export function formRows(questions, tab) {
3
+ const q = questions[tab];
4
+ if (!q)
5
+ return [{ kind: "submit" }, { kind: "cancel" }];
6
+ return [
7
+ ...q.options.map((_, index) => ({ kind: "option", index })),
8
+ { kind: "typed" },
9
+ ...(q.multiSelect ? [{ kind: "next" }] : []),
10
+ { kind: "chat" },
11
+ ];
6
12
  }
7
- // Map a flat cursor to either a question/option, or the submit row.
8
- export function rowAt(questions, cursor) {
9
- let i = cursor;
10
- for (let q = 0; q < questions.length; q++) {
11
- if (i < questions[q].options.length)
12
- return { q, opt: i };
13
- i -= questions[q].options.length;
14
- }
15
- return { submit: true };
13
+ export function initFormState(questions) {
14
+ return { tab: 0, cursor: 0, picked: questions.map(() => []), typed: questions.map(() => "") };
16
15
  }
17
- // The flat cursor position of a question's first option — used to jump the
18
- // cursor to the first unanswered question when the user tries to submit early.
19
- function cursorForQuestion(questions, q) {
20
- let c = 0;
21
- for (let i = 0; i < q; i++)
22
- c += questions[i].options.length;
23
- return c;
16
+ // Is the cursor on a "Type something" row? The drivers route typed keys into
17
+ // their line editor there (and load / clear it as the cursor arrives / leaves).
18
+ export function onTypedRow(questions, state) {
19
+ return formRows(questions, state.tab)[state.cursor]?.kind === "typed";
24
20
  }
25
- export function initFormState(questions) {
26
- return { cursor: 0, selections: questions.map(() => -1) }; // nothing chosen yet
21
+ const answered = (state, q) => state.picked[q].length > 0;
22
+ // Land on a tab with the cursor on its first chosen option, or at the top.
23
+ function goToTab(state, tab) {
24
+ return { ...state, tab, cursor: Math.max(0, state.picked[tab]?.[0] ?? 0) };
27
25
  }
28
- // The one transition function. Returns the next state, and `done: true` only
29
- // when the user submits with every question answered.
30
- export function reduceForm(questions, state, action) {
31
- const rows = formRowCount(questions);
32
- if (action === "up")
33
- return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
34
- if (action === "down")
35
- return { state: { ...state, cursor: Math.min(rows - 1, state.cursor + 1) } };
36
- // select (Enter / Space)
37
- const row = rowAt(questions, state.cursor);
38
- if ("submit" in row) {
39
- const firstUnanswered = state.selections.findIndex((s) => s < 0);
40
- if (firstUnanswered < 0)
41
- return { state, done: true }; // all answered → submit for real
42
- return { state: { ...state, cursor: cursorForQuestion(questions, firstUnanswered) } }; // nudge to the gap
26
+ // The one transition function. `text` is what the driver's editor holds while
27
+ // the cursor is on a "Type something" row; it is stored as that question's
28
+ // typed answer before the action runs, so leaving the row keeps it. Returns the
29
+ // next state, and an `outcome` when the form is finished.
30
+ export function reduceForm(questions, state, action, text) {
31
+ const single = questions.length === 1; // no tabs, no review: picking submits
32
+ let s = { ...state, picked: state.picked.map((p) => p.slice()), typed: state.typed.slice() };
33
+ const q = questions[s.tab];
34
+ const typedIndex = q?.options.length ?? -1;
35
+ if (q && text !== undefined && onTypedRow(questions, s)) {
36
+ s.typed[s.tab] = text.trim();
37
+ // A multi-select typed answer counts as checked exactly while it has text.
38
+ if (q.multiSelect)
39
+ s.picked[s.tab] = s.picked[s.tab].filter((i) => i !== typedIndex).concat(s.typed[s.tab] ? [typedIndex] : []);
40
+ }
41
+ const rows = formRows(questions, s.tab);
42
+ const advance = () => single ? (answered(s, 0) ? { state: s, outcome: "submit" } : { state: s }) : { state: goToTab(s, s.tab + 1) };
43
+ switch (action) {
44
+ case "up":
45
+ return { state: { ...s, cursor: Math.max(0, s.cursor - 1) } };
46
+ case "down":
47
+ return { state: { ...s, cursor: Math.min(rows.length - 1, s.cursor + 1) } };
48
+ case "left":
49
+ case "right":
50
+ if (single)
51
+ return { state: s };
52
+ return { state: goToTab(s, Math.min(questions.length, Math.max(0, s.tab + (action === "left" ? -1 : 1)))) };
53
+ case "escape":
54
+ return { state: s, outcome: "cancel" };
43
55
  }
44
- const selections = state.selections.slice();
45
- selections[row.q] = row.opt; // set this question's answer (single-select)
46
- return { state: { ...state, selections }, ...(questions.length === 1 ? { done: true } : {}) };
56
+ // enter
57
+ const row = rows[s.cursor];
58
+ switch (row?.kind) {
59
+ case "option":
60
+ if (q.multiSelect) {
61
+ const on = s.picked[s.tab].includes(row.index);
62
+ s.picked[s.tab] = on ? s.picked[s.tab].filter((i) => i !== row.index) : [...s.picked[s.tab], row.index].sort((a, b) => a - b);
63
+ return { state: s };
64
+ }
65
+ s.picked[s.tab] = [row.index];
66
+ return advance();
67
+ case "typed":
68
+ if (!s.typed[s.tab])
69
+ return { state: s }; // nothing typed yet
70
+ if (q.multiSelect)
71
+ return { state: { ...s, cursor: s.cursor + 1 } }; // checked by having text; move on to Next/Submit
72
+ s.picked[s.tab] = [typedIndex];
73
+ return advance();
74
+ case "next":
75
+ return advance();
76
+ case "chat":
77
+ return { state: s, outcome: "chat" };
78
+ case "submit": {
79
+ const gap = questions.findIndex((_, i) => !answered(s, i));
80
+ return gap < 0 ? { state: s, outcome: "submit" } : { state: goToTab(s, gap) }; // jump to what is still open
81
+ }
82
+ case "cancel":
83
+ return { state: s, outcome: "cancel" };
84
+ }
85
+ return { state: s };
86
+ }
87
+ // One question's answer: chosen labels in option order, the typed answer last.
88
+ function answerText(q, state, i) {
89
+ return state.picked[i].map((p) => (p === q.options.length ? state.typed[i] : q.options[p]?.label ?? "")).filter(Boolean).join(", ");
47
90
  }
48
91
  // Turn a completed form into question→answer pairs for the model.
49
92
  export function collectAnswers(questions, state) {
50
- return questions.map((q, i) => ({ question: q.question, answer: q.options[state.selections[i]] ?? "(unanswered)" }));
93
+ return questions.map((q, i) => ({ question: q.question, answer: answerText(q, state, i) || "(unanswered)" }));
51
94
  }
52
- const FORM_HINT = chalk.dim("↑↓ to move · Enter to choose / submit · Esc to cancel");
53
- // Render the whole form: each question with radio-style options, the cursor on
54
- // one row, then a submit row. Line count is constant for a given question set
55
- // (selections don't add/remove lines), which is what lets the driver redraw in
56
- // place by moving the cursor up a fixed number of lines.
57
- export function renderForm(questions, state) {
58
- const lines = ["The agent needs your input:", ""];
59
- let flat = 0;
60
- questions.forEach((q, qi) => {
61
- lines.push(chalk.bold(`${qi + 1}. ${q.question}`));
62
- q.options.forEach((opt, oi) => {
63
- const onCursor = state.cursor === flat;
64
- const chosen = state.selections[qi] === oi;
65
- const text = `${chosen ? "●" : "○"} ${opt}`; // filled radio = chosen
66
- lines.push(onCursor ? chalk.cyan.bold(`❯ ${text}`) : chosen ? chalk.cyan(` ${text}`) : chalk.dim(` ${text}`));
67
- flat++;
68
- });
69
- lines.push("");
70
- });
71
- const onSubmit = state.cursor === flat; // the submit row is last
72
- const allAnswered = state.selections.every((s) => s >= 0);
73
- const submitText = allAnswered ? "▶ Submit answers" : "▶ Submit answers (answer every question first)";
95
+ // The footer: what the keys do right now.
96
+ export function formHint(questions, state) {
97
+ if (onTypedRow(questions, state))
98
+ return "Type your answer · Enter to use it · ↑/↓ to move · Esc to cancel";
99
+ return `Enter to select · ↑/↓ to move${questions.length > 1 ? " · ←/→ or Tab to switch questions" : ""} · Esc to cancel`;
100
+ }
101
+ // Render the current tab. `live` is the driver's editor text, shown on the
102
+ // "Type something" row while the cursor is on it.
103
+ export function renderForm(questions, state, live) {
104
+ const lines = [];
74
105
  if (questions.length > 1) {
75
- lines.push(onSubmit ? chalk.green.bold(`❯ ${submitText}`) : chalk.dim(` ${submitText}`));
76
- lines.push("");
106
+ const tabs = questions.map((q, i) => {
107
+ const label = ` ${answered(state, i) ? "☒" : "☐"} ${q.header || `Q${i + 1}`} `;
108
+ return i === state.tab ? chalk.inverse(label) : label;
109
+ });
110
+ const submit = state.tab === questions.length ? chalk.inverse(" ✔ Submit ") : " ✔ Submit ";
111
+ lines.push(`${chalk.dim("←")}${[...tabs, submit].join("")}${chalk.dim("→")}`, "");
112
+ }
113
+ const rows = formRows(questions, state.tab);
114
+ const mark = (i, text) => (i === state.cursor ? chalk.cyan(`❯ ${text}`) : ` ${text}`);
115
+ const q = questions[state.tab];
116
+ if (!q) {
117
+ // The review tab: every question with its answer, then Submit / Cancel.
118
+ lines.push(chalk.bold("Review your answers"), "");
119
+ questions.forEach((qq, i) => {
120
+ const answer = answerText(qq, state, i);
121
+ lines.push(` ● ${qq.question}`, answer ? chalk.green(` → ${answer}`) : chalk.yellow(" → (not answered)"));
122
+ });
123
+ lines.push("", chalk.dim("Ready to submit your answers?"), "");
124
+ rows.forEach((row, i) => lines.push(mark(i, `${i + 1}. ${row.kind === "submit" ? "Submit answers" : "Cancel"}`)));
125
+ }
126
+ else {
127
+ lines.push(chalk.bold(q.question), "");
128
+ const typedIndex = q.options.length;
129
+ rows.forEach((row, i) => {
130
+ const box = (on) => (q.multiSelect ? (on ? chalk.green("[✔] ") : "[ ] ") : "");
131
+ if (row.kind === "option") {
132
+ const option = q.options[row.index];
133
+ const on = state.picked[state.tab].includes(row.index);
134
+ const label = !q.multiSelect && on ? chalk.green(`${option.label} ✔`) : option.label;
135
+ lines.push(mark(i, `${row.index + 1}. ${box(on)}${label}`));
136
+ if (option.description)
137
+ lines.push(chalk.dim(` ${q.multiSelect ? " " : ""}${option.description}`));
138
+ }
139
+ else if (row.kind === "typed") {
140
+ const text = i === state.cursor && live !== undefined ? live : state.typed[state.tab];
141
+ const on = state.picked[state.tab].includes(typedIndex);
142
+ const shown = text ? (!q.multiSelect && on ? chalk.green(`${text} ✔`) : text) : chalk.dim("Type something");
143
+ lines.push(mark(i, `${typedIndex + 1}. ${box(on)}${shown}`));
144
+ }
145
+ else if (row.kind === "next") {
146
+ lines.push(mark(i, ` ${questions.length > 1 && state.tab < questions.length - 1 ? "Next" : "Submit"}`));
147
+ }
148
+ else if (row.kind === "chat") {
149
+ lines.push(chalk.dim(" ─────"), mark(i, `${typedIndex + 2}. Chat about this`));
150
+ }
151
+ });
77
152
  }
78
- lines.push(FORM_HINT);
153
+ lines.push("", chalk.dim(formHint(questions, state)));
79
154
  return lines.join("\n");
80
155
  }