detached 4237dda

Merge e9101ea6208478221b0767eb9ff4486580ea9f16 into a9d674e0d05c093e5ff7dea21bc7d6723527b490

commit
4237dda69e7fd9370a7f0b12dc5804c2af94476d
author
ermite · · 2026-10-20 05:32 UTC
merge of
a9d674e + e9101ea · diff against the first parent
signature
unsigned
15 files · +868 -15
M README.md +2 -1
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
12+- eighteen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, ask_user_question, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - native MCP client for stdio and streamable HTTP servers, shared by the primary agent, orchestrator, and subagents; see [docs/mcp.md](docs/mcp.md).
@@ -275,6 +275,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
275275 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
276276 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
277277 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled.
278+- `ask_user_question`: ask up to 10 chained questions with optional single- or multi-select choices and custom text answers. Requires an interactive terminal; background agents cannot use it. Use arrows or j/k to move, digits to pick, Space to toggle multiple choices, Enter to confirm, Shift+Tab to go back, and Esc to decline (or leave custom-text editing).
278279 - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. Several steps may be `in_progress` at once to track parallel work such as concurrent sub-agents. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.
279280 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
280281
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
9 9
10 - one static binary.10 - one static binary.
11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.12+- eighteen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, ask_user_question, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - native MCP client for stdio and streamable HTTP servers, shared by the primary agent, orchestrator, and subagents; see [docs/mcp.md](docs/mcp.md).15 - native MCP client for stdio and streamable HTTP servers, shared by the primary agent, orchestrator, and subagents; see [docs/mcp.md](docs/mcp.md).
@@ -275,6 +275,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
275 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).275 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
276 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).276 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
277 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled.277 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled.
278+- `ask_user_question`: ask up to 10 chained questions with optional single- or multi-select choices and custom text answers. Requires an interactive terminal; background agents cannot use it. Use arrows or j/k to move, digits to pick, Space to toggle multiple choices, Enter to confirm, Shift+Tab to go back, and Esc to decline (or leave custom-text editing).
278 - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. Several steps may be `in_progress` at once to track parallel work such as concurrent sub-agents. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.279 - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. Several steps may be `in_progress` at once to track parallel work such as concurrent sub-agents. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.
279 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.280 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
280 281
M docs/extensions.md +2 -2
@@ -232,10 +232,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
232232
233233 Tool names live in the same namespace as built-in tools (`read`,
234234 `write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,
235-`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `plan`,
235+`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `plan`, `ask_user_question`,
236236 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
237237 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
238-`update_goal`, and `plan`, is reserved even when it is unavailable, so an
238+`update_goal`, `plan`, and `ask_user_question`, is reserved even when it is unavailable, so an
239239 extension cannot claim or replace it.
240240
241241 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
@@ -232,10 +232,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
232 232
233 Tool names live in the same namespace as built-in tools (`read`,233 Tool names live in the same namespace as built-in tools (`read`,
234 `write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,234 `write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,
235-`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `plan`,235+`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `plan`, `ask_user_question`,
236 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.236 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
237 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,237 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
238-`update_goal`, and `plan`, is reserved even when it is unavailable, so an238+`update_goal`, `plan`, and `ask_user_question`, is reserved even when it is unavailable, so an
239 extension cannot claim or replace it.239 extension cannot claim or replace it.
240 240
241 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:241 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
M packages/agent/build.go +8 -7
@@ -134,7 +134,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
134134 // Web capability and other native names remain reserved even when their
135135 // policy excludes the current session. An extension must not turn a
136136 // normal CLI opt-out into a differently implemented capability.
137- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName {
137+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName || info.Name == tools.AskUserQuestionToolName {
138138 continue
139139 }
140140 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1432,11 +1432,12 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
14321432 Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
14331433 Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
14341434 },
1435- "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1436- "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1437- "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1438- "update_goal": &tools.UpdateGoalTool{},
1439- "plan": &tools.PlanTool{},
1435+ "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1436+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1437+ "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1438+ "update_goal": &tools.UpdateGoalTool{},
1439+ "plan": &tools.PlanTool{},
1440+ "ask_user_question": &tools.AskUserQuestionTool{},
14401441 }
14411442 if webSearchAllowedForRegistry(args) {
14421443 for name, tool := range tools.NewWebTools() {
@@ -1607,7 +1608,7 @@ func subagentActionAllowed(args Args, action string) bool {
16071608 return false
16081609 }
16091610
1610-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
1611+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan", "ask_user_question"}
16111612
16121613 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
16131614 var out []ToolSummary
@@ -134,7 +134,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
134 // Web capability and other native names remain reserved even when their134 // Web capability and other native names remain reserved even when their
135 // policy excludes the current session. An extension must not turn a135 // policy excludes the current session. An extension must not turn a
136 // normal CLI opt-out into a differently implemented capability.136 // normal CLI opt-out into a differently implemented capability.
137- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName {137+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName || info.Name == tools.AskUserQuestionToolName {
138 continue138 continue
139 }139 }
140 if _, exists := r.ToolRegistry[info.Name]; exists {140 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1432,11 +1432,12 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1432 Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},1432 Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1433 Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},1433 Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1434 },1434 },
1435- "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},1435+ "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1436- "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},1436+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1437- "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1437+ "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1438- "update_goal": &tools.UpdateGoalTool{},1438+ "update_goal": &tools.UpdateGoalTool{},
1439- "plan": &tools.PlanTool{},1439+ "plan": &tools.PlanTool{},
1440+ "ask_user_question": &tools.AskUserQuestionTool{},
1440 }1441 }
1441 if webSearchAllowedForRegistry(args) {1442 if webSearchAllowedForRegistry(args) {
1442 for name, tool := range tools.NewWebTools() {1443 for name, tool := range tools.NewWebTools() {
@@ -1607,7 +1608,7 @@ func subagentActionAllowed(args Args, action string) bool {
1607 return false1608 return false
1608 }1609 }
1609 1610
1610-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}1611+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan", "ask_user_question"}
1611 1612
1612 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1613 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1613 var out []ToolSummary1614 var out []ToolSummary
M packages/agent/cli.go +21 -3
@@ -1657,6 +1657,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16571657 // host after constructing it below so startup alerts can be buffered.
16581658 var iv *modes.Interactive
16591659 extHooks := &interactiveExtHooks{}
1660+ attachQuestionAsker := func(reg core.Registry) {
1661+ if tool, ok := reg[tools.AskUserQuestionToolName].(*tools.AskUserQuestionTool); ok {
1662+ tool.Asker = func() tools.QuestionAsker {
1663+ host := extHooks.iv()
1664+ if host == nil {
1665+ return nil
1666+ }
1667+ return host
1668+ }
1669+ }
1670+ }
1671+ attachQuestionAsker(r.ToolRegistry)
16601672 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
16611673 var startupExtensionErrors []string
16621674 // --ext paths first so they win against installed extensions of
@@ -1747,8 +1759,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17471759 _ = closeSubagentRuntimeFresh(runtime)
17481760 }()
17491761
1750- prepareInteractiveRegistry := runtime.PrepareRegistry
1751- prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
1762+ prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1763+ attachQuestionAsker(reg)
1764+ return runtime.PrepareRegistry(reg)
1765+ }
1766+ prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) {
1767+ attachQuestionAsker(reg)
1768+ runtime.PrepareResolvedRegistry(reg, policy)
1769+ }
17521770 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
17531771 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
17541772 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
@@ -1783,7 +1801,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17831801 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
17841802 effectiveArgs = r.ModifiedArgs
17851803 }
1786- if confirmGate != nil {
1804+ if confirmGate != nil && call.Name != tools.AskUserQuestionToolName {
17871805 var content strings.Builder
17881806 // The remembered "always allow" grant is keyed by tool name,
17891807 // or a finer per-action key when the tool asks for one (the
@@ -1657,6 +1657,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1657 // host after constructing it below so startup alerts can be buffered.1657 // host after constructing it below so startup alerts can be buffered.
1658 var iv *modes.Interactive1658 var iv *modes.Interactive
1659 extHooks := &interactiveExtHooks{}1659 extHooks := &interactiveExtHooks{}
1660+ attachQuestionAsker := func(reg core.Registry) {
1661+ if tool, ok := reg[tools.AskUserQuestionToolName].(*tools.AskUserQuestionTool); ok {
1662+ tool.Asker = func() tools.QuestionAsker {
1663+ host := extHooks.iv()
1664+ if host == nil {
1665+ return nil
1666+ }
1667+ return host
1668+ }
1669+ }
1670+ }
1671+ attachQuestionAsker(r.ToolRegistry)
1660 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)1672 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1661 var startupExtensionErrors []string1673 var startupExtensionErrors []string
1662 // --ext paths first so they win against installed extensions of1674 // --ext paths first so they win against installed extensions of
@@ -1747,8 +1759,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1747 _ = closeSubagentRuntimeFresh(runtime)1759 _ = closeSubagentRuntimeFresh(runtime)
1748 }()1760 }()
1749 1761
1750- prepareInteractiveRegistry := runtime.PrepareRegistry1762+ prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1751- prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry1763+ attachQuestionAsker(reg)
1764+ return runtime.PrepareRegistry(reg)
1765+ }
1766+ prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) {
1767+ attachQuestionAsker(reg)
1768+ runtime.PrepareResolvedRegistry(reg, policy)
1769+ }
1752 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)1770 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1753 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)1771 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1754 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)1772 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
@@ -1783,7 +1801,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1783 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {1801 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1784 effectiveArgs = r.ModifiedArgs1802 effectiveArgs = r.ModifiedArgs
1785 }1803 }
1786- if confirmGate != nil {1804+ if confirmGate != nil && call.Name != tools.AskUserQuestionToolName {
1787 var content strings.Builder1805 var content strings.Builder
1788 // The remembered "always allow" grant is keyed by tool name,1806 // The remembered "always allow" grant is keyed by tool name,
1789 // or a finer per-action key when the tool asks for one (the1807 // or a finer per-action key when the tool asks for one (the
M packages/agent/interactive_extension_policy_test.go +15 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/zut/packages/agent/extensions"
1010 "github.com/bnema/zut/packages/agent/modes"
11+ "github.com/bnema/zut/packages/agent/tools"
1112 "github.com/bnema/zut/packages/core"
1213 )
1314
@@ -55,3 +56,17 @@ func TestResidentRegistryRejectsInteractiveExtensionTools(t *testing.T) {
5556 t.Fatalf("resident registry accepted interactive tool: %v", err)
5657 }
5758 }
59+
60+func TestNativeQuestionsExcludedFromBackgroundRegistries(t *testing.T) {
61+ tool := &tools.AskUserQuestionTool{}
62+ if !extensions.IsInteractiveTool(tool) {
63+ t.Fatal("native question tool is not interactive")
64+ }
65+ reg := core.Registry{tool.Name(): tool}
66+ if _, ok := scheduledSessionRegistry(core.Registry{tool.Name(): tool})[tool.Name()]; ok {
67+ t.Fatal("scheduled registry retained questions")
68+ }
69+ if _, err := residentChildRegistry(reg, []string{tool.Name()}); err == nil || !strings.Contains(err.Error(), "interactive") {
70+ t.Fatalf("resident registry: %v", err)
71+ }
72+}
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/zut/packages/agent/extensions"9 "github.com/bnema/zut/packages/agent/extensions"
10 "github.com/bnema/zut/packages/agent/modes"10 "github.com/bnema/zut/packages/agent/modes"
11+ "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
12 )13 )
13 14
@@ -55,3 +56,17 @@ func TestResidentRegistryRejectsInteractiveExtensionTools(t *testing.T) {
55 t.Fatalf("resident registry accepted interactive tool: %v", err)56 t.Fatalf("resident registry accepted interactive tool: %v", err)
56 }57 }
57 }58 }
59+
60+func TestNativeQuestionsExcludedFromBackgroundRegistries(t *testing.T) {
61+ tool := &tools.AskUserQuestionTool{}
62+ if !extensions.IsInteractiveTool(tool) {
63+ t.Fatal("native question tool is not interactive")
64+ }
65+ reg := core.Registry{tool.Name(): tool}
66+ if _, ok := scheduledSessionRegistry(core.Registry{tool.Name(): tool})[tool.Name()]; ok {
67+ t.Fatal("scheduled registry retained questions")
68+ }
69+ if _, err := residentChildRegistry(reg, []string{tool.Name()}); err == nil || !strings.Contains(err.Error(), "interactive") {
70+ t.Fatalf("resident registry: %v", err)
71+ }
72+}
M packages/agent/modes/interactive.go +2 -0
@@ -852,6 +852,7 @@ type Interactive struct {
852852 btwDialog *btwDialog
853853 skillsDialog *skillsDialog
854854 changelogDialog *changelogDialog
855+ questionDialog *questionDialog
855856 confirmDialog *confirmDialog
856857 logoutDialog *logoutDialog
857858 telegramDialog *telegramDialog
@@ -1109,6 +1110,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
11091110 btwDialog: newBtwDialog(),
11101111 skillsDialog: newSkillsDialog(),
11111112 changelogDialog: newChangelogDialog(),
1113+ questionDialog: newQuestionDialog(),
11121114 confirmDialog: newConfirmDialog(),
11131115 logoutDialog: newLogoutDialog(),
11141116 telegramDialog: newTelegramDialog(),
@@ -852,6 +852,7 @@ type Interactive struct {
852 btwDialog *btwDialog852 btwDialog *btwDialog
853 skillsDialog *skillsDialog853 skillsDialog *skillsDialog
854 changelogDialog *changelogDialog854 changelogDialog *changelogDialog
855+ questionDialog *questionDialog
855 confirmDialog *confirmDialog856 confirmDialog *confirmDialog
856 logoutDialog *logoutDialog857 logoutDialog *logoutDialog
857 telegramDialog *telegramDialog858 telegramDialog *telegramDialog
@@ -1109,6 +1110,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
1109 btwDialog: newBtwDialog(),1110 btwDialog: newBtwDialog(),
1110 skillsDialog: newSkillsDialog(),1111 skillsDialog: newSkillsDialog(),
1111 changelogDialog: newChangelogDialog(),1112 changelogDialog: newChangelogDialog(),
1113+ questionDialog: newQuestionDialog(),
1112 confirmDialog: newConfirmDialog(),1114 confirmDialog: newConfirmDialog(),
1113 logoutDialog: newLogoutDialog(),1115 logoutDialog: newLogoutDialog(),
1114 telegramDialog: newTelegramDialog(),1116 telegramDialog: newTelegramDialog(),
M packages/agent/modes/interactive_input_routing.go +6 -0
@@ -57,6 +57,7 @@ func (i *Interactive) confirmChildActive() bool {
5757 func (i *Interactive) confirmChildActiveLocked() bool {
5858 return len(i.helpBlock) > 0 ||
5959 len(i.extNotes) > 0 ||
60+ i.questionDialog.Active() ||
6061 i.dialog.Active() ||
6162 i.modelDialog.Active() ||
6263 i.llamaDialog.Active() ||
@@ -127,6 +128,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
127128 i.invalidate()
128129 return false
129130 }
131+ if i.questionDialog.Active() && !i.confirmDialog.Active() {
132+ i.questionDialog.HandleKey(k)
133+ i.invalidate()
134+ return false
135+ }
130136 if i.dialog.Active() {
131137 if k.Kind == tui.KeyCtrlC {
132138 i.dialog.Close()
@@ -57,6 +57,7 @@ func (i *Interactive) confirmChildActive() bool {
57 func (i *Interactive) confirmChildActiveLocked() bool {57 func (i *Interactive) confirmChildActiveLocked() bool {
58 return len(i.helpBlock) > 0 ||58 return len(i.helpBlock) > 0 ||
59 len(i.extNotes) > 0 ||59 len(i.extNotes) > 0 ||
60+ i.questionDialog.Active() ||
60 i.dialog.Active() ||61 i.dialog.Active() ||
61 i.modelDialog.Active() ||62 i.modelDialog.Active() ||
62 i.llamaDialog.Active() ||63 i.llamaDialog.Active() ||
@@ -127,6 +128,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
127 i.invalidate()128 i.invalidate()
128 return false129 return false
129 }130 }
131+ if i.questionDialog.Active() && !i.confirmDialog.Active() {
132+ i.questionDialog.HandleKey(k)
133+ i.invalidate()
134+ return false
135+ }
130 if i.dialog.Active() {136 if i.dialog.Active() {
131 if k.Kind == tui.KeyCtrlC {137 if k.Kind == tui.KeyCtrlC {
132 i.dialog.Close()138 i.dialog.Close()
M packages/agent/modes/interactive_rendering.go +6 -2
@@ -464,6 +464,9 @@ func (i *Interactive) redraw() {
464464 var dialog []string
465465 var dialogID string
466466 switch {
467+ case i.questionDialog.Active() && !i.confirmDialog.Active():
468+ dialogID = "questions"
469+ dialog = i.questionDialog.Render(i.cfg.Theme, dialogWidth)
467470 case i.dialog.Active():
468471 dialogID = "login"
469472 dialog = i.dialog.Render(i.cfg.Theme, dialogWidth)
@@ -537,8 +540,9 @@ func (i *Interactive) redraw() {
537540 dialogTitle := floatingOverlayTitle(dialogID)
538541 dialogRemovedTopRows := 0
539542 if len(dialog) > 0 {
540- // The error viewer budgets every content row for its scroll viewport.
541- if dialogID != "rescue-error" {
543+ // The error viewer budgets every content row for its scroll viewport;
544+ // the question dialog places its own spacing rows.
545+ if dialogID != "rescue-error" && dialogID != "questions" {
542546 dialog = padDialogFrame(dialog)
543547 }
544548 frameTitle, body, removedTopRows := floatingDialogBody(dialog)
@@ -464,6 +464,9 @@ func (i *Interactive) redraw() {
464 var dialog []string464 var dialog []string
465 var dialogID string465 var dialogID string
466 switch {466 switch {
467+ case i.questionDialog.Active() && !i.confirmDialog.Active():
468+ dialogID = "questions"
469+ dialog = i.questionDialog.Render(i.cfg.Theme, dialogWidth)
467 case i.dialog.Active():470 case i.dialog.Active():
468 dialogID = "login"471 dialogID = "login"
469 dialog = i.dialog.Render(i.cfg.Theme, dialogWidth)472 dialog = i.dialog.Render(i.cfg.Theme, dialogWidth)
@@ -537,8 +540,9 @@ func (i *Interactive) redraw() {
537 dialogTitle := floatingOverlayTitle(dialogID)540 dialogTitle := floatingOverlayTitle(dialogID)
538 dialogRemovedTopRows := 0541 dialogRemovedTopRows := 0
539 if len(dialog) > 0 {542 if len(dialog) > 0 {
540- // The error viewer budgets every content row for its scroll viewport.543+ // The error viewer budgets every content row for its scroll viewport;
541- if dialogID != "rescue-error" {544+ // the question dialog places its own spacing rows.
545+ if dialogID != "rescue-error" && dialogID != "questions" {
542 dialog = padDialogFrame(dialog)546 dialog = padDialogFrame(dialog)
543 }547 }
544 frameTitle, body, removedTopRows := floatingDialogBody(dialog)548 frameTitle, body, removedTopRows := floatingDialogBody(dialog)
A packages/agent/modes/question_dialog.go +282 -0
@@ -0,0 +1,282 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/zut/packages/agent/extproto"
10+ "github.com/bnema/zut/packages/agent/tools"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+type questionResponse struct {
15+ answers []tools.QuestionAnswer
16+ err error
17+}
18+type questionState struct {
19+ cursor int
20+ checked map[int]bool
21+ text string
22+ editing, answered bool
23+ answer tools.QuestionAnswer
24+}
25+type questionRequest struct {
26+ questions []tools.Question
27+ states []questionState
28+ index int
29+ hint string
30+ resp chan questionResponse
31+}
32+type questionDialog struct {
33+ mu sync.Mutex
34+ pending []*questionRequest
35+}
36+
37+func newQuestionDialog() *questionDialog { return &questionDialog{} }
38+func (d *questionDialog) Enqueue(qs []tools.Question) *questionRequest {
39+ r := &questionRequest{questions: qs, states: make([]questionState, len(qs)), resp: make(chan questionResponse, 1)}
40+ for n, q := range qs {
41+ r.states[n] = questionState{checked: map[int]bool{}, editing: len(q.Options) == 0}
42+ }
43+ d.mu.Lock()
44+ d.pending = append(d.pending, r)
45+ d.mu.Unlock()
46+ return r
47+}
48+func (d *questionDialog) Active() bool {
49+ if d == nil {
50+ return false
51+ }
52+ d.mu.Lock()
53+ defer d.mu.Unlock()
54+ return len(d.pending) > 0
55+}
56+func (d *questionDialog) Remove(r *questionRequest) {
57+ d.mu.Lock()
58+ defer d.mu.Unlock()
59+ for n, p := range d.pending {
60+ if p == r {
61+ d.pending = append(d.pending[:n], d.pending[n+1:]...)
62+ return
63+ }
64+ }
65+}
66+func (d *questionDialog) HandleKey(k tui.Key) {
67+ d.mu.Lock()
68+ defer d.mu.Unlock()
69+ if len(d.pending) == 0 {
70+ return
71+ }
72+ r := d.pending[0]
73+ s := &r.states[r.index]
74+ q := r.questions[r.index]
75+ finish := func(err error) {
76+ answers := make([]tools.QuestionAnswer, len(r.states))
77+ for n, st := range r.states {
78+ answers[n] = st.answer
79+ }
80+ d.pending = d.pending[1:]
81+ r.resp <- questionResponse{answers, err}
82+ }
83+ advance := func() {
84+ s.answered = true
85+ r.hint = ""
86+ if r.index == len(r.questions)-1 {
87+ finish(nil)
88+ } else {
89+ r.index++
90+ }
91+ }
92+ if k.Kind == tui.KeyCtrlC {
93+ finish(tools.ErrQuestionsDeclined)
94+ return
95+ }
96+ if k.Kind == tui.KeyShiftTab || (!s.editing && k.Kind == tui.KeyLeft) {
97+ if r.index > 0 {
98+ r.index--
99+ r.hint = ""
100+ }
101+ return
102+ }
103+ if !s.editing && (k.Kind == tui.KeyTab || k.Kind == tui.KeyRight) {
104+ if s.answered && r.index < len(r.questions)-1 {
105+ r.index++
106+ }
107+ return
108+ }
109+ if s.editing {
110+ switch k.Kind {
111+ case tui.KeyEsc:
112+ if len(q.Options) == 0 {
113+ finish(tools.ErrQuestionsDeclined)
114+ } else {
115+ s.editing = false
116+ }
117+ case tui.KeyEnter:
118+ if strings.TrimSpace(s.text) == "" {
119+ r.hint = "Enter an answer."
120+ } else {
121+ s.answer = tools.QuestionAnswer{Custom: strings.TrimSpace(s.text)}
122+ advance()
123+ }
124+ case tui.KeyBackspace:
125+ runes := []rune(s.text)
126+ if len(runes) > 0 {
127+ s.text = string(runes[:len(runes)-1])
128+ s.answered = false
129+ }
130+ case tui.KeyRune:
131+ s.text += strings.NewReplacer("\r", " ", "\n", " ").Replace(string(k.Rune))
132+ s.answered = false
133+ case tui.KeyPaste:
134+ s.text += strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(k.Paste)
135+ if k.Paste != "" {
136+ s.answered = false
137+ }
138+ }
139+ return
140+ }
141+ if k.Kind == tui.KeyEsc {
142+ finish(tools.ErrQuestionsDeclined)
143+ return
144+ }
145+ if k.Kind == tui.KeyUp || (k.Kind == tui.KeyRune && k.Rune == 'k') {
146+ s.cursor = max(0, s.cursor-1)
147+ return
148+ }
149+ if k.Kind == tui.KeyDown || (k.Kind == tui.KeyRune && k.Rune == 'j') {
150+ s.cursor = min(len(q.Options), s.cursor+1)
151+ return
152+ }
153+ if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '9' {
154+ n := int(k.Rune - '1')
155+ if n < len(q.Options) {
156+ s.cursor = n
157+ if q.MultiSelect {
158+ s.checked[n] = !s.checked[n]
159+ s.answered = false
160+ return
161+ }
162+ k.Kind = tui.KeyEnter
163+ }
164+ }
165+ if k.Kind == tui.KeyRune && k.Rune == ' ' && q.MultiSelect && s.cursor < len(q.Options) {
166+ s.checked[s.cursor] = !s.checked[s.cursor]
167+ s.answered = false
168+ return
169+ }
170+ if k.Kind != tui.KeyEnter {
171+ return
172+ }
173+ if s.cursor == len(q.Options) {
174+ s.editing = true
175+ return
176+ }
177+ a := tools.QuestionAnswer{}
178+ if q.MultiSelect {
179+ for n, o := range q.Options {
180+ if s.checked[n] {
181+ a.Selected = append(a.Selected, o.Label)
182+ }
183+ }
184+ if len(a.Selected) == 0 {
185+ r.hint = "Select at least one option (Space)."
186+ return
187+ }
188+ } else {
189+ a.Selected = []string{q.Options[s.cursor].Label}
190+ }
191+ s.answer = a
192+ advance()
193+}
194+func (d *questionDialog) Render(th tui.Theme, width int) []string {
195+ d.mu.Lock()
196+ defer d.mu.Unlock()
197+ if len(d.pending) == 0 {
198+ return nil
199+ }
200+ width = max(1, width)
201+ r := d.pending[0]
202+ q := r.questions[r.index]
203+ s := r.states[r.index]
204+ title := fmt.Sprintf("Question %d/%d", r.index+1, len(r.questions))
205+ if q.Header != "" {
206+ title += " · " + q.Header
207+ }
208+ lines := []string{frameHeader(th, title, width)}
209+ // Like wrapDialogTextRows, split logical lines before wrapping, but keep
210+ // this dialog's unindented layout. Style physical rows after wrapping.
211+ addRows := func(text string, focused bool) {
212+ for _, line := range strings.Split(text, "\n") {
213+ for _, row := range tui.WrapANSILine(line, width) {
214+ if focused {
215+ row = th.PadHighlight(stripANSIBytes(row), width)
216+ }
217+ lines = append(lines, row)
218+ }
219+ }
220+ }
221+ add := func(text string) { addRows(text, false) }
222+ add(q.Question)
223+ lines = append(lines, "")
224+ footer := th.FGColor(th.Muted, "↑/↓ j/k move · 1–9 pick · Space toggle · Enter confirm · ←/Shift+Tab back · Tab next · Esc decline")
225+ if s.editing {
226+ addRows("> "+s.text+"▏", true)
227+ footer = th.FGColor(th.Muted, "Enter submit · Esc back/cancel · Shift+Tab previous")
228+ } else {
229+ for n := 0; n <= len(q.Options); n++ {
230+ label := "Other…"
231+ if n < len(q.Options) {
232+ label = fmt.Sprintf("%d. %s", n+1, q.Options[n].Label)
233+ if q.MultiSelect {
234+ mark := "[ ] "
235+ if s.checked[n] {
236+ mark = "[x] "
237+ }
238+ label = mark + label
239+ }
240+ if q.Options[n].Description != "" {
241+ label += " — " + th.FGColor(th.Muted, q.Options[n].Description)
242+ }
243+ }
244+ prefix := " "
245+ if n == s.cursor {
246+ prefix = "› "
247+ }
248+ addRows(prefix+label, n == s.cursor)
249+ }
250+ }
251+ if r.hint != "" {
252+ add(th.FGColor(th.Muted, r.hint))
253+ }
254+ lines = append(lines, "")
255+ add(footer)
256+ lines = append(lines, frameRule(th, width))
257+ // Frame helpers may retain their minimum decoration width. Wrap these too.
258+ var out []string
259+ for _, line := range lines {
260+ for _, logical := range strings.Split(line, "\n") {
261+ out = append(out, tui.WrapANSILine(logical, width)...)
262+ }
263+ }
264+ return out
265+}
266+
267+// AskQuestions blocks only the calling agent goroutine, never the UI loop.
268+func (i *Interactive) AskQuestions(ctx context.Context, qs []tools.Question) ([]tools.QuestionAnswer, error) {
269+ if err := ctx.Err(); err != nil {
270+ return nil, err
271+ }
272+ r := i.questionDialog.Enqueue(qs)
273+ i.Alert("", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "waiting for answers"})
274+ i.invalidate()
275+ defer func() { i.questionDialog.Remove(r); i.invalidate() }()
276+ select {
277+ case <-ctx.Done():
278+ return nil, ctx.Err()
279+ case response := <-r.resp:
280+ return response.answers, response.err
281+ }
282+}
@@ -0,0 +1,282 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/zut/packages/agent/extproto"
10+ "github.com/bnema/zut/packages/agent/tools"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+type questionResponse struct {
15+ answers []tools.QuestionAnswer
16+ err error
17+}
18+type questionState struct {
19+ cursor int
20+ checked map[int]bool
21+ text string
22+ editing, answered bool
23+ answer tools.QuestionAnswer
24+}
25+type questionRequest struct {
26+ questions []tools.Question
27+ states []questionState
28+ index int
29+ hint string
30+ resp chan questionResponse
31+}
32+type questionDialog struct {
33+ mu sync.Mutex
34+ pending []*questionRequest
35+}
36+
37+func newQuestionDialog() *questionDialog { return &questionDialog{} }
38+func (d *questionDialog) Enqueue(qs []tools.Question) *questionRequest {
39+ r := &questionRequest{questions: qs, states: make([]questionState, len(qs)), resp: make(chan questionResponse, 1)}
40+ for n, q := range qs {
41+ r.states[n] = questionState{checked: map[int]bool{}, editing: len(q.Options) == 0}
42+ }
43+ d.mu.Lock()
44+ d.pending = append(d.pending, r)
45+ d.mu.Unlock()
46+ return r
47+}
48+func (d *questionDialog) Active() bool {
49+ if d == nil {
50+ return false
51+ }
52+ d.mu.Lock()
53+ defer d.mu.Unlock()
54+ return len(d.pending) > 0
55+}
56+func (d *questionDialog) Remove(r *questionRequest) {
57+ d.mu.Lock()
58+ defer d.mu.Unlock()
59+ for n, p := range d.pending {
60+ if p == r {
61+ d.pending = append(d.pending[:n], d.pending[n+1:]...)
62+ return
63+ }
64+ }
65+}
66+func (d *questionDialog) HandleKey(k tui.Key) {
67+ d.mu.Lock()
68+ defer d.mu.Unlock()
69+ if len(d.pending) == 0 {
70+ return
71+ }
72+ r := d.pending[0]
73+ s := &r.states[r.index]
74+ q := r.questions[r.index]
75+ finish := func(err error) {
76+ answers := make([]tools.QuestionAnswer, len(r.states))
77+ for n, st := range r.states {
78+ answers[n] = st.answer
79+ }
80+ d.pending = d.pending[1:]
81+ r.resp <- questionResponse{answers, err}
82+ }
83+ advance := func() {
84+ s.answered = true
85+ r.hint = ""
86+ if r.index == len(r.questions)-1 {
87+ finish(nil)
88+ } else {
89+ r.index++
90+ }
91+ }
92+ if k.Kind == tui.KeyCtrlC {
93+ finish(tools.ErrQuestionsDeclined)
94+ return
95+ }
96+ if k.Kind == tui.KeyShiftTab || (!s.editing && k.Kind == tui.KeyLeft) {
97+ if r.index > 0 {
98+ r.index--
99+ r.hint = ""
100+ }
101+ return
102+ }
103+ if !s.editing && (k.Kind == tui.KeyTab || k.Kind == tui.KeyRight) {
104+ if s.answered && r.index < len(r.questions)-1 {
105+ r.index++
106+ }
107+ return
108+ }
109+ if s.editing {
110+ switch k.Kind {
111+ case tui.KeyEsc:
112+ if len(q.Options) == 0 {
113+ finish(tools.ErrQuestionsDeclined)
114+ } else {
115+ s.editing = false
116+ }
117+ case tui.KeyEnter:
118+ if strings.TrimSpace(s.text) == "" {
119+ r.hint = "Enter an answer."
120+ } else {
121+ s.answer = tools.QuestionAnswer{Custom: strings.TrimSpace(s.text)}
122+ advance()
123+ }
124+ case tui.KeyBackspace:
125+ runes := []rune(s.text)
126+ if len(runes) > 0 {
127+ s.text = string(runes[:len(runes)-1])
128+ s.answered = false
129+ }
130+ case tui.KeyRune:
131+ s.text += strings.NewReplacer("\r", " ", "\n", " ").Replace(string(k.Rune))
132+ s.answered = false
133+ case tui.KeyPaste:
134+ s.text += strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(k.Paste)
135+ if k.Paste != "" {
136+ s.answered = false
137+ }
138+ }
139+ return
140+ }
141+ if k.Kind == tui.KeyEsc {
142+ finish(tools.ErrQuestionsDeclined)
143+ return
144+ }
145+ if k.Kind == tui.KeyUp || (k.Kind == tui.KeyRune && k.Rune == 'k') {
146+ s.cursor = max(0, s.cursor-1)
147+ return
148+ }
149+ if k.Kind == tui.KeyDown || (k.Kind == tui.KeyRune && k.Rune == 'j') {
150+ s.cursor = min(len(q.Options), s.cursor+1)
151+ return
152+ }
153+ if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '9' {
154+ n := int(k.Rune - '1')
155+ if n < len(q.Options) {
156+ s.cursor = n
157+ if q.MultiSelect {
158+ s.checked[n] = !s.checked[n]
159+ s.answered = false
160+ return
161+ }
162+ k.Kind = tui.KeyEnter
163+ }
164+ }
165+ if k.Kind == tui.KeyRune && k.Rune == ' ' && q.MultiSelect && s.cursor < len(q.Options) {
166+ s.checked[s.cursor] = !s.checked[s.cursor]
167+ s.answered = false
168+ return
169+ }
170+ if k.Kind != tui.KeyEnter {
171+ return
172+ }
173+ if s.cursor == len(q.Options) {
174+ s.editing = true
175+ return
176+ }
177+ a := tools.QuestionAnswer{}
178+ if q.MultiSelect {
179+ for n, o := range q.Options {
180+ if s.checked[n] {
181+ a.Selected = append(a.Selected, o.Label)
182+ }
183+ }
184+ if len(a.Selected) == 0 {
185+ r.hint = "Select at least one option (Space)."
186+ return
187+ }
188+ } else {
189+ a.Selected = []string{q.Options[s.cursor].Label}
190+ }
191+ s.answer = a
192+ advance()
193+}
194+func (d *questionDialog) Render(th tui.Theme, width int) []string {
195+ d.mu.Lock()
196+ defer d.mu.Unlock()
197+ if len(d.pending) == 0 {
198+ return nil
199+ }
200+ width = max(1, width)
201+ r := d.pending[0]
202+ q := r.questions[r.index]
203+ s := r.states[r.index]
204+ title := fmt.Sprintf("Question %d/%d", r.index+1, len(r.questions))
205+ if q.Header != "" {
206+ title += " · " + q.Header
207+ }
208+ lines := []string{frameHeader(th, title, width)}
209+ // Like wrapDialogTextRows, split logical lines before wrapping, but keep
210+ // this dialog's unindented layout. Style physical rows after wrapping.
211+ addRows := func(text string, focused bool) {
212+ for _, line := range strings.Split(text, "\n") {
213+ for _, row := range tui.WrapANSILine(line, width) {
214+ if focused {
215+ row = th.PadHighlight(stripANSIBytes(row), width)
216+ }
217+ lines = append(lines, row)
218+ }
219+ }
220+ }
221+ add := func(text string) { addRows(text, false) }
222+ add(q.Question)
223+ lines = append(lines, "")
224+ footer := th.FGColor(th.Muted, "↑/↓ j/k move · 1–9 pick · Space toggle · Enter confirm · ←/Shift+Tab back · Tab next · Esc decline")
225+ if s.editing {
226+ addRows("> "+s.text+"▏", true)
227+ footer = th.FGColor(th.Muted, "Enter submit · Esc back/cancel · Shift+Tab previous")
228+ } else {
229+ for n := 0; n <= len(q.Options); n++ {
230+ label := "Other…"
231+ if n < len(q.Options) {
232+ label = fmt.Sprintf("%d. %s", n+1, q.Options[n].Label)
233+ if q.MultiSelect {
234+ mark := "[ ] "
235+ if s.checked[n] {
236+ mark = "[x] "
237+ }
238+ label = mark + label
239+ }
240+ if q.Options[n].Description != "" {
241+ label += " — " + th.FGColor(th.Muted, q.Options[n].Description)
242+ }
243+ }
244+ prefix := " "
245+ if n == s.cursor {
246+ prefix = "› "
247+ }
248+ addRows(prefix+label, n == s.cursor)
249+ }
250+ }
251+ if r.hint != "" {
252+ add(th.FGColor(th.Muted, r.hint))
253+ }
254+ lines = append(lines, "")
255+ add(footer)
256+ lines = append(lines, frameRule(th, width))
257+ // Frame helpers may retain their minimum decoration width. Wrap these too.
258+ var out []string
259+ for _, line := range lines {
260+ for _, logical := range strings.Split(line, "\n") {
261+ out = append(out, tui.WrapANSILine(logical, width)...)
262+ }
263+ }
264+ return out
265+}
266+
267+// AskQuestions blocks only the calling agent goroutine, never the UI loop.
268+func (i *Interactive) AskQuestions(ctx context.Context, qs []tools.Question) ([]tools.QuestionAnswer, error) {
269+ if err := ctx.Err(); err != nil {
270+ return nil, err
271+ }
272+ r := i.questionDialog.Enqueue(qs)
273+ i.Alert("", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "waiting for answers"})
274+ i.invalidate()
275+ defer func() { i.questionDialog.Remove(r); i.invalidate() }()
276+ select {
277+ case <-ctx.Done():
278+ return nil, ctx.Err()
279+ case response := <-r.resp:
280+ return response.answers, response.err
281+ }
282+}
A packages/agent/modes/question_dialog_test.go +311 -0
@@ -0,0 +1,311 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "github.com/bnema/zut/packages/agent/tools"
7+ "github.com/bnema/zut/packages/tui"
8+ "github.com/mattn/go-runewidth"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestQuestionDialogChain(t *testing.T) {
14+ d := newQuestionDialog()
15+ opts := []tools.QuestionOption{{Label: "A"}, {Label: "B"}}
16+ r := d.Enqueue([]tools.Question{{Question: "Which?", Options: opts}, {Question: "Many?", Options: opts, MultiSelect: true}, {Question: "Explain?"}})
17+ key := func(k tui.KeyKind) { d.HandleKey(tui.Key{Kind: k}) }
18+ runeKey := func(c rune) { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: c}) }
19+ if !strings.Contains(strings.Join(d.Render(tui.Theme{}, 80), "\n"), "Question 1/3") {
20+ t.Fatal("missing counter")
21+ }
22+ runeKey('2')
23+ if r.index != 1 || r.states[0].answer.Selected[0] != "B" {
24+ t.Fatal("single pick")
25+ }
26+ key(tui.KeyEnter)
27+ if r.hint == "" || r.index != 1 {
28+ t.Fatal("empty multi accepted")
29+ }
30+ runeKey(' ')
31+ key(tui.KeyDown)
32+ runeKey(' ')
33+ key(tui.KeyEnter)
34+ if r.index != 2 || len(r.states[1].answer.Selected) != 2 {
35+ t.Fatal("multi pick")
36+ }
37+ runeKey('界')
38+ key(tui.KeyShiftTab)
39+ if !r.states[1].checked[0] || !r.states[1].checked[1] {
40+ t.Fatal("checks lost")
41+ }
42+ key(tui.KeyLeft)
43+ if r.states[0].cursor != 1 {
44+ t.Fatal("cursor lost")
45+ }
46+ key(tui.KeyTab)
47+ key(tui.KeyTab)
48+ if r.states[2].text != "界" {
49+ t.Fatal("text lost")
50+ }
51+ key(tui.KeyEnter)
52+ response := <-r.resp
53+ if response.err != nil || response.answers[2].Custom != "界" || d.Active() {
54+ t.Fatalf("finish %+v", response)
55+ }
56+}
57+func TestQuestionDialogOtherAndCancel(t *testing.T) {
58+ d := newQuestionDialog()
59+ r := d.Enqueue([]tools.Question{{Question: "Q", Options: []tools.QuestionOption{{Label: "A"}}}})
60+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
61+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
62+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'x'})
63+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
64+ if r.states[0].editing || r.states[0].text != "x" {
65+ t.Fatal("other escape lost text")
66+ }
67+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
68+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
69+ if (<-r.resp).answers[0].Custom != "x" {
70+ t.Fatal("other answer")
71+ }
72+ r = d.Enqueue([]tools.Question{{Question: "free"}})
73+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
74+ if (<-r.resp).err != tools.ErrQuestionsDeclined {
75+ t.Fatal("free cancel")
76+ }
77+ r = d.Enqueue([]tools.Question{{Question: "Q", Options: []tools.QuestionOption{{Label: "A"}}}})
78+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
79+ if (<-r.resp).err != tools.ErrQuestionsDeclined {
80+ t.Fatal("cancel")
81+ }
82+}
83+func TestQuestionDialogNarrowAndQueue(t *testing.T) {
84+ d := newQuestionDialog()
85+ r := d.Enqueue([]tools.Question{{Header: "Tag", Question: "Unicode 界界界 and a long question", Options: []tools.QuestionOption{{Label: "界 option", Description: "long description"}}}})
86+ other := d.Enqueue([]tools.Question{{Question: "second"}})
87+ for _, width := range []int{2, 8, 16, 30} {
88+ for _, line := range d.Render(tui.Theme{}, width) {
89+ if w := runewidth.StringWidth(stripANSIBytes(line)); w > width {
90+ t.Fatalf("width %d: %d %q", width, w, line)
91+ }
92+ }
93+ }
94+ d.Remove(other)
95+ if !d.Active() {
96+ t.Fatal("removed active by mistake")
97+ }
98+ d.Remove(r)
99+ if d.Active() {
100+ t.Fatal("request not removed")
101+ }
102+}
103+
104+func TestAskQuestionsContextCancellationDismisses(t *testing.T) {
105+ i := &Interactive{questionDialog: newQuestionDialog(), dirty: make(chan struct{}, 1)}
106+ ctx, cancel := context.WithCancel(context.Background())
107+ done := make(chan error, 1)
108+ go func() { _, err := i.AskQuestions(ctx, []tools.Question{{Question: "Q"}}); done <- err }()
109+ <-i.dirty // enqueue and alert completed, without a timer or polling
110+ if !i.questionDialog.Active() {
111+ t.Fatal("not enqueued")
112+ }
113+ cancel()
114+ if err := <-done; !errors.Is(err, context.Canceled) {
115+ t.Fatalf("cancel: %v", err)
116+ }
117+ if i.questionDialog.Active() {
118+ t.Fatal("cancel left dialog active")
119+ }
120+}
121+
122+func TestQuestionInputFocusAfterConfirmation(t *testing.T) {
123+ i := newConfirmationInputInteractive()
124+ r := i.questionDialog.Enqueue([]tools.Question{{Question: "Q", Options: []tools.QuestionOption{{Label: "A"}}}})
125+ confirm := enqueueConfirmation(i)
126+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
127+ if !(<-confirm).Allow || !i.questionDialog.Active() {
128+ t.Fatal("confirmation did not own focus")
129+ }
130+ select {
131+ case <-r.resp:
132+ t.Fatal("question answered during confirmation")
133+ default:
134+ }
135+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
136+ if (<-r.resp).answers[0].Selected[0] != "A" {
137+ t.Fatal("question did not regain focus")
138+ }
139+ if !i.ed.IsEmpty() {
140+ t.Fatal("question input leaked into editor")
141+ }
142+}
143+
144+func TestQuestionDialogSpacingInFloatingPane(t *testing.T) {
145+ d := newQuestionDialog()
146+ d.Enqueue([]tools.Question{{Header: "H", Question: "Which?", Options: []tools.QuestionOption{{Label: "A"}}}})
147+ _, body, _ := floatingDialogBody(d.Render(tui.Theme{}, 200))
148+ plain := make([]string, len(body))
149+ for n, line := range body {
150+ plain[n] = strings.TrimSpace(stripANSIBytes(line))
151+ }
152+ if plain[0] != "Which?" || plain[1] != "" || plain[len(plain)-2] != "" || !strings.Contains(plain[len(plain)-1], "Esc decline") {
153+ t.Fatalf("unexpected spacing: %q", plain)
154+ }
155+}
156+
157+func TestQuestionDialogFocusRows(t *testing.T) {
158+ d := newQuestionDialog()
159+ d.Enqueue([]tools.Question{{Question: "A long question that wraps over several rows", Options: []tools.QuestionOption{{Label: "A long option that wraps"}, {Label: "Last"}}}})
160+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
161+ assertFocus := func(want string) {
162+ t.Helper()
163+ _, body, _ := floatingDialogBody(d.Render(tui.Dark, 20))
164+ for _, row := range body {
165+ if strings.Contains(row, tui.Dark.SelectionStyle()) {
166+ if !strings.Contains(stripANSIBytes(row), want) {
167+ t.Fatalf("focus row = %q, want %q", row, want)
168+ }
169+ return
170+ }
171+ }
172+ t.Fatal("no detected focus row")
173+ }
174+ assertFocus("2. Last")
175+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
176+ assertFocus("Other…")
177+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
178+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "answer"})
179+ assertFocus("> answer▏")
180+}
181+
182+func TestQuestionDialogChangedSelectionRequiresConfirmation(t *testing.T) {
183+ for _, toggle := range []rune{' ', '1'} {
184+ t.Run(string(toggle), func(t *testing.T) {
185+ d := newQuestionDialog()
186+ r := d.Enqueue([]tools.Question{{Question: "Pick", MultiSelect: true, Options: []tools.QuestionOption{{Label: "A"}, {Label: "B"}}}, {Question: "Next"}})
187+ key := func(k tui.KeyKind) { d.HandleKey(tui.Key{Kind: k}) }
188+ pick := func(c rune) { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: c}) }
189+ pick(toggle)
190+ key(tui.KeyEnter)
191+ key(tui.KeyShiftTab)
192+ pick(toggle)
193+ key(tui.KeyDown)
194+ if toggle == '1' {
195+ pick('2')
196+ } else {
197+ pick(' ')
198+ }
199+ for _, k := range []tui.KeyKind{tui.KeyTab, tui.KeyRight} {
200+ key(k)
201+ if r.index != 0 {
202+ t.Fatal("advanced with stale answer")
203+ }
204+ }
205+ key(tui.KeyEnter)
206+ if r.index != 1 || strings.Join(r.states[0].answer.Selected, ",") != "B" {
207+ t.Fatalf("did not recommit B: %+v", r.states[0])
208+ }
209+ })
210+ }
211+}
212+
213+func TestQuestionDialogEditedTextRequiresConfirmation(t *testing.T) {
214+ for _, edit := range []tui.Key{{Kind: tui.KeyBackspace}, {Kind: tui.KeyRune, Rune: 'x'}, {Kind: tui.KeyPaste, Paste: "new\ntext"}} {
215+ d := newQuestionDialog()
216+ r := d.Enqueue([]tools.Question{{Question: "Pick", Options: []tools.QuestionOption{{Label: "A"}}}, {Question: "Next"}})
217+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
218+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
219+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "old"})
220+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
221+ d.HandleKey(tui.Key{Kind: tui.KeyShiftTab})
222+ d.HandleKey(edit)
223+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
224+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
225+ if r.index != 0 || r.states[0].answered {
226+ t.Fatalf("stale text accepted for %+v", edit)
227+ }
228+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
229+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
230+ if r.index != 1 || r.states[0].answer.Custom != r.states[0].text {
231+ t.Fatal("text not recommitted")
232+ }
233+ }
234+}
235+
236+func TestQuestionDialogMultilineRows(t *testing.T) {
237+ d := newQuestionDialog()
238+ r := d.Enqueue([]tools.Question{{Header: "H\nheader", Question: "Q\nquestion", Options: []tools.QuestionOption{{Label: "A\nlabel", Description: "D\ndescription"}}}})
239+ check := func() {
240+ t.Helper()
241+ for _, row := range d.Render(tui.Dark, 12) {
242+ if strings.Contains(row, "\n") {
243+ t.Fatalf("embedded newline: %q", row)
244+ }
245+ }
246+ }
247+ check()
248+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
249+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
250+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "one\r\ntwo\nthree\rfour"})
251+ if r.states[0].text != "one two three four" {
252+ t.Fatalf("paste = %q", r.states[0].text)
253+ }
254+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n'})
255+ check()
256+ // Rendering also tolerates multiline state independently of input normalization.
257+ r.states[0].text = "typed\ntext"
258+ check()
259+}
260+
261+func TestQuestionDialogClipboardAndConfirmFocus(t *testing.T) {
262+ i := newConfirmationInputInteractive()
263+ r := i.questionDialog.Enqueue([]tools.Question{{Question: "Free text"}})
264+ if !i.confirmChildActive() {
265+ t.Fatal("question not recognized as clipboard child")
266+ }
267+ key, ok, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) { return "one\ntwo", true, nil })
268+ if err != nil || !ok || key.Kind != tui.KeyPaste {
269+ t.Fatalf("resolve: %+v %v", key, err)
270+ }
271+ i.handleKey(context.Background(), key)
272+ if r.states[0].text != "one two" || !i.ed.IsEmpty() {
273+ t.Fatal("paste not delivered to question input")
274+ }
275+ confirm := enqueueConfirmation(i)
276+ i.confirmDialog.Blur()
277+ i.restoreConfirmFocus()
278+ if i.confirmDialog.Focused() {
279+ t.Fatal("question stole focus restoration")
280+ }
281+ i.confirmDialog.Focus()
282+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
283+ if !(<-confirm).Allow {
284+ t.Fatal("confirmation focus broken")
285+ }
286+}
287+
288+func TestQuestionReadyAlertWhileBusy(t *testing.T) {
289+ for _, enabled := range []bool{true, false} {
290+ term := &alertTestTerminal{}
291+ i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &enabled}, busy: true, questionDialog: newQuestionDialog(), dirty: make(chan struct{}, 1)}
292+ ctx, cancel := context.WithCancel(context.Background())
293+ done := make(chan error, 1)
294+ go func() { _, err := i.AskQuestions(ctx, []tools.Question{{Question: "Q"}}); done <- err }()
295+ <-i.dirty // Alert completes before invalidation; no redraw or timer needed.
296+ want := ""
297+ if enabled {
298+ want = "\a"
299+ }
300+ if got := term.String(); got != want {
301+ t.Errorf("busy alert = %q, want %q", got, want)
302+ }
303+ if i.pendingAlert != nil {
304+ t.Error("question alert deferred")
305+ }
306+ cancel()
307+ if err := <-done; !errors.Is(err, context.Canceled) {
308+ t.Fatal(err)
309+ }
310+ }
311+}
@@ -0,0 +1,311 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "github.com/bnema/zut/packages/agent/tools"
7+ "github.com/bnema/zut/packages/tui"
8+ "github.com/mattn/go-runewidth"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestQuestionDialogChain(t *testing.T) {
14+ d := newQuestionDialog()
15+ opts := []tools.QuestionOption{{Label: "A"}, {Label: "B"}}
16+ r := d.Enqueue([]tools.Question{{Question: "Which?", Options: opts}, {Question: "Many?", Options: opts, MultiSelect: true}, {Question: "Explain?"}})
17+ key := func(k tui.KeyKind) { d.HandleKey(tui.Key{Kind: k}) }
18+ runeKey := func(c rune) { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: c}) }
19+ if !strings.Contains(strings.Join(d.Render(tui.Theme{}, 80), "\n"), "Question 1/3") {
20+ t.Fatal("missing counter")
21+ }
22+ runeKey('2')
23+ if r.index != 1 || r.states[0].answer.Selected[0] != "B" {
24+ t.Fatal("single pick")
25+ }
26+ key(tui.KeyEnter)
27+ if r.hint == "" || r.index != 1 {
28+ t.Fatal("empty multi accepted")
29+ }
30+ runeKey(' ')
31+ key(tui.KeyDown)
32+ runeKey(' ')
33+ key(tui.KeyEnter)
34+ if r.index != 2 || len(r.states[1].answer.Selected) != 2 {
35+ t.Fatal("multi pick")
36+ }
37+ runeKey('界')
38+ key(tui.KeyShiftTab)
39+ if !r.states[1].checked[0] || !r.states[1].checked[1] {
40+ t.Fatal("checks lost")
41+ }
42+ key(tui.KeyLeft)
43+ if r.states[0].cursor != 1 {
44+ t.Fatal("cursor lost")
45+ }
46+ key(tui.KeyTab)
47+ key(tui.KeyTab)
48+ if r.states[2].text != "界" {
49+ t.Fatal("text lost")
50+ }
51+ key(tui.KeyEnter)
52+ response := <-r.resp
53+ if response.err != nil || response.answers[2].Custom != "界" || d.Active() {
54+ t.Fatalf("finish %+v", response)
55+ }
56+}
57+func TestQuestionDialogOtherAndCancel(t *testing.T) {
58+ d := newQuestionDialog()
59+ r := d.Enqueue([]tools.Question{{Question: "Q", Options: []tools.QuestionOption{{Label: "A"}}}})
60+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
61+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
62+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'x'})
63+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
64+ if r.states[0].editing || r.states[0].text != "x" {
65+ t.Fatal("other escape lost text")
66+ }
67+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
68+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
69+ if (<-r.resp).answers[0].Custom != "x" {
70+ t.Fatal("other answer")
71+ }
72+ r = d.Enqueue([]tools.Question{{Question: "free"}})
73+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
74+ if (<-r.resp).err != tools.ErrQuestionsDeclined {
75+ t.Fatal("free cancel")
76+ }
77+ r = d.Enqueue([]tools.Question{{Question: "Q", Options: []tools.QuestionOption{{Label: "A"}}}})
78+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
79+ if (<-r.resp).err != tools.ErrQuestionsDeclined {
80+ t.Fatal("cancel")
81+ }
82+}
83+func TestQuestionDialogNarrowAndQueue(t *testing.T) {
84+ d := newQuestionDialog()
85+ r := d.Enqueue([]tools.Question{{Header: "Tag", Question: "Unicode 界界界 and a long question", Options: []tools.QuestionOption{{Label: "界 option", Description: "long description"}}}})
86+ other := d.Enqueue([]tools.Question{{Question: "second"}})
87+ for _, width := range []int{2, 8, 16, 30} {
88+ for _, line := range d.Render(tui.Theme{}, width) {
89+ if w := runewidth.StringWidth(stripANSIBytes(line)); w > width {
90+ t.Fatalf("width %d: %d %q", width, w, line)
91+ }
92+ }
93+ }
94+ d.Remove(other)
95+ if !d.Active() {
96+ t.Fatal("removed active by mistake")
97+ }
98+ d.Remove(r)
99+ if d.Active() {
100+ t.Fatal("request not removed")
101+ }
102+}
103+
104+func TestAskQuestionsContextCancellationDismisses(t *testing.T) {
105+ i := &Interactive{questionDialog: newQuestionDialog(), dirty: make(chan struct{}, 1)}
106+ ctx, cancel := context.WithCancel(context.Background())
107+ done := make(chan error, 1)
108+ go func() { _, err := i.AskQuestions(ctx, []tools.Question{{Question: "Q"}}); done <- err }()
109+ <-i.dirty // enqueue and alert completed, without a timer or polling
110+ if !i.questionDialog.Active() {
111+ t.Fatal("not enqueued")
112+ }
113+ cancel()
114+ if err := <-done; !errors.Is(err, context.Canceled) {
115+ t.Fatalf("cancel: %v", err)
116+ }
117+ if i.questionDialog.Active() {
118+ t.Fatal("cancel left dialog active")
119+ }
120+}
121+
122+func TestQuestionInputFocusAfterConfirmation(t *testing.T) {
123+ i := newConfirmationInputInteractive()
124+ r := i.questionDialog.Enqueue([]tools.Question{{Question: "Q", Options: []tools.QuestionOption{{Label: "A"}}}})
125+ confirm := enqueueConfirmation(i)
126+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
127+ if !(<-confirm).Allow || !i.questionDialog.Active() {
128+ t.Fatal("confirmation did not own focus")
129+ }
130+ select {
131+ case <-r.resp:
132+ t.Fatal("question answered during confirmation")
133+ default:
134+ }
135+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
136+ if (<-r.resp).answers[0].Selected[0] != "A" {
137+ t.Fatal("question did not regain focus")
138+ }
139+ if !i.ed.IsEmpty() {
140+ t.Fatal("question input leaked into editor")
141+ }
142+}
143+
144+func TestQuestionDialogSpacingInFloatingPane(t *testing.T) {
145+ d := newQuestionDialog()
146+ d.Enqueue([]tools.Question{{Header: "H", Question: "Which?", Options: []tools.QuestionOption{{Label: "A"}}}})
147+ _, body, _ := floatingDialogBody(d.Render(tui.Theme{}, 200))
148+ plain := make([]string, len(body))
149+ for n, line := range body {
150+ plain[n] = strings.TrimSpace(stripANSIBytes(line))
151+ }
152+ if plain[0] != "Which?" || plain[1] != "" || plain[len(plain)-2] != "" || !strings.Contains(plain[len(plain)-1], "Esc decline") {
153+ t.Fatalf("unexpected spacing: %q", plain)
154+ }
155+}
156+
157+func TestQuestionDialogFocusRows(t *testing.T) {
158+ d := newQuestionDialog()
159+ d.Enqueue([]tools.Question{{Question: "A long question that wraps over several rows", Options: []tools.QuestionOption{{Label: "A long option that wraps"}, {Label: "Last"}}}})
160+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
161+ assertFocus := func(want string) {
162+ t.Helper()
163+ _, body, _ := floatingDialogBody(d.Render(tui.Dark, 20))
164+ for _, row := range body {
165+ if strings.Contains(row, tui.Dark.SelectionStyle()) {
166+ if !strings.Contains(stripANSIBytes(row), want) {
167+ t.Fatalf("focus row = %q, want %q", row, want)
168+ }
169+ return
170+ }
171+ }
172+ t.Fatal("no detected focus row")
173+ }
174+ assertFocus("2. Last")
175+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
176+ assertFocus("Other…")
177+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
178+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "answer"})
179+ assertFocus("> answer▏")
180+}
181+
182+func TestQuestionDialogChangedSelectionRequiresConfirmation(t *testing.T) {
183+ for _, toggle := range []rune{' ', '1'} {
184+ t.Run(string(toggle), func(t *testing.T) {
185+ d := newQuestionDialog()
186+ r := d.Enqueue([]tools.Question{{Question: "Pick", MultiSelect: true, Options: []tools.QuestionOption{{Label: "A"}, {Label: "B"}}}, {Question: "Next"}})
187+ key := func(k tui.KeyKind) { d.HandleKey(tui.Key{Kind: k}) }
188+ pick := func(c rune) { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: c}) }
189+ pick(toggle)
190+ key(tui.KeyEnter)
191+ key(tui.KeyShiftTab)
192+ pick(toggle)
193+ key(tui.KeyDown)
194+ if toggle == '1' {
195+ pick('2')
196+ } else {
197+ pick(' ')
198+ }
199+ for _, k := range []tui.KeyKind{tui.KeyTab, tui.KeyRight} {
200+ key(k)
201+ if r.index != 0 {
202+ t.Fatal("advanced with stale answer")
203+ }
204+ }
205+ key(tui.KeyEnter)
206+ if r.index != 1 || strings.Join(r.states[0].answer.Selected, ",") != "B" {
207+ t.Fatalf("did not recommit B: %+v", r.states[0])
208+ }
209+ })
210+ }
211+}
212+
213+func TestQuestionDialogEditedTextRequiresConfirmation(t *testing.T) {
214+ for _, edit := range []tui.Key{{Kind: tui.KeyBackspace}, {Kind: tui.KeyRune, Rune: 'x'}, {Kind: tui.KeyPaste, Paste: "new\ntext"}} {
215+ d := newQuestionDialog()
216+ r := d.Enqueue([]tools.Question{{Question: "Pick", Options: []tools.QuestionOption{{Label: "A"}}}, {Question: "Next"}})
217+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
218+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
219+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "old"})
220+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
221+ d.HandleKey(tui.Key{Kind: tui.KeyShiftTab})
222+ d.HandleKey(edit)
223+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
224+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
225+ if r.index != 0 || r.states[0].answered {
226+ t.Fatalf("stale text accepted for %+v", edit)
227+ }
228+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
229+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
230+ if r.index != 1 || r.states[0].answer.Custom != r.states[0].text {
231+ t.Fatal("text not recommitted")
232+ }
233+ }
234+}
235+
236+func TestQuestionDialogMultilineRows(t *testing.T) {
237+ d := newQuestionDialog()
238+ r := d.Enqueue([]tools.Question{{Header: "H\nheader", Question: "Q\nquestion", Options: []tools.QuestionOption{{Label: "A\nlabel", Description: "D\ndescription"}}}})
239+ check := func() {
240+ t.Helper()
241+ for _, row := range d.Render(tui.Dark, 12) {
242+ if strings.Contains(row, "\n") {
243+ t.Fatalf("embedded newline: %q", row)
244+ }
245+ }
246+ }
247+ check()
248+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
249+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
250+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "one\r\ntwo\nthree\rfour"})
251+ if r.states[0].text != "one two three four" {
252+ t.Fatalf("paste = %q", r.states[0].text)
253+ }
254+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n'})
255+ check()
256+ // Rendering also tolerates multiline state independently of input normalization.
257+ r.states[0].text = "typed\ntext"
258+ check()
259+}
260+
261+func TestQuestionDialogClipboardAndConfirmFocus(t *testing.T) {
262+ i := newConfirmationInputInteractive()
263+ r := i.questionDialog.Enqueue([]tools.Question{{Question: "Free text"}})
264+ if !i.confirmChildActive() {
265+ t.Fatal("question not recognized as clipboard child")
266+ }
267+ key, ok, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) { return "one\ntwo", true, nil })
268+ if err != nil || !ok || key.Kind != tui.KeyPaste {
269+ t.Fatalf("resolve: %+v %v", key, err)
270+ }
271+ i.handleKey(context.Background(), key)
272+ if r.states[0].text != "one two" || !i.ed.IsEmpty() {
273+ t.Fatal("paste not delivered to question input")
274+ }
275+ confirm := enqueueConfirmation(i)
276+ i.confirmDialog.Blur()
277+ i.restoreConfirmFocus()
278+ if i.confirmDialog.Focused() {
279+ t.Fatal("question stole focus restoration")
280+ }
281+ i.confirmDialog.Focus()
282+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
283+ if !(<-confirm).Allow {
284+ t.Fatal("confirmation focus broken")
285+ }
286+}
287+
288+func TestQuestionReadyAlertWhileBusy(t *testing.T) {
289+ for _, enabled := range []bool{true, false} {
290+ term := &alertTestTerminal{}
291+ i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &enabled}, busy: true, questionDialog: newQuestionDialog(), dirty: make(chan struct{}, 1)}
292+ ctx, cancel := context.WithCancel(context.Background())
293+ done := make(chan error, 1)
294+ go func() { _, err := i.AskQuestions(ctx, []tools.Question{{Question: "Q"}}); done <- err }()
295+ <-i.dirty // Alert completes before invalidation; no redraw or timer needed.
296+ want := ""
297+ if enabled {
298+ want = "\a"
299+ }
300+ if got := term.String(); got != want {
301+ t.Errorf("busy alert = %q, want %q", got, want)
302+ }
303+ if i.pendingAlert != nil {
304+ t.Error("question alert deferred")
305+ }
306+ cancel()
307+ if err := <-done; !errors.Is(err, context.Canceled) {
308+ t.Fatal(err)
309+ }
310+ }
311+}
A packages/agent/tools/ask_user_question.go +140 -0
@@ -0,0 +1,140 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "strings"
11+ "unicode/utf8"
12+
13+ "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+const AskUserQuestionToolName = "ask_user_question"
18+
19+type Question struct {
20+ Header string `json:"header"`
21+ Question string `json:"question"`
22+ Options []QuestionOption `json:"options"`
23+ MultiSelect bool `json:"multi_select"`
24+}
25+type QuestionOption struct {
26+ Label string `json:"label"`
27+ Description string `json:"description"`
28+}
29+type QuestionAnswer struct {
30+ Selected []string
31+ Custom string
32+}
33+
34+var ErrQuestionsDeclined = errors.New("user declined to answer")
35+
36+type QuestionAsker interface {
37+ AskQuestions(context.Context, []Question) ([]QuestionAnswer, error)
38+}
39+type AskUserQuestionTool struct{ Asker func() QuestionAsker }
40+
41+func (*AskUserQuestionTool) Name() string { return AskUserQuestionToolName }
42+func (*AskUserQuestionTool) Interactive() bool { return true }
43+func (*AskUserQuestionTool) Description() string {
44+ return "Ask the user up to 10 structured questions in an interactive terminal. Offer up to 9 options per question, or omit options for free text. The user can always provide a custom answer or decline."
45+}
46+func (*AskUserQuestionTool) Schema() json.RawMessage {
47+ return json.RawMessage(`{"type":"object","properties":{"questions":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","properties":{"header":{"type":"string","maxLength":16},"question":{"type":"string","minLength":1},"options":{"type":"array","maxItems":9,"items":{"type":"object","properties":{"label":{"type":"string","minLength":1},"description":{"type":"string"}},"required":["label"],"additionalProperties":false}},"multi_select":{"type":"boolean"}},"required":["question"],"additionalProperties":false}}},"required":["questions"],"additionalProperties":false}`)
48+}
49+func (t *AskUserQuestionTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
50+ fail := func(s string) (core.ToolResult, error) {
51+ return core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: s}}}, nil
52+ }
53+ // Null is not a valid value for any field in this schema. encoding/json
54+ // otherwise silently accepts null for strings and booleans.
55+ var shape any
56+ if err := json.Unmarshal(raw, &shape); err == nil && questionContainsNull(shape) {
57+ return fail("Invalid questions: null fields are not allowed")
58+ }
59+ var args struct {
60+ Questions []Question `json:"questions"`
61+ }
62+ d := json.NewDecoder(bytes.NewReader(raw))
63+ d.DisallowUnknownFields()
64+ if err := d.Decode(&args); err != nil {
65+ return fail("Invalid questions: " + err.Error())
66+ }
67+ if d.Decode(&struct{}{}) != io.EOF {
68+ return fail("Invalid questions: expected one JSON object")
69+ }
70+ if len(args.Questions) < 1 || len(args.Questions) > 10 {
71+ return fail("questions must contain 1 to 10 questions")
72+ }
73+ for _, q := range args.Questions {
74+ if strings.TrimSpace(q.Question) == "" || utf8.RuneCountInString(q.Header) > 16 || len(q.Options) > 9 {
75+ return fail("Each question needs text, a header of at most 16 characters, and at most 9 options")
76+ }
77+ seen := map[string]bool{}
78+ for _, o := range q.Options {
79+ label := strings.TrimSpace(o.Label)
80+ if label == "" || seen[label] {
81+ return fail("Option labels must be non-empty and unique")
82+ }
83+ seen[label] = true
84+ }
85+ }
86+ if err := ctx.Err(); err != nil {
87+ return core.ToolResult{}, err
88+ }
89+ var asker QuestionAsker
90+ if t.Asker != nil {
91+ asker = t.Asker()
92+ }
93+ if asker == nil {
94+ return fail("ask_user_question needs an interactive terminal session")
95+ }
96+ answers, err := asker.AskQuestions(ctx, args.Questions)
97+ if ctx.Err() != nil {
98+ return core.ToolResult{}, ctx.Err()
99+ }
100+ if errors.Is(err, ErrQuestionsDeclined) {
101+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "The user declined to answer the questions."}}}, nil
102+ }
103+ if err != nil {
104+ return fail(err.Error())
105+ }
106+ if len(answers) != len(args.Questions) {
107+ return fail("Interactive host returned an incomplete answer set")
108+ }
109+ var b strings.Builder
110+ for n, q := range args.Questions {
111+ a := answers[n]
112+ text := strings.Join(a.Selected, ", ")
113+ if a.Custom != "" {
114+ text = a.Custom + " (custom)"
115+ }
116+ fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", q.Question, text)
117+ }
118+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: strings.TrimSpace(b.String())}}}, nil
119+}
120+
121+func questionContainsNull(v any) bool {
122+ if v == nil {
123+ return true
124+ }
125+ switch x := v.(type) {
126+ case map[string]any:
127+ for _, child := range x {
128+ if questionContainsNull(child) {
129+ return true
130+ }
131+ }
132+ case []any:
133+ for _, child := range x {
134+ if questionContainsNull(child) {
135+ return true
136+ }
137+ }
138+ }
139+ return false
140+}
@@ -0,0 +1,140 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "strings"
11+ "unicode/utf8"
12+
13+ "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+const AskUserQuestionToolName = "ask_user_question"
18+
19+type Question struct {
20+ Header string `json:"header"`
21+ Question string `json:"question"`
22+ Options []QuestionOption `json:"options"`
23+ MultiSelect bool `json:"multi_select"`
24+}
25+type QuestionOption struct {
26+ Label string `json:"label"`
27+ Description string `json:"description"`
28+}
29+type QuestionAnswer struct {
30+ Selected []string
31+ Custom string
32+}
33+
34+var ErrQuestionsDeclined = errors.New("user declined to answer")
35+
36+type QuestionAsker interface {
37+ AskQuestions(context.Context, []Question) ([]QuestionAnswer, error)
38+}
39+type AskUserQuestionTool struct{ Asker func() QuestionAsker }
40+
41+func (*AskUserQuestionTool) Name() string { return AskUserQuestionToolName }
42+func (*AskUserQuestionTool) Interactive() bool { return true }
43+func (*AskUserQuestionTool) Description() string {
44+ return "Ask the user up to 10 structured questions in an interactive terminal. Offer up to 9 options per question, or omit options for free text. The user can always provide a custom answer or decline."
45+}
46+func (*AskUserQuestionTool) Schema() json.RawMessage {
47+ return json.RawMessage(`{"type":"object","properties":{"questions":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","properties":{"header":{"type":"string","maxLength":16},"question":{"type":"string","minLength":1},"options":{"type":"array","maxItems":9,"items":{"type":"object","properties":{"label":{"type":"string","minLength":1},"description":{"type":"string"}},"required":["label"],"additionalProperties":false}},"multi_select":{"type":"boolean"}},"required":["question"],"additionalProperties":false}}},"required":["questions"],"additionalProperties":false}`)
48+}
49+func (t *AskUserQuestionTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
50+ fail := func(s string) (core.ToolResult, error) {
51+ return core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: s}}}, nil
52+ }
53+ // Null is not a valid value for any field in this schema. encoding/json
54+ // otherwise silently accepts null for strings and booleans.
55+ var shape any
56+ if err := json.Unmarshal(raw, &shape); err == nil && questionContainsNull(shape) {
57+ return fail("Invalid questions: null fields are not allowed")
58+ }
59+ var args struct {
60+ Questions []Question `json:"questions"`
61+ }
62+ d := json.NewDecoder(bytes.NewReader(raw))
63+ d.DisallowUnknownFields()
64+ if err := d.Decode(&args); err != nil {
65+ return fail("Invalid questions: " + err.Error())
66+ }
67+ if d.Decode(&struct{}{}) != io.EOF {
68+ return fail("Invalid questions: expected one JSON object")
69+ }
70+ if len(args.Questions) < 1 || len(args.Questions) > 10 {
71+ return fail("questions must contain 1 to 10 questions")
72+ }
73+ for _, q := range args.Questions {
74+ if strings.TrimSpace(q.Question) == "" || utf8.RuneCountInString(q.Header) > 16 || len(q.Options) > 9 {
75+ return fail("Each question needs text, a header of at most 16 characters, and at most 9 options")
76+ }
77+ seen := map[string]bool{}
78+ for _, o := range q.Options {
79+ label := strings.TrimSpace(o.Label)
80+ if label == "" || seen[label] {
81+ return fail("Option labels must be non-empty and unique")
82+ }
83+ seen[label] = true
84+ }
85+ }
86+ if err := ctx.Err(); err != nil {
87+ return core.ToolResult{}, err
88+ }
89+ var asker QuestionAsker
90+ if t.Asker != nil {
91+ asker = t.Asker()
92+ }
93+ if asker == nil {
94+ return fail("ask_user_question needs an interactive terminal session")
95+ }
96+ answers, err := asker.AskQuestions(ctx, args.Questions)
97+ if ctx.Err() != nil {
98+ return core.ToolResult{}, ctx.Err()
99+ }
100+ if errors.Is(err, ErrQuestionsDeclined) {
101+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "The user declined to answer the questions."}}}, nil
102+ }
103+ if err != nil {
104+ return fail(err.Error())
105+ }
106+ if len(answers) != len(args.Questions) {
107+ return fail("Interactive host returned an incomplete answer set")
108+ }
109+ var b strings.Builder
110+ for n, q := range args.Questions {
111+ a := answers[n]
112+ text := strings.Join(a.Selected, ", ")
113+ if a.Custom != "" {
114+ text = a.Custom + " (custom)"
115+ }
116+ fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", q.Question, text)
117+ }
118+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: strings.TrimSpace(b.String())}}}, nil
119+}
120+
121+func questionContainsNull(v any) bool {
122+ if v == nil {
123+ return true
124+ }
125+ switch x := v.(type) {
126+ case map[string]any:
127+ for _, child := range x {
128+ if questionContainsNull(child) {
129+ return true
130+ }
131+ }
132+ case []any:
133+ for _, child := range x {
134+ if questionContainsNull(child) {
135+ return true
136+ }
137+ }
138+ }
139+ return false
140+}
A packages/agent/tools/ask_user_question_test.go +61 -0
@@ -0,0 +1,61 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "github.com/bnema/zut/packages/provider"
8+ "strings"
9+ "testing"
10+)
11+
12+type questionAskerFunc func(context.Context, []Question) ([]QuestionAnswer, error)
13+
14+func (f questionAskerFunc) AskQuestions(c context.Context, q []Question) ([]QuestionAnswer, error) {
15+ return f(c, q)
16+}
17+func TestAskUserQuestionValidation(t *testing.T) {
18+ for _, raw := range []string{`{}`, `null`, `{"questions":[]}`, `{"questions":[{}]}`, `{"questions":[{"question":"q","bogus":true}]}`, `{"questions":[{"question":"q","options":[{}]}]}`, `{"questions":[{"question":"q","header":"12345678901234567"}]}`, `{"questions":[{"question":"q","options":[{"label":"a"},{"label":"a"}]}]}`, `{"questions":[{"question":"q"}]} {}`} {
19+ tool := &AskUserQuestionTool{Asker: func() QuestionAsker { t.Fatal("invalid input reached host"); return nil }}
20+ r, e := tool.Execute(context.Background(), json.RawMessage(raw), nil)
21+ if e != nil || !r.IsError {
22+ t.Fatalf("%s: %+v %v", raw, r, e)
23+ }
24+ }
25+}
26+func TestAskUserQuestionResults(t *testing.T) {
27+ raw := json.RawMessage(`{"questions":[{"question":"Which?","options":[{"label":"A"},{"label":"B"}],"multi_select":true},{"question":"Why?"}]}`)
28+ tool := &AskUserQuestionTool{}
29+ r, e := tool.Execute(context.Background(), raw, nil)
30+ if e != nil || !r.IsError || !strings.Contains(r.Content[0].(provider.TextBlock).Text, "interactive terminal") {
31+ t.Fatalf("headless: %+v %v", r, e)
32+ }
33+ tool.Asker = func() QuestionAsker {
34+ return questionAskerFunc(func(context.Context, []Question) ([]QuestionAnswer, error) {
35+ return []QuestionAnswer{{Selected: []string{"A", "B"}}, {Custom: "Because"}}, nil
36+ })
37+ }
38+ r, e = tool.Execute(context.Background(), raw, nil)
39+ if e != nil || r.IsError || r.Content[0].(provider.TextBlock).Text != "Q: Which?\nA: A, B\n\nQ: Why?\nA: Because (custom)" {
40+ t.Fatalf("format: %+v %v", r, e)
41+ }
42+ tool.Asker = func() QuestionAsker {
43+ return questionAskerFunc(func(context.Context, []Question) ([]QuestionAnswer, error) { return nil, ErrQuestionsDeclined })
44+ }
45+ r, e = tool.Execute(context.Background(), raw, nil)
46+ if e != nil || r.IsError || !strings.Contains(r.Content[0].(provider.TextBlock).Text, "declined") {
47+ t.Fatalf("decline: %+v %v", r, e)
48+ }
49+ ctx, cancel := context.WithCancel(context.Background())
50+ tool.Asker = func() QuestionAsker {
51+ return questionAskerFunc(func(c context.Context, _ []Question) ([]QuestionAnswer, error) {
52+ cancel()
53+ <-c.Done()
54+ return nil, c.Err()
55+ })
56+ }
57+ _, e = tool.Execute(ctx, raw, nil)
58+ if !errors.Is(e, context.Canceled) {
59+ t.Fatalf("cancel: %v", e)
60+ }
61+}
@@ -0,0 +1,61 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "github.com/bnema/zut/packages/provider"
8+ "strings"
9+ "testing"
10+)
11+
12+type questionAskerFunc func(context.Context, []Question) ([]QuestionAnswer, error)
13+
14+func (f questionAskerFunc) AskQuestions(c context.Context, q []Question) ([]QuestionAnswer, error) {
15+ return f(c, q)
16+}
17+func TestAskUserQuestionValidation(t *testing.T) {
18+ for _, raw := range []string{`{}`, `null`, `{"questions":[]}`, `{"questions":[{}]}`, `{"questions":[{"question":"q","bogus":true}]}`, `{"questions":[{"question":"q","options":[{}]}]}`, `{"questions":[{"question":"q","header":"12345678901234567"}]}`, `{"questions":[{"question":"q","options":[{"label":"a"},{"label":"a"}]}]}`, `{"questions":[{"question":"q"}]} {}`} {
19+ tool := &AskUserQuestionTool{Asker: func() QuestionAsker { t.Fatal("invalid input reached host"); return nil }}
20+ r, e := tool.Execute(context.Background(), json.RawMessage(raw), nil)
21+ if e != nil || !r.IsError {
22+ t.Fatalf("%s: %+v %v", raw, r, e)
23+ }
24+ }
25+}
26+func TestAskUserQuestionResults(t *testing.T) {
27+ raw := json.RawMessage(`{"questions":[{"question":"Which?","options":[{"label":"A"},{"label":"B"}],"multi_select":true},{"question":"Why?"}]}`)
28+ tool := &AskUserQuestionTool{}
29+ r, e := tool.Execute(context.Background(), raw, nil)
30+ if e != nil || !r.IsError || !strings.Contains(r.Content[0].(provider.TextBlock).Text, "interactive terminal") {
31+ t.Fatalf("headless: %+v %v", r, e)
32+ }
33+ tool.Asker = func() QuestionAsker {
34+ return questionAskerFunc(func(context.Context, []Question) ([]QuestionAnswer, error) {
35+ return []QuestionAnswer{{Selected: []string{"A", "B"}}, {Custom: "Because"}}, nil
36+ })
37+ }
38+ r, e = tool.Execute(context.Background(), raw, nil)
39+ if e != nil || r.IsError || r.Content[0].(provider.TextBlock).Text != "Q: Which?\nA: A, B\n\nQ: Why?\nA: Because (custom)" {
40+ t.Fatalf("format: %+v %v", r, e)
41+ }
42+ tool.Asker = func() QuestionAsker {
43+ return questionAskerFunc(func(context.Context, []Question) ([]QuestionAnswer, error) { return nil, ErrQuestionsDeclined })
44+ }
45+ r, e = tool.Execute(context.Background(), raw, nil)
46+ if e != nil || r.IsError || !strings.Contains(r.Content[0].(provider.TextBlock).Text, "declined") {
47+ t.Fatalf("decline: %+v %v", r, e)
48+ }
49+ ctx, cancel := context.WithCancel(context.Background())
50+ tool.Asker = func() QuestionAsker {
51+ return questionAskerFunc(func(c context.Context, _ []Question) ([]QuestionAnswer, error) {
52+ cancel()
53+ <-c.Done()
54+ return nil, c.Err()
55+ })
56+ }
57+ _, e = tool.Execute(ctx, raw, nil)
58+ if !errors.Is(e, context.Canceled) {
59+ t.Fatalf("cancel: %v", e)
60+ }
61+}
M packages/agent/tools/schema_compat_test.go +1 -0
@@ -11,6 +11,7 @@ import (
1111 // 400. Every built-in schema must keep a plain object root.
1212 func TestToolSchemasAnthropicCompatible(t *testing.T) {
1313 schemas := map[string]json.RawMessage{
14+ "ask_user_question": (&AskUserQuestionTool{}).Schema(),
1415 "ast": (&ASTTool{}).Schema(),
1516 "bash": (&BashTool{}).Schema(),
1617 "create_worktree": (&CreateWorktreeTool{}).Schema(),
@@ -11,6 +11,7 @@ import (
11 // 400. Every built-in schema must keep a plain object root.11 // 400. Every built-in schema must keep a plain object root.
12 func TestToolSchemasAnthropicCompatible(t *testing.T) {12 func TestToolSchemasAnthropicCompatible(t *testing.T) {
13 schemas := map[string]json.RawMessage{13 schemas := map[string]json.RawMessage{
14+ "ask_user_question": (&AskUserQuestionTool{}).Schema(),
14 "ast": (&ASTTool{}).Schema(),15 "ast": (&ASTTool{}).Schema(),
15 "bash": (&BashTool{}).Schema(),16 "bash": (&BashTool{}).Schema(),
16 "create_worktree": (&CreateWorktreeTool{}).Schema(),17 "create_worktree": (&CreateWorktreeTool{}).Schema(),
M packages/tui/view.go +5 -0
@@ -2757,6 +2757,11 @@ func ShortArgs(tool string, raw json.RawMessage) string {
27572757 }
27582758 return s
27592759 }
2760+ if tool == "ask_user_question" {
2761+ if qs, ok := x["questions"].([]any); ok {
2762+ return fmt.Sprintf("· %d questions", len(qs))
2763+ }
2764+ }
27602765 var primary string
27612766 // Glob leads with its pattern; every other tool keeps the legacy
27622767 // path-first shape so tools carrying both (e.g. ast's pattern+path)
@@ -2757,6 +2757,11 @@ func ShortArgs(tool string, raw json.RawMessage) string {
2757 }2757 }
2758 return s2758 return s
2759 }2759 }
2760+ if tool == "ask_user_question" {
2761+ if qs, ok := x["questions"].([]any); ok {
2762+ return fmt.Sprintf("· %d questions", len(qs))
2763+ }
2764+ }
2760 var primary string2765 var primary string
2761 // Glob leads with its pattern; every other tool keeps the legacy2766 // Glob leads with its pattern; every other tool keeps the legacy
2762 // path-first shape so tools carrying both (e.g. ast's pattern+path)2767 // path-first shape so tools carrying both (e.g. ast's pattern+path)
M packages/tui/view_tool_arg_width_test.go +6 -0
@@ -74,3 +74,9 @@ func TestShortArgsGlobShowsPatternAndDir(t *testing.T) {
7474 t.Fatalf("ShortArgs glob with dot dir = %q, want bare pattern", got)
7575 }
7676 }
77+
78+func TestQuestionToolSummary(t *testing.T) {
79+ if got := ShortArgs("ask_user_question", json.RawMessage(`{"questions":[{}, {}, {}]}`)); got != "· 3 questions" {
80+ t.Fatalf("summary: %q", got)
81+ }
82+}
@@ -74,3 +74,9 @@ func TestShortArgsGlobShowsPatternAndDir(t *testing.T) {
74 t.Fatalf("ShortArgs glob with dot dir = %q, want bare pattern", got)74 t.Fatalf("ShortArgs glob with dot dir = %q, want bare pattern", got)
75 }75 }
76 }76 }
77+
78+func TestQuestionToolSummary(t *testing.T) {
79+ if got := ShortArgs("ask_user_question", json.RawMessage(`{"questions":[{}, {}, {}]}`)); got != "· 3 questions" {
80+ t.Fatalf("summary: %q", got)
81+ }
82+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close