nanocode-cli 0.7.1__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.
- {nanocode_cli-0.7.1/nanocode_cli.egg-info → nanocode_cli-0.7.2}/PKG-INFO +1 -1
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/nanocode.py +376 -71
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2/nanocode_cli.egg-info}/PKG-INFO +1 -1
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/pyproject.toml +1 -1
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/LICENSE +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/MANIFEST.in +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/README.md +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/README.zh-CN.md +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.7.1 → nanocode_cli-0.7.2}/setup.cfg +0 -0
|
@@ -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.
|
|
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": "
|
|
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
|
|
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.
|
|
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"
|
|
@@ -1245,6 +1285,59 @@ class UpdateChecker:
|
|
|
1245
1285
|
)
|
|
1246
1286
|
|
|
1247
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
|
+
|
|
1248
1341
|
class Tool:
|
|
1249
1342
|
NAME: ClassVar[str] = ""
|
|
1250
1343
|
DESCRIPTION: ClassVar[str] = ""
|
|
@@ -1260,9 +1353,13 @@ class Tool:
|
|
|
1260
1353
|
self.args = args
|
|
1261
1354
|
|
|
1262
1355
|
@classmethod
|
|
1263
|
-
def schema(cls) -> Json:
|
|
1356
|
+
def schema(cls, strict: bool = False) -> Json:
|
|
1264
1357
|
description = "\n".join([cls.DESCRIPTION, "Signature: " + cls.SIGNATURE, *(("- " + item) for item in cls.EXAMPLE if item)])
|
|
1265
|
-
|
|
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}
|
|
1266
1363
|
|
|
1267
1364
|
@classmethod
|
|
1268
1365
|
def params_schema(cls) -> Json:
|
|
@@ -1378,7 +1475,10 @@ class ReadTool(Tool):
|
|
|
1378
1475
|
def arg_schema(cls) -> Json:
|
|
1379
1476
|
return {
|
|
1380
1477
|
"type": "object",
|
|
1381
|
-
"properties": {
|
|
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
|
+
},
|
|
1382
1482
|
"required": ["path"],
|
|
1383
1483
|
"additionalProperties": False,
|
|
1384
1484
|
}
|
|
@@ -1388,9 +1488,9 @@ class ReadTool(Tool):
|
|
|
1388
1488
|
return {
|
|
1389
1489
|
"type": "object",
|
|
1390
1490
|
"properties": {
|
|
1391
|
-
"path": {"type": "string"},
|
|
1392
|
-
"ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1},
|
|
1393
|
-
"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"},
|
|
1394
1494
|
},
|
|
1395
1495
|
"additionalProperties": False,
|
|
1396
1496
|
}
|
|
@@ -1488,7 +1588,7 @@ class LineCountTool(Tool):
|
|
|
1488
1588
|
def params_schema(cls) -> Json:
|
|
1489
1589
|
return {
|
|
1490
1590
|
"type": "object",
|
|
1491
|
-
"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"}},
|
|
1492
1592
|
"required": ["paths"],
|
|
1493
1593
|
"additionalProperties": False,
|
|
1494
1594
|
}
|
|
@@ -1533,7 +1633,15 @@ class ListTool(Tool):
|
|
|
1533
1633
|
|
|
1534
1634
|
@classmethod
|
|
1535
1635
|
def params_schema(cls) -> Json:
|
|
1536
|
-
return {
|
|
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
|
+
}
|
|
1537
1645
|
|
|
1538
1646
|
@classmethod
|
|
1539
1647
|
def payload_args(cls, payload: Json) -> list[Any]:
|
|
@@ -1598,10 +1706,10 @@ class FindTool(Tool):
|
|
|
1598
1706
|
return {
|
|
1599
1707
|
"type": "object",
|
|
1600
1708
|
"properties": {
|
|
1601
|
-
"name": {"type": "string"},
|
|
1602
|
-
"path": {"type": "string"},
|
|
1603
|
-
"type": {"type": "string", "enum": ["file", "dir", "any"]},
|
|
1604
|
-
"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"},
|
|
1605
1713
|
},
|
|
1606
1714
|
"required": ["name"],
|
|
1607
1715
|
"additionalProperties": False,
|
|
@@ -1610,7 +1718,7 @@ class FindTool(Tool):
|
|
|
1610
1718
|
@classmethod
|
|
1611
1719
|
def params_schema(cls) -> Json:
|
|
1612
1720
|
props = dict(cls.arg_schema()["properties"])
|
|
1613
|
-
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"}
|
|
1614
1722
|
return {"type": "object", "properties": props, "additionalProperties": False}
|
|
1615
1723
|
|
|
1616
1724
|
@classmethod
|
|
@@ -1716,10 +1824,10 @@ class SearchTool(Tool):
|
|
|
1716
1824
|
return {
|
|
1717
1825
|
"type": "object",
|
|
1718
1826
|
"properties": {
|
|
1719
|
-
"pattern": {"type": "string"},
|
|
1720
|
-
"path": {"type": "string"},
|
|
1721
|
-
"glob": {"type": "string"},
|
|
1722
|
-
"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}"},
|
|
1723
1831
|
},
|
|
1724
1832
|
"required": ["pattern"],
|
|
1725
1833
|
"additionalProperties": False,
|
|
@@ -1728,7 +1836,7 @@ class SearchTool(Tool):
|
|
|
1728
1836
|
@classmethod
|
|
1729
1837
|
def params_schema(cls) -> Json:
|
|
1730
1838
|
props = dict(cls.arg_schema()["properties"])
|
|
1731
|
-
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"}
|
|
1732
1840
|
return {"type": "object", "properties": props, "additionalProperties": False}
|
|
1733
1841
|
|
|
1734
1842
|
@classmethod
|
|
@@ -2073,18 +2181,18 @@ class InspectCodeTool(Tool):
|
|
|
2073
2181
|
@classmethod
|
|
2074
2182
|
def params_schema(cls) -> Json:
|
|
2075
2183
|
props = {
|
|
2076
|
-
"mode": {"type": "string", "enum": list(cls.MODES)},
|
|
2077
|
-
"target": {"type": "string"},
|
|
2078
|
-
"limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_OUTLINE_LIMIT},
|
|
2079
|
-
"kind": {"type": "string"},
|
|
2080
|
-
"path": {"type": "string"},
|
|
2081
|
-
"symbol": {"type": "string"},
|
|
2082
|
-
"exact_only": {"type": "boolean"},
|
|
2083
|
-
"depth": {"type": "integer", "minimum": 1, "maximum": cls.MAX_DEPTH},
|
|
2084
|
-
"offset": {"type": "integer", "minimum": 0},
|
|
2085
|
-
"all_kinds": {"type": "boolean"},
|
|
2086
|
-
"ref_kind": {"type": "string"},
|
|
2087
|
-
"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)"},
|
|
2088
2196
|
}
|
|
2089
2197
|
return {"type": "object", "properties": props, "required": ["mode", "target"], "additionalProperties": False}
|
|
2090
2198
|
|
|
@@ -2208,13 +2316,23 @@ class EditTool(Tool):
|
|
|
2208
2316
|
def params_schema(cls) -> Json:
|
|
2209
2317
|
edit = {
|
|
2210
2318
|
"type": "object",
|
|
2211
|
-
"properties": {
|
|
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
|
+
},
|
|
2212
2327
|
"required": ["op"],
|
|
2213
2328
|
"additionalProperties": False,
|
|
2214
2329
|
}
|
|
2215
2330
|
return {
|
|
2216
2331
|
"type": "object",
|
|
2217
|
-
"properties": {
|
|
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
|
+
},
|
|
2218
2336
|
"required": ["path", "edits"],
|
|
2219
2337
|
"additionalProperties": False,
|
|
2220
2338
|
}
|
|
@@ -2463,7 +2581,7 @@ class BashTool(Tool):
|
|
|
2463
2581
|
def params_schema(cls) -> Json:
|
|
2464
2582
|
return {
|
|
2465
2583
|
"type": "object",
|
|
2466
|
-
"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"}},
|
|
2467
2585
|
"required": ["command"],
|
|
2468
2586
|
"additionalProperties": False,
|
|
2469
2587
|
}
|
|
@@ -2600,7 +2718,10 @@ class GitTool(Tool):
|
|
|
2600
2718
|
def params_schema(cls) -> Json:
|
|
2601
2719
|
return {
|
|
2602
2720
|
"type": "object",
|
|
2603
|
-
"properties": {
|
|
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
|
+
},
|
|
2604
2725
|
"required": ["argv"],
|
|
2605
2726
|
"additionalProperties": False,
|
|
2606
2727
|
}
|
|
@@ -2711,8 +2832,8 @@ class RecallTool(Tool):
|
|
|
2711
2832
|
return {
|
|
2712
2833
|
"type": "object",
|
|
2713
2834
|
"properties": {
|
|
2714
|
-
"keys": {"type": "array", "items": {"type": "string", "pattern": "^tr\\.\\d+$"}, "minItems": 1},
|
|
2715
|
-
"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"},
|
|
2716
2837
|
},
|
|
2717
2838
|
"required": ["keys"],
|
|
2718
2839
|
"additionalProperties": False,
|
|
@@ -2792,21 +2913,23 @@ class NoteTool(Tool):
|
|
|
2792
2913
|
|
|
2793
2914
|
@classmethod
|
|
2794
2915
|
def params_schema(cls) -> Json:
|
|
2795
|
-
strings = {"type": "array", "items": {"type": "string"}}
|
|
2796
2916
|
plan_item = {
|
|
2797
2917
|
"type": "object",
|
|
2798
|
-
"properties": {
|
|
2918
|
+
"properties": {
|
|
2919
|
+
"status": {"type": "string", "enum": list(PlanItem.STATUSES), "description": "todo|doing|done|blocked"},
|
|
2920
|
+
"text": {"type": "string", "description": "Plan step description"},
|
|
2921
|
+
},
|
|
2799
2922
|
"required": ["status", "text"],
|
|
2800
2923
|
"additionalProperties": False,
|
|
2801
2924
|
}
|
|
2802
2925
|
return {
|
|
2803
2926
|
"type": "object",
|
|
2804
2927
|
"properties": {
|
|
2805
|
-
"set_goal": {"type": "string"},
|
|
2806
|
-
"replace_plan": {"type": "array", "items": plan_item},
|
|
2807
|
-
"append_known":
|
|
2808
|
-
"replace_known":
|
|
2809
|
-
"set_check": {"type": "string"},
|
|
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"},
|
|
2810
2933
|
},
|
|
2811
2934
|
"additionalProperties": False,
|
|
2812
2935
|
}
|
|
@@ -4114,6 +4237,33 @@ class MCPManager:
|
|
|
4114
4237
|
def run_async(self, coroutine: Any) -> Any:
|
|
4115
4238
|
return asyncio.run_coroutine_threadsafe(coroutine, self._async_loop()).result()
|
|
4116
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
|
+
|
|
4117
4267
|
def oauth_client(self, config: MCPServerConfig, *, interactive: bool = False, notify: Callable[[str], None] | None = None) -> Any:
|
|
4118
4268
|
from fastmcp.client.auth import OAuth
|
|
4119
4269
|
|
|
@@ -4816,7 +4966,7 @@ class ToolRunner:
|
|
|
4816
4966
|
self.preview_fn: Callable[[str], bool] | None = None
|
|
4817
4967
|
self.preview_full_fn: Callable[[str], None] | None = None
|
|
4818
4968
|
self.live_output: Callable[[str, str], None] | None = None
|
|
4819
|
-
self.live_start: Callable[[], None] | None = None
|
|
4969
|
+
self.live_start: Callable[[str], None] | None = None
|
|
4820
4970
|
self.question_fn: Callable[[QuestionSpec, str], str] | None = None
|
|
4821
4971
|
|
|
4822
4972
|
def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
|
|
@@ -4985,7 +5135,7 @@ class ToolRunner:
|
|
|
4985
5135
|
return "refused", self.finish(call, output, failed=True, elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
|
|
4986
5136
|
approved = True
|
|
4987
5137
|
if isinstance(tool, BashTool) and self.live_start is not None:
|
|
4988
|
-
self.live_start()
|
|
5138
|
+
self.live_start(str(call.args[0]) if call.args else "")
|
|
4989
5139
|
output = planned_edit.call(tool) if planned_edit and isinstance(tool, EditTool) else tool.call()
|
|
4990
5140
|
except ToolError as error:
|
|
4991
5141
|
return "failed", self.reject(call, f"ToolError: {error}", elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
|
|
@@ -5260,7 +5410,7 @@ class ModelClient:
|
|
|
5260
5410
|
provider = self.session.config.provider
|
|
5261
5411
|
if missing := self.session.missing_config():
|
|
5262
5412
|
raise ModelError("missing config: " + ", ".join(missing))
|
|
5263
|
-
tools = [tool.schema() for tool in TOOL_REGISTRY.values()]
|
|
5413
|
+
tools = [tool.schema(provider.resolved_strict_tools()) for tool in TOOL_REGISTRY.values()]
|
|
5264
5414
|
for attempt in range(MODEL_REQUEST_RETRIES + 1):
|
|
5265
5415
|
self.session.state.current_model_call_started_at = time.monotonic()
|
|
5266
5416
|
try:
|
|
@@ -5301,6 +5451,8 @@ class ModelClient:
|
|
|
5301
5451
|
messages = Text.value(messages)
|
|
5302
5452
|
provider = self.session.config.provider
|
|
5303
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
|
|
5304
5456
|
if tools:
|
|
5305
5457
|
params["tools"] = tools
|
|
5306
5458
|
params["tool_choice"] = "auto"
|
|
@@ -5391,6 +5543,8 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5391
5543
|
return ""
|
|
5392
5544
|
if configured != "auto":
|
|
5393
5545
|
return configured
|
|
5546
|
+
if not provider.supports_prompt_cache_key():
|
|
5547
|
+
return ""
|
|
5394
5548
|
payload = {
|
|
5395
5549
|
"api": provider.resolved_api(),
|
|
5396
5550
|
"cwd": self.session.cwd,
|
|
@@ -5526,11 +5680,12 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5526
5680
|
return assistant, calls, text
|
|
5527
5681
|
|
|
5528
5682
|
def apply_provider_params(self, params: Json, provider: ProviderConfig) -> None:
|
|
5529
|
-
if provider.temperature is not None:
|
|
5530
|
-
params["temperature"] = provider.temperature
|
|
5531
5683
|
chat_reasoning = provider.resolved_chat_reasoning()
|
|
5532
5684
|
reasoning_enabled = provider.reasoning != "off"
|
|
5533
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
|
|
5534
5689
|
extra: Json = {}
|
|
5535
5690
|
if reasoning_enabled and chat_reasoning == "reasoning":
|
|
5536
5691
|
extra["reasoning"] = {"effort": effort}
|
|
@@ -5590,12 +5745,22 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5590
5745
|
calls.append(self.tool_call(raw.id, raw.function.name, payload))
|
|
5591
5746
|
return calls
|
|
5592
5747
|
|
|
5593
|
-
@
|
|
5594
|
-
def tool_payload(name: str, payload: Any) -> list[Any]:
|
|
5748
|
+
@classmethod
|
|
5749
|
+
def tool_payload(cls, name: str, payload: Any) -> list[Any]:
|
|
5595
5750
|
if isinstance(payload, dict) and (tool := TOOL_REGISTRY.get(name)):
|
|
5596
|
-
|
|
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))
|
|
5597
5754
|
return [payload]
|
|
5598
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
|
+
|
|
5599
5764
|
@classmethod
|
|
5600
5765
|
def tool_call(cls, call_id: str, name: str, payload: Any) -> ToolCall:
|
|
5601
5766
|
# payload_args may reject malformed arguments (e.g. Git with an empty argv). Capture that
|
|
@@ -5639,7 +5804,8 @@ FILE STATE:
|
|
|
5639
5804
|
- Read and Edit refresh FILE STATE; after Edit, trust the edited range.
|
|
5640
5805
|
|
|
5641
5806
|
FINAL:
|
|
5642
|
-
-
|
|
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.
|
|
5643
5809
|
- Concise markdown in the user's language; note changed files and checks run (or not run).\
|
|
5644
5810
|
"""
|
|
5645
5811
|
|
|
@@ -5736,6 +5902,7 @@ class CommandCompleter(Completer):
|
|
|
5736
5902
|
"/reason",
|
|
5737
5903
|
"/set",
|
|
5738
5904
|
"/yolo",
|
|
5905
|
+
"/strict",
|
|
5739
5906
|
"/exit",
|
|
5740
5907
|
"/quit",
|
|
5741
5908
|
)
|
|
@@ -5749,6 +5916,8 @@ class CommandCompleter(Completer):
|
|
|
5749
5916
|
"provider.chat_reasoning",
|
|
5750
5917
|
"provider.available_models",
|
|
5751
5918
|
"provider.temperature",
|
|
5919
|
+
"provider.max_tokens",
|
|
5920
|
+
"provider.strict_tools",
|
|
5752
5921
|
"provider.timeout",
|
|
5753
5922
|
"runtime.yolo",
|
|
5754
5923
|
"runtime.max_agent_steps",
|
|
@@ -5756,6 +5925,7 @@ class CommandCompleter(Completer):
|
|
|
5756
5925
|
"runtime.max_parallel_tools",
|
|
5757
5926
|
"runtime.shell_timeout",
|
|
5758
5927
|
"runtime.check_updates",
|
|
5928
|
+
"runtime.tips",
|
|
5759
5929
|
)
|
|
5760
5930
|
SET_VALUES = {
|
|
5761
5931
|
"provider.api": PROVIDER_API_CHOICES,
|
|
@@ -5763,8 +5933,10 @@ class CommandCompleter(Completer):
|
|
|
5763
5933
|
"provider.reasoning": REASONING_CHOICES,
|
|
5764
5934
|
"provider.chat_reasoning": CHAT_REASONING_CHOICES,
|
|
5765
5935
|
"provider.temperature": ("off",),
|
|
5936
|
+
"provider.strict_tools": ("on", "off", "true", "false"),
|
|
5766
5937
|
"runtime.yolo": ("on", "off", "true", "false"),
|
|
5767
5938
|
"runtime.check_updates": ("on", "off", "true", "false"),
|
|
5939
|
+
"runtime.tips": ("on", "off", "true", "false"),
|
|
5768
5940
|
}
|
|
5769
5941
|
|
|
5770
5942
|
def __init__(
|
|
@@ -5874,10 +6046,21 @@ class UiPrinter:
|
|
|
5874
6046
|
return [("ansibrightblack", text + "\n")]
|
|
5875
6047
|
if text.startswith("nanocode "):
|
|
5876
6048
|
return [("ansicyan", text + "\n")]
|
|
6049
|
+
if text.startswith("tip: "):
|
|
6050
|
+
return self.tip_segments(text[len("tip: "):])
|
|
5877
6051
|
if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
|
|
5878
6052
|
return [("ansired", text + "\n")]
|
|
5879
6053
|
return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
|
|
5880
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
|
+
|
|
5881
6064
|
def tool_segments(self, text: str) -> list[tuple[str, str]]:
|
|
5882
6065
|
segments = []
|
|
5883
6066
|
for line in text.splitlines() or [""]:
|
|
@@ -6005,28 +6188,69 @@ class UiPrinter:
|
|
|
6005
6188
|
class BashLivePreview:
|
|
6006
6189
|
HEIGHT: ClassVar[int] = 6
|
|
6007
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
|
|
6008
6195
|
|
|
6009
6196
|
def __init__(self):
|
|
6010
6197
|
self.output = create_output(sys.stderr)
|
|
6011
6198
|
self.active = False
|
|
6012
6199
|
self.rendered_lines = 0
|
|
6013
6200
|
self.text = ""
|
|
6201
|
+
self.command = ""
|
|
6202
|
+
self.started_at = 0.0
|
|
6203
|
+
self.lock = threading.Lock()
|
|
6204
|
+
self.timer: threading.Thread | None = None
|
|
6014
6205
|
|
|
6015
|
-
def start(self) -> None:
|
|
6206
|
+
def start(self, command: str = "") -> None:
|
|
6016
6207
|
if not sys.stderr.isatty():
|
|
6017
6208
|
return
|
|
6018
|
-
|
|
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()
|
|
6019
6224
|
|
|
6020
6225
|
def update(self, text: str) -> None:
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6226
|
+
with self.lock:
|
|
6227
|
+
if not self.active:
|
|
6228
|
+
return
|
|
6229
|
+
self.text = (self.text + text)[-self.MAX_CHARS :]
|
|
6230
|
+
self.render()
|
|
6025
6231
|
|
|
6026
6232
|
def finish(self) -> None:
|
|
6027
|
-
|
|
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:
|
|
6028
6246
|
return
|
|
6029
|
-
self.
|
|
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()
|
|
6030
6254
|
|
|
6031
6255
|
def render(self) -> None:
|
|
6032
6256
|
if not self.active:
|
|
@@ -6049,11 +6273,26 @@ class BashLivePreview:
|
|
|
6049
6273
|
self.output.flush()
|
|
6050
6274
|
self.rendered_lines = len(lines)
|
|
6051
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
|
+
|
|
6052
6283
|
def frame_lines(self) -> list[str]:
|
|
6053
6284
|
width = max(20, shutil.get_terminal_size((120, 20)).columns)
|
|
6054
6285
|
body = [line.expandtabs(4) for line in self.text.replace("\r", "\n").splitlines()[-self.HEIGHT :]]
|
|
6055
|
-
|
|
6056
|
-
|
|
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)]
|
|
6057
6296
|
|
|
6058
6297
|
|
|
6059
6298
|
class ModelRetryShortcut:
|
|
@@ -6320,6 +6559,46 @@ class CommandLoop:
|
|
|
6320
6559
|
"logout": (1, 1, "Usage: /mcp logout <server>\nExample: /mcp logout myOAuthServer"),
|
|
6321
6560
|
"refresh": (0, 1, "Usage: /mcp refresh [server]"),
|
|
6322
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 ""
|
|
6323
6602
|
MCP_HELP = "Try /mcp, /mcp tools [server], /mcp login <server>, /mcp logout <server>, /mcp refresh [server]"
|
|
6324
6603
|
|
|
6325
6604
|
HELP = """Commands:
|
|
@@ -6336,6 +6615,7 @@ class CommandLoop:
|
|
|
6336
6615
|
/reason Select reasoning effort.
|
|
6337
6616
|
/set KEY VALUE Set provider.* and runtime.*.
|
|
6338
6617
|
/yolo Toggle tool confirmations.
|
|
6618
|
+
/strict Toggle strict tool-call schemas (OpenAI / DeepSeek).
|
|
6339
6619
|
/mcp Show MCP server status.
|
|
6340
6620
|
/mcp tools [NAME] List MCP tools.
|
|
6341
6621
|
/mcp login NAME Start OAuth login for a server.
|
|
@@ -6550,6 +6830,8 @@ Tools:
|
|
|
6550
6830
|
|
|
6551
6831
|
def run(self) -> int:
|
|
6552
6832
|
self.emit(f"nanocode {__version__}. /help for commands.")
|
|
6833
|
+
if tip := self.startup_tip():
|
|
6834
|
+
self.emit("tip: " + tip)
|
|
6553
6835
|
self.session.clean_expired_snapshots()
|
|
6554
6836
|
self.render_resumed_session()
|
|
6555
6837
|
CodeIndex(self.session).refresh_existing_async()
|
|
@@ -6975,7 +7257,7 @@ Tools:
|
|
|
6975
7257
|
sys.stdout.flush()
|
|
6976
7258
|
self.transient_tool_lines = 0
|
|
6977
7259
|
|
|
6978
|
-
def tool_live_start(self) -> None:
|
|
7260
|
+
def tool_live_start(self, command: str = "") -> None:
|
|
6979
7261
|
if not self.ui.color:
|
|
6980
7262
|
return
|
|
6981
7263
|
self.live_queue_paused = self.interactive_input and not self.queue_input_paused.is_set()
|
|
@@ -6984,7 +7266,7 @@ Tools:
|
|
|
6984
7266
|
self.live_status_paused = self.status_bar.is_running()
|
|
6985
7267
|
if self.live_status_paused:
|
|
6986
7268
|
self.status_bar.stop()
|
|
6987
|
-
self.live_preview.start()
|
|
7269
|
+
self.live_preview.start(command)
|
|
6988
7270
|
|
|
6989
7271
|
def tool_live_output(self, _stream: str, text: str) -> None:
|
|
6990
7272
|
if not self.ui.color:
|
|
@@ -7030,6 +7312,7 @@ Tools:
|
|
|
7030
7312
|
"/reason": self.reason,
|
|
7031
7313
|
"/set": self.set_value,
|
|
7032
7314
|
"/yolo": self.yolo,
|
|
7315
|
+
"/strict": self.strict,
|
|
7033
7316
|
"/mcp": self.mcp_command,
|
|
7034
7317
|
}
|
|
7035
7318
|
handler = handlers.get(name)
|
|
@@ -7401,6 +7684,8 @@ Tools:
|
|
|
7401
7684
|
f"provider.resolved_chat_reasoning: {provider.resolved_chat_reasoning()}",
|
|
7402
7685
|
f"provider.chat_reasoning: {provider.chat_reasoning}",
|
|
7403
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()})",
|
|
7404
7689
|
f"provider.timeout: {provider.timeout}",
|
|
7405
7690
|
f"paths.data_dir: {self.session.data_path()}",
|
|
7406
7691
|
f"runtime.shell_timeout: {self.session.settings.shell_timeout}",
|
|
@@ -7583,6 +7868,16 @@ Tools:
|
|
|
7583
7868
|
self.session.settings.yolo = not self.session.settings.yolo
|
|
7584
7869
|
return "yolo: " + ("on" if self.session.settings.yolo else "off")
|
|
7585
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
|
+
|
|
7586
7881
|
def set_value(self, args: str) -> str:
|
|
7587
7882
|
key, _, value = args.partition(" ")
|
|
7588
7883
|
if not key or not value:
|
|
@@ -7603,6 +7898,10 @@ Tools:
|
|
|
7603
7898
|
provider.available_models = tuple(item.strip() for item in value.split(",") if item.strip())
|
|
7604
7899
|
elif key == "provider.temperature":
|
|
7605
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)
|
|
7606
7905
|
elif key == "provider.timeout":
|
|
7607
7906
|
provider.timeout = max(1, int(value))
|
|
7608
7907
|
elif key == "runtime.yolo":
|
|
@@ -7619,6 +7918,8 @@ Tools:
|
|
|
7619
7918
|
runtime.shell_timeout = max(1, int(value))
|
|
7620
7919
|
elif key == "runtime.max_parallel_tools":
|
|
7621
7920
|
runtime.max_parallel_tools = max(1, int(value))
|
|
7921
|
+
elif key == "runtime.tips":
|
|
7922
|
+
runtime.tips = Config.bool({key: value}, key)
|
|
7622
7923
|
else:
|
|
7623
7924
|
return "Unknown config key: " + key
|
|
7624
7925
|
except (ConfigError, ValueError):
|
|
@@ -7653,7 +7954,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
7653
7954
|
)
|
|
7654
7955
|
else:
|
|
7655
7956
|
session = Session.from_config_file(path=args.config, yolo=args.yolo, debug=args.debug, mcp_selector=args.mcp)
|
|
7656
|
-
|
|
7957
|
+
try:
|
|
7958
|
+
return CommandLoop(Agent(session)).run()
|
|
7959
|
+
finally:
|
|
7960
|
+
if session.mcp is not None:
|
|
7961
|
+
session.mcp.close()
|
|
7657
7962
|
except ConfigError as error:
|
|
7658
7963
|
print("ConfigError: " + str(error), file=sys.stderr)
|
|
7659
7964
|
return 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|