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
@@ -0,0 +1,21 @@
1
+ """Per-message-kind renderers — agent `src/components/messages/` pattern.
2
+
3
+ Each render method takes a ChatLog instance plus its kind-specific
4
+ arguments and writes content to the log. ChatLog acts as the dispatcher
5
+ (via `_dispatch_gap` for spacing + the per-kind method for content).
6
+
7
+ Extracted modules so far:
8
+ diff.py — file-edit diff with line-number gutter
9
+
10
+ To extract (follow-up — each is its own self-contained PR):
11
+ user.py — _render_user
12
+ assistant.py — _render_assistant + the streaming partial-line path
13
+ thinking.py — _render_thinking
14
+ tool.py — _render_tool, _render_tool_done, _render_tool_result
15
+ info.py — _render_info, _render_error, _render_approval
16
+ turn_summary.py — _render_turn_summary
17
+
18
+ Full extraction is a 1-day refactor — left for a quiet day. The diff
19
+ extraction here proves the pattern works and gives the most-complex
20
+ renderer its own testable home.
21
+ """
@@ -0,0 +1,287 @@
1
+ """Diff renderer — two-column layout with line-number gutter.
2
+
3
+ agent pattern from `src/components/StructuredDiff.tsx`:
4
+ - Right-aligned gutter (line numbers) at fixed width per hunk.
5
+ - Content column with `+`/`-` markers in green/red.
6
+ - Context lines dim, hunks separated by `…` row.
7
+
8
+ Extracted from `chat_log.ChatLog._render_diff` so the diff renderer
9
+ can be tested in isolation and (later) reused by other surfaces
10
+ (e.g. an inline diff preview during edit_file approval prompts).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import textwrap
16
+ from typing import TYPE_CHECKING
17
+
18
+ from rich.text import Text
19
+
20
+ from ....theme import C
21
+
22
+ if TYPE_CHECKING:
23
+ from ..chat_log import ChatLog
24
+
25
+
26
+ def _available_content_width(log: "ChatLog", prefix_width: int) -> int:
27
+ """Chars of CONTENT that fit on one row given the diff prefix.
28
+
29
+ Terminal width minus: the diff prefix (gutter + separator + marker),
30
+ RichLog padding, and the vertical scrollbar. Floor at 20 so we
31
+ still wrap on tiny terminals rather than returning ≤0.
32
+ """
33
+ try:
34
+ term_w = log.app.size.width
35
+ except Exception:
36
+ term_w = 80
37
+ # Reserve: 1 for scrollbar, 2 for RichLog padding = 3. Prefix on
38
+ # the left is already accounted for separately.
39
+ return max(20, term_w - prefix_width - 3)
40
+
41
+
42
+ def _emit_wrapped_line(
43
+ log: "ChatLog",
44
+ content: str,
45
+ build_first_prefix, # callable() → Text for first visual row
46
+ build_cont_prefix, # callable() → Text for every wrap-continuation row
47
+ content_style: str,
48
+ content_width: int,
49
+ highlighted=None, # optional pre-syntax-highlighted Text for `content`
50
+ ) -> None:
51
+ """Write a styled diff line to `log`, wrapping long content cleanly.
52
+
53
+ Without this, diff lines longer than the terminal width fell through
54
+ to RichLog's default word-wrap, which rendered the wrapped remainder
55
+ flush to column 0 — text appeared to "extend to the left past the
56
+ margin" (image 110). The fix: pre-split the content on terminal
57
+ width, write the first piece with the real line-number + marker
58
+ prefix, and write each continuation piece with a blank prefix of
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.
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
+
74
+ if content_width <= 0:
75
+ # Degenerate terminal size — fall back to one write and let
76
+ # Rich wrap as best it can.
77
+ dl = build_first_prefix()
78
+ dl.append(content, style=content_style)
79
+ log.write(dl)
80
+ log._track_lines()
81
+ return
82
+
83
+ # textwrap preserves leading whitespace (important for indented code
84
+ # diffs) but `drop_whitespace=False` also keeps trailing spaces,
85
+ # which is what we want for code.
86
+ pieces = textwrap.wrap(
87
+ content if content else " ",
88
+ width=content_width,
89
+ drop_whitespace=False,
90
+ replace_whitespace=False,
91
+ break_long_words=True,
92
+ break_on_hyphens=False,
93
+ expand_tabs=False,
94
+ ) or [content]
95
+
96
+ dl = build_first_prefix()
97
+ dl.append(pieces[0], style=content_style)
98
+ log.write(dl)
99
+ log._track_lines()
100
+
101
+ for extra in pieces[1:]:
102
+ cont = build_cont_prefix()
103
+ cont.append(extra, style=content_style)
104
+ log.write(cont)
105
+ log._track_lines()
106
+
107
+
108
+ _HUNK_HEADER_RE = re.compile(r'\+(\d+)(?:,(\d+))?')
109
+ _HUNK_OLD_RE = re.compile(r'-(\d+)')
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
+
150
+
151
+ def render_diff(log: "ChatLog", diff_text: str, max_body_lines: int = 8) -> None:
152
+ """Write a unified-diff blob to `log` with the structured layout.
153
+
154
+ The header row shows file path + change counts (+N -M). The body
155
+ shows up to `max_body_lines` lines of actual diff content; the
156
+ rest is summarized as "… +X more lines".
157
+ """
158
+ lines = diff_text.strip().splitlines()
159
+ added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
160
+ removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
161
+
162
+ file_path = ""
163
+ max_line_no = 1
164
+ for l in lines:
165
+ if l.startswith("+++ "):
166
+ file_path = l[4:].split("\t", 1)[0]
167
+ elif l.startswith("@@"):
168
+ m = _HUNK_HEADER_RE.search(l)
169
+ if m:
170
+ start = int(m.group(1))
171
+ span = int(m.group(2) or 0)
172
+ max_line_no = max(max_line_no, start + span)
173
+ gutter_w = max(2, len(str(max_line_no)) + 1)
174
+ lexer = _lexer_for(file_path)
175
+
176
+ # Header row
177
+ header = Text()
178
+ header.append(" ⎿ ", style=f"dim {C.primary}")
179
+ if file_path:
180
+ header.append(f"{file_path} ", style=f"dim {C.primary}")
181
+ parts = []
182
+ if added:
183
+ parts.append(f"+{added}")
184
+ if removed:
185
+ parts.append(f"-{removed}")
186
+ header.append(" ".join(parts) if parts else "no changes", style="dim")
187
+ log.write(header)
188
+ log._track_lines()
189
+
190
+ # Prefix width: 4 (left pad) + gutter_w + 1 (space) + 2 ("│ " or
191
+ # "│ ") + 2 ("+ " / "- " / " "). For context lines the "│ "
192
+ # is wider (4 instead of 2) — we compute per-line below. The
193
+ # continuation indent for wrapped long diff lines is a blank
194
+ # string of the same visual width as the prefix, so wrapped text
195
+ # visually hangs under the "+ …" / "- …" column instead of
196
+ # flush-lefting (image 110 bug).
197
+ prefix_w_changed = 4 + gutter_w + 1 + 2 + 2 # "- "/"+ " variant
198
+ prefix_w_context = 4 + gutter_w + 1 + 4 # "│ " + no marker
199
+ cont_indent_changed = " " * prefix_w_changed
200
+ cont_indent_context = " " * prefix_w_context
201
+
202
+ width_changed = _available_content_width(log, prefix_w_changed)
203
+ width_context = _available_content_width(log, prefix_w_context)
204
+
205
+ old_line = 0
206
+ new_line = 0
207
+ shown = 0
208
+ skipped = 0
209
+ for line_text in lines:
210
+ if line_text.startswith("---") or line_text.startswith("+++"):
211
+ continue
212
+ if line_text.startswith("@@"):
213
+ m = _HUNK_OLD_RE.search(line_text)
214
+ if m:
215
+ old_line = int(m.group(1))
216
+ m2 = _HUNK_HEADER_RE.search(line_text)
217
+ if m2:
218
+ new_line = int(m2.group(1))
219
+ if shown > 0 and shown < max_body_lines:
220
+ sep = Text()
221
+ sep.append(" " * (6 + gutter_w) + "…", style="dim")
222
+ log.write(sep)
223
+ log._track_lines()
224
+ shown += 1
225
+ continue
226
+ if shown >= max_body_lines:
227
+ skipped += 1
228
+ continue
229
+
230
+ if line_text.startswith("-"):
231
+ def _first_minus(ol=old_line):
232
+ t = Text()
233
+ t.append(f" {ol:>{gutter_w}} ", style=f"dim {C.error}")
234
+ t.append("│ ", style=f"dim {C.primary}")
235
+ t.append("- ", style=f"bold {C.error}")
236
+ return t
237
+ def _cont_minus():
238
+ return Text(cont_indent_changed)
239
+ _emit_wrapped_line(
240
+ log, line_text[1:], _first_minus, _cont_minus,
241
+ content_style=f"{C.error}", content_width=width_changed,
242
+ )
243
+ old_line += 1
244
+ elif line_text.startswith("+"):
245
+ def _first_plus(nl=new_line):
246
+ t = Text()
247
+ t.append(f" {nl:>{gutter_w}} ", style=f"dim {C.success}")
248
+ t.append("│ ", style=f"dim {C.primary}")
249
+ t.append("+ ", style=f"bold {C.success}")
250
+ return t
251
+ def _cont_plus():
252
+ return Text(cont_indent_changed)
253
+ _emit_wrapped_line(
254
+ log, line_text[1:], _first_plus, _cont_plus,
255
+ content_style=f"{C.success}", content_width=width_changed,
256
+ highlighted=_highlight(line_text[1:], lexer),
257
+ )
258
+ new_line += 1
259
+ else:
260
+ content = line_text[1:] if line_text.startswith(" ") else line_text
261
+ if not content.strip():
262
+ old_line += 1
263
+ new_line += 1
264
+ continue
265
+ def _first_ctx(nl=new_line):
266
+ t = Text()
267
+ t.append(f" {nl:>{gutter_w}} ", style="dim")
268
+ t.append("│ ", style=f"dim {C.primary}")
269
+ return t
270
+ def _cont_ctx():
271
+ return Text(cont_indent_context)
272
+ _emit_wrapped_line(
273
+ log, content, _first_ctx, _cont_ctx,
274
+ content_style="dim", content_width=width_context,
275
+ highlighted=_highlight(content, lexer),
276
+ )
277
+ old_line += 1
278
+ new_line += 1
279
+
280
+ shown += 1
281
+
282
+ if skipped > 0:
283
+ more = Text()
284
+ more.append(" " * (6 + gutter_w), style="")
285
+ more.append(f"… +{skipped} more lines", style="dim italic")
286
+ log.write(more)
287
+ log._track_lines()
@@ -0,0 +1,122 @@
1
+ """Live audio volume indicator — a single tall narrow bar.
2
+
3
+ Renders one short row containing a wide block character whose
4
+ *fill level* changes with the mic's instantaneous volume, plus a
5
+ small REC timer to the right. The bar's color cycles through a
6
+ rainbow palette over time so even silence has a quiet pulse.
7
+
8
+ Width is fixed (a few cells) — this isn't a waveform history strip,
9
+ it's a single VU-meter-style bar. Pulls `peak` straight off the
10
+ Recorder instance owned by the chat screen. Refreshes at ~30 FPS
11
+ when active; hidden the rest of the time.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Any
17
+
18
+ from textual.widgets import Static
19
+
20
+
21
+ # Vertical fill levels from empty to full. Each character occupies the
22
+ # bottom Nth of a single text cell, so reading left-to-right these
23
+ # look like a single bar growing from the bottom of the line up.
24
+ _FILL = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
25
+
26
+ # Curated rainbow that reads well on both dark and light terminals.
27
+ _PALETTE = [
28
+ "#ff5470", "#ff8a5b", "#ffd166", "#9bff8a", "#5bffc1",
29
+ "#5bd1ff", "#5b96ff", "#9b5bff", "#e75bff", "#ff5bd1",
30
+ ]
31
+
32
+ # Bar is one cell wide — reads as a single thin pulsing bar at a glance.
33
+ _BAR_WIDTH = 1
34
+
35
+
36
+ class VoiceVisualizer(Static):
37
+ """A small VU-meter-style bar that breathes with mic input."""
38
+
39
+ DEFAULT_CSS = """
40
+ VoiceVisualizer {
41
+ height: 1;
42
+ width: 1; /* exactly one cell — no phantom column */
43
+ background: ansi_default;
44
+ padding: 0;
45
+ margin: 0 0 0 1; /* one space gap from input, nothing else */
46
+ display: none; /* hidden when not recording */
47
+ }
48
+ VoiceVisualizer.active {
49
+ display: block;
50
+ }
51
+ """
52
+
53
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
54
+ super().__init__("", *args, **kwargs)
55
+ self.recorder: Any = None
56
+ self._t0 = time.time()
57
+ self._timer = None
58
+ # Smoothed amplitude — EMA so the bar's height doesn't jitter
59
+ # every frame. New samples weighted at 0.6, prior at 0.4.
60
+ self._smoothed = 0.0
61
+
62
+ # ── lifecycle ─────────────────────────────────────────────────
63
+
64
+ def activate(self, recorder: Any) -> None:
65
+ """Start the 30-FPS render loop pointed at `recorder`."""
66
+ self.recorder = recorder
67
+ self._smoothed = 0.0
68
+ self._t0 = time.time()
69
+ self.add_class("active")
70
+ if self._timer is None:
71
+ self._timer = self.set_interval(1 / 30, self._tick)
72
+
73
+ def deactivate(self) -> None:
74
+ """Stop the timer and hide the widget."""
75
+ if self._timer is not None:
76
+ try:
77
+ self._timer.stop()
78
+ except Exception:
79
+ pass
80
+ self._timer = None
81
+ self.recorder = None
82
+ self.remove_class("active")
83
+ self.update("")
84
+
85
+ # ── render ────────────────────────────────────────────────────
86
+
87
+ def _tick(self) -> None:
88
+ if self.recorder is None:
89
+ return
90
+ try:
91
+ peak = float(getattr(self.recorder, "peak", 0.0) or 0.0)
92
+ except Exception:
93
+ peak = 0.0
94
+ target = min(1.0, peak * 8.0)
95
+ self._smoothed = 0.3 * self._smoothed + 0.7 * target
96
+
97
+ # ALWAYS render the full-block char (█). The cell is fully
98
+ # filled — no "bottom gray" because we don't use partial-height
99
+ # fill chars anymore (they left the unfilled portion as the
100
+ # terminal background). Amplitude is shown by COLOR INTENSITY:
101
+ # quiet = dim, loud = bright. Color also cycles through the
102
+ # rainbow palette over time.
103
+ t = time.time() - self._t0
104
+ # Cycle through palette; amplitude jumps phase forward.
105
+ phase = int(t * 3) + int(self._smoothed * 4)
106
+ base_color = _PALETTE[phase % len(_PALETTE)]
107
+
108
+ # Brightness modulation — at silence, mix toward dim grey; at
109
+ # peak, full saturation. Linear blend on RGB.
110
+ def _hex_to_rgb(h):
111
+ h = h.lstrip("#")
112
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
113
+ r, g, b = _hex_to_rgb(base_color)
114
+ # Floor at 0.35 so silence still shows a visible glow (not pitch
115
+ # black, which would read as "off"). Loud peaks pull to 1.0.
116
+ intensity = 0.35 + 0.65 * self._smoothed
117
+ r = int(r * intensity)
118
+ g = int(g * intensity)
119
+ b = int(b * intensity)
120
+ color = f"#{r:02x}{g:02x}{b:02x}"
121
+
122
+ self.update(f"[{color}]█[/]")
localcode/turn_diff.py ADDED
@@ -0,0 +1,180 @@
1
+ """Turn-level diff aggregation — show what changed in one user turn.
2
+
3
+ Instead of showing individual file operation diffs, aggregate all changes
4
+ since the turn started into one unified view:
5
+
6
+ Turn summary:
7
+ +15 -3 auth.py (modified)
8
+ +42 models/user.py (created)
9
+ -1 old_config.py (deleted)
10
+ Total: 3 files, +57 -4
11
+
12
+ This gives users a clear "what did the agent do" picture.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+
22
+ @dataclass
23
+ class FileChange:
24
+ """A single file's changes within a turn."""
25
+ path: str
26
+ action: str # "created" | "modified" | "deleted"
27
+ lines_added: int = 0
28
+ lines_removed: int = 0
29
+ old_content: str = ""
30
+ new_content: str = ""
31
+
32
+
33
+ @dataclass
34
+ class TurnDiff:
35
+ """Aggregated changes for an entire turn."""
36
+ changes: list[FileChange] = field(default_factory=list)
37
+
38
+ @property
39
+ def total_added(self) -> int:
40
+ return sum(c.lines_added for c in self.changes)
41
+
42
+ @property
43
+ def total_removed(self) -> int:
44
+ return sum(c.lines_removed for c in self.changes)
45
+
46
+ @property
47
+ def files_changed(self) -> int:
48
+ return len(self.changes)
49
+
50
+ @property
51
+ def file_paths(self) -> list[str]:
52
+ return [c.path for c in self.changes]
53
+
54
+
55
+ class TurnDiffTracker:
56
+ """Track file changes within a single user turn.
57
+
58
+ Usage:
59
+ tracker = TurnDiffTracker(repo_root)
60
+ tracker.start_turn()
61
+ # ... agent does work ...
62
+ diff = tracker.end_turn()
63
+ print(format_turn_diff(diff))
64
+ """
65
+
66
+ def __init__(self, repo_root: str | Path) -> None:
67
+ self.root = Path(repo_root)
68
+ self._baselines: dict[str, str | None] = {} # path → content at turn start (None = didn't exist)
69
+ self._tracking = False
70
+
71
+ def start_turn(self, watched_files: list[str] | None = None) -> None:
72
+ """Snapshot current state of tracked files at turn start."""
73
+ self._baselines.clear()
74
+ self._tracking = True
75
+
76
+ if watched_files:
77
+ for rel_path in watched_files:
78
+ self._snapshot(rel_path)
79
+
80
+ def track_file(self, rel_path: str) -> None:
81
+ """Start tracking a file mid-turn (called when agent reads/writes a file)."""
82
+ if self._tracking and rel_path not in self._baselines:
83
+ self._snapshot(rel_path)
84
+
85
+ def end_turn(self) -> TurnDiff:
86
+ """Compute aggregated diff for the turn."""
87
+ self._tracking = False
88
+ changes = []
89
+
90
+ for rel_path, old_content in self._baselines.items():
91
+ full = self.root / rel_path
92
+ new_exists = full.is_file()
93
+
94
+ if old_content is None and new_exists:
95
+ # File was created
96
+ new_content = full.read_text(errors="replace")
97
+ added = len(new_content.splitlines())
98
+ changes.append(FileChange(
99
+ path=rel_path, action="created",
100
+ lines_added=added, lines_removed=0,
101
+ old_content="", new_content=new_content,
102
+ ))
103
+
104
+ elif old_content is not None and not new_exists:
105
+ # File was deleted
106
+ removed = len(old_content.splitlines())
107
+ changes.append(FileChange(
108
+ path=rel_path, action="deleted",
109
+ lines_added=0, lines_removed=removed,
110
+ old_content=old_content, new_content="",
111
+ ))
112
+
113
+ elif old_content is not None and new_exists:
114
+ # File was (potentially) modified
115
+ new_content = full.read_text(errors="replace")
116
+ if new_content != old_content:
117
+ old_lines = old_content.splitlines()
118
+ new_lines = new_content.splitlines()
119
+ diff = list(difflib.unified_diff(old_lines, new_lines))
120
+ added = sum(1 for l in diff if l.startswith("+") and not l.startswith("+++"))
121
+ removed = sum(1 for l in diff if l.startswith("-") and not l.startswith("---"))
122
+ changes.append(FileChange(
123
+ path=rel_path, action="modified",
124
+ lines_added=added, lines_removed=removed,
125
+ old_content=old_content, new_content=new_content,
126
+ ))
127
+
128
+ self._baselines.clear()
129
+ return TurnDiff(changes=changes)
130
+
131
+ def _snapshot(self, rel_path: str) -> None:
132
+ """Take a baseline snapshot of a file."""
133
+ full = self.root / rel_path
134
+ if full.is_file():
135
+ try:
136
+ self._baselines[rel_path] = full.read_text(errors="replace")
137
+ except Exception:
138
+ self._baselines[rel_path] = ""
139
+ else:
140
+ self._baselines[rel_path] = None # doesn't exist yet
141
+
142
+
143
+ # ── Display ─────────────────────────────────────────────────────────
144
+
145
+ GREEN = "\033[92m"
146
+ RED = "\033[91m"
147
+ DIM = "\033[2m"
148
+ BOLD = "\033[1m"
149
+ RESET = "\033[0m"
150
+
151
+
152
+ def format_turn_diff(diff: TurnDiff) -> str:
153
+ """Format a TurnDiff for terminal display."""
154
+ if not diff.changes:
155
+ return ""
156
+
157
+ lines = [f"\n{BOLD} Turn summary:{RESET}"]
158
+
159
+ for change in diff.changes:
160
+ added = f"{GREEN}+{change.lines_added}{RESET}" if change.lines_added else ""
161
+ removed = f"{RED}-{change.lines_removed}{RESET}" if change.lines_removed else ""
162
+ stats = f"{added} {removed}".strip()
163
+ action = f"{DIM}({change.action}){RESET}"
164
+ lines.append(f" {stats:>12} {change.path} {action}")
165
+
166
+ # Total
167
+ lines.append(
168
+ f" {DIM}Total: {diff.files_changed} file(s), "
169
+ f"{GREEN}+{diff.total_added}{RESET} {RED}-{diff.total_removed}{RESET}{DIM}{RESET}"
170
+ )
171
+
172
+ return "\n".join(lines)
173
+
174
+
175
+ def print_turn_diff(diff: TurnDiff) -> None:
176
+ """Print turn diff to stdout."""
177
+ text = format_turn_diff(diff)
178
+ if text:
179
+ sys.stdout.write(text + "\n")
180
+ sys.stdout.flush()
@@ -0,0 +1 @@
1
+ e0ca52b8bae67723ff15097b80a5531b6a74860a
@@ -0,0 +1,58 @@
1
+ """localcode's default interface: the localcode agent runtime (a fork of
2
+ opencode, branded and stripped of cloud features) driven by a small Python
3
+ supervisor that owns the bundled llama-server and serves the in-app model
4
+ picker on a localhost control port.
5
+
6
+ Layout:
7
+ launch.py what `localcode` runs by default (see entrypoint.py)
8
+ supervisor.py llama-server owner + control API (/catalog /quants /select ...)
9
+ server_cmd.py the llama-server command line for THIS machine (RAM tier, KV, thinking)
10
+ ports.py singleton guard + free port pair
11
+ plugin/ completion discipline loaded into the runtime as a plugin
12
+ FORK_COMMIT the exact source commit the bundled binary was built from
13
+
14
+ State lives under ~/.local/share/localcode-agent/run (logs, voice runtime),
15
+ never inside the user's project.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import platform
21
+ from pathlib import Path
22
+
23
+ UI_BINARY_NAME = "localcode-ui"
24
+
25
+
26
+ def run_dir() -> Path:
27
+ """Per-user scratch dir for the supervisor and launcher (logs, voice venv)."""
28
+ base = os.environ.get("LOCALCODE_AGENT_RUN_DIR")
29
+ path = Path(base) if base else Path.home() / ".local" / "share" / "localcode-agent" / "run"
30
+ return path
31
+
32
+
33
+ def fork_commit() -> str:
34
+ try:
35
+ return (Path(__file__).parent / "FORK_COMMIT").read_text().strip()
36
+ except OSError:
37
+ return "unknown"
38
+
39
+
40
+ def ui_binary_path() -> Path | None:
41
+ """The bundled UI binary, or None when this install has none.
42
+
43
+ LOCALCODE_UI_BIN overrides for development against a fresh fork build.
44
+ """
45
+ override = os.environ.get("LOCALCODE_UI_BIN")
46
+ if override:
47
+ p = Path(override).expanduser()
48
+ return p if p.is_file() else None
49
+ p = Path(__file__).resolve().parent.parent / "bin" / UI_BINARY_NAME
50
+ return p if p.is_file() else None
51
+
52
+
53
+ def platform_supported() -> bool:
54
+ return platform.system() == "Darwin" and platform.machine().lower() == "arm64"
55
+
56
+
57
+ def plugin_path() -> Path:
58
+ return Path(__file__).resolve().parent / "plugin" / "localcode.ts"