nanocode-cli 0.6.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.6.2
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.2"
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
- initial_git_branch: str = ""
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.initial_git_branch:
726
- self.initial_git_branch = self.git_branch(self.cwd)
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 = list(payload.get("argv") or [])
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
- initial = self.session.initial_git_branch
2231
- if initial and current and current != initial:
2232
- raise ToolError(f"refusing git commit because branch changed from {initial} to {current}")
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:
@@ -4634,11 +4764,12 @@ class Agent:
4634
4764
  You are nanocode, a concise terminal coding agent.
4635
4765
 
4636
4766
  TOOLS:
4637
- - Available: Read LineCount List Find InspectCode Search Edit Bash Git Recall Note MCP.
4767
+ - Available: Read LineCount List Find InspectCode Search Edit Bash Git Recall Note Question MCP.
4638
4768
  - Use exact tool names and named parameters; obey each tool DESCRIPTION/SIGNATURE.
4639
4769
  - Files/code: Read/LineCount/List inspect files; Find/Search locate paths/text; InspectCode navigates symbols when available.
4640
4770
  - Changes/commands: Edit writes files; Git handles git; Bash is fallback when built-ins do not fit.
4641
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.
4642
4773
 
4643
4774
  FLOW:
4644
4775
  - Act when clear; keep using tools until done, or return a final answer.
@@ -5347,7 +5478,7 @@ Mentions:
5347
5478
  CLI:
5348
5479
  --mcp "orion*,!orionEval" Select MCP servers by name glob; use all or none.
5349
5480
  Tools:
5350
- 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.
5351
5482
  """
5352
5483
 
5353
5484
  def __init__(self, agent: Agent, input_fn=input, output_fn=print):
@@ -5386,6 +5517,7 @@ Tools:
5386
5517
  self.agent.tools.preview_full_fn = lambda text: setattr(self, "approval_full_preview", text)
5387
5518
  self.agent.tools.live_start = self.tool_live_start
5388
5519
  self.agent.tools.live_output = self.tool_live_output
5520
+ self.agent.tools.question_fn = self.question_interaction
5389
5521
 
5390
5522
  @staticmethod
5391
5523
  def exit_app(app: Application) -> None:
@@ -5570,6 +5702,7 @@ Tools:
5570
5702
  "choice.title": "ansicyan bold",
5571
5703
  "choice.selected": "reverse",
5572
5704
  "choice.disabled": "ansibrightblack",
5705
+ "choice.preview": "ansigreen italic",
5573
5706
  "completion-menu": "noreverse bg:default",
5574
5707
  "completion-menu.completion": "noreverse bg:default fg:ansiwhite",
5575
5708
  "completion-menu.completion.current": "noreverse bg:default fg:ansicyan bold",
@@ -5975,7 +6108,14 @@ Tools:
5975
6108
  labels: dict[str, str],
5976
6109
  current: str,
5977
6110
  disabled: set[str],
6111
+ *,
6112
+ preview_fn: Callable[[str], str] | None = None,
6113
+ free_text: bool = False,
5978
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..."}
5979
6119
  state = {"query": "", "selected": 0, "search": False}
5980
6120
  searching = Condition(lambda: bool(state["search"]))
5981
6121
 
@@ -6019,6 +6159,13 @@ Tools:
6019
6159
  if selected:
6020
6160
  parts.append(("[SetCursorPosition]", ""))
6021
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"))
6022
6169
  if state["search"]:
6023
6170
  parts.append(("", "/" + query))
6024
6171
  return parts
@@ -6070,7 +6217,8 @@ Tools:
6070
6217
  return
6071
6218
  options = enabled()
6072
6219
  if options:
6073
- event.app.exit(result=options[int(state["selected"])])
6220
+ choice = options[int(state["selected"])]
6221
+ event.app.exit(result=SELECTION_FREE_TEXT if choice == FREE_TEXT else choice)
6074
6222
 
6075
6223
  @bindings.add("c-c", eager=True)
6076
6224
  @bindings.add("<sigint>", eager=True)
@@ -6105,6 +6253,47 @@ Tools:
6105
6253
  app = self._make_app(Layout(HSplit([choice_window, self.status_window()]), focused_element=choice_window), bindings)
6106
6254
  return self.run_input_app(app)
6107
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
+
6108
6297
  def select_model(self, choices: tuple[str, ...]) -> str | object | None:
6109
6298
  current = self.session.config.provider.model
6110
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.2
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.6.2"
7
+ version = "0.6.3"
8
8
  description = "A small terminal coding agent written in Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes