nanocode-cli 0.3.27__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.27
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.27"
54
+ __version__ = "0.3.32"
55
55
  HTTP_USER_AGENT = "nanocode/" + __version__
56
56
 
57
57
 
@@ -821,6 +821,12 @@ class RuntimeState:
821
821
  current_model_call_started_at: float = 0.0
822
822
  current_model_call_label: str = ""
823
823
  current_model_call_reasoning_label: str = ""
824
+ current_model_call_activity: str = ""
825
+ current_model_call_has_content: bool = False
826
+ current_model_call_streaming_chars: int = 0
827
+ last_model_call_rate: float = 0.0
828
+ status_notice: str = ""
829
+ status_notice_until: float = 0.0
824
830
  conversation: list[ConversationItem] = field(default_factory=list)
825
831
  user_rules: UserRules = field(default_factory=UserRules)
826
832
  range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
@@ -1666,7 +1672,7 @@ class SearchTool(Tool):
1666
1672
  MAX_CONTEXT_LINES: ClassVar[int] = 30
1667
1673
  EFFECT: ClassVar[ToolEffect] = ToolEffect.READONLY
1668
1674
  DESCRIPTION: ClassVar[tuple[str, ...]] = (
1669
- "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.",
1670
1676
  "For exact text, escape regex metacharacters like braces, parens, dots, stars, and brackets.",
1671
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.",
1672
1678
  "Second positional arg is always path, third positional arg is always glob; with path=, extra leading positional args are joined as regex alternatives.",
@@ -1709,8 +1715,7 @@ class SearchTool(Tool):
1709
1715
  pattern = raw_pattern[3:] if raw_pattern.startswith("re:") else raw_pattern
1710
1716
  if not pattern:
1711
1717
  raise ToolCallArgError("pattern cannot be empty")
1712
- if "\n" in pattern:
1713
- 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")
1714
1719
  target_path_arg = "."
1715
1720
  glob_pattern = ""
1716
1721
  context_lines = cls.CONTEXT_LINES
@@ -1859,7 +1864,9 @@ class SearchTool(Tool):
1859
1864
  yield path
1860
1865
 
1861
1866
  def _make_match(self, path: str, line_number: int, text: str) -> Match:
1862
- 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))
1863
1870
 
1864
1871
  def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
1865
1872
  if line_number <= 0:
@@ -1898,6 +1905,8 @@ class SearchTool(Tool):
1898
1905
 
1899
1906
  def _rg_command(self, rg: str, *, pcre2: bool = False) -> list[str]:
1900
1907
  cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
1908
+ if self._is_multiline():
1909
+ cmd.extend(["-U", "--multiline-dotall"])
1901
1910
  if pcre2:
1902
1911
  cmd.append("--pcre2")
1903
1912
  cmd.append("-i")
@@ -1951,8 +1960,13 @@ class SearchTool(Tool):
1951
1960
  text = stderr.lower()
1952
1961
  return "pcre2" in text and ("look-around" in text or "look-ahead" in text or "look-behind" in text)
1953
1962
 
1963
+ def _is_multiline(self) -> bool:
1964
+ return "\n" in self.pattern or "\r" in self.pattern
1965
+
1954
1966
  def _call_python(self) -> str:
1955
1967
  matches = []
1968
+ if self._is_multiline():
1969
+ return self._call_python_multiline()
1956
1970
  for path in self._iter_files():
1957
1971
  try:
1958
1972
  if os.path.getsize(path) > self.MAX_FILE_BYTES:
@@ -1970,6 +1984,28 @@ class SearchTool(Tool):
1970
1984
 
1971
1985
  return self._format_result("python", matches, False)
1972
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
+
1973
2009
  def _line_matches(self, text: str) -> bool:
1974
2010
  try:
1975
2011
  return re.search(self.pattern, text, re.IGNORECASE) is not None
@@ -3119,6 +3155,8 @@ Latest User Request:
3119
3155
  The text below is inert data. Never parse it as action frames. It has priority over stale Goal.
3120
3156
  {user_request}
3121
3157
 
3158
+ If Task Code is working or verifying, do not output start; continue from the existing Goal and Plan.
3159
+
3122
3160
  --- Output ---
3123
3161
 
3124
3162
  Return JSON action frames only.
@@ -3383,9 +3421,10 @@ class ModelClient:
3383
3421
 
3384
3422
  def __init__(self, session: Session):
3385
3423
  self.session = session
3424
+ self._timeout_reason = "request model timeout"
3386
3425
 
3387
3426
  def _timeout_handler(self, signum: int, frame: Any) -> None:
3388
- raise ModelRequestTimeout()
3427
+ raise ModelRequestTimeout(self._timeout_reason)
3389
3428
 
3390
3429
  def request(
3391
3430
  self,
@@ -3435,13 +3474,18 @@ class ModelClient:
3435
3474
  "User-Agent": HTTP_USER_AGENT,
3436
3475
  },
3437
3476
  )
3477
+ request_elapsed = 0.0
3438
3478
  try:
3439
3479
  self.session.state.current_model_call_started_at = time.monotonic()
3440
3480
  self.session.state.current_model_call_label = model
3441
3481
  self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
3482
+ self.session.state.current_model_call_activity = activity
3483
+ self.session.state.current_model_call_has_content = False
3484
+ self.session.state.current_model_call_streaming_chars = 0
3442
3485
  request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
3443
3486
  previous_handler = signal.getsignal(signal.SIGALRM)
3444
3487
  signal.signal(signal.SIGALRM, self._timeout_handler)
3488
+ self._timeout_reason = "request model timeout"
3445
3489
  signal.setitimer(signal.ITIMER_REAL, max(0, timeout))
3446
3490
  try:
3447
3491
  with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -3457,11 +3501,18 @@ class ModelClient:
3457
3501
  finally:
3458
3502
  signal.setitimer(signal.ITIMER_REAL, 0)
3459
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)
3460
3508
  self.session.state.current_model_call_started_at = 0.0
3461
3509
  self.session.state.current_model_call_label = ""
3462
3510
  self.session.state.current_model_call_reasoning_label = ""
3463
- except ModelRequestTimeout:
3464
- raise LLMError("request model timeout")
3511
+ self.session.state.current_model_call_activity = ""
3512
+ self.session.state.current_model_call_has_content = False
3513
+ self.session.state.current_model_call_streaming_chars = 0
3514
+ except ModelRequestTimeout as error:
3515
+ raise LLMError(str(error) or "request model timeout")
3465
3516
  except (socket.timeout, TimeoutError):
3466
3517
  raise LLMError("request model timeout")
3467
3518
  except urllib.error.HTTPError as error:
@@ -3480,7 +3531,7 @@ class ModelClient:
3480
3531
  except json.JSONDecodeError:
3481
3532
  raise LLMError("API response is not JSON: " + _shorten(body))
3482
3533
 
3483
- 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)
3484
3535
  if not stream:
3485
3536
  content = self._message_content(result)
3486
3537
  if content is None:
@@ -3525,16 +3576,24 @@ class ModelClient:
3525
3576
  continue
3526
3577
  if not first_content_seen:
3527
3578
  first_content_seen = True
3579
+ self.session.state.current_model_call_has_content = True
3528
3580
  self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
3529
3581
  parts.append(content)
3582
+ self.session.state.current_model_call_streaming_chars += len(content)
3530
3583
  return "".join(parts), usage
3531
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
+
3532
3588
  def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
3533
3589
  remaining = request_deadline - time.monotonic()
3534
3590
  if remaining <= 0:
3535
- raise ModelRequestTimeout()
3591
+ raise ModelRequestTimeout("request model timeout")
3592
+ self._timeout_reason = "request model timeout"
3536
3593
  if not first_content_seen and first_token_timeout is not None and first_token_timeout > 0:
3537
- remaining = min(remaining, first_token_timeout)
3594
+ if first_token_timeout < remaining:
3595
+ remaining = first_token_timeout
3596
+ self._timeout_reason = "request first token timeout"
3538
3597
  signal.setitimer(signal.ITIMER_REAL, remaining)
3539
3598
 
3540
3599
  def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
@@ -3565,8 +3624,11 @@ class ModelClient:
3565
3624
  def _parse_model_content(self, content: str) -> Json:
3566
3625
  text = content.strip()
3567
3626
  text = self._strip_leaked_think_tags(text)
3627
+ text = self._strip_leaked_tool_code(text)
3568
3628
  text = self._strip_json_fence(text)
3629
+ text = self._strip_fence_marker_lines(text)
3569
3630
  text = self._strip_leaked_think_tags(text)
3631
+ text = self._strip_leaked_tool_code(text)
3570
3632
  if not self._has_action_frame_end(text):
3571
3633
  actions, error = self._parse_unmarked_actions(text)
3572
3634
  if actions:
@@ -3644,6 +3706,7 @@ class ModelClient:
3644
3706
  if isinstance(value, dict):
3645
3707
  if "actions" in value:
3646
3708
  return self._actions_from_json_value(value.get("actions"))
3709
+ self._normalize_tool_type(value)
3647
3710
  if not _json_str(value.get("type")):
3648
3711
  return [], "action missing type"
3649
3712
  return [value], ""
@@ -3653,12 +3716,20 @@ class ModelClient:
3653
3716
  action = _json_dict(raw)
3654
3717
  if not action:
3655
3718
  return [], "array item " + str(index) + ": expected JSON object action"
3719
+ self._normalize_tool_type(action)
3656
3720
  if not _json_str(action.get("type")):
3657
3721
  return [], "array item " + str(index) + ": action missing type"
3658
3722
  actions.append(action)
3659
3723
  return actions, ""
3660
3724
  return [], "expected JSON object action"
3661
3725
 
3726
+ def _normalize_tool_type(self, action: Json) -> None:
3727
+ action_type = _json_str(action.get("type"))
3728
+ tool_name = next((name for name in TOOL_REGISTRY if name.lower() == action_type.lower()), "") if action_type else ""
3729
+ if tool_name:
3730
+ action["type"] = "tool"
3731
+ action.setdefault("name", tool_name)
3732
+
3662
3733
  def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
3663
3734
  actions: list[Json] = []
3664
3735
  decoder = json.JSONDecoder()
@@ -3669,8 +3740,8 @@ class ModelClient:
3669
3740
  if index < len(text) and text[index] != "{":
3670
3741
  if text[index] == "[":
3671
3742
  try:
3672
- value, index = decoder.raw_decode(text, index)
3673
- except json.JSONDecodeError as error:
3743
+ value, index = self._decode_json_array_text(text, index)
3744
+ except (json.JSONDecodeError, ValueError) as error:
3674
3745
  return [], str(error)
3675
3746
  parsed, error = self._actions_from_json_value(value)
3676
3747
  if error:
@@ -3682,12 +3753,15 @@ class ModelClient:
3682
3753
  return parsed, ""
3683
3754
  action_start = text.find("{", index)
3684
3755
  if action_start < 0:
3756
+ progress = self._plain_progress_text(text[index:])
3757
+ if progress:
3758
+ return [{"type": "progress", "text": progress}], ""
3685
3759
  try:
3686
3760
  decoder.raw_decode(text, index)
3687
3761
  except json.JSONDecodeError as error:
3688
3762
  return [], str(error)
3689
3763
  return [], "expected JSON object action"
3690
- prefix = _shorten(" ".join(text[:action_start].split()), 500)
3764
+ prefix = self._progress_text(text[:action_start])
3691
3765
  index = action_start
3692
3766
  while True:
3693
3767
  while index < len(text) and text[index].isspace():
@@ -3699,6 +3773,14 @@ class ModelClient:
3699
3773
  try:
3700
3774
  value, index = decoder.raw_decode(text, index)
3701
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, ""
3702
3784
  return [], str(error)
3703
3785
  parsed, error = self._actions_from_json_value(value)
3704
3786
  if error:
@@ -3708,6 +3790,71 @@ class ModelClient:
3708
3790
  index += 1
3709
3791
  if index < len(text) and text[index] == ",":
3710
3792
  index += 1
3793
+ continue
3794
+ if index < len(text) and text[index] != "{":
3795
+ next_action = text.find("{", index)
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, ""
3801
+ return [], "unexpected text after JSON action"
3802
+ progress = self._progress_text(text[index:next_action])
3803
+ if progress:
3804
+ actions.append({"type": "progress", "text": progress})
3805
+ index = next_action
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
+
3831
+ def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
3832
+ decoder = json.JSONDecoder()
3833
+ value, end = decoder.raw_decode(text, index)
3834
+ cursor = end
3835
+ while cursor < len(text) and text[cursor].isspace():
3836
+ cursor += 1
3837
+ if cursor >= len(text):
3838
+ return value, cursor
3839
+ value = json_repair.loads(text[index:])
3840
+ if not isinstance(value, list):
3841
+ raise ValueError("expected JSON action array")
3842
+ return value, len(text)
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
3711
3858
 
3712
3859
  def _has_action_frame_end(self, line: str) -> bool:
3713
3860
  return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
@@ -3722,6 +3869,9 @@ class ModelClient:
3722
3869
  lines = lines[:-1]
3723
3870
  return "\n".join(lines).strip()
3724
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
+
3725
3875
  def _strip_leaked_think_tags(self, text: str) -> str:
3726
3876
  text = text.strip()
3727
3877
  while text.startswith("</think>"):
@@ -3735,6 +3885,9 @@ class ModelClient:
3735
3885
  text = text[len("</think>") :].lstrip()
3736
3886
  return text
3737
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
+
3738
3891
  def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
3739
3892
  guidance = ""
3740
3893
  if self._strip_leaked_think_tags(content.strip()).startswith("<tool_call>"):
@@ -3768,10 +3921,12 @@ class ModelClient:
3768
3921
  }
3769
3922
  return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
3770
3923
 
3771
- def _record_usage(self, usage: Json, config: ProviderConfig) -> None:
3924
+ def _record_usage(self, usage: Json, config: ProviderConfig, *, elapsed: float = 0.0) -> None:
3772
3925
  prompt_tokens = self._json_int(usage.get("prompt_tokens"))
3773
3926
  completion_tokens = self._json_int(usage.get("completion_tokens"))
3774
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
3775
3930
  self.session.state.last_prompt_tokens = prompt_tokens
3776
3931
  self.session.state.last_completion_tokens = completion_tokens
3777
3932
  self.session.state.last_total_tokens = total_tokens
@@ -4743,7 +4898,6 @@ class Agent:
4743
4898
  KEPT_TOOL_RESULT_CHARS: ClassVar[int] = 96_000
4744
4899
  RECENT_TOOL_CALL_SUMMARIES: ClassVar[int] = 40
4745
4900
  PENDING_OBSERVE_RESULTS: ClassVar[int] = 8
4746
- PENDING_OBSERVE_CHAR_RATIO: ClassVar[float] = 0.4
4747
4901
  PENDING_OBSERVE_TOOL_TURNS: ClassVar[int] = 2
4748
4902
  PLAN_MODE_GIT_READONLY: ClassVar[frozenset[str]] = GIT_READONLY_COMMANDS
4749
4903
 
@@ -4766,6 +4920,7 @@ class Agent:
4766
4920
  self.failed_tool_call_count = 0
4767
4921
  self.agent_feedback_errors: list[str] = []
4768
4922
  self.observe_feedback_errors: list[str] = []
4923
+ self.task_alignment_required = False
4769
4924
  self.mode = AgentMode.ACT
4770
4925
 
4771
4926
  def build_user_prompt(self) -> str:
@@ -4793,12 +4948,14 @@ class Agent:
4793
4948
  self.session.state.turn_model_calls += 1
4794
4949
  return self.model_client.request(system_prompt, user_prompt, activity=activity)
4795
4950
  except LLMError as error:
4796
- if str(error) != "request model timeout" or attempt >= len(self.MODEL_TIMEOUT_RETRY_DELAYS):
4951
+ timeout_reason = str(error)
4952
+ if timeout_reason not in ("request model timeout", "request first token timeout") or attempt >= len(self.MODEL_TIMEOUT_RETRY_DELAYS):
4797
4953
  raise
4798
4954
  delay = self.MODEL_TIMEOUT_RETRY_DELAYS[attempt]
4955
+ self._set_status_notice("err:first_token" if timeout_reason == "request first token timeout" else "err:timeout")
4799
4956
  if on_message is not None and self.session.settings.debug:
4800
4957
  on_message(
4801
- "Retrying: request model timeout; retry "
4958
+ "Retrying: " + timeout_reason + "; retry "
4802
4959
  + str(attempt + 1)
4803
4960
  + "/"
4804
4961
  + str(len(self.MODEL_TIMEOUT_RETRY_DELAYS))
@@ -4809,6 +4966,10 @@ class Agent:
4809
4966
  time.sleep(delay)
4810
4967
  raise LLMError("request model timeout")
4811
4968
 
4969
+ def _set_status_notice(self, text: str, ttl: float = 5.0) -> None:
4970
+ self.session.state.status_notice = text
4971
+ self.session.state.status_notice_until = time.monotonic() + ttl
4972
+
4812
4973
  def compact_history(self) -> int:
4813
4974
  return self.compactor.compact()
4814
4975
 
@@ -4834,6 +4995,7 @@ class Agent:
4834
4995
  format_error = _json_str(response.get("_format_error"))
4835
4996
  if format_error:
4836
4997
  consecutive_format_errors += 1
4998
+ self._set_status_notice("err:format")
4837
4999
  remember_error = self._remember_observe_error if self.mode == AgentMode.OBSERVE else self._remember_agent_error
4838
5000
  remember_error(
4839
5001
  self._format_gate_user_message("Error: model returned invalid output", format_error) + " Rule: return valid JSON action frames only."
@@ -4919,6 +5081,8 @@ class Agent:
4919
5081
  def _report_gate(self, on_message: MessageCallback | None, message: str, debug_message: str) -> None:
4920
5082
  if on_message is None:
4921
5083
  return
5084
+ if message.startswith(("Retrying:", "Continuing:")) and self.session.state.status_notice_until <= time.monotonic():
5085
+ self._set_status_notice("err:gate")
4922
5086
  if self.session.settings.debug:
4923
5087
  on_message(debug_message)
4924
5088
  return
@@ -4945,13 +5109,15 @@ class Agent:
4945
5109
  if self.mode == AgentMode.OBSERVE:
4946
5110
  system_prompt = self.prompt_builder.system_prompt(AGENT_OBSERVE_SYSTEM_PROMPT, tools=())
4947
5111
  user_prompt = self.build_observe_prompt()
5112
+ activity = "observe"
4948
5113
  else:
4949
5114
  system_prompt = self.prompt_builder.system_prompt(
4950
5115
  AGENT_PLAN_SYSTEM_PROMPT if self.session.settings.plan_mode else None,
4951
5116
  tools=PLAN_MODE_TOOLS if self.session.settings.plan_mode else None,
4952
5117
  )
4953
5118
  user_prompt = self.build_user_prompt()
4954
- response = self.request(system_prompt, user_prompt, activity="agent", on_message=on_message)
5119
+ activity = "agent"
5120
+ response = self.request(system_prompt, user_prompt, activity=activity, on_message=on_message)
4955
5121
  if _json_str(response.get("_format_error")):
4956
5122
  return response
4957
5123
  invalid_response = self._validate_action_response(response)
@@ -5039,8 +5205,6 @@ class Agent:
5039
5205
  return True
5040
5206
  if len(pending) >= self.PENDING_OBSERVE_RESULTS:
5041
5207
  return True
5042
- if len("\n\n".join(pending)) >= int(self.RECENT_TOOL_CALL_CHARS * self.PENDING_OBSERVE_CHAR_RATIO):
5043
- return True
5044
5208
  return self.runtime.consecutive_tool_turns >= self.PENDING_OBSERVE_TOOL_TURNS
5045
5209
 
5046
5210
  def _tool_failure_needs_observe(self, execution: ToolCallExecution) -> bool:
@@ -5297,29 +5461,6 @@ class Agent:
5297
5461
  conflict = sorted((forgotten & protected) - released)
5298
5462
  return "active hypothesis source: " + ", ".join(conflict) if conflict else ""
5299
5463
 
5300
- def _plan_shape_error(self, actions: list[Json]) -> str:
5301
- plan = [PlanItem(text=item.text, status=item.status, id=item.id, context=item.context) for item in self.blackboard.plan]
5302
- changed = False
5303
- for action in actions:
5304
- action_type = _json_str(action.get("type"))
5305
- if action_type == "start":
5306
- items = self._plan_items_from_json(action.get("plan"))
5307
- if items:
5308
- plan = items
5309
- changed = True
5310
- elif action_type == "plan":
5311
- items = self._plan_items_from_json(action.get("items"))
5312
- if action.get("mode") != "patch":
5313
- if items:
5314
- plan = items
5315
- changed = True
5316
- continue
5317
- changed = self.state_updater._apply_plan_patches(plan, action.get("items")) or changed
5318
- doing = [item for item in plan if item.status == PlanStatus.DOING]
5319
- if changed and len(doing) > 1:
5320
- return "multiple doing plan items: " + self._format_plan_gate_items(doing)
5321
- return ""
5322
-
5323
5464
  def _plan_items_from_json(self, value: JsonValue) -> list[PlanItem]:
5324
5465
  return [item for item in (self.state_updater._plan_item_from_json(raw) for raw in _json_list(value)) if item]
5325
5466
 
@@ -5427,15 +5568,6 @@ class Agent:
5427
5568
  "ToolResult_Gate: " + forget_hypothesis_error + ".",
5428
5569
  )
5429
5570
  return True
5430
- plan_shape_error = self._plan_shape_error(ctx.actions)
5431
- if plan_shape_error:
5432
- self._remember_agent_error("Error: Plan is invalid: " + plan_shape_error + ". Rule: at most one Plan item may be doing.")
5433
- self._report_gate(
5434
- on_message,
5435
- "Retrying: keep only one plan item doing.",
5436
- "Plan_Gate: " + plan_shape_error + ".",
5437
- )
5438
- return True
5439
5571
  repeated_tool_retry_error = self._repeated_tool_retry_error(ctx.tool_calls)
5440
5572
  if repeated_tool_retry_error:
5441
5573
  self._remember_agent_error(
@@ -5458,6 +5590,24 @@ class Agent:
5458
5590
  "PlanMode_Gate: " + plan_mode_tool_error + ".",
5459
5591
  )
5460
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
5461
5611
  if self.blackboard.task_code != TaskCode.NEW and any(_json_str(action.get("type")) == "start" for action in ctx.actions):
5462
5612
  self._remember_agent_error(
5463
5613
  "Error: repeated start is invalid after the current task is active. Rule: follow Current Task Code and continue with plan/tool/verify/goal."
@@ -5732,15 +5882,6 @@ class Agent:
5732
5882
  "ToolResult_Gate: " + forget_hypothesis_error + ".",
5733
5883
  )
5734
5884
  return AgentRunResult()
5735
- plan_shape_error = self._plan_shape_error(ctx.actions)
5736
- if plan_shape_error:
5737
- self._remember_observe_error("Error: Plan is invalid: " + plan_shape_error + ". Rule: at most one Plan item may be doing.")
5738
- self._report_gate(
5739
- on_message,
5740
- "Retrying: keep only one plan item doing.",
5741
- "Plan_Gate: " + plan_shape_error + ".",
5742
- )
5743
- return AgentRunResult()
5744
5885
  if any(_json_str(action.get("type")) == "verify" and _json_str(action.get("status")) == "pending" for action in ctx.actions):
5745
5886
  self._remember_observe_error("Error: cannot request new verification before observing latest results. Rule: keep or forget latest results first.")
5746
5887
  self._report_gate(
@@ -5948,10 +6089,16 @@ class Agent:
5948
6089
  self.mode = AgentMode.ACT
5949
6090
  self.session.state.turn_tool_calls = 0
5950
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)
5951
6094
  self.blackboard.user_input = user_input
5952
6095
  previous_task_done = self.blackboard.task_code == TaskCode.DONE
5953
6096
  if previous_task_done:
5954
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)
5955
6102
  self.blackboard.task_code = TaskCode.NEW
5956
6103
  self.blackboard.goal_reached = False
5957
6104
  self.blackboard.verification_required = False
@@ -5974,6 +6121,9 @@ class Agent:
5974
6121
  on_step_limit=lambda: (_ for _ in ()).throw(LLMError("agent step limit reached")),
5975
6122
  )
5976
6123
 
6124
+ def _task_text_key(self, text: str) -> str:
6125
+ return re.sub(r"\s+", " ", text).strip(" \t\r\n。.;;").lower()
6126
+
5977
6127
  def handle_response(
5978
6128
  self,
5979
6129
  response: Json,
@@ -6642,14 +6792,35 @@ class StatusBar:
6642
6792
  context = str(len(session.state.conversation)) + "/" + str(session.settings.compact_at)
6643
6793
  last_tokens = _format_count(session.state.last_total_tokens)
6644
6794
  session_tokens = _format_count(session.state.session_total_tokens)
6645
- tokens = "last:" + last_tokens + " session:" + session_tokens
6646
- 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]
6647
6798
  if show_elapsed:
6648
- parts.append(f"{turn_elapsed:.1f}s")
6799
+ parts.append(f"turn:{turn_elapsed:.1f}s")
6649
6800
  if session.state.current_model_call_started_at > 0:
6650
- parts.append("calling(" + str(session.state.turn_model_calls) + "):" + f"{max(0.0, now - session.state.current_model_call_started_at):.1f}s")
6801
+ activity = self._activity_label(session.state.current_model_call_activity)
6802
+ if session.state.current_model_call_has_content:
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
6807
+ parts.append(
6808
+ activity
6809
+ + "("
6810
+ + str(session.state.turn_model_calls)
6811
+ + "):"
6812
+ + f"{elapsed:.1f}s"
6813
+ )
6814
+ if rate > 0:
6815
+ parts[3] += " " + _format_count(int(rate)) + "t/s"
6816
+ if session.state.status_notice and session.state.status_notice_until > now:
6817
+ parts.append(session.state.status_notice)
6651
6818
  return " | ".join(parts)
6652
6819
 
6820
+ @staticmethod
6821
+ def _activity_label(activity: str) -> str:
6822
+ return {"compact": "compacting", "observe": "observing"}.get(activity, "working")
6823
+
6653
6824
  def _sweep_fragments(self, text: str, now: float) -> list[tuple[str, str]]:
6654
6825
  if not text:
6655
6826
  return [("", "")]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.3.27
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.27"
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