nanocode-cli 0.5.0__tar.gz → 0.5.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.5.0
3
+ Version: 0.5.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
@@ -54,7 +54,7 @@ from prompt_toolkit.widgets import SearchToolbar
54
54
  from rich.console import Console
55
55
  from rich.markdown import Markdown
56
56
 
57
- __version__ = "0.5.0"
57
+ __version__ = "0.5.2"
58
58
 
59
59
  Json = dict[str, Any]
60
60
  HTTP_USER_AGENT = "nanocode/" + __version__
@@ -92,6 +92,22 @@ class ToolError(NanocodeError):
92
92
  pass
93
93
 
94
94
 
95
+ class Text:
96
+ @staticmethod
97
+ def clean(text: str) -> str:
98
+ return text.encode("utf-8", errors="replace").decode("utf-8")
99
+
100
+ @classmethod
101
+ def value(cls, value: Any) -> Any:
102
+ if isinstance(value, str):
103
+ return cls.clean(value)
104
+ if isinstance(value, dict):
105
+ return {cls.clean(str(key)): cls.value(item) for key, item in value.items()}
106
+ if isinstance(value, (list, tuple)):
107
+ return [cls.value(item) for item in value]
108
+ return value
109
+
110
+
95
111
  @dataclass
96
112
  class ProviderConfig:
97
113
  ALIYUN_CHAT_REASONING_RULES: ClassVar[tuple[tuple[str, tuple[str, ...]], ...]] = (
@@ -505,8 +521,9 @@ class Session:
505
521
  def store_tool_result(self, name: str, args: list[Any], intention: str, output: str) -> str:
506
522
  self.tool_counter += 1
507
523
  key = f"tr.{self.tool_counter}"
524
+ args, intention, output = Text.value(list(args)), Text.clean(intention), Text.clean(output)
508
525
  self.tool_results[key] = output
509
- self.tool_records.append(ToolResultRecord(key, name, list(args), intention, output))
526
+ self.tool_records.append(ToolResultRecord(key, name, args, intention, output))
510
527
  if len(self.tool_results) > 400:
511
528
  old = self.tool_records.pop(0)
512
529
  self.tool_results.pop(old.key, None)
@@ -521,7 +538,7 @@ class Session:
521
538
  return count
522
539
 
523
540
  def record_tool_error(self, key: str, name: str, args: list[Any], intention: str, error: str) -> None:
524
- self.tool_errors.append(ToolErrorRecord(key, name, list(args), intention, " ".join(error.split())))
541
+ self.tool_errors.append(ToolErrorRecord(key, name, Text.value(list(args)), Text.clean(intention), " ".join(Text.clean(error).split())))
525
542
  self.tool_errors = self.tool_errors[-5:]
526
543
 
527
544
 
@@ -849,7 +866,9 @@ class SearchTool(Tool):
849
866
  return requests
850
867
 
851
868
  def search(self, request: Json) -> str:
852
- rows = None if "\n" in str(request["pattern"]) else self.rg_matches(request)
869
+ patterns = self.gitignore_patterns(str(request["path"]))
870
+ rows = [] if self.default_ignored(str(request["path"]), patterns) else None
871
+ rows = rows if rows is not None or "\n" in str(request["pattern"]) else self.rg_matches(request)
853
872
  rows = rows if rows is not None else self.python_matches(request)
854
873
  header = f"<SearchToolResult pattern={json.dumps(request['pattern'])} matches={len(rows)}>"
855
874
  return "\n".join([header, *rows, "</SearchToolResult>"])
@@ -899,10 +918,12 @@ class SearchTool(Tool):
899
918
  return rows
900
919
 
901
920
  def files(self, root: str, glob_pattern: str) -> list[str]:
921
+ gitignore = self.gitignore_patterns(root)
922
+ if self.default_ignored(root, gitignore):
923
+ return []
902
924
  if os.path.isfile(root):
903
925
  return [root]
904
926
  found = []
905
- gitignore = self.gitignore_patterns(root)
906
927
  skip_dirs = {".git", ".hg", ".svn", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", "node_modules"}
907
928
  for dirpath, dirnames, filenames in os.walk(root):
908
929
  dirnames[:] = [
@@ -980,14 +1001,25 @@ class SearchTool(Tool):
980
1001
  def ignored(self, path: str, patterns: list[str]) -> bool:
981
1002
  rel = self.session.relpath(path).replace(os.sep, "/")
982
1003
  name = os.path.basename(path)
983
- for pattern in patterns:
984
- pattern = pattern.rstrip("/")
1004
+ parts = [part for part in rel.split("/") if part and part != "."]
1005
+ for raw in patterns:
1006
+ directory = raw.endswith("/")
1007
+ pattern = raw.rstrip("/")
985
1008
  if not pattern:
986
1009
  continue
987
- if ("/" in pattern and fnmatch.fnmatch(rel, pattern)) or fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel, pattern):
1010
+ if "/" in pattern:
1011
+ matched = fnmatch.fnmatch(rel, pattern) or (directory and (rel == pattern or rel.startswith(pattern + "/")))
1012
+ else:
1013
+ matched = any(fnmatch.fnmatch(part, pattern) for part in parts) or fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel, pattern)
1014
+ if matched:
988
1015
  return True
989
1016
  return False
990
1017
 
1018
+ def default_ignored(self, path: str, patterns: list[str]) -> bool:
1019
+ rel = self.session.relpath(path).replace(os.sep, "/")
1020
+ hidden = rel not in {"", "."} and any(part.startswith(".") for part in rel.split("/") if part and part != ".")
1021
+ return hidden or self.ignored(path, patterns)
1022
+
991
1023
 
992
1024
  class CodeIndex:
993
1025
  AUTO_UPDATE_LIMIT: ClassVar[int] = 20
@@ -1109,15 +1141,22 @@ class CodeIndex:
1109
1141
  if status not in {"ready", "stale"}:
1110
1142
  return False
1111
1143
  self.notice("syncing", refreshing=True)
1144
+ try:
1145
+ worker = csi.refresh_async(self.session.cwd)
1146
+ except Exception as error:
1147
+ self.fail(error)
1148
+ return False
1112
1149
 
1113
- def refresh() -> None:
1150
+ def finish() -> None:
1151
+ worker.join()
1114
1152
  try:
1115
- csi.index(self.session.cwd)
1116
- self.finish()
1153
+ self.session.state.code_index_refreshing = False
1154
+ self.session.state.code_index_notice = ""
1155
+ self.status(check=True)
1117
1156
  except Exception as error:
1118
1157
  self.fail(error)
1119
1158
 
1120
- threading.Thread(target=refresh, daemon=True).start()
1159
+ threading.Thread(target=finish, daemon=True).start()
1121
1160
  return True
1122
1161
 
1123
1162
  def update_paths(self, paths: list[str]) -> list[str]:
@@ -1795,7 +1834,7 @@ class ContextManager:
1795
1834
  return key
1796
1835
 
1797
1836
  def model_messages(self, base_system: str, user_input: str = "", extra_messages: list[Json] | None = None) -> list[Json]:
1798
- return [{"role": "system", "content": base_system.strip()}, {"role": "user", "content": self.render(user_input, extra_messages)}]
1837
+ return Text.value([{"role": "system", "content": base_system.strip()}, {"role": "user", "content": self.render(user_input, extra_messages)}])
1799
1838
 
1800
1839
  def maybe_compact(self, model: "ModelClient", base_system: str, user_input: str = "", extra_messages: list[Json] | None = None) -> None:
1801
1840
  if self.estimated_tokens(self.model_messages(base_system, user_input, extra_messages)) < self.session.settings.max_context_tokens:
@@ -1818,10 +1857,14 @@ class ContextManager:
1818
1857
  ("Discovery Context", self.discovery_context()),
1819
1858
  ("Error Feedback", self.error_feedback()),
1820
1859
  ("Latest Tool Results", self.latest_results()),
1860
+ ("Current Date", self.current_date()),
1821
1861
  ("Current User Request", user_input.strip() or "(empty)"),
1822
1862
  ]
1823
1863
  return "\n\n".join(f"--- {name} ---\n{body or '(empty)'}" for name, body in sections)
1824
1864
 
1865
+ def current_date(self) -> str:
1866
+ return "- date: " + datetime.now().astimezone().strftime("%Y-%m-%d")
1867
+
1825
1868
  def environment(self) -> str:
1826
1869
  info = self.session.system_info
1827
1870
  return "\n".join([
@@ -2205,10 +2248,11 @@ class DebugTrace:
2205
2248
  if isinstance(value, (list, tuple)):
2206
2249
  return [cls.value(item) for item in value]
2207
2250
  if isinstance(value, str):
2251
+ value = Text.clean(value)
2208
2252
  return value if len(value) <= cls.STRING_LIMIT else value[: cls.STRING_LIMIT] + "...<truncated>"
2209
2253
  if value is None or isinstance(value, (int, float, bool)):
2210
2254
  return value
2211
- return str(value)
2255
+ return Text.clean(str(value))
2212
2256
 
2213
2257
  @classmethod
2214
2258
  def prompt(cls, session: Session, *, activity: str, messages: list[Json]) -> None:
@@ -2268,6 +2312,7 @@ class ModelClient:
2268
2312
  self.session.state.current_model_call_started_at = 0.0
2269
2313
 
2270
2314
  def chat_request(self, messages: list[Json], tools: list[Json] | None = None, *, activity: str = "agent") -> tuple[Json, list[ToolCall], str]:
2315
+ messages = Text.value(messages)
2271
2316
  provider = self.session.config.provider
2272
2317
  params: Json = {"model": provider.model, "messages": messages, "stream": False}
2273
2318
  if tools:
@@ -2301,7 +2346,7 @@ Compact the nanocode working context.
2301
2346
  Return exactly one JSON object with keys: summary, goal, plan, known.
2302
2347
  Keep only durable facts needed to continue; preserve file paths, symbols, constraints, and tr.N keys.
2303
2348
  """.strip()
2304
- messages = [{"role": "system", "content": prompt}, {"role": "user", "content": context}]
2349
+ messages = [{"role": "system", "content": prompt}, {"role": "user", "content": Text.clean(context)}]
2305
2350
  _, _, content = (
2306
2351
  self.anthropic_request(messages, None, activity="compact")
2307
2352
  if self.session.config.provider.resolved_api() == "anthropic"
@@ -2373,6 +2418,7 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
2373
2418
  return ",".join(sorted(names)) or "(none)"
2374
2419
 
2375
2420
  def anthropic_request(self, messages: list[Json], tools: list[Json] | None, *, activity: str = "agent") -> tuple[Json, list[ToolCall], str]:
2421
+ messages = Text.value(messages)
2376
2422
  provider = self.session.config.provider
2377
2423
  params = self.anthropic_params(messages, tools)
2378
2424
  DebugTrace.prompt(self.session, activity=activity, messages=messages)
@@ -2925,6 +2971,7 @@ class ModelRetryShortcut:
2925
2971
 
2926
2972
  class StatusBar:
2927
2973
  INTERVAL: ClassVar[float] = 0.2
2974
+ INDEX_SPINNER: ClassVar[tuple[str, ...]] = ("~", "/", "-", "\\", "|")
2928
2975
 
2929
2976
  def __init__(self, session: Session):
2930
2977
  self.session = session
@@ -3056,9 +3103,12 @@ class StatusBar:
3056
3103
  return CodeIndex.label("error")
3057
3104
  if self.session.state.code_index_refreshing:
3058
3105
  notice = self.session.state.code_index_notice or "syncing"
3059
- return CodeIndex.label("syncing") if notice in {"syncing", "updating"} else notice
3106
+ return self.index_spinner() if notice in {"syncing", "updating"} else notice
3060
3107
  return CodeIndex.label(self.session.state.code_index_status)
3061
3108
 
3109
+ def index_spinner(self) -> str:
3110
+ return self.INDEX_SPINNER[int(time.monotonic() / self.INTERVAL) % len(self.INDEX_SPINNER)]
3111
+
3062
3112
  def stress_after(self) -> float:
3063
3113
  return max(30.0, self.session.config.provider.timeout * 0.5)
3064
3114
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.5.0
3
+ Version: 0.5.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.5.0"
7
+ version = "0.5.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