localcode 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

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.
localcode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -45,6 +45,37 @@ if TYPE_CHECKING:
45
45
  __all__: list[str] = []
46
46
 
47
47
 
48
+ def _spill_tool_output(result: str, tool_name: str) -> str | None:
49
+ """Write an oversized tool result to a file and return its path.
50
+
51
+ Truncation alone makes a small model re-run the command "to see the rest".
52
+ Instead we keep the full output on disk and point the model at it (grep/
53
+ read the exact part). Best-effort: None on error → caller falls back to
54
+ plain truncation. Content-hashed filenames; old spills reaped after 7 days.
55
+ """
56
+ try:
57
+ import hashlib
58
+ import time as _time
59
+ from ..paths import global_state_dir
60
+ spill_dir = global_state_dir() / "tool_output"
61
+ spill_dir.mkdir(parents=True, exist_ok=True)
62
+ # Reap spills older than 7 days so this never grows unbounded.
63
+ try:
64
+ cutoff = _time.time() - 7 * 86400
65
+ for old in spill_dir.glob("*.txt"):
66
+ if old.stat().st_mtime < cutoff:
67
+ old.unlink(missing_ok=True)
68
+ except Exception:
69
+ pass
70
+ digest = hashlib.sha1(result.encode("utf-8", "replace")).hexdigest()[:12]
71
+ path = spill_dir / f"{tool_name}_{digest}.txt"
72
+ if not path.exists():
73
+ path.write_text(result, encoding="utf-8", errors="replace")
74
+ return str(path)
75
+ except Exception:
76
+ return None
77
+
78
+
48
79
  def _truncate_result(result: str, tool_name: str) -> str:
49
80
  """Truncate a tool result to its per-tool size limit with a
50
81
  strategy tuned to what each tool's output actually looks like.
@@ -70,6 +101,15 @@ def _truncate_result(result: str, tool_name: str) -> str:
70
101
  return result
71
102
 
72
103
  dropped = len(result) - limit
104
+ # Spill the FULL output to disk and point the model at it, so it fetches
105
+ # the exact part it needs instead of re-running the command to "see the
106
+ # rest". Appended to every strategy's hint below.
107
+ _spill = _spill_tool_output(result, tool_name)
108
+ spill_hint = (
109
+ f" Full output saved to {_spill} — read_file (with offset) or grep it "
110
+ f"to see the rest; do NOT re-run the command."
111
+ if _spill else ""
112
+ )
73
113
 
74
114
  if tool_name == "read_file":
75
115
  # Keep the HEAD; if we can find line numbers in the content
@@ -85,7 +125,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
85
125
  last_numbered = int(line.split("\t", 1)[0])
86
126
  hint = (
87
127
  f"\n\n[truncated — {dropped} more chars not shown. "
88
- f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}]"
128
+ f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}{spill_hint}]"
89
129
  )
90
130
  return head + hint
91
131
 
@@ -99,7 +139,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
99
139
  dropped_lines = result[limit:].count("\n") + 1
100
140
  hint = (
101
141
  f"\n\n[truncated — {dropped_lines} more match-lines not shown. "
102
- "Narrow with a more specific pattern or `include=*.py` to see them.]"
142
+ f"Narrow with a more specific pattern or `include=*.py` to see them.{spill_hint}]"
103
143
  )
104
144
  return head + hint
105
145
 
@@ -114,7 +154,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
114
154
  first_sig = "\n".join(non_empty[:8])
115
155
  last_sig = "\n".join(non_empty[-12:]) if len(non_empty) > 12 else ""
116
156
  marker = (
117
- f"\n\n[... {dropped} chars of bash output compressed ...]\n\n"
157
+ f"\n\n[... {dropped} chars of bash output compressed.{spill_hint} ...]\n\n"
118
158
  )
119
159
  if any(token in result for token in ("backend", "frontend", "src/", "node_modules", "package.json", "pyproject.toml")):
120
160
  tree_hint = (
@@ -133,14 +173,14 @@ def _truncate_result(result: str, tool_name: str) -> str:
133
173
  tail_size = limit - head_size - 64 # 64 chars for the marker line
134
174
  head = result[:head_size]
135
175
  tail = result[-tail_size:]
136
- marker = f"\n\n[... {dropped} chars of middle output dropped ...]\n\n"
176
+ marker = f"\n\n[... {dropped} chars of middle output dropped.{spill_hint} ...]\n\n"
137
177
  return head + marker + tail
138
178
 
139
179
  # Default — middle-drop.
140
180
  half = limit // 2
141
181
  return (
142
182
  result[:half]
143
- + f"\n\n[...{dropped} chars truncated...]\n\n"
183
+ + f"\n\n[...{dropped} chars truncated.{spill_hint} ...]\n\n"
144
184
  + result[-half:]
145
185
  )
146
186
 
@@ -205,11 +245,12 @@ def _semantic_tool_summary(content: str) -> str:
205
245
  or "Traceback " in content
206
246
  )
207
247
  if is_error:
208
- tail = "\n".join([ln for ln in lines[-12:] if ln.strip()])[:1200]
209
- return (
210
- f"[older tool error preserved: {len(content)} chars, "
211
- f"{len(lines)} lines]\n{first_line}\n{tail}"
212
- )
248
+ # Self-conditioning (arXiv:2509.09677): echoing an OLD error's text
249
+ # pushes the model to repeat the mistake (scale-independent). Recent
250
+ # errors stay intact (kept above by keep_recent) so it can fix the
251
+ # immediate problem; older ones drop to a neutral note. The progress
252
+ # ledger still records the failure fact, minus the conditioning text.
253
+ return "[an earlier tool call errored and was handled — details dropped]"
213
254
  if facts:
214
255
  return (
215
256
  f"[older successful tool result summarized: {len(content)} chars, "
localcode/agent/loop.py CHANGED
@@ -721,6 +721,19 @@ def run_agent_loop(
721
721
  ]
722
722
  except Exception:
723
723
  pass
724
+ # Inject the working-memory checklist (todo_write tool): the model
725
+ # always sees its own plan (done / in-progress / left) so it
726
+ # advances instead of repeating. Appended LAST, ephemeral, empty-safe.
727
+ try:
728
+ from ..tools.todo_write import render_todo_reminder
729
+ _todos = list(getattr(app.session, "todos", []) or [])
730
+ _todo_note = render_todo_reminder(_todos)
731
+ if _todo_note:
732
+ model_messages = list(model_messages) + [
733
+ {"role": "user", "content": _todo_note}
734
+ ]
735
+ except Exception:
736
+ pass
724
737
  round_tool_schemas = schemas_for_goal(
725
738
  _goal_state.goal_type,
726
739
  user_text,
@@ -1206,7 +1219,11 @@ def run_agent_loop(
1206
1219
  break
1207
1220
 
1208
1221
  # ── No tool calls = model is done ──
1209
- if not tool_calls:
1222
+ # Also require no pending tool call: the runtime promotes pending
1223
+ # tools into `tool_calls`, but if a future path doesn't, we keep
1224
+ # looping instead of ending early (small models often emit a stop
1225
+ # token with a call still pending).
1226
+ if not tool_calls and not _round_pending_tool_count:
1210
1227
  # Match v0.2.12's no-tool-calls path exactly: render content,
1211
1228
  # render any grounded file summary, break. Nothing else.
1212
1229
  #
@@ -42,11 +42,12 @@ _STACK_MARKERS: tuple[tuple[str, str], ...] = (
42
42
  SYSTEM_PROMPT = """\
43
43
  You are LocalCode, a coding agent on the user's machine with full filesystem access.
44
44
 
45
- Available tools: read_file, write_file, append_file, edit_file, bash, list_files.
45
+ Available tools: read_file, write_file, append_file, edit_file, bash, list_files, todo_write.
46
46
 
47
47
  How to work:
48
48
  - Match scope to the request. Answer plain questions plainly. Build what was asked when asked. Don't write a script when a one-line bash command will do.
49
49
  - Cover every requirement the user named. If your chosen approach can't deliver one, change the approach — don't drop the requirement to fit the approach.
50
+ - PLAN MULTI-STEP WORK. For any task with 3+ steps, call `todo_write` FIRST to lay out the steps, then keep it current: mark exactly ONE item in_progress before you start it, and mark it completed the instant it's done. Your task list is shown back to you every round — read it to see what's already finished and what's left, so you advance to the next step instead of redoing one you've already done. If you catch yourself about to re-read a file or re-run a command you ran before, check the list: you've likely already done it.
50
51
  - If the request is ambiguous in a way that affects your approach (stack, interface, scope), ask one short question before building, not after.
51
52
  - ACT, DON'T NARRATE. Every "let me read", "let me fix", "now I'll write" MUST be followed by the actual tool call IN THE SAME TURN. If you say "let me write the fix" and then end your turn without calling edit_file or write_file, you have failed the user. The user has explicitly complained about this. Never describe a fix without immediately performing it.
52
53
  - Forbidden patterns: "Let me read the file:" + end of turn. "I'll rewrite this:" + end of turn. "Now I'll apply the fix:" + end of turn. If the next thing out of your mouth is a verb of intent, the very next action MUST be the tool call that delivers on that intent.
@@ -58,6 +59,8 @@ How to work:
58
59
  - For new projects, create a small multi-file structure by default. Keep entrypoints thin; move reusable logic, styles, data/config, templates, and assets into focused files. Use one large file only if requested.
59
60
  - Prefer edit_file for existing files. Use write_file when creating a new file or doing a deliberate full rewrite.
60
61
  - When a tool returns an error, read it, fix the specific problem, and retry with DIFFERENT input. Never repeat an identical failing call: if the same call fails or returns the same result twice, change the arguments or the approach, or move on — do not loop. Don't give up after one failed call, but don't spin on it either.
62
+ - DON'T RE-READ OR RE-SEARCH what you already have. If you already read a file this turn and it hasn't changed, the content is still above — use it, don't read it again. If a search already gave you a useful result, act on it (open the match, make the edit); don't run the same search again. Repeating a read/search you've already done is the #1 way turns stall.
63
+ - After a tool result, CONTINUE from where you left off — don't restate what you just did or re-summarize the plan. Take the next concrete action.
61
64
  - DON'T CONFABULATE. If the user names a person, song, place, term, library, command, or concept you do not recognize, your FIRST move is to say "I don't recognize that" — NOT to invent a plausible-sounding meaning by phonetic association. Phonetic fits ("Alombasi sounds Bantu so it must be a Zambian chant", "Pyfoo sounds like a Python library so it must do X") are exactly the failure mode. If a web search would help, run it BEFORE asserting facts; if results are empty, say so plainly. Never write paragraph-length cultural/etymological/technical descriptions of something you can't actually source — "I'd love to know more, what's it from?" is the correct answer. Doubling down when the user repeats the unknown term is also forbidden; repetition is not evidence.
62
65
 
63
66
  Runtime facts (true today; rely on these instead of guessing):
localcode/entrypoint.py CHANGED
@@ -22,21 +22,74 @@ _TERMINAL_RESTORE = (
22
22
  "\x1b[?25h\x1b[?1049l\x1b[0m"
23
23
  )
24
24
 
25
+ # Snapshot of the controlling tty's termios settings, captured BEFORE the
26
+ # TUI puts the terminal into raw mode. The escape sequence above only undoes
27
+ # escape-driven state (alt-screen, mouse tracking, hidden cursor); it does
28
+ # NOT undo the kernel-level raw mode (ECHO/ICANON/ISIG off). If the TUI
29
+ # crashes or hangs and its own teardown is skipped, the terminal is left in
30
+ # raw mode — no echo, and Ctrl+C generates no SIGINT — which looks like a
31
+ # dead terminal. Restoring this snapshot (or `stty sane`) brings it back.
32
+ _ORIG_TERMIOS = None # tuple[int fd, list attrs] | None
33
+
34
+
35
+ def _snapshot_terminal_state() -> None:
36
+ """Capture the tty's termios attrs before the TUI mutates them."""
37
+ global _ORIG_TERMIOS
38
+ if _ORIG_TERMIOS is not None:
39
+ return
40
+ try:
41
+ import termios
42
+ for stream in (sys.__stdin__, sys.__stdout__):
43
+ try:
44
+ if stream is not None and stream.isatty():
45
+ fd = stream.fileno()
46
+ _ORIG_TERMIOS = (fd, termios.tcgetattr(fd))
47
+ return
48
+ except Exception:
49
+ continue
50
+ except Exception:
51
+ pass
52
+
25
53
 
26
54
  def _reset_terminal_state() -> None:
27
55
  payload = _TERMINAL_RESTORE.encode()
56
+ wrote = False
28
57
  try:
29
58
  with open("/dev/tty", "wb", buffering=0) as tty:
30
59
  tty.write(payload)
31
- return
60
+ wrote = True
32
61
  except Exception:
33
62
  pass
63
+ if not wrote:
64
+ try:
65
+ if sys.__stdout__.isatty():
66
+ sys.__stdout__.write(_TERMINAL_RESTORE)
67
+ sys.__stdout__.flush()
68
+ except Exception:
69
+ pass
70
+ # Undo raw mode at the kernel level — escape codes can't do this. Restore
71
+ # the snapshot if we have one; otherwise fall back to `stty sane`. Without
72
+ # this, a crashed/hung TUI leaves the shell with no echo and a dead
73
+ # Ctrl+C until the user blindly types `reset`.
34
74
  try:
35
- if sys.__stdout__.isatty():
36
- sys.__stdout__.write(_TERMINAL_RESTORE)
37
- sys.__stdout__.flush()
75
+ import termios
76
+ if _ORIG_TERMIOS is not None:
77
+ fd, attrs = _ORIG_TERMIOS
78
+ termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
79
+ else:
80
+ raise RuntimeError("no snapshot")
38
81
  except Exception:
39
- pass
82
+ try:
83
+ import subprocess
84
+ subprocess.run(
85
+ ["stty", "sane"],
86
+ stdin=open("/dev/tty", "rb"),
87
+ stdout=subprocess.DEVNULL,
88
+ stderr=subprocess.DEVNULL,
89
+ timeout=2,
90
+ )
91
+ except Exception:
92
+ pass
40
93
 
41
94
 
42
95
  def build_parser() -> argparse.ArgumentParser:
@@ -248,6 +301,9 @@ def _run_headless(config, args, console) -> int:
248
301
 
249
302
  def main(argv: list[str] | None = None) -> None:
250
303
  _harden_against_debugger_attach()
304
+ # Snapshot the terminal while it's still sane — before the TUI enters
305
+ # raw mode — so any exit path (crash, hang, kill) can restore it.
306
+ _snapshot_terminal_state()
251
307
  import os
252
308
  import signal
253
309
  import warnings
localcode/runtime.py CHANGED
@@ -242,6 +242,11 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
242
242
  self.config = config
243
243
  self._client: httpx.Client | None = None
244
244
  self.last_response_meta: dict[str, Any] = {} # for token tracking
245
+ # Circuit-breaker state for the stream-recovery loop: consecutive
246
+ # server deaths since the last successful stream. Persists across
247
+ # calls/turns (this gateway is one per session) so a deterministic
248
+ # crash can't restart-loop forever. Reset to 0 on any success.
249
+ self._consecutive_stream_deaths = 0
245
250
  # llama_cpp is the only HTTP runtime (ollama/mlx/hf removed); diffusion
246
251
  # models are architecture-routed to the one-shot CLI, not this endpoint.
247
252
  base = self.config.base_url.rstrip("/")
@@ -1324,6 +1329,10 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
1324
1329
  # error instead of thrashing.
1325
1330
  _MAX_STREAM_RESTARTS = 1
1326
1331
  _restarts_done = 0
1332
+ # Circuit-breaker ceiling: how many consecutive server deaths (across
1333
+ # calls/turns, reset on any successful stream) we'll keep restarting
1334
+ # through before declaring the server unstable and stopping the loop.
1335
+ _MAX_CONSECUTIVE_STREAM_DEATHS = 3
1327
1336
  # STREAM-LEVEL "have we handed real content to the consumer yet?"
1328
1337
  # flag. The per-attempt `_emitted_real` is reset each try; this one
1329
1338
  # survives across attempts and is the streaming-safety gate for
@@ -1814,6 +1823,10 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
1814
1823
  "type": "content",
1815
1824
  "content": _fmt_err(_LCE(_by_code("E3108"))),
1816
1825
  }
1826
+ # Reached the end of a stream without an exception — the server
1827
+ # answered. Clear the circuit breaker so a later transient death
1828
+ # gets a fresh recovery budget (only CONSECUTIVE deaths count).
1829
+ self._consecutive_stream_deaths = 0
1817
1830
  yield {
1818
1831
  "type": "stream_done",
1819
1832
  "finish_reason": last_finish_reason,
@@ -1858,7 +1871,29 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
1858
1871
  # With recovery: detect the pattern, wait for memory to
1859
1872
  # settle, restart the server, retry the stream once.
1860
1873
  err_str = _error_message(exc).lower()
1861
- is_conn_err = "connect" in err_str or "refused" in err_str
1874
+ # Classify by EXCEPTION TYPE (with a text fallback), not a bare
1875
+ # `"connect" in err_str` — that substring also matched httpx's
1876
+ # RemoteProtocolError ("peer closed CONNECTION … incomplete
1877
+ # chunked read"), but that's fine on its own; both a dead socket
1878
+ # AND a mid-body drop are recoverable by restart. The real
1879
+ # defect was the ABSENCE of a stop condition: a deterministic
1880
+ # failure (an oversized request that kills the server every
1881
+ # time) restarted + re-POSTed forever. The circuit breaker
1882
+ # below (_consecutive_stream_deaths) bounds that.
1883
+ try:
1884
+ import httpx as _httpx
1885
+ _is_protocol_drop = isinstance(exc, _httpx.RemoteProtocolError)
1886
+ _is_connect_err = isinstance(
1887
+ exc, (_httpx.ConnectError, _httpx.ConnectTimeout)
1888
+ )
1889
+ except Exception:
1890
+ _is_protocol_drop = "incomplete chunked read" in err_str or "peer closed" in err_str
1891
+ _is_connect_err = False
1892
+ if not _is_connect_err and not _is_protocol_drop:
1893
+ _is_connect_err = "connect" in err_str or "refused" in err_str
1894
+ # Both a connect failure and a mid-stream protocol drop are
1895
+ # "server went away" — recoverable by restart + retry.
1896
+ is_conn_err = _is_connect_err or _is_protocol_drop
1862
1897
  # Auto-recovery on connection-refused: try ONE server
1863
1898
  # restart before surfacing the error to the user. Any
1864
1899
  # time the server is dead (pressure-kill, OOM, crash,
@@ -1876,27 +1911,44 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
1876
1911
  # e2e run 2026-04-23T23:54, long_coding_session failed
1877
1912
  # 25/25 turns at 0.5 s each on a dead server).
1878
1913
  _pressure_related = _pressure_kill_recent()
1879
- # STREAMING-SAFETY RULE: a connection drop AFTER we've already
1880
- # yielded real content cannot be recovered by restart+retry —
1881
- # re-running the stream would re-emit content the consumer
1882
- # already saw (double answer / corrupted turn). Restart+retry
1883
- # is only safe when nothing has been handed downstream yet
1884
- # (drop before first token). Mid-stream → surface E3102 with
1885
- # diagnostic context instead of silently replaying.
1914
+ # STREAMING-SAFETY: a mid-stream drop that already yielded
1915
+ # content may replay a partial duplicate on retry — acceptable
1916
+ # (the recovery stage explains it) and far better than killing a
1917
+ # turn that would otherwise self-heal (a hard E3102 here used to
1918
+ # kill builds on a transient memory-pressure pause).
1886
1919
  if is_conn_err:
1887
- # Capture the disconnect class for diagnostics.
1920
+ # Capture the disconnect class for diagnostics (both a dead
1921
+ # socket and a mid-body protocol drop land here).
1888
1922
  _log_disconnect_context(
1889
- "stream_chat_events", exc, mid_stream=_emitted_real_stream
1923
+ "stream_chat_events", exc,
1924
+ mid_stream=_emitted_real_stream or _is_protocol_drop,
1890
1925
  )
1891
- # RECOVER even when content was already streamed: restart +
1892
- # retry (below). The server gets paused/killed under memory
1893
- # pressure mid-build and used to silently restart+continue;
1894
- # hard-failing with E3102 here was a REGRESSION (turns that
1895
- # used to self-heal started dying). A mid-response restart
1896
- # may replay a partial duplicate of the reply — far better
1897
- # than killing the whole turn. The retry path emits a clear
1898
- # "restarting" stage so the duplicate is explained.
1899
- if is_conn_err and attempt < self.config.max_retries and _restarts_done < _MAX_STREAM_RESTARTS:
1926
+ # CIRCUIT BREAKER the fix for the restart loop. Count
1927
+ # consecutive server deaths across calls (reset on any
1928
+ # successful stream, see below). A transient death recovers
1929
+ # on the first restart; a DETERMINISTIC one — a request too
1930
+ # large for the server, killing it every time — would
1931
+ # otherwise restart + re-POST forever. Once the deaths pile
1932
+ # up we stop restarting and surface actionable guidance.
1933
+ self._consecutive_stream_deaths = (
1934
+ getattr(self, "_consecutive_stream_deaths", 0) + 1
1935
+ )
1936
+ if self._consecutive_stream_deaths > _MAX_CONSECUTIVE_STREAM_DEATHS:
1937
+ yield {
1938
+ "type": "stage", "name": "server_unstable",
1939
+ "message": (
1940
+ "The model server keeps crashing on this request — "
1941
+ "it's likely too large for the current settings. "
1942
+ "Try /clear to shrink the context, or /model to "
1943
+ "switch to a lighter model."
1944
+ ),
1945
+ }
1946
+ break # stop the restart loop; fall through to E3102
1947
+ if (
1948
+ is_conn_err
1949
+ and attempt < self.config.max_retries
1950
+ and _restarts_done < _MAX_STREAM_RESTARTS
1951
+ ):
1900
1952
  _restarts_done += 1
1901
1953
  recovery_label = (
1902
1954
  "memory_pressure_recovery"
@@ -398,23 +398,20 @@ class ServerManager:
398
398
  """
399
399
  with self._lock:
400
400
  had_prior = self._process is not None
401
+ _prev_model = self._model_path # capture before shutdown clears it
401
402
  self._shutdown_locked()
402
- # Wait for the OLD server's memory to actually release
403
- # before spawning the new one. `process.wait()` returns
404
- # when the child reaps, but the kernel may take 1-3 s to
405
- # release wired Metal memory. Spawning the new ~10 GB
406
- # server in that window double-commits memory and reliably
407
- # triggers the pressure monitor on a 16 GB Mac (the bug
408
- # behind the user's "two servers running" hypothesis).
409
- #
410
- # Target: enough free memory to fit a typical model + 1 GB
411
- # margin (~11 GB for our standard quant). Best-effort —
412
- # times out at 8 s if the target isn't met, logs the
413
- # outcome, and proceeds anyway (the user wanted to switch
414
- # models; refusing entirely would be worse than retry).
403
+ # Wait for the OLD server's memory to actually release before
404
+ # spawning the new one: the kernel may take 1-3 s to free wired
405
+ # Metal memory after the child reaps, and spawning the new ~10 GB
406
+ # server in that window double-commits and trips the pressure
407
+ # monitor on a 16 GB Mac. Best-effort targets ~11 GB free, times
408
+ # out at 8 s, and proceeds anyway (refusing would be worse).
415
409
  if had_prior:
416
- _lifecycle_log("memory_wait_start",
417
- reason="model_switch", target_mb=11000)
410
+ # Only a genuine model change is "model_switch"; a same-model
411
+ # relaunch (crash/disconnect recovery) is "server_restart".
412
+ _reason = ("model_switch" if (model_path and model_path != _prev_model)
413
+ else "server_restart")
414
+ _lifecycle_log("memory_wait_start", reason=_reason, target_mb=11000)
418
415
  _wait_for_memory_release(min_free_mb=11000, timeout_s=8.0)
419
416
 
420
417
  # Find a port we can actually bind to. If the default is
localcode/session.py CHANGED
@@ -49,6 +49,10 @@ class SessionState:
49
49
  events: list[dict[str, str]] = field(default_factory=list)
50
50
  current_task: TaskState | None = None
51
51
  recent_tasks: list[TaskState] = field(default_factory=list)
52
+ # Working-memory checklist written by the todo_write tool: a list of
53
+ # {content, status, activeForm} dicts. Persisted so a resumed session
54
+ # keeps its plan; injected into context each round by the agent loop.
55
+ todos: list[dict] = field(default_factory=list)
52
56
 
53
57
 
54
58
  class SessionStore:
@@ -170,6 +174,7 @@ class SessionStore:
170
174
  events=list(data.get("events", [])),
171
175
  current_task=_load_task(current_task),
172
176
  recent_tasks=[task for task in (_load_task(t) for t in recent_tasks) if task is not None],
177
+ todos=list(data.get("todos", [])),
173
178
  )
174
179
 
175
180
  def save(self, session: SessionState) -> Path:
@@ -184,6 +189,7 @@ class SessionStore:
184
189
  "pinned_files": session.pinned_files,
185
190
  "last_assistant_text": session.last_assistant_text,
186
191
  "events": session.events,
192
+ "todos": session.todos,
187
193
  "current_task": None if session.current_task is None else {
188
194
  "task_id": session.current_task.task_id,
189
195
  "user_request": session.current_task.user_request,
@@ -36,6 +36,7 @@ from . import (
36
36
  plan_mode,
37
37
  read_file,
38
38
  skill_tool,
39
+ todo_write,
39
40
  web_fetch,
40
41
  web_search,
41
42
  write_file,
@@ -60,6 +61,7 @@ _TOOLS: dict[str, tuple[dict, Callable[[ToolContext, dict], str]]] = {
60
61
  "web_search": (web_search.SCHEMA, web_search.execute),
61
62
  "web_fetch": (web_fetch.SCHEMA, web_fetch.execute),
62
63
  "skill": (skill_tool.SCHEMA, skill_tool.execute),
64
+ "todo_write": (todo_write.SCHEMA, todo_write.execute),
63
65
  "agent": (agent.SCHEMA, agent.execute),
64
66
  "enter_plan_mode": (plan_mode.ENTER_SCHEMA, plan_mode.execute_enter),
65
67
  "exit_plan_mode": (plan_mode.EXIT_SCHEMA, plan_mode.execute_exit),
@@ -80,6 +82,7 @@ _PUBLIC_TOOL_NAMES = [
80
82
  "web_search",
81
83
  "web_fetch",
82
84
  "skill",
85
+ "todo_write",
83
86
  "agent",
84
87
  ]
85
88
 
@@ -126,6 +129,9 @@ def schemas_for_goal(
126
129
  # Sub-agent — lets the model spawn focused sub-tasks (explore/plan/verify
127
130
  # /general-purpose) so it doesn't burn its own context on long searches.
128
131
  "agent",
132
+ # Working-memory checklist — lets the model record a plan and track
133
+ # done/in-progress/remaining across rounds so it stops repeating work.
134
+ "todo_write",
129
135
  }
130
136
  schemas = schemas_for_names(selected)
131
137
  # MCP tools — any tools exposed by user-configured MCP servers in
@@ -154,6 +160,7 @@ _MODULES = {
154
160
  "web_search": web_search,
155
161
  "web_fetch": web_fetch,
156
162
  "skill": skill_tool,
163
+ "todo_write": todo_write,
157
164
  }
158
165
 
159
166
 
@@ -0,0 +1,140 @@
1
+ """todo_write — the agent's working-memory checklist.
2
+
3
+ Small quantized models lose track of what they've already done across
4
+ rounds: they re-read the same file, re-run the same `find`, and restart from
5
+ scratch. The reactive dedup guards reject those repeats but don't tell the
6
+ model where it is in the job. This tool gives the model an explicit,
7
+ persistent task list — what's done, what's in progress, what's left — that
8
+ the agent loop feeds back into context every round (see
9
+ `agent/loop.py`), so the model can progress sequentially instead of looping.
10
+
11
+ Modelled on the TodoWrite pattern: the model sends the FULL updated list
12
+ each call (it replaces the stored one), keeps exactly ONE item in_progress,
13
+ and marks items completed the moment they're done.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .base import ToolContext
18
+
19
+ _STATUSES = ("pending", "in_progress", "completed")
20
+
21
+ SCHEMA = {
22
+ "type": "function",
23
+ "function": {
24
+ "name": "todo_write",
25
+ "description": (
26
+ "Record and update your task list for a multi-step job. Call this "
27
+ "at the START of any task with 3+ steps to lay out the plan, then "
28
+ "again every time you finish a step or discover a new one. Send the "
29
+ "FULL list each time — it replaces the previous one. Keep exactly "
30
+ "ONE item 'in_progress'; mark an item 'completed' the moment it's "
31
+ "done (don't batch). This is how you remember what you've already "
32
+ "done and what's left, so you don't repeat work."
33
+ ),
34
+ "parameters": {
35
+ "type": "object",
36
+ "properties": {
37
+ "todos": {
38
+ "type": "array",
39
+ "description": "The full, updated task list (replaces the previous one).",
40
+ "items": {
41
+ "type": "object",
42
+ "properties": {
43
+ "content": {
44
+ "type": "string",
45
+ "description": "The task in imperative form, e.g. 'Add /login endpoint'.",
46
+ },
47
+ "status": {
48
+ "type": "string",
49
+ "enum": list(_STATUSES),
50
+ "description": "pending | in_progress | completed.",
51
+ },
52
+ "activeForm": {
53
+ "type": "string",
54
+ "description": "Optional present-continuous form shown while active, e.g. 'Adding /login endpoint'.",
55
+ },
56
+ },
57
+ "required": ["content", "status"],
58
+ },
59
+ },
60
+ },
61
+ "required": ["todos"],
62
+ },
63
+ },
64
+ }
65
+
66
+
67
+ def _normalize(todos) -> list[dict]:
68
+ """Coerce the model's list into clean {content, status, activeForm} dicts."""
69
+ cleaned: list[dict] = []
70
+ for item in todos:
71
+ if not isinstance(item, dict):
72
+ continue
73
+ content = str(item.get("content", "") or "").strip()
74
+ if not content:
75
+ continue
76
+ status = str(item.get("status", "pending") or "pending").strip().lower()
77
+ if status not in _STATUSES:
78
+ status = "pending"
79
+ active = str(item.get("activeForm", "") or "").strip() or content
80
+ cleaned.append({"content": content, "status": status, "activeForm": active})
81
+ return cleaned
82
+
83
+
84
+ def render_todo_reminder(todos: list[dict]) -> str:
85
+ """Render the current list as a compact reminder for the model context.
86
+
87
+ Used both by this tool's result and by the agent loop's per-round
88
+ injection. Returns "" for an empty list so the first round's prompt
89
+ prefix stays stable (cache-friendly).
90
+ """
91
+ if not todos:
92
+ return ""
93
+ lines = ["Your task list (keep exactly one in_progress; update it as you go):"]
94
+ marks = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}
95
+ for i, t in enumerate(todos, 1):
96
+ status = t.get("status", "pending")
97
+ mark = marks.get(status, "[ ]")
98
+ label = t.get("content", "")
99
+ if status == "in_progress" and t.get("activeForm"):
100
+ label = t["activeForm"]
101
+ lines.append(f"{i}. {mark} {label}")
102
+ return "\n".join(lines)
103
+
104
+
105
+ def execute(ctx: ToolContext, args: dict) -> str:
106
+ todos = args.get("todos")
107
+ if not isinstance(todos, list):
108
+ return (
109
+ "Error: todo_write expects a 'todos' array of "
110
+ "{content, status} objects. Retry with the full task list."
111
+ )
112
+ cleaned = _normalize(todos)
113
+ session = getattr(ctx.app, "session", None)
114
+
115
+ # Auto-clear once everything is done — an all-completed list means the
116
+ # job is finished, so persist it as empty rather than a wall of [x].
117
+ all_done = bool(cleaned) and all(t["status"] == "completed" for t in cleaned)
118
+ if session is not None:
119
+ session.todos = [] if all_done else cleaned
120
+
121
+ if all_done:
122
+ return "All tasks completed. Task list cleared."
123
+ if not cleaned:
124
+ if session is not None:
125
+ session.todos = []
126
+ return "Task list cleared."
127
+
128
+ in_progress = [t for t in cleaned if t["status"] == "in_progress"]
129
+ body = render_todo_reminder(cleaned)
130
+ note = ""
131
+ if len(in_progress) == 0:
132
+ note = "\nNote: nothing is in_progress — mark the next task in_progress before you start it."
133
+ elif len(in_progress) > 1:
134
+ note = "\nNote: more than one task is in_progress — keep it to exactly one at a time."
135
+ return f"Task list updated.\n{body}{note}"
136
+
137
+
138
+ def is_concurrency_safe(args: dict) -> bool:
139
+ # Mutates session state; never run it alongside other tools in a batch.
140
+ return False
@@ -174,7 +174,28 @@ def execute(ctx: ToolContext, args: dict) -> str:
174
174
  # write_file" disaster, which was the one case where rejecting
175
175
  # was load-bearing.
176
176
  existed = path.is_file()
177
+ # Capture the prior content so an in-place rewrite renders as a diff card
178
+ # (like edit_file/multi_edit), not a bare "Rewrote (N lines)" one-liner.
179
+ old_content = ""
180
+ if existed:
181
+ try:
182
+ old_content = path.read_text(errors="replace")
183
+ except Exception:
184
+ old_content = ""
177
185
  path.write_text(content)
178
186
  lines = content.count("\n") + 1
179
187
  verb = "Rewrote" if existed else "Created"
188
+
189
+ # Emit a unified diff for rewrites of existing files so the TUI draws the
190
+ # diff card. New files (or unchanged rewrites) keep the concise summary —
191
+ # a full new-file dump as "+" lines is noise, and the summary reads fine.
192
+ if existed and old_content != content:
193
+ import difflib
194
+ diff = list(difflib.unified_diff(
195
+ old_content.splitlines(keepends=True),
196
+ content.splitlines(keepends=True),
197
+ fromfile=args["path"], tofile=args["path"], lineterm="",
198
+ ))
199
+ if diff:
200
+ return "\n".join(diff[:120])
180
201
  return f"{verb} {args['path']} ({lines} lines)"
localcode/tui/app.py CHANGED
@@ -45,20 +45,36 @@ _TERMINAL_RESTORE = (
45
45
  def _restore_terminal_state() -> None:
46
46
  """Best-effort reset for shutdown paths that bypass Textual teardown."""
47
47
  payload = _TERMINAL_RESTORE.encode()
48
+ wrote = False
48
49
  try:
49
50
  with open("/dev/tty", "wb", buffering=0) as tty:
50
51
  tty.write(payload)
51
- return
52
+ wrote = True
52
53
  except Exception:
53
54
  pass
54
- try:
55
- os.write(1, payload)
56
- except Exception:
55
+ if not wrote:
57
56
  try:
58
- sys.__stdout__.write(_TERMINAL_RESTORE)
59
- sys.__stdout__.flush()
57
+ os.write(1, payload)
60
58
  except Exception:
61
- pass
59
+ try:
60
+ sys.__stdout__.write(_TERMINAL_RESTORE)
61
+ sys.__stdout__.flush()
62
+ except Exception:
63
+ pass
64
+ # Escape codes don't undo kernel raw mode (no echo, dead Ctrl+C). On the
65
+ # signal path this runs instead of the launcher's termios restore, so
66
+ # bring the line discipline back to sane here too.
67
+ try:
68
+ import subprocess
69
+ subprocess.run(
70
+ ["stty", "sane"],
71
+ stdin=open("/dev/tty", "rb"),
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ timeout=2,
75
+ )
76
+ except Exception:
77
+ pass
62
78
 
63
79
 
64
80
  class LocalCodeTUI(App):
@@ -399,16 +399,24 @@ class _ChatTextArea(TextArea):
399
399
  DEFAULT_CSS = """
400
400
  _ChatTextArea {
401
401
  background: ansi_default;
402
+ /* Scrollbar shows once the input grows past its cap. Force the brand
403
+ blue + a thin bar — otherwise it inherits the textual-ansi theme's
404
+ `scrollbar: ansi_blue`, which renders as the terminal's dark navy
405
+ (the "wrong blue"). */
406
+ scrollbar-color: #5f87ff;
407
+ scrollbar-color-hover: #5f87ff;
408
+ scrollbar-color-active: #7aa2ff;
409
+ scrollbar-background: ansi_default;
410
+ scrollbar-size-vertical: 1;
402
411
  & .text-area--cursor-line {
403
412
  background: ansi_default;
404
413
  }
405
- /* The caret. Without an explicit style it inherits the ansi_default
406
- theme where $foreground == $background, rendering an INVISIBLE
407
- cursor. Give it concrete high-contrast colors so it shows (and
408
- blinks) on every terminal, light or dark. */
414
+ /* The caret. `reverse` swaps the cell's fg/bg using the terminal's
415
+ OWN colors, so the cursor is a light block on a dark terminal and
416
+ a dark block on a light one no hardcoded hex that fights the
417
+ ansi_default palette (which rendered as a near-black block). */
409
418
  & .text-area--cursor {
410
- color: #1e1e1e;
411
- background: #d0d0d0;
419
+ text-style: reverse;
412
420
  }
413
421
  &:focus {
414
422
  background-tint: transparent 0%;
@@ -484,6 +492,14 @@ class _ChatTextArea(TextArea):
484
492
  # being cut off (the CSS `max-height` was a static 10).
485
493
  self.styles.max_height = cap
486
494
 
495
+ def on_text_area_changed(self, event: "TextArea.Changed") -> None:
496
+ # Re-measure on EVERY content change — typing, backspace, cut — not
497
+ # just paste/history. A long typed line soft-wraps in the document;
498
+ # without this the widget stayed one row tall and only the last
499
+ # wrapped row (the "end") was visible, so mid-line edits looked
500
+ # impossible. Growing the box exposes the whole wrapped input.
501
+ self.autosize_height()
502
+
487
503
  # ── input history (per-session, in-memory) ──
488
504
  # Same shell-style behaviour as the old Input: ↑ recalls older
489
505
  # submissions, ↓ walks newer / clears the draft. For a multi-line
@@ -667,6 +683,23 @@ _SLASH_COMMANDS = [
667
683
  # command handler still treats it as a no-op alias to avoid surprising
668
684
  # anyone who typed it before.
669
685
 
686
+ # Every recognized slash command (palette names + aliases the handler
687
+ # accepts). Input starting with "/" is only treated as a command when its
688
+ # first token is one of these; anything else — e.g. a filesystem path like
689
+ # /Users/you/project — is sent to the model as a normal message instead of
690
+ # being rejected as an "Unknown command". Only `!` enters shell mode.
691
+ _KNOWN_COMMANDS = {name for name, _desc in _SLASH_COMMANDS} | {
692
+ "/quit", "/search", "/copy",
693
+ }
694
+
695
+
696
+ def _is_known_command(text: str) -> bool:
697
+ """True only when `text`'s leading token is a recognized slash command."""
698
+ if not text.startswith("/"):
699
+ return False
700
+ head = text.split(None, 1)[0].lower()
701
+ return head in _KNOWN_COMMANDS
702
+
670
703
  if TYPE_CHECKING:
671
704
  from ..app import LocalCodeTUI
672
705
  from ...telemetry import TurnTrace
@@ -684,6 +717,14 @@ _SPINNER_GERUNDS = [
684
717
  ]
685
718
 
686
719
 
720
+ # How long (seconds) each spinner word stays before rotating. Deliberately
721
+ # slow: the word is a mood indicator, not a data readout. Rotating it per
722
+ # token (at 50-100 tok/s) makes it strobe unreadably — the reference CLI
723
+ # agents keep the word steady for a couple seconds and let only the little
724
+ # frame glyph + timer animate quickly. ~2.4 s reads as "alive but calm".
725
+ _SPINNER_WORD_PERIOD = 2.4
726
+
727
+
687
728
  def _spinner_label(tick: int = 0) -> str:
688
729
  """Return a generic playful gerund for the streaming spinner.
689
730
 
@@ -693,6 +734,16 @@ def _spinner_label(tick: int = 0) -> str:
693
734
  """
694
735
  return _SPINNER_GERUNDS[tick % len(_SPINNER_GERUNDS)]
695
736
 
737
+
738
+ def _spinner_label_for_elapsed(elapsed: float) -> str:
739
+ """Pick the gerund by WALL-CLOCK time, not token/tick count.
740
+
741
+ The word advances once every `_SPINNER_WORD_PERIOD` seconds regardless of
742
+ how fast tokens stream, so a fast decode no longer flickers the label.
743
+ """
744
+ idx = int(max(0.0, elapsed) / _SPINNER_WORD_PERIOD)
745
+ return _SPINNER_GERUNDS[idx % len(_SPINNER_GERUNDS)]
746
+
696
747
  _TOOL_CALL_RE = re.compile(
697
748
  r'<\|?tool_call\|?>.*?<\|?/?tool_call\|?>', re.DOTALL
698
749
  )
@@ -1311,6 +1362,14 @@ class ChatScreen(Screen):
1311
1362
  if not text:
1312
1363
  return
1313
1364
 
1365
+ # While the model is REASONING, rotate the playful gerund on a slow
1366
+ # wall-clock cadence here — never per streamed chunk. This is the one
1367
+ # place the word is chosen, so token speed can't strobe it. Fixed
1368
+ # phases ("generating") keep their word.
1369
+ if self._active_mode == "thinking" and getattr(self, "_thinking_phase", "") == "thinking":
1370
+ elapsed = time.time() - (self._turn_start or time.time())
1371
+ text = _spinner_label_for_elapsed(elapsed)
1372
+
1314
1373
  timer = self._elapsed_str()
1315
1374
 
1316
1375
  # ● for tools (blue ball), ◆ for thinking
@@ -1820,9 +1879,9 @@ class ChatScreen(Screen):
1820
1879
  if not text:
1821
1880
  return
1822
1881
  # Record into per-input history so ↑/↓ can recall it next time.
1823
- # Skipped for slash commands because users normally don't want
1824
- # `/clear` and `/quit` in their navigable history.
1825
- if not text.startswith("/"):
1882
+ # Skipped only for real slash commands (users don't want `/clear`
1883
+ # and `/quit` in navigable history) — a `/path` message IS recorded.
1884
+ if not _is_known_command(text):
1826
1885
  inp.history_push(text)
1827
1886
  inp.clear()
1828
1887
  # Belt + suspenders for the "submitted text reappears in the
@@ -1860,7 +1919,10 @@ class ChatScreen(Screen):
1860
1919
  self.set_timer(0.5, _double_clear)
1861
1920
  self.set_timer(1.5, _double_clear)
1862
1921
 
1863
- if text.startswith("/"):
1922
+ # A leading "/" is a command ONLY when it matches a known one —
1923
+ # otherwise (e.g. a pasted path like /Users/you/repo) it's a normal
1924
+ # message for the model, not an "Unknown command".
1925
+ if _is_known_command(text):
1864
1926
  self._handle_command(text)
1865
1927
  return
1866
1928
 
@@ -3958,24 +4020,18 @@ class ChatScreen(Screen):
3958
4020
  self._thinking_text += chunk
3959
4021
  self._turn_tokens += max(1, len(chunk) // 4) if chunk else 0
3960
4022
  self._thinking_phase = "thinking"
3961
- # Keep the spinner on a generic placeholder never surface the
3962
- # model's real reasoning text here (it goes to the expandable
3963
- # thinking section on thinking_done). Token count + elapsed time
3964
- # next to the label keep updating via _tick_active.
4023
+ # Do NOT touch the spinner word here the word rotates on the
4024
+ # 50 ms wall-clock timer in _tick_active, so a fast decode can't
4025
+ # strobe it (and the shimmer stays smooth instead of jumping on
4026
+ # every irregular chunk). Just make sure the animation is running.
3965
4027
  if self._active_mode != "thinking":
3966
- self._show_active_thinking(_spinner_label(self._tick_count))
3967
- else:
3968
- self._active_step_text = _spinner_label(self._tick_count)
3969
- self._tick_active()
4028
+ self._show_active_thinking("thinking")
3970
4029
  elif t == "thinking_peek":
3971
4030
  self._thinking_phase = "thinking"
3972
4031
  # thinking_peek carries model text in p["text"]; deliberately
3973
- # ignore it for the spinner label and show a placeholder instead.
4032
+ # ignore it the timer picks the placeholder word (see above).
3974
4033
  if self._active_mode != "thinking":
3975
- self._show_active_thinking(_spinner_label(self._tick_count))
3976
- else:
3977
- self._active_step_text = _spinner_label(self._tick_count)
3978
- self._tick_active()
4034
+ self._show_active_thinking("thinking")
3979
4035
  elif t == "thinking_done":
3980
4036
  text = p.get("text", "")
3981
4037
  self._thinking_text = text
@@ -120,8 +120,8 @@ class SetupScreen(Screen):
120
120
  id="setup-onetime-note",
121
121
  )
122
122
  yield Static(
123
- "[dim]q / Esc / Ctrl+C to quit — the download resumes "
124
- "where it left off next launch.[/]",
123
+ "[dim]Esc to quit — the download resumes where it "
124
+ "left off next launch.[/]",
125
125
  id="setup-quit-hint",
126
126
  )
127
127
 
@@ -838,7 +838,7 @@ class SetupScreen(Screen):
838
838
  f"[bold]/model[/].\n\n"
839
839
  f"[bold]c[/] chat now with {fallback.name} · "
840
840
  f"[bold]w[/] wait here for {target.name}\n"
841
- f"[dim]q / Esc / Ctrl+C to quit — the download resumes next launch.[/]"
841
+ f"[dim]Esc to quit — the download resumes next launch.[/]"
842
842
  )
843
843
  # Stored as the live status text; `_tick` renders it verbatim while
844
844
  # `_awaiting_choice` is set (no [dim] wrap, no clobber).
@@ -66,8 +66,10 @@ Screen {
66
66
  background-tint: transparent 0%;
67
67
  }
68
68
 
69
+ /* height: auto so the row grows with the multi-line input instead of
70
+ clipping it to a single line. The `›` prompt (height 1) top-aligns. */
69
71
  #input-row {
70
- height: 1;
72
+ height: auto;
71
73
  border: none;
72
74
  background: ansi_default;
73
75
  background-tint: transparent 0%;
@@ -87,7 +89,7 @@ Screen {
87
89
  border: none;
88
90
  padding: 0;
89
91
  margin: 0;
90
- height: 1;
92
+ height: auto;
91
93
  width: 1fr;
92
94
  background: ansi_default;
93
95
  background-tint: transparent 0%;
@@ -143,7 +143,21 @@ class ChatLog(RichLog):
143
143
  self._prompt_gap_pending: bool = False
144
144
 
145
145
  def write(self, content, *args, **kwargs) -> "ChatLog":
146
- """Override to always constrain width to the visible area."""
146
+ """Override to constrain width AND to only follow the tail when the
147
+ user is already at the bottom.
148
+
149
+ RichLog's `auto_scroll=True` snaps to the bottom on EVERY write, so
150
+ new output (streaming prose, tool lines, info) yanked a user who had
151
+ scrolled up to read back down to the end. Gate it: stick to the
152
+ bottom only when already (near) there; otherwise leave the scroll
153
+ position alone. Skipped during `_rerender`, which manages
154
+ `auto_scroll` itself and restores the exact prior position.
155
+ """
156
+ if not getattr(self, "_rerendering", False):
157
+ try:
158
+ self.auto_scroll = self._at_bottom()
159
+ except Exception:
160
+ pass
147
161
  try:
148
162
  w = self._content_width()
149
163
  if w > 10 and "width" not in kwargs:
@@ -152,6 +166,14 @@ class ChatLog(RichLog):
152
166
  pass
153
167
  return super().write(content, *args, **kwargs)
154
168
 
169
+ def _at_bottom(self) -> bool:
170
+ """True when the viewport is at (or within ~2 lines of) the bottom —
171
+ i.e. the user is following the tail rather than reading history."""
172
+ try:
173
+ return self.scroll_offset.y >= self.max_scroll_y - 2
174
+ except Exception:
175
+ return True
176
+
155
177
  def _content_width(self, *, fallback: int = 76) -> int:
156
178
  """Actual visible width for chat content.
157
179
 
@@ -46,6 +46,7 @@ def _emit_wrapped_line(
46
46
  build_cont_prefix, # callable() → Text for every wrap-continuation row
47
47
  content_style: str,
48
48
  content_width: int,
49
+ highlighted=None, # optional pre-syntax-highlighted Text for `content`
49
50
  ) -> None:
50
51
  """Write a styled diff line to `log`, wrapping long content cleanly.
51
52
 
@@ -56,7 +57,20 @@ def _emit_wrapped_line(
56
57
  width, write the first piece with the real line-number + marker
57
58
  prefix, and write each continuation piece with a blank prefix of
58
59
  the same width so everything lines up under the `+`/`-` column.
60
+
61
+ When `highlighted` (a syntax-colored Text of `content`) is given AND the
62
+ line fits on one row, we render it directly to preserve token colors;
63
+ long lines fall back to plain-style wrapping so layout never breaks.
59
64
  """
65
+ # Syntax-highlighted fast path: only when the whole line fits one row
66
+ # (avoids re-implementing style-preserving wrap). Otherwise fall through.
67
+ if highlighted is not None and content_width > 0 and len(content) <= content_width:
68
+ dl = build_first_prefix()
69
+ dl.append_text(highlighted)
70
+ log.write(dl)
71
+ log._track_lines()
72
+ return
73
+
60
74
  if content_width <= 0:
61
75
  # Degenerate terminal size — fall back to one write and let
62
76
  # Rich wrap as best it can.
@@ -94,6 +108,45 @@ def _emit_wrapped_line(
94
108
  _HUNK_HEADER_RE = re.compile(r'\+(\d+)(?:,(\d+))?')
95
109
  _HUNK_OLD_RE = re.compile(r'-(\d+)')
96
110
 
111
+ # Extension → Pygments lexer name for syntax-highlighting diff content.
112
+ _LEXER_BY_EXT = {
113
+ ".py": "python", ".pyi": "python", ".ts": "typescript", ".tsx": "tsx",
114
+ ".js": "javascript", ".jsx": "jsx", ".mjs": "javascript", ".go": "go",
115
+ ".rs": "rust", ".java": "java", ".kt": "kotlin", ".swift": "swift",
116
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp",
117
+ ".cs": "csharp", ".rb": "ruby", ".php": "php", ".css": "css",
118
+ ".scss": "scss", ".html": "html", ".xml": "xml", ".json": "json",
119
+ ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".sh": "bash",
120
+ ".bash": "bash", ".zsh": "bash", ".sql": "sql", ".md": "markdown",
121
+ }
122
+
123
+
124
+ def _lexer_for(file_path: str) -> str | None:
125
+ import os
126
+ return _LEXER_BY_EXT.get(os.path.splitext(file_path or "")[1].lower())
127
+
128
+
129
+ def _highlight(content: str, lexer: str | None):
130
+ """Syntax-highlight one code line into a Rich Text, or None on failure.
131
+
132
+ Bounded to the few preview lines a diff card shows, so the Pygments cost
133
+ is negligible. Uses `background_color="default"` so it stays transparent
134
+ over the terminal palette (ansi_default theme). Any failure → None, and
135
+ the caller falls back to the flat green/red/dim styling.
136
+ """
137
+ if not lexer or not content.strip():
138
+ return None
139
+ try:
140
+ from rich.syntax import Syntax
141
+ txt = Syntax(
142
+ content, lexer, theme="ansi_dark",
143
+ background_color="default", word_wrap=False,
144
+ ).highlight(content)
145
+ txt.rstrip() # drop the trailing newline Syntax appends
146
+ return txt
147
+ except Exception:
148
+ return None
149
+
97
150
 
98
151
  def render_diff(log: "ChatLog", diff_text: str, max_body_lines: int = 8) -> None:
99
152
  """Write a unified-diff blob to `log` with the structured layout.
@@ -118,6 +171,7 @@ def render_diff(log: "ChatLog", diff_text: str, max_body_lines: int = 8) -> None
118
171
  span = int(m.group(2) or 0)
119
172
  max_line_no = max(max_line_no, start + span)
120
173
  gutter_w = max(2, len(str(max_line_no)) + 1)
174
+ lexer = _lexer_for(file_path)
121
175
 
122
176
  # Header row
123
177
  header = Text()
@@ -199,6 +253,7 @@ def render_diff(log: "ChatLog", diff_text: str, max_body_lines: int = 8) -> None
199
253
  _emit_wrapped_line(
200
254
  log, line_text[1:], _first_plus, _cont_plus,
201
255
  content_style=f"{C.success}", content_width=width_changed,
256
+ highlighted=_highlight(line_text[1:], lexer),
202
257
  )
203
258
  new_line += 1
204
259
  else:
@@ -217,6 +272,7 @@ def render_diff(log: "ChatLog", diff_text: str, max_body_lines: int = 8) -> None
217
272
  _emit_wrapped_line(
218
273
  log, content, _first_ctx, _cont_ctx,
219
274
  content_style="dim", content_width=width_context,
275
+ highlighted=_highlight(content, lexer),
220
276
  )
221
277
  old_line += 1
222
278
  new_line += 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localcode
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: High-performance AI coding on consumer hardware.
5
5
  Author: LocalCode contributors
6
6
  License-Expression: Apache-2.0
@@ -1,4 +1,4 @@
1
- localcode/__init__.py,sha256=PcC5VXE54DKaV56uIqe24mQFZ_lu_yD-3qkZtHFJ1jc,49
1
+ localcode/__init__.py,sha256=7CJ7c6EVsH8bTBDIvPtzt8awFwxSSTwMCdNZ31RVGFc,49
2
2
  localcode/__main__.py,sha256=_4Tblte9F1KOpB-oGBQuTlaSN-IncLmhihqhBkpH3pU,69
3
3
  localcode/_subproc_env.py,sha256=eltq_kKvqGj22pIFivB5BZft9-s5fziBW0lsV9FhEV4,2383
4
4
  localcode/app.py,sha256=FSwpce6Jfn7zJmhL1ipaqTq0xKh0MxCMnEnMQuFV8L8,67820
@@ -15,7 +15,7 @@ localcode/config.py,sha256=hiro3Xij6dYWMsRH2UAZSQo4PHQfz11qqmgg_iMOt6M,23590
15
15
  localcode/context.py,sha256=eNcTSn5h0FThSYU3l70pMvt4wBXMGwSXe-dUZ0ZTxCs,2968
16
16
  localcode/display.py,sha256=3U4RHUTwbRRloOtnen-BiRGeRKYgzwd-6eAwtLsyGCg,1591
17
17
  localcode/embeddings.py,sha256=rjhK6o-byPVEz3__EuMerM69upKz8yidgECDsRhki3c,17187
18
- localcode/entrypoint.py,sha256=aPuPajrjmdvjleuxJFzTINCsjkBkntnWXCGqntgllDw,17769
18
+ localcode/entrypoint.py,sha256=JklE0hvKMFe1aOc92reR2N8pjPssJiRJELsSBTTLi9g,20023
19
19
  localcode/errors.py,sha256=PwvM-Ur2cTeXAuyJ_J-cGeezxkMl8Sq2dMPCh6IORW4,16342
20
20
  localcode/events.py,sha256=eSC7fKcGGWXxGUvxlMzZw6GrxPsNkjwC5a8QRv5r_1I,15125
21
21
  localcode/features.py,sha256=eE_k6SAdo_drGNOfU4cXjH_dVf5kMn2GVmWUyJ59Hxo,12217
@@ -46,10 +46,10 @@ localcode/plans.py,sha256=Tih7WzRmkWO0cF4fEDwjI07978mJZ_l9jXTSTidLb9k,7424
46
46
  localcode/process_registry.py,sha256=KBd5ndChtbmIEIFSkGTmAtaLEIIAzc8DhuYX692q73Y,5165
47
47
  localcode/recommendations.py,sha256=HVh8u2L3xbwc2qxH7Syec2cEtlEoFV9n1c33SqWF3ng,3184
48
48
  localcode/recovery.py,sha256=QUSsBA9IS5AJ6W4m4nIv2RaktTO9TJeL0RcDeUSWHJk,5237
49
- localcode/runtime.py,sha256=CsiItf-6TviGxeiexK-fD4W0GGYTJZ6TuaT6UTBOWGw,110330
49
+ localcode/runtime.py,sha256=7BWAf-PaUvP0SUBFzxLB4edb6FcLOZay_sBafAPLDIk,113508
50
50
  localcode/runtime_diffusion.py,sha256=bVopikSdQeaV9mAuzWYoXtoXpUb7U0yMXU5VFTpbsBc,39741
51
- localcode/server_manager.py,sha256=LakxnLebZvB7b2hKNd7BeEgChzGNM7mwBmlhCMEceNM,34383
52
- localcode/session.py,sha256=eYQpJbjdVX9NCo7Ugg_eyJAEE22pt-3PUR0DIFA3Jkg,11614
51
+ localcode/server_manager.py,sha256=DLXnijvGfWbW3tGNaj0sEWIQVJ0N7Jadl_YIWc4W1RE,34339
52
+ localcode/session.py,sha256=taEyOOiCcNXEr8oOeszyWvzvn-oAZ7knXQ7BxRew08Q,11970
53
53
  localcode/shell.py,sha256=2QcXk0NB6d8t2gpbbKOB5nGZ_xBJ7VVsLtF4hnUknVM,2300
54
54
  localcode/skills.py,sha256=TGdy63dQTlfa6U2l7wjg3zSatKyLAYdu_wVO-J2BrHY,25800
55
55
  localcode/snapshots.py,sha256=RYvvC7r1YVaWZNoM7n70X7gjQ_T5MJS6yGro1bmyaeg,6995
@@ -67,13 +67,13 @@ localcode/voice.py,sha256=ZAh1GmJiQJF4cXQ2YCclJZB77Sa9yflox22MnDMl7t8,39178
67
67
  localcode/agent/__init__.py,sha256=-aQIBiYIojvwl9nDf_wcyiT5UQTmcEQbEBwnBEsrjEU,7349
68
68
  localcode/agent/app_tasks.py,sha256=e5tcxBZczgQyq5irGePfe5fPmNAK6AUJ_N2i11iXb5Q,5725
69
69
  localcode/agent/constants.py,sha256=0zlgyqZYJs0MOoh70rOgrXfl_ZuU5kR29Sjzrrz7TlI,12350
70
- localcode/agent/context.py,sha256=CKF0rE6ho5b04e9h5aBPZfmlruvebxt0TB2zKdgM3Yc,35507
70
+ localcode/agent/context.py,sha256=nXwOhBN5phQ8yxVPrIaju8VDngf2BmdejpvtBP8e88c,37559
71
71
  localcode/agent/goal.py,sha256=X87Q3rsdp70wOfkpbh35M4qFWSNyklX3RuRWjv-pCXs,6264
72
72
  localcode/agent/helpers.py,sha256=vutFyCtnca0UnF0wgk7LdsqVWdiqyYm4YnFOxtnCJ_A,15004
73
73
  localcode/agent/hooks.py,sha256=XadEbSM7gHtKlLrqSKnvcQFcoWoNUXdlMdIPS-EwBGA,15841
74
- localcode/agent/loop.py,sha256=rI36_dosJLCA9Vp4Xd5QOWYnkWrR0Wf4V-EfSNgZqCc,98891
74
+ localcode/agent/loop.py,sha256=ZX3VS_7jCcRLX3VCSOsl8uMF51AnPICt18awXGk928s,99869
75
75
  localcode/agent/prompt_context.py,sha256=abJPLMhTzuVcsW4AfMBIW-SEqmrU69DhI-0vGZllZVU,13707
76
- localcode/agent/prompts.py,sha256=FjBG_XmM1svqhjfmQxmanAvm8Su0JSXqHbHqY7OS-SA,13580
76
+ localcode/agent/prompts.py,sha256=1VFrtzumZ2A7BaZ1gStpre-mZBs721FZi9hiB4qenZw,14639
77
77
  localcode/agent/recovery.py,sha256=8wwPeESsIHtdsSb_eU95JJvlVJrHxeoTVAofxmm3UIs,16639
78
78
  localcode/agent/sections.py,sha256=9hnwaNritQHgn7qIGFBZ3ChZgMQXQcqtmpdbR1wafA0,8998
79
79
  localcode/agent/streaming.py,sha256=TEcKbmKfDGV1razrV-ceVrvIIxvS-GmLWGNDnF0gSTA,12872
@@ -95,7 +95,7 @@ localcode/skills/locate.md,sha256=sOcEWfzOTueqwhtjMq0lrvBjL4K-4MCAg5dOvmP9kNA,15
95
95
  localcode/skills/plan-task.md,sha256=thaz9KdZd6KcErvdduuQEmMSdEtLEyg4b1H_-qsxVkU,2002
96
96
  localcode/skills/review.md,sha256=dcR5Qk9ZWUG2wvvJ_JrbbCRgp-k3OUd6ZC5wszVWLcQ,1216
97
97
  localcode/skills/run-tests.md,sha256=X9nhFAWtNh7HJwu6CawWgy-8KrDXUVQhWO3V3xCE7zM,1340
98
- localcode/tools/__init__.py,sha256=BDHMPiL--jHtD5hHWbO4Dp9kor1X9pTKJcfeUwak67k,13550
98
+ localcode/tools/__init__.py,sha256=6FknaKvS-0mo4dYFTegcJcyihcJpH42NzQnM-FkKCPk,13860
99
99
  localcode/tools/agent.py,sha256=aIcoGc3JiiSN4MSumwNc9TiKPZciyNXWKTYjFNir79s,8786
100
100
  localcode/tools/append_file.py,sha256=GNjPBnnV7hpD32u0Jx6YxRmsOSr4uwvJgIcInH1qhSc,2015
101
101
  localcode/tools/base.py,sha256=2QV-q1DzXWM4KL9mFKNPzlBiLOS2uKLXhlgGyeWEKCI,2064
@@ -111,28 +111,29 @@ localcode/tools/multi_edit.py,sha256=bT9OROgwTAZ1gKHE6DnpOU6_EBzLcDiisxXDnkwlyEg
111
111
  localcode/tools/plan_mode.py,sha256=2RU6QH1IJoOTKuoAr7Ga7uTaebAS8v7OFt5v1zLEXFw,2886
112
112
  localcode/tools/read_file.py,sha256=u8ZySkvSj-ZzPVSdi1ybZ6yTgZ-yXiqAW54ZVdZWf-g,3017
113
113
  localcode/tools/skill_tool.py,sha256=vTVPyRWxICtDCVYWMiAMInGnc-wGAk2L1EZn-6e79nE,1478
114
+ localcode/tools/todo_write.py,sha256=8A4k9MzoqOtzJ6y2Gd3lEGkINcpV3JVCd2lYBmdd6qI,5801
114
115
  localcode/tools/web_fetch.py,sha256=UPhMrLipwN5xJx5-zr634gL4p6nIRqYI2_7tbUw_8Go,2770
115
116
  localcode/tools/web_search.py,sha256=ILfBBx50p00OjA939HzdDmdao4QLL8sRuxlBoUDjmC8,1122
116
- localcode/tools/write_file.py,sha256=N_cAWtl5V0JekwVIMtEaYdu3tvy87mzD6kUsi0flYsA,8229
117
+ localcode/tools/write_file.py,sha256=zsG2QDkofjjqaUte1mio9_0BvdXpr-LIrNBD1U-pp9Y,9129
117
118
  localcode/tui/__init__.py,sha256=Yfy9bvw3l1iY4FDhRX8qZVgnZLuYiq8lc9SDYl4cWwc,61
118
- localcode/tui/app.py,sha256=I8SBm89yBHqiMqTbI9kI_mKsRT-XyMz1lkneJaC3FkE,17143
119
+ localcode/tui/app.py,sha256=KjQzpGKnxkgh-0kVc3zcb63Q6a5LBuGreBqTUrasiqM,17700
119
120
  localcode/tui/bridge.py,sha256=urPPCqC_nisAYawWk3juvMI8IaoJfv4g7USpGOZOqQc,2803
120
121
  localcode/tui/screens/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
121
- localcode/tui/screens/chat.py,sha256=DEMeWKHq-i-lV72P3-mZT-_WG29OyWfzwMr_l8CaQHs,190479
122
+ localcode/tui/screens/chat.py,sha256=aTYu03GTAlbXFuZDZaZBIvGNE4we--nRHZw5OYRgvAw,193390
122
123
  localcode/tui/screens/mode_picker.py,sha256=cK_xNo7ytOwJXJwzUevMTcb5dwQKtjMfh60of-ntRz4,2347
123
124
  localcode/tui/screens/model_picker.py,sha256=VkhyREIT2W1J8A6tUe0ix5TbWXUa2XSe9qKzcXgjjz4,35991
124
- localcode/tui/screens/setup.py,sha256=JvmV0VRb7uq-uyPrvoPC2arxbTpeBOJO6ry1E5PU-v0,40870
125
+ localcode/tui/screens/setup.py,sha256=6sSKU1KagLyf5DtkQWOSBA7inPFZMTF570t-20PIwaE,40844
125
126
  localcode/tui/styles/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
- localcode/tui/styles/app.tcss,sha256=_M_1rlyu1fTAj1MNVtStyvLfc6kAIc6nkbLeKP_Tsl0,4746
127
+ localcode/tui/styles/app.tcss,sha256=9ajThb1pLvI7Ram1ksvtrNGJg1-UqkDY-WerZSJPKlA,4898
127
128
  localcode/tui/widgets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
128
129
  localcode/tui/widgets/approval.py,sha256=nFyGEaIW4yIVxE8Pi-TAXlCA-geE0E23RJ5ucWhNhgE,105
129
- localcode/tui/widgets/chat_log.py,sha256=J2QZGL-jd2XDD_LwJ2v_SygrdE4_CqxJcLEGrmjmGmY,74695
130
+ localcode/tui/widgets/chat_log.py,sha256=D5DUkrQXL_ilxyC8N1xIPLw4Pqeh0jSWt1mPgho0M-4,75678
130
131
  localcode/tui/widgets/voice_visualizer.py,sha256=zOhrIxaMcg-F2Y8LvHJTddq227phmiTjtEyRbkx1XxU,4709
131
132
  localcode/tui/widgets/messages/__init__.py,sha256=952AZ1qVMGUIqPIry7mwxnYbMkgPlcY5FAwZtEa5YPg,967
132
- localcode/tui/widgets/messages/diff.py,sha256=fF7GMDSrJtVEzw1FfqlNMlg51MzOi4DwWIe9Oz6Bu6Y,8457
133
- localcode-0.3.1.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
134
- localcode-0.3.1.dist-info/METADATA,sha256=7Ytl4F6kiXVDqdKE5pA6ZkV-QC-yRHmizF8eeibTcJM,7813
135
- localcode-0.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
136
- localcode-0.3.1.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
137
- localcode-0.3.1.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
138
- localcode-0.3.1.dist-info/RECORD,,
133
+ localcode/tui/widgets/messages/diff.py,sha256=khDf_MThrQz0S62-t-m7i3OU5cdNolUk1TCeKMRPDQI,10910
134
+ localcode-0.3.2.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
135
+ localcode-0.3.2.dist-info/METADATA,sha256=NfiKTcmNdZeRHzlMdJd6Qm5lTlLT9W2ZPWEnMYsZBKU,7813
136
+ localcode-0.3.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
137
+ localcode-0.3.2.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
138
+ localcode-0.3.2.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
139
+ localcode-0.3.2.dist-info/RECORD,,