Merge e9101ea6208478221b0767eb9ff4486580ea9f16 into a9d674e0d05c093e5ff7dea21bc7d6723527b490
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. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 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 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 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 | 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 | 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 | 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 | 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 | 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 | ||
| @@ -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). | |||
| 232 | 232 | ||
| 233 | 233 | Tool names live in the same namespace as built-in tools (`read`, | |
| 234 | 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 | 236 | `skill`). Conflicts with active built-ins are silently shadowed by the built-in. | |
| 237 | 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 an | |
| 238 | + | `update_goal`, `plan`, and `ask_user_question`, is reserved even when it is unavailable, so an | |
| 239 | 239 | extension cannot claim or replace it. | |
| 240 | 240 | ||
| 241 | 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`: | |
| @@ -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 an | 238 | + | `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) { | |||
| 134 | 134 | // Web capability and other native names remain reserved even when their | |
| 135 | 135 | // policy excludes the current session. An extension must not turn a | |
| 136 | 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 | 138 | continue | |
| 139 | 139 | } | |
| 140 | 140 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -1432,11 +1432,12 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1432 | 1432 | Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")}, | |
| 1433 | 1433 | Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox}, | |
| 1434 | 1434 | }, | |
| 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{}, | |
| 1440 | 1441 | } | |
| 1441 | 1442 | if webSearchAllowedForRegistry(args) { | |
| 1442 | 1443 | for name, tool := range tools.NewWebTools() { | |
| @@ -1607,7 +1608,7 @@ func subagentActionAllowed(args Args, action string) bool { | |||
| 1607 | 1608 | 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 | 1613 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1613 | 1614 | var out []ToolSummary | |
| @@ -134,7 +134,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 134 | // Web capability and other native names remain reserved even when their | 134 | // Web capability and other native names remain reserved even when their | ||
| 135 | // policy excludes the current session. An extension must not turn a | 135 | // 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 | continue | 138 | 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 false | 1608 | 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 []ToolSummary | 1614 | 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 | |||
| 1657 | 1657 | // host after constructing it below so startup alerts can be buffered. | |
| 1658 | 1658 | var iv *modes.Interactive | |
| 1659 | 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 | 1672 | extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |
| 1661 | 1673 | var startupExtensionErrors []string | |
| 1662 | 1674 | // --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 | 1759 | _ = closeSubagentRuntimeFresh(runtime) | |
| 1748 | 1760 | }() | |
| 1749 | 1761 | ||
| 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 | + | } | |
| 1752 | 1770 | prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy) | |
| 1753 | 1771 | initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry) | |
| 1754 | 1772 | webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow) | |
| @@ -1783,7 +1801,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1783 | 1801 | if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) { | |
| 1784 | 1802 | effectiveArgs = r.ModifiedArgs | |
| 1785 | 1803 | } | |
| 1786 | - | if confirmGate != nil { | |
| 1804 | + | if confirmGate != nil && call.Name != tools.AskUserQuestionToolName { | |
| 1787 | 1805 | var content strings.Builder | |
| 1788 | 1806 | // The remembered "always allow" grant is keyed by tool name, | |
| 1789 | 1807 | // 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.Interactive | 1658 | 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 []string | 1673 | var startupExtensionErrors []string | ||
| 1662 | // --ext paths first so they win against installed extensions of | 1674 | // --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.PrepareRegistry | 1762 | + | prepareInteractiveRegistry := func(reg core.Registry) core.Registry { |
| 1751 | - | prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry | 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 | + | } | |||
| 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.ModifiedArgs | 1802 | effectiveArgs = r.ModifiedArgs | ||
| 1785 | } | 1803 | } | ||
| 1786 | - | if confirmGate != nil { | 1804 | + | if confirmGate != nil && call.Name != tools.AskUserQuestionToolName { |
| 1787 | var content strings.Builder | 1805 | 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 (the | 1807 | // 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/agent/extensions" | |
| 10 | 10 | "github.com/bnema/zut/packages/agent/modes" | |
| 11 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 11 | 12 | "github.com/bnema/zut/packages/core" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -55,3 +56,17 @@ func TestResidentRegistryRejectsInteractiveExtensionTools(t *testing.T) { | |||
| 55 | 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 | + | } | |
| @@ -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 { | |||
| 852 | 852 | btwDialog *btwDialog | |
| 853 | 853 | skillsDialog *skillsDialog | |
| 854 | 854 | changelogDialog *changelogDialog | |
| 855 | + | questionDialog *questionDialog | |
| 855 | 856 | confirmDialog *confirmDialog | |
| 856 | 857 | logoutDialog *logoutDialog | |
| 857 | 858 | telegramDialog *telegramDialog | |
| @@ -1109,6 +1110,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1109 | 1110 | btwDialog: newBtwDialog(), | |
| 1110 | 1111 | skillsDialog: newSkillsDialog(), | |
| 1111 | 1112 | changelogDialog: newChangelogDialog(), | |
| 1113 | + | questionDialog: newQuestionDialog(), | |
| 1112 | 1114 | confirmDialog: newConfirmDialog(), | |
| 1113 | 1115 | logoutDialog: newLogoutDialog(), | |
| 1114 | 1116 | telegramDialog: newTelegramDialog(), | |
| @@ -852,6 +852,7 @@ type Interactive struct { | |||||
| 852 | btwDialog *btwDialog | 852 | btwDialog *btwDialog | ||
| 853 | skillsDialog *skillsDialog | 853 | skillsDialog *skillsDialog | ||
| 854 | changelogDialog *changelogDialog | 854 | changelogDialog *changelogDialog | ||
| 855 | + | questionDialog *questionDialog | |||
| 855 | confirmDialog *confirmDialog | 856 | confirmDialog *confirmDialog | ||
| 856 | logoutDialog *logoutDialog | 857 | logoutDialog *logoutDialog | ||
| 857 | telegramDialog *telegramDialog | 858 | 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 { | |||
| 57 | 57 | func (i *Interactive) confirmChildActiveLocked() bool { | |
| 58 | 58 | return len(i.helpBlock) > 0 || | |
| 59 | 59 | len(i.extNotes) > 0 || | |
| 60 | + | i.questionDialog.Active() || | |
| 60 | 61 | i.dialog.Active() || | |
| 61 | 62 | i.modelDialog.Active() || | |
| 62 | 63 | i.llamaDialog.Active() || | |
| @@ -127,6 +128,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 127 | 128 | i.invalidate() | |
| 128 | 129 | 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 | 136 | if i.dialog.Active() { | |
| 131 | 137 | if k.Kind == tui.KeyCtrlC { | |
| 132 | 138 | 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 false | 129 | 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() { | |||
| 464 | 464 | var dialog []string | |
| 465 | 465 | var dialogID string | |
| 466 | 466 | switch { | |
| 467 | + | case i.questionDialog.Active() && !i.confirmDialog.Active(): | |
| 468 | + | dialogID = "questions" | |
| 469 | + | dialog = i.questionDialog.Render(i.cfg.Theme, dialogWidth) | |
| 467 | 470 | case i.dialog.Active(): | |
| 468 | 471 | dialogID = "login" | |
| 469 | 472 | dialog = i.dialog.Render(i.cfg.Theme, dialogWidth) | |
| @@ -537,8 +540,9 @@ func (i *Interactive) redraw() { | |||
| 537 | 540 | dialogTitle := floatingOverlayTitle(dialogID) | |
| 538 | 541 | dialogRemovedTopRows := 0 | |
| 539 | 542 | 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" { | |
| 542 | 546 | dialog = padDialogFrame(dialog) | |
| 543 | 547 | } | |
| 544 | 548 | frameTitle, body, removedTopRows := floatingDialogBody(dialog) | |
| @@ -464,6 +464,9 @@ func (i *Interactive) redraw() { | |||||
| 464 | var dialog []string | 464 | var dialog []string | ||
| 465 | var dialogID string | 465 | 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 := 0 | 541 | 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 ( | |||
| 11 | 11 | // 400. Every built-in schema must keep a plain object root. | |
| 12 | 12 | func TestToolSchemasAnthropicCompatible(t *testing.T) { | |
| 13 | 13 | schemas := map[string]json.RawMessage{ | |
| 14 | + | "ask_user_question": (&AskUserQuestionTool{}).Schema(), | |
| 14 | 15 | "ast": (&ASTTool{}).Schema(), | |
| 15 | 16 | "bash": (&BashTool{}).Schema(), | |
| 16 | 17 | "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 { | |||
| 2757 | 2757 | } | |
| 2758 | 2758 | 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 | 2765 | var primary string | |
| 2761 | 2766 | // Glob leads with its pattern; every other tool keeps the legacy | |
| 2762 | 2767 | // 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 s | 2758 | 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 string | 2765 | var primary string | ||
| 2761 | // Glob leads with its pattern; every other tool keeps the legacy | 2766 | // 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) { | |||
| 74 | 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 | + | } | |
| @@ -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 | + | } | |||