@vietor/easy-agent 0.7.5 → 0.8.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
@@ -154,7 +154,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
154
154
  - **Glob** — list files, optionally filtered by a glob pattern.
155
155
  - **Grep** — search file contents by regex with `glob`/`type` filters, context lines, case-insensitive, and `files_with_matches`/`count` output modes.
156
156
  - **WebFetch** — fetch a URL as markdown or text.
157
- - **AskUser** — ask the user a question and wait for their answer.
157
+ - **AskUser** — ask the user up to 4 questions in one call, each shown as a tab page (↑↓ pick options, Tab switches questions, custom input allowed) and wait for the answers.
158
158
  - **TodoWrite** — track multi-step work as a task list (pending / inProgress / completed), shown live as a panel in the TUI.
159
159
  - **SubAgent** — delegate investigation (`explore`) or implementation-planning (`plan`) subtasks to a nested read-only sub-agent.
160
160
 
@@ -3,40 +3,137 @@ import { useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  const CUSTOM_LABEL = "✎ Custom input";
6
+ function nextUnconfirmed(from, confirmed) {
7
+ for (let i = 1; i <= confirmed.length; i++) {
8
+ const idx = (from + i) % confirmed.length;
9
+ if (!confirmed[idx])
10
+ return idx;
11
+ }
12
+ return from;
13
+ }
6
14
  export function QuestionView({ question, onAnswer }) {
7
- const hasOptions = question.options.length > 0;
8
- const items = hasOptions ? [...question.options, CUSTOM_LABEL] : [];
15
+ const starts = [];
16
+ {
17
+ let acc = 0;
18
+ for (const q of question.questions) {
19
+ starts.push(acc);
20
+ acc += q.options.length;
21
+ }
22
+ }
23
+ const [focus, setFocus] = useState(0);
9
24
  const [selected, setSelected] = useState(0);
10
- const [mode, setMode] = useState(hasOptions ? "select" : "input");
25
+ const [inputting, setInputting] = useState(false);
26
+ const [checked, setChecked] = useState(new Set());
27
+ const [answers, setAnswers] = useState(() => question.questions.map(() => ""));
28
+ const [confirmed, setConfirmed] = useState(() => question.questions.map(() => false));
29
+ const [customText, setCustomText] = useState(() => question.questions.map(() => null));
11
30
  const [text, setText] = useState("");
31
+ const submitWith = (next, nextConfirmed) => {
32
+ if (nextConfirmed.every(Boolean))
33
+ onAnswer(next);
34
+ else
35
+ setFocus(nextUnconfirmed(focus, nextConfirmed));
36
+ };
37
+ const confirmCurrent = (next) => {
38
+ const nextConfirmed = [...confirmed];
39
+ nextConfirmed[focus] = true;
40
+ setAnswers(next);
41
+ setConfirmed(nextConfirmed);
42
+ submitWith(next, nextConfirmed);
43
+ };
12
44
  useInput((input, key) => {
13
- if (mode === "select") {
14
- if (key.upArrow) {
15
- setSelected((i) => (i <= 0 ? items.length - 1 : i - 1));
16
- }
17
- else if (key.downArrow) {
18
- setSelected((i) => (i >= items.length - 1 ? 0 : i + 1));
19
- }
20
- else if (key.return) {
21
- if (selected === items.length - 1)
22
- setMode("input");
45
+ if (inputting) {
46
+ if (key.escape)
47
+ setInputting(false);
48
+ return;
49
+ }
50
+ const q = question.questions[focus];
51
+ const itemCount = q.options.length + 1;
52
+ if (key.upArrow) {
53
+ setSelected((i) => (i <= 0 ? itemCount - 1 : i - 1));
54
+ }
55
+ else if (key.downArrow) {
56
+ setSelected((i) => (i >= itemCount - 1 ? 0 : i + 1));
57
+ }
58
+ else if (key.tab || key.rightArrow) {
59
+ setFocus((f) => (f + 1) % question.questions.length);
60
+ setSelected(0);
61
+ }
62
+ else if ((key.tab && key.shift) || key.leftArrow) {
63
+ setFocus((f) => (f <= 0 ? question.questions.length - 1 : f - 1));
64
+ setSelected(0);
65
+ }
66
+ else if (key.return) {
67
+ if (confirmed[focus]) {
68
+ if (confirmed.every(Boolean))
69
+ onAnswer(answers);
23
70
  else
24
- onAnswer(items[selected]);
71
+ setFocus(nextUnconfirmed(focus, confirmed));
25
72
  }
26
- else if (key.escape) {
27
- onAnswer("");
73
+ else if (selected === q.options.length) {
74
+ if (customText[focus] !== null) {
75
+ const answer = q.multiSelect
76
+ ? [...q.options.filter((_, i) => checked.has(starts[focus] + i)).map((o) => o.label), customText[focus]]
77
+ : customText[focus];
78
+ confirmCurrent([...answers.slice(0, focus), answer, ...answers.slice(focus + 1)]);
79
+ }
80
+ else {
81
+ setText("");
82
+ setInputting(true);
83
+ }
28
84
  }
29
- else if (input && !key.ctrl && !key.meta) {
30
- setText(input);
31
- setMode("input");
85
+ else {
86
+ const answer = q.multiSelect
87
+ ? q.options.filter((_, i) => checked.has(starts[focus] + i)).map((o) => o.label)
88
+ : q.options[selected].label;
89
+ confirmCurrent([...answers.slice(0, focus), answer, ...answers.slice(focus + 1)]);
90
+ }
91
+ }
92
+ else if (input === " ") {
93
+ if (q.multiSelect) {
94
+ if (selected < q.options.length) {
95
+ const gi = starts[focus] + selected;
96
+ setChecked((s) => {
97
+ const next = new Set(s);
98
+ if (next.has(gi))
99
+ next.delete(gi);
100
+ else
101
+ next.add(gi);
102
+ return next;
103
+ });
104
+ }
105
+ else if (customText[focus] !== null) {
106
+ setCustomText((c) => {
107
+ const nc = [...c];
108
+ nc[focus] = null;
109
+ return nc;
110
+ });
111
+ }
32
112
  }
33
113
  }
34
114
  else if (key.escape) {
35
- onAnswer("");
115
+ onAnswer(question.questions.map(() => ""));
116
+ }
117
+ else if (input && !key.ctrl && !key.meta) {
118
+ setText(input);
119
+ setSelected(q.options.length);
120
+ setInputting(true);
36
121
  }
37
122
  });
38
- if (mode === "input") {
39
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { color: "cyan", children: `? ${question.text}` }), _jsxs(Box, { borderStyle: "single", borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: text, onChange: setText, onSubmit: () => onAnswer(text) })] })] }));
40
- }
41
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { color: "cyan", children: `? ${question.text}` }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderBottom: false, borderColor: "gray", children: items.map((item, i) => (_jsx(Box, { children: _jsxs(Text, { color: i === selected ? "cyan" : undefined, children: [i === selected ? "▸ " : " ", item] }) }, item))) })] }));
123
+ const q = question.questions[focus];
124
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Box, { flexDirection: "row", children: question.questions.map((q, qi) => (_jsx(Text, { color: qi === focus ? "cyan" : "gray", bold: qi === focus, children: ` ${qi === focus ? "▸" : " "}[${confirmed[qi] ? "✓ " : ""}${q.header ?? `Q${qi + 1}`}]` }, qi))) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: "cyan", children: [`? ${q.question}`, q.multiSelect ? _jsx(Text, { dimColor: true, children: " (multi)" }) : null] }), q.options.map((opt, oi) => {
125
+ const gi = starts[focus] + oi;
126
+ const isSelected = oi === selected;
127
+ const isChecked = checked.has(gi);
128
+ const prefix = q.multiSelect ? (isChecked ? "[✓] " : "[ ] ") : isSelected ? "▸ " : " ";
129
+ const color = isSelected || isChecked ? "cyan" : undefined;
130
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: color, children: `${prefix}${opt.label}` }), opt.description ? _jsx(Text, { dimColor: true, children: ` ${opt.description}` }) : null] }, oi));
131
+ }), inputting ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: text, onChange: setText, onSubmit: () => {
132
+ setCustomText((c) => {
133
+ const nc = [...c];
134
+ nc[focus] = text;
135
+ return nc;
136
+ });
137
+ setInputting(false);
138
+ } })] })) : (_jsx(Text, { color: selected === q.options.length ? "cyan" : undefined, children: `${q.multiSelect ? (customText[focus] !== null ? "[✓] " : "[ ] ") : selected === q.options.length ? "▸ " : " "}${customText[focus] ? `${CUSTOM_LABEL}: ${customText[focus].slice(0, 40)}` : CUSTOM_LABEL}` }))] })] }));
42
139
  }
@@ -10,7 +10,7 @@ export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit,
10
10
  const ctxColor = pct >= 85 ? "red" : pct >= 60 ? "yellow" : "green";
11
11
  let hints;
12
12
  if (questionPending)
13
- hints = "↑↓ select · enter confirm · esc skip";
13
+ hints = "↑↓ select · tab switch · space toggle · enter confirm · esc skip";
14
14
  else if (running)
15
15
  hints = thinkingAvailable ? "esc stop · t thinking" : "esc stop";
16
16
  else
@@ -19,13 +19,18 @@ export const TimelineView = memo(function TimelineView({ entry }) {
19
19
  case "interrupted":
20
20
  return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25FC " }), _jsx(Text, { dimColor: true, children: "interrupted" })] }) }));
21
21
  case "question":
22
- if (entry.answer === null)
22
+ if (entry.questions.every((q) => q.answer === null))
23
23
  return null;
24
- return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: `? ${entry.text}` }), _jsx(Text, { dimColor: true, children: ` ⎿ ${entry.answer || "(skipped)"}` })] }));
24
+ return (_jsx(Box, { marginTop: 1, flexDirection: "column", children: entry.questions.map((q, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: `? ${q.header ? `[${q.header}] ` : ""}${q.question}` }), _jsx(Text, { dimColor: true, children: ` ⎿ ${formatAnswer(q)}` })] }, i))) }));
25
25
  case "notice":
26
26
  return (_jsx(Box, { children: _jsx(Text, { color: "blue", children: entry.text }) }));
27
27
  }
28
28
  });
29
+ function formatAnswer(q) {
30
+ if (Array.isArray(q.answer))
31
+ return q.answer.length ? q.answer.join(", ") : "(skipped)";
32
+ return q.answer || "(skipped)";
33
+ }
29
34
  function ToolEntry({ entry }) {
30
35
  const running = entry.result === null;
31
36
  const [on, setOn] = useState(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "react": "^19.2.8",
24
24
  "string-width": "^8.2.2",
25
25
  "zod": "^4.4.3",
26
- "@vietor/agent-core": "0.7.5"
26
+ "@vietor/agent-core": "0.8.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.1",