chad-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chad/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
chad/tui.py
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
"""Terminal UI for chad — the part that makes it feel like Claude Code.
|
|
2
|
+
|
|
3
|
+
Features that close the gap with Claude Code's UX:
|
|
4
|
+
* shift-tab cycles permission modes: normal -> auto-accept edits -> plan mode
|
|
5
|
+
* type-ahead message queue: keep typing while the agent works; messages run in order
|
|
6
|
+
* ctrl-c interrupts the running turn (stops generation) without killing the session
|
|
7
|
+
* inline y/n approval for mutating tools in normal mode
|
|
8
|
+
* a live status line: model, mode, queued count, context usage
|
|
9
|
+
|
|
10
|
+
Rendering model (this is why copy/paste and scrolling work like a normal terminal):
|
|
11
|
+
the transcript is printed straight into the terminal's *normal* scrollback as ANSI
|
|
12
|
+
text — there is no alternate screen and no mouse capture. Only the status line and
|
|
13
|
+
the input box live in a small prompt_toolkit region pinned at the bottom
|
|
14
|
+
(`full_screen=False`), and background output is routed above it via `patch_stdout`.
|
|
15
|
+
Because the app never grabs the mouse or switches to the alt screen, the terminal's
|
|
16
|
+
own text selection (copy/paste) and scrollback behave exactly as they do at a shell.
|
|
17
|
+
|
|
18
|
+
Architecture: the agent loop runs on a background worker thread pulling from a
|
|
19
|
+
message queue; the prompt_toolkit Application owns the input/status on the asyncio
|
|
20
|
+
loop. The agent's I/O is injected (emit/confirm/should_stop callbacks) so the same
|
|
21
|
+
Agent code drives both this TUI and the plain REPL. Emitted fragments are buffered
|
|
22
|
+
and flushed to stdout on a UI-loop refresher (~20 Hz) so a fast token stream doesn't
|
|
23
|
+
trigger a redraw per token.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from collections import deque
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
from prompt_toolkit.application import Application
|
|
37
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
38
|
+
from prompt_toolkit.filters import Condition, has_focus
|
|
39
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
40
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
41
|
+
from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window
|
|
42
|
+
from prompt_toolkit.layout.containers import ConditionalContainer
|
|
43
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
44
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
45
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
46
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
47
|
+
from prompt_toolkit.styles import Style
|
|
48
|
+
from prompt_toolkit.widgets import TextArea
|
|
49
|
+
|
|
50
|
+
from . import config
|
|
51
|
+
from .agent import INIT_PROMPT, MODE_LABEL, Agent
|
|
52
|
+
from .base_engine import BaseEngine
|
|
53
|
+
from .ignore import IGNORE_DIRS
|
|
54
|
+
from .render import C_RST, C_YEL, ansi_fragment, banner, confirm_preview, render_tool_result
|
|
55
|
+
|
|
56
|
+
# Styling for the pinned bottom region only (status line + input). The transcript
|
|
57
|
+
# above is plain ANSI (see _ansi_for), so it lives in normal terminal scrollback.
|
|
58
|
+
_STYLE = Style.from_dict({
|
|
59
|
+
"spinner": "#8fce8f bold",
|
|
60
|
+
"idle": "#6b6b6b",
|
|
61
|
+
"user": "#d7a86e bold",
|
|
62
|
+
"status.normal": "reverse",
|
|
63
|
+
"status.auto": "bg:#3a5f3a #ffffff",
|
|
64
|
+
"status.plan": "bg:#3a3a6f #ffffff",
|
|
65
|
+
"confirm": "bg:#6f5a2a #ffffff",
|
|
66
|
+
"todo.done": "#6b8f6b",
|
|
67
|
+
"todo.cur": "#d7a86e bold",
|
|
68
|
+
"todo.pending": "#8a8a8a",
|
|
69
|
+
"todo.summary": "#8a8a8a",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
MODE_STYLE = {"normal": "status.normal", "auto": "status.auto", "plan": "status.plan"}
|
|
73
|
+
|
|
74
|
+
# Spinner frames + the gerund shown next to it, keyed off the latest activity.
|
|
75
|
+
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
76
|
+
_PHASE_VERB = {"Read": "Reading", "Edit": "Editing", "Write": "Writing", "Run": "Running",
|
|
77
|
+
"Search": "Searching", "Find": "Searching", "Plan": "Planning",
|
|
78
|
+
"Overview": "Reading", "View": "Reading", "Refs": "Searching",
|
|
79
|
+
"Task": "Delegating"}
|
|
80
|
+
|
|
81
|
+
# A one-cell state glyph coupled to the activity verb, so the indicator carries phase at
|
|
82
|
+
# a glance (the spinner braille is the *motion*; this is the *identity*). Matched on a
|
|
83
|
+
# verb prefix; prefill/compaction labels and anything unknown fall back to a neutral dot.
|
|
84
|
+
_PHASE_GLYPH = {"Thinking": "✶", "Planning": "✶", "Responding": "◆", "Reading": "◇",
|
|
85
|
+
"Editing": "✎", "Writing": "✎", "Running": "▸", "Searching": "⌕"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _phase_glyph(phase: str) -> str:
|
|
89
|
+
g = _PHASE_GLYPH.get(phase)
|
|
90
|
+
if g:
|
|
91
|
+
return g
|
|
92
|
+
low = phase.lower()
|
|
93
|
+
if "prefill" in low or "compact" in low:
|
|
94
|
+
return "⋯"
|
|
95
|
+
return "•"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _kfmt(n: int) -> str:
|
|
99
|
+
"""Compact token count for the status line: 8200 → "8.2k", <1000 → "900"."""
|
|
100
|
+
if n < 1000:
|
|
101
|
+
return str(n)
|
|
102
|
+
return f"{n / 1000:.1f}k"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Pinned todo panel (plan 042 item 1). Pure helpers so the collapse/glyph logic is
|
|
107
|
+
# unit-testable without constructing a prompt_toolkit layout; the TUI wraps the rows in
|
|
108
|
+
# styled fragments. `write_todos` items are {content, status} dicts.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_TODO_GLYPH = {"completed": "✓", "in_progress": "▸", "pending": "·"}
|
|
112
|
+
_TODO_STYLE = {"completed": "class:todo.done", "in_progress": "class:todo.cur",
|
|
113
|
+
"pending": "class:todo.pending", "summary": "class:todo.summary"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _clip(s: str, n: int) -> str:
|
|
117
|
+
s = " ".join(str(s).split())
|
|
118
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _todo_status(t) -> str:
|
|
122
|
+
return t.get("status", "pending") if isinstance(t, dict) else "pending"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _todo_panel_rows(todos, max_items: int = 8):
|
|
126
|
+
"""Rows to pin above the status line: `[(kind, text)]`. Empty list → hidden.
|
|
127
|
+
A long list (> max_items) collapses to one summary row (`3/7 done ▸ current…`);
|
|
128
|
+
otherwise one glyphed row per item (✓ done / ▸ current / · pending)."""
|
|
129
|
+
if not todos:
|
|
130
|
+
return []
|
|
131
|
+
total = len(todos)
|
|
132
|
+
done = sum(1 for t in todos if _todo_status(t) == "completed")
|
|
133
|
+
if total > max_items:
|
|
134
|
+
cur = next((t.get("content", "") for t in todos
|
|
135
|
+
if isinstance(t, dict) and _todo_status(t) == "in_progress"), "")
|
|
136
|
+
if not cur:
|
|
137
|
+
cur = next((t.get("content", "") for t in todos
|
|
138
|
+
if isinstance(t, dict) and _todo_status(t) == "pending"), "")
|
|
139
|
+
label = f"{done}/{total} done"
|
|
140
|
+
if cur:
|
|
141
|
+
label += f" ▸ {_clip(cur, 56)}"
|
|
142
|
+
return [("summary", label)]
|
|
143
|
+
rows = []
|
|
144
|
+
for t in todos:
|
|
145
|
+
st = _todo_status(t)
|
|
146
|
+
content = t.get("content", "") if isinstance(t, dict) else str(t)
|
|
147
|
+
rows.append((st, f"{_TODO_GLYPH.get(st, '·')} {content}"))
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Input completion (plan 042 item 2). The completer wiring is thin; all the logic
|
|
153
|
+
# lives in these pure helpers so it is unit-testable (prompt_toolkit Completers are
|
|
154
|
+
# awkward to drive in a test — the completer/menu wiring is verified manually).
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# (command, one-line description) for the `/` menu — mirrors _on_accept + /help.
|
|
158
|
+
SLASH_COMMANDS = [
|
|
159
|
+
("/help", "commands & keybindings"),
|
|
160
|
+
("/init", "analyze the project, write CLAUDE.md"),
|
|
161
|
+
("/skills", "list available Agent Skills"),
|
|
162
|
+
("/mcp", "MCP server status"),
|
|
163
|
+
("/mcp trust", "trust this project's .mcp.json servers"),
|
|
164
|
+
("/mcp login", "authenticate an MCP server (OAuth)"),
|
|
165
|
+
("/compact", "reclaim context now"),
|
|
166
|
+
("/resume", "list recent sessions; /resume <n> forks one"),
|
|
167
|
+
("/reset", "clear the conversation + KV cache"),
|
|
168
|
+
("/clear", "clear the conversation + KV cache"),
|
|
169
|
+
("/model", "show model + context window"),
|
|
170
|
+
("/mode", "cycle permission mode"),
|
|
171
|
+
("/accept", "accept a pending plan and implement it"),
|
|
172
|
+
("/exit", "quit chad"),
|
|
173
|
+
("/quit", "quit chad"),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def slash_matches(text: str):
|
|
178
|
+
"""`[(cmd, desc)]` whose command starts with the typed line. Only fires for a
|
|
179
|
+
single-line input that starts with `/`; once the text runs past a known command
|
|
180
|
+
(an arg is being typed, e.g. `/mcp login foo`) nothing matches, so completion stops."""
|
|
181
|
+
if "\n" in text or not text.startswith("/"):
|
|
182
|
+
return []
|
|
183
|
+
return [(c, d) for (c, d) in SLASH_COMMANDS if c.startswith(text)]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def at_path_token(text_before_cursor: str) -> Optional[str]:
|
|
187
|
+
"""The `@`-path fragment under the cursor (text AFTER the `@`), or None when the
|
|
188
|
+
cursor isn't in an `@`-token. The token is the last whitespace-delimited chunk."""
|
|
189
|
+
if not text_before_cursor or "@" not in text_before_cursor:
|
|
190
|
+
return None
|
|
191
|
+
frag = re.split(r"\s", text_before_cursor)[-1]
|
|
192
|
+
if not frag.startswith("@"):
|
|
193
|
+
return None
|
|
194
|
+
return frag[1:]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def path_matches(fragment: str, cwd: Optional[str] = None):
|
|
198
|
+
"""Filesystem completions for an `@`-path `fragment` (text after `@`). Directories
|
|
199
|
+
get a trailing `/`; `IGNORE_DIRS` are skipped; dotfiles are hidden until a leading
|
|
200
|
+
dot is typed. Returns sorted display strings (the path, without the leading `@`)."""
|
|
201
|
+
base = cwd or os.getcwd()
|
|
202
|
+
dirname, partial = os.path.split(fragment or "")
|
|
203
|
+
listdir = dirname if os.path.isabs(dirname) else os.path.join(base, dirname)
|
|
204
|
+
try:
|
|
205
|
+
entries = os.listdir(listdir or ".")
|
|
206
|
+
except OSError:
|
|
207
|
+
return []
|
|
208
|
+
out = []
|
|
209
|
+
for name in entries:
|
|
210
|
+
if name in IGNORE_DIRS or not name.startswith(partial):
|
|
211
|
+
continue
|
|
212
|
+
if not partial and name.startswith("."):
|
|
213
|
+
continue
|
|
214
|
+
disp = os.path.join(dirname, name) if dirname else name
|
|
215
|
+
if os.path.isdir(os.path.join(listdir, name)):
|
|
216
|
+
disp += "/"
|
|
217
|
+
out.append(disp)
|
|
218
|
+
return sorted(out)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _ChadCompleter(Completer):
|
|
222
|
+
"""Slash-command menu at line start + `@`-path filesystem completion. Thin wrapper
|
|
223
|
+
over the pure helpers above; keeps ⏎-submits/multiline editing untouched."""
|
|
224
|
+
|
|
225
|
+
def get_completions(self, document, complete_event):
|
|
226
|
+
text = document.text_before_cursor
|
|
227
|
+
for cmd, desc in slash_matches(text):
|
|
228
|
+
yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)
|
|
229
|
+
if text.startswith("/"):
|
|
230
|
+
return
|
|
231
|
+
frag = at_path_token(text)
|
|
232
|
+
if frag is not None:
|
|
233
|
+
for disp in path_matches(frag):
|
|
234
|
+
yield Completion(disp, start_position=-len(frag), display=disp)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _make_history():
|
|
238
|
+
"""Persistent input history: `FileHistory` at ~/.chad/history (mode 0600 — it can
|
|
239
|
+
hold typed paths/snippets, like the session store), or `InMemoryHistory` when
|
|
240
|
+
CHAD_NO_SESSION_LOG is set. Falls back to in-memory on any filesystem error."""
|
|
241
|
+
if config.flag("CHAD_NO_SESSION_LOG"):
|
|
242
|
+
return InMemoryHistory()
|
|
243
|
+
path = os.path.expanduser("~/.chad/history")
|
|
244
|
+
try:
|
|
245
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
246
|
+
if not os.path.exists(path):
|
|
247
|
+
os.close(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600))
|
|
248
|
+
else:
|
|
249
|
+
os.chmod(path, 0o600)
|
|
250
|
+
return FileHistory(path)
|
|
251
|
+
except OSError:
|
|
252
|
+
return InMemoryHistory()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class TUI:
|
|
256
|
+
def __init__(self, engine: BaseEngine, ctx_limit: int, mode: str = "normal",
|
|
257
|
+
thinking: bool = True, max_chars: int = 400_000, resume: list = None,
|
|
258
|
+
ctx_window: int = None, finalize=None):
|
|
259
|
+
self.engine = engine
|
|
260
|
+
self.ctx_limit = ctx_limit
|
|
261
|
+
self.ctx_window = ctx_window or ctx_limit # window shown in the banner
|
|
262
|
+
self.thinking = thinking
|
|
263
|
+
self._resume = resume
|
|
264
|
+
|
|
265
|
+
# Background model load (plan: instant startup). When `finalize` is given the
|
|
266
|
+
# weights load on a worker thread while the banner + input are already on screen;
|
|
267
|
+
# `_model_ready` gates the turn worker until they're in, `_load_error` records a
|
|
268
|
+
# load failure so a queued turn reports it instead of hanging. With no finalize
|
|
269
|
+
# (tests, or an already-loaded engine) the model is ready immediately.
|
|
270
|
+
self._finalize = finalize
|
|
271
|
+
self._model_ready = threading.Event()
|
|
272
|
+
self._load_error = None
|
|
273
|
+
if finalize is None:
|
|
274
|
+
self._model_ready.set()
|
|
275
|
+
|
|
276
|
+
self._pending = [] # ANSI chunks awaiting flush to stdout
|
|
277
|
+
self._todos = [] # live write_todos list for the pinned panel
|
|
278
|
+
self._lock = threading.Lock()
|
|
279
|
+
self._queue = deque() # user messages awaiting the worker
|
|
280
|
+
self._wake = threading.Event() # signal the worker that work/queue changed
|
|
281
|
+
self._shutdown = False
|
|
282
|
+
self._busy = False
|
|
283
|
+
self._cur_prompt_tokens = 0 # last rendered prompt size (context gauge)
|
|
284
|
+
self._tick = 0 # animation frame counter (spinner)
|
|
285
|
+
self._phase = "Thinking" # current activity verb shown by the spinner
|
|
286
|
+
# Live activity readouts for the bottom status line (plans 033/034). Reset per
|
|
287
|
+
# turn in _worker; updated by the agent's gen/prefill emits. Display-only.
|
|
288
|
+
self._turn_start = 0.0 # time.monotonic() at turn start (elapsed timer)
|
|
289
|
+
self._gen_tokens = 0 # ↓ generated this step (live)
|
|
290
|
+
self._prefilled = 0 # ↑ tokens prefilled so far (live)
|
|
291
|
+
self._prefill_total = 0 # total tokens in the active prefill (for %)
|
|
292
|
+
self._think_capped = 0 # soft think-cap firings this turn (plan 057)
|
|
293
|
+
|
|
294
|
+
# interrupt + confirmation plumbing between UI and worker threads
|
|
295
|
+
self._interrupt = threading.Event()
|
|
296
|
+
self._confirm_req: Optional[tuple] = None # (name, args) awaiting a y/n answer
|
|
297
|
+
self._confirm_event = threading.Event()
|
|
298
|
+
self._confirm_answer = False
|
|
299
|
+
|
|
300
|
+
self.agent = Agent(
|
|
301
|
+
engine, ctx_limit=ctx_limit, mode=mode, thinking=thinking,
|
|
302
|
+
emit=self._emit, confirm=self._confirm, should_stop=self._interrupt.is_set,
|
|
303
|
+
resume=resume, persist=True,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Plan-mode handoff state. After a plan-mode turn writes a plan file,
|
|
307
|
+
# `_pending_plan` holds its path and the user can steer (type) or accept
|
|
308
|
+
# (ctrl-g / `/accept`). The accepted implementation session inherits the
|
|
309
|
+
# session's baseline permission mode (auto when launched --yolo, else normal).
|
|
310
|
+
self._pending_plan = None
|
|
311
|
+
self._base_mode = self.agent.mode if self.agent.mode != "plan" else "normal"
|
|
312
|
+
# Governor handoff (plan 040): after a turn hard-stops on its budget, holds the
|
|
313
|
+
# deterministic progress note. The next typed message relaunches a FRESH turn
|
|
314
|
+
# (cleared context) seeded with the note — shedding the ramble + huge prefill.
|
|
315
|
+
self._pending_budget_note = None
|
|
316
|
+
# Sessions last shown by a bare `/resume` (so `/resume <n>` maps a number to a
|
|
317
|
+
# session without re-listing / a race with new saves).
|
|
318
|
+
self._resume_list = []
|
|
319
|
+
|
|
320
|
+
# Multiline input that auto-grows up to 8 rows. Enter submits; alt-enter
|
|
321
|
+
# (or ctrl-j) inserts a newline; pasted text keeps its newlines.
|
|
322
|
+
self.input = TextArea(
|
|
323
|
+
height=Dimension(min=1, max=8), multiline=True, wrap_lines=True,
|
|
324
|
+
prompt="» ", style="class:user", history=_make_history(),
|
|
325
|
+
accept_handler=self._on_accept,
|
|
326
|
+
completer=_ChadCompleter(), complete_while_typing=True,
|
|
327
|
+
)
|
|
328
|
+
self.status = Window(
|
|
329
|
+
content=FormattedTextControl(self._status_fragments), height=1,
|
|
330
|
+
)
|
|
331
|
+
# A pinned todo checklist above the status line (plan 042 item 1). Hidden when
|
|
332
|
+
# empty; `dont_extend_height` so it takes exactly its row count — no blank rows.
|
|
333
|
+
self.todo_panel = ConditionalContainer(
|
|
334
|
+
Window(content=FormattedTextControl(self._todo_fragments),
|
|
335
|
+
dont_extend_height=True),
|
|
336
|
+
filter=Condition(lambda: bool(_todo_panel_rows(self._todos))),
|
|
337
|
+
)
|
|
338
|
+
# Only the todo panel + status line + input are owned by prompt_toolkit; the
|
|
339
|
+
# transcript is printed above this region into the terminal's normal scrollback.
|
|
340
|
+
# A FloatContainer hosts the `/` + `@` completion menu (plan 042 item 2).
|
|
341
|
+
root = FloatContainer(
|
|
342
|
+
HSplit([self.todo_panel, self.status, self.input]),
|
|
343
|
+
floats=[Float(xcursor=True, ycursor=True,
|
|
344
|
+
content=CompletionsMenu(max_height=8, scroll_offset=1))],
|
|
345
|
+
)
|
|
346
|
+
self.app = Application(
|
|
347
|
+
layout=Layout(root, focused_element=self.input),
|
|
348
|
+
key_bindings=self._bindings(),
|
|
349
|
+
style=_STYLE,
|
|
350
|
+
full_screen=False,
|
|
351
|
+
mouse_support=False,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# -- agent I/O callbacks (called from the worker thread) --------------
|
|
355
|
+
|
|
356
|
+
def _emit(self, kind: str, text: str):
|
|
357
|
+
# Map activity to the spinner verb, then queue the transcript fragment (if any).
|
|
358
|
+
if kind == "think":
|
|
359
|
+
self._phase = "Thinking"
|
|
360
|
+
elif kind == "status": # explicit activity verb from the agent (no transcript)
|
|
361
|
+
self._phase = text
|
|
362
|
+
return
|
|
363
|
+
elif kind == "ctx": # live prompt-size gauge for the status line
|
|
364
|
+
try:
|
|
365
|
+
self._cur_prompt_tokens = int(text)
|
|
366
|
+
except ValueError:
|
|
367
|
+
pass
|
|
368
|
+
return
|
|
369
|
+
elif kind == "gen": # live ↓ generated-token count (no transcript)
|
|
370
|
+
try:
|
|
371
|
+
self._gen_tokens = int(text)
|
|
372
|
+
except ValueError:
|
|
373
|
+
pass
|
|
374
|
+
return
|
|
375
|
+
elif kind == "prefill": # live ↑ prefill progress as "done/total" (no transcript)
|
|
376
|
+
try:
|
|
377
|
+
done, total = text.split("/", 1)
|
|
378
|
+
self._prefilled = int(done)
|
|
379
|
+
self._prefill_total = int(total)
|
|
380
|
+
except (ValueError, IndexError):
|
|
381
|
+
pass
|
|
382
|
+
return
|
|
383
|
+
elif kind == "thinkcap": # soft think-cap fired this turn (plan 057; no transcript)
|
|
384
|
+
try:
|
|
385
|
+
self._think_capped = int(text)
|
|
386
|
+
except ValueError:
|
|
387
|
+
pass
|
|
388
|
+
return
|
|
389
|
+
elif kind == "todos": # write_todos payload for the pinned panel (no transcript)
|
|
390
|
+
try:
|
|
391
|
+
self._todos = json.loads(text)
|
|
392
|
+
except (ValueError, TypeError):
|
|
393
|
+
self._todos = []
|
|
394
|
+
return
|
|
395
|
+
elif kind == "stream":
|
|
396
|
+
self._phase = "Responding"
|
|
397
|
+
elif kind == "tool":
|
|
398
|
+
self._phase = _PHASE_VERB.get(text.split(" ", 1)[0], "Working")
|
|
399
|
+
frag = self._ansi_for(kind, text)
|
|
400
|
+
if frag:
|
|
401
|
+
with self._lock:
|
|
402
|
+
self._pending.append(frag)
|
|
403
|
+
|
|
404
|
+
def _ansi_for(self, kind: str, text: str) -> str:
|
|
405
|
+
# Transcript fragments as raw ANSI for the terminal scrollback. Prose is
|
|
406
|
+
# left at the terminal's default foreground (near-white); reasoning is dim.
|
|
407
|
+
if kind == "stream": # DIVERGES from the REPL: raw here, green-wrapped there.
|
|
408
|
+
return text
|
|
409
|
+
if kind == "user": # DIVERGES from the REPL: multi-line here, single-line there.
|
|
410
|
+
return self._user_ansi(text)
|
|
411
|
+
frag = ansi_fragment(kind, text)
|
|
412
|
+
return frag if frag is not None else "" # 'stat'/unknowns drop from the UI
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _user_ansi(text: str) -> str:
|
|
416
|
+
# Render a (possibly multiline) user message with the prompt marker on the
|
|
417
|
+
# first line and aligned continuation on the rest.
|
|
418
|
+
lines = text.split("\n")
|
|
419
|
+
out = f"\n{C_YEL}» {lines[0]}{C_RST}\n"
|
|
420
|
+
out += "".join(f"{C_YEL} {ln}{C_RST}\n" for ln in lines[1:])
|
|
421
|
+
return out
|
|
422
|
+
|
|
423
|
+
def _confirm(self, name, args) -> bool:
|
|
424
|
+
# Block the worker until the user answers y/n in the UI.
|
|
425
|
+
if self._interrupt.is_set():
|
|
426
|
+
return False
|
|
427
|
+
self._confirm_answer = False
|
|
428
|
+
self._confirm_event.clear()
|
|
429
|
+
self._confirm_req = (name, args)
|
|
430
|
+
self.app.invalidate()
|
|
431
|
+
while not self._confirm_event.wait(timeout=0.1):
|
|
432
|
+
if self._shutdown or self._interrupt.is_set():
|
|
433
|
+
self._confirm_req = None
|
|
434
|
+
return False
|
|
435
|
+
self._confirm_req = None
|
|
436
|
+
return self._confirm_answer
|
|
437
|
+
|
|
438
|
+
# -- UI rendering ----------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def _status_fragments(self):
|
|
441
|
+
if self._confirm_req:
|
|
442
|
+
name, args = self._confirm_req
|
|
443
|
+
# The status window is a single line (height=1), so flatten the
|
|
444
|
+
# (possibly multi-line) preview into one clipped line.
|
|
445
|
+
preview = confirm_preview(name, args).replace("\n", " ⏎ ")
|
|
446
|
+
if len(preview) > 160:
|
|
447
|
+
preview = preview[:160] + " …"
|
|
448
|
+
return [("class:confirm",
|
|
449
|
+
f" allow {name}({preview})? [y]es [n]o ")]
|
|
450
|
+
mode = self.agent.mode
|
|
451
|
+
pct = int(100 * self._cur_prompt_tokens / self.ctx_limit) if self.ctx_limit else 0
|
|
452
|
+
qn = len(self._queue)
|
|
453
|
+
# Left: an animated activity indicator. Plans 033/034 reverse the old "verb only"
|
|
454
|
+
# choice for THIS live line only (session.log still carries the rate diagnostics):
|
|
455
|
+
# a state glyph + the verb, then the two numbers that are reassurance not noise —
|
|
456
|
+
# elapsed seconds and ↑prefilled/↓generated counts — plus an advancing % while a
|
|
457
|
+
# big prefill streams (the silent gap this is meant to make legible).
|
|
458
|
+
if not self._model_ready.is_set():
|
|
459
|
+
# Startup: weights still loading on the background thread. Show a live spinner
|
|
460
|
+
# so the pinned line reads as "working", not hung, while you type ahead.
|
|
461
|
+
frame = _SPINNER[(self._tick // 2) % len(_SPINNER)]
|
|
462
|
+
label = self.engine.model_id.split("/")[-1]
|
|
463
|
+
hint = "type ahead — runs when ready" if not qn else f"queued:{qn} — runs when ready"
|
|
464
|
+
left = [("class:spinner", f" {frame} loading {label}… "),
|
|
465
|
+
("class:idle", f"{hint} ")]
|
|
466
|
+
return left
|
|
467
|
+
if self._busy:
|
|
468
|
+
frame = _SPINNER[(self._tick // 2) % len(_SPINNER)]
|
|
469
|
+
glyph = _phase_glyph(self._phase)
|
|
470
|
+
elapsed = int(time.monotonic() - self._turn_start) if self._turn_start else 0
|
|
471
|
+
prog = ""
|
|
472
|
+
if self._prefill_total and self._gen_tokens == 0: # still prefilling this step
|
|
473
|
+
prog = f"{int(100 * self._prefilled / self._prefill_total)}% "
|
|
474
|
+
# ✂N: the adaptive soft think-cap (plan 039) trimmed N over-long reasoning runs
|
|
475
|
+
# this turn. Shown only when it actually fired — the cap is off by default, so the
|
|
476
|
+
# default line is byte-identical to before (plan 057).
|
|
477
|
+
cap = f"✂{self._think_capped} · " if self._think_capped else ""
|
|
478
|
+
left = [("class:spinner", f" {frame} {glyph} {self._phase}… "),
|
|
479
|
+
("class:idle", f"{prog}{elapsed}s · ↑{_kfmt(self._prefilled)} "
|
|
480
|
+
f"↓{_kfmt(self._gen_tokens)} · {cap}ctrl-c ")]
|
|
481
|
+
else:
|
|
482
|
+
left = [("class:idle", f" {_phase_glyph(self._phase)} ready ")]
|
|
483
|
+
# Model id lives in the startup banner now — no need to repeat it every frame.
|
|
484
|
+
bits = [
|
|
485
|
+
f" {MODE_LABEL[mode]} (shift-tab) ",
|
|
486
|
+
f" ctx {pct}% ",
|
|
487
|
+
]
|
|
488
|
+
if qn:
|
|
489
|
+
bits.append(f" queued:{qn} ")
|
|
490
|
+
return left + [("class:" + MODE_STYLE[mode], "".join(bits))]
|
|
491
|
+
|
|
492
|
+
def _todo_fragments(self):
|
|
493
|
+
# prompt_toolkit fragments for the pinned todo panel; one styled row per line.
|
|
494
|
+
frags = []
|
|
495
|
+
for i, (kind, text) in enumerate(_todo_panel_rows(self._todos)):
|
|
496
|
+
if i:
|
|
497
|
+
frags.append(("", "\n"))
|
|
498
|
+
frags.append((_TODO_STYLE.get(kind, "class:todo.pending"), " " + text))
|
|
499
|
+
return frags
|
|
500
|
+
|
|
501
|
+
def _flush(self):
|
|
502
|
+
# Runs on the UI loop (refresher). Under patch_stdout this writes the
|
|
503
|
+
# buffered transcript above the pinned input/status region. Coalescing here
|
|
504
|
+
# caps redraws at the refresher rate regardless of token throughput.
|
|
505
|
+
#
|
|
506
|
+
# Critically we do NOT call sys.stdout.flush(): StdoutProxy holds back any
|
|
507
|
+
# text after the last newline, because a partial (newline-less) line would
|
|
508
|
+
# be overwritten by the redraw of the pinned input/status region. Forcing a
|
|
509
|
+
# flush pushes that partial line out and it gets clobbered — which is why an
|
|
510
|
+
# un-flushed stream looked garbled. So we write whole lines as they settle
|
|
511
|
+
# and let the worker emit a trailing newline at turn end (see _worker) to
|
|
512
|
+
# commit the final line.
|
|
513
|
+
with self._lock:
|
|
514
|
+
if not self._pending:
|
|
515
|
+
return
|
|
516
|
+
chunk = "".join(self._pending)
|
|
517
|
+
self._pending.clear()
|
|
518
|
+
sys.stdout.write(chunk)
|
|
519
|
+
|
|
520
|
+
async def _refresher(self):
|
|
521
|
+
while not self._shutdown:
|
|
522
|
+
self._flush()
|
|
523
|
+
if self._busy:
|
|
524
|
+
self._tick += 1
|
|
525
|
+
self.app.invalidate()
|
|
526
|
+
await asyncio.sleep(0.05)
|
|
527
|
+
|
|
528
|
+
# -- key bindings ----------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def _bindings(self):
|
|
531
|
+
kb = KeyBindings()
|
|
532
|
+
confirming = Condition(lambda: self._confirm_req is not None)
|
|
533
|
+
in_input = has_focus(self.input)
|
|
534
|
+
|
|
535
|
+
# Enter submits (eager, so it beats the multiline buffer's newline insert);
|
|
536
|
+
# alt-enter and ctrl-j insert a literal newline for multiline prompts.
|
|
537
|
+
@kb.add("enter", filter=in_input & ~confirming, eager=True)
|
|
538
|
+
def _(event):
|
|
539
|
+
self.input.buffer.validate_and_handle()
|
|
540
|
+
|
|
541
|
+
@kb.add("escape", "enter", filter=in_input)
|
|
542
|
+
@kb.add("c-j", filter=in_input)
|
|
543
|
+
def _(event):
|
|
544
|
+
self.input.buffer.insert_text("\n")
|
|
545
|
+
|
|
546
|
+
@kb.add("s-tab", filter=~confirming)
|
|
547
|
+
def _(event):
|
|
548
|
+
self.agent.cycle_mode()
|
|
549
|
+
event.app.invalidate()
|
|
550
|
+
|
|
551
|
+
# ctrl-g accepts a pending plan (clear context + start implementing) when the
|
|
552
|
+
# input is empty; a one-keystroke alternative to typing /accept.
|
|
553
|
+
pending = Condition(lambda: self._pending_plan is not None)
|
|
554
|
+
@kb.add("c-g", filter=in_input & pending)
|
|
555
|
+
def _(event):
|
|
556
|
+
self._accept_plan()
|
|
557
|
+
event.app.invalidate()
|
|
558
|
+
|
|
559
|
+
# eager=True so y/n answer the prompt instead of being typed into the input box
|
|
560
|
+
@kb.add("y", filter=confirming, eager=True)
|
|
561
|
+
@kb.add("Y", filter=confirming, eager=True)
|
|
562
|
+
def _(event):
|
|
563
|
+
self._confirm_answer = True
|
|
564
|
+
self._confirm_event.set()
|
|
565
|
+
|
|
566
|
+
@kb.add("n", filter=confirming, eager=True)
|
|
567
|
+
@kb.add("N", filter=confirming, eager=True)
|
|
568
|
+
@kb.add("escape", filter=confirming, eager=True)
|
|
569
|
+
def _(event):
|
|
570
|
+
self._confirm_answer = False
|
|
571
|
+
self._confirm_event.set()
|
|
572
|
+
|
|
573
|
+
@kb.add("c-c")
|
|
574
|
+
def _(event):
|
|
575
|
+
if self._busy or self._confirm_req:
|
|
576
|
+
self._interrupt.set()
|
|
577
|
+
self._confirm_event.set() # unblock a pending confirm as a denial
|
|
578
|
+
self._emit("info", " [interrupting…]")
|
|
579
|
+
elif self.input.text.strip():
|
|
580
|
+
self.input.buffer.reset()
|
|
581
|
+
else:
|
|
582
|
+
self._shutdown_app(event)
|
|
583
|
+
|
|
584
|
+
@kb.add("c-d")
|
|
585
|
+
def _(event):
|
|
586
|
+
self._shutdown_app(event)
|
|
587
|
+
|
|
588
|
+
return kb
|
|
589
|
+
|
|
590
|
+
def _shutdown_app(self, event):
|
|
591
|
+
self._shutdown = True
|
|
592
|
+
self._wake.set()
|
|
593
|
+
self._confirm_event.set()
|
|
594
|
+
event.app.exit()
|
|
595
|
+
|
|
596
|
+
# -- session reset / plan handoff ------------------------------------
|
|
597
|
+
|
|
598
|
+
def _fresh_agent(self, mode: str) -> bool:
|
|
599
|
+
"""Clear the conversation + KV cache and start a new Agent in `mode`.
|
|
600
|
+
Returns False (without resetting) if a turn won't yield in time."""
|
|
601
|
+
self._queue.clear()
|
|
602
|
+
if self._busy:
|
|
603
|
+
# A turn is on the worker thread mutating engine._cache / _cached_ids.
|
|
604
|
+
# Signal it to stop and wait for it to unwind before we reset the cache,
|
|
605
|
+
# otherwise we race the live generate().
|
|
606
|
+
self._interrupt.set()
|
|
607
|
+
deadline = time.time() + 10
|
|
608
|
+
while self._busy and time.time() < deadline:
|
|
609
|
+
time.sleep(0.02)
|
|
610
|
+
if self._busy:
|
|
611
|
+
self._emit("info", "reset deferred: turn still running.")
|
|
612
|
+
return False
|
|
613
|
+
self.agent = Agent(
|
|
614
|
+
self.engine, ctx_limit=self.ctx_limit, mode=mode,
|
|
615
|
+
thinking=self.thinking, emit=self._emit, confirm=self._confirm,
|
|
616
|
+
should_stop=self._interrupt.is_set,
|
|
617
|
+
)
|
|
618
|
+
self._pending_plan = None
|
|
619
|
+
self._pending_budget_note = None
|
|
620
|
+
self._interrupt.clear() # the new turn must start un-interrupted
|
|
621
|
+
self.engine.reset()
|
|
622
|
+
return True
|
|
623
|
+
|
|
624
|
+
def _accept_plan(self):
|
|
625
|
+
"""Accept a pending plan: clear context and start a fresh implementation
|
|
626
|
+
session (inheriting the session's baseline perms) seeded to execute it."""
|
|
627
|
+
path = self._pending_plan
|
|
628
|
+
if not path:
|
|
629
|
+
self._emit("info", "no plan pending.")
|
|
630
|
+
return
|
|
631
|
+
rel = os.path.relpath(path)
|
|
632
|
+
if not self._fresh_agent(self._base_mode):
|
|
633
|
+
return
|
|
634
|
+
kickoff = (f"Implement the plan in {rel}. Read the whole file first, then "
|
|
635
|
+
f"execute each step. Run the verification commands when done.")
|
|
636
|
+
self._queue.append(kickoff)
|
|
637
|
+
self._emit("info", f"context cleared · implementing {rel}")
|
|
638
|
+
self._emit("user", kickoff)
|
|
639
|
+
self._wake.set()
|
|
640
|
+
|
|
641
|
+
def _handle_resume(self, arg: str):
|
|
642
|
+
"""`/resume` lists this directory's recent sessions; `/resume <n>` forks the
|
|
643
|
+
picked one — a fresh Agent (new session_id, cache reset) seeded with the old
|
|
644
|
+
messages, so the original session file is never overwritten (plan 043)."""
|
|
645
|
+
from . import session
|
|
646
|
+
if not arg:
|
|
647
|
+
items = session.list_sessions(os.getcwd(), limit=10)
|
|
648
|
+
if not items:
|
|
649
|
+
self._emit("info", "no saved sessions for this directory.")
|
|
650
|
+
return
|
|
651
|
+
self._resume_list = items
|
|
652
|
+
self._emit("info", "resume which session? type /resume <n>")
|
|
653
|
+
for i, it in enumerate(items, 1):
|
|
654
|
+
self._emit("info", f" {i}. {session.describe(it)}")
|
|
655
|
+
return
|
|
656
|
+
try:
|
|
657
|
+
n = int(arg)
|
|
658
|
+
except ValueError:
|
|
659
|
+
self._emit("info", "usage: /resume (to list) · /resume <number>")
|
|
660
|
+
return
|
|
661
|
+
items = self._resume_list or session.list_sessions(os.getcwd(), limit=10)
|
|
662
|
+
if not (1 <= n <= len(items)):
|
|
663
|
+
self._emit("info", "out of range — run /resume to see the list.")
|
|
664
|
+
return
|
|
665
|
+
pick = items[n - 1]
|
|
666
|
+
data = session.load_session(os.getcwd(), pick["session_id"])
|
|
667
|
+
if not data:
|
|
668
|
+
self._emit("info", "could not load that session.")
|
|
669
|
+
return
|
|
670
|
+
if self._busy:
|
|
671
|
+
self._emit("info", "busy — /resume once the current turn finishes.")
|
|
672
|
+
return
|
|
673
|
+
if not self._fresh_agent(self._base_mode):
|
|
674
|
+
return
|
|
675
|
+
# Seed the fresh Agent (which already minted a new session_id) with the restored
|
|
676
|
+
# transcript; next save() writes a NEW file, leaving the picked one untouched.
|
|
677
|
+
self.agent.messages += [m for m in data["messages"] if m.get("role") != "system"]
|
|
678
|
+
self._resume_list = []
|
|
679
|
+
self._emit("info", f"resumed (forked): {session.describe(pick)}")
|
|
680
|
+
|
|
681
|
+
# -- input handling --------------------------------------------------
|
|
682
|
+
|
|
683
|
+
def _on_accept(self, buff):
|
|
684
|
+
text = buff.text.strip()
|
|
685
|
+
if not text:
|
|
686
|
+
return False
|
|
687
|
+
if text in ("/exit", "/quit"):
|
|
688
|
+
self._shutdown = True
|
|
689
|
+
self._wake.set()
|
|
690
|
+
self.app.exit()
|
|
691
|
+
return False
|
|
692
|
+
# While the weights load in the background the engine isn't built yet, so the
|
|
693
|
+
# commands that reset/compact/reslot the KV cache would crash. Typing a task is
|
|
694
|
+
# fine — it just queues (type-ahead). Everything else waits for the model.
|
|
695
|
+
if not self._model_ready.is_set() and (
|
|
696
|
+
text.startswith(("/reset", "/clear", "/compact", "/resume", "/accept"))):
|
|
697
|
+
self._emit("info", "still loading the model — try that once it's ready.")
|
|
698
|
+
return False
|
|
699
|
+
if text in ("/reset", "/clear"):
|
|
700
|
+
if self._fresh_agent(self.agent.mode):
|
|
701
|
+
self._emit("info", "session reset.")
|
|
702
|
+
return False
|
|
703
|
+
if text == "/accept":
|
|
704
|
+
self._accept_plan()
|
|
705
|
+
return False
|
|
706
|
+
if text == "/mode":
|
|
707
|
+
self.agent.cycle_mode()
|
|
708
|
+
return False
|
|
709
|
+
if text == "/compact":
|
|
710
|
+
# Manual context reclaim. Refuse mid-turn (mutating messages under the
|
|
711
|
+
# worker thread would corrupt the in-flight render); ask the user to wait.
|
|
712
|
+
if self._busy:
|
|
713
|
+
self._emit("info", "busy — /compact again once the current turn finishes.")
|
|
714
|
+
else:
|
|
715
|
+
b, a = self.agent.compact_now()
|
|
716
|
+
self._emit("info", f"compacted context: {b:,}→{a:,} tokens"
|
|
717
|
+
+ (" (already lean)" if a >= b else ""))
|
|
718
|
+
return False
|
|
719
|
+
if text == "/resume" or text.startswith("/resume "):
|
|
720
|
+
self._handle_resume(text[len("/resume"):].strip())
|
|
721
|
+
return False
|
|
722
|
+
if text == "/model":
|
|
723
|
+
self._emit("info", f"model {self.engine.model_id} · context "
|
|
724
|
+
f"{self.engine.effective_ctx:,} (compact at {self.ctx_limit:,}) "
|
|
725
|
+
f"· mode {self.agent.mode}")
|
|
726
|
+
return False
|
|
727
|
+
if text == "/init":
|
|
728
|
+
self._queue.append(INIT_PROMPT)
|
|
729
|
+
self._emit("user", "/init — analyzing the project to write CLAUDE.md"
|
|
730
|
+
+ (" (queued)" if self._busy else ""))
|
|
731
|
+
self._wake.set()
|
|
732
|
+
return False
|
|
733
|
+
if text == "/skills":
|
|
734
|
+
from . import skills
|
|
735
|
+
for ln in skills.summary_lines():
|
|
736
|
+
self._emit("info", " " + ln)
|
|
737
|
+
return False
|
|
738
|
+
if text == "/mcp trust":
|
|
739
|
+
from . import mcp
|
|
740
|
+
mcp.trust()
|
|
741
|
+
self._emit("info", "trusted this project — its .mcp.json servers will "
|
|
742
|
+
"connect on the next turn")
|
|
743
|
+
return False
|
|
744
|
+
if text.startswith("/mcp login"):
|
|
745
|
+
from . import mcp
|
|
746
|
+
name = text[len("/mcp login"):].strip()
|
|
747
|
+
if not name:
|
|
748
|
+
self._emit("info", "usage: /mcp login <server>")
|
|
749
|
+
return False
|
|
750
|
+
self._emit("info", mcp.login(name, emit=lambda m: self._emit("info", m)))
|
|
751
|
+
return False
|
|
752
|
+
if text == "/mcp":
|
|
753
|
+
from . import mcp
|
|
754
|
+
for ln in mcp.summary_lines():
|
|
755
|
+
self._emit("info", " " + ln)
|
|
756
|
+
return False
|
|
757
|
+
if text == "/help":
|
|
758
|
+
self._emit("info", "shift-tab: cycle mode (normal/auto/plan) · ctrl-c: "
|
|
759
|
+
"interrupt · /init /skills /mcp /mcp trust /mcp login <server> "
|
|
760
|
+
"/resume /reset /clear /compact /model /mode /accept /exit · !cmd shell · @path "
|
|
761
|
+
"attach · type while busy to queue · plan ready: type to "
|
|
762
|
+
"steer, ctrl-g to accept")
|
|
763
|
+
return False
|
|
764
|
+
# A typed message while a governor budget note is pending = continue fresh:
|
|
765
|
+
# clear context and relaunch, seeding the note + the user's steer (plan 040).
|
|
766
|
+
if self._pending_budget_note and not self._busy:
|
|
767
|
+
note = self._pending_budget_note
|
|
768
|
+
self._pending_budget_note = None
|
|
769
|
+
if not self._fresh_agent(self._base_mode):
|
|
770
|
+
return False
|
|
771
|
+
seed = f"{text}\n\n[{note}]"
|
|
772
|
+
self._queue.append(seed)
|
|
773
|
+
self._emit("info", "context cleared · continuing with the progress note")
|
|
774
|
+
self._emit("user", text)
|
|
775
|
+
self._wake.set()
|
|
776
|
+
return False
|
|
777
|
+
# A typed message while a plan is pending = steer: continue the plan-mode
|
|
778
|
+
# session so the model revises the plan file. Drop the pending banner state.
|
|
779
|
+
self._pending_plan = None
|
|
780
|
+
# enqueue the message; echo it (note when it's queued behind running work)
|
|
781
|
+
self._queue.append(text)
|
|
782
|
+
self._emit("user", text + (" (queued)" if self._busy else ""))
|
|
783
|
+
self._wake.set()
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
# -- worker thread ---------------------------------------------------
|
|
787
|
+
|
|
788
|
+
def _worker(self):
|
|
789
|
+
while not self._shutdown:
|
|
790
|
+
if not self._queue:
|
|
791
|
+
self._wake.wait(timeout=0.2)
|
|
792
|
+
self._wake.clear()
|
|
793
|
+
continue
|
|
794
|
+
# Hold queued messages until the background weight load finishes (the input
|
|
795
|
+
# accepts and queues them meanwhile, so typing ahead feels instant).
|
|
796
|
+
if not self._model_ready.is_set():
|
|
797
|
+
self._model_ready.wait(timeout=0.2)
|
|
798
|
+
continue
|
|
799
|
+
if self._load_error:
|
|
800
|
+
self._queue.clear()
|
|
801
|
+
self._emit("error", f"[cannot run — model load failed: {self._load_error}]")
|
|
802
|
+
continue
|
|
803
|
+
msg = self._queue.popleft()
|
|
804
|
+
self._busy = True
|
|
805
|
+
self._turn_start = time.monotonic() # elapsed timer for the status line
|
|
806
|
+
self._gen_tokens = 0
|
|
807
|
+
self._prefilled = 0
|
|
808
|
+
self._prefill_total = 0
|
|
809
|
+
self._think_capped = 0
|
|
810
|
+
is_shell = msg.startswith("!")
|
|
811
|
+
self._phase = "Running" if is_shell else "Thinking"
|
|
812
|
+
self._interrupt.clear()
|
|
813
|
+
try:
|
|
814
|
+
if is_shell:
|
|
815
|
+
# `!cmd` shell passthrough (Claude-Code parity): run it directly and
|
|
816
|
+
# show the output without invoking the model. Interruptible via ctrl-c;
|
|
817
|
+
# not added to the conversation (it's a side-channel convenience).
|
|
818
|
+
from .tools import tool_bash
|
|
819
|
+
cmd = msg[1:].strip()
|
|
820
|
+
if cmd:
|
|
821
|
+
self._emit("tool", f"Run {cmd}")
|
|
822
|
+
out = tool_bash(cmd, should_stop=self._interrupt.is_set)
|
|
823
|
+
render_tool_result(self._emit, "bash", {"command": cmd}, out)
|
|
824
|
+
else:
|
|
825
|
+
self.agent.run_turn(msg, stream=True)
|
|
826
|
+
self.agent.save() # persist conversation for --continue
|
|
827
|
+
# Governor hard-stop (plan 040): the turn ran out of budget with no
|
|
828
|
+
# landed+verified change. Surface the banked progress note and arm the
|
|
829
|
+
# fresh-continue handoff — the next typed message starts clean, seeded.
|
|
830
|
+
if self.agent.budget_note:
|
|
831
|
+
self._pending_budget_note = self.agent.budget_note
|
|
832
|
+
self.agent.budget_note = None
|
|
833
|
+
self._emit("info", "turn hit its budget — no verified change landed.")
|
|
834
|
+
self._emit("info", " type to continue fresh (context cleared, "
|
|
835
|
+
"seeded with what was learned) — or start a new task")
|
|
836
|
+
# A finished plan-mode turn that wrote a plan file -> offer the
|
|
837
|
+
# steer (type) / accept (ctrl-g or /accept) handoff.
|
|
838
|
+
if self.agent.mode == "plan" and self.agent.last_plan_path:
|
|
839
|
+
self._pending_plan = self.agent.last_plan_path
|
|
840
|
+
self.agent.last_plan_path = None
|
|
841
|
+
rel = os.path.relpath(self._pending_plan)
|
|
842
|
+
self._emit("info", f"plan ready → {rel}")
|
|
843
|
+
self._emit("info", " type to steer · ctrl-g (or /accept) "
|
|
844
|
+
"to accept & implement")
|
|
845
|
+
except Exception as e: # noqa: BLE001 — surface, keep the session alive
|
|
846
|
+
self._emit("error", f"[turn error: {type(e).__name__}: {e}]")
|
|
847
|
+
finally:
|
|
848
|
+
# Commit the turn's trailing (newline-less) line to scrollback and
|
|
849
|
+
# leave a blank line between turns. Without this, the final prose
|
|
850
|
+
# line stays buffered in StdoutProxy until the next newline.
|
|
851
|
+
with self._lock:
|
|
852
|
+
self._pending.append("\n")
|
|
853
|
+
self._busy = False
|
|
854
|
+
self._settle_ctx_gauge()
|
|
855
|
+
|
|
856
|
+
def _settle_ctx_gauge(self):
|
|
857
|
+
"""Refresh the end-of-turn context gauge WITHOUT re-tokenizing the transcript
|
|
858
|
+
(plan 044). The turn's last `ctx` emit already set `_cur_prompt_tokens` to the
|
|
859
|
+
final step's prompt size, and generation then appended ~`_gen_tokens` tokens; the
|
|
860
|
+
sum approximates the new context size at ~zero cost. The old
|
|
861
|
+
`len(self.agent._render())` re-ran apply_chat_template over the whole conversation
|
|
862
|
+
on the worker thread just to nudge a display gauge — pure waste on long sessions."""
|
|
863
|
+
self._cur_prompt_tokens += self._gen_tokens
|
|
864
|
+
|
|
865
|
+
def _load_model(self):
|
|
866
|
+
"""Load the weights off the UI thread, then adopt the RAM-aware compaction limit
|
|
867
|
+
and unblock the turn worker. Runs once, at startup, only when `finalize` was given."""
|
|
868
|
+
try:
|
|
869
|
+
load_s, ctx_limit = self._finalize()
|
|
870
|
+
self.ctx_limit = ctx_limit
|
|
871
|
+
self.agent.ctx_limit = ctx_limit
|
|
872
|
+
self._emit("info", f"ready in {load_s:.0f}s · context {self.engine.effective_ctx:,} "
|
|
873
|
+
f"(compact at {ctx_limit:,})")
|
|
874
|
+
except Exception as e: # noqa: BLE001 — surface load failure; don't hang the worker
|
|
875
|
+
self._load_error = f"{type(e).__name__}: {e}"
|
|
876
|
+
self._emit("error", f"[model load failed: {self._load_error}]")
|
|
877
|
+
finally:
|
|
878
|
+
self._model_ready.set()
|
|
879
|
+
self._wake.set() # nudge the worker if a message was queued while loading
|
|
880
|
+
|
|
881
|
+
def _emit_first_task_hint(self):
|
|
882
|
+
"""One muted line under the banner on a FRESH session (resume is None): a small
|
|
883
|
+
local model needs a *scoped* ask, so a blinking cursor doesn't invite a
|
|
884
|
+
frontier-sized request that flails. Resumed sessions already have a thread going,
|
|
885
|
+
so they stay hint-free."""
|
|
886
|
+
if self._resume is not None:
|
|
887
|
+
return
|
|
888
|
+
self._emit("muted",
|
|
889
|
+
'tip: small model, scoped asks — "fix the failing test in '
|
|
890
|
+
'tests/test_x.py" lands; "improve my codebase" flails. '
|
|
891
|
+
"shift-tab cycles plan mode.")
|
|
892
|
+
|
|
893
|
+
async def run(self):
|
|
894
|
+
worker = threading.Thread(target=self._worker, daemon=True)
|
|
895
|
+
worker.start()
|
|
896
|
+
refresher = asyncio.create_task(self._refresher())
|
|
897
|
+
art = banner(self.engine.model_id.split("/")[-1], self.ctx_window,
|
|
898
|
+
mode=self.agent.mode)
|
|
899
|
+
with self._lock:
|
|
900
|
+
self._pending.append("\n" + art + "\n")
|
|
901
|
+
self._emit("info", "shift-tab for modes · /help")
|
|
902
|
+
self._emit_first_task_hint()
|
|
903
|
+
if self._finalize is not None:
|
|
904
|
+
self._emit("info", f"loading {self.engine.model_id.split('/')[-1]}… "
|
|
905
|
+
"(type ahead — your first message runs when it's ready)")
|
|
906
|
+
threading.Thread(target=self._load_model, daemon=True).start()
|
|
907
|
+
try:
|
|
908
|
+
# raw=True passes our ANSI through untouched; patch_stdout keeps the
|
|
909
|
+
# input/status region pinned below while output scrolls above it.
|
|
910
|
+
with patch_stdout(raw=True):
|
|
911
|
+
await self.app.run_async()
|
|
912
|
+
finally:
|
|
913
|
+
self._shutdown = True
|
|
914
|
+
self._wake.set()
|
|
915
|
+
refresher.cancel()
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def run_tui(engine: BaseEngine, ctx_limit: int, mode: str = "normal", thinking: bool = True,
|
|
919
|
+
resume: list = None, ctx_window: int = None, finalize=None):
|
|
920
|
+
asyncio.run(TUI(engine, ctx_limit, mode=mode, thinking=thinking, resume=resume,
|
|
921
|
+
ctx_window=ctx_window, finalize=finalize).run())
|