nanocode-cli 0.6.1__tar.gz → 0.6.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nanocode_cli-0.6.1/nanocode_cli.egg-info → nanocode_cli-0.6.3}/PKG-INFO +2 -1
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/README.md +1 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/README.zh-CN.md +1 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/nanocode.py +213 -47
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3/nanocode_cli.egg-info}/PKG-INFO +2 -1
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/pyproject.toml +1 -1
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/LICENSE +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/MANIFEST.in +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.6.1 → nanocode_cli-0.6.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -121,6 +121,7 @@ Interactive selectors support `j`/`k`, arrows, `/` search, Enter, and Esc. Input
|
|
|
121
121
|
- Shell: `Bash`, `Git`.
|
|
122
122
|
- Tool results: `Recall`.
|
|
123
123
|
- Working notes: `Note`.
|
|
124
|
+
- Ask the user: `Question` asks one or more questions (optional choices, previews, recommended) when intent is genuinely ambiguous.
|
|
124
125
|
- MCP: `MCP` calls tools on configured MCP servers.
|
|
125
126
|
|
|
126
127
|
`Read`, `Search`, and `InspectCode` return line anchors where useful. `Edit` uses current `line:hash` anchors to reject stale edits.
|
|
@@ -85,6 +85,7 @@ Interactive selectors support `j`/`k`, arrows, `/` search, Enter, and Esc. Input
|
|
|
85
85
|
- Shell: `Bash`, `Git`.
|
|
86
86
|
- Tool results: `Recall`.
|
|
87
87
|
- Working notes: `Note`.
|
|
88
|
+
- Ask the user: `Question` asks one or more questions (optional choices, previews, recommended) when intent is genuinely ambiguous.
|
|
88
89
|
- MCP: `MCP` calls tools on configured MCP servers.
|
|
89
90
|
|
|
90
91
|
`Read`, `Search`, and `InspectCode` return line anchors where useful. `Edit` uses current `line:hash` anchors to reject stale edits.
|
|
@@ -85,6 +85,7 @@ nanocode
|
|
|
85
85
|
- Shell:`Bash`, `Git`。
|
|
86
86
|
- 工具结果:`Recall`。
|
|
87
87
|
- 工作笔记:`Note`。
|
|
88
|
+
- 询问用户:`Question` 在意图真正歧义时向用户提一个或多个问题(可选 choices、previews、recommended)。
|
|
88
89
|
- MCP:`MCP` 调用已配置 MCP 服务器上的工具。
|
|
89
90
|
|
|
90
91
|
`Read`、`Search` 和 `InspectCode` 会在合适时返回行锚点。`Edit` 使用当前 `line:hash` 锚点拒绝过期编辑。
|
|
@@ -59,7 +59,7 @@ from rich.console import Console
|
|
|
59
59
|
from rich.markdown import Markdown
|
|
60
60
|
from rich.rule import Rule
|
|
61
61
|
|
|
62
|
-
__version__ = "0.6.
|
|
62
|
+
__version__ = "0.6.3"
|
|
63
63
|
|
|
64
64
|
Json = dict[str, Any]
|
|
65
65
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
@@ -81,6 +81,8 @@ CHAT_REASONING_EFFORT_VALUES: dict[str, dict[str, str | int]] = {
|
|
|
81
81
|
"enable_thinking": {"minimal": 256, "low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384},
|
|
82
82
|
}
|
|
83
83
|
SELECTION_BACK = object()
|
|
84
|
+
SELECTION_FREE_TEXT = object()
|
|
85
|
+
DISMISSED = "(The user dismissed the question without answering.)"
|
|
84
86
|
|
|
85
87
|
|
|
86
88
|
class NanocodeError(Exception):
|
|
@@ -704,7 +706,7 @@ class SystemInfo:
|
|
|
704
706
|
class Session:
|
|
705
707
|
cwd: str = field(default_factory=os.getcwd)
|
|
706
708
|
system_info: SystemInfo | None = None
|
|
707
|
-
|
|
709
|
+
expected_git_branch: str = ""
|
|
708
710
|
config: Config = field(default_factory=Config)
|
|
709
711
|
settings: RuntimeSettings = field(default_factory=RuntimeSettings)
|
|
710
712
|
messages: list[Json] = field(default_factory=list)
|
|
@@ -722,8 +724,8 @@ class Session:
|
|
|
722
724
|
def __post_init__(self) -> None:
|
|
723
725
|
if self.system_info is None:
|
|
724
726
|
self.system_info = SystemInfo.detect(self.cwd)
|
|
725
|
-
if not self.
|
|
726
|
-
self.
|
|
727
|
+
if not self.expected_git_branch:
|
|
728
|
+
self.expected_git_branch = self.git_branch(self.cwd)
|
|
727
729
|
if self.mcp is None:
|
|
728
730
|
self.mcp = MCPManager(self)
|
|
729
731
|
|
|
@@ -2183,7 +2185,14 @@ class GitTool(Tool):
|
|
|
2183
2185
|
|
|
2184
2186
|
@classmethod
|
|
2185
2187
|
def payload_args(cls, payload: Json) -> list[Any]:
|
|
2186
|
-
argv =
|
|
2188
|
+
argv = payload.get("argv")
|
|
2189
|
+
if not isinstance(argv, list) or not argv:
|
|
2190
|
+
raise ToolError(
|
|
2191
|
+
"Git requires a non-empty 'argv' list. "
|
|
2192
|
+
'Signature: Git(argv=[command,...], cwd?) '
|
|
2193
|
+
'Example: {"argv":["status","--short"]}'
|
|
2194
|
+
)
|
|
2195
|
+
argv = [str(a) for a in argv]
|
|
2187
2196
|
return [("cwd=" + str(payload["cwd"])), *argv] if payload.get("cwd") else argv
|
|
2188
2197
|
|
|
2189
2198
|
def needs_confirmation(self) -> bool:
|
|
@@ -2198,6 +2207,11 @@ class GitTool(Tool):
|
|
|
2198
2207
|
self.validate_branch_safety(args, cwd)
|
|
2199
2208
|
try:
|
|
2200
2209
|
proc = subprocess.run([git, *args], cwd=cwd, text=True, capture_output=True, timeout=self.session.settings.shell_timeout)
|
|
2210
|
+
# When the user has nanocode switch branches, update expected_git_branch to the new
|
|
2211
|
+
# branch so later commits are not rejected. Gate on branch-changing commands only: an
|
|
2212
|
+
# unexpected (external) switch should still trip validate_branch_safety on commit.
|
|
2213
|
+
if proc.returncode == 0 and self.changes_branch(args):
|
|
2214
|
+
self.session.expected_git_branch = self.session.git_branch(cwd)
|
|
2201
2215
|
return self.process_result("GitToolResult", proc.returncode, proc.stdout, proc.stderr)
|
|
2202
2216
|
except subprocess.TimeoutExpired as error:
|
|
2203
2217
|
return self.process_result("GitToolResult", -1, error.stdout or "", (error.stderr or "") + "\ntimeout")
|
|
@@ -2227,9 +2241,9 @@ class GitTool(Tool):
|
|
|
2227
2241
|
if args[0] != "commit":
|
|
2228
2242
|
return
|
|
2229
2243
|
current = self.session.git_branch(cwd)
|
|
2230
|
-
|
|
2231
|
-
if
|
|
2232
|
-
raise ToolError(f"refusing git commit because branch changed from {
|
|
2244
|
+
expected = self.session.expected_git_branch
|
|
2245
|
+
if expected and current and current != expected:
|
|
2246
|
+
raise ToolError(f"refusing git commit because branch changed from {expected} to {current}")
|
|
2233
2247
|
if current in {"main", "master"} and self.session.settings.yolo:
|
|
2234
2248
|
raise ToolError(f"refusing git commit on {current} in yolo mode; explicit confirmation is required")
|
|
2235
2249
|
|
|
@@ -2425,6 +2439,118 @@ class NoteTool(Tool):
|
|
|
2425
2439
|
return ["\n".join(lines) or "{}"]
|
|
2426
2440
|
|
|
2427
2441
|
|
|
2442
|
+
@dataclass(frozen=True)
|
|
2443
|
+
class QuestionSpec:
|
|
2444
|
+
"""One validated question the model wants to ask the user."""
|
|
2445
|
+
|
|
2446
|
+
question: str
|
|
2447
|
+
choices: list[str] | None = None
|
|
2448
|
+
previews: list[str] | None = None
|
|
2449
|
+
recommended: int | None = None
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
class QuestionTool(Tool):
|
|
2453
|
+
NAME = "Question"
|
|
2454
|
+
DESCRIPTION = "Ask the user one or more questions (asked in sequence) and wait for their answers. Use when intent is genuinely ambiguous, a choice affects the codebase's external shape (module layout, public API, naming), or you need prioritization; prefer offering choices with previews, and optionally a recommended index when one option is clearly best. Do NOT ask about trivial internal details or anything determinable from context (Read/InspectCode/Bash) or already specified; if a reasonable default exists, proceed."
|
|
2455
|
+
SIGNATURE = 'Question(questions=[{question, choices?, previews?, recommended?}, ...])'
|
|
2456
|
+
EXAMPLE = (
|
|
2457
|
+
'One question, recommending a choice. Example: {"questions":[{"question":"Which approach?","choices":["Refactor","Rewrite"],"previews":["Extract module +87 -12","Rewrite from scratch"],"recommended":0}]}',
|
|
2458
|
+
'Batch related questions. Example: {"questions":[{"question":"Target runtime?","choices":["Node","Deno"]},{"question":"Name the module?"}]}',
|
|
2459
|
+
)
|
|
2460
|
+
MUTATES = False
|
|
2461
|
+
STORES_RESULT = True
|
|
2462
|
+
question_fn: Callable[[QuestionSpec, str], str] | None = None
|
|
2463
|
+
|
|
2464
|
+
@classmethod
|
|
2465
|
+
def params_schema(cls) -> Json:
|
|
2466
|
+
return {
|
|
2467
|
+
"type": "object",
|
|
2468
|
+
"properties": {
|
|
2469
|
+
"questions": {
|
|
2470
|
+
"type": "array",
|
|
2471
|
+
"minItems": 1,
|
|
2472
|
+
"description": "Questions to ask, one after another",
|
|
2473
|
+
"items": {
|
|
2474
|
+
"type": "object",
|
|
2475
|
+
"properties": {
|
|
2476
|
+
"question": {"type": "string", "description": "The question to ask the user"},
|
|
2477
|
+
"choices": {
|
|
2478
|
+
"type": "array",
|
|
2479
|
+
"items": {"type": "string"},
|
|
2480
|
+
"description": "Optional predefined choices the user can pick from",
|
|
2481
|
+
},
|
|
2482
|
+
"previews": {
|
|
2483
|
+
"type": "array",
|
|
2484
|
+
"items": {"type": "string"},
|
|
2485
|
+
"description": "Optional preview text per choice, shown as the user navigates",
|
|
2486
|
+
},
|
|
2487
|
+
"recommended": {
|
|
2488
|
+
"type": "integer",
|
|
2489
|
+
"minimum": 0,
|
|
2490
|
+
"description": "Optional 0-based index of the recommended choice; pre-selected and marked",
|
|
2491
|
+
},
|
|
2492
|
+
},
|
|
2493
|
+
"required": ["question"],
|
|
2494
|
+
"additionalProperties": False,
|
|
2495
|
+
},
|
|
2496
|
+
},
|
|
2497
|
+
},
|
|
2498
|
+
"required": ["questions"],
|
|
2499
|
+
"additionalProperties": False,
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
@classmethod
|
|
2503
|
+
def payload_args(cls, payload: Json) -> list[Any]:
|
|
2504
|
+
return [payload]
|
|
2505
|
+
|
|
2506
|
+
def call(self) -> str:
|
|
2507
|
+
if len(self.args) != 1 or not isinstance(self.args[0], dict):
|
|
2508
|
+
raise ToolError("Question requires named fields")
|
|
2509
|
+
questions = self.args[0].get("questions")
|
|
2510
|
+
if not isinstance(questions, list) or not questions:
|
|
2511
|
+
raise ToolError("Question requires a non-empty 'questions' list")
|
|
2512
|
+
# Validate the whole batch up front, so a malformed later question never strands the
|
|
2513
|
+
# user after they have already answered earlier ones.
|
|
2514
|
+
prepared: list[QuestionSpec] = []
|
|
2515
|
+
for item in questions:
|
|
2516
|
+
if not isinstance(item, dict):
|
|
2517
|
+
raise ToolError("each question must be an object with a 'question' field")
|
|
2518
|
+
question = str(item.get("question", "")).strip()
|
|
2519
|
+
if not question:
|
|
2520
|
+
raise ToolError("each question requires a 'question' field")
|
|
2521
|
+
choices = item.get("choices")
|
|
2522
|
+
previews = item.get("previews")
|
|
2523
|
+
recommended = item.get("recommended")
|
|
2524
|
+
if choices is not None:
|
|
2525
|
+
if not isinstance(choices, list) or not all(isinstance(c, str) for c in choices):
|
|
2526
|
+
raise ToolError("Question choices must be a list of strings")
|
|
2527
|
+
if previews is not None:
|
|
2528
|
+
if not isinstance(previews, list) or not all(isinstance(p, str) for p in previews):
|
|
2529
|
+
raise ToolError("Question previews must be a list of strings")
|
|
2530
|
+
if len(previews) != len(choices):
|
|
2531
|
+
raise ToolError("Question previews must match choices length")
|
|
2532
|
+
if recommended is not None and (
|
|
2533
|
+
isinstance(recommended, bool) or not isinstance(recommended, int) or not choices or not 0 <= recommended < len(choices)
|
|
2534
|
+
):
|
|
2535
|
+
raise ToolError("Question recommended must be a valid 0-based choice index")
|
|
2536
|
+
prepared.append(QuestionSpec(question, choices, previews, recommended))
|
|
2537
|
+
total = len(prepared)
|
|
2538
|
+
answers: list[tuple[str, str]] = []
|
|
2539
|
+
for index, spec in enumerate(prepared):
|
|
2540
|
+
position = f"{index + 1}/{total}" if total > 1 else ""
|
|
2541
|
+
answers.append((spec.question, self.question_fn(spec, position) if self.question_fn else spec.question))
|
|
2542
|
+
if len(answers) == 1:
|
|
2543
|
+
return answers[0][1]
|
|
2544
|
+
return "\n\n".join(f"Q: {q}\nA: {a}" for q, a in answers)
|
|
2545
|
+
|
|
2546
|
+
def short_args(self) -> list[str]:
|
|
2547
|
+
questions = self.args[0].get("questions") if self.args and isinstance(self.args[0], dict) else None
|
|
2548
|
+
if not isinstance(questions, list) or not questions:
|
|
2549
|
+
return [""]
|
|
2550
|
+
first = str((questions[0] or {}).get("question", "") or "").strip() if isinstance(questions[0], dict) else ""
|
|
2551
|
+
label = Tool.compact(first, 80)
|
|
2552
|
+
return [label + (f" (+{len(questions) - 1} more)" if len(questions) > 1 else "")]
|
|
2553
|
+
|
|
2428
2554
|
class MCPTool(Tool):
|
|
2429
2555
|
NAME = "MCP"
|
|
2430
2556
|
DESCRIPTION = "Call or describe external MCP server tools"
|
|
@@ -2525,6 +2651,7 @@ TOOLS: tuple[type[Tool], ...] = (
|
|
|
2525
2651
|
GitTool,
|
|
2526
2652
|
RecallTool,
|
|
2527
2653
|
NoteTool,
|
|
2654
|
+
QuestionTool,
|
|
2528
2655
|
)
|
|
2529
2656
|
TOOL_REGISTRY: dict[str, type[Tool]] = {tool.NAME: tool for tool in TOOLS}
|
|
2530
2657
|
|
|
@@ -3967,6 +4094,7 @@ class ToolRunner:
|
|
|
3967
4094
|
self.preview_full_fn: Callable[[str], None] | None = None
|
|
3968
4095
|
self.live_output: Callable[[str, str], None] | None = None
|
|
3969
4096
|
self.live_start: Callable[[], None] | None = None
|
|
4097
|
+
self.question_fn: Callable[[QuestionSpec, str], str] | None = None
|
|
3970
4098
|
|
|
3971
4099
|
def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
|
|
3972
4100
|
messages = []
|
|
@@ -4026,6 +4154,8 @@ class ToolRunner:
|
|
|
4026
4154
|
if isinstance(tool, BashTool):
|
|
4027
4155
|
tool.live_output = self.live_output
|
|
4028
4156
|
started, approved, display = time.monotonic(), False, None
|
|
4157
|
+
if isinstance(tool, QuestionTool):
|
|
4158
|
+
tool.question_fn = self.question_fn
|
|
4029
4159
|
try:
|
|
4030
4160
|
display = self.short_call(call, tool.short_args())
|
|
4031
4161
|
if plan_error:
|
|
@@ -4633,48 +4763,26 @@ class Agent:
|
|
|
4633
4763
|
SYSTEM_PROMPT = """\
|
|
4634
4764
|
You are nanocode, a concise terminal coding agent.
|
|
4635
4765
|
|
|
4636
|
-
TOOLS:
|
|
4637
|
-
|
|
4766
|
+
TOOLS:
|
|
4767
|
+
- Available: Read LineCount List Find InspectCode Search Edit Bash Git Recall Note Question MCP.
|
|
4768
|
+
- Use exact tool names and named parameters; obey each tool DESCRIPTION/SIGNATURE.
|
|
4769
|
+
- Files/code: Read/LineCount/List inspect files; Find/Search locate paths/text; InspectCode navigates symbols when available.
|
|
4770
|
+
- Changes/commands: Edit writes files; Git handles git; Bash is fallback when built-ins do not fit.
|
|
4771
|
+
- State/external: Recall retrieves tr.N outputs; Note maintains goal/plan/known/check; MCP calls configured external tools.
|
|
4772
|
+
- Restraint: Before calling "Question", make progress with other tools first; only ask when genuinely blocked, and batch related questions into one call.
|
|
4638
4773
|
|
|
4639
4774
|
FLOW:
|
|
4640
|
-
-
|
|
4641
|
-
-
|
|
4642
|
-
-
|
|
4643
|
-
-
|
|
4644
|
-
-
|
|
4645
|
-
- Before committing, check the branch; stop if it changed since task start.
|
|
4775
|
+
- Act when clear; keep using tools until done, or return a final answer.
|
|
4776
|
+
- Batch tool calls when practical.
|
|
4777
|
+
- Use tool feedback; do not repeat failed calls unchanged.
|
|
4778
|
+
- Do not switch/create/delete git branches unless explicitly asked. Before committing, check the branch and stop if it changed since task start.
|
|
4779
|
+
- Keep changes small/local/reversible and never overwrite unrelated user work.
|
|
4646
4780
|
- All assistant text is user-visible markdown in the latest user's language.
|
|
4647
4781
|
|
|
4648
|
-
TOOL CHOICE:
|
|
4649
|
-
- Edit writes files. Use Edit for file changes; keep patches small.
|
|
4650
|
-
- Read reads known files/ranges and returns line:hash anchors.
|
|
4651
|
-
- Search finds text/patterns in files; Find finds paths.
|
|
4652
|
-
- InspectCode navigates code symbols: defs, refs, impls, callers/callees, outline.
|
|
4653
|
-
- If code_index is unavailable, use Search/Read.
|
|
4654
|
-
|
|
4655
|
-
CONTEXT:
|
|
4656
|
-
- Recall bounded tr.N only when needed; prefer FILE STATE over old outputs.
|
|
4657
|
-
- For multi-step work, call Note early; use set_goal plus replace_plan/append_known/replace_known arrays, even for one item; record verification with set_check.
|
|
4658
|
-
|
|
4659
4782
|
FILE STATE:
|
|
4660
|
-
- FILE STATE is the
|
|
4661
|
-
-
|
|
4662
|
-
-
|
|
4663
|
-
- FILE STATE is not Memory/Recall; do not call it "full memory" or treat it as old output.
|
|
4664
|
-
- Do not re-Read a file/range already present in FILE STATE when it has the needed lines and anchors; proceed to Edit.
|
|
4665
|
-
- After a successful Edit, trust FILE STATE and do not re-Read just to verify the edited range.
|
|
4666
|
-
|
|
4667
|
-
EDITS:
|
|
4668
|
-
- Inspect/read before edits.
|
|
4669
|
-
- Patch with Edit line:hash anchors from the newest FILE STATE.
|
|
4670
|
-
- After stale-anchor errors or successful edits, discard old anchors for that file/range.
|
|
4671
|
-
- If a stale-anchor error includes `current is line:hash|text`, retry Edit with that current anchor; do not Read first.
|
|
4672
|
-
- Read after a stale-anchor error only when there is no usable `current is` anchor or surrounding lines are needed.
|
|
4673
|
-
- If `edit produced no changes` includes current target ranges, compare them with your intended change: stop if the file is already correct, otherwise retry with corrected content.
|
|
4674
|
-
- Read after `edit produced no changes` only when the error lacks enough current target content or surrounding lines are needed.
|
|
4675
|
-
- Do not batch multiple Edit calls for the same file; sequence them.
|
|
4676
|
-
- Keep edits small/local/reversible; never overwrite unrelated user work.
|
|
4677
|
-
- Edit op=create creates files, including empty files.
|
|
4783
|
+
- FILE STATE is the latest known file snapshot, possibly partial.
|
|
4784
|
+
- Read only when needed lines, anchors, or surrounding context are absent.
|
|
4785
|
+
- Read and Edit refresh FILE STATE; after Edit, trust the edited range.
|
|
4678
4786
|
|
|
4679
4787
|
FINAL:
|
|
4680
4788
|
- Concise markdown in the user's language.
|
|
@@ -5370,7 +5478,7 @@ Mentions:
|
|
|
5370
5478
|
CLI:
|
|
5371
5479
|
--mcp "orion*,!orionEval" Select MCP servers by name glob; use all or none.
|
|
5372
5480
|
Tools:
|
|
5373
|
-
Read, LineCount, List, Find, InspectCode, Search, Edit, Bash, Git, Recall, Note, MCP.
|
|
5481
|
+
Read, LineCount, List, Find, InspectCode, Search, Edit, Bash, Git, Recall, Note, Question, MCP.
|
|
5374
5482
|
"""
|
|
5375
5483
|
|
|
5376
5484
|
def __init__(self, agent: Agent, input_fn=input, output_fn=print):
|
|
@@ -5409,6 +5517,7 @@ Tools:
|
|
|
5409
5517
|
self.agent.tools.preview_full_fn = lambda text: setattr(self, "approval_full_preview", text)
|
|
5410
5518
|
self.agent.tools.live_start = self.tool_live_start
|
|
5411
5519
|
self.agent.tools.live_output = self.tool_live_output
|
|
5520
|
+
self.agent.tools.question_fn = self.question_interaction
|
|
5412
5521
|
|
|
5413
5522
|
@staticmethod
|
|
5414
5523
|
def exit_app(app: Application) -> None:
|
|
@@ -5593,6 +5702,7 @@ Tools:
|
|
|
5593
5702
|
"choice.title": "ansicyan bold",
|
|
5594
5703
|
"choice.selected": "reverse",
|
|
5595
5704
|
"choice.disabled": "ansibrightblack",
|
|
5705
|
+
"choice.preview": "ansigreen italic",
|
|
5596
5706
|
"completion-menu": "noreverse bg:default",
|
|
5597
5707
|
"completion-menu.completion": "noreverse bg:default fg:ansiwhite",
|
|
5598
5708
|
"completion-menu.completion.current": "noreverse bg:default fg:ansicyan bold",
|
|
@@ -5998,7 +6108,14 @@ Tools:
|
|
|
5998
6108
|
labels: dict[str, str],
|
|
5999
6109
|
current: str,
|
|
6000
6110
|
disabled: set[str],
|
|
6111
|
+
*,
|
|
6112
|
+
preview_fn: Callable[[str], str] | None = None,
|
|
6113
|
+
free_text: bool = False,
|
|
6001
6114
|
) -> str | object | None:
|
|
6115
|
+
FREE_TEXT = "\x00free_text"
|
|
6116
|
+
if free_text and self.interactive_input:
|
|
6117
|
+
choices = (*choices, FREE_TEXT)
|
|
6118
|
+
labels = {**labels, FREE_TEXT: "Type freely..."}
|
|
6002
6119
|
state = {"query": "", "selected": 0, "search": False}
|
|
6003
6120
|
searching = Condition(lambda: bool(state["search"]))
|
|
6004
6121
|
|
|
@@ -6042,6 +6159,13 @@ Tools:
|
|
|
6042
6159
|
if selected:
|
|
6043
6160
|
parts.append(("[SetCursorPosition]", ""))
|
|
6044
6161
|
parts.append((style, ("> " if selected else " ") + f"{number:2d}. {label}\n"))
|
|
6162
|
+
if preview_fn and options:
|
|
6163
|
+
sel = int(state["selected"])
|
|
6164
|
+
preview_text = preview_fn(options[sel]) if 0 <= sel < len(options) else ""
|
|
6165
|
+
if preview_text:
|
|
6166
|
+
parts.append(("class:choice.disabled", " ──────────────────────────────────\n"))
|
|
6167
|
+
for line in preview_text.splitlines():
|
|
6168
|
+
parts.append(("class:choice.preview", " │ " + line + "\n"))
|
|
6045
6169
|
if state["search"]:
|
|
6046
6170
|
parts.append(("", "/" + query))
|
|
6047
6171
|
return parts
|
|
@@ -6093,7 +6217,8 @@ Tools:
|
|
|
6093
6217
|
return
|
|
6094
6218
|
options = enabled()
|
|
6095
6219
|
if options:
|
|
6096
|
-
|
|
6220
|
+
choice = options[int(state["selected"])]
|
|
6221
|
+
event.app.exit(result=SELECTION_FREE_TEXT if choice == FREE_TEXT else choice)
|
|
6097
6222
|
|
|
6098
6223
|
@bindings.add("c-c", eager=True)
|
|
6099
6224
|
@bindings.add("<sigint>", eager=True)
|
|
@@ -6128,6 +6253,47 @@ Tools:
|
|
|
6128
6253
|
app = self._make_app(Layout(HSplit([choice_window, self.status_window()]), focused_element=choice_window), bindings)
|
|
6129
6254
|
return self.run_input_app(app)
|
|
6130
6255
|
|
|
6256
|
+
def question_application(self, spec: QuestionSpec, position: str = "") -> str:
|
|
6257
|
+
"""Ask via the shared choice selector, with dynamic previews and a free-text fallback."""
|
|
6258
|
+
choices = spec.choices
|
|
6259
|
+
# Prefix the position (e.g. "(1/3) ...") into the question text so it renders as plain
|
|
6260
|
+
# markdown — no separate styled line, hence no ANSI escapes to mangle.
|
|
6261
|
+
prompt = f"({position}) {spec.question}" if position else spec.question
|
|
6262
|
+
if not choices or not self.interactive_input:
|
|
6263
|
+
return self.read_input(prompt)
|
|
6264
|
+
|
|
6265
|
+
if self.ui.color:
|
|
6266
|
+
self.ui.console.print(Markdown(prompt))
|
|
6267
|
+
else:
|
|
6268
|
+
self.emit(prompt + "\n")
|
|
6269
|
+
|
|
6270
|
+
# An optional recommended choice is pre-selected (via current) and marked (via labels),
|
|
6271
|
+
# reusing the selector's existing machinery.
|
|
6272
|
+
labels, current = {}, ""
|
|
6273
|
+
if spec.recommended is not None and 0 <= spec.recommended < len(choices):
|
|
6274
|
+
current = choices[spec.recommended]
|
|
6275
|
+
labels = {current: current + " (recommended)"}
|
|
6276
|
+
previews = spec.previews
|
|
6277
|
+
preview_map = {c: previews[i] for i, c in enumerate(choices) if previews and i < len(previews) and previews[i]}
|
|
6278
|
+
result = self.choice_application(
|
|
6279
|
+
"Select:", tuple(choices), labels, current, set(),
|
|
6280
|
+
preview_fn=lambda choice: preview_map.get(choice, ""),
|
|
6281
|
+
free_text=True,
|
|
6282
|
+
)
|
|
6283
|
+
if result is SELECTION_FREE_TEXT:
|
|
6284
|
+
return self.read_input(spec.question + " (type freely)")
|
|
6285
|
+
if isinstance(result, str):
|
|
6286
|
+
return result
|
|
6287
|
+
return DISMISSED # SELECTION_BACK (Esc) — user declined to answer
|
|
6288
|
+
|
|
6289
|
+
def question_interaction(self, spec: QuestionSpec, position: str = "") -> str:
|
|
6290
|
+
"""Entry point for Question tool — shows the chosen answer in CLI after selection."""
|
|
6291
|
+
result = self.question_application(spec, position)
|
|
6292
|
+
# Echo the picked choice (free-text/dismissal are already surfaced elsewhere).
|
|
6293
|
+
if spec.choices and result in spec.choices:
|
|
6294
|
+
self.emit(result + "\n")
|
|
6295
|
+
return result
|
|
6296
|
+
|
|
6131
6297
|
def select_model(self, choices: tuple[str, ...]) -> str | object | None:
|
|
6132
6298
|
current = self.session.config.provider.model
|
|
6133
6299
|
labels = {label: label for label in self.MODEL_LABELS if label in choices}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -121,6 +121,7 @@ Interactive selectors support `j`/`k`, arrows, `/` search, Enter, and Esc. Input
|
|
|
121
121
|
- Shell: `Bash`, `Git`.
|
|
122
122
|
- Tool results: `Recall`.
|
|
123
123
|
- Working notes: `Note`.
|
|
124
|
+
- Ask the user: `Question` asks one or more questions (optional choices, previews, recommended) when intent is genuinely ambiguous.
|
|
124
125
|
- MCP: `MCP` calls tools on configured MCP servers.
|
|
125
126
|
|
|
126
127
|
`Read`, `Search`, and `InspectCode` return line anchors where useful. `Edit` uses current `line:hash` anchors to reject stale edits.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|