nanocode-cli 0.6.4__tar.gz → 0.6.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.6.4
3
+ Version: 0.6.6
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
@@ -240,7 +240,7 @@ model request
240
240
  | conversation, compacted summaries, tools |
241
241
  +--------------------------------------------------+
242
242
  | user |
243
- | Memory: goal, plan, known, date |
243
+ | Memory: goal, plan, known, code index, date |
244
244
  +--------------------------------------------------+
245
245
  | user |
246
246
  | FILE STATE: latest Read/Edit file view |
@@ -249,6 +249,7 @@ model request
249
249
 
250
250
  Core rules:
251
251
 
252
+ - Environment holds only stable host facts (cwd, os, arch, detected commands) so it stays byte-identical across turns and keeps the conversation prefix cacheable; volatile state like the code index status lives in the late Memory section instead.
252
253
  - Mid-turn assistant text and appended user input are kept as conversation.
253
254
  - Earlier conversation is compacted into an explicit summary when the context grows too large.
254
255
  - FILE STATE is updated by successful `Read` and `Edit` tools and shows current listed file ranges, with recent files first.
@@ -204,7 +204,7 @@ model request
204
204
  | conversation, compacted summaries, tools |
205
205
  +--------------------------------------------------+
206
206
  | user |
207
- | Memory: goal, plan, known, date |
207
+ | Memory: goal, plan, known, code index, date |
208
208
  +--------------------------------------------------+
209
209
  | user |
210
210
  | FILE STATE: latest Read/Edit file view |
@@ -213,6 +213,7 @@ model request
213
213
 
214
214
  Core rules:
215
215
 
216
+ - Environment holds only stable host facts (cwd, os, arch, detected commands) so it stays byte-identical across turns and keeps the conversation prefix cacheable; volatile state like the code index status lives in the late Memory section instead.
216
217
  - Mid-turn assistant text and appended user input are kept as conversation.
217
218
  - Earlier conversation is compacted into an explicit summary when the context grows too large.
218
219
  - FILE STATE is updated by successful `Read` and `Edit` tools and shows current listed file ranges, with recent files first.
@@ -41,7 +41,7 @@ from prompt_toolkit.buffer import Buffer
41
41
  from prompt_toolkit.completion import CompleteEvent, Completer, Completion
42
42
  from prompt_toolkit.document import Document
43
43
  from prompt_toolkit.filters import Condition, has_completions, is_done
44
- from prompt_toolkit.formatted_text import FormattedText
44
+ from prompt_toolkit.formatted_text import ANSI, FormattedText
45
45
  from prompt_toolkit.history import FileHistory
46
46
  from prompt_toolkit.key_binding import KeyBindings
47
47
  from prompt_toolkit.keys import Keys
@@ -59,7 +59,7 @@ from rich.console import Console
59
59
  from rich.markdown import Markdown
60
60
  from rich.rule import Rule
61
61
 
62
- __version__ = "0.6.4"
62
+ __version__ = "0.6.6"
63
63
 
64
64
  Json = dict[str, Any]
65
65
  HTTP_USER_AGENT = "nanocode/" + __version__
@@ -410,6 +410,7 @@ class ModelUsage:
410
410
  total_tokens: int = 0
411
411
  cached_prompt_tokens: int = 0
412
412
  last_total_tokens: int = 0
413
+ last_prompt_tokens: int = 0
413
414
  last_cached_prompt_tokens: int = 0
414
415
 
415
416
  def add(self, usage: Any) -> None:
@@ -436,6 +437,7 @@ class ModelUsage:
436
437
  self.total_tokens += total_tokens
437
438
  self.cached_prompt_tokens += cached_tokens
438
439
  self.last_total_tokens = total_tokens
440
+ self.last_prompt_tokens = prompt_tokens
439
441
  self.last_cached_prompt_tokens = cached_tokens
440
442
 
441
443
 
@@ -659,6 +661,15 @@ class MCPToolInfo:
659
661
  annotations: Json = field(default_factory=dict)
660
662
 
661
663
 
664
+ @dataclass
665
+ class MCPResourceInfo:
666
+ server: str
667
+ uri: str
668
+ name: str
669
+ description: str
670
+ mime_type: str = ""
671
+
672
+
662
673
  @dataclass
663
674
  class SystemInfo:
664
675
  COMMANDS: ClassVar[tuple[str, ...]] = (
@@ -773,6 +784,7 @@ class SessionSnapshotCodec:
773
784
  "total_tokens": usage.total_tokens,
774
785
  "cached_prompt_tokens": usage.cached_prompt_tokens,
775
786
  "last_cached_prompt_tokens": usage.last_cached_prompt_tokens,
787
+ "last_prompt_tokens": usage.last_prompt_tokens,
776
788
  "last_total_tokens": usage.last_total_tokens,
777
789
  }
778
790
 
@@ -858,6 +870,7 @@ class SessionSnapshotCodec:
858
870
  usage.total_tokens = data.get("total_tokens", 0)
859
871
  usage.cached_prompt_tokens = data.get("cached_prompt_tokens", 0)
860
872
  usage.last_cached_prompt_tokens = data.get("last_cached_prompt_tokens", 0)
873
+ usage.last_prompt_tokens = data.get("last_prompt_tokens", 0)
861
874
  usage.last_total_tokens = data.get("last_total_tokens", 0)
862
875
  return usage
863
876
 
@@ -2880,8 +2893,8 @@ class QuestionTool(Tool):
2880
2893
 
2881
2894
  class MCPTool(Tool):
2882
2895
  NAME = "MCP"
2883
- DESCRIPTION = "Call or describe external MCP server tools"
2884
- SIGNATURE = 'MCP(action="call"|"describe", server, tool, arguments={})'
2896
+ DESCRIPTION = "Call/describe external MCP server tools, and list/read MCP resources"
2897
+ SIGNATURE = 'MCP(action="call"|"describe"|"list_resources"|"read_resource", server, tool?, arguments?, uri?)'
2885
2898
  MUTATES = True
2886
2899
 
2887
2900
  @classmethod
@@ -2891,8 +2904,8 @@ class MCPTool(Tool):
2891
2904
  "properties": {
2892
2905
  "action": {
2893
2906
  "type": "string",
2894
- "enum": ["call", "describe"],
2895
- "description": '"call" invokes the tool; "describe" returns metadata',
2907
+ "enum": ["call", "describe", "list_resources", "read_resource"],
2908
+ "description": '"call" invokes a tool; "describe" returns a tool\'s schema; "list_resources" lists a server\'s resources; "read_resource" reads one by uri',
2896
2909
  },
2897
2910
  "server": {
2898
2911
  "type": "string",
@@ -2900,14 +2913,18 @@ class MCPTool(Tool):
2900
2913
  },
2901
2914
  "tool": {
2902
2915
  "type": "string",
2903
- "description": "Remote MCP tool name",
2916
+ "description": "Remote MCP tool name (required for call/describe)",
2904
2917
  },
2905
2918
  "arguments": {
2906
2919
  "type": "object",
2907
2920
  "description": "Arguments for the remote tool (required for call)",
2908
2921
  },
2922
+ "uri": {
2923
+ "type": "string",
2924
+ "description": "Resource URI (required for read_resource), e.g. scheme://path",
2925
+ },
2909
2926
  },
2910
- "required": ["action", "server", "tool"],
2927
+ "required": ["action", "server"],
2911
2928
  "additionalProperties": False,
2912
2929
  }
2913
2930
 
@@ -2920,21 +2937,35 @@ class MCPTool(Tool):
2920
2937
  raise ToolError("MCP requires named fields")
2921
2938
  return self.args[0]
2922
2939
 
2940
+ ACTIONS: ClassVar[tuple[str, ...]] = ("call", "describe", "list_resources", "read_resource")
2941
+
2942
+ @classmethod
2943
+ def resolved_action(cls, payload: Json) -> str:
2944
+ """Effective action; defaults to "call" when omitted but the envelope looks like an invocation."""
2945
+ action = str(payload.get("action") or "").strip()
2946
+ if action:
2947
+ return action
2948
+ if payload.get("tool") or payload.get("arguments") is not None:
2949
+ return "call"
2950
+ return action
2951
+
2923
2952
  def needs_confirmation(self) -> bool:
2924
2953
  payload = self.payload()
2925
- action = payload.get("action", "")
2926
- if action == "describe":
2954
+ if self.resolved_action(payload) != "call":
2927
2955
  return False
2928
- if action != "call" or self.session.mcp is None:
2956
+ if self.session.mcp is None:
2929
2957
  return False
2930
2958
  return self.session.mcp.tool_needs_confirmation(str(payload.get("server") or ""), str(payload.get("tool") or ""))
2931
2959
 
2932
2960
  def short_args(self) -> list[str]:
2933
2961
  payload = self.payload()
2934
- action = str(payload.get("action") or "")
2962
+ action = self.resolved_action(payload)
2935
2963
  server = str(payload.get("server") or "")
2936
2964
  tool_name = str(payload.get("tool") or "")
2937
- target = (server + "." + tool_name).strip(".")
2965
+ if action == "read_resource":
2966
+ target = (server + " " + str(payload.get("uri") or "")).strip()
2967
+ else:
2968
+ target = (server + "." + tool_name).strip(".")
2938
2969
  parts = [part for part in (action, target) if part]
2939
2970
  arguments = payload.get("arguments")
2940
2971
  if action == "call" and isinstance(arguments, dict) and arguments:
@@ -2948,7 +2979,7 @@ class MCPTool(Tool):
2948
2979
 
2949
2980
  def call(self) -> str:
2950
2981
  payload = self.payload()
2951
- action = payload.get("action", "")
2982
+ action = self.resolved_action(payload)
2952
2983
  server = payload.get("server", "")
2953
2984
  tool_name = payload.get("tool", "")
2954
2985
  arguments = payload.get("arguments", {})
@@ -2962,8 +2993,20 @@ class MCPTool(Tool):
2962
2993
  if action == "describe":
2963
2994
  return mcp.describe_tool(server, tool_name)
2964
2995
  if action == "call":
2965
- return mcp.call_tool(server, tool_name, arguments)
2966
- raise ToolError(f"unknown MCP action: {action}")
2996
+ prefix = mcp.auto_read_prefix(server, tool_name)
2997
+ try:
2998
+ output = mcp.call_tool(server, tool_name, arguments)
2999
+ except ToolError as error:
3000
+ raise ToolError(f"{error}\n\n{prefix}" if prefix else str(error)) from error
3001
+ return prefix + output if prefix else output
3002
+ if action == "list_resources":
3003
+ return mcp.list_resources(server)
3004
+ if action == "read_resource":
3005
+ return mcp.read_resource(server, str(payload.get("uri") or ""))
3006
+ raise ToolError(
3007
+ f"unknown MCP action {action!r}. Valid actions: {', '.join(self.ACTIONS)}. "
3008
+ f"To invoke a remote tool named {action!r}, use action=\"call\", tool={action!r}."
3009
+ )
2967
3010
 
2968
3011
  TOOLS: tuple[type[Tool], ...] = (
2969
3012
  MCPTool,
@@ -2993,9 +3036,7 @@ class ToolCall:
2993
3036
  class ContextManager:
2994
3037
  COMPACT_TITLE: ClassVar[str] = "--- Prior Conversation Summary (compacted) ---"
2995
3038
  COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
2996
- MCP_DETAILS_BUDGET: ClassVar[int] = 8_000
2997
- MCP_DETAILS_MAX_TOOLS: ClassVar[int] = 20
2998
- MCP_DETAILS_MAX_TOOL_CHARS: ClassVar[int] = 2_000
3039
+ MCP_DESCRIBE_BLOCK: ClassVar[re.Pattern] = re.compile(r"<MCPDescribe server=(\".*?\") tool=(\".*?\")>.*?</MCPDescribe>", re.DOTALL)
2999
3040
  CODE_EXTENSIONS: ClassVar[set[str]] = set(
3000
3041
  ".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
3001
3042
  ".swift .toml .ts .tsx .vue .yaml .yml".split()
@@ -3022,7 +3063,6 @@ class ContextManager:
3022
3063
  def model_messages(self, base_system: str, turn_messages: list[Json] | None = None) -> list[Json]:
3023
3064
  file_context = self.file_context() or "(empty)"
3024
3065
  mcp_tools = self.mcp_tools_context()
3025
- mcp_details = self.mcp_tool_details()
3026
3066
 
3027
3067
  messages: list[Json] = [
3028
3068
  {"role": "system", "content": base_system.strip()},
@@ -3032,75 +3072,51 @@ class ContextManager:
3032
3072
  if mcp_tools:
3033
3073
  messages.append({"role": "user", "content": mcp_tools})
3034
3074
 
3035
- messages.extend(self.session.messages)
3036
- messages.extend(turn_messages or [])
3075
+ messages.extend(self.dedup_mcp_describes([*self.session.messages, *(turn_messages or [])]))
3037
3076
  messages.append({"role": "user", "content": "--- Memory ---\n" + (self.memory_context(with_date=True) or "(empty)")})
3038
-
3039
- if mcp_details:
3040
- messages.append({"role": "user", "content": mcp_details})
3041
-
3042
3077
  messages.append({"role": "user", "content": "--- FILE STATE ---\n" + file_context})
3043
3078
  return Text.value(messages)
3044
3079
 
3080
+ def dedup_mcp_describes(self, messages: list[Json]) -> list[Json]:
3081
+ """Collapse repeated MCP describe results to a pointer, keeping the first per (server, tool).
3082
+
3083
+ Pure send-time transform — stored history is never mutated. The first describe of a tool keeps
3084
+ its full schema (and stays in the cached prefix); a later duplicate shrinks to a one-line pointer
3085
+ the moment it appears, so the sent prefix stays byte-stable across calls and we reclaim the
3086
+ repeated schema tokens. Only ever collapses the newer occurrence, never an earlier (cached) one;
3087
+ if the first occurrence is later compacted away, the next one is promoted to full on its own.
3088
+ """
3089
+ seen: dict[tuple[str, str], str] = {}
3090
+ result: list[Json] = []
3091
+ for message in messages:
3092
+ content = message.get("content")
3093
+ if message.get("role") != "tool" or not isinstance(content, str):
3094
+ result.append(message)
3095
+ continue
3096
+ match = self.MCP_DESCRIBE_BLOCK.search(content)
3097
+ if match is None:
3098
+ result.append(message)
3099
+ continue
3100
+ try:
3101
+ identity = (str(json.loads(match.group(1))), str(json.loads(match.group(2))))
3102
+ except (json.JSONDecodeError, ValueError):
3103
+ result.append(message)
3104
+ continue
3105
+ first_key = seen.get(identity)
3106
+ if first_key is None:
3107
+ key = re.search(r"\btr\.\d+\b", content)
3108
+ seen[identity] = key.group(0) if key else "above"
3109
+ result.append(message)
3110
+ continue
3111
+ marker = f"(repeat describe of {identity[0]}.{identity[1]}; schema shown earlier at {first_key}, unchanged)"
3112
+ result.append({**message, "content": self.MCP_DESCRIBE_BLOCK.sub(lambda _: marker, content)})
3113
+ return result
3114
+
3045
3115
  def mcp_tools_context(self) -> str:
3046
3116
  if self.session.mcp is None:
3047
3117
  return ""
3048
3118
  return self.session.mcp.render_tools_index()
3049
3119
 
3050
- def mcp_tool_details(self) -> str:
3051
- items = self.active_mcp_tool_details()
3052
- if not items:
3053
- return ""
3054
-
3055
- chunks: list[str] = []
3056
- chunks.append("--- MCP TOOL DETAILS ---")
3057
- chunks.append('Details previously requested with MCP(action="describe").')
3058
- chunks.append('Use MCP(action="call", server, tool, arguments) to call them.')
3059
- chunks.append("")
3060
-
3061
- for _source_order, server, tool, key, body in items:
3062
- detail_block = f"{server}.{tool} source={key}\n{body}"
3063
- chunks.append(detail_block)
3064
- chunks.append("")
3065
-
3066
- return "\n".join(chunks).strip()
3067
-
3068
- def active_mcp_tool_details(self) -> list[tuple[int, str, str, str, str]]:
3069
- details: dict[tuple[str, str], tuple[int, str, str]] = {}
3070
- for order, record in enumerate(self.session.tool_records):
3071
- if record.name != "MCP":
3072
- continue
3073
- for match in re.finditer(r'<MCPDescribe server=(".*?") tool=(".*?")>(.*?)</MCPDescribe>', record.output, re.DOTALL):
3074
- try:
3075
- server = str(json.loads(match.group(1)))
3076
- tool = str(json.loads(match.group(2)))
3077
- body = match.group(3).strip()
3078
- except (json.JSONDecodeError, ValueError):
3079
- continue
3080
- details[(server, tool)] = (order, record.key, body)
3081
-
3082
- items = sorted((order, server, tool, key, body) for (server, tool), (order, key, body) in details.items())
3083
- items = items[-self.MCP_DETAILS_MAX_TOOLS :]
3084
- active: list[tuple[int, str, str, str, str]] = []
3085
- total_chars = len("--- MCP TOOL DETAILS ---\n") + 160
3086
- truncated = False
3087
- for order, server, tool, key, body in items:
3088
- header = f"{server}.{tool} source={key}"
3089
- detail_block = f"{header}\n{body}"
3090
- if len(detail_block) > self.MCP_DETAILS_MAX_TOOL_CHARS:
3091
- body = body[: max(0, self.MCP_DETAILS_MAX_TOOL_CHARS - len(header) - 24)] + "\n... detail truncated"
3092
- detail_block = f"{header}\n{body}"
3093
- truncated = True
3094
- if total_chars + len(detail_block) + 1 > self.MCP_DETAILS_BUDGET:
3095
- truncated = True
3096
- break
3097
- active.append((order, server, tool, key, body))
3098
- total_chars += len(detail_block) + 1
3099
- if truncated and active:
3100
- order, server, tool, key, body = active[-1]
3101
- active[-1] = (order, server, tool, key, body + '\n... MCP tool details truncated; use MCP(action="describe", server, tool) again if needed.')
3102
- return active
3103
-
3104
3120
  def update_percent(self, messages: list[Json]) -> int:
3105
3121
  tokens = self.estimated_tokens(messages)
3106
3122
  self.session.state.context_percent = min(100, tokens * 100 // self.session.settings.max_context_tokens)
@@ -3127,11 +3143,14 @@ class ContextManager:
3127
3143
  return self.estimated_tokens(self.model_messages(base_system, turn_messages)) >= self.session.settings.max_context_tokens
3128
3144
 
3129
3145
  def memory_context(self, *, with_date: bool = False) -> str:
3146
+ index_status = self.session.state.code_index_status or "missing"
3147
+ index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
3130
3148
  rows = [
3131
3149
  "Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
3132
3150
  "Plan:\n" + "\n".join(self.session.state.plan_rows() or ["- (empty; use Note for a short plan)"]),
3133
3151
  "Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
3134
3152
  "Check: " + (self.session.state.check or "(empty)"),
3153
+ f"Code index: {index_status} (InspectCode usable: {index_usable})",
3135
3154
  ]
3136
3155
  if with_date:
3137
3156
  rows.append("Date: " + datetime.now().astimezone().strftime("%Y-%m-%d"))
@@ -3139,15 +3158,12 @@ class ContextManager:
3139
3158
 
3140
3159
  def environment(self) -> str:
3141
3160
  info = self.session.system_info
3142
- index_status = self.session.state.code_index_status or "missing"
3143
- index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
3144
3161
  rows = [
3145
3162
  f"- cwd: {info.cwd}",
3146
3163
  f"- os: {info.os}",
3147
3164
  f"- arch: {info.arch}",
3148
3165
  f"- shell_timeout: {self.session.settings.shell_timeout}s",
3149
3166
  "- detected_commands: " + (", ".join(info.commands) or "(none)"),
3150
- f"- code_index: {index_status} (InspectCode usable: {index_usable})",
3151
3167
  ]
3152
3168
  if branch := self.session.git_branch(self.session.cwd):
3153
3169
  rows.append(f"- git_branch: {branch}")
@@ -3413,8 +3429,6 @@ class ContextManager:
3413
3429
  for offset, record in enumerate(records):
3414
3430
  if record.name == "Edit" and any(path in mins and offset >= mins[path] for path in paths.get(record.key, [])):
3415
3431
  keep.add(record.key)
3416
- for _order, _server, _tool, key, _body in self.active_mcp_tool_details():
3417
- keep.add(key)
3418
3432
 
3419
3433
  self.session.tool_records = [record for record in records if record.key in keep][-400:]
3420
3434
  self.session.tool_results = {record.key: record.output for record in self.session.tool_records}
@@ -3689,10 +3703,14 @@ class MCPManager:
3689
3703
  DESCRIBE_DESCRIPTION_LIMIT: ClassVar[int] = 1_000
3690
3704
  DESCRIBE_ARGUMENT_LIMIT: ClassVar[int] = 50
3691
3705
  DESCRIBE_ARGUMENT_DESCRIPTION_LIMIT: ClassVar[int] = 160
3706
+ INDEX_SCHEMA_LIMIT: ClassVar[int] = 700 # per-tool schema cap in the early (cached) tools index
3707
+ INDEX_TOTAL_LIMIT: ClassVar[int] = 16_000 # overall cap for the tools index block
3692
3708
 
3693
3709
  def __init__(self, session: Session):
3694
3710
  self.session = session
3695
3711
  self.tools: dict[str, list[MCPToolInfo]] = {}
3712
+ self.resources: dict[str, list[MCPResourceInfo]] = {}
3713
+ self._auto_read_done: set[tuple[str, str]] = set()
3696
3714
  self.server_errors: dict[str, str] = {}
3697
3715
  self.server_skips: dict[str, str] = {}
3698
3716
  self.lock = threading.Lock()
@@ -3802,6 +3820,8 @@ class MCPManager:
3802
3820
 
3803
3821
  def _forget_locked(self, name: str) -> None:
3804
3822
  self.tools.pop(name, None)
3823
+ self.resources.pop(name, None)
3824
+ self._auto_read_done = {entry for entry in self._auto_read_done if entry[0] != name}
3805
3825
  self.server_errors.pop(name, None)
3806
3826
  self.server_skips.pop(name, None)
3807
3827
 
@@ -3854,22 +3874,31 @@ class MCPManager:
3854
3874
  self.set_server_error(config.name, headers)
3855
3875
  return
3856
3876
 
3877
+ if config.auth == "oauth" and not self.oauth_token_store().has_server_tokens(config.url):
3878
+ self.set_server_error(config.name, "oauth login required; run /mcp login " + config.name)
3879
+ return
3857
3880
  try:
3858
- if config.auth == "oauth":
3859
- if not self.oauth_token_store().has_server_tokens(config.url):
3860
- self.set_server_error(config.name, "oauth login required; run /mcp login " + config.name)
3861
- return
3862
- tools = self.run_async(self._list_oauth_tools(config, headers))
3863
- else:
3864
- tools = self.run_async(self._list_tools(config, headers))
3865
- tools_info = self._tools_info(config.name, tools)
3881
+ tools, resources = self.run_async(self._gather_assets(config, headers))
3866
3882
  with self.lock:
3867
- self.tools[config.name] = tools_info
3883
+ self.tools[config.name] = self._tools_info(config.name, tools)
3884
+ self.resources[config.name] = self._resources_info(config.name, resources)
3868
3885
  self.server_errors.pop(config.name, None)
3869
3886
  self.server_skips.pop(config.name, None)
3870
3887
  except Exception as e:
3871
3888
  self.set_server_error(config.name, self.error_text(e, timeout=self.discovery_timeout()))
3872
3889
 
3890
+ async def _gather_assets(self, config: MCPServerConfig, headers: dict[str, str]) -> tuple[Any, list[Any]]:
3891
+ """Fetch tools and resources concurrently. Tool failure aborts discovery; resources are best-effort."""
3892
+ oauth = config.auth == "oauth"
3893
+ tools_co = self._list_oauth_tools(config, headers) if oauth else self._list_tools(config, headers)
3894
+ resources_co = self._list_oauth_resources(config, headers) if oauth else self._list_resources(config, headers)
3895
+ tools, resources = await asyncio.gather(tools_co, resources_co, return_exceptions=True)
3896
+ if isinstance(tools, BaseException):
3897
+ raise tools
3898
+ if isinstance(resources, BaseException):
3899
+ resources = []
3900
+ return tools, resources
3901
+
3873
3902
  def set_server_error(self, name: str, error: str) -> None:
3874
3903
  with self.lock:
3875
3904
  self._forget_locked(name)
@@ -3908,6 +3937,26 @@ class MCPManager:
3908
3937
  for t in tools
3909
3938
  ]
3910
3939
 
3940
+ def _resources_info(self, server: str, resources: Any) -> list[MCPResourceInfo]:
3941
+ infos: list[MCPResourceInfo] = []
3942
+ for r in resources or []:
3943
+ uri = str(getattr(r, "uri", "") or "")
3944
+ if not uri:
3945
+ continue
3946
+ infos.append(
3947
+ MCPResourceInfo(
3948
+ server=server,
3949
+ uri=uri,
3950
+ name=str(getattr(r, "name", "") or ""),
3951
+ description=str(getattr(r, "description", "") or ""),
3952
+ mime_type=str(getattr(r, "mimeType", "") or ""),
3953
+ )
3954
+ )
3955
+ return infos
3956
+
3957
+ def resource_info(self, server: str, uri: str) -> MCPResourceInfo | None:
3958
+ return next((res for res in self.resources.get(server, []) if res.uri == uri), None)
3959
+
3911
3960
  @staticmethod
3912
3961
  def tool_annotations(tool: Any) -> Json:
3913
3962
  annotations = getattr(tool, "annotations", None)
@@ -4061,6 +4110,109 @@ class MCPManager:
4061
4110
  text = self.normalize_result(result)
4062
4111
  return f"<MCPCall server={json.dumps(server)} tool={json.dumps(tool_name)}>\n{text}\n</MCPCall>"
4063
4112
 
4113
+ def _resource_preamble(self, server: str) -> tuple[MCPServerConfig, dict[str, str]]:
4114
+ config = self.find_config(server)
4115
+ if config is None:
4116
+ raise ToolError(f"MCP server '{server}' not found")
4117
+ if config.error:
4118
+ raise ToolError(config.error)
4119
+ headers = self._build_mcp_headers(config)
4120
+ if isinstance(headers, str):
4121
+ raise ToolError(headers)
4122
+ if config.auth == "oauth" and not self.oauth_token_store().has_server_tokens(config.url):
4123
+ raise ToolError(f"MCP server '{server}' requires OAuth login; run /mcp login {server}")
4124
+ if server not in self.tools and server not in self.resources:
4125
+ self.discover_server(server)
4126
+ if server in self.server_errors:
4127
+ raise ToolError(f"MCP server '{server}' error: {self.server_errors[server]}")
4128
+ return config, headers
4129
+
4130
+ def list_resources(self, server: str) -> str:
4131
+ self._resource_preamble(server)
4132
+ resources = self.resources.get(server, [])
4133
+ lines = [f"<MCPResources server={json.dumps(server)}>"]
4134
+ if resources:
4135
+ lines.extend(self._format_resource_line(res) for res in resources)
4136
+ else:
4137
+ lines.append("(no resources advertised by this server)")
4138
+ lines.append("</MCPResources>")
4139
+ return "\n".join(lines)
4140
+
4141
+ def read_resource(self, server: str, uri: str) -> str:
4142
+ if not uri:
4143
+ raise ToolError("MCP read_resource requires a uri")
4144
+ config, headers = self._resource_preamble(server)
4145
+ try:
4146
+ result = self.run_async(
4147
+ self._read_oauth_resource(config, headers, uri) if config.auth == "oauth" else self._read_resource(config, headers, uri)
4148
+ )
4149
+ except Exception as e:
4150
+ raise ToolError("MCP resource read failed: " + self.error_text(e))
4151
+ text = self.normalize_resource(result)
4152
+ return f"<MCPResource server={json.dumps(server)} uri={json.dumps(uri)}>\n{text}\n</MCPResource>"
4153
+
4154
+ AUTO_READ_LIMIT: ClassVar[int] = 6_000 # per-doc cap for resources auto-injected on first tool call
4155
+
4156
+ def auto_read_prefix(self, server: str, tool_name: str) -> str:
4157
+ """On the first call to a tool whose description references a resource doc, fetch it once.
4158
+
4159
+ Returns a block to attach to that call's result (so the grammar reaches the model on the
4160
+ first attempt and lands in cached history), or "" when there is nothing new to inject.
4161
+ Best-effort: failures are swallowed and never retried for the same uri.
4162
+ """
4163
+ info = self.tool_info(server, tool_name)
4164
+ if info is None:
4165
+ return ""
4166
+ advertised = {res.uri for res in self.resources.get(server, [])}
4167
+ blocks: list[str] = []
4168
+ for uri in self._extract_uris(info.description):
4169
+ if (server, uri) in self._auto_read_done:
4170
+ continue
4171
+ scheme = uri.split("://", 1)[0].lower()
4172
+ # Only fetch things we can actually read over MCP: advertised resources or custom
4173
+ # (non-web) schemes. Plain http(s) links are left for the model to read explicitly.
4174
+ if uri not in advertised and scheme in ("http", "https"):
4175
+ continue
4176
+ self._auto_read_done.add((server, uri)) # mark before fetching so failures don't retry
4177
+ try:
4178
+ blocks.append(self.read_resource(server, uri)[: self.AUTO_READ_LIMIT])
4179
+ except Exception:
4180
+ continue
4181
+ if not blocks:
4182
+ return ""
4183
+ body = "\n".join(blocks)
4184
+ return f'<MCPAutoResources note="docs referenced by {server}.{tool_name}; injected once">\n{body}\n</MCPAutoResources>\n'
4185
+
4186
+ def normalize_resource(self, result: Any) -> str:
4187
+ items = result if isinstance(result, list) else [result]
4188
+ parts: list[str] = []
4189
+ for item in items:
4190
+ text = getattr(item, "text", None)
4191
+ if text:
4192
+ parts.append(str(text))
4193
+ continue
4194
+ blob = getattr(item, "blob", None)
4195
+ if blob is not None:
4196
+ mime = str(getattr(item, "mimeType", "") or "application/octet-stream")
4197
+ parts.append(f"<binary mimeType={json.dumps(mime)} bytes={len(blob)}/>")
4198
+ continue
4199
+ if hasattr(item, "model_dump"):
4200
+ parts.append(json.dumps(item.model_dump(mode="json"), ensure_ascii=False, indent=2))
4201
+ continue
4202
+ parts.append(str(item))
4203
+ text = "\n".join(part for part in parts if part).strip()
4204
+ if len(text) > self.RAW_OUTPUT_LIMIT:
4205
+ text = text[: self.RAW_OUTPUT_LIMIT] + f"\n<MCPOutputTruncated chars={json.dumps(len(text))}/>"
4206
+ return text
4207
+
4208
+ def _format_resource_line(self, info: MCPResourceInfo) -> str:
4209
+ desc = " ".join((info.description or "").split())
4210
+ if len(desc) > 100:
4211
+ desc = desc[:97] + "..."
4212
+ mime = f" [{info.mime_type}]" if info.mime_type else ""
4213
+ label = f"{info.uri}{mime}"
4214
+ return f"- {label} - {desc}" if desc else f"- {label}"
4215
+
4064
4216
  def normalize_result(self, result: Any) -> str:
4065
4217
  parts: list[str] = []
4066
4218
  content = getattr(result, "content", result)
@@ -4099,6 +4251,36 @@ class MCPManager:
4099
4251
  async with Client(self._transport(config, headers), timeout=timeout, init_timeout=timeout) as client:
4100
4252
  return await asyncio.wait_for(client.call_tool(name, arguments), timeout=timeout)
4101
4253
 
4254
+ async def _list_resources(self, config: MCPServerConfig, headers: dict[str, str]) -> list[Any]:
4255
+ from fastmcp.client import Client
4256
+
4257
+ timeout = self.discovery_timeout()
4258
+ async with Client(self._transport(config, headers), timeout=timeout, init_timeout=timeout) as client:
4259
+ return await asyncio.wait_for(client.list_resources(), timeout=timeout)
4260
+
4261
+ async def _list_oauth_resources(self, config: MCPServerConfig, headers: dict[str, str]) -> list[Any]:
4262
+ from fastmcp.client import Client
4263
+ from fastmcp.client.transports import StreamableHttpTransport
4264
+
4265
+ timeout = self.discovery_timeout()
4266
+ async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
4267
+ return await asyncio.wait_for(client.list_resources(), timeout=timeout)
4268
+
4269
+ async def _read_resource(self, config: MCPServerConfig, headers: dict[str, str], uri: str) -> Any:
4270
+ from fastmcp.client import Client
4271
+
4272
+ timeout = self.call_timeout()
4273
+ async with Client(self._transport(config, headers), timeout=timeout, init_timeout=timeout) as client:
4274
+ return await asyncio.wait_for(client.read_resource(uri), timeout=timeout)
4275
+
4276
+ async def _read_oauth_resource(self, config: MCPServerConfig, headers: dict[str, str], uri: str) -> Any:
4277
+ from fastmcp.client import Client
4278
+ from fastmcp.client.transports import StreamableHttpTransport
4279
+
4280
+ timeout = self.call_timeout()
4281
+ async with Client(StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout) as client:
4282
+ return await asyncio.wait_for(client.read_resource(uri), timeout=timeout)
4283
+
4102
4284
  async def _call_oauth_tool(self, config: MCPServerConfig, headers: dict[str, str], name: str, arguments: Json) -> Any:
4103
4285
  from fastmcp.client import Client
4104
4286
  from fastmcp.client.transports import StreamableHttpTransport
@@ -4198,15 +4380,10 @@ class MCPManager:
4198
4380
  desc = Tool.compact(str(prop.get("description", "") or ""), self.DESCRIBE_ARGUMENT_DESCRIPTION_LIMIT)
4199
4381
  lines.append(f"- {name} {req} {typ}: {desc}")
4200
4382
  lines.append("</arguments>")
4201
- lines.append("<schema_summary>")
4202
- if props:
4203
- summary = f"Top-level object with {', '.join(props.keys())}."
4204
- else:
4205
- summary = "No arguments."
4206
- if len(summary) > 200:
4207
- summary = summary[:197] + "..."
4208
- lines.append(summary)
4209
- lines.append("</schema_summary>")
4383
+ if isinstance(schema, dict) and schema:
4384
+ lines.append("<schema>")
4385
+ lines.append(json.dumps(schema, ensure_ascii=False, indent=2))
4386
+ lines.append("</schema>")
4210
4387
  lines.append("</MCPDescribe>")
4211
4388
  return "\n".join(lines)
4212
4389
 
@@ -4218,14 +4395,17 @@ class MCPManager:
4218
4395
  lines: list[str] = []
4219
4396
  lines.append("--- MCP TOOLS ---")
4220
4397
  lines.append('Use MCP(action="call", server, tool, arguments) for external MCP server tools.')
4221
- lines.append('Use MCP(action="describe", server, tool) for full details when needed.')
4398
+ lines.append('Use MCP(action="describe", server, tool) for the full schema when one is truncated below; the result stays in the conversation, so do not describe the same tool again once its schema is shown — just call it.')
4399
+ lines.append('Use MCP(action="read_resource", server, uri) to read a listed resource (e.g. docs describing how to build a tool\'s arguments). Read relevant resources before calling.')
4222
4400
  lines.append("Format: server.tool(req: type; opt: type) - description")
4401
+ lines.append(" schema: <JSON Schema for the arguments object>")
4223
4402
  lines.append("")
4224
4403
 
4225
4404
  pending: list[str] = []
4226
4405
  for config in configs:
4227
4406
  tools = self.tools.get(config.name, [])
4228
- if not tools:
4407
+ resources = self.resources.get(config.name, [])
4408
+ if not tools and not resources:
4229
4409
  pending.append(f"- {config.name}: {self._pending_status(config.name)}")
4230
4410
  continue
4231
4411
  name_display = config.name.capitalize()
@@ -4234,6 +4414,9 @@ class MCPManager:
4234
4414
  line = self._format_tool_line(config.name, tool)
4235
4415
  if line:
4236
4416
  lines.append(line)
4417
+ if resources:
4418
+ lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(config.name)}, uri=...):")
4419
+ lines.extend(self._format_resource_line(res) for res in resources)
4237
4420
  lines.append("")
4238
4421
 
4239
4422
  if pending:
@@ -4242,8 +4425,8 @@ class MCPManager:
4242
4425
  lines.append("")
4243
4426
 
4244
4427
  text = "\n".join(lines)
4245
- if len(text) > 4000:
4246
- text = text[:3990] + "\n... MCP tools truncated; use /mcp tools for full list."
4428
+ if len(text) > self.INDEX_TOTAL_LIMIT:
4429
+ text = text[: self.INDEX_TOTAL_LIMIT - 10] + "\n... MCP tools truncated; use /mcp tools for full list."
4247
4430
  return text
4248
4431
 
4249
4432
  def server_issue(self, name: str) -> tuple[str, str] | None:
@@ -4260,6 +4443,8 @@ class MCPManager:
4260
4443
  return message if kind == "error" else "skipped: " + message
4261
4444
  if self.discovery_status == "discovering":
4262
4445
  return "discovering — tools not loaded yet; retry shortly"
4446
+ if name in self.tools:
4447
+ return "connected; no tools or resources advertised"
4263
4448
  return "not connected"
4264
4449
 
4265
4450
  MENTION_PATTERN = re.compile(r"@([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]+))?")
@@ -4312,6 +4497,10 @@ class MCPManager:
4312
4497
  line = self._format_tool_line(server, info)
4313
4498
  if line:
4314
4499
  lines.append(line)
4500
+ resources = self.resources.get(server, [])
4501
+ if resources:
4502
+ lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(server)}, uri=...):")
4503
+ lines.extend(self._format_resource_line(res) for res in resources)
4315
4504
  return "\n".join(lines)
4316
4505
 
4317
4506
  def _format_tool_line(self, server: str, info: MCPToolInfo) -> str:
@@ -4324,8 +4513,40 @@ class MCPManager:
4324
4513
  line = f"{server}.{info.name}{args_str} - {desc}"
4325
4514
  if len(line) > 200:
4326
4515
  line = line[:197] + "..."
4516
+ # The full description (often naming a resource doc with the argument grammar) is
4517
+ # truncated above, so surface any resource-like URIs it mentions explicitly.
4518
+ uris = self._extract_uris(info.description)
4519
+ if uris:
4520
+ line += "\n refs (read with MCP action=\"read_resource\"): " + ", ".join(uris)
4521
+ schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
4522
+ if schema:
4523
+ line += f"\n schema: {schema}"
4327
4524
  return line
4328
4525
 
4526
+ URI_PATTERN: ClassVar[re.Pattern] = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>)\]}]+")
4527
+
4528
+ @classmethod
4529
+ def _extract_uris(cls, text: str, limit: int = 5) -> list[str]:
4530
+ """Pull resource-like URIs out of free text, deduped and lightly de-punctuated."""
4531
+ seen: list[str] = []
4532
+ for match in cls.URI_PATTERN.findall(text or ""):
4533
+ uri = match.rstrip(".,;:")
4534
+ if uri not in seen:
4535
+ seen.append(uri)
4536
+ if len(seen) >= limit:
4537
+ break
4538
+ return seen
4539
+
4540
+ @staticmethod
4541
+ def _schema_json(schema: Json, limit: int) -> str:
4542
+ """Render a remote tool's input schema as compact JSON, capped at `limit` chars (0 = no cap)."""
4543
+ if not isinstance(schema, dict) or not schema:
4544
+ return ""
4545
+ text = json.dumps(schema, ensure_ascii=False, separators=(",", ":"))
4546
+ if limit and len(text) > limit:
4547
+ text = text[: limit - 1].rstrip() + "… (truncated; MCP describe for full schema)"
4548
+ return text
4549
+
4329
4550
  def _tool_args_summary(self, info: MCPToolInfo) -> str:
4330
4551
  schema = info.input_schema or {}
4331
4552
  props = schema.get("properties", {})
@@ -4510,9 +4731,16 @@ class ToolRunner:
4510
4731
  if self.session.settings.debug:
4511
4732
  return self.finish(call, output, failed=True, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
4512
4733
  self.session.record_tool_error("-", call.name, call.args, output)
4513
- self.output_fn(self.finish_display(call, "", output, failed=True, display=display, batch_suffix=batch_suffix))
4734
+ self.output_fn(self.reject_display(call, output, display=display, batch_suffix=batch_suffix))
4514
4735
  return self.tool_message(call, "", output, failed=True, display=display)
4515
4736
 
4737
+ def reject_display(self, call: ToolCall, output: str, *, display: str | None = None, batch_suffix: str = "") -> str:
4738
+ # Argument/usage rejections are usually self-corrected on retry, so show a quiet one-liner
4739
+ # (rendered dim by UiPrinter) instead of the full red failed block. Full error still goes to
4740
+ # the model and to debug.
4741
+ reason = self.oneline(output.removeprefix("ToolError:").strip(), 60)
4742
+ return self.with_batch_suffix("tool " + (display or self.short_call(call)) + " · rejected: " + reason, batch_suffix)
4743
+
4516
4744
  def finish(
4517
4745
  self,
4518
4746
  call: ToolCall,
@@ -4542,8 +4770,6 @@ class ToolRunner:
4542
4770
  head = "tool " + ((key + " ") if key else ("- " if failed else "")) + (display or self.short_call(call))
4543
4771
  if not failed and call.name in {"Read", "Edit"}:
4544
4772
  return head + " -> FILE STATE"
4545
- if not failed and call.name == "MCP" and "<MCPDescribe " in output:
4546
- return head + " -> MCP TOOL DETAILS"
4547
4773
  rows = [head]
4548
4774
  if failed:
4549
4775
  rows.append("status: failed")
@@ -5093,7 +5319,7 @@ You are nanocode, a concise terminal coding agent.
5093
5319
  TOOLS:
5094
5320
  - Available: Read LineCount List Find InspectCode Search Edit Bash Git Recall Note Question MCP.
5095
5321
  - Use exact tool names and named parameters; obey each tool DESCRIPTION/SIGNATURE.
5096
- - Files/code: Read/LineCount/List inspect files; Find/Search locate paths/text; InspectCode navigates symbols when available.
5322
+ - Files/code: Read/LineCount/List inspect files; Find/Search locate paths/text; prefer InspectCode over Search for symbols (defs, refs, impls, callers/callees, outline) when code_index is usable.
5097
5323
  - Changes/commands: Edit writes files; Git handles git; Bash is fallback when built-ins do not fit.
5098
5324
  - State/external: Recall retrieves tr.N outputs; Note maintains goal/plan/known/check; MCP calls configured external tools.
5099
5325
  - Restraint: Before calling "Question", make progress with other tools first; only ask when genuinely blocked, and batch related questions into one call.
@@ -5112,9 +5338,8 @@ FILE STATE:
5112
5338
  - Read and Edit refresh FILE STATE; after Edit, trust the edited range.
5113
5339
 
5114
5340
  FINAL:
5115
- - Concise markdown in the user's language.
5116
- - Include changed files and checks when relevant.
5117
- - Mention checks run, or say checks not run.\
5341
+ - Default to a few lines; scale to the task. Lead with the result; no preamble or recap.
5342
+ - Concise markdown in the user's language; note changed files and checks run (or not run).\
5118
5343
  """
5119
5344
 
5120
5345
  def __init__(self, session: Session, input_fn=input, output_fn=print):
@@ -5309,6 +5534,9 @@ class UiPrinter:
5309
5534
  self.output_fn = output_fn
5310
5535
  self.color = output_fn is print and sys.stdout.isatty()
5311
5536
  self.console = Console() if self.color else None
5537
+ # When set, render Rich answers to an ANSI string and emit via prompt_toolkit, so
5538
+ # answers printed from inside a running prompt app (queue input) aren't mangled by patch_stdout.
5539
+ self.capture_ansi = False
5312
5540
 
5313
5541
  def emit(self, text: str = "") -> None:
5314
5542
  if not self.color:
@@ -5321,6 +5549,13 @@ class UiPrinter:
5321
5549
  self.emit(text)
5322
5550
  return
5323
5551
  assert self.console is not None
5552
+ if self.capture_ansi:
5553
+ console = Console(force_terminal=True, width=shutil.get_terminal_size().columns)
5554
+ with console.capture() as capture:
5555
+ console.print(Rule(style="bright_black", characters="─"))
5556
+ console.print(Markdown(text))
5557
+ print_formatted_text(ANSI(capture.get()), end="", flush=True)
5558
+ return
5324
5559
  self.console.print(Rule(style="bright_black", characters="─"))
5325
5560
  self.console.print(Markdown(text))
5326
5561
 
@@ -5344,7 +5579,9 @@ class UiPrinter:
5344
5579
  def tool_segments(self, text: str) -> list[tuple[str, str]]:
5345
5580
  segments = []
5346
5581
  for line in text.splitlines() or [""]:
5347
- if line.startswith("tool "):
5582
+ if line.startswith("tool ") and " · rejected:" in line:
5583
+ segments.append(("ansibrightblack", line))
5584
+ elif line.startswith("tool "):
5348
5585
  body = line[5:]
5349
5586
  call, sep, tail = body.partition(" -> ")
5350
5587
  failed = body.endswith(" [failed]") or body.endswith(" [refused]")
@@ -5768,6 +6005,9 @@ class StatusBar:
5768
6005
 
5769
6006
 
5770
6007
  class CommandLoop:
6008
+ # Commands safe to run from the background queue-input thread while the agent works: read-only
6009
+ # views plus /yolo, whose single atomic flag flip the agent simply reads at the next approval.
6010
+ QUEUE_RUN_COMMANDS: ClassVar[frozenset[str]] = frozenset({"/help", "/status", "/memory", "/mcp", "/yolo"})
5771
6011
  MODEL_CONFIGURED_LABEL = "---- Configured models ----"
5772
6012
  MODEL_DISCOVERED_LABEL = "---- Discovered models ----"
5773
6013
  MODEL_LABELS = frozenset((MODEL_CONFIGURED_LABEL, MODEL_DISCOVERED_LABEL))
@@ -5873,7 +6113,13 @@ Tools:
5873
6113
  def changed(buffer: Buffer) -> None:
5874
6114
  self.queue_input_text = buffer.text
5875
6115
 
5876
- buffer = Buffer(document=Document(self.queue_input_text), multiline=False, on_text_changed=changed)
6116
+ buffer = Buffer(
6117
+ document=Document(self.queue_input_text),
6118
+ multiline=False,
6119
+ on_text_changed=changed,
6120
+ completer=self.input_completer,
6121
+ complete_while_typing=False,
6122
+ )
5877
6123
  control = BufferControl(buffer=buffer, input_processors=[BeforeInput(prompt)])
5878
6124
  input_window = Window(control, height=1, dont_extend_height=True, wrap_lines=False)
5879
6125
  bindings = KeyBindings()
@@ -5882,11 +6128,15 @@ Tools:
5882
6128
  texts = [Text.clean(text.strip()) for text in texts if text.strip()]
5883
6129
  if not texts:
5884
6130
  return
5885
- self.session.pending_user_inputs.extend(texts)
6131
+ queued = [text for text in texts if not text.startswith("/")]
6132
+ commands = [text for text in texts if text.startswith("/")]
6133
+ self.session.pending_user_inputs.extend(queued)
5886
6134
 
5887
6135
  def show() -> None:
5888
- for text in texts:
6136
+ for text in queued:
5889
6137
  self.emit("+ " + text)
6138
+ for text in commands:
6139
+ self.run_queued_command(text)
5890
6140
 
5891
6141
  run_in_terminal(show)
5892
6142
 
@@ -5907,6 +6157,21 @@ Tools:
5907
6157
  self.session.state.model_retry_count += 1
5908
6158
  os.kill(os.getpid(), signal.SIGINT)
5909
6159
 
6160
+ @bindings.add("tab")
6161
+ def _tab(event):
6162
+ if buffer.complete_state:
6163
+ buffer.complete_next()
6164
+ return
6165
+ completions = list(self.input_completer.get_completions(buffer.document, CompleteEvent(completion_requested=True)))
6166
+ if len(completions) == 1:
6167
+ buffer.apply_completion(completions[0])
6168
+ else:
6169
+ buffer.start_completion(select_first=False)
6170
+
6171
+ @bindings.add("s-tab")
6172
+ def _shift_tab(event):
6173
+ buffer.complete_previous() if buffer.complete_state else buffer.start_completion(select_last=True)
6174
+
5910
6175
  @bindings.add(Keys.BracketedPaste)
5911
6176
  def _paste(event):
5912
6177
  parts = event.data.replace("\r\n", "\n").replace("\r", "\n").split("\n")
@@ -5917,7 +6182,12 @@ Tools:
5917
6182
  self.queue_input_text = parts[-1]
5918
6183
  buffer.reset(Document(self.queue_input_text))
5919
6184
 
5920
- app = self._make_app(Layout(HSplit([input_window, self.status_window(active=True)]), focused_element=input_window), bindings)
6185
+ completion_space = ConditionalContainer(Window(height=12, dont_extend_height=True), filter=has_completions & ~is_done)
6186
+ root = FloatContainer(
6187
+ HSplit([input_window, completion_space, self.status_window(active=True)]),
6188
+ [Float(CompletionsMenu(max_height=12, scroll_offset=1), xcursor=True, ycursor=True, attach_to_window=input_window, transparent=True)],
6189
+ )
6190
+ app = self._make_app(Layout(root, focused_element=input_window), bindings)
5921
6191
  self.queue_input_app = app
5922
6192
  self.queue_input_active.set()
5923
6193
 
@@ -5938,6 +6208,24 @@ Tools:
5938
6208
  if self.queue_input_app is app:
5939
6209
  self.queue_input_app = None
5940
6210
 
6211
+ def run_queued_command(self, text: str) -> None:
6212
+ """Dispatch a slash command typed in the queue input. Only read-only commands run while the
6213
+ agent is working; mutating/control commands would race the in-flight turn, so they are refused."""
6214
+ name = text.partition(" ")[0]
6215
+ if name not in self.QUEUE_RUN_COMMANDS:
6216
+ self.emit(f"{name} is unavailable while the agent is working; press Ctrl-C to run it.")
6217
+ return
6218
+ if name == "/mcp":
6219
+ sub = text.partition(" ")[2].split()
6220
+ if sub and sub[0] != "tools":
6221
+ self.emit("Only read-only /mcp (status, tools) is available while the agent is working.")
6222
+ return
6223
+ self.ui.capture_ansi = True
6224
+ try:
6225
+ self.command(text)
6226
+ finally:
6227
+ self.ui.capture_ansi = False
6228
+
5941
6229
  def pause_queue_input(self) -> None:
5942
6230
  self.queue_input_paused.set()
5943
6231
  if self.queue_input_app is not None:
@@ -5967,11 +6255,8 @@ Tools:
5967
6255
  UpdateChecker(self.session).start()
5968
6256
  while True:
5969
6257
  try:
5970
- user_input = self.drain_queued_input()
5971
- if not user_input:
5972
- user_input = self.read_input(initial_text="")
5973
- elif self.input_history is not None:
5974
- self.input_history.append_string(user_input)
6258
+ # Pre-fill any queued input into the prompt for review/edit instead of auto-submitting it.
6259
+ user_input = self.read_input(initial_text=self.drain_queued_input())
5975
6260
  except EOFError:
5976
6261
  self.emit("")
5977
6262
  self.save_and_emit_resume()
@@ -6181,7 +6466,7 @@ Tools:
6181
6466
  initial_text: str = "",
6182
6467
  ) -> str:
6183
6468
  if self.input_history is None:
6184
- return self.input_fn(prompt_text)
6469
+ return initial_text or self.input_fn(prompt_text)
6185
6470
 
6186
6471
  def accept(buffer: Buffer) -> bool:
6187
6472
  app.exit(result=buffer.text)
@@ -6750,8 +7035,8 @@ Tools:
6750
7035
  index_message = (index_message + "; " if index_message else "") + "run /index"
6751
7036
  elif index_status == "stale" and "run /index" not in index_message:
6752
7037
  index_message = (index_message + "; " if index_message else "") + "run /index or wait for auto update"
6753
- cache_ratio = (usage.cached_prompt_tokens * 100 / usage.total_tokens) if usage.total_tokens else 0
6754
- last_cache_ratio = (usage.last_cached_prompt_tokens * 100 / usage.last_total_tokens) if usage.last_total_tokens else 0
7038
+ cache_ratio = (usage.cached_prompt_tokens * 100 / usage.prompt_tokens) if usage.prompt_tokens else 0
7039
+ last_cache_ratio = (usage.last_cached_prompt_tokens * 100 / usage.last_prompt_tokens) if usage.last_prompt_tokens else 0
6755
7040
  rows = [
6756
7041
  ("workspace", "`" + self.session.cwd + "`"),
6757
7042
  ("session", "`" + self.session.uid + "`"),
@@ -6766,7 +7051,7 @@ Tools:
6766
7051
  ("goal", self.session.state.goal or "(empty)"),
6767
7052
  (
6768
7053
  "usage",
6769
- f"calls `{usage.calls}`; total `{usage.total_tokens}`; cached `{usage.cached_prompt_tokens}` (`{cache_ratio:.1f}%`); last `{usage.last_cached_prompt_tokens}/{usage.last_total_tokens}` (`{last_cache_ratio:.1f}%`)",
7054
+ f"calls `{usage.calls}`; total `{usage.total_tokens}`; cached `{usage.cached_prompt_tokens}/{usage.prompt_tokens}` (`{cache_ratio:.1f}%`); last `{usage.last_cached_prompt_tokens}/{usage.last_prompt_tokens}` (`{last_cache_ratio:.1f}%`)",
6770
7055
  ),
6771
7056
  (
6772
7057
  "runtime",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.6.4
3
+ Version: 0.6.6
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
@@ -240,7 +240,7 @@ model request
240
240
  | conversation, compacted summaries, tools |
241
241
  +--------------------------------------------------+
242
242
  | user |
243
- | Memory: goal, plan, known, date |
243
+ | Memory: goal, plan, known, code index, date |
244
244
  +--------------------------------------------------+
245
245
  | user |
246
246
  | FILE STATE: latest Read/Edit file view |
@@ -249,6 +249,7 @@ model request
249
249
 
250
250
  Core rules:
251
251
 
252
+ - Environment holds only stable host facts (cwd, os, arch, detected commands) so it stays byte-identical across turns and keeps the conversation prefix cacheable; volatile state like the code index status lives in the late Memory section instead.
252
253
  - Mid-turn assistant text and appended user input are kept as conversation.
253
254
  - Earlier conversation is compacted into an explicit summary when the context grows too large.
254
255
  - FILE STATE is updated by successful `Read` and `Edit` tools and shows current listed file ranges, with recent files first.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.6.4"
7
+ version = "0.6.6"
8
8
  description = "A small terminal coding agent written in Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes