agent-from-zero 0.37.0 → 0.39.1
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 +28 -1
- package/dist/agent.js +13 -13
- package/dist/auto-budget.js +8 -1
- package/dist/auto-context.js +1 -1
- package/dist/auto-review.js +8 -3
- package/dist/auto.js +2 -1
- package/dist/background.js +11 -6
- package/dist/config.js +5 -0
- package/dist/cron.js +7 -3
- package/dist/form.js +141 -66
- package/dist/ink/app.js +125 -55
- package/dist/loop.js +73 -16
- package/dist/mcp.js +90 -15
- package/dist/menu.js +60 -22
- package/dist/permissions.js +56 -4
- package/dist/prompt.js +1 -1
- package/dist/title.js +4 -3
- package/dist/tools.js +30 -15
- package/package.json +1 -1
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
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
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
|
};
|
|
@@ -535,6 +532,9 @@ async function main() {
|
|
|
535
532
|
}
|
|
536
533
|
apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
|
|
537
534
|
};
|
|
535
|
+
// The login link for /mcp auth. This REPL waits on the login, so a browser on
|
|
536
|
+
// another machine (SSH) gets its redirect here through curl, run on this one.
|
|
537
|
+
const showAuthUrl = (url) => console.log(chalk.dim(`authenticate in your browser: ${url}\n browser on another machine (SSH)? after authorizing, run here in another terminal: curl '<the address it lands on>'`));
|
|
538
538
|
// /mcp — list configured MCP servers + status, then manage one (like Claude Code).
|
|
539
539
|
const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : status === "pending" ? chalk.dim("connecting…") : chalk.dim("disabled");
|
|
540
540
|
const handleMcpCommand = async (arg) => {
|
|
@@ -549,7 +549,7 @@ async function main() {
|
|
|
549
549
|
const name = parts.slice(1).join(" ");
|
|
550
550
|
const action = MCP_SUBCOMMANDS[parts[0]];
|
|
551
551
|
try {
|
|
552
|
-
console.log(chalk.dim(await runMcpAction(name, action,
|
|
552
|
+
console.log(chalk.dim(await runMcpAction(name, action, showAuthUrl)));
|
|
553
553
|
}
|
|
554
554
|
catch (err) {
|
|
555
555
|
console.log(chalk.yellow(err.message));
|
|
@@ -581,7 +581,7 @@ async function main() {
|
|
|
581
581
|
return;
|
|
582
582
|
}
|
|
583
583
|
try {
|
|
584
|
-
console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action,
|
|
584
|
+
console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action, showAuthUrl)));
|
|
585
585
|
}
|
|
586
586
|
catch (err) {
|
|
587
587
|
console.log(chalk.yellow(err.message));
|
package/dist/auto-budget.js
CHANGED
|
@@ -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; }
|
package/dist/auto-context.js
CHANGED
|
@@ -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;
|
package/dist/auto-review.js
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
package/dist/background.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ============ Background tasks (Day 37) ============
|
|
2
|
-
// The problem: run_bash blocks. A command that takes longer than its
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// tokens we aren't using, while a child process churns.
|
|
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
|
|
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
|
@@ -134,6 +134,11 @@ export const CONFIG = {
|
|
|
134
134
|
policy: (process.env.MINI_AGENT_AUTO_POLICY ?? projectSettings.autoMode?.policy ?? globalSettings.autoMode?.policy ?? "scores") === "rules" ? "rules" : "scores",
|
|
135
135
|
enabled: process.env.MINI_AGENT_AUTO_MODE !== undefined ? process.env.MINI_AGENT_AUTO_MODE === "1" : (projectSettings.autoMode?.enabled ?? globalSettings.autoMode?.enabled ?? false),
|
|
136
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()))),
|
|
137
142
|
},
|
|
138
143
|
// Token prices for the /cost estimate (defaults applied in cost.ts). Project
|
|
139
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
function
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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.
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
93
|
+
return questions.map((q, i) => ({ question: q.question, answer: answerText(q, state, i) || "(unanswered)" }));
|
|
51
94
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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(
|
|
153
|
+
lines.push("", chalk.dim(formHint(questions, state)));
|
|
79
154
|
return lines.join("\n");
|
|
80
155
|
}
|