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/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())