nanocode-cli 0.3.28__tar.gz → 0.3.34__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.34
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.34"
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)
@@ -1167,6 +1169,17 @@ class ToolResultContext:
1167
1169
  self.recent = [compact(block) for block in self.recent]
1168
1170
  self.latest = [compact(block) for block in self.latest]
1169
1171
 
1172
+ def act_blocks(self) -> list[str]:
1173
+ # Pending observe blocks are unresolved raw results. Keep them visible to
1174
+ # ACT until observe explicitly keeps/forgets them; otherwise an older
1175
+ # pending result can degrade to a compact recent summary before observe
1176
+ # gets a chance to select it.
1177
+ latest_keys = set(self.blocks_by_key(self.latest))
1178
+ pending = [block for block in self.pending_observe if self.result_key(block) not in latest_keys]
1179
+ raw_keys = set(self.blocks_by_key(pending)) | latest_keys
1180
+ recent = [block for block in self.recent if self.result_key(block) not in raw_keys]
1181
+ return recent + pending + self.latest
1182
+
1170
1183
  def visible_counter(self, mode: AgentMode) -> int:
1171
1184
  if mode == AgentMode.OBSERVE and self.pending_observe:
1172
1185
  return self.max_counter(self.pending_observe)
@@ -1670,7 +1683,7 @@ class SearchTool(Tool):
1670
1683
  MAX_CONTEXT_LINES: ClassVar[int] = 30
1671
1684
  EFFECT: ClassVar[ToolEffect] = ToolEffect.READONLY
1672
1685
  DESCRIPTION: ClassVar[tuple[str, ...]] = (
1673
- "Case-insensitive regex search before Read; use A|B|C for alternatives.",
1686
+ "Case-insensitive regex search before Read; use A|B|C for alternatives and \\n for multiline matches.",
1674
1687
  "For exact text, escape regex metacharacters like braces, parens, dots, stars, and brackets.",
1675
1688
  "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
1689
  "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 +1726,7 @@ class SearchTool(Tool):
1713
1726
  pattern = raw_pattern[3:] if raw_pattern.startswith("re:") else raw_pattern
1714
1727
  if not pattern:
1715
1728
  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.")
1729
+ pattern = pattern.replace("\\n", "\n").replace("\\r", "\r")
1718
1730
  target_path_arg = "."
1719
1731
  glob_pattern = ""
1720
1732
  context_lines = cls.CONTEXT_LINES
@@ -1863,7 +1875,9 @@ class SearchTool(Tool):
1863
1875
  yield path
1864
1876
 
1865
1877
  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))
1878
+ text = text.rstrip("\n")
1879
+ preview = _shorten(" ".join(text.split()), 300) if "\n" in text or "\r" in text else text[:300]
1880
+ return self.Match(path=path, line_number=line_number, text=preview, context=self._read_match_context(path, line_number))
1867
1881
 
1868
1882
  def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
1869
1883
  if line_number <= 0:
@@ -1902,6 +1916,8 @@ class SearchTool(Tool):
1902
1916
 
1903
1917
  def _rg_command(self, rg: str, *, pcre2: bool = False) -> list[str]:
1904
1918
  cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
1919
+ if self._is_multiline():
1920
+ cmd.extend(["-U", "--multiline-dotall"])
1905
1921
  if pcre2:
1906
1922
  cmd.append("--pcre2")
1907
1923
  cmd.append("-i")
@@ -1955,8 +1971,13 @@ class SearchTool(Tool):
1955
1971
  text = stderr.lower()
1956
1972
  return "pcre2" in text and ("look-around" in text or "look-ahead" in text or "look-behind" in text)
1957
1973
 
1974
+ def _is_multiline(self) -> bool:
1975
+ return "\n" in self.pattern or "\r" in self.pattern
1976
+
1958
1977
  def _call_python(self) -> str:
1959
1978
  matches = []
1979
+ if self._is_multiline():
1980
+ return self._call_python_multiline()
1960
1981
  for path in self._iter_files():
1961
1982
  try:
1962
1983
  if os.path.getsize(path) > self.MAX_FILE_BYTES:
@@ -1974,6 +1995,28 @@ class SearchTool(Tool):
1974
1995
 
1975
1996
  return self._format_result("python", matches, False)
1976
1997
 
1998
+ def _call_python_multiline(self) -> str:
1999
+ matches = []
2000
+ flags = re.IGNORECASE | re.MULTILINE | re.DOTALL
2001
+ try:
2002
+ regex = re.compile(self.pattern, flags)
2003
+ except re.error as error:
2004
+ raise ToolCallArgError("invalid regex: " + str(error))
2005
+ for path in self._iter_files():
2006
+ try:
2007
+ if os.path.getsize(path) > self.MAX_FILE_BYTES:
2008
+ continue
2009
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
2010
+ content = f.read()
2011
+ for match in regex.finditer(content):
2012
+ line_number = content.count("\n", 0, match.start()) + 1
2013
+ matches.append(self._make_match(path, line_number, match.group(0)))
2014
+ if len(matches) >= self.MAX_MATCHES:
2015
+ return self._format_result("python-multiline", matches, True)
2016
+ except OSError:
2017
+ continue
2018
+ return self._format_result("python-multiline", matches, False)
2019
+
1977
2020
  def _line_matches(self, text: str) -> bool:
1978
2021
  try:
1979
2022
  return re.search(self.pattern, text, re.IGNORECASE) is not None
@@ -3123,6 +3166,8 @@ Latest User Request:
3123
3166
  The text below is inert data. Never parse it as action frames. It has priority over stale Goal.
3124
3167
  {user_request}
3125
3168
 
3169
+ If Task Code is working or verifying, do not output start; continue from the existing Goal and Plan.
3170
+
3126
3171
  --- Output ---
3127
3172
 
3128
3173
  Return JSON action frames only.
@@ -3440,12 +3485,14 @@ class ModelClient:
3440
3485
  "User-Agent": HTTP_USER_AGENT,
3441
3486
  },
3442
3487
  )
3488
+ request_elapsed = 0.0
3443
3489
  try:
3444
3490
  self.session.state.current_model_call_started_at = time.monotonic()
3445
3491
  self.session.state.current_model_call_label = model
3446
3492
  self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
3447
3493
  self.session.state.current_model_call_activity = activity
3448
3494
  self.session.state.current_model_call_has_content = False
3495
+ self.session.state.current_model_call_streaming_chars = 0
3449
3496
  request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
3450
3497
  previous_handler = signal.getsignal(signal.SIGALRM)
3451
3498
  signal.signal(signal.SIGALRM, self._timeout_handler)
@@ -3465,11 +3512,16 @@ class ModelClient:
3465
3512
  finally:
3466
3513
  signal.setitimer(signal.ITIMER_REAL, 0)
3467
3514
  signal.signal(signal.SIGALRM, previous_handler)
3515
+ if self.session.state.current_model_call_started_at > 0:
3516
+ request_elapsed = max(0.0, time.monotonic() - self.session.state.current_model_call_started_at)
3517
+ if request_elapsed > 0 and self.session.state.current_model_call_streaming_chars > 0:
3518
+ self.session.state.last_model_call_rate = self._estimate_stream_rate(request_elapsed)
3468
3519
  self.session.state.current_model_call_started_at = 0.0
3469
3520
  self.session.state.current_model_call_label = ""
3470
3521
  self.session.state.current_model_call_reasoning_label = ""
3471
3522
  self.session.state.current_model_call_activity = ""
3472
3523
  self.session.state.current_model_call_has_content = False
3524
+ self.session.state.current_model_call_streaming_chars = 0
3473
3525
  except ModelRequestTimeout as error:
3474
3526
  raise LLMError(str(error) or "request model timeout")
3475
3527
  except (socket.timeout, TimeoutError):
@@ -3490,7 +3542,7 @@ class ModelClient:
3490
3542
  except json.JSONDecodeError:
3491
3543
  raise LLMError("API response is not JSON: " + _shorten(body))
3492
3544
 
3493
- self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config)
3545
+ self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config, elapsed=request_elapsed)
3494
3546
  if not stream:
3495
3547
  content = self._message_content(result)
3496
3548
  if content is None:
@@ -3538,8 +3590,12 @@ class ModelClient:
3538
3590
  self.session.state.current_model_call_has_content = True
3539
3591
  self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
3540
3592
  parts.append(content)
3593
+ self.session.state.current_model_call_streaming_chars += len(content)
3541
3594
  return "".join(parts), usage
3542
3595
 
3596
+ def _estimate_stream_rate(self, elapsed: float) -> float:
3597
+ return self.session.state.current_model_call_streaming_chars / 4 / elapsed if elapsed > 0 else 0.0
3598
+
3543
3599
  def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
3544
3600
  remaining = request_deadline - time.monotonic()
3545
3601
  if remaining <= 0:
@@ -3579,8 +3635,11 @@ class ModelClient:
3579
3635
  def _parse_model_content(self, content: str) -> Json:
3580
3636
  text = content.strip()
3581
3637
  text = self._strip_leaked_think_tags(text)
3638
+ text = self._strip_leaked_tool_code(text)
3582
3639
  text = self._strip_json_fence(text)
3640
+ text = self._strip_fence_marker_lines(text)
3583
3641
  text = self._strip_leaked_think_tags(text)
3642
+ text = self._strip_leaked_tool_code(text)
3584
3643
  if not self._has_action_frame_end(text):
3585
3644
  actions, error = self._parse_unmarked_actions(text)
3586
3645
  if actions:
@@ -3677,9 +3736,10 @@ class ModelClient:
3677
3736
 
3678
3737
  def _normalize_tool_type(self, action: Json) -> None:
3679
3738
  action_type = _json_str(action.get("type"))
3680
- if action_type in TOOL_REGISTRY:
3739
+ tool_name = next((name for name in TOOL_REGISTRY if name.lower() == action_type.lower()), "") if action_type else ""
3740
+ if tool_name:
3681
3741
  action["type"] = "tool"
3682
- action.setdefault("name", action_type)
3742
+ action.setdefault("name", tool_name)
3683
3743
 
3684
3744
  def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
3685
3745
  actions: list[Json] = []
@@ -3704,12 +3764,15 @@ class ModelClient:
3704
3764
  return parsed, ""
3705
3765
  action_start = text.find("{", index)
3706
3766
  if action_start < 0:
3767
+ progress = self._plain_progress_text(text[index:])
3768
+ if progress:
3769
+ return [{"type": "progress", "text": progress}], ""
3707
3770
  try:
3708
3771
  decoder.raw_decode(text, index)
3709
3772
  except json.JSONDecodeError as error:
3710
3773
  return [], str(error)
3711
3774
  return [], "expected JSON object action"
3712
- prefix = _shorten(" ".join(text[:action_start].split()), 500)
3775
+ prefix = self._progress_text(text[:action_start])
3713
3776
  index = action_start
3714
3777
  while True:
3715
3778
  while index < len(text) and text[index].isspace():
@@ -3721,6 +3784,14 @@ class ModelClient:
3721
3784
  try:
3722
3785
  value, index = decoder.raw_decode(text, index)
3723
3786
  except json.JSONDecodeError as error:
3787
+ if actions:
3788
+ return [], str(error)
3789
+ if self._should_repair_json_decode_error(str(error), text):
3790
+ repaired, repair_error = self._repair_single_json_action(text)
3791
+ if not repair_error:
3792
+ if prefix:
3793
+ repaired.insert(0, {"type": "progress", "text": prefix})
3794
+ return repaired, ""
3724
3795
  return [], str(error)
3725
3796
  parsed, error = self._actions_from_json_value(value)
3726
3797
  if error:
@@ -3734,12 +3805,40 @@ class ModelClient:
3734
3805
  if index < len(text) and text[index] != "{":
3735
3806
  next_action = text.find("{", index)
3736
3807
  if next_action < 0:
3808
+ if self._should_repair_trailing_json_text(text[index:]):
3809
+ repaired, error = self._repair_single_json_action(text)
3810
+ if not error:
3811
+ return repaired, ""
3737
3812
  return [], "unexpected text after JSON action"
3738
- progress = _shorten(" ".join(text[index:next_action].split()), 500)
3813
+ progress = self._progress_text(text[index:next_action])
3739
3814
  if progress:
3740
3815
  actions.append({"type": "progress", "text": progress})
3741
3816
  index = next_action
3742
3817
 
3818
+ def _progress_text(self, text: str) -> str:
3819
+ text = re.sub(r"```[a-zA-Z0-9_-]*", "", text)
3820
+ text = text.replace("```", "")
3821
+ return _shorten(" ".join(text.split()), 500)
3822
+
3823
+ def _plain_progress_text(self, text: str) -> str:
3824
+ progress = self._progress_text(text)
3825
+ if not progress or "{" in progress or "}" in progress:
3826
+ return ""
3827
+ starters = (
3828
+ "let me ",
3829
+ "i need ",
3830
+ "i will ",
3831
+ "i'll ",
3832
+ "now ",
3833
+ "next ",
3834
+ "我需要",
3835
+ "让我",
3836
+ "我会",
3837
+ "现在",
3838
+ "接下来",
3839
+ )
3840
+ return progress if progress.lower().startswith(starters) else ""
3841
+
3743
3842
  def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
3744
3843
  decoder = json.JSONDecoder()
3745
3844
  value, end = decoder.raw_decode(text, index)
@@ -3753,6 +3852,21 @@ class ModelClient:
3753
3852
  raise ValueError("expected JSON action array")
3754
3853
  return value, len(text)
3755
3854
 
3855
+ def _repair_single_json_action(self, text: str) -> tuple[list[Json], str]:
3856
+ try:
3857
+ value = json_repair.loads(text)
3858
+ except Exception as error:
3859
+ return [], str(error)
3860
+ if isinstance(value, list):
3861
+ return [], "unexpected text after JSON action"
3862
+ return self._actions_from_json_value(value)
3863
+
3864
+ def _should_repair_json_decode_error(self, error: str, text: str) -> bool:
3865
+ return "Invalid control character" in error or re.fullmatch(r".*[}\]]\s*[}\]]+\s*", text, re.DOTALL) is not None
3866
+
3867
+ def _should_repair_trailing_json_text(self, text: str) -> bool:
3868
+ return re.fullmatch(r"\s*[}\]]+\s*", text) is not None
3869
+
3756
3870
  def _has_action_frame_end(self, line: str) -> bool:
3757
3871
  return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
3758
3872
 
@@ -3766,6 +3880,9 @@ class ModelClient:
3766
3880
  lines = lines[:-1]
3767
3881
  return "\n".join(lines).strip()
3768
3882
 
3883
+ def _strip_fence_marker_lines(self, text: str) -> str:
3884
+ return re.sub(r"(?m)^\s*```[a-zA-Z0-9_-]*\s*$\n?", "", text).strip()
3885
+
3769
3886
  def _strip_leaked_think_tags(self, text: str) -> str:
3770
3887
  text = text.strip()
3771
3888
  while text.startswith("</think>"):
@@ -3779,6 +3896,9 @@ class ModelClient:
3779
3896
  text = text[len("</think>") :].lstrip()
3780
3897
  return text
3781
3898
 
3899
+ def _strip_leaked_tool_code(self, text: str) -> str:
3900
+ return re.sub(r"<tool_code>.*?</tool_code>", "", text, flags=re.DOTALL).strip()
3901
+
3782
3902
  def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
3783
3903
  guidance = ""
3784
3904
  if self._strip_leaked_think_tags(content.strip()).startswith("<tool_call>"):
@@ -3812,10 +3932,12 @@ class ModelClient:
3812
3932
  }
3813
3933
  return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
3814
3934
 
3815
- def _record_usage(self, usage: Json, config: ProviderConfig) -> None:
3935
+ def _record_usage(self, usage: Json, config: ProviderConfig, *, elapsed: float = 0.0) -> None:
3816
3936
  prompt_tokens = self._json_int(usage.get("prompt_tokens"))
3817
3937
  completion_tokens = self._json_int(usage.get("completion_tokens"))
3818
3938
  total_tokens = self._json_int(usage.get("total_tokens"))
3939
+ if completion_tokens > 0 and elapsed > 0:
3940
+ self.session.state.last_model_call_rate = completion_tokens / elapsed
3819
3941
  self.session.state.last_prompt_tokens = prompt_tokens
3820
3942
  self.session.state.last_completion_tokens = completion_tokens
3821
3943
  self.session.state.last_total_tokens = total_tokens
@@ -4786,9 +4908,8 @@ class Agent:
4786
4908
  RECENT_TOOL_CALL_CHARS: ClassVar[int] = 72_000
4787
4909
  KEPT_TOOL_RESULT_CHARS: ClassVar[int] = 96_000
4788
4910
  RECENT_TOOL_CALL_SUMMARIES: ClassVar[int] = 40
4789
- PENDING_OBSERVE_RESULTS: ClassVar[int] = 8
4790
- PENDING_OBSERVE_CHAR_RATIO: ClassVar[float] = 0.4
4791
- PENDING_OBSERVE_TOOL_TURNS: ClassVar[int] = 2
4911
+ # Trigger observe after this many unresolved raw tool result blocks accumulate.
4912
+ OBSERVE_AFTER_PENDING_RESULT_COUNT: ClassVar[int] = 8
4792
4913
  PLAN_MODE_GIT_READONLY: ClassVar[frozenset[str]] = GIT_READONLY_COMMANDS
4793
4914
 
4794
4915
  def __init__(self, session: Session):
@@ -4810,6 +4931,7 @@ class Agent:
4810
4931
  self.failed_tool_call_count = 0
4811
4932
  self.agent_feedback_errors: list[str] = []
4812
4933
  self.observe_feedback_errors: list[str] = []
4934
+ self.task_alignment_required = False
4813
4935
  self.mode = AgentMode.ACT
4814
4936
 
4815
4937
  def build_user_prompt(self) -> str:
@@ -4926,7 +5048,7 @@ class Agent:
4926
5048
  def _format_recent_tool_call_context(self) -> str:
4927
5049
  if self.mode == AgentMode.OBSERVE and self.tool_context.pending_observe:
4928
5050
  return "\n\n".join(self.tool_context.pending_observe)
4929
- return "\n\n".join(self.tool_context.recent + self.tool_context.latest)
5051
+ return "\n\n".join(self.tool_context.act_blocks())
4930
5052
 
4931
5053
  def _prune_tool_result_store(self) -> None:
4932
5054
  keep = self._protected_tool_result_keys()
@@ -5092,11 +5214,7 @@ class Agent:
5092
5214
  return False
5093
5215
  if any(self._tool_failure_needs_observe(execution) for execution in self.tool_runner.latest_executions):
5094
5216
  return True
5095
- if len(pending) >= self.PENDING_OBSERVE_RESULTS:
5096
- return True
5097
- if len("\n\n".join(pending)) >= int(self.RECENT_TOOL_CALL_CHARS * self.PENDING_OBSERVE_CHAR_RATIO):
5098
- return True
5099
- return self.runtime.consecutive_tool_turns >= self.PENDING_OBSERVE_TOOL_TURNS
5217
+ return len(pending) >= self.OBSERVE_AFTER_PENDING_RESULT_COUNT
5100
5218
 
5101
5219
  def _tool_failure_needs_observe(self, execution: ToolCallExecution) -> bool:
5102
5220
  if execution.outcome == "success":
@@ -5481,6 +5599,24 @@ class Agent:
5481
5599
  "PlanMode_Gate: " + plan_mode_tool_error + ".",
5482
5600
  )
5483
5601
  return True
5602
+ if (
5603
+ self.blackboard.task_code == TaskCode.NEW
5604
+ and self.task_alignment_required
5605
+ and (ctx.tool_calls or ctx.pending_verify_requested)
5606
+ and not ctx.has_goal_action
5607
+ and not ctx.has_plan_action
5608
+ and not ctx.has_user_rule_action
5609
+ ):
5610
+ self._remember_agent_error(
5611
+ "Error: previous task context is still present. Rule: before work, emit start if the latest request changes the task; "
5612
+ "if continuing, update or confirm the plan first."
5613
+ )
5614
+ self._report_gate(
5615
+ on_message,
5616
+ "Retrying: align this request with the task before work.",
5617
+ "GoalPlan_Gate: work before task alignment with previous task context.",
5618
+ )
5619
+ return True
5484
5620
  if self.blackboard.task_code != TaskCode.NEW and any(_json_str(action.get("type")) == "start" for action in ctx.actions):
5485
5621
  self._remember_agent_error(
5486
5622
  "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 +6098,16 @@ class Agent:
5962
6098
  self.mode = AgentMode.ACT
5963
6099
  self.session.state.turn_tool_calls = 0
5964
6100
  self.session.state.turn_model_calls = 0
6101
+ old_goal = self.blackboard.goal
6102
+ old_task_context = bool(self.blackboard.goal or self.blackboard.plan or self.blackboard.hypotheses)
5965
6103
  self.blackboard.user_input = user_input
5966
6104
  previous_task_done = self.blackboard.task_code == TaskCode.DONE
5967
6105
  if previous_task_done:
5968
6106
  self.blackboard.work_mode = WorkMode.NORMAL
6107
+ # Keep previous task state at a new user turn so short follow-ups like
6108
+ # "continue" can resume. The first response must align with it before work
6109
+ # when the new request does not match the previous goal.
6110
+ self.task_alignment_required = old_task_context and self._task_text_key(user_input) != self._task_text_key(old_goal)
5969
6111
  self.blackboard.task_code = TaskCode.NEW
5970
6112
  self.blackboard.goal_reached = False
5971
6113
  self.blackboard.verification_required = False
@@ -5988,6 +6130,9 @@ class Agent:
5988
6130
  on_step_limit=lambda: (_ for _ in ()).throw(LLMError("agent step limit reached")),
5989
6131
  )
5990
6132
 
6133
+ def _task_text_key(self, text: str) -> str:
6134
+ return re.sub(r"\s+", " ", text).strip(" \t\r\n。.;;").lower()
6135
+
5991
6136
  def handle_response(
5992
6137
  self,
5993
6138
  response: Json,
@@ -6656,21 +6801,27 @@ class StatusBar:
6656
6801
  context = str(len(session.state.conversation)) + "/" + str(session.settings.compact_at)
6657
6802
  last_tokens = _format_count(session.state.last_total_tokens)
6658
6803
  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]
6804
+ rate = session.state.last_model_call_rate
6805
+ token_summary = "last:" + last_tokens + " sess:" + session_tokens
6806
+ parts = [model + " (" + reasoning + ")" + modes, "ctx:" + context, "tool:" + str(session.state.turn_tool_calls), "tok:" + token_summary]
6661
6807
  if show_elapsed:
6662
- parts.append(f"{turn_elapsed:.1f}s")
6808
+ parts.append(f"turn:{turn_elapsed:.1f}s")
6663
6809
  if session.state.current_model_call_started_at > 0:
6664
6810
  activity = self._activity_label(session.state.current_model_call_activity)
6665
6811
  if session.state.current_model_call_has_content:
6666
6812
  activity += "*"
6813
+ elapsed = max(0.0, now - session.state.current_model_call_started_at)
6814
+ if session.state.current_model_call_has_content and elapsed > 0:
6815
+ rate = session.state.current_model_call_streaming_chars / 4 / elapsed
6667
6816
  parts.append(
6668
6817
  activity
6669
6818
  + "("
6670
6819
  + str(session.state.turn_model_calls)
6671
6820
  + "):"
6672
- + f"{max(0.0, now - session.state.current_model_call_started_at):.1f}s"
6821
+ + f"{elapsed:.1f}s"
6673
6822
  )
6823
+ if rate > 0:
6824
+ parts[3] += " " + _format_count(int(rate)) + "t/s"
6674
6825
  if session.state.status_notice and session.state.status_notice_until > now:
6675
6826
  parts.append(session.state.status_notice)
6676
6827
  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.34
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.34"
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