nanocode-cli 0.5.7__tar.gz → 0.5.10__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.10
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.10"
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
@@ -549,6 +553,7 @@ class Session:
549
553
  tool_counter: int = 0
550
554
  usage: ModelUsage = field(default_factory=ModelUsage)
551
555
  update: UpdateStatus = field(default_factory=UpdateStatus)
556
+ _gitignore_cache: dict[str, tuple[int, list[str]]] = field(default_factory=dict)
552
557
 
553
558
  def __post_init__(self) -> None:
554
559
  if self.system_info is None:
@@ -755,12 +760,23 @@ class Tool:
755
760
 
756
761
  def gitignore_patterns(self, root: str) -> list[str]:
757
762
  patterns = []
758
- for path in dict.fromkeys([os.path.join(self.session.cwd, ".gitignore"), *([os.path.join(root, ".gitignore")] if os.path.isdir(root) else [])]):
763
+ cache = self.session._gitignore_cache
764
+ paths = [os.path.join(self.session.cwd, ".gitignore")]
765
+ if os.path.isdir(root):
766
+ paths.append(os.path.join(root, ".gitignore"))
767
+ for path in dict.fromkeys(paths):
759
768
  try:
769
+ mtime = os.stat(path).st_mtime_ns
770
+ cached = cache.get(path)
771
+ if cached is not None and cached[0] == mtime:
772
+ patterns.extend(cached[1])
773
+ continue
760
774
  with open(path, encoding="utf-8") as file:
761
- patterns.extend(line.strip() for line in file if line.strip() and not line.lstrip().startswith("#") and not line.startswith("!"))
775
+ pats = [line.strip() for line in file if line.strip() and not line.lstrip().startswith("#") and not line.startswith("!")]
776
+ cache[path] = (mtime, pats)
777
+ patterns.extend(pats)
762
778
  except OSError:
763
- pass
779
+ cache.pop(path, None)
764
780
  return patterns
765
781
 
766
782
  def ignored(self, path: str, patterns: list[str]) -> bool:
@@ -1742,7 +1758,7 @@ class EditTool(Tool):
1742
1758
 
1743
1759
  class BashTool(Tool):
1744
1760
  NAME = "Bash"
1745
- DESCRIPTION = "Run one bash shell invocation in the workspace; returns exit_code/stdout/stderr and shows live output."
1761
+ DESCRIPTION = "Run one bash shell invocation in the workspace; returns exit_code/stdout/stderr and shows live output. Avoid unbounded output; limit noisy commands with head/tail/sed/rg filters or command-specific limits, and inspect large outputs in chunks."
1746
1762
  SIGNATURE = "Bash(command)"
1747
1763
  EXAMPLE = (
1748
1764
  'Check environment. Example: {"command":"python3 --version"}',
@@ -1869,20 +1885,22 @@ class BashTool(Tool):
1869
1885
 
1870
1886
  class GitTool(Tool):
1871
1887
  NAME = "Git"
1872
- DESCRIPTION = "Run git argv in the workspace; returns exit/stdout/stderr, with approval for mutating commands."
1873
- SIGNATURE = "Git(argv=[...], cwd?)"
1888
+ DESCRIPTION = 'Run git with explicit argv (default: cwd from Environment; use cwd= for other directories). For add, pass explicit file paths; broad add is rejected.'
1889
+ SIGNATURE = "Git(argv=[command,...], cwd?)"
1874
1890
  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"]}',
1891
+ 'Status. Example: {"argv":["status","--short"]}',
1892
+ 'Diff. Example: {"cwd":"src","argv":["diff","--","app.py"]}',
1893
+ 'Stage explicit files. Example: {"argv":["add","--","nanocode.py","README.md"]}',
1894
+ 'Show commit/file. Example: {"argv":["show","--stat","HEAD"]}',
1878
1895
  )
1879
1896
  READONLY = {"status", "diff", "log", "show", "rev-parse", "ls-files", "grep", "blame"}
1897
+ BROAD_ADD = {"-A", "--all", ".", "./", ":/", "*"}
1880
1898
 
1881
1899
  @classmethod
1882
1900
  def params_schema(cls) -> Json:
1883
1901
  return {
1884
1902
  "type": "object",
1885
- "properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string"}, "minItems": 1}},
1903
+ "properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}},
1886
1904
  "required": ["argv"],
1887
1905
  "additionalProperties": False,
1888
1906
  }
@@ -1923,8 +1941,26 @@ class GitTool(Tool):
1923
1941
  raise ToolError("git cwd is not a directory")
1924
1942
  if not args:
1925
1943
  raise ToolError("Git requires arguments")
1944
+ self.validate_add(args, cwd)
1926
1945
  return args, cwd
1927
1946
 
1947
+ def validate_add(self, args: list[str], cwd: str) -> None:
1948
+ if args[0] != "add":
1949
+ return
1950
+ paths, explicit = [], False
1951
+ for arg in args[1:]:
1952
+ if arg == "--":
1953
+ explicit = True
1954
+ elif arg in self.BROAD_ADD or arg.startswith("--pathspec-from-file"):
1955
+ raise ToolError("Git add requires explicit file paths")
1956
+ elif explicit or not arg.startswith("-"):
1957
+ paths.append(arg)
1958
+ if not paths:
1959
+ raise ToolError("Git add requires explicit file paths")
1960
+ for path in paths:
1961
+ 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))):
1962
+ raise ToolError("Git add requires explicit file paths inside workspace")
1963
+
1928
1964
 
1929
1965
  class RecallTool(Tool):
1930
1966
  NAME = "Recall"
@@ -2013,15 +2049,15 @@ class RecallTool(Tool):
2013
2049
 
2014
2050
  class NoteTool(Tool):
2015
2051
  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"]}',)
2052
+ DESCRIPTION = "Maintain durable working notes; goal, plan, and check replace current values, known appends unique facts."
2053
+ SIGNATURE = "Note(goal?, plan?, known?, check?)"
2054
+ EXAMPLE = ('Set memory. Example: {"goal":"ship parser fix","plan":["inspect parser","patch bug"],"known":["tests use pytest"],"check":"pytest passed"}',)
2019
2055
  STORES_RESULT = False
2020
2056
 
2021
2057
  @classmethod
2022
2058
  def params_schema(cls) -> Json:
2023
2059
  strings = {"type": "array", "items": {"type": "string"}, "minItems": 1}
2024
- return {"type": "object", "properties": {"goal": {"type": "string"}, "plan": strings, "known": strings}, "additionalProperties": False}
2060
+ return {"type": "object", "properties": {"goal": {"type": "string"}, "plan": strings, "known": strings, "check": {"type": "string"}}, "additionalProperties": False}
2025
2061
 
2026
2062
  @classmethod
2027
2063
  def payload_args(cls, payload: Json) -> list[Any]:
@@ -2031,7 +2067,7 @@ class NoteTool(Tool):
2031
2067
  if len(self.args) != 1 or not isinstance(self.args[0], dict):
2032
2068
  raise ToolError("Note requires named fields")
2033
2069
  data = self.args[0]
2034
- if unexpected := sorted(set(data) - {"goal", "plan", "known"}):
2070
+ if unexpected := sorted(set(data) - {"goal", "plan", "known", "check"}):
2035
2071
  raise ToolError("Note unexpected field: " + ", ".join(unexpected))
2036
2072
  changed = []
2037
2073
  if "goal" in data:
@@ -2047,8 +2083,11 @@ class NoteTool(Tool):
2047
2083
  raise ToolError("Note known must be an array")
2048
2084
  self.session.state.known = list(dict.fromkeys([*self.session.state.known, *(str(item).strip() for item in data["known"] if str(item).strip())]))
2049
2085
  changed.append("known")
2086
+ if "check" in data:
2087
+ self.session.state.check = str(data["check"]).strip()
2088
+ changed.append("check")
2050
2089
  if not changed:
2051
- raise ToolError("Note requires goal, plan, or known")
2090
+ raise ToolError("Note requires goal, plan, known, or check")
2052
2091
  return "Updated memory: " + ", ".join(changed)
2053
2092
 
2054
2093
  def short_args(self) -> list[str]:
@@ -2062,6 +2101,8 @@ class NoteTool(Tool):
2062
2101
  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
2102
  if known:
2064
2103
  lines.extend(["known:", *(f" + {item}" for item in known)])
2104
+ if check := str(data.get("check") or "").strip():
2105
+ lines.append("check -> " + Tool.compact(check, 120))
2065
2106
  return ["\n".join(lines) or "{}"]
2066
2107
 
2067
2108
 
@@ -2091,6 +2132,11 @@ class ToolCall:
2091
2132
  class ContextManager:
2092
2133
  COMPACT_TITLE: ClassVar[str] = "--- Prior Conversation Summary (compacted) ---"
2093
2134
  COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
2135
+ CODE_EXTENSIONS: ClassVar[set[str]] = set(
2136
+ ".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
2137
+ ".swift .toml .ts .tsx .vue .yaml .yml".split()
2138
+ )
2139
+ CODE_FILENAMES: ClassVar[set[str]] = {"CMakeLists.txt", "Dockerfile", "Makefile", "go.mod", "package.json", "pyproject.toml"}
2094
2140
 
2095
2141
  @dataclass
2096
2142
  class FileContextItem:
@@ -2123,28 +2169,35 @@ class ContextManager:
2123
2169
 
2124
2170
  def update_percent(self, messages: list[Json]) -> int:
2125
2171
  tokens = self.estimated_tokens(messages)
2126
- self.session.state.context_percent = min(100, round(tokens * 100 / self.session.settings.max_context_tokens))
2172
+ self.session.state.context_percent = min(100, tokens * 100 // self.session.settings.max_context_tokens)
2127
2173
  return self.session.state.context_percent
2128
2174
 
2129
2175
  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:
2176
+ if not self.over_budget(base_system, turn_messages):
2131
2177
  return
2132
2178
  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 [])])
2179
+ if compacted:
2180
+ try:
2181
+ self.apply_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
2182
+ except Exception:
2183
+ self.apply_compaction_fallback(keep, turn_messages)
2184
+ if turn_messages is not None and self.over_budget(base_system, turn_messages):
2185
+ compacted, keep = self.turn_compaction_parts(turn_messages)
2186
+ if compacted:
2187
+ try:
2188
+ self.apply_turn_compaction(model.compact(self.compaction_input(compacted)), keep, turn_messages)
2189
+ except Exception:
2190
+ self.apply_turn_compaction_fallback(keep, turn_messages)
2191
+
2192
+ def over_budget(self, base_system: str, turn_messages: list[Json] | None = None) -> bool:
2193
+ return self.estimated_tokens(self.model_messages(base_system, turn_messages)) >= self.session.settings.max_context_tokens
2142
2194
 
2143
2195
  def memory_context(self, *, with_date: bool = False) -> str:
2144
2196
  rows = [
2145
2197
  "Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
2146
2198
  "Plan:\n" + "\n".join(self.session.state.plan_rows() or ["- (empty; use Note for a short plan)"]),
2147
2199
  "Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
2200
+ "Check: " + (self.session.state.check or "(empty)"),
2148
2201
  ]
2149
2202
  if with_date:
2150
2203
  rows.append("Date: " + datetime.now().astimezone().strftime("%Y-%m-%d"))
@@ -2242,8 +2295,10 @@ class ContextManager:
2242
2295
  )
2243
2296
 
2244
2297
  paths = sorted((path for path in lines_by_path if lines_by_path[path]), key=lambda path: (-recent(path), path))
2298
+ code_edits = self.recent_code_edits()
2299
+ check_status = self.check_status(code_edits)
2245
2300
  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:
2301
+ if not paths and not omitted and not focus and not actions and not code_edits and not check_status and not errors:
2247
2302
  return ""
2248
2303
  chunks = ["Read/Edit outputs update this section. Treat listed ranges as current file state."] if paths else []
2249
2304
  if focus:
@@ -2254,6 +2309,10 @@ class ContextManager:
2254
2309
  chunks.extend(f"- {path} {start}:{end} current" for start, end in self.coverage(lines_by_path[path]))
2255
2310
  if actions:
2256
2311
  chunks.extend(["", "Recent file events:", *actions])
2312
+ if code_edits:
2313
+ chunks.extend(["", "Recent code edits:", *code_edits])
2314
+ if check_status:
2315
+ chunks.extend(["", "Check status:", *check_status])
2257
2316
  if errors:
2258
2317
  chunks.extend(["", "Recent tool errors:", *errors])
2259
2318
  if paths:
@@ -2267,7 +2326,6 @@ class ContextManager:
2267
2326
  chunks.append("Omitted content:")
2268
2327
  for path in sorted(omitted):
2269
2328
  chunks.extend(f"- {path} source={source} lines={count}" for source, count in sorted(omitted[path].items()))
2270
- chunks.extend(["", "OUTPUT IN USER LANGUAGE"])
2271
2329
  return "\n".join(chunks).strip() if len(chunks) > 4 else ""
2272
2330
 
2273
2331
  def recent_file_actions(self) -> list[str]:
@@ -2284,6 +2342,30 @@ class ContextManager:
2284
2342
  for record in self.session.tool_errors[-5:]
2285
2343
  ]
2286
2344
 
2345
+ def recent_code_edits(self) -> list[str]:
2346
+ rows: dict[str, str] = {}
2347
+ for record in self.session.tool_records[-20:]:
2348
+ if record.name == "Edit":
2349
+ for match in re.finditer(r'<Edit\s+path=(".*?")', record.output):
2350
+ try:
2351
+ path = str(json.loads(match.group(1)))
2352
+ except json.JSONDecodeError:
2353
+ continue
2354
+ if self.code_like_path(path):
2355
+ rows[path] = f"- {record.key} Edit {path}"
2356
+ return list(rows.values())[-8:]
2357
+
2358
+ def check_status(self, code_edits: list[str]) -> list[str]:
2359
+ if not code_edits:
2360
+ return []
2361
+ check = self.session.state.check.strip()
2362
+ return ["- " + check] if check else ["- Code changed recently. Use Note(check=...) after checks, or final must say checks not run."]
2363
+
2364
+ @classmethod
2365
+ def code_like_path(cls, path: str) -> bool:
2366
+ name = os.path.basename(path)
2367
+ return name in cls.CODE_FILENAMES or os.path.splitext(name)[1].lower() in cls.CODE_EXTENSIONS
2368
+
2287
2369
  def coverage(self, numbered: dict[int, tuple[str, str, str]]) -> list[tuple[int, int]]:
2288
2370
  numbers = sorted(numbered)
2289
2371
  if not numbers:
@@ -2320,7 +2402,7 @@ class ContextManager:
2320
2402
  return segments
2321
2403
 
2322
2404
  def compaction_input(self, messages: list[Json]) -> str:
2323
- older, recent = self.compaction_recent(messages)
2405
+ older, recent = self.compaction_parts_for(messages)
2324
2406
  return "\n\n".join(
2325
2407
  [
2326
2408
  "State:\n" + self.session.state.format(),
@@ -2334,7 +2416,14 @@ class ContextManager:
2334
2416
  index = self.latest_user_index(self.session.messages)
2335
2417
  return (self.session.messages, []) if index is None else (self.session.messages[:index], self.session.messages[index:])
2336
2418
 
2337
- def compaction_recent(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
2419
+ def turn_compaction_parts(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
2420
+ index = self.latest_user_index(messages)
2421
+ if index is None:
2422
+ return self.compaction_parts_for(messages)
2423
+ compacted, keep = self.compaction_parts_for(messages[index + 1 :])
2424
+ return compacted, messages[: index + 1] + keep
2425
+
2426
+ def compaction_parts_for(self, messages: list[Json]) -> tuple[list[Json], list[Json]]:
2338
2427
  cut = max(0, len(messages) - self.COMPACT_RECENT_MESSAGES)
2339
2428
  return messages[:cut], messages[cut:]
2340
2429
 
@@ -2347,6 +2436,24 @@ class ContextManager:
2347
2436
  self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
2348
2437
  self.prune_tool_records([*self.session.messages, *(tool_messages or [])])
2349
2438
 
2439
+ def apply_compaction_fallback(self, keep: list[Json], tool_messages: list[Json] | None = None) -> None:
2440
+ self.session.state.summary = (self.session.state.summary + "\nPrevious context was deterministically trimmed.").strip()
2441
+ summary = self.session.state.summary
2442
+ self.session.messages = ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep
2443
+ self.prune_tool_records([*keep, *(tool_messages or [])])
2444
+
2445
+ def apply_turn_compaction(self, data: Json, keep: list[Json], turn_messages: list[Json]) -> None:
2446
+ self.session.state.apply(data)
2447
+ summary = self.session.state.summary
2448
+ index = self.latest_user_index(keep)
2449
+ insert = len(keep) if index is None else index + 1
2450
+ turn_messages[:] = keep[:insert] + ([{"role": "user", "content": self.COMPACT_TITLE + "\n" + summary}] if summary else []) + keep[insert:]
2451
+ self.prune_tool_records([*self.session.messages, *turn_messages])
2452
+
2453
+ def apply_turn_compaction_fallback(self, keep: list[Json], turn_messages: list[Json]) -> None:
2454
+ self.session.state.summary = (self.session.state.summary + "\nCurrent turn context was deterministically trimmed.").strip()
2455
+ self.apply_turn_compaction({"summary": self.session.state.summary}, keep, turn_messages)
2456
+
2350
2457
  def prune_tool_records(self, keep_messages: list[Json]) -> None:
2351
2458
  records = self.session.tool_records
2352
2459
  keep = set(re.findall(r"\btr\.\d+\b", self.messages_text(keep_messages)))
@@ -2629,6 +2736,7 @@ class ToolRunner:
2629
2736
  self.input_fn = input_fn
2630
2737
  self.output_fn = output_fn
2631
2738
  self.preview_fn: Callable[[str], bool] | None = None
2739
+ self.preview_full_fn: Callable[[str], None] | None = None
2632
2740
  self.live_output: Callable[[str, str], None] | None = None
2633
2741
  self.live_start: Callable[[], None] | None = None
2634
2742
 
@@ -2783,8 +2891,11 @@ class ToolRunner:
2783
2891
  CodeIndex(self.session).update(list(dict.fromkeys(paths)))
2784
2892
 
2785
2893
  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))
2894
+ display = self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit, preview_lines=40)
2895
+ if self.preview_full_fn and tool.NAME == "Edit":
2896
+ self.preview_full_fn(self.approval_display(call, tool, "confirm", batch_suffix=batch_suffix, planned_edit=planned_edit, preview_lines=None))
2897
+ if not (self.preview_fn and self.preview_fn(display)):
2898
+ self.output_fn(display)
2788
2899
  answer = self.input_fn("[Y/n or reason] ").strip()
2789
2900
  lower = answer.lower()
2790
2901
  if lower in {"", "y", "yes"}:
@@ -2792,19 +2903,26 @@ class ToolRunner:
2792
2903
  return False, "" if lower in {"n", "no"} else answer
2793
2904
 
2794
2905
  def approval_display(
2795
- self, call: ToolCall, tool: Tool, status: str, batch_suffix: str = "", planned_edit: EditBatchPlan.PlannedEdit | None = None
2906
+ self,
2907
+ call: ToolCall,
2908
+ tool: Tool,
2909
+ status: str,
2910
+ batch_suffix: str = "",
2911
+ planned_edit: EditBatchPlan.PlannedEdit | None = None,
2912
+ preview_lines: int | None = 40,
2796
2913
  ) -> str:
2797
2914
  header = self.with_batch_suffix(("approve " if status == "confirm" else "auto ") + self.short_call(call), batch_suffix)
2798
2915
  if tool.NAME != "Edit":
2799
2916
  return header
2800
2917
  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 "")
2918
+ return header + (("\n" + block) if (block := self.preview_block(preview, max_lines=preview_lines)) else "")
2802
2919
 
2803
- def preview_block(self, preview: str, *, max_lines: int = 40) -> str:
2920
+ def preview_block(self, preview: str, *, max_lines: int | None = 40) -> str:
2804
2921
  lines = preview.rstrip().splitlines()
2805
2922
  if not lines:
2806
2923
  return ""
2807
- lines = lines[:max_lines] + (["... preview truncated ..."] if len(lines) > max_lines else [])
2924
+ if max_lines is not None and len(lines) > max_lines:
2925
+ lines = lines[:max_lines] + [f"... preview truncated: {len(lines) - max_lines} more lines (Ctrl-A: full preview) ..."]
2808
2926
  return "\n".join([" preview", *(" " + line for line in lines)])
2809
2927
 
2810
2928
  def finish_display(
@@ -2981,7 +3099,8 @@ class ModelClient:
2981
3099
  def compact(self, context: str) -> Json:
2982
3100
  prompt = """
2983
3101
  Compact the nanocode working context.
2984
- Return exactly one JSON object with keys: summary, goal, plan, known.
3102
+ Return one JSON object only. No markdown, prose, code fences, or comments.
3103
+ Use keys: summary, goal, plan, known.
2985
3104
  Rewrite recent conversation briefly inside summary.
2986
3105
  Keep only durable facts needed to continue; preserve file paths, symbols, constraints, and tr.N keys.
2987
3106
  """.strip()
@@ -2991,13 +3110,29 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
2991
3110
  if self.session.config.provider.resolved_api() == "anthropic"
2992
3111
  else self.chat_request(messages, None, activity="compact")
2993
3112
  )
2994
- content = content.strip()
2995
- content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE | re.DOTALL).strip()
2996
- data = json.loads(content)
3113
+ data = self.parse_json_object(content)
2997
3114
  if not isinstance(data, dict):
2998
3115
  raise ModelError("compactor returned non-object JSON")
2999
3116
  return data
3000
3117
 
3118
+ @classmethod
3119
+ def parse_json_object(cls, text: str) -> Json:
3120
+ text = cls.strip_json_fence(Text.clean(text).strip())
3121
+ if not text:
3122
+ raise ModelError("compactor returned empty output")
3123
+ try:
3124
+ data = json.loads(text)
3125
+ except json.JSONDecodeError:
3126
+ data = repair_json(text, return_objects=True)
3127
+ if isinstance(data, dict):
3128
+ return data
3129
+ raise ModelError("compactor returned invalid JSON: " + Tool.compact(text, 200))
3130
+
3131
+ @staticmethod
3132
+ def strip_json_fence(text: str) -> str:
3133
+ match = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, flags=re.IGNORECASE | re.DOTALL)
3134
+ return (match.group(1) if match else text).strip()
3135
+
3001
3136
  def client(self) -> OpenAI:
3002
3137
  provider = self.session.config.provider
3003
3138
  if missing := self.session.missing_config():
@@ -3238,13 +3373,13 @@ FLOW:
3238
3373
  - ACT when clear; keep using tools until done.
3239
3374
  - Every turn must call tools or return final; never emit empty content.
3240
3375
  - 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.
3376
+ - All assistant text is user-visible, including interim narration before tool calls and final answers.
3377
+ - Write every assistant text message as markdown in the latest user's language; do not switch languages unless asked.
3243
3378
 
3244
3379
  CONTEXT:
3245
3380
  - Trust LATEST FILE STATE from Read/Edit for listed ranges.
3246
3381
  - 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.
3382
+ - For multi-step work, call Note early with goal and a short plan; update plan/known/check when they change.
3248
3383
 
3249
3384
  EDITS:
3250
3385
  - Inspect/read before edits.
@@ -3256,7 +3391,8 @@ EDITS:
3256
3391
 
3257
3392
  FINAL:
3258
3393
  - Concise markdown in the user's language.
3259
- - Include changed files and checks when relevant.\
3394
+ - Include changed files and checks when relevant.
3395
+ - Mention checks run, or say checks not run.\
3260
3396
  """
3261
3397
 
3262
3398
  def __init__(self, session: Session, input_fn=input, output_fn=print):
@@ -3445,7 +3581,7 @@ class UiPrinter:
3445
3581
  return [("ansicyan", text + "\n")]
3446
3582
  if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
3447
3583
  return [("ansired", text + "\n")]
3448
- return self.text_segments(text)
3584
+ return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
3449
3585
 
3450
3586
  def tool_segments(self, text: str) -> list[tuple[str, str]]:
3451
3587
  segments = []
@@ -3562,10 +3698,6 @@ class UiPrinter:
3562
3698
  at_start = part.endswith("\n")
3563
3699
  return indented
3564
3700
 
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
3701
  class BashLivePreview:
3570
3702
  HEIGHT: ClassVar[int] = 6
3571
3703
  MAX_CHARS: ClassVar[int] = 8000
@@ -3737,15 +3869,13 @@ class StatusBar:
3737
3869
  self.output.flush()
3738
3870
  self.rendered = False
3739
3871
 
3740
- def elapsed(self) -> float:
3741
- return max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
3742
-
3743
3872
  def idle_fragments(self) -> list[tuple[str, str]]:
3744
3873
  return self.fragments(0.0, sweep=False, show_elapsed=False)
3745
3874
 
3746
3875
  def active_fragments(self) -> list[tuple[str, str]]:
3747
3876
  self.refresh_retry_state()
3748
- return self.fragments(self.elapsed(), sweep=True, show_elapsed=True)
3877
+ elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
3878
+ return self.fragments(elapsed, sweep=True, show_elapsed=True)
3749
3879
 
3750
3880
  def fragments(self, elapsed: float, *, sweep: bool, show_elapsed: bool) -> list[tuple[str, str]]:
3751
3881
  text = self.text(elapsed, show_elapsed=show_elapsed)
@@ -3870,6 +4000,7 @@ Tools:
3870
4000
  self.live_status_paused = False
3871
4001
  self.live_queue_paused = False
3872
4002
  self.transient_tool_lines = 0
4003
+ self.approval_full_preview = ""
3873
4004
  self.interactive_input = input_fn is input and sys.stdin.isatty()
3874
4005
  self.queue_input_paused = threading.Event()
3875
4006
  self.queue_input_active = threading.Event()
@@ -3888,6 +4019,7 @@ Tools:
3888
4019
  self.agent.tools.output_fn = self.tool_output
3889
4020
  self.agent.tools.input_fn = self.tool_input
3890
4021
  self.agent.tools.preview_fn = self.tool_preview
4022
+ self.agent.tools.preview_full_fn = lambda text: setattr(self, "approval_full_preview", text)
3891
4023
  self.agent.tools.live_start = self.tool_live_start
3892
4024
  self.agent.tools.live_output = self.tool_live_output
3893
4025
 
@@ -4094,7 +4226,14 @@ Tools:
4094
4226
  frame = "|/-\\"[int(time.monotonic() / 0.2) % 4]
4095
4227
  return [("class:approval", prompt_text), ("class:approval.wait", frame + " ")]
4096
4228
 
4097
- def read_input(self, prompt_text: str = "nano> ", *, multiline: bool = False, submit_on_enter: bool = False, prompt_style: str = "class:prompt") -> str:
4229
+ def read_input(
4230
+ self,
4231
+ prompt_text: str = "nano> ",
4232
+ *,
4233
+ multiline: bool = False,
4234
+ submit_on_enter: bool = False,
4235
+ prompt_style: str = "class:prompt",
4236
+ ) -> str:
4098
4237
  if self.input_history is None:
4099
4238
  return self.input_fn(prompt_text)
4100
4239
 
@@ -4150,6 +4289,10 @@ Tools:
4150
4289
  else:
4151
4290
  pt_search.start_search(direction=direction)
4152
4291
 
4292
+ @bindings.add("c-a", filter=Condition(lambda: bool(self.approval_full_preview)), eager=True)
4293
+ def _ctrl_a(event):
4294
+ run_in_terminal(self.open_approval_preview)
4295
+
4153
4296
  @bindings.add("tab")
4154
4297
  def _tab(event):
4155
4298
  if buffer.complete_state:
@@ -4209,7 +4352,12 @@ Tools:
4209
4352
  if text.startswith("approve ") and self.interactive_input and sys.stdout.isatty():
4210
4353
  self.with_status_paused(lambda: self.show_transient_tool_output(text))
4211
4354
  return
4212
- self.with_status_paused(lambda: self.emit_tool_output(text))
4355
+
4356
+ def emit() -> None:
4357
+ self.clear_transient_tool_output()
4358
+ self.emit(text)
4359
+
4360
+ self.with_status_paused(emit)
4213
4361
 
4214
4362
  def agent_output(self, text: str = "") -> None:
4215
4363
  self.with_status_paused(lambda: self.emit_agent_output(text))
@@ -4225,6 +4373,7 @@ Tools:
4225
4373
  finally:
4226
4374
  if self.interactive_input and sys.stdout.isatty():
4227
4375
  self.clear_transient_tool_output()
4376
+ self.approval_full_preview = ""
4228
4377
 
4229
4378
  return self.with_status_paused(read)
4230
4379
 
@@ -4239,20 +4388,46 @@ Tools:
4239
4388
  self.with_status_paused(lambda: self.show_transient_tool_preview(text))
4240
4389
  return True
4241
4390
 
4391
+ def open_approval_preview(self) -> None:
4392
+ if not self.approval_full_preview:
4393
+ return
4394
+ fd, path = tempfile.mkstemp(prefix="nanocode-preview-", suffix=".diff")
4395
+ try:
4396
+ pager_env = os.environ.get("PAGER", "")
4397
+ pager = shlex.split(pager_env) if pager_env else ([less, "-R"] if (less := shutil.which("less")) else [])
4398
+ text = self.ansi_diff_preview(self.approval_full_preview) if pager and os.path.basename(pager[0]) == "less" else self.approval_full_preview
4399
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
4400
+ file.write(text.rstrip() + "\n")
4401
+ if pager:
4402
+ subprocess.run([*pager, path])
4403
+ else:
4404
+ print(self.approval_full_preview)
4405
+ input("Press Enter to return...")
4406
+ finally:
4407
+ try:
4408
+ os.unlink(path)
4409
+ except OSError:
4410
+ pass
4411
+
4412
+ @staticmethod
4413
+ def ansi_diff_preview(text: str) -> str:
4414
+ colors = [("---", "\033[90m"), ("+++", "\033[90m"), ("@@", "\033[36m"), ("+", "\033[32m"), ("-", "\033[31m")]
4415
+ lines = []
4416
+ for line in text.splitlines():
4417
+ style = next((color for prefix, color in colors if line.lstrip().startswith(prefix)), "")
4418
+ lines.append(style + line + ("\033[0m" if style else ""))
4419
+ return "\n".join(lines)
4420
+
4242
4421
  def show_transient_tool_preview(self, text: str) -> None:
4243
4422
  self.clear_transient_tool_output()
4244
4423
  lines = text.rstrip().splitlines()
4245
4424
  if not lines:
4246
4425
  return
4247
4426
  height, width = 12, max(20, shutil.get_terminal_size((120, 20)).columns)
4248
- shown = lines[:height] + (["... preview truncated ..."] if len(lines) > height else [])
4427
+ shown = lines[:height] + ([f"... preview truncated: {len(lines) - height} more lines (Ctrl-A: full preview) ..."] if len(lines) > height else [])
4249
4428
  self.emit("\n".join(line[: max(0, width - 1)] for line in shown))
4250
4429
  self.transient_tool_lines = len(shown)
4251
4430
 
4252
- def emit_tool_output(self, text: str) -> None:
4253
- self.clear_transient_tool_output()
4254
- self.emit(text)
4255
-
4256
4431
  def emit_agent_output(self, text: str) -> None:
4257
4432
  self.clear_transient_tool_output()
4258
4433
  if self.ui.color and text.strip():
@@ -4651,16 +4826,20 @@ Tools:
4651
4826
  compacted, keep = self.agent.context.compaction_parts()
4652
4827
  if not compacted:
4653
4828
  return "No prior conversation to compact"
4829
+ fallback = False
4654
4830
  try:
4655
4831
  self.status_bar.start()
4656
4832
  data = self.agent.model.compact(self.agent.context.compaction_input(compacted))
4657
4833
  except KeyboardInterrupt:
4658
4834
  return "Cancelled"
4659
- except Exception as error:
4660
- return "Error: " + str(error)
4835
+ except Exception:
4836
+ self.agent.context.apply_compaction_fallback(keep)
4837
+ fallback = True
4838
+ data = None
4661
4839
  finally:
4662
4840
  self.status_bar.stop()
4663
- self.agent.context.apply_compaction(data, keep)
4841
+ if data is not None:
4842
+ self.agent.context.apply_compaction(data, keep)
4664
4843
  self.agent.context.update_percent(self.agent.context.model_messages(self.agent.SYSTEM_PROMPT))
4665
4844
  return (
4666
4845
  "Compacted context: messages "
@@ -4670,6 +4849,7 @@ Tools:
4670
4849
  + ", prior summary inserted, ctx "
4671
4850
  + str(self.session.state.context_percent)
4672
4851
  + "%"
4852
+ + (" (fallback)" if fallback else "")
4673
4853
  )
4674
4854
 
4675
4855
  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.10
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.10"
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