nanocode-cli 0.5.7__tar.gz → 0.5.9__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,3 +1,4 @@
1
1
  include LICENSE
2
+ include README.zh-CN.md
2
3
  prune demo*
3
4
  prune tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
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
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: anthropic>=0.64.0
25
25
  Requires-Dist: code-symbol-index>=0.1.13
26
+ Requires-Dist: json-repair
26
27
  Requires-Dist: openai>=2.37.0
27
28
  Requires-Dist: prompt-toolkit>=3.0
28
29
  Requires-Dist: rich>=13.0
@@ -36,19 +37,22 @@ Dynamic: license-file
36
37
 
37
38
  A small terminal coding agent written in Python.
38
39
 
40
+ [简体中文](README.zh-CN.md)
41
+
39
42
  nanocode is pre-1.0 software. Commands, configuration, and tool behavior may change before a stable release.
40
43
 
41
44
  ![nanocode screenshot](snapshots/nanocode-snapshot.png)
42
45
 
43
46
  ## Features
44
47
 
45
- - **Current turn flow**: Interim answers, tool results, and appended user input stay in order during a running task.
46
- - **Latest file state**: `Read` and `Edit` maintain a current, line-numbered file view with stale-range checks.
47
- - **Anchored edits**: `line:hash` anchors catch stale edits before they touch the wrong code.
48
- - **Working memory**: `Note` keeps the active goal, plan, and known facts separate from noisy tool output.
49
- - **Symbol index**: Jump from names to outlines, references, and changed files without searching blindly.
50
- - **Tool recall**: Prompt output is bounded, while raw `tr.N` results remain recallable when needed.
51
- - **Terminal-native UI**: Model picking, history search, confirmations, live command output, appended input, and status stay in the terminal.
48
+ - **Live turn control**: Add follow-up input while the agent is still working, without losing the current tool flow.
49
+ - **File-state brain**: Reads and edits build a current, line-numbered view of the files that matter now.
50
+ - **Stale-edit protection**: `line:hash` anchors reject edits when the target code has drifted.
51
+ - **Project-aware navigation**: Use the symbol index to jump through outlines, references, and changed files quickly.
52
+ - **Recoverable context**: Tool output stays bounded in the prompt, while raw `tr.N` results remain recallable.
53
+ - **Cache-aware context**: Stable sections stay early and noisy working state stays late to improve prompt-cache reuse.
54
+ - **Focused working memory**: `Note` separates goal, plan, and known facts from noisy execution logs.
55
+ - **Terminal-first workflow**: Model selection, history search, confirmations, live command output, appended input, and status all stay in one CLI.
52
56
 
53
57
  ## Install
54
58
 
@@ -56,6 +60,12 @@ nanocode is pre-1.0 software. Commands, configuration, and tool behavior may cha
56
60
  uv tool install nanocode-cli
57
61
  ```
58
62
 
63
+ Upgrade:
64
+
65
+ ```sh
66
+ uv tool upgrade nanocode-cli
67
+ ```
68
+
59
69
  For local development:
60
70
 
61
71
  ```sh
@@ -2,19 +2,22 @@
2
2
 
3
3
  A small terminal coding agent written in Python.
4
4
 
5
+ [简体中文](README.zh-CN.md)
6
+
5
7
  nanocode is pre-1.0 software. Commands, configuration, and tool behavior may change before a stable release.
6
8
 
7
9
  ![nanocode screenshot](snapshots/nanocode-snapshot.png)
8
10
 
9
11
  ## Features
10
12
 
11
- - **Current turn flow**: Interim answers, tool results, and appended user input stay in order during a running task.
12
- - **Latest file state**: `Read` and `Edit` maintain a current, line-numbered file view with stale-range checks.
13
- - **Anchored edits**: `line:hash` anchors catch stale edits before they touch the wrong code.
14
- - **Working memory**: `Note` keeps the active goal, plan, and known facts separate from noisy tool output.
15
- - **Symbol index**: Jump from names to outlines, references, and changed files without searching blindly.
16
- - **Tool recall**: Prompt output is bounded, while raw `tr.N` results remain recallable when needed.
17
- - **Terminal-native UI**: Model picking, history search, confirmations, live command output, appended input, and status stay in the terminal.
13
+ - **Live turn control**: Add follow-up input while the agent is still working, without losing the current tool flow.
14
+ - **File-state brain**: Reads and edits build a current, line-numbered view of the files that matter now.
15
+ - **Stale-edit protection**: `line:hash` anchors reject edits when the target code has drifted.
16
+ - **Project-aware navigation**: Use the symbol index to jump through outlines, references, and changed files quickly.
17
+ - **Recoverable context**: Tool output stays bounded in the prompt, while raw `tr.N` results remain recallable.
18
+ - **Cache-aware context**: Stable sections stay early and noisy working state stays late to improve prompt-cache reuse.
19
+ - **Focused working memory**: `Note` separates goal, plan, and known facts from noisy execution logs.
20
+ - **Terminal-first workflow**: Model selection, history search, confirmations, live command output, appended input, and status all stay in one CLI.
18
21
 
19
22
  ## Install
20
23
 
@@ -22,6 +25,12 @@ nanocode is pre-1.0 software. Commands, configuration, and tool behavior may cha
22
25
  uv tool install nanocode-cli
23
26
  ```
24
27
 
28
+ Upgrade:
29
+
30
+ ```sh
31
+ uv tool upgrade nanocode-cli
32
+ ```
33
+
25
34
  For local development:
26
35
 
27
36
  ```sh
@@ -0,0 +1,142 @@
1
+ # nanocode
2
+
3
+ 一个用 Python 写的小型终端编程代理。
4
+
5
+ [English](README.md)
6
+
7
+ nanocode 仍是 1.0 前的软件。稳定版发布前,命令、配置和工具行为都可能变化。
8
+
9
+ ![nanocode screenshot](snapshots/nanocode-snapshot.png)
10
+
11
+ ## 特性
12
+
13
+ - **实时回合控制**:代理还在工作时,也可以追加输入,不打断当前工具流程。
14
+ - **文件状态大脑**:`Read` 和 `Edit` 会构建当前重要文件的带行号视图。
15
+ - **过期编辑保护**:`line:hash` 锚点会在目标代码漂移后拒绝错误编辑。
16
+ - **项目级导航**:通过符号索引快速查看 outline、references 和变更文件。
17
+ - **可恢复上下文**:prompt 中的工具输出保持有界,原始 `tr.N` 结果仍可按需召回。
18
+ - **缓存友好上下文**:稳定内容靠前,嘈杂的工作状态靠后,提高 prompt cache 复用率。
19
+ - **聚焦工作记忆**:`Note` 把 goal、plan、known facts 从嘈杂执行日志中拆出来。
20
+ - **终端优先工作流**:模型选择、历史搜索、确认、实时命令输出、追加输入和状态展示都在一个 CLI 内完成。
21
+
22
+ ## 安装
23
+
24
+ ```sh
25
+ uv tool install nanocode-cli
26
+ ```
27
+
28
+ 升级:
29
+
30
+ ```sh
31
+ uv tool upgrade nanocode-cli
32
+ ```
33
+
34
+ 本地开发:
35
+
36
+ ```sh
37
+ uv sync --extra dev
38
+ uv run nanocode
39
+ ```
40
+
41
+ ## 使用
42
+
43
+ 启动 CLI:
44
+
45
+ ```sh
46
+ nanocode
47
+ ```
48
+
49
+ 常用参数:
50
+
51
+ - `--config <path>`:使用指定 TOML 配置文件。
52
+ - `--init-config`:创建默认配置文件。
53
+ - `--yolo`:跳过会修改环境的工具确认。
54
+ - `-v`, `--version`:显示版本。
55
+
56
+ 代理运行中,`+>` 提示符可以接收追加输入,并在下一次模型请求中发送。
57
+
58
+ ## 命令
59
+
60
+ - `/help`:显示命令和工具。
61
+ - `/status`:显示运行状态。
62
+ - `/config`:显示当前配置。
63
+ - `/api [auto|chat|anthropic]`:显示或设置 provider API 格式。
64
+ - `/debug [on|off]`:切换模型 I/O debug trace。
65
+ - `/compact`:立即压缩上下文。
66
+ - `/index [force]`:同步或重建代码符号索引。
67
+ - `/provider [NAME]`:显示或设置 provider。
68
+ - `/model [MODEL]`:显示或设置模型。
69
+ - `/reason`:选择 reasoning effort。
70
+ - `/set KEY VALUE`:设置 provider/runtime 值。
71
+ - `/yolo`:切换工具确认。
72
+ - `/exit`, `/quit`:退出。
73
+
74
+ 交互选择器支持 `j`/`k`、方向键、`/` 搜索、Enter 和 Esc。输入框支持历史、补全和 `Ctrl-R` 历史搜索。
75
+
76
+ ## 工具
77
+
78
+ - 文件:`Read`, `LineCount`, `List`, `Find`, `Search`。
79
+ - 代码索引:`InspectCode`。
80
+ - 编辑:`Edit` 创建或修改文件内容。
81
+ - Shell:`Bash`, `Git`。
82
+ - 工具结果:`Recall`。
83
+ - 工作笔记:`Note`。
84
+
85
+ `Read`、`Search` 和 `InspectCode` 会在合适时返回行锚点。`Edit` 使用当前 `line:hash` 锚点拒绝过期编辑。
86
+
87
+ ## 配置
88
+
89
+ 运行:
90
+
91
+ ```sh
92
+ nanocode --init-config
93
+ ```
94
+
95
+ 默认配置位置是 `~/.nanocode/config.toml`。
96
+
97
+ 主要字段:
98
+
99
+ - `[provider] active = "name"`
100
+ - `[provider.<name>]`:`url`, `key`, `model`, `api`, `prompt_cache_key`, `available_models`, `reasoning`, `chat_reasoning`, `temperature`, `timeout`
101
+ - `[paths] data_dir`
102
+ - `[runtime] shell_timeout`, `max_agent_steps`, `max_context_tokens`, `yolo`
103
+
104
+ `api = "auto"` 会根据 provider/model profile 在 Chat Completions 和 Anthropic Messages 之间选择。`prompt_cache_key = "auto"` 会根据 provider、model、workspace 和工具 schema 名称生成稳定 key。
105
+
106
+ ## 上下文设计
107
+
108
+ 每次模型请求都由 nanocode 手动构建成明确的 messages。稳定上下文在前,会话作为 messages 保留,工作记忆随后,最新文件状态放在末尾。
109
+
110
+ ```text
111
+ model request
112
+ +--------------------------------------------------+
113
+ | system |
114
+ | concise agent contract and tool rules |
115
+ +--------------------------------------------------+
116
+ | user |
117
+ | Environment |
118
+ +--------------------------------------------------+
119
+ | user/assistant |
120
+ | conversation, compacted summaries, tools |
121
+ +--------------------------------------------------+
122
+ | user |
123
+ | Memory: goal, plan, known, date |
124
+ +--------------------------------------------------+
125
+ | user |
126
+ | FILE STATE: latest Read/Edit file view |
127
+ +--------------------------------------------------+
128
+ ```
129
+
130
+ 核心规则:
131
+
132
+ - 回合中的 assistant 文本和用户追加输入都会作为 conversation 保留。
133
+ - 上下文过大时,较早 conversation 会压缩成明确的 summary。
134
+ - FILE STATE 由成功的 `Read` 和 `Edit` 更新,展示当前文件范围,最近文件优先。
135
+ - 更新的文件行会覆盖旧行;edit invalidation 会清理过期范围。
136
+ - 文件行展示前会通过当前文件 stat 或行 hash 校验。
137
+ - 成功的 `Read` 和 `Edit` 工具消息只指向 FILE STATE,不重复塞入文件正文。
138
+ - 其他工具输出在 conversation messages 中保持有界,并可通过 `tr.N` 召回。
139
+
140
+ ## 安全
141
+
142
+ nanocode 会在启动它的环境中编辑文件和执行 shell 命令。它不提供 sandbox 保护。需要隔离时,请在你自己的 sandbox、容器、虚拟机或其他隔离环境中运行。
@@ -11,10 +11,12 @@ import os
11
11
  import platform
12
12
  import re
13
13
  import selectors
14
+ import shlex
14
15
  import shutil
15
16
  import signal
16
17
  import subprocess
17
18
  import sys
19
+ import tempfile
18
20
  import threading
19
21
  import time
20
22
  import tomllib
@@ -28,6 +30,7 @@ from urllib.request import Request, urlopen
28
30
 
29
31
  import code_symbol_index as csi
30
32
  from anthropic import Anthropic
33
+ from json_repair import repair_json
31
34
  from openai import OpenAI
32
35
  from prompt_toolkit import print_formatted_text, search as pt_search
33
36
  from prompt_toolkit.application import Application, run_in_terminal
@@ -52,7 +55,7 @@ from prompt_toolkit.widgets import SearchToolbar
52
55
  from rich.console import Console
53
56
  from rich.markdown import Markdown
54
57
 
55
- __version__ = "0.5.7"
58
+ __version__ = "0.5.9"
56
59
 
57
60
  Json = dict[str, Any]
58
61
  HTTP_USER_AGENT = "nanocode/" + __version__
@@ -407,6 +410,7 @@ class AgentState:
407
410
  goal: str = ""
408
411
  plan: list[str] = field(default_factory=list)
409
412
  known: list[str] = field(default_factory=list)
413
+ check: str = ""
410
414
  summary: str = ""
411
415
  code_index_status: str = ""
412
416
  code_index_error: str = ""
@@ -439,7 +443,7 @@ class AgentState:
439
443
  return next((text for item in self.plan if (text := self.plan_text(item))), "")
440
444
 
441
445
  def apply(self, data: Json) -> None:
442
- for attr in ("goal", "summary"):
446
+ for attr in ("goal", "summary", "check"):
443
447
  if isinstance(data.get(attr), str):
444
448
  setattr(self, attr, str(data[attr]).strip())
445
449
  for attr in ("plan", "known"):
@@ -450,7 +454,7 @@ class AgentState:
450
454
 
451
455
  def format(self) -> str:
452
456
  known = ["- " + item for item in self.known] or ["- (empty)"]
453
- return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows(), "Known:", *known])
457
+ return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows(), "Known:", *known, "Check: " + (self.check or "(empty)")])
454
458
 
455
459
 
456
460
  @dataclass
@@ -1869,20 +1873,22 @@ class BashTool(Tool):
1869
1873
 
1870
1874
  class GitTool(Tool):
1871
1875
  NAME = "Git"
1872
- DESCRIPTION = "Run git argv in the workspace; returns exit/stdout/stderr, with approval for mutating commands."
1873
- SIGNATURE = "Git(argv=[...], cwd?)"
1876
+ DESCRIPTION = 'Run git with explicit argv in the workspace. For add, pass explicit file paths; broad add is rejected.'
1877
+ SIGNATURE = "Git(argv=[command,...], cwd?)"
1874
1878
  EXAMPLE = (
1875
- 'Read repo status. Example: {"argv":["status","--short"]}',
1876
- 'Diff inside a subdir. Example: {"cwd":"src","argv":["diff","--","app.py"]}',
1877
- 'Read history/object. Example: {"argv":["show","--stat","HEAD"]}',
1879
+ 'Status. Example: {"argv":["status","--short"]}',
1880
+ 'Diff. Example: {"cwd":"src","argv":["diff","--","app.py"]}',
1881
+ 'Stage explicit files. Example: {"argv":["add","--","nanocode.py","README.md"]}',
1882
+ 'Show commit/file. Example: {"argv":["show","--stat","HEAD"]}',
1878
1883
  )
1879
1884
  READONLY = {"status", "diff", "log", "show", "rev-parse", "ls-files", "grep", "blame"}
1885
+ BROAD_ADD = {"-A", "--all", ".", "./", ":/", "*"}
1880
1886
 
1881
1887
  @classmethod
1882
1888
  def params_schema(cls) -> Json:
1883
1889
  return {
1884
1890
  "type": "object",
1885
- "properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string"}, "minItems": 1}},
1891
+ "properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}},
1886
1892
  "required": ["argv"],
1887
1893
  "additionalProperties": False,
1888
1894
  }
@@ -1923,8 +1929,26 @@ class GitTool(Tool):
1923
1929
  raise ToolError("git cwd is not a directory")
1924
1930
  if not args:
1925
1931
  raise ToolError("Git requires arguments")
1932
+ self.validate_add(args, cwd)
1926
1933
  return args, cwd
1927
1934
 
1935
+ def validate_add(self, args: list[str], cwd: str) -> None:
1936
+ if args[0] != "add":
1937
+ return
1938
+ paths, explicit = [], False
1939
+ for arg in args[1:]:
1940
+ if arg == "--":
1941
+ explicit = True
1942
+ elif arg in self.BROAD_ADD or arg.startswith("--pathspec-from-file"):
1943
+ raise ToolError("Git add requires explicit file paths")
1944
+ elif explicit or not arg.startswith("-"):
1945
+ paths.append(arg)
1946
+ if not paths:
1947
+ raise ToolError("Git add requires explicit file paths")
1948
+ for path in paths:
1949
+ if path.startswith(":") or any(char in path for char in "*?[]") or not self.session.in_cwd(os.path.abspath(os.path.join(cwd, path))):
1950
+ raise ToolError("Git add requires explicit file paths inside workspace")
1951
+
1928
1952
 
1929
1953
  class RecallTool(Tool):
1930
1954
  NAME = "Recall"
@@ -2013,15 +2037,15 @@ class RecallTool(Tool):
2013
2037
 
2014
2038
  class NoteTool(Tool):
2015
2039
  NAME = "Note"
2016
- DESCRIPTION = "Maintain durable working notes; goal and plan replace current values, known appends unique facts."
2017
- SIGNATURE = "Note(goal?, plan?, known?)"
2018
- EXAMPLE = ('Set memory. Example: {"goal":"ship parser fix","plan":["inspect parser","patch bug"],"known":["tests use pytest"]}',)
2040
+ DESCRIPTION = "Maintain durable working notes; goal, plan, and check replace current values, known appends unique facts."
2041
+ SIGNATURE = "Note(goal?, plan?, known?, check?)"
2042
+ EXAMPLE = ('Set memory. Example: {"goal":"ship parser fix","plan":["inspect parser","patch bug"],"known":["tests use pytest"],"check":"pytest passed"}',)
2019
2043
  STORES_RESULT = False
2020
2044
 
2021
2045
  @classmethod
2022
2046
  def params_schema(cls) -> Json:
2023
2047
  strings = {"type": "array", "items": {"type": "string"}, "minItems": 1}
2024
- return {"type": "object", "properties": {"goal": {"type": "string"}, "plan": strings, "known": strings}, "additionalProperties": False}
2048
+ return {"type": "object", "properties": {"goal": {"type": "string"}, "plan": strings, "known": strings, "check": {"type": "string"}}, "additionalProperties": False}
2025
2049
 
2026
2050
  @classmethod
2027
2051
  def payload_args(cls, payload: Json) -> list[Any]:
@@ -2031,7 +2055,7 @@ class NoteTool(Tool):
2031
2055
  if len(self.args) != 1 or not isinstance(self.args[0], dict):
2032
2056
  raise ToolError("Note requires named fields")
2033
2057
  data = self.args[0]
2034
- if unexpected := sorted(set(data) - {"goal", "plan", "known"}):
2058
+ if unexpected := sorted(set(data) - {"goal", "plan", "known", "check"}):
2035
2059
  raise ToolError("Note unexpected field: " + ", ".join(unexpected))
2036
2060
  changed = []
2037
2061
  if "goal" in data:
@@ -2047,8 +2071,11 @@ class NoteTool(Tool):
2047
2071
  raise ToolError("Note known must be an array")
2048
2072
  self.session.state.known = list(dict.fromkeys([*self.session.state.known, *(str(item).strip() for item in data["known"] if str(item).strip())]))
2049
2073
  changed.append("known")
2074
+ if "check" in data:
2075
+ self.session.state.check = str(data["check"]).strip()
2076
+ changed.append("check")
2050
2077
  if not changed:
2051
- raise ToolError("Note requires goal, plan, or known")
2078
+ raise ToolError("Note requires goal, plan, known, or check")
2052
2079
  return "Updated memory: " + ", ".join(changed)
2053
2080
 
2054
2081
  def short_args(self) -> list[str]:
@@ -2062,6 +2089,8 @@ class NoteTool(Tool):
2062
2089
  known = [Tool.compact(item, 120) for item in data["known"] if str(item).strip() and str(item).strip() not in self.session.state.known]
2063
2090
  if known:
2064
2091
  lines.extend(["known:", *(f" + {item}" for item in known)])
2092
+ if check := str(data.get("check") or "").strip():
2093
+ lines.append("check -> " + Tool.compact(check, 120))
2065
2094
  return ["\n".join(lines) or "{}"]
2066
2095
 
2067
2096
 
@@ -2091,6 +2120,11 @@ class ToolCall:
2091
2120
  class ContextManager:
2092
2121
  COMPACT_TITLE: ClassVar[str] = "--- Prior Conversation Summary (compacted) ---"
2093
2122
  COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
2123
+ CODE_EXTENSIONS: ClassVar[set[str]] = set(
2124
+ ".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
2125
+ ".swift .toml .ts .tsx .vue .yaml .yml".split()
2126
+ )
2127
+ CODE_FILENAMES: ClassVar[set[str]] = {"CMakeLists.txt", "Dockerfile", "Makefile", "go.mod", "package.json", "pyproject.toml"}
2094
2128
 
2095
2129
  @dataclass
2096
2130
  class FileContextItem:
@@ -2123,28 +2157,35 @@ class ContextManager:
2123
2157
 
2124
2158
  def update_percent(self, messages: list[Json]) -> int:
2125
2159
  tokens = self.estimated_tokens(messages)
2126
- self.session.state.context_percent = min(100, round(tokens * 100 / self.session.settings.max_context_tokens))
2160
+ self.session.state.context_percent = min(100, tokens * 100 // self.session.settings.max_context_tokens)
2127
2161
  return self.session.state.context_percent
2128
2162
 
2129
2163
  def maybe_compact(self, model: "ModelClient", base_system: str, turn_messages: list[Json] | None = None) -> None:
2130
- if self.estimated_tokens(self.model_messages(base_system, turn_messages)) < self.session.settings.max_context_tokens:
2164
+ if not self.over_budget(base_system, turn_messages):
2131
2165
  return
2132
2166
  compacted, keep = self.compaction_parts()
2133
- if not compacted:
2134
- return
2135
- try:
2136
- self.apply_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
2137
- except Exception:
2138
- self.session.state.summary = (self.session.state.summary + "\nPrevious context was deterministically trimmed.").strip()
2139
- summary = self.session.state.summary
2140
- self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
2141
- self.prune_tool_records([*keep, *(turn_messages or [])])
2167
+ if compacted:
2168
+ try:
2169
+ self.apply_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
2170
+ except Exception:
2171
+ self.apply_compaction_fallback(keep, turn_messages)
2172
+ if turn_messages is not None and self.over_budget(base_system, turn_messages):
2173
+ compacted, keep = self.turn_compaction_parts(turn_messages)
2174
+ if compacted:
2175
+ try:
2176
+ self.apply_turn_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
2177
+ except Exception:
2178
+ self.apply_turn_compaction_fallback(keep, turn_messages)
2179
+
2180
+ def over_budget(self, base_system: str, turn_messages: list[Json] | None = None) -> bool:
2181
+ return self.estimated_tokens(self.model_messages(base_system, turn_messages)) >= self.session.settings.max_context_tokens
2142
2182
 
2143
2183
  def memory_context(self, *, with_date: bool = False) -> str:
2144
2184
  rows = [
2145
2185
  "Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
2146
2186
  "Plan:\n" + "\n".join(self.session.state.plan_rows() or ["- (empty; use Note for a short plan)"]),
2147
2187
  "Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
2188
+ "Check: " + (self.session.state.check or "(empty)"),
2148
2189
  ]
2149
2190
  if with_date:
2150
2191
  rows.append("Date: " + datetime.now().astimezone().strftime("%Y-%m-%d"))
@@ -2242,8 +2283,10 @@ class ContextManager:
2242
2283
  )
2243
2284
 
2244
2285
  paths = sorted((path for path in lines_by_path if lines_by_path[path]), key=lambda path: (-recent(path), path))
2286
+ code_edits = self.recent_code_edits()
2287
+ check_status = self.check_status(code_edits)
2245
2288
  focus, actions, errors = self.session.state.current_focus(), self.recent_file_actions(), self.recent_tool_errors()
2246
- if not paths and not omitted and not focus and not actions and not errors:
2289
+ if not paths and not omitted and not focus and not actions and not code_edits and not check_status and not errors:
2247
2290
  return ""
2248
2291
  chunks = ["Read/Edit outputs update this section. Treat listed ranges as current file state."] if paths else []
2249
2292
  if focus:
@@ -2254,6 +2297,10 @@ class ContextManager:
2254
2297
  chunks.extend(f"- {path} {start}:{end} current" for start, end in self.coverage(lines_by_path[path]))
2255
2298
  if actions:
2256
2299
  chunks.extend(["", "Recent file events:", *actions])
2300
+ if code_edits:
2301
+ chunks.extend(["", "Recent code edits:", *code_edits])
2302
+ if check_status:
2303
+ chunks.extend(["", "Check status:", *check_status])
2257
2304
  if errors:
2258
2305
  chunks.extend(["", "Recent tool errors:", *errors])
2259
2306
  if paths:
@@ -2267,7 +2314,6 @@ class ContextManager:
2267
2314
  chunks.append("Omitted content:")
2268
2315
  for path in sorted(omitted):
2269
2316
  chunks.extend(f"- {path} source={source} lines={count}" for source, count in sorted(omitted[path].items()))
2270
- chunks.extend(["", "OUTPUT IN USER LANGUAGE"])
2271
2317
  return "\n".join(chunks).strip() if len(chunks) > 4 else ""
2272
2318
 
2273
2319
  def recent_file_actions(self) -> list[str]:
@@ -2284,6 +2330,30 @@ class ContextManager:
2284
2330
  for record in self.session.tool_errors[-5:]
2285
2331
  ]
2286
2332
 
2333
+ def recent_code_edits(self) -> list[str]:
2334
+ rows: dict[str, str] = {}
2335
+ for record in self.session.tool_records[-20:]:
2336
+ if record.name == "Edit":
2337
+ for match in re.finditer(r'<Edit\s+path=(".*?")', record.output):
2338
+ try:
2339
+ path = str(json.loads(match.group(1)))
2340
+ except json.JSONDecodeError:
2341
+ continue
2342
+ if self.code_like_path(path):
2343
+ rows[path] = f"- {record.key} Edit {path}"
2344
+ return list(rows.values())[-8:]
2345
+
2346
+ def check_status(self, code_edits: list[str]) -> list[str]:
2347
+ if not code_edits:
2348
+ return []
2349
+ check = self.session.state.check.strip()
2350
+ return ["- " + check] if check else ["- Code changed recently. Use Note(check=...) after checks, or final must say checks not run."]
2351
+
2352
+ @classmethod
2353
+ def code_like_path(cls, path: str) -> bool:
2354
+ name = os.path.basename(path)
2355
+ return name in cls.CODE_FILENAMES or os.path.splitext(name)[1].lower() in cls.CODE_EXTENSIONS
2356
+
2287
2357
  def coverage(self, numbered: dict[int, tuple[str, str, str]]) -> list[tuple[int, int]]:
2288
2358
  numbers = sorted(numbered)
2289
2359
  if not numbers:
@@ -2320,7 +2390,7 @@ class ContextManager:
2320
2390
  return segments
2321
2391
 
2322
2392
  def compaction_input(self, messages: list[Json]) -> str:
2323
- older, recent = self.compaction_recent(messages)
2393
+ older, recent = self.compaction_parts_for(messages)
2324
2394
  return "\n\n".join(
2325
2395
  [
2326
2396
  "State:\n" + self.session.state.format(),
@@ -2334,7 +2404,14 @@ class ContextManager:
2334
2404
  index = self.latest_user_index(self.session.messages)
2335
2405
  return (self.session.messages, []) if index is None else (self.session.messages[:index], self.session.messages[index:])
2336
2406
 
2337
- def compaction_recent(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
2407
+ def turn_compaction_parts(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
2408
+ index = self.latest_user_index(messages)
2409
+ if index is None:
2410
+ return self.compaction_parts_for(messages)
2411
+ compacted, keep = self.compaction_parts_for(messages[index + 1 :])
2412
+ return compacted, messages[: index + 1] + keep
2413
+
2414
+ def compaction_parts_for(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
2338
2415
  cut = max(0, len(messages) - self.COMPACT_RECENT_MESSAGES)
2339
2416
  return messages[:cut], messages[cut:]
2340
2417
 
@@ -2347,6 +2424,24 @@ class ContextManager:
2347
2424
  self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
2348
2425
  self.prune_tool_records([*self.session.messages, *(tool_messages or [])])
2349
2426
 
2427
+ def apply_compaction_fallback(self, keep: list[Json], tool_messages: list[Json] | None = None) -> None:
2428
+ self.session.state.summary = (self.session.state.summary + "\nPrevious context was deterministically trimmed.").strip()
2429
+ summary = self.session.state.summary
2430
+ self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
2431
+ self.prune_tool_records([*keep, *(tool_messages or [])])
2432
+
2433
+ def apply_turn_compaction(self, data: Json, keep: list[Json], turn_messages: list[Json]) -> None:
2434
+ self.session.state.apply(data)
2435
+ summary = self.session.state.summary
2436
+ index = self.latest_user_index(keep)
2437
+ insert = len(keep) if index is None else index + 1
2438
+ turn_messages[:] = keep[:insert] + ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep[insert:]
2439
+ self.prune_tool_records([*self.session.messages, *turn_messages])
2440
+
2441
+ def apply_turn_compaction_fallback(self, keep: list[Json], turn_messages: list[Json]) -> None:
2442
+ self.session.state.summary = (self.session.state.summary + "\nCurrent turn context was deterministically trimmed.").strip()
2443
+ self.apply_turn_compaction({"summary": self.session.state.summary}, keep, turn_messages)
2444
+
2350
2445
  def prune_tool_records(self, keep_messages: list[Json]) -> None:
2351
2446
  records = self.session.tool_records
2352
2447
  keep = set(re.findall(r"\btr\.\d+\b", self.messages_text(keep_messages)))
@@ -2629,6 +2724,7 @@ class ToolRunner:
2629
2724
  self.input_fn = input_fn
2630
2725
  self.output_fn = output_fn
2631
2726
  self.preview_fn: Callable[[str], bool] | None = None
2727
+ self.preview_full_fn: Callable[[str], None] | None = None
2632
2728
  self.live_output: Callable[[str, str], None] | None = None
2633
2729
  self.live_start: Callable[[], None] | None = None
2634
2730
 
@@ -2783,8 +2879,11 @@ class ToolRunner:
2783
2879
  CodeIndex(self.session).update(list(dict.fromkeys(paths)))
2784
2880
 
2785
2881
  def confirm(self, call: ToolCall, tool: Tool, batch_suffix: str = "", planned_edit: EditBatchPlan.PlannedEdit | None = None) -> tuple[bool, str]:
2786
- if not (self.preview_fn and self.preview_fn(self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit))):
2787
- self.output_fn(self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit))
2882
+ display = self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit, preview_lines=40)
2883
+ if self.preview_full_fn and tool.NAME == "Edit":
2884
+ self.preview_full_fn(self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit, preview_lines=None))
2885
+ if not (self.preview_fn and self.preview_fn(display)):
2886
+ self.output_fn(display)
2788
2887
  answer = self.input_fn("[Y/n or reason] ").strip()
2789
2888
  lower = answer.lower()
2790
2889
  if lower in {"", "y", "yes"}:
@@ -2792,19 +2891,26 @@ class ToolRunner:
2792
2891
  return False, "" if lower in {"n", "no"} else answer
2793
2892
 
2794
2893
  def approval_display(
2795
- self, call: ToolCall, tool: Tool, status: str, batch_suffix: str = "", planned_edit: EditBatchPlan.PlannedEdit | None = None
2894
+ self,
2895
+ call: ToolCall,
2896
+ tool: Tool,
2897
+ status: str,
2898
+ batch_suffix: str = "",
2899
+ planned_edit: EditBatchPlan.PlannedEdit | None = None,
2900
+ preview_lines: int | None = 40,
2796
2901
  ) -> str:
2797
2902
  header = self.with_batch_suffix(("approve " if status == "confirm" else "auto ") + self.short_call(call), batch_suffix)
2798
2903
  if tool.NAME != "Edit":
2799
2904
  return header
2800
2905
  preview = planned_edit.preview(tool) if planned_edit and isinstance(tool, EditTool) else tool.preview()
2801
- return header + (("\n" + block) if (block := self.preview_block(preview)) else "")
2906
+ return header + (("\n" + block) if (block := self.preview_block(preview, max_lines=preview_lines)) else "")
2802
2907
 
2803
- def preview_block(self, preview: str, *, max_lines: int = 40) -> str:
2908
+ def preview_block(self, preview: str, *, max_lines: int | None = 40) -> str:
2804
2909
  lines = preview.rstrip().splitlines()
2805
2910
  if not lines:
2806
2911
  return ""
2807
- lines = lines[:max_lines] + (["... preview truncated ..."] if len(lines) > max_lines else [])
2912
+ if max_lines is not None and len(lines) > max_lines:
2913
+ lines = lines[:max_lines] + [f"... preview truncated: {len(lines) - max_lines} more lines (Ctrl-A: full preview) ..."]
2808
2914
  return "\n".join([" preview", *(" " + line for line in lines)])
2809
2915
 
2810
2916
  def finish_display(
@@ -2981,7 +3087,8 @@ class ModelClient:
2981
3087
  def compact(self, context: str) -> Json:
2982
3088
  prompt = """
2983
3089
  Compact the nanocode working context.
2984
- Return exactly one JSON object with keys: summary, goal, plan, known.
3090
+ Return one JSON object only. No markdown, prose, code fences, or comments.
3091
+ Use keys: summary, goal, plan, known.
2985
3092
  Rewrite recent conversation briefly inside summary.
2986
3093
  Keep only durable facts needed to continue; preserve file paths, symbols, constraints, and tr.N keys.
2987
3094
  """.strip()
@@ -2991,13 +3098,29 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
2991
3098
  if self.session.config.provider.resolved_api() == "anthropic"
2992
3099
  else self.chat_request(messages, None, activity="compact")
2993
3100
  )
2994
- content = content.strip()
2995
- content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE | re.DOTALL).strip()
2996
- data = json.loads(content)
3101
+ data = self.parse_json_object(content)
2997
3102
  if not isinstance(data, dict):
2998
3103
  raise ModelError("compactor returned non-object JSON")
2999
3104
  return data
3000
3105
 
3106
+ @classmethod
3107
+ def parse_json_object(cls, text: str) -> Json:
3108
+ text = cls.strip_json_fence(Text.clean(text).strip())
3109
+ if not text:
3110
+ raise ModelError("compactor returned empty output")
3111
+ try:
3112
+ data = json.loads(text)
3113
+ except json.JSONDecodeError:
3114
+ data = repair_json(text, return_objects=True)
3115
+ if isinstance(data, dict):
3116
+ return data
3117
+ raise ModelError("compactor returned invalid JSON: " + Tool.compact(text, 200))
3118
+
3119
+ @staticmethod
3120
+ def strip_json_fence(text: str) -> str:
3121
+ match = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, flags=re.IGNORECASE | re.DOTALL)
3122
+ return (match.group(1) if match else text).strip()
3123
+
3001
3124
  def client(self) -> OpenAI:
3002
3125
  provider = self.session.config.provider
3003
3126
  if missing := self.session.missing_config():
@@ -3238,13 +3361,13 @@ FLOW:
3238
3361
  - ACT when clear; keep using tools until done.
3239
3362
  - Every turn must call tools or return final; never emit empty content.
3240
3363
  - Prefer built-ins over Bash. Batch independent read-only calls.
3241
- - All user-visible output follows the latest user input and uses the user's language.
3242
- - Interim text is shown to the user and kept as conversation.
3364
+ - All assistant text is user-visible, including interim narration before tool calls and final answers.
3365
+ - Write every assistant text message as markdown in the latest user's language; do not switch languages unless asked.
3243
3366
 
3244
3367
  CONTEXT:
3245
3368
  - Trust LATEST FILE STATE from Read/Edit for listed ranges.
3246
3369
  - Recall bounded tr.N only when needed; prefer FILE STATE over old outputs.
3247
- - For multi-step work, call Note early with goal and a short plan; update plan/known when they change.
3370
+ - For multi-step work, call Note early with goal and a short plan; update plan/known/check when they change.
3248
3371
 
3249
3372
  EDITS:
3250
3373
  - Inspect/read before edits.
@@ -3256,7 +3379,8 @@ EDITS:
3256
3379
 
3257
3380
  FINAL:
3258
3381
  - Concise markdown in the user's language.
3259
- - Include changed files and checks when relevant.\
3382
+ - Include changed files and checks when relevant.
3383
+ - Mention checks run, or say checks not run.\
3260
3384
  """
3261
3385
 
3262
3386
  def __init__(self, session: Session, input_fn=input, output_fn=print):
@@ -3445,7 +3569,7 @@ class UiPrinter:
3445
3569
  return [("ansicyan", text + "\n")]
3446
3570
  if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
3447
3571
  return [("ansired", text + "\n")]
3448
- return self.text_segments(text)
3572
+ return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
3449
3573
 
3450
3574
  def tool_segments(self, text: str) -> list[tuple[str, str]]:
3451
3575
  segments = []
@@ -3562,10 +3686,6 @@ class UiPrinter:
3562
3686
  at_start = part.endswith("\n")
3563
3687
  return indented
3564
3688
 
3565
- def text_segments(self, text: str) -> list[tuple[str, str]]:
3566
- return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
3567
-
3568
-
3569
3689
  class BashLivePreview:
3570
3690
  HEIGHT: ClassVar[int] = 6
3571
3691
  MAX_CHARS: ClassVar[int] = 8000
@@ -3737,15 +3857,13 @@ class StatusBar:
3737
3857
  self.output.flush()
3738
3858
  self.rendered = False
3739
3859
 
3740
- def elapsed(self) -> float:
3741
- return max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
3742
-
3743
3860
  def idle_fragments(self) -> list[tuple[str, str]]:
3744
3861
  return self.fragments(0.0, sweep=False, show_elapsed=False)
3745
3862
 
3746
3863
  def active_fragments(self) -> list[tuple[str, str]]:
3747
3864
  self.refresh_retry_state()
3748
- return self.fragments(self.elapsed(), sweep=True, show_elapsed=True)
3865
+ elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
3866
+ return self.fragments(elapsed, sweep=True, show_elapsed=True)
3749
3867
 
3750
3868
  def fragments(self, elapsed: float, *, sweep: bool, show_elapsed: bool) -> list[tuple[str, str]]:
3751
3869
  text = self.text(elapsed, show_elapsed=show_elapsed)
@@ -3870,6 +3988,7 @@ Tools:
3870
3988
  self.live_status_paused = False
3871
3989
  self.live_queue_paused = False
3872
3990
  self.transient_tool_lines = 0
3991
+ self.approval_full_preview = ""
3873
3992
  self.interactive_input = input_fn is input and sys.stdin.isatty()
3874
3993
  self.queue_input_paused = threading.Event()
3875
3994
  self.queue_input_active = threading.Event()
@@ -3888,6 +4007,7 @@ Tools:
3888
4007
  self.agent.tools.output_fn = self.tool_output
3889
4008
  self.agent.tools.input_fn = self.tool_input
3890
4009
  self.agent.tools.preview_fn = self.tool_preview
4010
+ self.agent.tools.preview_full_fn = lambda text: setattr(self, "approval_full_preview", text)
3891
4011
  self.agent.tools.live_start = self.tool_live_start
3892
4012
  self.agent.tools.live_output = self.tool_live_output
3893
4013
 
@@ -4094,7 +4214,14 @@ Tools:
4094
4214
  frame = "|/-\\"[int(time.monotonic() / 0.2) % 4]
4095
4215
  return [("class:approval", prompt_text), ("class:approval.wait", frame + " ")]
4096
4216
 
4097
- def read_input(self, prompt_text: str = "nano> ", *, multiline: bool = False, submit_on_enter: bool = False, prompt_style: str = "class:prompt") -> str:
4217
+ def read_input(
4218
+ self,
4219
+ prompt_text: str = "nano> ",
4220
+ *,
4221
+ multiline: bool = False,
4222
+ submit_on_enter: bool = False,
4223
+ prompt_style: str = "class:prompt",
4224
+ ) -> str:
4098
4225
  if self.input_history is None:
4099
4226
  return self.input_fn(prompt_text)
4100
4227
 
@@ -4150,6 +4277,10 @@ Tools:
4150
4277
  else:
4151
4278
  pt_search.start_search(direction=direction)
4152
4279
 
4280
+ @bindings.add("c-a", filter=Condition(lambda: bool(self.approval_full_preview)), eager=True)
4281
+ def _ctrl_a(event):
4282
+ run_in_terminal(self.open_approval_preview)
4283
+
4153
4284
  @bindings.add("tab")
4154
4285
  def _tab(event):
4155
4286
  if buffer.complete_state:
@@ -4209,7 +4340,12 @@ Tools:
4209
4340
  if text.startswith("approve ") and self.interactive_input and sys.stdout.isatty():
4210
4341
  self.with_status_paused(lambda: self.show_transient_tool_output(text))
4211
4342
  return
4212
- self.with_status_paused(lambda: self.emit_tool_output(text))
4343
+
4344
+ def emit() -> None:
4345
+ self.clear_transient_tool_output()
4346
+ self.emit(text)
4347
+
4348
+ self.with_status_paused(emit)
4213
4349
 
4214
4350
  def agent_output(self, text: str = "") -> None:
4215
4351
  self.with_status_paused(lambda: self.emit_agent_output(text))
@@ -4225,6 +4361,7 @@ Tools:
4225
4361
  finally:
4226
4362
  if self.interactive_input and sys.stdout.isatty():
4227
4363
  self.clear_transient_tool_output()
4364
+ self.approval_full_preview = ""
4228
4365
 
4229
4366
  return self.with_status_paused(read)
4230
4367
 
@@ -4239,20 +4376,46 @@ Tools:
4239
4376
  self.with_status_paused(lambda: self.show_transient_tool_preview(text))
4240
4377
  return True
4241
4378
 
4379
+ def open_approval_preview(self) -> None:
4380
+ if not self.approval_full_preview:
4381
+ return
4382
+ fd, path = tempfile.mkstemp(prefix="nanocode-preview-", suffix=".diff")
4383
+ try:
4384
+ pager_env = os.environ.get("PAGER", "")
4385
+ pager = shlex.split(pager_env) if pager_env else ([less, "-R"] if (less := shutil.which("less")) else [])
4386
+ text = self.ansi_diff_preview(self.approval_full_preview) if pager and os.path.basename(pager[0]) == "less" else self.approval_full_preview
4387
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
4388
+ file.write(text.rstrip() + "\n")
4389
+ if pager:
4390
+ subprocess.run([*pager, path])
4391
+ else:
4392
+ print(self.approval_full_preview)
4393
+ input("Press Enter to return...")
4394
+ finally:
4395
+ try:
4396
+ os.unlink(path)
4397
+ except OSError:
4398
+ pass
4399
+
4400
+ @staticmethod
4401
+ def ansi_diff_preview(text: str) -> str:
4402
+ colors = [("---", "\033[90m"), ("+++", "\033[90m"), ("@@", "\033[36m"), ("+", "\033[32m"), ("-", "\033[31m")]
4403
+ lines = []
4404
+ for line in text.splitlines():
4405
+ style = next((color for prefix, color in colors if line.lstrip().startswith(prefix)), "")
4406
+ lines.append(style + line + ("\033[0m" if style else ""))
4407
+ return "\n".join(lines)
4408
+
4242
4409
  def show_transient_tool_preview(self, text: str) -> None:
4243
4410
  self.clear_transient_tool_output()
4244
4411
  lines = text.rstrip().splitlines()
4245
4412
  if not lines:
4246
4413
  return
4247
4414
  height, width = 12, max(20, shutil.get_terminal_size((120, 20)).columns)
4248
- shown = lines[:height] + (["... preview truncated ..."] if len(lines) > height else [])
4415
+ shown = lines[:height] + ([f"... preview truncated: {len(lines) - height} more lines (Ctrl-A: full preview) ..."] if len(lines) > height else [])
4249
4416
  self.emit("\n".join(line[: max(0, width - 1)] for line in shown))
4250
4417
  self.transient_tool_lines = len(shown)
4251
4418
 
4252
- def emit_tool_output(self, text: str) -> None:
4253
- self.clear_transient_tool_output()
4254
- self.emit(text)
4255
-
4256
4419
  def emit_agent_output(self, text: str) -> None:
4257
4420
  self.clear_transient_tool_output()
4258
4421
  if self.ui.color and text.strip():
@@ -4651,16 +4814,20 @@ Tools:
4651
4814
  compacted, keep = self.agent.context.compaction_parts()
4652
4815
  if not compacted:
4653
4816
  return "No prior conversation to compact"
4817
+ fallback = False
4654
4818
  try:
4655
4819
  self.status_bar.start()
4656
4820
  data = self.agent.model.compact(self.agent.context.compaction_input(compacted))
4657
4821
  except KeyboardInterrupt:
4658
4822
  return "Cancelled"
4659
- except Exception as error:
4660
- return "Error: " + str(error)
4823
+ except Exception:
4824
+ self.agent.context.apply_compaction_fallback(keep)
4825
+ fallback = True
4826
+ data = None
4661
4827
  finally:
4662
4828
  self.status_bar.stop()
4663
- self.agent.context.apply_compaction(data, keep)
4829
+ if data is not None:
4830
+ self.agent.context.apply_compaction(data, keep)
4664
4831
  self.agent.context.update_percent(self.agent.context.model_messages(self.agent.SYSTEM_PROMPT))
4665
4832
  return (
4666
4833
  "Compacted context: messages "
@@ -4670,6 +4837,7 @@ Tools:
4670
4837
  + ", prior summary inserted, ctx "
4671
4838
  + str(self.session.state.context_percent)
4672
4839
  + "%"
4840
+ + (" (fallback)" if fallback else "")
4673
4841
  )
4674
4842
 
4675
4843
  def index(self, args: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
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
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: anthropic>=0.64.0
25
25
  Requires-Dist: code-symbol-index>=0.1.13
26
+ Requires-Dist: json-repair
26
27
  Requires-Dist: openai>=2.37.0
27
28
  Requires-Dist: prompt-toolkit>=3.0
28
29
  Requires-Dist: rich>=13.0
@@ -36,19 +37,22 @@ Dynamic: license-file
36
37
 
37
38
  A small terminal coding agent written in Python.
38
39
 
40
+ [简体中文](README.zh-CN.md)
41
+
39
42
  nanocode is pre-1.0 software. Commands, configuration, and tool behavior may change before a stable release.
40
43
 
41
44
  ![nanocode screenshot](snapshots/nanocode-snapshot.png)
42
45
 
43
46
  ## Features
44
47
 
45
- - **Current turn flow**: Interim answers, tool results, and appended user input stay in order during a running task.
46
- - **Latest file state**: `Read` and `Edit` maintain a current, line-numbered file view with stale-range checks.
47
- - **Anchored edits**: `line:hash` anchors catch stale edits before they touch the wrong code.
48
- - **Working memory**: `Note` keeps the active goal, plan, and known facts separate from noisy tool output.
49
- - **Symbol index**: Jump from names to outlines, references, and changed files without searching blindly.
50
- - **Tool recall**: Prompt output is bounded, while raw `tr.N` results remain recallable when needed.
51
- - **Terminal-native UI**: Model picking, history search, confirmations, live command output, appended input, and status stay in the terminal.
48
+ - **Live turn control**: Add follow-up input while the agent is still working, without losing the current tool flow.
49
+ - **File-state brain**: Reads and edits build a current, line-numbered view of the files that matter now.
50
+ - **Stale-edit protection**: `line:hash` anchors reject edits when the target code has drifted.
51
+ - **Project-aware navigation**: Use the symbol index to jump through outlines, references, and changed files quickly.
52
+ - **Recoverable context**: Tool output stays bounded in the prompt, while raw `tr.N` results remain recallable.
53
+ - **Cache-aware context**: Stable sections stay early and noisy working state stays late to improve prompt-cache reuse.
54
+ - **Focused working memory**: `Note` separates goal, plan, and known facts from noisy execution logs.
55
+ - **Terminal-first workflow**: Model selection, history search, confirmations, live command output, appended input, and status all stay in one CLI.
52
56
 
53
57
  ## Install
54
58
 
@@ -56,6 +60,12 @@ nanocode is pre-1.0 software. Commands, configuration, and tool behavior may cha
56
60
  uv tool install nanocode-cli
57
61
  ```
58
62
 
63
+ Upgrade:
64
+
65
+ ```sh
66
+ uv tool upgrade nanocode-cli
67
+ ```
68
+
59
69
  For local development:
60
70
 
61
71
  ```sh
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  MANIFEST.in
3
3
  README.md
4
+ README.zh-CN.md
4
5
  nanocode.py
5
6
  pyproject.toml
6
7
  nanocode_cli.egg-info/PKG-INFO
@@ -1,5 +1,6 @@
1
1
  anthropic>=0.64.0
2
2
  code-symbol-index>=0.1.13
3
+ json-repair
3
4
  openai>=2.37.0
4
5
  prompt-toolkit>=3.0
5
6
  rich>=13.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.5.7"
7
+ version = "0.5.9"
8
8
  description = "A small terminal coding agent written in Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -29,6 +29,7 @@ classifiers = [
29
29
  dependencies = [
30
30
  "anthropic>=0.64.0",
31
31
  "code-symbol-index>=0.1.13",
32
+ "json-repair",
32
33
  "openai>=2.37.0",
33
34
  "prompt-toolkit>=3.0",
34
35
  "rich>=13.0",
File without changes
File without changes