nanocode-cli 0.3.28__tar.gz → 0.3.32__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.3.28
3
+ Version: 0.3.32
4
4
  Summary: A lightweight terminal-based AI coding assistant
5
5
  Author-email: hit9 <hit9@icloud.com>
6
6
  License-Expression: BSD-3-Clause
@@ -119,7 +119,7 @@ USE AT YOUR OWN RISK.
119
119
  - Maintenance: `/clean`.
120
120
  - Exit: `/exit`, `/quit`.
121
121
 
122
- `/model` groups configured `available_models` first, then any extra models automatically discovered from the provider. In selectors, use `j`/`k` or arrow keys to move, `/keyword` to filter choices, Enter to select, and Esc to step back or cancel. `/model <model_name>` sets a model directly. Changing model also opens a reasoning effort selector: choose `off` to disable reasoning, or choose an effort to enable reasoning and set it. Use `/reason` to change reasoning without changing model.
122
+ Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
123
123
 
124
124
  ## Configuration
125
125
 
@@ -89,7 +89,7 @@ USE AT YOUR OWN RISK.
89
89
  - Maintenance: `/clean`.
90
90
  - Exit: `/exit`, `/quit`.
91
91
 
92
- `/model` groups configured `available_models` first, then any extra models automatically discovered from the provider. In selectors, use `j`/`k` or arrow keys to move, `/keyword` to filter choices, Enter to select, and Esc to step back or cancel. `/model <model_name>` sets a model directly. Changing model also opens a reasoning effort selector: choose `off` to disable reasoning, or choose an effort to enable reasoning and set it. Use `/reason` to change reasoning without changing model.
92
+ Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
93
93
 
94
94
  ## Configuration
95
95
 
@@ -51,7 +51,7 @@ from prompt_toolkit.output.defaults import create_output
51
51
  from prompt_toolkit.patch_stdout import patch_stdout
52
52
  from prompt_toolkit.styles import Style
53
53
 
54
- __version__ = "0.3.28"
54
+ __version__ = "0.3.32"
55
55
  HTTP_USER_AGENT = "nanocode/" + __version__
56
56
 
57
57
 
@@ -823,6 +823,8 @@ class RuntimeState:
823
823
  current_model_call_reasoning_label: str = ""
824
824
  current_model_call_activity: str = ""
825
825
  current_model_call_has_content: bool = False
826
+ current_model_call_streaming_chars: int = 0
827
+ last_model_call_rate: float = 0.0
826
828
  status_notice: str = ""
827
829
  status_notice_until: float = 0.0
828
830
  conversation: list[ConversationItem] = field(default_factory=list)
@@ -1670,7 +1672,7 @@ class SearchTool(Tool):
1670
1672
  MAX_CONTEXT_LINES: ClassVar[int] = 30
1671
1673
  EFFECT: ClassVar[ToolEffect] = ToolEffect.READONLY
1672
1674
  DESCRIPTION: ClassVar[tuple[str, ...]] = (
1673
- "Case-insensitive regex search before Read; use A|B|C for alternatives.",
1675
+ "Case-insensitive regex search before Read; use A|B|C for alternatives and \\n for multiline matches.",
1674
1676
  "For exact text, escape regex metacharacters like braces, parens, dots, stars, and brackets.",
1675
1677
  "Scope with path=FILE_OR_DIR, optionally filter with one glob=*.py, set context=N for 0..30 lines; omitted path defaults to current directory.",
1676
1678
  "Second positional arg is always path, third positional arg is always glob; with path=, extra leading positional args are joined as regex alternatives.",
@@ -1713,8 +1715,7 @@ class SearchTool(Tool):
1713
1715
  pattern = raw_pattern[3:] if raw_pattern.startswith("re:") else raw_pattern
1714
1716
  if not pattern:
1715
1717
  raise ToolCallArgError("pattern cannot be empty")
1716
- if "\n" in pattern:
1717
- raise ToolCallArgError("multiline regex is not supported; Search is line-oriented. Search each line separately or Read a nearby range.")
1718
+ pattern = pattern.replace("\\n", "\n").replace("\\r", "\r")
1718
1719
  target_path_arg = "."
1719
1720
  glob_pattern = ""
1720
1721
  context_lines = cls.CONTEXT_LINES
@@ -1863,7 +1864,9 @@ class SearchTool(Tool):
1863
1864
  yield path
1864
1865
 
1865
1866
  def _make_match(self, path: str, line_number: int, text: str) -> Match:
1866
- return self.Match(path=path, line_number=line_number, text=text[:300], context=self._read_match_context(path, line_number))
1867
+ text = text.rstrip("\n")
1868
+ preview = _shorten(" ".join(text.split()), 300) if "\n" in text or "\r" in text else text[:300]
1869
+ return self.Match(path=path, line_number=line_number, text=preview, context=self._read_match_context(path, line_number))
1867
1870
 
1868
1871
  def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
1869
1872
  if line_number <= 0:
@@ -1902,6 +1905,8 @@ class SearchTool(Tool):
1902
1905
 
1903
1906
  def _rg_command(self, rg: str, *, pcre2: bool = False) -> list[str]:
1904
1907
  cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
1908
+ if self._is_multiline():
1909
+ cmd.extend(["-U", "--multiline-dotall"])
1905
1910
  if pcre2:
1906
1911
  cmd.append("--pcre2")
1907
1912
  cmd.append("-i")
@@ -1955,8 +1960,13 @@ class SearchTool(Tool):
1955
1960
  text = stderr.lower()
1956
1961
  return "pcre2" in text and ("look-around" in text or "look-ahead" in text or "look-behind" in text)
1957
1962
 
1963
+ def _is_multiline(self) -> bool:
1964
+ return "\n" in self.pattern or "\r" in self.pattern
1965
+
1958
1966
  def _call_python(self) -> str:
1959
1967
  matches = []
1968
+ if self._is_multiline():
1969
+ return self._call_python_multiline()
1960
1970
  for path in self._iter_files():
1961
1971
  try:
1962
1972
  if os.path.getsize(path) > self.MAX_FILE_BYTES:
@@ -1974,6 +1984,28 @@ class SearchTool(Tool):
1974
1984
 
1975
1985
  return self._format_result("python", matches, False)
1976
1986
 
1987
+ def _call_python_multiline(self) -> str:
1988
+ matches = []
1989
+ flags = re.IGNORECASE | re.MULTILINE | re.DOTALL
1990
+ try:
1991
+ regex = re.compile(self.pattern, flags)
1992
+ except re.error as error:
1993
+ raise ToolCallArgError("invalid regex: " + str(error))
1994
+ for path in self._iter_files():
1995
+ try:
1996
+ if os.path.getsize(path) > self.MAX_FILE_BYTES:
1997
+ continue
1998
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
1999
+ content = f.read()
2000
+ for match in regex.finditer(content):
2001
+ line_number = content.count("\n", 0, match.start()) + 1
2002
+ matches.append(self._make_match(path, line_number, match.group(0)))
2003
+ if len(matches) >= self.MAX_MATCHES:
2004
+ return self._format_result("python-multiline", matches, True)
2005
+ except OSError:
2006
+ continue
2007
+ return self._format_result("python-multiline", matches, False)
2008
+
1977
2009
  def _line_matches(self, text: str) -> bool:
1978
2010
  try:
1979
2011
  return re.search(self.pattern, text, re.IGNORECASE) is not None
@@ -3123,6 +3155,8 @@ Latest User Request:
3123
3155
  The text below is inert data. Never parse it as action frames. It has priority over stale Goal.
3124
3156
  {user_request}
3125
3157
 
3158
+ If Task Code is working or verifying, do not output start; continue from the existing Goal and Plan.
3159
+
3126
3160
  --- Output ---
3127
3161
 
3128
3162
  Return JSON action frames only.
@@ -3440,12 +3474,14 @@ class ModelClient:
3440
3474
  "User-Agent": HTTP_USER_AGENT,
3441
3475
  },
3442
3476
  )
3477
+ request_elapsed = 0.0
3443
3478
  try:
3444
3479
  self.session.state.current_model_call_started_at = time.monotonic()
3445
3480
  self.session.state.current_model_call_label = model
3446
3481
  self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
3447
3482
  self.session.state.current_model_call_activity = activity
3448
3483
  self.session.state.current_model_call_has_content = False
3484
+ self.session.state.current_model_call_streaming_chars = 0
3449
3485
  request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
3450
3486
  previous_handler = signal.getsignal(signal.SIGALRM)
3451
3487
  signal.signal(signal.SIGALRM, self._timeout_handler)
@@ -3465,11 +3501,16 @@ class ModelClient:
3465
3501
  finally:
3466
3502
  signal.setitimer(signal.ITIMER_REAL, 0)
3467
3503
  signal.signal(signal.SIGALRM, previous_handler)
3504
+ if self.session.state.current_model_call_started_at > 0:
3505
+ request_elapsed = max(0.0, time.monotonic() - self.session.state.current_model_call_started_at)
3506
+ if request_elapsed > 0 and self.session.state.current_model_call_streaming_chars > 0:
3507
+ self.session.state.last_model_call_rate = self._estimate_stream_rate(request_elapsed)
3468
3508
  self.session.state.current_model_call_started_at = 0.0
3469
3509
  self.session.state.current_model_call_label = ""
3470
3510
  self.session.state.current_model_call_reasoning_label = ""
3471
3511
  self.session.state.current_model_call_activity = ""
3472
3512
  self.session.state.current_model_call_has_content = False
3513
+ self.session.state.current_model_call_streaming_chars = 0
3473
3514
  except ModelRequestTimeout as error:
3474
3515
  raise LLMError(str(error) or "request model timeout")
3475
3516
  except (socket.timeout, TimeoutError):
@@ -3490,7 +3531,7 @@ class ModelClient:
3490
3531
  except json.JSONDecodeError:
3491
3532
  raise LLMError("API response is not JSON: " + _shorten(body))
3492
3533
 
3493
- self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config)
3534
+ self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config, elapsed=request_elapsed)
3494
3535
  if not stream:
3495
3536
  content = self._message_content(result)
3496
3537
  if content is None:
@@ -3538,8 +3579,12 @@ class ModelClient:
3538
3579
  self.session.state.current_model_call_has_content = True
3539
3580
  self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
3540
3581
  parts.append(content)
3582
+ self.session.state.current_model_call_streaming_chars += len(content)
3541
3583
  return "".join(parts), usage
3542
3584
 
3585
+ def _estimate_stream_rate(self, elapsed: float) -> float:
3586
+ return self.session.state.current_model_call_streaming_chars / 4 / elapsed if elapsed > 0 else 0.0
3587
+
3543
3588
  def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
3544
3589
  remaining = request_deadline - time.monotonic()
3545
3590
  if remaining <= 0:
@@ -3579,8 +3624,11 @@ class ModelClient:
3579
3624
  def _parse_model_content(self, content: str) -> Json:
3580
3625
  text = content.strip()
3581
3626
  text = self._strip_leaked_think_tags(text)
3627
+ text = self._strip_leaked_tool_code(text)
3582
3628
  text = self._strip_json_fence(text)
3629
+ text = self._strip_fence_marker_lines(text)
3583
3630
  text = self._strip_leaked_think_tags(text)
3631
+ text = self._strip_leaked_tool_code(text)
3584
3632
  if not self._has_action_frame_end(text):
3585
3633
  actions, error = self._parse_unmarked_actions(text)
3586
3634
  if actions:
@@ -3677,9 +3725,10 @@ class ModelClient:
3677
3725
 
3678
3726
  def _normalize_tool_type(self, action: Json) -> None:
3679
3727
  action_type = _json_str(action.get("type"))
3680
- if action_type in TOOL_REGISTRY:
3728
+ tool_name = next((name for name in TOOL_REGISTRY if name.lower() == action_type.lower()), "") if action_type else ""
3729
+ if tool_name:
3681
3730
  action["type"] = "tool"
3682
- action.setdefault("name", action_type)
3731
+ action.setdefault("name", tool_name)
3683
3732
 
3684
3733
  def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
3685
3734
  actions: list[Json] = []
@@ -3704,12 +3753,15 @@ class ModelClient:
3704
3753
  return parsed, ""
3705
3754
  action_start = text.find("{", index)
3706
3755
  if action_start < 0:
3756
+ progress = self._plain_progress_text(text[index:])
3757
+ if progress:
3758
+ return [{"type": "progress", "text": progress}], ""
3707
3759
  try:
3708
3760
  decoder.raw_decode(text, index)
3709
3761
  except json.JSONDecodeError as error:
3710
3762
  return [], str(error)
3711
3763
  return [], "expected JSON object action"
3712
- prefix = _shorten(" ".join(text[:action_start].split()), 500)
3764
+ prefix = self._progress_text(text[:action_start])
3713
3765
  index = action_start
3714
3766
  while True:
3715
3767
  while index < len(text) and text[index].isspace():
@@ -3721,6 +3773,14 @@ class ModelClient:
3721
3773
  try:
3722
3774
  value, index = decoder.raw_decode(text, index)
3723
3775
  except json.JSONDecodeError as error:
3776
+ if actions:
3777
+ return [], str(error)
3778
+ if self._should_repair_json_decode_error(str(error), text):
3779
+ repaired, repair_error = self._repair_single_json_action(text)
3780
+ if not repair_error:
3781
+ if prefix:
3782
+ repaired.insert(0, {"type": "progress", "text": prefix})
3783
+ return repaired, ""
3724
3784
  return [], str(error)
3725
3785
  parsed, error = self._actions_from_json_value(value)
3726
3786
  if error:
@@ -3734,12 +3794,40 @@ class ModelClient:
3734
3794
  if index < len(text) and text[index] != "{":
3735
3795
  next_action = text.find("{", index)
3736
3796
  if next_action < 0:
3797
+ if self._should_repair_trailing_json_text(text[index:]):
3798
+ repaired, error = self._repair_single_json_action(text)
3799
+ if not error:
3800
+ return repaired, ""
3737
3801
  return [], "unexpected text after JSON action"
3738
- progress = _shorten(" ".join(text[index:next_action].split()), 500)
3802
+ progress = self._progress_text(text[index:next_action])
3739
3803
  if progress:
3740
3804
  actions.append({"type": "progress", "text": progress})
3741
3805
  index = next_action
3742
3806
 
3807
+ def _progress_text(self, text: str) -> str:
3808
+ text = re.sub(r"```[a-zA-Z0-9_-]*", "", text)
3809
+ text = text.replace("```", "")
3810
+ return _shorten(" ".join(text.split()), 500)
3811
+
3812
+ def _plain_progress_text(self, text: str) -> str:
3813
+ progress = self._progress_text(text)
3814
+ if not progress or "{" in progress or "}" in progress:
3815
+ return ""
3816
+ starters = (
3817
+ "let me ",
3818
+ "i need ",
3819
+ "i will ",
3820
+ "i'll ",
3821
+ "now ",
3822
+ "next ",
3823
+ "我需要",
3824
+ "让我",
3825
+ "我会",
3826
+ "现在",
3827
+ "接下来",
3828
+ )
3829
+ return progress if progress.lower().startswith(starters) else ""
3830
+
3743
3831
  def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
3744
3832
  decoder = json.JSONDecoder()
3745
3833
  value, end = decoder.raw_decode(text, index)
@@ -3753,6 +3841,21 @@ class ModelClient:
3753
3841
  raise ValueError("expected JSON action array")
3754
3842
  return value, len(text)
3755
3843
 
3844
+ def _repair_single_json_action(self, text: str) -> tuple[list[Json], str]:
3845
+ try:
3846
+ value = json_repair.loads(text)
3847
+ except Exception as error:
3848
+ return [], str(error)
3849
+ if isinstance(value, list):
3850
+ return [], "unexpected text after JSON action"
3851
+ return self._actions_from_json_value(value)
3852
+
3853
+ def _should_repair_json_decode_error(self, error: str, text: str) -> bool:
3854
+ return "Invalid control character" in error or re.fullmatch(r".*[}\]]\s*[}\]]+\s*", text, re.DOTALL) is not None
3855
+
3856
+ def _should_repair_trailing_json_text(self, text: str) -> bool:
3857
+ return re.fullmatch(r"\s*[}\]]+\s*", text) is not None
3858
+
3756
3859
  def _has_action_frame_end(self, line: str) -> bool:
3757
3860
  return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
3758
3861
 
@@ -3766,6 +3869,9 @@ class ModelClient:
3766
3869
  lines = lines[:-1]
3767
3870
  return "\n".join(lines).strip()
3768
3871
 
3872
+ def _strip_fence_marker_lines(self, text: str) -> str:
3873
+ return re.sub(r"(?m)^\s*```[a-zA-Z0-9_-]*\s*$\n?", "", text).strip()
3874
+
3769
3875
  def _strip_leaked_think_tags(self, text: str) -> str:
3770
3876
  text = text.strip()
3771
3877
  while text.startswith("</think>"):
@@ -3779,6 +3885,9 @@ class ModelClient:
3779
3885
  text = text[len("</think>") :].lstrip()
3780
3886
  return text
3781
3887
 
3888
+ def _strip_leaked_tool_code(self, text: str) -> str:
3889
+ return re.sub(r"<tool_code>.*?</tool_code>", "", text, flags=re.DOTALL).strip()
3890
+
3782
3891
  def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
3783
3892
  guidance = ""
3784
3893
  if self._strip_leaked_think_tags(content.strip()).startswith("<tool_call>"):
@@ -3812,10 +3921,12 @@ class ModelClient:
3812
3921
  }
3813
3922
  return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
3814
3923
 
3815
- def _record_usage(self, usage: Json, config: ProviderConfig) -> None:
3924
+ def _record_usage(self, usage: Json, config: ProviderConfig, *, elapsed: float = 0.0) -> None:
3816
3925
  prompt_tokens = self._json_int(usage.get("prompt_tokens"))
3817
3926
  completion_tokens = self._json_int(usage.get("completion_tokens"))
3818
3927
  total_tokens = self._json_int(usage.get("total_tokens"))
3928
+ if completion_tokens > 0 and elapsed > 0:
3929
+ self.session.state.last_model_call_rate = completion_tokens / elapsed
3819
3930
  self.session.state.last_prompt_tokens = prompt_tokens
3820
3931
  self.session.state.last_completion_tokens = completion_tokens
3821
3932
  self.session.state.last_total_tokens = total_tokens
@@ -4787,7 +4898,6 @@ class Agent:
4787
4898
  KEPT_TOOL_RESULT_CHARS: ClassVar[int] = 96_000
4788
4899
  RECENT_TOOL_CALL_SUMMARIES: ClassVar[int] = 40
4789
4900
  PENDING_OBSERVE_RESULTS: ClassVar[int] = 8
4790
- PENDING_OBSERVE_CHAR_RATIO: ClassVar[float] = 0.4
4791
4901
  PENDING_OBSERVE_TOOL_TURNS: ClassVar[int] = 2
4792
4902
  PLAN_MODE_GIT_READONLY: ClassVar[frozenset[str]] = GIT_READONLY_COMMANDS
4793
4903
 
@@ -4810,6 +4920,7 @@ class Agent:
4810
4920
  self.failed_tool_call_count = 0
4811
4921
  self.agent_feedback_errors: list[str] = []
4812
4922
  self.observe_feedback_errors: list[str] = []
4923
+ self.task_alignment_required = False
4813
4924
  self.mode = AgentMode.ACT
4814
4925
 
4815
4926
  def build_user_prompt(self) -> str:
@@ -5094,8 +5205,6 @@ class Agent:
5094
5205
  return True
5095
5206
  if len(pending) >= self.PENDING_OBSERVE_RESULTS:
5096
5207
  return True
5097
- if len("\n\n".join(pending)) >= int(self.RECENT_TOOL_CALL_CHARS * self.PENDING_OBSERVE_CHAR_RATIO):
5098
- return True
5099
5208
  return self.runtime.consecutive_tool_turns >= self.PENDING_OBSERVE_TOOL_TURNS
5100
5209
 
5101
5210
  def _tool_failure_needs_observe(self, execution: ToolCallExecution) -> bool:
@@ -5481,6 +5590,24 @@ class Agent:
5481
5590
  "PlanMode_Gate: " + plan_mode_tool_error + ".",
5482
5591
  )
5483
5592
  return True
5593
+ if (
5594
+ self.blackboard.task_code == TaskCode.NEW
5595
+ and self.task_alignment_required
5596
+ and (ctx.tool_calls or ctx.pending_verify_requested)
5597
+ and not ctx.has_goal_action
5598
+ and not ctx.has_plan_action
5599
+ and not ctx.has_user_rule_action
5600
+ ):
5601
+ self._remember_agent_error(
5602
+ "Error: previous task context is still present. Rule: before work, emit start if the latest request changes the task; "
5603
+ "if continuing, update or confirm the plan first."
5604
+ )
5605
+ self._report_gate(
5606
+ on_message,
5607
+ "Retrying: align this request with the task before work.",
5608
+ "GoalPlan_Gate: work before task alignment with previous task context.",
5609
+ )
5610
+ return True
5484
5611
  if self.blackboard.task_code != TaskCode.NEW and any(_json_str(action.get("type")) == "start" for action in ctx.actions):
5485
5612
  self._remember_agent_error(
5486
5613
  "Error: repeated start is invalid after the current task is active. Rule: follow Current Task Code and continue with plan/tool/verify/goal."
@@ -5962,10 +6089,16 @@ class Agent:
5962
6089
  self.mode = AgentMode.ACT
5963
6090
  self.session.state.turn_tool_calls = 0
5964
6091
  self.session.state.turn_model_calls = 0
6092
+ old_goal = self.blackboard.goal
6093
+ old_task_context = bool(self.blackboard.goal or self.blackboard.plan or self.blackboard.hypotheses)
5965
6094
  self.blackboard.user_input = user_input
5966
6095
  previous_task_done = self.blackboard.task_code == TaskCode.DONE
5967
6096
  if previous_task_done:
5968
6097
  self.blackboard.work_mode = WorkMode.NORMAL
6098
+ # Keep previous task state at a new user turn so short follow-ups like
6099
+ # "continue" can resume. The first response must align with it before work
6100
+ # when the new request does not match the previous goal.
6101
+ self.task_alignment_required = old_task_context and self._task_text_key(user_input) != self._task_text_key(old_goal)
5969
6102
  self.blackboard.task_code = TaskCode.NEW
5970
6103
  self.blackboard.goal_reached = False
5971
6104
  self.blackboard.verification_required = False
@@ -5988,6 +6121,9 @@ class Agent:
5988
6121
  on_step_limit=lambda: (_ for _ in ()).throw(LLMError("agent step limit reached")),
5989
6122
  )
5990
6123
 
6124
+ def _task_text_key(self, text: str) -> str:
6125
+ return re.sub(r"\s+", " ", text).strip(" \t\r\n。.;;").lower()
6126
+
5991
6127
  def handle_response(
5992
6128
  self,
5993
6129
  response: Json,
@@ -6656,21 +6792,27 @@ class StatusBar:
6656
6792
  context = str(len(session.state.conversation)) + "/" + str(session.settings.compact_at)
6657
6793
  last_tokens = _format_count(session.state.last_total_tokens)
6658
6794
  session_tokens = _format_count(session.state.session_total_tokens)
6659
- tokens = "last:" + last_tokens + " session:" + session_tokens
6660
- parts = [model + " (" + reasoning + ")" + modes, "ctx:" + context, "tools:" + str(session.state.turn_tool_calls), "tok:" + tokens]
6795
+ rate = session.state.last_model_call_rate
6796
+ token_summary = "last:" + last_tokens + " sess:" + session_tokens
6797
+ parts = [model + " (" + reasoning + ")" + modes, "ctx:" + context, "tool:" + str(session.state.turn_tool_calls), "tok:" + token_summary]
6661
6798
  if show_elapsed:
6662
- parts.append(f"{turn_elapsed:.1f}s")
6799
+ parts.append(f"turn:{turn_elapsed:.1f}s")
6663
6800
  if session.state.current_model_call_started_at > 0:
6664
6801
  activity = self._activity_label(session.state.current_model_call_activity)
6665
6802
  if session.state.current_model_call_has_content:
6666
6803
  activity += "*"
6804
+ elapsed = max(0.0, now - session.state.current_model_call_started_at)
6805
+ if session.state.current_model_call_has_content and elapsed > 0:
6806
+ rate = session.state.current_model_call_streaming_chars / 4 / elapsed
6667
6807
  parts.append(
6668
6808
  activity
6669
6809
  + "("
6670
6810
  + str(session.state.turn_model_calls)
6671
6811
  + "):"
6672
- + f"{max(0.0, now - session.state.current_model_call_started_at):.1f}s"
6812
+ + f"{elapsed:.1f}s"
6673
6813
  )
6814
+ if rate > 0:
6815
+ parts[3] += " " + _format_count(int(rate)) + "t/s"
6674
6816
  if session.state.status_notice and session.state.status_notice_until > now:
6675
6817
  parts.append(session.state.status_notice)
6676
6818
  return " | ".join(parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.3.28
3
+ Version: 0.3.32
4
4
  Summary: A lightweight terminal-based AI coding assistant
5
5
  Author-email: hit9 <hit9@icloud.com>
6
6
  License-Expression: BSD-3-Clause
@@ -119,7 +119,7 @@ USE AT YOUR OWN RISK.
119
119
  - Maintenance: `/clean`.
120
120
  - Exit: `/exit`, `/quit`.
121
121
 
122
- `/model` groups configured `available_models` first, then any extra models automatically discovered from the provider. In selectors, use `j`/`k` or arrow keys to move, `/keyword` to filter choices, Enter to select, and Esc to step back or cancel. `/model <model_name>` sets a model directly. Changing model also opens a reasoning effort selector: choose `off` to disable reasoning, or choose an effort to enable reasoning and set it. Use `/reason` to change reasoning without changing model.
122
+ Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
123
123
 
124
124
  ## Configuration
125
125
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.3.28"
7
+ version = "0.3.32"
8
8
  description = "A lightweight terminal-based AI coding assistant"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes