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 +1 -1
- localcode/agent/context.py +51 -10
- localcode/agent/loop.py +18 -1
- localcode/agent/prompts.py +4 -1
- localcode/entrypoint.py +61 -5
- localcode/runtime.py +71 -19
- localcode/server_manager.py +12 -15
- localcode/session.py +6 -0
- localcode/tools/__init__.py +7 -0
- localcode/tools/todo_write.py +140 -0
- localcode/tools/write_file.py +21 -0
- localcode/tui/app.py +23 -7
- localcode/tui/screens/chat.py +79 -23
- localcode/tui/screens/setup.py +3 -3
- localcode/tui/styles/app.tcss +4 -2
- localcode/tui/widgets/chat_log.py +23 -1
- localcode/tui/widgets/messages/diff.py +56 -0
- {localcode-0.3.1.dist-info → localcode-0.3.2.dist-info}/METADATA +1 -1
- {localcode-0.3.1.dist-info → localcode-0.3.2.dist-info}/RECORD +23 -22
- {localcode-0.3.1.dist-info → localcode-0.3.2.dist-info}/WHEEL +0 -0
- {localcode-0.3.1.dist-info → localcode-0.3.2.dist-info}/entry_points.txt +0 -0
- {localcode-0.3.1.dist-info → localcode-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.3.1.dist-info → localcode-0.3.2.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
localcode/agent/context.py
CHANGED
|
@@ -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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
#
|
localcode/agent/prompts.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1880
|
-
#
|
|
1881
|
-
#
|
|
1882
|
-
#
|
|
1883
|
-
#
|
|
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,
|
|
1923
|
+
"stream_chat_events", exc,
|
|
1924
|
+
mid_stream=_emitted_real_stream or _is_protocol_drop,
|
|
1890
1925
|
)
|
|
1891
|
-
#
|
|
1892
|
-
#
|
|
1893
|
-
#
|
|
1894
|
-
#
|
|
1895
|
-
#
|
|
1896
|
-
#
|
|
1897
|
-
#
|
|
1898
|
-
|
|
1899
|
-
|
|
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"
|
localcode/server_manager.py
CHANGED
|
@@ -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
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
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
|
-
|
|
417
|
-
|
|
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,
|
localcode/tools/__init__.py
CHANGED
|
@@ -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
|
localcode/tools/write_file.py
CHANGED
|
@@ -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
|
-
|
|
52
|
+
wrote = True
|
|
52
53
|
except Exception:
|
|
53
54
|
pass
|
|
54
|
-
|
|
55
|
-
os.write(1, payload)
|
|
56
|
-
except Exception:
|
|
55
|
+
if not wrote:
|
|
57
56
|
try:
|
|
58
|
-
|
|
59
|
-
sys.__stdout__.flush()
|
|
57
|
+
os.write(1, payload)
|
|
60
58
|
except Exception:
|
|
61
|
-
|
|
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):
|
localcode/tui/screens/chat.py
CHANGED
|
@@ -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.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
1824
|
-
#
|
|
1825
|
-
if not text
|
|
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
|
-
|
|
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
|
-
#
|
|
3962
|
-
#
|
|
3963
|
-
#
|
|
3964
|
-
#
|
|
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(
|
|
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
|
|
4032
|
+
# ignore it — the timer picks the placeholder word (see above).
|
|
3974
4033
|
if self._active_mode != "thinking":
|
|
3975
|
-
self._show_active_thinking(
|
|
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
|
localcode/tui/screens/setup.py
CHANGED
|
@@ -120,8 +120,8 @@ class SetupScreen(Screen):
|
|
|
120
120
|
id="setup-onetime-note",
|
|
121
121
|
)
|
|
122
122
|
yield Static(
|
|
123
|
-
"[dim]
|
|
124
|
-
"
|
|
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]
|
|
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).
|
localcode/tui/styles/app.tcss
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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,4 +1,4 @@
|
|
|
1
|
-
localcode/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
49
|
+
localcode/runtime.py,sha256=7BWAf-PaUvP0SUBFzxLB4edb6FcLOZay_sBafAPLDIk,113508
|
|
50
50
|
localcode/runtime_diffusion.py,sha256=bVopikSdQeaV9mAuzWYoXtoXpUb7U0yMXU5VFTpbsBc,39741
|
|
51
|
-
localcode/server_manager.py,sha256=
|
|
52
|
-
localcode/session.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
133
|
-
localcode-0.3.
|
|
134
|
-
localcode-0.3.
|
|
135
|
-
localcode-0.3.
|
|
136
|
-
localcode-0.3.
|
|
137
|
-
localcode-0.3.
|
|
138
|
-
localcode-0.3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|