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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- 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()
|
localcode/ui/FORK_COMMIT
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
e0ca52b8bae67723ff15097b80a5531b6a74860a
|
localcode/ui/__init__.py
ADDED
|
@@ -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"
|