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
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 "")
|