nanocode-cli 0.7.0__tar.gz → 0.7.2__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.7.0
3
+ Version: 0.7.2
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
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import codecs
7
+ import contextlib
8
+ import copy
7
9
  import difflib
8
10
  import asyncio
9
11
  import fnmatch
@@ -12,6 +14,7 @@ import json
12
14
  import logging
13
15
  import os
14
16
  import platform
17
+ import random
15
18
  import re
16
19
  import selectors
17
20
  import shlex
@@ -60,7 +63,7 @@ from rich.console import Console
60
63
  from rich.markdown import Markdown
61
64
  from rich.rule import Rule
62
65
 
63
- __version__ = "0.7.0"
66
+ __version__ = "0.7.2"
64
67
 
65
68
  Json = dict[str, Any]
66
69
  HTTP_USER_AGENT = "nanocode/" + __version__
@@ -77,8 +80,9 @@ REASONING_LEVELS = ("minimal", "low", "medium", "high", "xhigh")
77
80
  REASONING_CHOICES = ("off", *REASONING_LEVELS)
78
81
  CHAT_REASONING_CHOICES = ("auto", "off", "reasoning", "reasoning_effort", "thinking", "enable_thinking")
79
82
  ANTHROPIC_DEFAULT_MAX_TOKENS = 16_384
83
+ DEEPSEEK_DEFAULT_MAX_TOKENS = 32_768
80
84
  CHAT_REASONING_EFFORT_VALUES: dict[str, dict[str, str | int]] = {
81
- "thinking": {"minimal": "high", "low": "high", "medium": "high", "high": "high", "xhigh": "max"},
85
+ "thinking": {"minimal": "high", "low": "high", "medium": "high", "high": "max", "xhigh": "max"},
82
86
  "enable_thinking": {"minimal": 256, "low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384},
83
87
  }
84
88
  SELECTION_BACK = object()
@@ -139,10 +143,10 @@ class ProviderConfig:
139
143
  ("thinking", ("deepseek-v4",)),
140
144
  )
141
145
  PROFILES: ClassVar[dict[str, dict[str, Any]]] = {
142
- "api.openai.com": {"chat_reasoning_rules": (("reasoning_effort", ("o1", "o3", "o4", "gpt-5")),)},
146
+ "api.openai.com": {"chat_reasoning_rules": (("reasoning_effort", ("o1", "o3", "o4", "gpt-5")),), "strict_tools": True},
143
147
  "openrouter.ai": {"chat_reasoning": "reasoning"},
144
148
  "opencode.ai": {"api_rules": (("anthropic", ("claude-", "qwen3.")),), "chat_reasoning_rules": (("reasoning", ("deepseek-v4",)),)},
145
- "api.deepseek.com": {"chat_reasoning": "thinking"},
149
+ "api.deepseek.com": {"chat_reasoning": "thinking", "max_tokens": DEEPSEEK_DEFAULT_MAX_TOKENS, "prompt_cache_key": False, "strict_tools": True, "strict_beta": True},
146
150
  "dashscope.aliyuncs.com": {"chat_reasoning_rules": ALIYUN_CHAT_REASONING_RULES},
147
151
  "dashscope-intl.aliyuncs.com": {"chat_reasoning_rules": ALIYUN_CHAT_REASONING_RULES},
148
152
  "dashscope-us.aliyuncs.com": {"chat_reasoning_rules": ALIYUN_CHAT_REASONING_RULES},
@@ -155,6 +159,8 @@ class ProviderConfig:
155
159
  prompt_cache_key: str = "auto"
156
160
  available_models: tuple[str, ...] = ()
157
161
  temperature: float | None = None
162
+ max_tokens: int = 0
163
+ strict_tools: bool = False
158
164
  reasoning: str = "medium"
159
165
  chat_reasoning: str = "auto"
160
166
  timeout: int = 180
@@ -180,20 +186,30 @@ class ProviderConfig:
180
186
  prompt_cache_key=prompt_cache_key,
181
187
  available_models=Config.str_tuple(data, "available_models"),
182
188
  temperature=Config.float(data, "temperature", None),
189
+ max_tokens=max(0, Config.int(data, "max_tokens", 0)),
190
+ strict_tools=Config.bool(data, "strict_tools", False),
183
191
  reasoning=reasoning,
184
192
  chat_reasoning=chat_reasoning,
185
193
  timeout=Config.int(data, "timeout", 180),
186
194
  )
187
195
 
188
- def base_url(self) -> str:
196
+ def _stripped_url(self) -> str:
189
197
  url = self.url.rstrip("/")
190
198
  for suffix in ("/chat/completions", "/responses", "/messages"):
191
199
  if url.endswith(suffix):
192
200
  return url[: -len(suffix)]
193
201
  return url
194
202
 
203
+ def base_url(self) -> str:
204
+ url = self._stripped_url()
205
+ # Strict tool calling is a beta feature on some hosts (DeepSeek); route to /beta only
206
+ # when strict is actually active, so non-strict users stay on the stable endpoint.
207
+ if self.resolved_strict_tools() and (self.PROFILES.get(self.host()) or {}).get("strict_beta") and not url.endswith("/beta"):
208
+ url = url + "/beta"
209
+ return url
210
+
195
211
  def host(self) -> str:
196
- return (urlparse(self.base_url()).hostname or "").lower()
212
+ return (urlparse(self._stripped_url()).hostname or "").lower()
197
213
 
198
214
  def resolved_chat_reasoning(self) -> str:
199
215
  return self.profile_value(self.chat_reasoning, "off", "chat_reasoning", "chat_reasoning_rules")
@@ -216,6 +232,25 @@ class ProviderConfig:
216
232
  def reasoning_effort(self) -> str:
217
233
  return self.reasoning if self.reasoning in REASONING_LEVELS else "medium"
218
234
 
235
+ def resolved_max_tokens(self) -> int:
236
+ if self.max_tokens > 0:
237
+ return self.max_tokens
238
+ # No global default: generic OpenAI-compatible providers keep their own server-side cap.
239
+ # Only profiles that opt in (e.g. DeepSeek thinking mode) get an explicit ceiling.
240
+ return int((self.PROFILES.get(self.host()) or {}).get("max_tokens", 0))
241
+
242
+ def supports_prompt_cache_key(self) -> bool:
243
+ # Default on for unknown OpenAI-compatible hosts (status quo); profiles opt out
244
+ # (e.g. DeepSeek caches automatically by prefix and ignores the key).
245
+ return bool((self.PROFILES.get(self.host()) or {}).get("prompt_cache_key", True))
246
+
247
+ def supports_strict_tools(self) -> bool:
248
+ return bool((self.PROFILES.get(self.host()) or {}).get("strict_tools"))
249
+
250
+ def resolved_strict_tools(self) -> bool:
251
+ # Only emit strict schemas on the chat path of a host known to support strict mode.
252
+ return self.strict_tools and self.supports_strict_tools() and self.resolved_api() == "chat"
253
+
219
254
  @staticmethod
220
255
  def clean_prompt_cache_key(value: str) -> str:
221
256
  value = value.strip()
@@ -242,6 +277,7 @@ class RuntimeSettings:
242
277
  mcp_selector: str = ""
243
278
  yolo: bool = False
244
279
  debug: bool = False
280
+ tips: bool = True
245
281
 
246
282
  @classmethod
247
283
  def from_dict(cls, data: Json, *, yolo: bool = False, debug: bool = False, mcp_selector: str = "") -> "RuntimeSettings":
@@ -257,6 +293,7 @@ class RuntimeSettings:
257
293
  mcp_selector=mcp_selector,
258
294
  yolo=yolo or Config.bool(runtime, "yolo", False),
259
295
  debug=debug or Config.bool(runtime, "debug", False),
296
+ tips=Config.bool(runtime, "tips", True),
260
297
  )
261
298
 
262
299
 
@@ -350,6 +387,8 @@ api = "auto"
350
387
  prompt_cache_key = "auto"
351
388
  # available_models = ["gpt-5", "gpt-5-mini"]
352
389
  # temperature = 0.2
390
+ # max_tokens = 16384
391
+ # strict_tools = false # constrain tool-call args to the schema (OpenAI / DeepSeek beta)
353
392
  reasoning = "medium"
354
393
  # chat_reasoning = "auto"
355
394
  timeout = 180
@@ -366,6 +405,7 @@ check_updates = true
366
405
  update_check_interval_hours = 24
367
406
  session_retention_days = 7
368
407
  yolo = false
408
+ tips = true
369
409
 
370
410
  # [mcp.example]
371
411
  # url = "https://example.com/mcp"
@@ -490,6 +530,7 @@ class AgentState:
490
530
  code_index_error: str = ""
491
531
  code_index_notice: str = ""
492
532
  code_index_refreshing: bool = False
533
+ code_index_checking: bool = False
493
534
  context_percent: int = 0
494
535
  turn_step: int = 0
495
536
  turn_tool_calls: int = 0
@@ -528,7 +569,9 @@ class AgentState:
528
569
 
529
570
  def format(self) -> str:
530
571
  known = ["- " + item for item in self.known] or ["- (empty)"]
531
- return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows_for(self.plan), "Known:", *known, "Check: " + (self.check or "(empty)")])
572
+ return "\n".join(
573
+ ["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows_for(self.plan), "Known:", *known, "Check: " + (self.check or "(empty)")]
574
+ )
532
575
 
533
576
 
534
577
  @dataclass
@@ -915,16 +958,12 @@ class SessionSnapshotCodec:
915
958
  @staticmethod
916
959
  def tool_records(data: list[Json]) -> list[ToolResultRecord]:
917
960
  return [
918
- ToolResultRecord(key=rec["key"], name=rec["name"], args=rec.get("args", []), output=rec.get("output", ""), note=rec.get("note", ""))
919
- for rec in data
961
+ ToolResultRecord(key=rec["key"], name=rec["name"], args=rec.get("args", []), output=rec.get("output", ""), note=rec.get("note", "")) for rec in data
920
962
  ]
921
963
 
922
964
  @staticmethod
923
965
  def tool_errors(data: list[Json]) -> list[ToolErrorRecord]:
924
- return [
925
- ToolErrorRecord(key=err["key"], name=err["name"], args=err.get("args", []), error=err.get("error", ""))
926
- for err in data
927
- ]
966
+ return [ToolErrorRecord(key=err["key"], name=err["name"], args=err.get("args", []), error=err.get("error", "")) for err in data]
928
967
 
929
968
 
930
969
  class SessionSnapshotStore:
@@ -1246,6 +1285,59 @@ class UpdateChecker:
1246
1285
  )
1247
1286
 
1248
1287
 
1288
+ def strict_tool_schema(schema: Json) -> Json:
1289
+ """Rewrite a JSON Schema to satisfy strict function-calling (OpenAI / DeepSeek beta):
1290
+ every object property becomes required (genuine optionals turned nullable),
1291
+ additionalProperties is forced false, and unsupported keywords are dropped."""
1292
+
1293
+ def transform(node: Any) -> Any:
1294
+ if isinstance(node, list):
1295
+ return [transform(item) for item in node]
1296
+ if not isinstance(node, dict):
1297
+ return node
1298
+ node = {key: transform(value) for key, value in node.items() if key not in ("minItems", "maxItems", "minLength", "maxLength")}
1299
+ if isinstance(node.get("properties"), dict):
1300
+ required = set(node.get("required") or [])
1301
+ for key, sub in node["properties"].items():
1302
+ if key not in required and isinstance(sub, dict):
1303
+ node["properties"][key] = nullable(sub)
1304
+ node["required"] = list(node["properties"].keys())
1305
+ node["additionalProperties"] = False
1306
+ return node
1307
+
1308
+ # Strict validators only allow scalar types in a `type` union; object/array nullability
1309
+ # must be expressed with anyOf instead (e.g. {"anyOf": [<array schema>, {"type": "null"}]}).
1310
+ scalars = ("string", "number", "integer", "boolean")
1311
+
1312
+ def nullable(sub: Json) -> Json:
1313
+ kind = sub.get("type")
1314
+ if isinstance(kind, str) and kind in scalars:
1315
+ sub["type"] = [kind, "null"]
1316
+ elif isinstance(kind, list) and all(item in (*scalars, "null") for item in kind):
1317
+ if "null" not in kind:
1318
+ sub["type"] = [*kind, "null"]
1319
+ else:
1320
+ return {"anyOf": [sub, {"type": "null"}]}
1321
+ # An enum must accept null too, otherwise strict validation rejects the "omitted" value.
1322
+ if isinstance(sub.get("enum"), list) and None not in sub["enum"]:
1323
+ sub["enum"] = [*sub["enum"], None]
1324
+ return sub
1325
+
1326
+ return transform(copy.deepcopy(schema))
1327
+
1328
+
1329
+ def strictifiable(schema: Any) -> bool:
1330
+ """False if the schema contains a free-form object (an `object` with no `properties`),
1331
+ which strict function calling cannot represent — such tools fall back to non-strict."""
1332
+ if isinstance(schema, dict):
1333
+ if schema.get("type") == "object" and "properties" not in schema:
1334
+ return False
1335
+ return all(strictifiable(value) for value in schema.values())
1336
+ if isinstance(schema, list):
1337
+ return all(strictifiable(item) for item in schema)
1338
+ return True
1339
+
1340
+
1249
1341
  class Tool:
1250
1342
  NAME: ClassVar[str] = ""
1251
1343
  DESCRIPTION: ClassVar[str] = ""
@@ -1261,9 +1353,13 @@ class Tool:
1261
1353
  self.args = args
1262
1354
 
1263
1355
  @classmethod
1264
- def schema(cls) -> Json:
1356
+ def schema(cls, strict: bool = False) -> Json:
1265
1357
  description = "\n".join([cls.DESCRIPTION, "Signature: " + cls.SIGNATURE, *(("- " + item) for item in cls.EXAMPLE if item)])
1266
- return {"type": "function", "function": {"name": cls.NAME, "description": description, "parameters": cls.params_schema()}}
1358
+ function: Json = {"name": cls.NAME, "description": description, "parameters": cls.params_schema()}
1359
+ if strict and strictifiable(function["parameters"]):
1360
+ function["parameters"] = strict_tool_schema(function["parameters"])
1361
+ function["strict"] = True
1362
+ return {"type": "function", "function": function}
1267
1363
 
1268
1364
  @classmethod
1269
1365
  def params_schema(cls) -> Json:
@@ -1379,7 +1475,10 @@ class ReadTool(Tool):
1379
1475
  def arg_schema(cls) -> Json:
1380
1476
  return {
1381
1477
  "type": "object",
1382
- "properties": {"path": {"type": "string"}, "ranges": {"type": "array", "minItems": 1, "items": cls.RANGE_SCHEMA}},
1478
+ "properties": {
1479
+ "path": {"type": "string", "description": "File path to read"},
1480
+ "ranges": {"type": "array", "minItems": 1, "items": cls.RANGE_SCHEMA, "description": "Line ranges [[start,end],...], 0-based and end-exclusive; omit to read the whole file"},
1481
+ },
1383
1482
  "required": ["path"],
1384
1483
  "additionalProperties": False,
1385
1484
  }
@@ -1389,9 +1488,9 @@ class ReadTool(Tool):
1389
1488
  return {
1390
1489
  "type": "object",
1391
1490
  "properties": {
1392
- "path": {"type": "string"},
1393
- "ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1},
1394
- "files": {"type": "array", "items": cls.arg_schema(), "minItems": 1},
1491
+ "path": {"type": "string", "description": "File path to read (single-file form)"},
1492
+ "ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1, "description": "Line ranges [[start,end],...], 0-based and end-exclusive; omit to read the whole file"},
1493
+ "files": {"type": "array", "items": cls.arg_schema(), "minItems": 1, "description": "Batch form: list of {path, ranges} to read several files in one call"},
1395
1494
  },
1396
1495
  "additionalProperties": False,
1397
1496
  }
@@ -1489,7 +1588,7 @@ class LineCountTool(Tool):
1489
1588
  def params_schema(cls) -> Json:
1490
1589
  return {
1491
1590
  "type": "object",
1492
- "properties": {"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1}},
1591
+ "properties": {"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "File paths to count lines for"}},
1493
1592
  "required": ["paths"],
1494
1593
  "additionalProperties": False,
1495
1594
  }
@@ -1534,7 +1633,15 @@ class ListTool(Tool):
1534
1633
 
1535
1634
  @classmethod
1536
1635
  def params_schema(cls) -> Json:
1537
- return {"type": "object", "properties": {"path": {"type": "string"}, "glob": {"type": "string"}}, "required": ["path"], "additionalProperties": False}
1636
+ return {
1637
+ "type": "object",
1638
+ "properties": {
1639
+ "path": {"type": "string", "description": "Directory to list"},
1640
+ "glob": {"type": "string", "description": "Optional glob filtering child names (non-recursive), e.g. test_*.py"},
1641
+ },
1642
+ "required": ["path"],
1643
+ "additionalProperties": False,
1644
+ }
1538
1645
 
1539
1646
  @classmethod
1540
1647
  def payload_args(cls, payload: Json) -> list[Any]:
@@ -1599,10 +1706,10 @@ class FindTool(Tool):
1599
1706
  return {
1600
1707
  "type": "object",
1601
1708
  "properties": {
1602
- "name": {"type": "string"},
1603
- "path": {"type": "string"},
1604
- "type": {"type": "string", "enum": ["file", "dir", "any"]},
1605
- "limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_LIMIT},
1709
+ "name": {"type": "string", "description": "Glob or exact name to match, e.g. *.py or migrations"},
1710
+ "path": {"type": "string", "description": "Directory to search under; defaults to repo root"},
1711
+ "type": {"type": "string", "enum": ["file", "dir", "any"], "description": "Match files, dirs, or any; default file"},
1712
+ "limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_LIMIT, "description": f"Max results, 1..{cls.MAX_LIMIT}; default 100"},
1606
1713
  },
1607
1714
  "required": ["name"],
1608
1715
  "additionalProperties": False,
@@ -1611,7 +1718,7 @@ class FindTool(Tool):
1611
1718
  @classmethod
1612
1719
  def params_schema(cls) -> Json:
1613
1720
  props = dict(cls.arg_schema()["properties"])
1614
- props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1}
1721
+ props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1, "description": "Batch form: list of find queries to run in one call"}
1615
1722
  return {"type": "object", "properties": props, "additionalProperties": False}
1616
1723
 
1617
1724
  @classmethod
@@ -1717,10 +1824,10 @@ class SearchTool(Tool):
1717
1824
  return {
1718
1825
  "type": "object",
1719
1826
  "properties": {
1720
- "pattern": {"type": "string"},
1721
- "path": {"type": "string"},
1722
- "glob": {"type": "string"},
1723
- "context": {"type": "integer", "minimum": 0, "maximum": cls.MAX_CONTEXT},
1827
+ "pattern": {"type": "string", "description": "Case-insensitive regex; alternation A|B|C is allowed"},
1828
+ "path": {"type": "string", "description": "File or directory to search under; defaults to repo root"},
1829
+ "glob": {"type": "string", "description": "Optional glob limiting which files are searched, e.g. *.py"},
1830
+ "context": {"type": "integer", "minimum": 0, "maximum": cls.MAX_CONTEXT, "description": f"Context lines around each match, 0..{cls.MAX_CONTEXT}"},
1724
1831
  },
1725
1832
  "required": ["pattern"],
1726
1833
  "additionalProperties": False,
@@ -1729,7 +1836,7 @@ class SearchTool(Tool):
1729
1836
  @classmethod
1730
1837
  def params_schema(cls) -> Json:
1731
1838
  props = dict(cls.arg_schema()["properties"])
1732
- props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1}
1839
+ props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1, "description": "Batch form: list of search queries to run in one call"}
1733
1840
  return {"type": "object", "properties": props, "additionalProperties": False}
1734
1841
 
1735
1842
  @classmethod
@@ -2001,6 +2108,25 @@ class CodeIndex:
2001
2108
  return ""
2002
2109
  return self.update([self.session.resolve_path(path) for path in files])
2003
2110
 
2111
+ def update_pending_async(self) -> None:
2112
+ """Run the working-tree check (and any auto-update) off the UI critical path.
2113
+
2114
+ ``update_pending`` does a ``check=True`` scan that walks/hashes the tree — slow on
2115
+ large repos. Running it inline blocks answer emission and /status, so spawn it in a
2116
+ daemon thread. Guarded so only one scan/update runs at a time.
2117
+ """
2118
+ if self.session.state.code_index_checking or self.session.state.code_index_refreshing:
2119
+ return
2120
+ self.session.state.code_index_checking = True
2121
+
2122
+ def run() -> None:
2123
+ try:
2124
+ self.update_pending()
2125
+ finally:
2126
+ self.session.state.code_index_checking = False
2127
+
2128
+ threading.Thread(target=run, daemon=True).start()
2129
+
2004
2130
  def refresh_existing_async(self) -> bool:
2005
2131
  if self.session.state.code_index_refreshing:
2006
2132
  return False
@@ -2055,18 +2181,18 @@ class InspectCodeTool(Tool):
2055
2181
  @classmethod
2056
2182
  def params_schema(cls) -> Json:
2057
2183
  props = {
2058
- "mode": {"type": "string", "enum": list(cls.MODES)},
2059
- "target": {"type": "string"},
2060
- "limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_OUTLINE_LIMIT},
2061
- "kind": {"type": "string"},
2062
- "path": {"type": "string"},
2063
- "symbol": {"type": "string"},
2064
- "exact_only": {"type": "boolean"},
2065
- "depth": {"type": "integer", "minimum": 1, "maximum": cls.MAX_DEPTH},
2066
- "offset": {"type": "integer", "minimum": 0},
2067
- "all_kinds": {"type": "boolean"},
2068
- "ref_kind": {"type": "string"},
2069
- "loose": {"type": "boolean"},
2184
+ "mode": {"type": "string", "enum": list(cls.MODES), "description": "Query type: find|inspect|outline|refs|impls|callers|callees"},
2185
+ "target": {"type": "string", "description": "Symbol name (find/inspect/refs/impls/callers/callees) or file path (outline)"},
2186
+ "limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_OUTLINE_LIMIT, "description": "Max results"},
2187
+ "kind": {"type": "string", "description": "Restrict to a symbol kind, e.g. function, class, method"},
2188
+ "path": {"type": "string", "description": "Restrict the search to this file or directory"},
2189
+ "symbol": {"type": "string", "description": "Disambiguate target when multiple symbols share a name"},
2190
+ "exact_only": {"type": "boolean", "description": "Match the target name exactly instead of fuzzily"},
2191
+ "depth": {"type": "integer", "minimum": 1, "maximum": cls.MAX_DEPTH, "description": "Call-chain depth for callers/callees"},
2192
+ "offset": {"type": "integer", "minimum": 0, "description": "Pagination offset for refs/impls"},
2193
+ "all_kinds": {"type": "boolean", "description": "Include all reference kinds, not just behavioral ones (refs)"},
2194
+ "ref_kind": {"type": "string", "description": "Restrict refs to a specific reference kind"},
2195
+ "loose": {"type": "boolean", "description": "Loosen call-chain matching (callees)"},
2070
2196
  }
2071
2197
  return {"type": "object", "properties": props, "required": ["mode", "target"], "additionalProperties": False}
2072
2198
 
@@ -2190,13 +2316,23 @@ class EditTool(Tool):
2190
2316
  def params_schema(cls) -> Json:
2191
2317
  edit = {
2192
2318
  "type": "object",
2193
- "properties": {key: {"type": "string"} for key in ("op", "start", "end", "content", "old", "new")},
2319
+ "properties": {
2320
+ "op": {"type": "string", "description": "create|replace|delete|insert_before|insert_after|replace_all"},
2321
+ "start": {"type": "string", "description": "Start anchor line:hash (inclusive) for replace/delete/insert"},
2322
+ "end": {"type": "string", "description": "End anchor line:hash (inclusive) for replace/delete"},
2323
+ "content": {"type": "string", "description": "New text for create/replace/insert"},
2324
+ "old": {"type": "string", "description": "Text to find for replace_all"},
2325
+ "new": {"type": "string", "description": "Replacement text for replace_all"},
2326
+ },
2194
2327
  "required": ["op"],
2195
2328
  "additionalProperties": False,
2196
2329
  }
2197
2330
  return {
2198
2331
  "type": "object",
2199
- "properties": {"path": {"type": "string"}, "edits": {"type": "array", "items": edit, "minItems": 1}},
2332
+ "properties": {
2333
+ "path": {"type": "string", "description": "File to create or patch"},
2334
+ "edits": {"type": "array", "items": edit, "minItems": 1, "description": "Ordered edit operations to apply"},
2335
+ },
2200
2336
  "required": ["path", "edits"],
2201
2337
  "additionalProperties": False,
2202
2338
  }
@@ -2445,7 +2581,7 @@ class BashTool(Tool):
2445
2581
  def params_schema(cls) -> Json:
2446
2582
  return {
2447
2583
  "type": "object",
2448
- "properties": {"command": {"type": "string", "minLength": 1, "pattern": "^.*\\S.*$"}},
2584
+ "properties": {"command": {"type": "string", "minLength": 1, "pattern": "^.*\\S.*$", "description": "Bash command to run in the workspace; filter noisy output with head/tail/rg"}},
2449
2585
  "required": ["command"],
2450
2586
  "additionalProperties": False,
2451
2587
  }
@@ -2565,7 +2701,9 @@ class BashTool(Tool):
2565
2701
 
2566
2702
  class GitTool(Tool):
2567
2703
  NAME = "Git"
2568
- DESCRIPTION = 'Run git with explicit argv (default: cwd from Environment; use cwd= for other directories). For add, pass explicit file paths; broad add is rejected.'
2704
+ DESCRIPTION = (
2705
+ "Run git with explicit argv (default: cwd from Environment; use cwd= for other directories). For add, pass explicit file paths; broad add is rejected."
2706
+ )
2569
2707
  SIGNATURE = "Git(argv=[command,...], cwd?)"
2570
2708
  EXAMPLE = (
2571
2709
  'Status. Example: {"argv":["status","--short"]}',
@@ -2580,7 +2718,10 @@ class GitTool(Tool):
2580
2718
  def params_schema(cls) -> Json:
2581
2719
  return {
2582
2720
  "type": "object",
2583
- "properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}},
2721
+ "properties": {
2722
+ "cwd": {"type": "string", "description": "Working directory for the git command; defaults to repo root"},
2723
+ "argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "description": "Git command and args as a list, e.g. [\"status\",\"--short\"]"},
2724
+ },
2584
2725
  "required": ["argv"],
2585
2726
  "additionalProperties": False,
2586
2727
  }
@@ -2589,11 +2730,7 @@ class GitTool(Tool):
2589
2730
  def payload_args(cls, payload: Json) -> list[Any]:
2590
2731
  argv = payload.get("argv")
2591
2732
  if not isinstance(argv, list) or not argv:
2592
- raise ToolError(
2593
- "Git requires a non-empty 'argv' list. "
2594
- 'Signature: Git(argv=[command,...], cwd?) '
2595
- 'Example: {"argv":["status","--short"]}'
2596
- )
2733
+ raise ToolError('Git requires a non-empty \'argv\' list. Signature: Git(argv=[command,...], cwd?) Example: {"argv":["status","--short"]}')
2597
2734
  argv = [str(a) for a in argv]
2598
2735
  return [("cwd=" + str(payload["cwd"])), *argv] if payload.get("cwd") else argv
2599
2736
 
@@ -2695,8 +2832,8 @@ class RecallTool(Tool):
2695
2832
  return {
2696
2833
  "type": "object",
2697
2834
  "properties": {
2698
- "keys": {"type": "array", "items": {"type": "string", "pattern": "^tr\\.\\d+$"}, "minItems": 1},
2699
- "ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1},
2835
+ "keys": {"type": "array", "items": {"type": "string", "pattern": "^tr\\.\\d+$"}, "minItems": 1, "description": "Stored result keys to recall, e.g. [\"tr.3\",\"tr.5\"]"},
2836
+ "ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1, "description": "Optional 0-based [start,end] output-line slices to limit recalled context"},
2700
2837
  },
2701
2838
  "required": ["keys"],
2702
2839
  "additionalProperties": False,
@@ -2776,16 +2913,24 @@ class NoteTool(Tool):
2776
2913
 
2777
2914
  @classmethod
2778
2915
  def params_schema(cls) -> Json:
2779
- strings = {"type": "array", "items": {"type": "string"}}
2780
2916
  plan_item = {
2781
2917
  "type": "object",
2782
- "properties": {"status": {"type": "string", "enum": list(PlanItem.STATUSES)}, "text": {"type": "string"}},
2918
+ "properties": {
2919
+ "status": {"type": "string", "enum": list(PlanItem.STATUSES), "description": "todo|doing|done|blocked"},
2920
+ "text": {"type": "string", "description": "Plan step description"},
2921
+ },
2783
2922
  "required": ["status", "text"],
2784
2923
  "additionalProperties": False,
2785
2924
  }
2786
2925
  return {
2787
2926
  "type": "object",
2788
- "properties": {"set_goal": {"type": "string"}, "replace_plan": {"type": "array", "items": plan_item}, "append_known": strings, "replace_known": strings, "set_check": {"type": "string"}},
2927
+ "properties": {
2928
+ "set_goal": {"type": "string", "description": "Replace the current goal"},
2929
+ "replace_plan": {"type": "array", "items": plan_item, "description": "Replace the plan with these status/text items"},
2930
+ "append_known": {"type": "array", "items": {"type": "string"}, "description": "Append these facts to known"},
2931
+ "replace_known": {"type": "array", "items": {"type": "string"}, "description": "Replace all known facts with these"},
2932
+ "set_check": {"type": "string", "description": "Replace the success/verification criteria"},
2933
+ },
2789
2934
  "additionalProperties": False,
2790
2935
  }
2791
2936
 
@@ -2872,7 +3017,7 @@ class QuestionSpec:
2872
3017
  class QuestionTool(Tool):
2873
3018
  NAME = "Question"
2874
3019
  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."
2875
- SIGNATURE = 'Question(questions=[{question, choices?, previews?, recommended?}, ...])'
3020
+ SIGNATURE = "Question(questions=[{question, choices?, previews?, recommended?}, ...])"
2876
3021
  EXAMPLE = (
2877
3022
  'One question, recommending a choice. Example: {"questions":[{"question":"Which approach?","choices":["Refactor","Rewrite"],"previews":["Extract module +87 -12","Rewrite from scratch"],"recommended":0}]}',
2878
3023
  'Batch related questions. Example: {"questions":[{"question":"Target runtime?","choices":["Node","Deno"]},{"question":"Name the module?"}]}',
@@ -2971,6 +3116,7 @@ class QuestionTool(Tool):
2971
3116
  label = Tool.compact(first, 80)
2972
3117
  return [label + (f" (+{len(questions) - 1} more)" if len(questions) > 1 else "")]
2973
3118
 
3119
+
2974
3120
  class MCPTool(Tool):
2975
3121
  NAME = "MCP"
2976
3122
  DESCRIPTION = "Call/describe external MCP server tools, and list/read MCP resources"
@@ -3085,9 +3231,10 @@ class MCPTool(Tool):
3085
3231
  return mcp.read_resource(server, str(payload.get("uri") or ""))
3086
3232
  raise ToolError(
3087
3233
  f"unknown MCP action {action!r}. Valid actions: {', '.join(self.ACTIONS)}. "
3088
- f"To invoke a remote tool named {action!r}, use action=\"call\", tool={action!r}."
3234
+ f'To invoke a remote tool named {action!r}, use action="call", tool={action!r}.'
3089
3235
  )
3090
3236
 
3237
+
3091
3238
  TOOLS: tuple[type[Tool], ...] = (
3092
3239
  MCPTool,
3093
3240
  ReadTool,
@@ -3121,8 +3268,7 @@ class ContextManager:
3121
3268
  COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
3122
3269
  MCP_DESCRIBE_BLOCK: ClassVar[re.Pattern] = re.compile(r"<MCPDescribe server=(\".*?\") tool=(\".*?\")>.*?</MCPDescribe>", re.DOTALL)
3123
3270
  CODE_EXTENSIONS: ClassVar[set[str]] = set(
3124
- ".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
3125
- ".swift .toml .ts .tsx .vue .yaml .yml".split()
3271
+ ".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql .swift .toml .ts .tsx .vue .yaml .yml".split()
3126
3272
  )
3127
3273
  CODE_FILENAMES: ClassVar[set[str]] = {"CMakeLists.txt", "Dockerfile", "Makefile", "go.mod", "package.json", "pyproject.toml"}
3128
3274
 
@@ -4091,6 +4237,33 @@ class MCPManager:
4091
4237
  def run_async(self, coroutine: Any) -> Any:
4092
4238
  return asyncio.run_coroutine_threadsafe(coroutine, self._async_loop()).result()
4093
4239
 
4240
+ def close(self) -> None:
4241
+ # Stop and join the background loop before the interpreter tears down its
4242
+ # default executors. Otherwise an in-flight client cleanup (HTTP session
4243
+ # termination, DNS via run_in_executor) races the concurrent.futures atexit
4244
+ # shutdown and prints "cannot schedule new futures after shutdown".
4245
+ with self._loop_lock:
4246
+ loop = self._loop
4247
+ thread = self._loop_thread
4248
+ self._loop = None
4249
+ self._loop_thread = None
4250
+ if loop is None or thread is None:
4251
+ return
4252
+
4253
+ async def _shutdown() -> None:
4254
+ pending = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task()]
4255
+ for task in pending:
4256
+ task.cancel()
4257
+ for task in pending:
4258
+ with contextlib.suppress(BaseException):
4259
+ await task
4260
+
4261
+ if loop.is_running():
4262
+ with contextlib.suppress(BaseException):
4263
+ asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(timeout=5)
4264
+ loop.call_soon_threadsafe(loop.stop)
4265
+ thread.join(timeout=5)
4266
+
4094
4267
  def oauth_client(self, config: MCPServerConfig, *, interactive: bool = False, notify: Callable[[str], None] | None = None) -> Any:
4095
4268
  from fastmcp.client.auth import OAuth
4096
4269
 
@@ -4185,7 +4358,9 @@ class MCPManager:
4185
4358
 
4186
4359
  try:
4187
4360
  result = self.run_async(
4188
- self._call_oauth_tool(config, headers, tool_name, arguments) if config.auth == "oauth" else self._call_tool(config, headers, tool_name, arguments)
4361
+ self._call_oauth_tool(config, headers, tool_name, arguments)
4362
+ if config.auth == "oauth"
4363
+ else self._call_tool(config, headers, tool_name, arguments)
4189
4364
  )
4190
4365
  except Exception as e:
4191
4366
  raise ToolError("MCP call failed: " + self.error_text(e))
@@ -4226,9 +4401,7 @@ class MCPManager:
4226
4401
  raise ToolError("MCP read_resource requires a uri")
4227
4402
  config, headers = self._resource_preamble(server)
4228
4403
  try:
4229
- result = self.run_async(
4230
- self._read_oauth_resource(config, headers, uri) if config.auth == "oauth" else self._read_resource(config, headers, uri)
4231
- )
4404
+ result = self.run_async(self._read_oauth_resource(config, headers, uri) if config.auth == "oauth" else self._read_resource(config, headers, uri))
4232
4405
  except Exception as e:
4233
4406
  raise ToolError("MCP resource read failed: " + self.error_text(e))
4234
4407
  text = self.normalize_resource(result)
@@ -4346,7 +4519,9 @@ class MCPManager:
4346
4519
  from fastmcp.client.transports import StreamableHttpTransport
4347
4520
 
4348
4521
  timeout = self.discovery_timeout()
4349
- async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
4522
+ async with Client(
4523
+ StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout
4524
+ ) as client:
4350
4525
  return await asyncio.wait_for(client.list_resources(), timeout=timeout)
4351
4526
 
4352
4527
  async def _read_resource(self, config: MCPServerConfig, headers: dict[str, str], uri: str) -> Any:
@@ -4361,7 +4536,9 @@ class MCPManager:
4361
4536
  from fastmcp.client.transports import StreamableHttpTransport
4362
4537
 
4363
4538
  timeout = self.call_timeout()
4364
- async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
4539
+ async with Client(
4540
+ StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout
4541
+ ) as client:
4365
4542
  return await asyncio.wait_for(client.read_resource(uri), timeout=timeout)
4366
4543
 
4367
4544
  async def _call_oauth_tool(self, config: MCPServerConfig, headers: dict[str, str], name: str, arguments: Json) -> Any:
@@ -4369,7 +4546,9 @@ class MCPManager:
4369
4546
  from fastmcp.client.transports import StreamableHttpTransport
4370
4547
 
4371
4548
  timeout = self.call_timeout()
4372
- async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
4549
+ async with Client(
4550
+ StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout
4551
+ ) as client:
4373
4552
  return await asyncio.wait_for(client.call_tool(name, arguments), timeout=timeout)
4374
4553
 
4375
4554
  def login_server(self, name: str, notify: Callable[[str], None] | None = None) -> str:
@@ -4553,7 +4732,7 @@ class MCPManager:
4553
4732
  if line:
4554
4733
  lines.append(line)
4555
4734
  if resources:
4556
- lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(config.name)}, uri=...):")
4735
+ lines.append(f'resources ({len(resources)}) — read with MCP(action="read_resource", server={json.dumps(config.name)}, uri=...):')
4557
4736
  lines.extend(self._format_resource_line(res) for res in resources)
4558
4737
  lines.append("")
4559
4738
 
@@ -4633,7 +4812,7 @@ class MCPManager:
4633
4812
  lines.append(line)
4634
4813
  resources = self.resources.get(server, [])
4635
4814
  if resources:
4636
- lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(server)}, uri=...):")
4815
+ lines.append(f'resources ({len(resources)}) — read with MCP(action="read_resource", server={json.dumps(server)}, uri=...):')
4637
4816
  lines.extend(self._format_resource_line(res) for res in resources)
4638
4817
  return "\n".join(lines)
4639
4818
 
@@ -4651,7 +4830,7 @@ class MCPManager:
4651
4830
  # truncated above, so surface any resource-like URIs it mentions explicitly.
4652
4831
  uris = self._extract_uris(info.description)
4653
4832
  if uris:
4654
- line += "\n refs (read with MCP action=\"read_resource\"): " + ", ".join(uris)
4833
+ line += '\n refs (read with MCP action="read_resource"): ' + ", ".join(uris)
4655
4834
  if include_schema:
4656
4835
  schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
4657
4836
  if schema:
@@ -4760,13 +4939,24 @@ class MCPManager:
4760
4939
  if config.env_http_headers:
4761
4940
  for header_name in config.env_http_headers:
4762
4941
  auth.append("env_header(" + header_name + ")")
4763
- lines.append("| `" + self.markdown_cell(config.name) + "` | " + self.markdown_cell(status) + " | " + self.markdown_cell(tools or "-") + " | " + self.markdown_cell(", ".join(auth) or "-") + " |")
4942
+ lines.append(
4943
+ "| `"
4944
+ + self.markdown_cell(config.name)
4945
+ + "` | "
4946
+ + self.markdown_cell(status)
4947
+ + " | "
4948
+ + self.markdown_cell(tools or "-")
4949
+ + " | "
4950
+ + self.markdown_cell(", ".join(auth) or "-")
4951
+ + " |"
4952
+ )
4764
4953
  return "\n".join(lines) if len(lines) > 2 else "(no MCP servers configured)"
4765
4954
 
4766
4955
  @staticmethod
4767
4956
  def markdown_cell(text: str) -> str:
4768
4957
  return Text.clean(str(text)).replace("\n", " ").replace("|", "\\|")
4769
4958
 
4959
+
4770
4960
  class ToolRunner:
4771
4961
  def __init__(self, session: Session, context: ContextManager, input_fn=input, output_fn=print):
4772
4962
  self.session = session
@@ -4776,7 +4966,7 @@ class ToolRunner:
4776
4966
  self.preview_fn: Callable[[str], bool] | None = None
4777
4967
  self.preview_full_fn: Callable[[str], None] | None = None
4778
4968
  self.live_output: Callable[[str, str], None] | None = None
4779
- self.live_start: Callable[[], None] | None = None
4969
+ self.live_start: Callable[[str], None] | None = None
4780
4970
  self.question_fn: Callable[[QuestionSpec, str], str] | None = None
4781
4971
 
4782
4972
  def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
@@ -4945,7 +5135,7 @@ class ToolRunner:
4945
5135
  return "refused", self.finish(call, output, failed=True, elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
4946
5136
  approved = True
4947
5137
  if isinstance(tool, BashTool) and self.live_start is not None:
4948
- self.live_start()
5138
+ self.live_start(str(call.args[0]) if call.args else "")
4949
5139
  output = planned_edit.call(tool) if planned_edit and isinstance(tool, EditTool) else tool.call()
4950
5140
  except ToolError as error:
4951
5141
  return "failed", self.reject(call, f"ToolError: {error}", elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
@@ -4991,7 +5181,9 @@ class ToolRunner:
4991
5181
  self.session.record_tool_error(key or "-", call.name, call.args, output)
4992
5182
  elif key:
4993
5183
  self.update_code_index(call, output)
4994
- self.output_fn(self.finish_display(call, key, output, failed=failed, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix, elapsed=elapsed))
5184
+ self.output_fn(
5185
+ self.finish_display(call, key, output, failed=failed, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix, elapsed=elapsed)
5186
+ )
4995
5187
  return self.tool_message(call, key, output, failed=failed, display=display)
4996
5188
 
4997
5189
  def tool_message(self, call: ToolCall, key: str, output: str, *, failed: bool = False, display: str | None = None) -> str:
@@ -5067,7 +5259,17 @@ class ToolRunner:
5067
5259
  return "\n".join([" preview", *(" " + line for line in lines)])
5068
5260
 
5069
5261
  def finish_display(
5070
- self, call: ToolCall, key: str, output: str, *, failed: bool, approved: bool = False, auto: bool = False, display: str | None = None, batch_suffix: str = "", elapsed: float | None = None
5262
+ self,
5263
+ call: ToolCall,
5264
+ key: str,
5265
+ output: str,
5266
+ *,
5267
+ failed: bool,
5268
+ approved: bool = False,
5269
+ auto: bool = False,
5270
+ display: str | None = None,
5271
+ batch_suffix: str = "",
5272
+ elapsed: float | None = None,
5071
5273
  ) -> str:
5072
5274
  if call.name == "Note" and not failed and display:
5073
5275
  return self.with_batch_suffix(display.removeprefix("Note ").strip(), batch_suffix)
@@ -5208,7 +5410,7 @@ class ModelClient:
5208
5410
  provider = self.session.config.provider
5209
5411
  if missing := self.session.missing_config():
5210
5412
  raise ModelError("missing config: " + ", ".join(missing))
5211
- tools = [tool.schema() for tool in TOOL_REGISTRY.values()]
5413
+ tools = [tool.schema(provider.resolved_strict_tools()) for tool in TOOL_REGISTRY.values()]
5212
5414
  for attempt in range(MODEL_REQUEST_RETRIES + 1):
5213
5415
  self.session.state.current_model_call_started_at = time.monotonic()
5214
5416
  try:
@@ -5249,6 +5451,8 @@ class ModelClient:
5249
5451
  messages = Text.value(messages)
5250
5452
  provider = self.session.config.provider
5251
5453
  params: Json = {"model": provider.model, "messages": messages, "stream": False}
5454
+ if (max_tokens := provider.resolved_max_tokens()) > 0:
5455
+ params["max_tokens"] = max_tokens
5252
5456
  if tools:
5253
5457
  params["tools"] = tools
5254
5458
  params["tool_choice"] = "auto"
@@ -5339,6 +5543,8 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5339
5543
  return ""
5340
5544
  if configured != "auto":
5341
5545
  return configured
5546
+ if not provider.supports_prompt_cache_key():
5547
+ return ""
5342
5548
  payload = {
5343
5549
  "api": provider.resolved_api(),
5344
5550
  "cwd": self.session.cwd,
@@ -5474,11 +5680,12 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5474
5680
  return assistant, calls, text
5475
5681
 
5476
5682
  def apply_provider_params(self, params: Json, provider: ProviderConfig) -> None:
5477
- if provider.temperature is not None:
5478
- params["temperature"] = provider.temperature
5479
5683
  chat_reasoning = provider.resolved_chat_reasoning()
5480
5684
  reasoning_enabled = provider.reasoning != "off"
5481
5685
  effort = provider.reasoning_effort()
5686
+ # Native thinking modes (DeepSeek, Qwen) ignore or reject temperature while thinking is on.
5687
+ if provider.temperature is not None and not (reasoning_enabled and chat_reasoning in ("thinking", "enable_thinking")):
5688
+ params["temperature"] = provider.temperature
5482
5689
  extra: Json = {}
5483
5690
  if reasoning_enabled and chat_reasoning == "reasoning":
5484
5691
  extra["reasoning"] = {"effort": effort}
@@ -5538,12 +5745,22 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5538
5745
  calls.append(self.tool_call(raw.id, raw.function.name, payload))
5539
5746
  return calls
5540
5747
 
5541
- @staticmethod
5542
- def tool_payload(name: str, payload: Any) -> list[Any]:
5748
+ @classmethod
5749
+ def tool_payload(cls, name: str, payload: Any) -> list[Any]:
5543
5750
  if isinstance(payload, dict) and (tool := TOOL_REGISTRY.get(name)):
5544
- return tool.payload_args(payload)
5751
+ # Strict schemas express optional params as nullable, so the model may send explicit
5752
+ # null for an omitted argument. In every nanocode tool null means "absent", so drop it.
5753
+ return tool.payload_args(cls.drop_nulls(payload))
5545
5754
  return [payload]
5546
5755
 
5756
+ @classmethod
5757
+ def drop_nulls(cls, value: Any) -> Any:
5758
+ if isinstance(value, dict):
5759
+ return {key: cls.drop_nulls(item) for key, item in value.items() if item is not None}
5760
+ if isinstance(value, list):
5761
+ return [cls.drop_nulls(item) for item in value]
5762
+ return value
5763
+
5547
5764
  @classmethod
5548
5765
  def tool_call(cls, call_id: str, name: str, payload: Any) -> ToolCall:
5549
5766
  # payload_args may reject malformed arguments (e.g. Git with an empty argv). Capture that
@@ -5587,7 +5804,8 @@ FILE STATE:
5587
5804
  - Read and Edit refresh FILE STATE; after Edit, trust the edited range.
5588
5805
 
5589
5806
  FINAL:
5590
- - Default to a few lines; scale to the task. Lead with the result; no preamble or recap.
5807
+ - Be concise by default: answer in as few lines as the task allows (often 1-3), and stop. Lead with the result; no preamble, recap, or filler.
5808
+ - Do not over-explain, enumerate options, or add background unless the user asks for detail, a walkthrough, or "why". Match length to what was requested; only go long when the user requests it or the task genuinely requires it.
5591
5809
  - Concise markdown in the user's language; note changed files and checks run (or not run).\
5592
5810
  """
5593
5811
 
@@ -5684,6 +5902,7 @@ class CommandCompleter(Completer):
5684
5902
  "/reason",
5685
5903
  "/set",
5686
5904
  "/yolo",
5905
+ "/strict",
5687
5906
  "/exit",
5688
5907
  "/quit",
5689
5908
  )
@@ -5697,6 +5916,8 @@ class CommandCompleter(Completer):
5697
5916
  "provider.chat_reasoning",
5698
5917
  "provider.available_models",
5699
5918
  "provider.temperature",
5919
+ "provider.max_tokens",
5920
+ "provider.strict_tools",
5700
5921
  "provider.timeout",
5701
5922
  "runtime.yolo",
5702
5923
  "runtime.max_agent_steps",
@@ -5704,6 +5925,7 @@ class CommandCompleter(Completer):
5704
5925
  "runtime.max_parallel_tools",
5705
5926
  "runtime.shell_timeout",
5706
5927
  "runtime.check_updates",
5928
+ "runtime.tips",
5707
5929
  )
5708
5930
  SET_VALUES = {
5709
5931
  "provider.api": PROVIDER_API_CHOICES,
@@ -5711,8 +5933,10 @@ class CommandCompleter(Completer):
5711
5933
  "provider.reasoning": REASONING_CHOICES,
5712
5934
  "provider.chat_reasoning": CHAT_REASONING_CHOICES,
5713
5935
  "provider.temperature": ("off",),
5936
+ "provider.strict_tools": ("on", "off", "true", "false"),
5714
5937
  "runtime.yolo": ("on", "off", "true", "false"),
5715
5938
  "runtime.check_updates": ("on", "off", "true", "false"),
5939
+ "runtime.tips": ("on", "off", "true", "false"),
5716
5940
  }
5717
5941
 
5718
5942
  def __init__(
@@ -5822,10 +6046,21 @@ class UiPrinter:
5822
6046
  return [("ansibrightblack", text + "\n")]
5823
6047
  if text.startswith("nanocode "):
5824
6048
  return [("ansicyan", text + "\n")]
6049
+ if text.startswith("tip: "):
6050
+ return self.tip_segments(text[len("tip: "):])
5825
6051
  if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
5826
6052
  return [("ansired", text + "\n")]
5827
6053
  return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
5828
6054
 
6055
+ def tip_segments(self, text: str) -> list[tuple[str, str]]:
6056
+ # Muted hint line with a labeled marker; `code` spans are highlighted so commands stand out.
6057
+ segments: list[tuple[str, str]] = [("ansibrightblack", " "), ("ansiyellow", "💡 tip "), ("ansibrightblack", "· ")]
6058
+ for index, part in enumerate(text.split("`")):
6059
+ if part:
6060
+ segments.append(("ansicyan" if index % 2 else "ansibrightblack", part))
6061
+ segments.append(("", "\n"))
6062
+ return segments
6063
+
5829
6064
  def tool_segments(self, text: str) -> list[tuple[str, str]]:
5830
6065
  segments = []
5831
6066
  for line in text.splitlines() or [""]:
@@ -5949,31 +6184,73 @@ class UiPrinter:
5949
6184
  at_start = part.endswith("\n")
5950
6185
  return indented
5951
6186
 
6187
+
5952
6188
  class BashLivePreview:
5953
6189
  HEIGHT: ClassVar[int] = 6
5954
6190
  MAX_CHARS: ClassVar[int] = 8000
6191
+ # Heartbeat tick so the elapsed timer advances even while a command produces no output
6192
+ # (e.g. quiet long-runners or `... | tail` that buffers until EOF), so the terminal never
6193
+ # looks frozen during a blocking command.
6194
+ TICK: ClassVar[float] = 0.1
5955
6195
 
5956
6196
  def __init__(self):
5957
6197
  self.output = create_output(sys.stderr)
5958
6198
  self.active = False
5959
6199
  self.rendered_lines = 0
5960
6200
  self.text = ""
6201
+ self.command = ""
6202
+ self.started_at = 0.0
6203
+ self.lock = threading.Lock()
6204
+ self.timer: threading.Thread | None = None
5961
6205
 
5962
- def start(self) -> None:
6206
+ def start(self, command: str = "") -> None:
5963
6207
  if not sys.stderr.isatty():
5964
6208
  return
5965
- self.active, self.rendered_lines, self.text = True, 0, ""
6209
+ with self.lock:
6210
+ self.active, self.rendered_lines, self.text = True, 0, ""
6211
+ self.command = " ".join(command.split())
6212
+ self.started_at = time.monotonic()
6213
+ self.render()
6214
+ self.timer = threading.Thread(target=self.tick, daemon=True)
6215
+ self.timer.start()
6216
+
6217
+ def tick(self) -> None:
6218
+ while True:
6219
+ time.sleep(self.TICK)
6220
+ with self.lock:
6221
+ if not self.active:
6222
+ return
6223
+ self.render()
5966
6224
 
5967
6225
  def update(self, text: str) -> None:
5968
- if not self.active:
5969
- return
5970
- self.text = (self.text + text)[-self.MAX_CHARS :]
5971
- self.render()
6226
+ with self.lock:
6227
+ if not self.active:
6228
+ return
6229
+ self.text = (self.text + text)[-self.MAX_CHARS :]
6230
+ self.render()
5972
6231
 
5973
6232
  def finish(self) -> None:
5974
- if not self.active:
6233
+ with self.lock:
6234
+ if not self.active:
6235
+ return
6236
+ self.active = False
6237
+ timer = self.timer
6238
+ if timer is not None:
6239
+ timer.join()
6240
+ with self.lock:
6241
+ self.clear()
6242
+ self.rendered_lines, self.text = 0, ""
6243
+
6244
+ def clear(self) -> None:
6245
+ if not self.rendered_lines:
5975
6246
  return
5976
- self.active, self.rendered_lines = False, 0
6247
+ self.output.write_raw(f"\x1b[{self.rendered_lines}A")
6248
+ for _ in range(self.rendered_lines):
6249
+ self.output.write_raw("\r")
6250
+ self.output.erase_end_of_line()
6251
+ self.output.write_raw("\n")
6252
+ self.output.write_raw(f"\x1b[{self.rendered_lines}A")
6253
+ self.output.flush()
5977
6254
 
5978
6255
  def render(self) -> None:
5979
6256
  if not self.active:
@@ -5996,11 +6273,26 @@ class BashLivePreview:
5996
6273
  self.output.flush()
5997
6274
  self.rendered_lines = len(lines)
5998
6275
 
6276
+ def elapsed_label(self) -> str:
6277
+ elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
6278
+ if elapsed < 60:
6279
+ return f"{elapsed:.1f}s"
6280
+ minutes, rest = divmod(int(elapsed), 60)
6281
+ return f"{minutes}m{rest:02d}s"
6282
+
5999
6283
  def frame_lines(self) -> list[str]:
6000
6284
  width = max(20, shutil.get_terminal_size((120, 20)).columns)
6001
6285
  body = [line.expandtabs(4) for line in self.text.replace("\r", "\n").splitlines()[-self.HEIGHT :]]
6002
- limit = width - 2
6003
- return [" output", *(" " + (line if len(line) <= limit else line[: max(0, limit - 3)] + "...") for line in body)] if body else []
6286
+ label = self.elapsed_label()
6287
+ # `limit` leaves a column of slack so a full-width line cannot auto-wrap and desync the
6288
+ # cursor-up math in render().
6289
+ limit = width - 3
6290
+ clip = lambda line: line if len(line) <= limit else line[: max(0, limit - 3)] + "..."
6291
+ # Always emit a header (the running command + a live elapsed timer) so the frame is visible
6292
+ # even before any output arrives and the user can see what is executing.
6293
+ status = f"output · {label}" if body else f"running… {label}"
6294
+ header = [clip(" $ " + self.command)] if self.command else []
6295
+ return [*header, " " + status, *(" " + clip(line) for line in body)]
6004
6296
 
6005
6297
 
6006
6298
  class ModelRetryShortcut:
@@ -6267,6 +6559,46 @@ class CommandLoop:
6267
6559
  "logout": (1, 1, "Usage: /mcp logout <server>\nExample: /mcp logout myOAuthServer"),
6268
6560
  "refresh": (0, 1, "Usage: /mcp refresh [server]"),
6269
6561
  }
6562
+ # (predicate, tip): predicate gates a tip to contexts where it is actually useful.
6563
+ ALWAYS = staticmethod(lambda s: True)
6564
+ TIPS: ClassVar[tuple[tuple[Callable[["Session"], bool], str], ...]] = (
6565
+ # Sessions & input
6566
+ (ALWAYS, "Resume your last session anytime with `nanocode --resume`."),
6567
+ (ALWAYS, "Keep typing while the agent works — your input is picked up at the next step."),
6568
+ (ALWAYS, "Press Ctrl+C to cancel the current input or interrupt a running turn."),
6569
+ (ALWAYS, "Search your input history with Ctrl+R."),
6570
+ (ALWAYS, "Tab completes commands, file paths, and mentions."),
6571
+ # Context & memory
6572
+ (ALWAYS, "`/compact` summarizes a long conversation to reclaim context."),
6573
+ (ALWAYS, "`/memory` shows the agent's current goal, plan, and known facts."),
6574
+ (ALWAYS, "`/status` shows token usage, context %, and prompt-cache hit rate."),
6575
+ (ALWAYS, "Stable context is kept early so the prompt cache is reused — cheaper, faster turns."),
6576
+ # Model & reasoning
6577
+ (ALWAYS, "`/model` switches model and `/reason` sets reasoning effort on the fly."),
6578
+ (ALWAYS, "`/set provider.reasoning high` digs deeper on hard tasks; `off` is fastest."),
6579
+ (ALWAYS, "`/set provider.max_tokens N` caps the model's output length."),
6580
+ (ALWAYS, "`/api` shows or switches the API protocol (auto / chat / anthropic)."),
6581
+ (lambda s: len(s.config.providers) > 1, "`/provider` switches between your configured providers."),
6582
+ (lambda s: s.config.provider.supports_strict_tools(), "`/strict` constrains tool-call arguments to each tool's schema (OpenAI / DeepSeek)."),
6583
+ # Tools & navigation
6584
+ (ALWAYS, "`/index` manages the code symbol index for fast symbol navigation."),
6585
+ (ALWAYS, "`/yolo` skips tool confirmations when you want to move fast."),
6586
+ (ALWAYS, "`/set runtime.max_parallel_tools N` tunes how many reads run at once."),
6587
+ (lambda s: bool(s.config.mcp), "Mention an MCP tool inline with `@server.tool` to pull in its schema."),
6588
+ (lambda s: bool(s.config.mcp), "`/mcp` manages servers; `/mcp login NAME` starts an OAuth flow."),
6589
+ # Config & setup
6590
+ (ALWAYS, "`/config` opens your config; `/set KEY VALUE` changes settings live."),
6591
+ (ALWAYS, "Scaffold a fresh config with `nanocode --init-config`."),
6592
+ (ALWAYS, "Launch with `--yolo` to skip confirmations, or `--debug` to record request traces."),
6593
+ (ALWAYS, "Filter MCP servers at launch with `--mcp \"name*,!exclude\"`."),
6594
+ (ALWAYS, "Silence these hints with `/set runtime.tips off`."),
6595
+ )
6596
+
6597
+ def startup_tip(self) -> str:
6598
+ if not self.session.settings.tips:
6599
+ return ""
6600
+ eligible = [tip for predicate, tip in self.TIPS if predicate(self.session)]
6601
+ return random.choice(eligible) if eligible else ""
6270
6602
  MCP_HELP = "Try /mcp, /mcp tools [server], /mcp login <server>, /mcp logout <server>, /mcp refresh [server]"
6271
6603
 
6272
6604
  HELP = """Commands:
@@ -6283,6 +6615,7 @@ class CommandLoop:
6283
6615
  /reason Select reasoning effort.
6284
6616
  /set KEY VALUE Set provider.* and runtime.*.
6285
6617
  /yolo Toggle tool confirmations.
6618
+ /strict Toggle strict tool-call schemas (OpenAI / DeepSeek).
6286
6619
  /mcp Show MCP server status.
6287
6620
  /mcp tools [NAME] List MCP tools.
6288
6621
  /mcp login NAME Start OAuth login for a server.
@@ -6484,7 +6817,6 @@ Tools:
6484
6817
  while self.queue_input_active.is_set() and time.monotonic() < deadline:
6485
6818
  time.sleep(0.01)
6486
6819
 
6487
-
6488
6820
  def drain_queued_input(self) -> str:
6489
6821
  """Return queued user input, or empty string if nothing queued."""
6490
6822
  texts = []
@@ -6495,8 +6827,11 @@ Tools:
6495
6827
  texts.append(self.queue_input_text)
6496
6828
  self.queue_input_text = ""
6497
6829
  return "\n".join(texts)
6830
+
6498
6831
  def run(self) -> int:
6499
6832
  self.emit(f"nanocode {__version__}. /help for commands.")
6833
+ if tip := self.startup_tip():
6834
+ self.emit("tip: " + tip)
6500
6835
  self.session.clean_expired_snapshots()
6501
6836
  self.render_resumed_session()
6502
6837
  CodeIndex(self.session).refresh_existing_async()
@@ -6546,7 +6881,7 @@ Tools:
6546
6881
  watcher.join(timeout=1.0)
6547
6882
  self.queue_input_paused.clear()
6548
6883
  self.session.state.manual_model_retry_requested = False
6549
- CodeIndex(self.session).update_pending()
6884
+ CodeIndex(self.session).update_pending_async()
6550
6885
  self.status_bar.stop()
6551
6886
  elapsed = time.monotonic() - started
6552
6887
  self.ui.emit_answer(answer)
@@ -6639,14 +6974,12 @@ Tools:
6639
6974
  # the index is too large to fit even as name-only (some tools are hidden from the model).
6640
6975
  self.session.mcp.render_tools_index()
6641
6976
  if self.session.mcp.index_truncated:
6642
- self.emit("mcp: tools index exceeds the size budget; some tools are hidden from the model. Reduce enabled servers or run /mcp tools to see the full list.")
6977
+ self.emit(
6978
+ "mcp: tools index exceeds the size budget; some tools are hidden from the model. Reduce enabled servers or run /mcp tools to see the full list."
6979
+ )
6643
6980
 
6644
6981
  def mcp_error_notice(self) -> str:
6645
- errors = [
6646
- (name, error)
6647
- for name, error in sorted(self.session.mcp.server_errors.items())
6648
- if error and not error.startswith("oauth login required")
6649
- ]
6982
+ errors = [(name, error) for name, error in sorted(self.session.mcp.server_errors.items()) if error and not error.startswith("oauth login required")]
6650
6983
  if not errors:
6651
6984
  return ""
6652
6985
  shown = errors if self.session.settings.debug else errors[:3]
@@ -6924,7 +7257,7 @@ Tools:
6924
7257
  sys.stdout.flush()
6925
7258
  self.transient_tool_lines = 0
6926
7259
 
6927
- def tool_live_start(self) -> None:
7260
+ def tool_live_start(self, command: str = "") -> None:
6928
7261
  if not self.ui.color:
6929
7262
  return
6930
7263
  self.live_queue_paused = self.interactive_input and not self.queue_input_paused.is_set()
@@ -6933,7 +7266,7 @@ Tools:
6933
7266
  self.live_status_paused = self.status_bar.is_running()
6934
7267
  if self.live_status_paused:
6935
7268
  self.status_bar.stop()
6936
- self.live_preview.start()
7269
+ self.live_preview.start(command)
6937
7270
 
6938
7271
  def tool_live_output(self, _stream: str, text: str) -> None:
6939
7272
  if not self.ui.color:
@@ -6979,6 +7312,7 @@ Tools:
6979
7312
  "/reason": self.reason,
6980
7313
  "/set": self.set_value,
6981
7314
  "/yolo": self.yolo,
7315
+ "/strict": self.strict,
6982
7316
  "/mcp": self.mcp_command,
6983
7317
  }
6984
7318
  handler = handlers.get(name)
@@ -7239,7 +7573,11 @@ Tools:
7239
7573
  previews = spec.previews
7240
7574
  preview_map = {c: previews[i] for i, c in enumerate(choices) if previews and i < len(previews) and previews[i]}
7241
7575
  result = self.choice_application(
7242
- "Select:", tuple(choices), labels, current, set(),
7576
+ "Select:",
7577
+ tuple(choices),
7578
+ labels,
7579
+ current,
7580
+ set(),
7243
7581
  preview_fn=lambda choice: preview_map.get(choice, ""),
7244
7582
  free_text=True,
7245
7583
  )
@@ -7270,7 +7608,9 @@ Tools:
7270
7608
  usage = self.session.usage
7271
7609
  provider = self.session.config.provider
7272
7610
  self.agent.context.update_percent(self.agent.context.model_messages(self.agent.SYSTEM_PROMPT))
7273
- index_status, index_message = CodeIndex(self.session).status(check=True)
7611
+ index = CodeIndex(self.session)
7612
+ index_status, index_message = index.status(check=False)
7613
+ index.update_pending_async()
7274
7614
  if self.session.state.code_index_refreshing:
7275
7615
  index_status, index_message = self.session.state.code_index_notice or "syncing", ""
7276
7616
  elif self.session.state.code_index_error:
@@ -7316,7 +7656,15 @@ Tools:
7316
7656
  state = self.session.state
7317
7657
  known = ["- " + item for item in state.known] or ["- (empty)"]
7318
7658
  return "\n".join(
7319
- ["goal: " + (state.goal or "(empty)"), "summary:", state.summary or "(empty)", "plan:", *AgentState.plan_rows_for(state.plan, status=True, style="symbol"), "known:", *known]
7659
+ [
7660
+ "goal: " + (state.goal or "(empty)"),
7661
+ "summary:",
7662
+ state.summary or "(empty)",
7663
+ "plan:",
7664
+ *AgentState.plan_rows_for(state.plan, status=True, style="symbol"),
7665
+ "known:",
7666
+ *known,
7667
+ ]
7320
7668
  )
7321
7669
 
7322
7670
  def config(self, args: str) -> str:
@@ -7336,6 +7684,8 @@ Tools:
7336
7684
  f"provider.resolved_chat_reasoning: {provider.resolved_chat_reasoning()}",
7337
7685
  f"provider.chat_reasoning: {provider.chat_reasoning}",
7338
7686
  f"provider.temperature: {provider.temperature if provider.temperature is not None else '(off)'}",
7687
+ f"provider.max_tokens: {provider.max_tokens or ('(resolved ' + str(provider.resolved_max_tokens() or 'server default') + ')')}",
7688
+ f"provider.strict_tools: {provider.strict_tools} (active {provider.resolved_strict_tools()})",
7339
7689
  f"provider.timeout: {provider.timeout}",
7340
7690
  f"paths.data_dir: {self.session.data_path()}",
7341
7691
  f"runtime.shell_timeout: {self.session.settings.shell_timeout}",
@@ -7518,6 +7868,16 @@ Tools:
7518
7868
  self.session.settings.yolo = not self.session.settings.yolo
7519
7869
  return "yolo: " + ("on" if self.session.settings.yolo else "off")
7520
7870
 
7871
+ def strict(self, args: str) -> str:
7872
+ if args:
7873
+ return "Usage: /strict"
7874
+ provider = self.session.config.provider
7875
+ provider.strict_tools = not provider.strict_tools
7876
+ state = "on" if provider.strict_tools else "off"
7877
+ if provider.strict_tools and not provider.resolved_strict_tools():
7878
+ return f"strict_tools: {state} (inactive: {provider.host() or 'this provider'} does not support strict tool calling)"
7879
+ return f"strict_tools: {state}"
7880
+
7521
7881
  def set_value(self, args: str) -> str:
7522
7882
  key, _, value = args.partition(" ")
7523
7883
  if not key or not value:
@@ -7538,6 +7898,10 @@ Tools:
7538
7898
  provider.available_models = tuple(item.strip() for item in value.split(",") if item.strip())
7539
7899
  elif key == "provider.temperature":
7540
7900
  provider.temperature = None if value == "off" else float(value)
7901
+ elif key == "provider.max_tokens":
7902
+ provider.max_tokens = max(0, int(value))
7903
+ elif key == "provider.strict_tools":
7904
+ provider.strict_tools = Config.bool({key: value}, key)
7541
7905
  elif key == "provider.timeout":
7542
7906
  provider.timeout = max(1, int(value))
7543
7907
  elif key == "runtime.yolo":
@@ -7554,6 +7918,8 @@ Tools:
7554
7918
  runtime.shell_timeout = max(1, int(value))
7555
7919
  elif key == "runtime.max_parallel_tools":
7556
7920
  runtime.max_parallel_tools = max(1, int(value))
7921
+ elif key == "runtime.tips":
7922
+ runtime.tips = Config.bool({key: value}, key)
7557
7923
  else:
7558
7924
  return "Unknown config key: " + key
7559
7925
  except (ConfigError, ValueError):
@@ -7568,8 +7934,7 @@ def main(argv: list[str] | None = None) -> int:
7568
7934
  parser.add_argument("--yolo", action="store_true", help="Skip confirmations for mutating tools")
7569
7935
  parser.add_argument("--debug", action="store_true", help="Enable debug mode")
7570
7936
  parser.add_argument("--mcp", default="", help='Filter MCP servers, e.g. "orion*,!orionEval", "all", or "none"')
7571
- parser.add_argument("--resume", default="", nargs="?", const="latest",
7572
- help='Resume a session by UID, or "latest"/"last" for most recent')
7937
+ parser.add_argument("--resume", default="", nargs="?", const="latest", help='Resume a session by UID, or "latest"/"last" for most recent')
7573
7938
  parser.add_argument("-v", "--version", action="store_true", help="Show version")
7574
7939
  args = parser.parse_args(argv)
7575
7940
  if args.version:
@@ -7589,7 +7954,11 @@ def main(argv: list[str] | None = None) -> int:
7589
7954
  )
7590
7955
  else:
7591
7956
  session = Session.from_config_file(path=args.config, yolo=args.yolo, debug=args.debug, mcp_selector=args.mcp)
7592
- return CommandLoop(Agent(session)).run()
7957
+ try:
7958
+ return CommandLoop(Agent(session)).run()
7959
+ finally:
7960
+ if session.mcp is not None:
7961
+ session.mcp.close()
7593
7962
  except ConfigError as error:
7594
7963
  print("ConfigError: " + str(error), file=sys.stderr)
7595
7964
  return 2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.7.0
3
+ Version: 0.7.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.7.0"
7
+ version = "0.7.2"
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
File without changes