localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
Files changed (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
localcode/notebook.py ADDED
@@ -0,0 +1,123 @@
1
+ """Session-scoped notebook directory for the agent's working files.
2
+
3
+ Motivation — reduce in-context memory bloat
4
+ --------------------------------------------
5
+ Without a notebook, every intermediate draft, plan, half-finished script, or
6
+ piece of working data lived in the conversation history: written via
7
+ `write_file` into the user's project tree (polluting the repo) OR shoved
8
+ into a content message the model then has to re-read every round. Both
9
+ approaches leak context: project writes stick around forever and surprise
10
+ the user with random files; content-in-history grows the prompt linearly
11
+ and eats the window the model needs for the actual task.
12
+
13
+ The notebook is a per-session directory at
14
+ `~/.localcode/notebook/<session-id>/` that the model treats as its own
15
+ working memory. It uses regular `write_file`, `read_file`, and `bash`
16
+ against this path — no new tool — but the system prompt points the model
17
+ at the directory and tells it "use this for drafts, plans, intermediate
18
+ data; the user's project tree is for final output only."
19
+
20
+ Benefits:
21
+ • Drafts and intermediate data don't pollute the user's repo.
22
+ • The model can offload working memory to disk instead of re-emitting
23
+ it into the chat context every round.
24
+ • Trivial cleanup — wipe the session directory on exit.
25
+ • Writes / edits inside the notebook are auto-approved at the
26
+ permission layer — see `PermissionManager.check` in
27
+ permissions_v2.py — regardless of autonomy mode (`suggest`,
28
+ `auto_edit`, `full_auto`). `is_within_notebook` resolves
29
+ symlinks and rejects path-traversal (`../..`), so the bypass
30
+ cannot be abused to write into the user's project tree.
31
+
32
+ Design notes:
33
+ • One directory per session — session_id is generated once at app start
34
+ (UUID-based) and persists for the lifetime of that LocalCodeApp.
35
+ • Parent directory `~/.localcode/notebook/` persists across sessions so
36
+ stale session dirs from crashes can be garbage-collected later. For
37
+ now, the most recent N are kept on disk for post-mortem debugging.
38
+ • Does NOT persist across sessions by default — fresh session, fresh
39
+ directory. If we later want long-term memory, that's a separate
40
+ mechanism on top of this scratchpad.
41
+ • Writes to the notebook never prompt for permission (see
42
+ `is_within_notebook` check in the write/edit tool dispatchers).
43
+ """
44
+ from __future__ import annotations
45
+
46
+ from pathlib import Path
47
+
48
+ from .paths import notebook_root as _notebook_root
49
+
50
+
51
+ # Per-project notebook root: `<project_root>/.localcode/notebook/`.
52
+ # Computed on first access (so `cd` to a different project picks up the
53
+ # new location). Resolved lazily via the property below; do not cache
54
+ # the value at module import time.
55
+ def _root() -> Path:
56
+ return _notebook_root()
57
+
58
+
59
+ # Backwards-compat shim: code that imported `NOTEBOOK_ROOT` previously
60
+ # got a `Path`. Anyone reading the module-level constant now gets the
61
+ # CURRENT project's notebook root resolved at access time. If you need
62
+ # to pin a value (e.g., for the duration of a session), capture it
63
+ # into a local variable.
64
+ NOTEBOOK_ROOT = _root()
65
+ # Cap on the number of historical session directories we keep on disk.
66
+ # Each one holds a few small files at most, so 20 is cheap and gives a
67
+ # useful debugging tail without unbounded growth.
68
+ MAX_HISTORICAL_SESSIONS = 20
69
+
70
+
71
+ def notebook_dir_for(session_id: str) -> Path:
72
+ """Return (and create) the notebook directory for `session_id`.
73
+
74
+ Creating on access is idempotent — subsequent calls just return the
75
+ existing path. Safe to call from any thread.
76
+ """
77
+ # Resolve the notebook root LIVE (not the cached module-level
78
+ # NOTEBOOK_ROOT) so the lookup follows the current project even if
79
+ # the user changed working directories since import time.
80
+ d = _root() / session_id
81
+ d.mkdir(parents=True, exist_ok=True)
82
+ return d
83
+
84
+
85
+ def is_within_notebook(path: Path) -> bool:
86
+ """True if `path` resolves inside any session's notebook directory.
87
+
88
+ Used by the tool-permission layer to skip the approval prompt for
89
+ writes into the notebook. A path traversal (`../..`) from inside the
90
+ notebook will resolve outside the notebook root and correctly return
91
+ False, so the user's project is still protected from malicious or
92
+ confused tool calls.
93
+ """
94
+ try:
95
+ resolved = path.resolve()
96
+ return resolved.is_relative_to(_root().resolve())
97
+ except (OSError, ValueError):
98
+ return False
99
+
100
+
101
+ def gc_old_sessions(keep: int = MAX_HISTORICAL_SESSIONS) -> None:
102
+ """Delete all but the `keep` most-recently-modified session directories.
103
+
104
+ Best-effort — silent on any filesystem error. Safe to call on every
105
+ app start; idempotent when under the cap.
106
+ """
107
+ root = _root()
108
+ if not root.is_dir():
109
+ return
110
+ try:
111
+ subdirs = [p for p in root.iterdir() if p.is_dir()]
112
+ except OSError:
113
+ return
114
+ if len(subdirs) <= keep:
115
+ return
116
+ subdirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
117
+ for old in subdirs[keep:]:
118
+ try:
119
+ # rmtree without importing shutil at module level (cold path).
120
+ import shutil
121
+ shutil.rmtree(old, ignore_errors=True)
122
+ except Exception:
123
+ pass
localcode/output.py ADDED
@@ -0,0 +1,403 @@
1
+ """Centralized output manager — ALL terminal output goes through here.
2
+
3
+ agent-inspired design:
4
+ - ▪ bullet prefix on each section (tool groups, text responses, status)
5
+ - Bold action headers: "Ran", "Read", "Wrote", "Searched"
6
+ - └ tree connectors for tool results
7
+ - Clear spacing between sections
8
+ - "Working (Xs · esc to interrupt)" status line
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum, auto
18
+
19
+
20
+ class Phase(Enum):
21
+ IDLE = auto()
22
+ THINKING = auto()
23
+ TOOL_CALL = auto()
24
+ STREAMING = auto()
25
+ DONE = auto()
26
+ ERROR = auto()
27
+
28
+
29
+ @dataclass
30
+ class ToolAction:
31
+ name: str
32
+ args: str
33
+ status: str = "running" # running | done | error
34
+ result: str = ""
35
+
36
+
37
+ @dataclass
38
+ class OutputState:
39
+ phase: Phase = Phase.IDLE
40
+ start_time: float = 0.0
41
+ thinking_peek: str = ""
42
+ tokens: int = 0
43
+ tool_actions: list[ToolAction] = field(default_factory=list)
44
+ content_chunks: list[str] = field(default_factory=list)
45
+ error: str = ""
46
+
47
+
48
+ # Tool name → human-readable action header
49
+ TOOL_HEADERS = {
50
+ "bash": "Ran",
51
+ "read_file": "Read",
52
+ "write_file": "Wrote",
53
+ "append_file": "Appended",
54
+ "edit_file": "Edited",
55
+ "grep": "Searched",
56
+ "glob": "Found",
57
+ "list_files": "Listed",
58
+ "web_search": "Searched web",
59
+ "web_fetch": "Fetched",
60
+ }
61
+
62
+
63
+ def _cols() -> int:
64
+ try:
65
+ return os.get_terminal_size().columns
66
+ except Exception:
67
+ return 80
68
+
69
+
70
+ class OutputManager:
71
+ """Single output controller for the entire app."""
72
+
73
+ def __init__(self) -> None:
74
+ self.state = OutputState()
75
+ self._lock = threading.Lock()
76
+ self._indicator_thread: threading.Thread | None = None
77
+ self._indicator_running = False
78
+ self._event_callback = None
79
+ self._approval_callback = None # TUI: blocks worker thread until approved/denied
80
+ self._stream_started = False
81
+ self._custom_stage = ""
82
+ self._status_line = ""
83
+ self._input_field = None # set by app.py to enable typeahead
84
+
85
+ def set_event_callback(self, callback) -> None:
86
+ self._event_callback = callback
87
+
88
+ def set_approval_callback(self, callback) -> None:
89
+ """Set callback for TUI approval: callback(tool_name, command) -> bool."""
90
+ self._approval_callback = callback
91
+
92
+ def _emit_event(self, event_type: str, **payload) -> None:
93
+ if self._event_callback is not None:
94
+ try:
95
+ self._event_callback(event_type, payload)
96
+ except Exception:
97
+ pass
98
+
99
+ # ── Phase transitions ────────────────────────────────────────────
100
+
101
+ def start_thinking(self, reset: bool = True) -> None:
102
+ """Begin thinking phase — shows working indicator."""
103
+ with self._lock:
104
+ self.state.phase = Phase.THINKING
105
+ self._custom_stage = ""
106
+ if reset:
107
+ self.state.start_time = time.time()
108
+ self.state.tokens = 0
109
+ self.state.thinking_peek = ""
110
+ self.state.tool_actions.clear()
111
+ self.state.content_chunks.clear()
112
+ self._stream_started = False
113
+ else:
114
+ self.state.thinking_peek = ""
115
+ self._emit_event("thinking_start", reset=str(reset).lower())
116
+ self._start_indicator()
117
+
118
+ def start_streaming(self) -> None:
119
+ """Transition to content streaming — stops indicator."""
120
+ self._stop_indicator()
121
+ with self._lock:
122
+ self.state.phase = Phase.STREAMING
123
+ self._custom_stage = ""
124
+ self._emit_event("stream_start")
125
+ # Clear indicator and add breathing room before response
126
+ sys.stdout.write("\r\033[K\n\n ")
127
+ sys.stdout.flush()
128
+ self._stream_started = False
129
+
130
+ def done(self) -> None:
131
+ """Finished — cleanup."""
132
+ self._stop_indicator()
133
+ elapsed = time.time() - self.state.start_time
134
+ tools_used = len(self.state.tool_actions)
135
+ with self._lock:
136
+ self.state.phase = Phase.DONE
137
+ self._custom_stage = ""
138
+ self._emit_event("done")
139
+ # Summary line + breathing room (guard against stale start_time)
140
+ parts = [f"{elapsed:.1f}s"]
141
+ if tools_used:
142
+ tool_names = {}
143
+ for t in self.state.tool_actions:
144
+ tool_names[t.name] = tool_names.get(t.name, 0) + 1
145
+ tool_summary = ", ".join(f"{n}×{c}" if c > 1 else n for n, c in tool_names.items())
146
+ parts.append(f"tools: {tool_summary}")
147
+ sys.stdout.write(f"\n\033[2m Done in {' — '.join(parts)}\033[0m\n")
148
+ sys.stdout.flush()
149
+
150
+ def set_error(self, msg: str) -> None:
151
+ self._stop_indicator()
152
+ with self._lock:
153
+ self.state.phase = Phase.ERROR
154
+ self.state.error = msg
155
+ self._custom_stage = ""
156
+ self._emit_event("error", message=msg[:240])
157
+ sys.stdout.write(f"\n\033[31m▪ Error: {msg}\033[0m\n\n")
158
+ sys.stdout.flush()
159
+
160
+ # ── Thinking ─────────────────────────────────────────────────────
161
+
162
+ def feed_thinking(self, chunk: str) -> None:
163
+ with self._lock:
164
+ self.state.tokens += max(1, len(chunk) // 4)
165
+ self._emit_event("thinking_chunk", chunk=chunk[:2000])
166
+
167
+ def thinking_done(self, full_text: str) -> None:
168
+ """Emit the complete thinking text for display in TUI."""
169
+ self._last_thinking_text = full_text # Store for direct access
170
+ self._emit_event("thinking_done", text=full_text[:8000])
171
+
172
+ def set_stage(self, stage: str) -> None:
173
+ with self._lock:
174
+ self._custom_stage = stage
175
+ self._emit_event("stage", stage=stage[:120])
176
+
177
+ def set_thinking_peek(self, text: str) -> None:
178
+ with self._lock:
179
+ self.state.thinking_peek = text[:120]
180
+ if text.strip():
181
+ self._emit_event("thinking_peek", text=text[:120])
182
+
183
+ # ── Tool calls ───────────────────────────────────────────────────
184
+
185
+ def update_turn_tokens(
186
+ self,
187
+ prompt_tokens: int,
188
+ completion_tokens: int,
189
+ total_tokens: int = 0,
190
+ ) -> None:
191
+ """Forward llama-server's `usage` info (prompt + completion token
192
+ counts) to the UI so the per-turn summary can show real
193
+ in/out counts instead of estimates.
194
+
195
+ Called once per round from `agent/loop.py` after `stream_done`.
196
+ Multi-tool turns fire it N times; the chat screen accumulates
197
+ and resets at turn boundaries.
198
+ """
199
+ self._emit_event(
200
+ "turn_tokens",
201
+ prompt_tokens=str(int(prompt_tokens or 0)),
202
+ completion_tokens=str(int(completion_tokens or 0)),
203
+ total_tokens=str(int(total_tokens or 0)),
204
+ )
205
+
206
+ def tool_preview(self, name: str, args_chars: int = 0, args_snippet: str = "") -> None:
207
+ """Mid-stream signal that a tool call is forming.
208
+
209
+ Fires the moment the model commits to a tool name and again as the
210
+ args grow. Lets the TUI render a `▪ Write` placeholder + size
211
+ progress IMMEDIATELY instead of waiting for end-of-stream — without
212
+ this, a 5K-line file write looks like a 3-minute hang.
213
+
214
+ `args_snippet` carries the first ~256 chars of the accumulating
215
+ JSON args so the UI can regex-extract `"path": "x"` and show
216
+ `writing(pyproject.toml… 3 KB)` instead of the generic
217
+ `writing(streaming… 3 KB)`.
218
+
219
+ Distinct from `log_tool`: no state mutation, no stdout write, no
220
+ `tool_actions.append`. Just an event for the UI to update its
221
+ floating active-step widget.
222
+ """
223
+ self._emit_event(
224
+ "tool_preview",
225
+ name=name,
226
+ chars=str(args_chars),
227
+ snippet=args_snippet[:256],
228
+ )
229
+
230
+ def log_tool(self, name: str, args: str = "") -> int:
231
+ """Log a tool call with agent-style formatting."""
232
+ self._stop_indicator()
233
+ idx = len(self.state.tool_actions)
234
+ self.state.tool_actions.append(ToolAction(name=name, args=args))
235
+ self._emit_event("tool_start", name=name, args=args[:200], index=str(idx))
236
+
237
+ header = TOOL_HEADERS.get(name, name)
238
+ # Bold header + cyan args
239
+ if name == "bash":
240
+ sys.stdout.write(f"\n\033[1m▪ {header}\033[0m \033[36m{args[:80]}\033[0m\n")
241
+ elif name in ("read_file", "write_file", "append_file", "edit_file"):
242
+ sys.stdout.write(f"\n\033[1m▪ {header}\033[0m \033[36m{args[:80]}\033[0m\n")
243
+ elif name in ("grep", "glob"):
244
+ sys.stdout.write(f"\n\033[1m▪ {header}\033[0m \033[36m{args[:80]}\033[0m\n")
245
+ else:
246
+ sys.stdout.write(f"\n\033[1m▪ {header}\033[0m \033[36m{args[:80]}\033[0m\n")
247
+ sys.stdout.flush()
248
+ self._start_indicator()
249
+ return idx
250
+
251
+ @staticmethod
252
+ def _filter_noise(text: str) -> str:
253
+ lines = text.splitlines()
254
+ filtered = [
255
+ l for l in lines
256
+ if "MallocStackLogging" not in l
257
+ and "MallocStackLoggingNoCompact" not in l
258
+ and "MallocNanoZone" not in l
259
+ and "can't turn off malloc stack logging" not in l
260
+ ]
261
+ return "\n".join(filtered)
262
+
263
+ def tool_result(self, result: str, error: bool = False, idx: int = -1) -> None:
264
+ """Show tool result with tree-style indentation."""
265
+ result = self._filter_noise(result)
266
+ if idx >= 0 and idx < len(self.state.tool_actions):
267
+ self.state.tool_actions[idx].status = "error" if error else "done"
268
+ self.state.tool_actions[idx].result = result
269
+ with self._lock:
270
+ self._custom_stage = ""
271
+ # Include tool name/args so TUI doesn't rely on stale _active_tool_name
272
+ tool_name = ""
273
+ tool_args = ""
274
+ if idx >= 0 and idx < len(self.state.tool_actions):
275
+ tool_name = self.state.tool_actions[idx].name
276
+ tool_args = self.state.tool_actions[idx].args
277
+ self._emit_event(
278
+ "tool_result",
279
+ error=str(error).lower(),
280
+ index=str(idx),
281
+ result=result[:4000],
282
+ name=tool_name,
283
+ args=tool_args[:200],
284
+ )
285
+ self._stop_indicator()
286
+ lines = result.strip().splitlines()
287
+ if error:
288
+ # A tool returning an error is normal, recoverable agentic operation
289
+ # (the model reads it and adjusts) — render it calm amber, NOT alarm
290
+ # red. Red is reserved for a terminal, turn-ending failure (set_error).
291
+ for line in lines[:5]:
292
+ sys.stdout.write(f"\033[33m └ {line[:90]}\033[0m\n")
293
+ elif lines:
294
+ max_lines = 8
295
+ for i, line in enumerate(lines[:max_lines]):
296
+ sys.stdout.write(f"\033[2m └ {line[:90]}\033[0m\n")
297
+ if len(lines) > max_lines:
298
+ sys.stdout.write(f"\033[2m … +{len(lines) - max_lines} lines\033[0m\n")
299
+ sys.stdout.flush()
300
+ self._start_indicator()
301
+
302
+ # ── Content streaming ────────────────────────────────────────────
303
+
304
+ _col_pos = 0 # track column position for soft wrapping
305
+
306
+ def stream(self, chunk: str) -> None:
307
+ """Stream content to terminal with soft wrapping.
308
+
309
+ TUI mode (event_callback is set): emits a `content` event for
310
+ the chat-log widget to consume — DOES NOT write to stdout.
311
+ Earlier code wrote to stdout unconditionally, which (a) doubled
312
+ the model's prose into both the TUI render path AND raw stdout,
313
+ and (b) leaked the raw bytes to the visible terminal whenever
314
+ Textual's alt-screen was in a transitional state (startup race,
315
+ mid-resize, paste handoff). Real failure 2026-04-26: user saw
316
+ ~14 sentences of model narration printed raw to the terminal
317
+ outside the chat log because the TUI hadn't fully attached yet.
318
+
319
+ CLI mode (no event callback): same as before — soft-wrap and
320
+ write to stdout for the no-Textual fallback.
321
+ """
322
+ if self.state.phase != Phase.STREAMING:
323
+ self.start_streaming()
324
+ self._col_pos = 2 # we start with 2-char indent
325
+ with self._lock:
326
+ self.state.content_chunks.append(chunk)
327
+ self.state.tokens += max(1, len(chunk) // 4)
328
+ self._emit_event("content", chunk=chunk[:2000], chars=str(len(chunk)))
329
+ # In TUI mode the chat-log widget renders streamed content
330
+ # exclusively. Skipping stdout here is the difference between
331
+ # "model prose appears in the chat log only" and "model prose
332
+ # leaks to the raw terminal whenever the alt-screen flickers".
333
+ if self._event_callback is not None:
334
+ return
335
+ cols = _cols()
336
+ wrap_at = cols - 1 # leave 1 char margin
337
+ out = []
338
+ for ch in chunk:
339
+ if ch == "\n":
340
+ out.append("\n ")
341
+ self._col_pos = 2
342
+ else:
343
+ if self._col_pos >= wrap_at:
344
+ out.append("\n ")
345
+ self._col_pos = 2
346
+ out.append(ch)
347
+ self._col_pos += 1
348
+ sys.stdout.write("".join(out))
349
+ sys.stdout.flush()
350
+
351
+ def print_info(self, text: str) -> None:
352
+ """Print dim info text."""
353
+ was_running = self._indicator_running
354
+ if was_running:
355
+ self._stop_indicator()
356
+ sys.stdout.write(f"\033[2m {text}\033[0m\n")
357
+ sys.stdout.flush()
358
+ if was_running:
359
+ self._start_indicator()
360
+
361
+ def notice(self, text: str) -> None:
362
+ """User-facing notice shown in BOTH the CLI (dim stdout) and the TUI
363
+ (as an info line). Unlike print_info — which is stdout-only and so is
364
+ invisible in the TUI — this emits an event the TUI renders. Use it for
365
+ anything the user must actually see (e.g. why a turn ended)."""
366
+ self.print_info(text)
367
+ self._emit_event("notice", text=text)
368
+
369
+ # ── Indicator (background thread) ────────────────────────────────
370
+
371
+ def _start_indicator(self) -> None:
372
+ if self._indicator_running:
373
+ return
374
+ self._indicator_running = True
375
+ self._indicator_thread = threading.Thread(target=self._run_indicator, daemon=True)
376
+ try:
377
+ self._indicator_thread.start()
378
+ except RuntimeError:
379
+ self._indicator_thread = threading.Thread(target=self._run_indicator, daemon=True)
380
+ self._indicator_thread.start()
381
+
382
+ def _stop_indicator(self) -> None:
383
+ if not self._indicator_running:
384
+ return
385
+ self._indicator_running = False
386
+ if self._indicator_thread:
387
+ try:
388
+ self._indicator_thread.join(timeout=1)
389
+ except (KeyboardInterrupt, RuntimeError):
390
+ pass
391
+ try:
392
+ sys.stdout.write("\033[?25h") # show cursor
393
+ sys.stdout.write("\r\033[J") # clear from cursor to end of screen
394
+ sys.stdout.flush()
395
+ except (BrokenPipeError, OSError):
396
+ pass
397
+
398
+ def _run_indicator(self) -> None:
399
+ # CLI-mode spinner removed — TUI owns the terminal and renders
400
+ # its own thinking indicator. start/stop scaffolding kept for
401
+ # legacy call sites; this thread now just idles until stopped.
402
+ while self._indicator_running:
403
+ time.sleep(0.5)
localcode/patching.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ DIFF_BLOCK_RE = re.compile(r"```diff\s*(.*?)```", re.DOTALL)
8
+
9
+
10
+ @dataclass
11
+ class DiffHunk:
12
+ header: str
13
+ lines: list[str]
14
+
15
+
16
+ @dataclass
17
+ class DiffFile:
18
+ old_path: str
19
+ new_path: str
20
+ headers: list[str]
21
+ hunks: list[DiffHunk]
22
+
23
+
24
+ def parse_diff(diff_text: str) -> list[DiffFile]:
25
+ files: list[DiffFile] = []
26
+ current: DiffFile | None = None
27
+ current_hunk: DiffHunk | None = None
28
+ for line in diff_text.splitlines():
29
+ if line.startswith("diff --git "):
30
+ if current is not None:
31
+ if current_hunk is not None:
32
+ current.hunks.append(current_hunk)
33
+ current_hunk = None
34
+ files.append(current)
35
+ parts = line.split()
36
+ old_path = parts[2][2:] if len(parts) > 2 else ""
37
+ new_path = parts[3][2:] if len(parts) > 3 else old_path
38
+ current = DiffFile(old_path=old_path, new_path=new_path, headers=[line], hunks=[])
39
+ continue
40
+ if current is None:
41
+ continue
42
+ if line.startswith("@@"):
43
+ if current_hunk is not None:
44
+ current.hunks.append(current_hunk)
45
+ current_hunk = DiffHunk(header=line, lines=[])
46
+ continue
47
+ if current_hunk is not None:
48
+ current_hunk.lines.append(line)
49
+ else:
50
+ current.headers.append(line)
51
+ if current is not None:
52
+ if current_hunk is not None:
53
+ current.hunks.append(current_hunk)
54
+ files.append(current)
55
+ return files
56
+
57
+
58
+ def build_diff(selected_files: list[DiffFile]) -> str:
59
+ blocks: list[str] = []
60
+ for file in selected_files:
61
+ blocks.extend(file.headers)
62
+ for hunk in file.hunks:
63
+ blocks.append(hunk.header)
64
+ blocks.extend(hunk.lines)
65
+ return "\n".join(blocks) + ("\n" if blocks else "")