sliceagent 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.
Files changed (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/tui.py ADDED
@@ -0,0 +1,1377 @@
1
+ """Optional rich terminal UI (the `tui` extra: rich + prompt_toolkit).
2
+
3
+ Periphery — NOT the moat. The loop already decouples rendering via the event
4
+ dispatcher; this is just (a) a rich rendering SINK over those events and (b) a prompt_toolkit
5
+ input layer. loop.py / pfc.py / seed.py are never touched. The whole module is import-guarded behind the
6
+ `tui` extra: core/headless/eval never import rich or prompt_toolkit.
7
+
8
+ Design (a rich + prompt_toolkit terminal UI):
9
+ - SCROLLBACK model: Rich prints finalized output to history; prompt_toolkit owns the input line.
10
+ They are TEMPORALLY separate (output during the synchronous run_turn, input between turns), so
11
+ there is no patch_stdout/threading minefield.
12
+ - tool-call CARDS (spinner -> ✓/✗, primary-arg header, inline diff for edits),
13
+ - a two-line STATUS footer (model · policy · workspace · tokens),
14
+ - a SLASH-command palette wired to existing session ops (/new /switch /resume /threads /help /exit),
15
+ - graceful ctrl-c AND esc: a physical Ctrl-C (SIGINT) aborts a running turn via Python's own
16
+ KeyboardInterrupt delivery; Esc does the SAME thing via `_EscSentinel`, a narrow background thread that
17
+ translates a bare Esc keypress into a real SIGINT (loop.py only checks a `signal=` Event at STEP
18
+ BOUNDARIES, never inside a blocking LLM/tool call, so only a real SIGINT interrupts promptly).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import threading
24
+ import time
25
+
26
+ from rich import box as _box
27
+ from rich.console import Console, Group
28
+ from rich.markdown import Markdown
29
+ from rich.padding import Padding
30
+ from rich.panel import Panel
31
+ from rich.text import Text
32
+ from rich.theme import Theme
33
+
34
+ import shutil
35
+
36
+ from prompt_toolkit import PromptSession
37
+ from prompt_toolkit.completion import Completer, Completion
38
+ from prompt_toolkit.formatted_text import FormattedText
39
+ from prompt_toolkit.history import FileHistory
40
+
41
+ from .events import (AssistantText, ApiRetry, Event, LessonSaved, SliceBuilt, StepBegin, StepEnd,
42
+ ToolResult, ToolStarted, TurnEnd, TurnInterrupted)
43
+
44
+ # ── theme (semantic tokens; one place to retheme) ───────────────────────────────────────────
45
+ TH = {
46
+ "accent": "bright_cyan", "ok": "green", "fail": "red", "warn": "yellow",
47
+ "dim": "grey50", "tool": "magenta", "add": "green", "del": "red", "user": "bright_cyan",
48
+ }
49
+
50
+ # Rich's DEFAULT markdown styles are "bold cyan on black" / "cyan on black" — so any file path or inline
51
+ # `code` the model writes in backticks renders with a heavy BLACK-BACKGROUND highlight. Drop the bg
52
+ # (foreground-only) so paths read cleanly in a normal terminal. inherit=True keeps every other default.
53
+ MD_THEME = Theme({"markdown.code": "cyan", "markdown.code_block": "cyan"}, inherit=True)
54
+
55
+
56
+ def make_console() -> Console:
57
+ """A Rich Console themed so inline `code` / file paths aren't highlighted on a black background."""
58
+ return Console(theme=MD_THEME)
59
+
60
+ # per-tool emoji + a short verb + which arg is the "primary" one to show in the card header
61
+ _TOOL = {
62
+ "read_file": ("📖", "read", "path"),
63
+ "edit_file": ("✏️ ", "write", "path"),
64
+ "append_to_file": ("➕", "append", "path"),
65
+ "str_replace": ("✏️ ", "edit", "path"),
66
+ "list_files": ("📂", "list", "path"),
67
+ "run_command": ("⚡", "run", "command"),
68
+ "execute_code": ("🐍", "exec", "code"),
69
+ "grep": ("🔍", "grep", "pattern"),
70
+ "glob": ("🔍", "glob", "pattern"),
71
+ "skill": ("📚", "skill", "name"),
72
+ "recall_history": ("🕮 ", "recall", "index"),
73
+ "new_topic": ("🟢", "topic", "goal"),
74
+ "switch_topic": ("🔀", "switch", "task_id"),
75
+ "spawn_subagent": ("🤖", "agent", "task"),
76
+ "spawn_explore": ("🔭", "explore", "task"),
77
+ }
78
+
79
+
80
+ # read-only / navigation tools — a long run of these (a review reads + greps a dozen files) is just
81
+ # noise as one card each, so the sink COALESCES a consecutive run into ONE compact line. recall_history
82
+ # is deliberately NOT here: it's the memory channel and stays its own visible card.
83
+ _COALESCE = {"read_file", "list_files", "grep", "glob"}
84
+ _READ_VERB = {"read_file": "read", "list_files": "list", "grep": "grep", "glob": "glob"}
85
+
86
+
87
+ def _shorten(s: str, n: int = 64) -> str:
88
+ s = " ".join((s or "").split())
89
+ return s if len(s) <= n else s[: n - 1] + "…"
90
+
91
+
92
+ def _primary(name: str, args: dict) -> str:
93
+ key = _TOOL.get(name, (None, None, None))[2]
94
+ val = args.get(key) if (key and isinstance(args, dict)) else None
95
+ if val is None and isinstance(args, dict): # fallback: first non-note string arg
96
+ val = next((v for k, v in args.items() if k != "note" and isinstance(v, str)), "")
97
+ return _shorten(str(val or ""))
98
+
99
+
100
+ def _tool_header(name: str, args: dict) -> str:
101
+ emoji, verb, _ = _TOOL.get(name, ("•", name, None))
102
+ p = _primary(name, args)
103
+ return f"{emoji} {verb} {p}".rstrip()
104
+
105
+
106
+ def _diff(name: str, args: dict):
107
+ """A compact inline diff for a str_replace (old → new). Returns a Rich renderable or None."""
108
+ if name != "str_replace" or not isinstance(args, dict):
109
+ return None
110
+ old, new = str(args.get("old_string") or ""), str(args.get("new_string") or "") # tolerate non-str model args
111
+ if not old and not new:
112
+ return None
113
+ lines = []
114
+ for ln in old.splitlines()[:12]:
115
+ lines.append(Text(f"- {ln}", style=TH["del"]))
116
+ for ln in new.splitlines()[:12]:
117
+ lines.append(Text(f"+ {ln}", style=TH["add"]))
118
+ extra = max(0, len(old.splitlines()) - 12) + max(0, len(new.splitlines()) - 12)
119
+ if extra:
120
+ lines.append(Text(f"… {extra} more diff lines", style=TH["dim"]))
121
+ return Group(*lines) if lines else None
122
+
123
+
124
+ _PLAN_GLYPH = {"done": ("✓", "ok"), "in_progress": ("▶", "accent"), "pending": ("○", "dim")}
125
+
126
+
127
+ def _render_plan(steps: list):
128
+ """A live PLAN/TODO checklist panel: '✓ done', '▶ in-progress', '○ pending'.
129
+ Surfaces the model's update_plan tier as first-class UI instead of a generic tool card."""
130
+ lines = []
131
+ for it in steps:
132
+ if not isinstance(it, dict):
133
+ continue
134
+ status = it.get("status", "pending")
135
+ glyph, gstyle = _PLAN_GLYPH.get(status, ("○", "dim"))
136
+ text_style = TH["dim"] if status == "done" else "default"
137
+ lines.append(Text.assemble(Text(f"{glyph} ", style=TH.get(gstyle, gstyle)),
138
+ Text(_shorten(str(it.get("step", "")), 80), style=text_style)))
139
+ done = sum(1 for it in steps if isinstance(it, dict) and it.get("status") == "done")
140
+ title = Text(f"plan · {done}/{len(steps)} done", style=TH["accent"])
141
+ return Panel(Group(*lines) if lines else Text("(empty plan)", style=TH["dim"]),
142
+ title=title, border_style=TH["dim"], expand=False)
143
+
144
+
145
+ # ── the rendering sink (consumes the loop's events) ──────────────────────────────────────────
146
+ def _box_width(console: Console) -> int:
147
+ """Bound the response box so long replies read as a column, not edge-to-edge."""
148
+ try:
149
+ w = int(console.width)
150
+ except Exception:
151
+ w = 100
152
+ return max(48, min(w - 2, 100))
153
+
154
+
155
+ def _response_panel(content: str, console: Console) -> Panel:
156
+ """The assistant reply as Rich Markdown in a clean HORIZONTALS box: light
157
+ top/bottom rules, a left-aligned label, generous padding, bounded width — vs bare full-width Markdown."""
158
+ return Panel(
159
+ Markdown(content),
160
+ title=f"[bold {TH['accent']}]assistant[/]",
161
+ title_align="left",
162
+ border_style=TH["accent"],
163
+ box=_box.HORIZONTALS,
164
+ padding=(1, 2),
165
+ width=_box_width(console),
166
+ )
167
+
168
+
169
+ def _render_tool_result(e):
170
+ """The renderable for a ToolResult — SHARED by RichSink (REPL) and LiveSink (live box) so they can't
171
+ drift. The model-curated tiers render as first-class UI (a live PLAN checklist, the MISSION line);
172
+ everything else is a dim '┊'-gutter card: mark · header · optional inline diff · bounded output (shown
173
+ only for action tools / failures — read/list say it all in the header)."""
174
+ if e.name == "update_plan" and not e.failing:
175
+ return _render_plan(e.args.get("steps") or [])
176
+ if e.name == "set_mission" and not e.failing:
177
+ return Text.assemble(Text(" 🎯 mission: ", style=TH["accent"]),
178
+ Text(_shorten(str(e.args.get("text", "")), 80), style="bold"))
179
+ mark = Text("✓", style=TH["ok"]) if not e.failing else Text("✗", style=TH["fail"])
180
+ head = Text.assemble(Text("┊ ", style=TH["dim"]), mark, " ",
181
+ Text(_tool_header(e.name, e.args), style=TH["tool"]))
182
+ body = [head]
183
+ d = _diff(e.name, e.args)
184
+ if d is not None:
185
+ body.append(Padding(d, (0, 0, 0, 2))) # indent the diff under the gutter
186
+ if e.failing or e.name not in ("read_file", "list_files"):
187
+ out = _shorten(e.output, 200)
188
+ if out:
189
+ body.append(Text(f" ┊ {out}", style=TH["fail"] if e.failing else TH["dim"]))
190
+ return Group(*body)
191
+
192
+
193
+ # The RichSink whose spinner / streaming Live currently owns the terminal. A mid-turn console.input()
194
+ # (ask_user, confirm) while a Live is active does NOT echo the user's keystrokes — the Live redraws over
195
+ # the input line, so the typed answer is invisible. `_pause_active_live()` stops it first; the next event
196
+ # restarts a fresh region. Single point so EVERY mid-turn Rich prompt is covered (no per-call-site fix).
197
+ _ACTIVE_RICH_SINK = None
198
+ # The _EscSentinel (if any) watching for Esc during the current RICH-mode turn — SAME single-choke-point
199
+ # idiom as _ACTIVE_RICH_SINK above. Must release the tty raw-mode fd before a confirm()/ask_user() prompt
200
+ # does its OWN raw-mode read (_arrow_select), or the two would race for ownership of the same fd.
201
+ _ACTIVE_ESC_SENTINEL = None
202
+
203
+
204
+ def _pause_active_live() -> None:
205
+ """Stop the turn spinner AND release the Esc-sentinel's hold on the tty — the ONE choke point every
206
+ mid-turn synchronous read (confirm, ask_user) goes through before touching raw mode itself."""
207
+ s = _ACTIVE_RICH_SINK
208
+ if s is not None:
209
+ try:
210
+ s._stop()
211
+ except Exception: # noqa: BLE001 — pausing the live UI must never break the prompt
212
+ pass
213
+ sentinel = _ACTIVE_ESC_SENTINEL
214
+ if sentinel is not None:
215
+ try:
216
+ sentinel.pause()
217
+ except Exception: # noqa: BLE001 — pausing the sentinel must never break the prompt
218
+ pass
219
+
220
+
221
+ def _resume_active_esc_sentinel() -> None:
222
+ """Re-arm the Esc-sentinel after a mid-turn confirm()/ask_user() read finishes — the counterpart to
223
+ the pause() in _pause_active_live(), called once the caller no longer needs raw-mode ownership."""
224
+ sentinel = _ACTIVE_ESC_SENTINEL
225
+ if sentinel is not None:
226
+ try:
227
+ sentinel.resume()
228
+ except Exception: # noqa: BLE001
229
+ pass
230
+
231
+
232
+ class _EscSentinel:
233
+ """Translates a bare Esc keypress into a real SIGINT during a RICH-mode (non-live) turn, so Esc aborts
234
+ a running turn exactly like Ctrl-C already does. loop.py's `signal=` Event is checked ONLY at STEP
235
+ BOUNDARIES (never inside a blocking llm.complete() or a slow run_command) — an Event-only abort would
236
+ silently NOT interrupt a turn stuck on a slow model call or a hung command. A real SIGINT does: Python
237
+ always delivers it to the MAIN thread regardless of which thread calls os.kill, reaching the exact
238
+ `except KeyboardInterrupt` handlers a physical Ctrl-C already hits — zero new abort logic in loop.py.
239
+
240
+ Also RE-IMPLEMENTS physical Ctrl-C detection while active: putting the tty in raw mode (tty.setraw)
241
+ disables ISIG, which is what makes the tty driver auto-generate SIGINT on Ctrl-C in normal (cooked)
242
+ mode — so without this, a real Ctrl-C press would go SILENT for the whole time this sentinel holds
243
+ raw mode. Both \\x1b (Esc) and \\x03 (Ctrl-C/INTR) are handled identically here for that reason.
244
+
245
+ Owns the tty raw-mode fd only while ACTIVE; releases it (restores termios) before going idle on
246
+ pause(), so it never races a mid-turn confirm()/ask_user() call for the SAME fd (_arrow_select's own
247
+ comment: raw mode is process-global, main-thread-only — only one owner at a time). Runs entirely on a
248
+ second daemon thread; the turn itself NEVER leaves the main thread, so confirm()'s arrow-key selector
249
+ (Yes/No/Always) is completely unaffected — its own main-thread-only guard is never even exercised.
250
+
251
+ Lifetime: created + started immediately before ONE run_turn() call (RICH mode only, never live mode —
252
+ prompt_toolkit already owns all keystrokes there natively), stopped in a `finally` right after — never
253
+ persists between turns, never leaks a thread."""
254
+
255
+ def __init__(self):
256
+ self._thread = None
257
+ self._stop_flag = threading.Event()
258
+ self._pause_flag = threading.Event()
259
+ self._paused_ack = threading.Event()
260
+ self._fd = None
261
+ self._raw = False # True iff THIS sentinel currently holds the fd in raw mode
262
+ self._old_termios = None
263
+
264
+ def start(self) -> None:
265
+ """No-op (never spawns a thread) unless this is a real POSIX tty on the MAIN thread — the SAME
266
+ safety gate _arrow_select uses, so a non-tty/headless/eval run is byte-for-byte unaffected."""
267
+ import sys
268
+ global _ACTIVE_ESC_SENTINEL
269
+ if threading.current_thread() is not threading.main_thread():
270
+ return
271
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
272
+ return
273
+ try:
274
+ import termios
275
+ self._fd = sys.stdin.fileno()
276
+ termios.tcgetattr(self._fd) # confirms this really is a controllable tty
277
+ except Exception: # noqa: BLE001 — not a real terminal / no termios (e.g. Windows) → stay inert
278
+ return
279
+ _ACTIVE_ESC_SENTINEL = self
280
+ self._thread = threading.Thread(target=self._run, daemon=True)
281
+ self._thread.start()
282
+
283
+ def _enter_raw(self) -> None:
284
+ import termios
285
+ import tty
286
+ try:
287
+ self._old_termios = termios.tcgetattr(self._fd)
288
+ tty.setraw(self._fd)
289
+ # setraw() also clears OPOST (output post-processing), so while this sentinel holds the tty for
290
+ # the whole turn, the reply's '\n' is no longer mapped to '\r\n' — the output staircases to the
291
+ # right and the spinner cascades. Re-enable cooked OUTPUT (OPOST|ONLCR); INPUT stays raw so Esc
292
+ # and Ctrl-C still arrive as bytes (ISIG/ICANON off). This is the fix for the mid-turn garble.
293
+ a = termios.tcgetattr(self._fd)
294
+ a[1] |= (termios.OPOST | termios.ONLCR) # oflag
295
+ termios.tcsetattr(self._fd, termios.TCSANOW, a)
296
+ termios.tcflush(self._fd, termios.TCIFLUSH) # drain type-ahead so a stray byte can't false-fire
297
+ self._raw = True
298
+ except Exception: # noqa: BLE001 — a wedged/vanished tty must never crash the turn
299
+ self._raw = False
300
+
301
+ def _exit_raw(self) -> None:
302
+ if not self._raw:
303
+ return
304
+ import termios
305
+ try:
306
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_termios)
307
+ except Exception: # noqa: BLE001
308
+ try:
309
+ termios.tcsetattr(self._fd, termios.TCSANOW, self._old_termios)
310
+ except Exception: # noqa: BLE001
311
+ pass
312
+ self._raw = False
313
+
314
+ def _run(self) -> None:
315
+ import select
316
+ import signal as _signal
317
+ self._enter_raw()
318
+ try:
319
+ while not self._stop_flag.is_set():
320
+ if self._pause_flag.is_set():
321
+ self._exit_raw()
322
+ self._paused_ack.set()
323
+ # sleep in short increments (not a blocking read) so resume()/stop() are noticed
324
+ # promptly WITHOUT holding raw mode while idle.
325
+ while self._pause_flag.is_set() and not self._stop_flag.is_set():
326
+ self._stop_flag.wait(0.05)
327
+ if self._stop_flag.is_set():
328
+ return
329
+ self._enter_raw()
330
+ continue
331
+ if not self._raw: # a previous _enter_raw failed (tty went away) → stop, don't spin
332
+ return
333
+ try:
334
+ ready, _, _ = select.select([self._fd], [], [], 0.15)
335
+ except Exception: # noqa: BLE001 — fd gone / select unsupported → stop, never crash the turn
336
+ return
337
+ if not ready:
338
+ continue
339
+ try:
340
+ data = os.read(self._fd, 16)
341
+ except Exception: # noqa: BLE001
342
+ return
343
+ if data in (b"\x1b", b"\x03"): # bare Esc, or Ctrl-C (\x03/INTR). raw mode (tty.setraw)
344
+ # disables ISIG, so the tty driver's OWN auto-SIGINT-on-Ctrl-C is OFF while this
345
+ # sentinel holds raw mode (exactly like _arrow_select already has to handle \x03
346
+ # itself for the same reason, just for a much shorter window) — WITHOUT this, a
347
+ # physical Ctrl-C would go silent for the whole turn instead of aborting it. An
348
+ # arrow/CSI sequence also starts with 0x1b but is LONGER (e.g. \x1b[C); a lone
349
+ # single-byte read of exactly \x1b means nothing followed.
350
+ try:
351
+ os.kill(os.getpid(), _signal.SIGINT)
352
+ except Exception: # noqa: BLE001
353
+ pass
354
+ return # one-shot per turn, same as a physical Ctrl-C
355
+ finally:
356
+ self._exit_raw()
357
+
358
+ def pause(self) -> None:
359
+ """Release the fd BEFORE returning, so the caller's own raw-mode read (confirm/_arrow_select) can
360
+ never race this thread for ownership. Blocks briefly (bounded by the poll granularity) on an ack
361
+ so the caller only proceeds once the fd is provably free."""
362
+ if self._thread is None or not self._thread.is_alive():
363
+ return
364
+ self._paused_ack.clear()
365
+ self._pause_flag.set()
366
+ self._paused_ack.wait(timeout=1.0) # generous bound; the sentinel acks within ~1 poll tick (~50ms)
367
+
368
+ def resume(self) -> None:
369
+ if self._thread is None or not self._thread.is_alive():
370
+ return
371
+ self._pause_flag.clear()
372
+
373
+ def stop(self) -> None:
374
+ self._stop_flag.set()
375
+ if self._thread is not None:
376
+ self._thread.join(timeout=0.3)
377
+ global _ACTIVE_ESC_SENTINEL
378
+ if _ACTIVE_ESC_SENTINEL is self:
379
+ _ACTIVE_ESC_SENTINEL = None
380
+
381
+
382
+ def make_esc_sentinel() -> "_EscSentinel":
383
+ return _EscSentinel()
384
+
385
+
386
+ # running turn tally — bucket each completed tool by KIND so the status line shows "how far along" at a glance
387
+ _VERB_BUCKET = {"read_file": "read", "list_files": "read", "grep": "read", "glob": "read",
388
+ "edit_file": "edit", "str_replace": "edit", "append_to_file": "edit",
389
+ "run_command": "cmd", "execute_code": "cmd"}
390
+
391
+
392
+ def _fmt_tally(tally: dict) -> str:
393
+ """Compact 'N read · N edit · N cmd · N fail' — only non-zero buckets, in a stable order."""
394
+ return " · ".join(f"{tally[k]} {k}" for k in ("read", "edit", "cmd", "fail") if tally.get(k))
395
+
396
+
397
+ class _LiveStatus:
398
+ """A Rich RichCast whose __rich__ RECOMPUTES the elapsed clocks every frame off the Status Live loop —
399
+ so the timer ticks with NO extra thread (console.status() already redraws ~12×/s to animate the dots).
400
+
401
+ HARD RULE: the object handed to console.status() must be THIS instance, never a Text/str — a static
402
+ body freezes the timer. __rich__ emits ONLY Text.assemble segments (never an f-string into markup), so a
403
+ bracketed path/command in the action label can't trigger a MarkupError. It reads its fields off the sink
404
+ under the sink's lock, so a parallel subagent write can't tear a frame; any error degrades to a bare Text
405
+ (a progress indicator must never crash the run)."""
406
+
407
+ def __init__(self, sink: "RichSink"):
408
+ self._sink = sink
409
+
410
+ def __rich__(self) -> Text:
411
+ s = self._sink
412
+ try:
413
+ with s._lock:
414
+ label, step = (s._subagent or s._label or "working…"), s._step
415
+ a0, t0, tally = s._action_t0, s._turn_t0, dict(s._tally)
416
+ now = time.monotonic()
417
+ a, turn = max(0.0, now - (a0 or now)), max(0.0, now - (t0 or now))
418
+ parts = []
419
+ if step:
420
+ parts.append((f"step {step} · ", TH["dim"]))
421
+ parts.append((str(label), TH["tool"])) # str() + Text.assemble → never parsed as markup
422
+ parts.append((f" · {a:.0f}s", TH["dim"]))
423
+ tally_str = _fmt_tally(tally)
424
+ parts.append((f" · {tally_str + ' · ' if tally_str else ''}{turn:.0f}s", TH["dim"]))
425
+ return Text.assemble(*parts)
426
+ except Exception: # noqa: BLE001 — a progress indicator must never break the run
427
+ return Text("working…", style=TH["dim"])
428
+
429
+
430
+ class RichSink:
431
+ """An event sink that renders the live turn with Rich. Drop-in for cli_sink."""
432
+
433
+ def __init__(self, console: Console, stats: dict):
434
+ global _ACTIVE_RICH_SINK
435
+ _ACTIVE_RICH_SINK = self # so ask_user/confirm can pause the live region before reading input
436
+ self.c = console
437
+ self.stats = stats
438
+ self._lock = threading.RLock() # parallel explorer threads call subagent_notify concurrently; serialize
439
+ # all _status transitions (rich Status is not thread-safe)
440
+ self._status = None
441
+ # AGENT_SPINNER=off disables the animated in-place status spinner (a Rich live region), keeping every
442
+ # other Rich element (reply panel, markdown, tool cards). Default ON everywhere. (The mid-turn garble
443
+ # once blamed on the spinner + Terminal.app was actually the Esc-sentinel clearing OPOST — fixed in
444
+ # _EscSentinel._enter_raw — so the spinner is safe again; this stays as a plain user preference.)
445
+ self._spinner_on = os.environ.get("AGENT_SPINNER", "on").strip().lower() not in ("off", "0", "false", "no")
446
+ self._reads: list = [] # buffered consecutive read-only tool cards (coalesced on the next event)
447
+ # LIVE STATUS fields (read each frame by _LiveStatus.__rich__ under _lock): the current action label,
448
+ # step number, per-action + whole-turn start clocks, running verb tally, and any active subagent line.
449
+ self._body = None # the _LiveStatus handed to console.status() (None ⇒ no live region)
450
+ self._label = "thinking…"
451
+ self._subagent = None
452
+ self._step = 0
453
+ self._action_t0 = None # monotonic start of the CURRENT action (resets each step/tool)
454
+ self._turn_t0 = None # monotonic start of the WHOLE turn (armed on SliceBuilt, survives step churn)
455
+ self._tally: dict = {} # bucket -> count (read/edit/cmd/fail) for the "how far along" summary
456
+
457
+ def _stop(self) -> None:
458
+ """Tear down the live status region, if any, and reset the label to idle. The transient Status already
459
+ erases the visible line on stop; resetting _label/_subagent keeps the sink's resting state honest so a
460
+ stale 'writing…' can never render via a later path that reuses the region without going through _spin."""
461
+ with self._lock: # serialize vs parallel subagent_notify on _status
462
+ if self._status is not None:
463
+ self._status.stop()
464
+ self._status = None
465
+ self._body = None
466
+ self._label = "thinking…"
467
+ self._subagent = None
468
+
469
+ def _spin(self, label: str) -> None:
470
+ """Set the CURRENT action and ensure the ticking status region is live. MUTATES in place when the
471
+ region already exists (no tear-down → no flicker, and the turn clock keeps ticking); only creates the
472
+ Status the first time. On a non-tty, console.status() no-ops its animation (no ANSI, body never
473
+ refreshed) — same degraded behaviour as before, so on_delta's 'a step is active' gate still holds."""
474
+ with self._lock: # serialize vs parallel subagent_notify on _status
475
+ self._label = label
476
+ self._subagent = None
477
+ self._action_t0 = time.monotonic()
478
+ if self._turn_t0 is None:
479
+ self._turn_t0 = self._action_t0
480
+ if self._spinner_on and self._status is None: # create once; MUTATE the same region every frame
481
+ self._body = _LiveStatus(self)
482
+ self._status = self.c.status(self._body, spinner="dots")
483
+ self._status.start()
484
+
485
+ def subagent_notify(self, text: str) -> None:
486
+ """A child agent's CURRENT activity → the status line's action segment (overwrites in place), so a
487
+ subagent doing 80 reads shows a single updating line, not 80. Called from PARALLEL explorer worker
488
+ threads → guarded by self._lock; each now writes ONE field instead of the non-thread-safe Status.update."""
489
+ try:
490
+ with self._lock:
491
+ self._subagent = text
492
+ if self._action_t0 is None:
493
+ self._action_t0 = time.monotonic()
494
+ if self._spinner_on and self._status is None:
495
+ self._body = _LiveStatus(self)
496
+ self._status = self.c.status(self._body, spinner="dots")
497
+ self._status.start()
498
+ except Exception: # noqa: BLE001 — a progress indicator must never break the run
499
+ pass
500
+
501
+ def _bump_tally(self, name: str, failing: bool) -> None:
502
+ with self._lock:
503
+ if failing:
504
+ self._tally["fail"] = self._tally.get("fail", 0) + 1
505
+ bucket = _VERB_BUCKET.get(name)
506
+ if bucket:
507
+ self._tally[bucket] = self._tally.get(bucket, 0) + 1
508
+
509
+ def on_delta(self, kind: str, text: str) -> None:
510
+ """Live token sink wired to OpenAILLM.set_delta_sink. While a reply streams, the live region just
511
+ flips its label to a calm, FIXED single-line "writing…" (same shape as the "thinking…" spinner, with
512
+ the running clock) — it does NOT render a live preview of the reply text. The full, formatted reply
513
+ prints once when streaming ends, via AssistantText → _response_panel.
514
+
515
+ Why no live preview: three separate live reports of stacked "assistant streaming…" panels traced to
516
+ one root cause shared by every variant that showed the GROWING reply (a Markdown rich.live.Live
517
+ panel, then a plain-text bounded panel, then a bounded text tail inside this status line). Any region
518
+ that grows/wraps eventually reaches the bottom of the terminal and forces a scroll, and ANSI
519
+ cursor-up/erase codes cannot un-scroll content already committed to scrollback — so stale frames pile
520
+ up. A fixed one-line indicator has no height to grow and nothing to scroll, which removes the whole
521
+ bug class regardless of terminal size or emulator. No-op until a step is active (nothing streams
522
+ during routing)."""
523
+ if kind != "content" or not text or self._status is None:
524
+ return
525
+ with self._lock:
526
+ self._label = "writing…" # thinking → writing: the status line reflects the phase (+ its clock)
527
+
528
+ def _flush_reads(self) -> None:
529
+ """Emit ONE compact dim line for a buffered run of read-only tools (📖 7 read · 🔍 3 grep · names),
530
+ instead of one card each — so a review that reads a dozen files doesn't bury the window."""
531
+ if not self._reads:
532
+ return
533
+ reads, self._reads = self._reads, []
534
+ from collections import Counter
535
+ cnt = Counter(n for n, _ in reads)
536
+ parts = [f"{_TOOL.get(n, ('•',))[0]} {c} {_READ_VERB.get(n, n)}" for n, c in cnt.items()]
537
+ names = [v for _, v in reads if v]
538
+ tail = ""
539
+ if names:
540
+ tail = " " + ", ".join(_shorten(x, 30) for x in names[:5]) + (f" +{len(names) - 5}" if len(names) > 5 else "")
541
+ self.c.print(Text(f"┊ {' · '.join(parts)}{tail}", style=TH["dim"]))
542
+
543
+ def __call__(self, e: Event) -> None:
544
+ if isinstance(e, SliceBuilt):
545
+ self._turn_t0 = time.monotonic() # arm the whole-turn clock (once per turn)
546
+ self._tally = {}
547
+ self._step = 0
548
+ self._spin("thinking…")
549
+ if not self._spinner_on: # spinner off → one plain line so it's not silent
550
+ self.c.print(Text(" · thinking…", style=TH["dim"]))
551
+ elif isinstance(e, StepBegin):
552
+ self._step = e.step # the step counter the status line shows
553
+ self._spin("thinking…") # new step → reset the action clock, keep the turn clock
554
+ elif isinstance(e, ToolStarted):
555
+ self._spin(_tool_header(e.name, e.args)) # the ticking action segment (spinner conveys "…")
556
+ elif isinstance(e, ToolResult):
557
+ self._bump_tally(e.name, e.failing) # grow the running "how far along" summary
558
+ if e.name in _COALESCE and not e.failing: # buffer a read-only run → one line on next event; the
559
+ self._reads.append((e.name, _primary(e.name, e.args))) # status stays LIVE so the timer keeps
560
+ return # ticking through a 12-file read run (no dead air)
561
+ self._flush_reads() # a mutating/failing tool ends the read run
562
+ self.c.print(_render_tool_result(e)) # prints ABOVE the live status region
563
+ elif isinstance(e, AssistantText):
564
+ self._stop()
565
+ self._flush_reads()
566
+ if (e.content or "").strip():
567
+ self.c.print(_response_panel(e.content, self.c))
568
+ elif isinstance(e, ApiRetry):
569
+ self._stop()
570
+ self._flush_reads()
571
+ self.c.print(Text(f" …retry #{e.attempt} ({_shorten(e.error, 60)})", style=TH["warn"]))
572
+ elif isinstance(e, StepEnd):
573
+ u = e.usage or {}
574
+ self.stats["tokens"] = self.stats.get("tokens", 0) + u.get("prompt_tokens", 0) + u.get("completion_tokens", 0)
575
+ # FRESH (non-cache-read) input — the moat metric (typed usage from the llm adapter). Shown in
576
+ # the toolbar so the user sees the bounded-slice cost stay flat, not the gross token count.
577
+ self.stats["fresh"] = self.stats.get("fresh", 0) + (u.get("input_other", 0) or 0)
578
+ _accrue_cost(self.stats, u)
579
+ elif isinstance(e, LessonSaved):
580
+ self._flush_reads()
581
+ self.c.print(Text(f" 💡 learned: {_shorten(e.title, 70)}", style=TH["dim"]))
582
+ elif isinstance(e, TurnInterrupted):
583
+ self._stop()
584
+ self._turn_t0 = None # disarm the turn clock (next turn re-arms on SliceBuilt)
585
+ self._flush_reads()
586
+ self.c.print(Text(f" ⚠ interrupted: {e.message or e.reason}", style=TH["warn"]))
587
+ elif isinstance(e, TurnEnd):
588
+ self._stop()
589
+ self._turn_t0 = None
590
+ self._flush_reads()
591
+ tok = (e.usage or {}).get("prompt_tokens", 0) + (e.usage or {}).get("completion_tokens", 0)
592
+ self.c.print(Text(f" ✓ done · {e.steps} steps · {tok} tokens", style=TH["dim"]))
593
+
594
+
595
+ def make_rich_sink(console: Console, stats: dict) -> RichSink:
596
+ return RichSink(console, stats)
597
+
598
+
599
+ class LiveSink:
600
+ """Event sink for the LIVE composer (AGENT_TUI=live). Static output — tool cards, the reply panel —
601
+ prints ABOVE the pinned box via the Rich console (routed to scrollback by patch_stdout, verified to
602
+ compose: Rich reads sys.stdout dynamically). The transient spinner + streamed tail live in the app's
603
+ STATUS line via a callback, so no Rich Live fights the running prompt_toolkit Application for the screen."""
604
+
605
+ def __init__(self, console: Console, stats: dict, set_status):
606
+ self.c = console
607
+ self.stats = stats
608
+ self._set_status = set_status # callable(str|None) → update the app status line (thread-safe)
609
+ self._stream = ""
610
+
611
+ def on_delta(self, kind: str, text: str) -> None:
612
+ if kind != "content" or not text:
613
+ return
614
+ self._stream += text
615
+ self._set_status("writing… " + " ".join(self._stream.split())[-80:])
616
+
617
+ def __call__(self, e: Event) -> None:
618
+ if isinstance(e, SliceBuilt):
619
+ self._stream = ""; self._set_status("thinking…")
620
+ elif isinstance(e, ToolStarted):
621
+ self._stream = ""; self._set_status(f"{_tool_header(e.name, e.args)} …")
622
+ elif isinstance(e, ToolResult):
623
+ self.c.print(_render_tool_result(e)) # static card ABOVE the pinned box
624
+ self._set_status("working…")
625
+ elif isinstance(e, AssistantText):
626
+ self._set_status(None) # reply complete → clear "writing…" BEFORE the panel, not at TurnEnd
627
+ self._stream = "" # (else the final answer prints above a stale streaming spinner)
628
+ if (e.content or "").strip():
629
+ self.c.print(_response_panel(e.content, self.c))
630
+ elif isinstance(e, ApiRetry):
631
+ self.c.print(Text(f" …retry #{e.attempt} ({_shorten(e.error, 60)})", style=TH["warn"]))
632
+ elif isinstance(e, StepEnd):
633
+ u = e.usage or {}
634
+ self.stats["tokens"] = self.stats.get("tokens", 0) + u.get("prompt_tokens", 0) + u.get("completion_tokens", 0)
635
+ self.stats["fresh"] = self.stats.get("fresh", 0) + (u.get("input_other", 0) or 0)
636
+ _accrue_cost(self.stats, u)
637
+ elif isinstance(e, LessonSaved):
638
+ self.c.print(Text(f" 💡 learned: {_shorten(e.title, 70)}", style=TH["dim"]))
639
+ elif isinstance(e, TurnInterrupted):
640
+ self.c.print(Text(f" ⚠ interrupted: {e.message or e.reason}", style=TH["warn"]))
641
+ elif isinstance(e, TurnEnd):
642
+ self._set_status(None)
643
+
644
+
645
+ def build_live_app(*, console: Console, stats: dict, root: str | None, run_one_turn, handle_slash=None,
646
+ pt_input=None, pt_output=None):
647
+ """Build the LIVE composer Application (split out from run_live so a test can drive it with a pipe input).
648
+ Returns (app, state). state = {status, running, signal, last} so a test can inspect what happened.
649
+ run_one_turn(text, sink, signal) executes ONE turn synchronously; it runs in a daemon worker thread so
650
+ the bordered input box stays pinned + responsive WHILE the agent streams output above it."""
651
+ import threading
652
+ from prompt_toolkit.application import Application
653
+ from prompt_toolkit.history import FileHistory
654
+ from prompt_toolkit.key_binding import KeyBindings
655
+ from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window
656
+ from prompt_toolkit.layout.controls import FormattedTextControl
657
+ from prompt_toolkit.layout.menus import MultiColumnCompletionsMenu
658
+ from prompt_toolkit.widgets import Frame, TextArea
659
+
660
+ state = {"status": "", "running": False, "signal": None, "last": None, "threads": []}
661
+ toolbar = _toolbar(stats)
662
+
663
+ def set_status(text): # called from the worker thread; invalidate() is thread-safe
664
+ state["status"] = text or ""
665
+ try:
666
+ app.invalidate()
667
+ except Exception:
668
+ pass
669
+
670
+ def _status_line():
671
+ if state["running"] or state["status"]:
672
+ return FormattedText([("fg:ansibrightcyan", " ✶ "),
673
+ ("fg:ansibrightblack", _shorten(state["status"] or "working…", 110))])
674
+ return toolbar() # idle → the model · policy · topic · tokens bar
675
+
676
+ hist_dir = os.path.expanduser("~/.sliceagent")
677
+ os.makedirs(hist_dir, exist_ok=True)
678
+ ta = TextArea(prompt="❯ ", multiline=False, wrap_lines=True,
679
+ history=FileHistory(os.path.join(hist_dir, "history")),
680
+ completer=_InputCompleter(_repo_files(root) if root else None),
681
+ complete_while_typing=True)
682
+ kb = KeyBindings()
683
+
684
+ @kb.add("enter")
685
+ def _(ev):
686
+ if state["running"]: # one turn at a time — ignore Enter mid-turn
687
+ return
688
+ text = ta.text.strip()
689
+ ta.text = ""
690
+ if not text:
691
+ return
692
+ if text in ("exit", "quit", "/exit"):
693
+ ev.app.exit(); return
694
+ if text == "/learn" or text.startswith("/learn "): # transcript → reusable skill, runs as a TURN (mirror the REPL)
695
+ from .neocortex import build_learn_prompt
696
+ text = build_learn_prompt(text[len("/learn"):].strip())
697
+ elif text.startswith("/") and handle_slash is not None:
698
+ handle_slash(text); return
699
+ user_echo(console, text) # echo ABOVE the box (instant), THEN run the turn
700
+ state["last"] = text
701
+ sink = LiveSink(console, stats, set_status)
702
+ sig = threading.Event()
703
+ state["running"] = True; state["signal"] = sig; state["status"] = "thinking…"
704
+
705
+ def _work():
706
+ try:
707
+ run_one_turn(text, sink, sig)
708
+ except Exception as exc: # a turn crash must NOT kill the composer
709
+ console.print(Text(f" ✗ turn error: {type(exc).__name__}: {exc}", style=TH["fail"]))
710
+ finally:
711
+ state["running"] = False; state["signal"] = None
712
+ set_status(None)
713
+ state["threads"] = [t for t in state["threads"] if t.is_alive()] # prune finished workers (no unbounded growth)
714
+ th = threading.Thread(target=_work, daemon=True)
715
+ state["threads"].append(th)
716
+ th.start()
717
+ ev.app.invalidate()
718
+
719
+ @kb.add("c-c")
720
+ def _(ev):
721
+ if state["running"] and state["signal"] is not None:
722
+ state["signal"].set() # abort the running turn at the next step boundary
723
+ set_status("interrupting…")
724
+ else:
725
+ ev.app.exit()
726
+
727
+ @kb.add("c-d")
728
+ def _(ev):
729
+ ev.app.exit()
730
+
731
+ @kb.add("escape")
732
+ def _(ev): # mid-turn: abort (same as ctrl-c); idle: clear the line, or undo
733
+ if state["running"]:
734
+ if state["signal"] is not None:
735
+ state["signal"].set() # abort the running turn at the next step boundary
736
+ set_status("interrupting…")
737
+ return
738
+ if ta.text.strip():
739
+ ta.text = ""
740
+ elif handle_slash is not None:
741
+ handle_slash("/undo")
742
+
743
+ app = Application(
744
+ layout=Layout(FloatContainer(
745
+ content=HSplit([Frame(ta, title="message"),
746
+ Window(FormattedTextControl(_status_line), height=1)]),
747
+ floats=[Float(xcursor=True, ycursor=True,
748
+ content=MultiColumnCompletionsMenu(min_rows=3, show_meta=True))],
749
+ ), focused_element=ta),
750
+ key_bindings=kb, full_screen=False, mouse_support=False, input=pt_input, output=pt_output)
751
+ return app, state
752
+
753
+
754
+ def run_live(*, console: Console, stats: dict, banner_info: str, root: str | None,
755
+ run_one_turn, handle_slash=None) -> None:
756
+ """The LIVE composer (AGENT_TUI=live): a bordered input box stays pinned at the bottom EVEN WHILE the
757
+ agent streams — output prints above it in the NORMAL terminal buffer (native copy/paste preserved), the
758
+ Python analogue of Ink's <Static>+live-region. ctrl-c aborts a running turn; ctrl-c at idle / ctrl-d quits."""
759
+ from prompt_toolkit.patch_stdout import patch_stdout
760
+ app, _state = build_live_app(console=console, stats=stats, root=root,
761
+ run_one_turn=run_one_turn, handle_slash=handle_slash)
762
+ banner(console, banner_info)
763
+ with patch_stdout(raw=True):
764
+ app.run()
765
+
766
+
767
+ # ── modal selectors (second-tier menus) ───────────────────────────────────────────
768
+ def run_selector(title, rows, *, current=-1, hint="↑↓ move · Enter select · Esc cancel",
769
+ pt_input=None, pt_output=None):
770
+ """A modal single-choice list (a prompt_toolkit choice picker). ``rows`` is a list of
771
+ (label, description); returns the chosen INDEX, or None if cancelled. Non-full-screen + transient
772
+ (erases on close), so it overlays the scrollback like the composer. Safe to call ONLY between turns
773
+ (no other pt Application live) — the REPL slash path satisfies that; live mode falls back to typed args."""
774
+ from prompt_toolkit.application import Application
775
+ from prompt_toolkit.key_binding import KeyBindings
776
+ from prompt_toolkit.layout import HSplit, Layout, Window
777
+ from prompt_toolkit.layout.controls import FormattedTextControl
778
+ from prompt_toolkit.patch_stdout import patch_stdout
779
+ from prompt_toolkit.widgets import Frame
780
+
781
+ if not rows:
782
+ return None
783
+ state = {"cur": current if 0 <= current < len(rows) else 0}
784
+
785
+ def render():
786
+ out = []
787
+ for i, (label, desc) in enumerate(rows):
788
+ sel, cur = i == state["cur"], i == current
789
+ prefix = ("✓" if cur else " ") + ("❯" if sel else " ") + " "
790
+ style = "fg:ansibrightcyan bold" if sel else ("fg:ansigreen" if cur else "")
791
+ out.append((style, prefix + str(label) + "\n"))
792
+ if desc:
793
+ out.append(("fg:ansibrightblack", " " + str(desc) + "\n"))
794
+ return out
795
+
796
+ kb = KeyBindings()
797
+
798
+ @kb.add("up")
799
+ @kb.add("c-p")
800
+ def _(ev):
801
+ state["cur"] = (state["cur"] - 1) % len(rows)
802
+
803
+ @kb.add("down")
804
+ @kb.add("c-n")
805
+ def _(ev):
806
+ state["cur"] = (state["cur"] + 1) % len(rows)
807
+
808
+ @kb.add("enter")
809
+ def _(ev):
810
+ ev.app.exit(result=state["cur"])
811
+
812
+ @kb.add("escape")
813
+ @kb.add("c-c")
814
+ def _(ev):
815
+ ev.app.exit(result=None)
816
+
817
+ header = Window(FormattedTextControl(
818
+ lambda: [("fg:ansicyan bold", f" {title}\n"), ("fg:ansibrightblack", f" {hint}")]), height=2)
819
+ body = Frame(HSplit([header, Window(FormattedTextControl(render))]))
820
+ app = Application(layout=Layout(body), key_bindings=kb, full_screen=False, mouse_support=False,
821
+ erase_when_done=True, input=pt_input, output=pt_output)
822
+ with patch_stdout(raw=True):
823
+ return app.run()
824
+
825
+
826
+ def _model_candidates(llm, cfg):
827
+ """Models to offer in the /model menu: the current model + any configured providers' models + a known
828
+ set, deduped and grouped by inferred provider family. Returns [(model, family)] sorted by family."""
829
+ from .model_catalog import capability
830
+ known = ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "deepseek-chat", "kimi-k2-0905-preview", "claude-sonnet-4-6"]
831
+ prov = []
832
+ try:
833
+ for tbl in (cfg.providers() or {}).values():
834
+ m = tbl.get("model") if isinstance(tbl, dict) else None
835
+ if m:
836
+ prov.append(m)
837
+ except Exception: # noqa: BLE001 — a malformed providers table must not break the menu
838
+ pass
839
+ out, seen = [], set()
840
+ for m in [llm.model] + prov + known:
841
+ if m and m not in seen:
842
+ seen.add(m)
843
+ base = getattr(llm, "_base_url", "") if m == llm.model else ""
844
+ out.append((m, capability(m, base).family))
845
+ out.sort(key=lambda mf: (mf[1], mf[0]))
846
+ return out
847
+
848
+
849
+ def _reasoning_levels(model, base_url):
850
+ """Reasoning levels valid for a model, derived from its capability (provider-aware). Effort-capable
851
+ models (gpt-5/o-series) expose all four; others only fast/full (high/max would degrade to default)."""
852
+ from .model_catalog import capability
853
+ full4 = [("fast", "minimal reasoning — fastest, cheapest"),
854
+ ("full", "provider default reasoning"),
855
+ ("high", "deeper reasoning (effort=high, /v1/responses)"),
856
+ ("max", "deepest reasoning (effort=xhigh)")]
857
+ if capability(model, base_url).supports_reasoning_effort:
858
+ return full4
859
+ return full4[:2] # fast | full only — the model has no effort knob
860
+
861
+
862
+ def select_model_reasoning(llm, cfg, *, pt_input=None, pt_output=None):
863
+ """Two-tier picker: choose a model (grouped by provider) then its reasoning level (only the levels that
864
+ model supports). Returns (model, reasoning) to apply, or None if the model step was cancelled."""
865
+ cands = _model_candidates(llm, cfg)
866
+ rows = [(m, f"provider: {fam}") for m, fam in cands]
867
+ cur_idx = next((i for i, (m, _) in enumerate(cands) if m == llm.model), -1)
868
+ pick = run_selector("Select model", rows, current=cur_idx,
869
+ hint="↑↓ move · Enter choose model → reasoning · Esc cancel",
870
+ pt_input=pt_input, pt_output=pt_output)
871
+ if pick is None:
872
+ return None
873
+ model = cands[pick][0]
874
+ base = getattr(llm, "_base_url", "") if model == llm.model else ""
875
+ levels = _reasoning_levels(model, base)
876
+ lvl_rows = [(name, desc) for name, desc in levels]
877
+ lvl_cur = next((i for i, (n, _) in enumerate(levels) if n == llm.reasoning), -1)
878
+ lpick = run_selector(f"Reasoning for {model}", lvl_rows, current=lvl_cur,
879
+ hint="↑↓ move · Enter select · Esc keep current",
880
+ pt_input=pt_input, pt_output=pt_output)
881
+ reasoning = levels[lpick][0] if lpick is not None else llm.reasoning # Esc on step 2 = keep current
882
+ return (model, reasoning)
883
+
884
+
885
+ # ── input layer (prompt_toolkit) ─────────────────────────────────────────────────────────────
886
+ _SLASH = {
887
+ "/model": "switch model + reasoning — opens a menu (or /model <name> [fast|full|high|max])",
888
+ "/mode": "permission mode — opens a menu (baby-sitter · teenager · let-it-go)",
889
+ "/cwd": "switch workspace root (/cwd <path>) — re-roots repo map, file tools & commands",
890
+ "/learn": "turn what you just did into a reusable SKILL (/learn [name])",
891
+ "/plan": "show the agent's current PLAN + mission",
892
+ "/cost": "show $ saved vs full-history + per-turn token metrics",
893
+ "/threads": "list open/parked topics",
894
+ "/plugins": "list loaded plugins + their tools",
895
+ "/mcp": "list configured MCP servers + connection status",
896
+ "/help": "show commands · Esc = undo last turn",
897
+ "/exit": "quit",
898
+ }
899
+
900
+
901
+ _COMPLETE_IGNORE = {".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__",
902
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", "dist", "build", ".idea", ".vscode"}
903
+
904
+
905
+ def _repo_files(root: str, cap: int = 4000) -> list:
906
+ """A bounded, ignore-pruned list of repo-relative file paths for prompt file-completion
907
+ (let the user tab-complete a filename to reference it). Best-effort; empty on any error."""
908
+ out = []
909
+ try:
910
+ for dp, dirs, files in os.walk(root):
911
+ dirs[:] = [d for d in dirs if d not in _COMPLETE_IGNORE and not d.startswith(".")]
912
+ for fn in files:
913
+ if fn.startswith("."):
914
+ continue
915
+ rel = os.path.relpath(os.path.join(dp, fn), root)
916
+ out.append(rel)
917
+ if len(out) >= cap:
918
+ return out
919
+ except OSError:
920
+ pass
921
+ return out
922
+
923
+
924
+ # Argument suggestions so the menu fills in the VALUE, not just the command. (Suggestions only — you can
925
+ # still type any model.) Keep the model list roughly in sync with cli's /model "known" hint.
926
+ _REASONING = (("fast", "minimal reasoning, fastest"), ("full", "provider default"),
927
+ ("high", "deeper (gpt-5: /v1/responses)"), ("max", "deepest (gpt-5: xhigh)"))
928
+ _KNOWN_MODELS = ("gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "deepseek-chat", "kimi-k2-0905-preview", "claude-sonnet-4-6")
929
+ _SLASH_ARGS = {
930
+ "/reasoning": list(_REASONING),
931
+ "/mode": [("baby-sitter", "confirm every edit + command"), ("teenager", "auto edits, confirm commands"),
932
+ ("let-it-go", "auto-run, still blocks catastrophic")],
933
+ }
934
+
935
+
936
+ class _InputCompleter(Completer):
937
+ """Slash-command completion at line start (a command palette) + ARGUMENT suggestions for /model,
938
+ /reasoning, /mode + filename completion on an explicit @mention (the same @path syntax cli.py's
939
+ message parser already recognizes for pinning/attaching a file — see the `@([\\w./\\-]+)` scan).
940
+ Matching ANY plain word against the repo file list (the original behavior) popped a
941
+ completion menu on ordinary prose ("please edit util" → suggests util.py) — annoying enough in
942
+ practice that gating it behind the @ the user already has to type to reference a file is strictly
943
+ better: same capability, zero unsolicited popups."""
944
+
945
+ def __init__(self, files=None):
946
+ self._files = files or []
947
+
948
+ def get_completions(self, document, complete_event):
949
+ text = document.text_before_cursor
950
+ if text.startswith("/") and " " not in text: # slash command palette
951
+ for cmd, desc in _SLASH.items():
952
+ if cmd.startswith(text):
953
+ yield Completion(cmd, start_position=-len(text), display_meta=desc)
954
+ return
955
+ if text.startswith("/"): # ARGUMENT of an already-typed slash command
956
+ parts = text.split(" ")
957
+ cmd, cur = parts[0], parts[-1]
958
+ if cmd == "/model": # 1st arg = model name, 2nd = reasoning effort
959
+ opts = [(m, "model") for m in _KNOWN_MODELS] if len(parts) == 2 else (
960
+ list(_REASONING) if len(parts) == 3 else [])
961
+ else:
962
+ opts = _SLASH_ARGS.get(cmd, [])
963
+ for val, meta in opts:
964
+ if val.lower().startswith(cur.lower()):
965
+ yield Completion(val, start_position=-len(cur), display_meta=meta)
966
+ return
967
+ words = text.split()
968
+ word = words[-1] if (words and not text.endswith(" ")) else ""
969
+ if not word.startswith("@"): # file-path completion ONLY on @word
970
+ return
971
+ wl = word[1:].lower()
972
+ starts = [p for p in self._files if os.path.basename(p).lower().startswith(wl)]
973
+ starts_set = set(starts) # O(1) membership — `p not in starts` (a list) was O(n) per file → O(n²)/keystroke
974
+ subs = [p for p in self._files if wl in p.lower() and p not in starts_set]
975
+ for p in (starts + subs)[:20]: # basename-prefix first, then substring
976
+ yield Completion(p, start_position=-(len(word) - 1), display_meta="file") # keep the "@", replace after it
977
+
978
+
979
+ # rough public list prices, USD per 1M tokens: (input, cached_input, output). Substring-matched on the
980
+ # model id; an unknown model shows token counts only (no $). Update as prices change.
981
+ def _price(model: str):
982
+ """USD/1M (input, cached, output) for the cost meter — single source is model_catalog.pricing."""
983
+ from .model_catalog import pricing
984
+ return pricing(model)
985
+
986
+
987
+ def _accrue_cost(stats: dict, usage: dict) -> None:
988
+ """Per step: accrue actual $ spend (stats['cost']) AND the MOAT savings in TOKENS (model-independent, so
989
+ a /model switch re-prices them for free — see _saved_dollars).
990
+
991
+ Savings model: a full-transcript agent re-reads the WHOLE prior history every step (a growing cache-read)
992
+ while the bounded slice re-reads only its small cached prefix. The saving is that cache-read DIFFERENTIAL;
993
+ the fresh cost of genuinely-new content is the same for both agents, so it cancels. We track the naive
994
+ transcript size (`_transcript_tok`, grown by each step's fresh-input + output) and bank, per step, the
995
+ tokens the naive agent would re-read that the slice didn't (prefix − this step's actual cache-read)."""
996
+ if not usage:
997
+ return
998
+ prefix = stats.get("_transcript_tok", 0)
999
+ actual_cache_read = usage.get("input_cache_read", 0) or 0
1000
+ stats["saved_cached_tok"] = stats.get("saved_cached_tok", 0) + max(0, prefix - actual_cache_read)
1001
+ stats["_transcript_tok"] = prefix + (usage.get("input_other", 0) or 0) + (usage.get("output", 0) or 0)
1002
+
1003
+ pr = _price(stats.get("model", ""))
1004
+ if not pr:
1005
+ return
1006
+ pin, pcached, pout = pr
1007
+ stats["cost"] = stats.get("cost", 0.0) + (
1008
+ usage.get("input_other", 0) * pin
1009
+ + usage.get("input_cache_read", 0) * pcached
1010
+ + usage.get("output", 0) * pout) / 1_000_000
1011
+
1012
+
1013
+ def _saved_dollars(stats: dict):
1014
+ """$ the slice saved vs a full-transcript agent, priced at the CURRENT model's cached rate (the rate for
1015
+ re-read history). Token-based, so switching /model re-prices the same savings. None if price unknown."""
1016
+ pr = _price(stats.get("model", ""))
1017
+ if not pr:
1018
+ return None
1019
+ return stats.get("saved_cached_tok", 0) * pr[1] / 1_000_000
1020
+
1021
+
1022
+ def _toolbar(stats: dict):
1023
+ """A pinned status bar (re-rendered each redraw). FormattedText, not HTML — so a workspace
1024
+ name containing < & > can never break the markup."""
1025
+ _dim, _accent, _val = "fg:ansibrightblack", "fg:ansibrightcyan bold", "fg:ansicyan"
1026
+ sep = (_dim, " │ ")
1027
+
1028
+ def render():
1029
+ ws = _shorten(stats.get("workspace") or "—", 28)
1030
+ ft = [
1031
+ (_accent, " ◆ "), (_val, str(stats.get("model", "?"))),
1032
+ sep, ("", str(stats.get("policy", "?"))),
1033
+ sep, (_dim, f"📂 {ws}"),
1034
+ sep, (_dim, f"Σ {stats.get('tokens', 0)} tok · {stats.get('fresh', 0)} fresh"),
1035
+ ]
1036
+ # Headline the MOAT number — $ SAVED vs a full-transcript agent (priced at the current model; flips
1037
+ # automatically on /model switch). Falls back to token-savings when the model price is unknown.
1038
+ saved = _saved_dollars(stats)
1039
+ if saved:
1040
+ ft += [sep, ("fg:ansigreen bold", f"💰 ${saved:.4f} saved")]
1041
+ elif stats.get("saved_cached_tok"):
1042
+ ft += [sep, (_dim, f"💰 {stats['saved_cached_tok'] // 1000}k tok saved")]
1043
+ t = stats.get("last_turn_s")
1044
+ if t is not None:
1045
+ ft += [sep, (_dim, f"⏲ {t:.0f}s")]
1046
+ ft.append((_dim, " "))
1047
+ return FormattedText(ft)
1048
+ return render
1049
+
1050
+
1051
+ def _force_cooked_output() -> None:
1052
+ """Re-assert cooked tty OUTPUT (OPOST|ONLCR) so a bare '\\n' is mapped to '\\r\\n'. prompt_toolkit puts the
1053
+ terminal in raw mode for its Application; on some terminals (verified: macOS Terminal.app) that output
1054
+ mode LEAKS past the box, so the turn's Rich output prints line-feeds with no carriage return and
1055
+ staircases to the right. Called after every prompt to guarantee the next turn prints left-aligned.
1056
+ No-op off a real tty (tests/pipes/Windows)."""
1057
+ try:
1058
+ import sys
1059
+ import termios
1060
+ fd = sys.stdout.fileno()
1061
+ a = termios.tcgetattr(fd)
1062
+ a[1] |= (termios.OPOST | termios.ONLCR) # oflag
1063
+ termios.tcsetattr(fd, termios.TCSADRAIN, a)
1064
+ except Exception: # noqa: BLE001 — not a real tty → nothing to restore
1065
+ pass
1066
+
1067
+
1068
+ class TuiInput:
1069
+ """prompt_toolkit input with history, slash/file completion, and the status toolbar.
1070
+
1071
+ The composer is a BORDERED box pinned at the bottom. It's a prompt_toolkit
1072
+ Application run full_screen=False with mouse_support=False — so it stays in the NORMAL terminal buffer:
1073
+ the conversation above is real scrollback, and native select/copy/paste keep working on EVERY terminal
1074
+ (incl. macOS Terminal.app). Degrades to a plain ❯ prompt if the
1075
+ framed Application can't run, so input is never broken."""
1076
+
1077
+ def __init__(self, stats: dict, root: str | None = None):
1078
+ self.stats = stats
1079
+ hist_dir = os.path.expanduser("~/.sliceagent")
1080
+ os.makedirs(hist_dir, exist_ok=True)
1081
+ self._history = FileHistory(os.path.join(hist_dir, "history"))
1082
+ self._completer = _InputCompleter(_repo_files(root) if root else None)
1083
+ # kept for the fallback path (a plain prompt) if the framed Application errors
1084
+ self.session = PromptSession(history=self._history, completer=self._completer,
1085
+ complete_while_typing=True, bottom_toolbar=_toolbar(stats))
1086
+
1087
+ def prompt(self) -> str | None:
1088
+ """Return the next line, or None to QUIT (ctrl-d/ctrl-c at the idle prompt both exit cleanly)."""
1089
+ try:
1090
+ return self._pinned_prompt()
1091
+ except (EOFError, KeyboardInterrupt):
1092
+ return None
1093
+ except Exception: # any prompt_toolkit Application hiccup → robust plain prompt
1094
+ return self._simple_prompt()
1095
+
1096
+ def _build_composer(self, *, pt_input=None, pt_output=None):
1097
+ """Build the framed-composer Application + its TextArea. Split out from _pinned_prompt so a test can
1098
+ drive it with a pipe input + DummyOutput (verify Enter→submit / ctrl-c→quit without a real tty)."""
1099
+ from prompt_toolkit.application import Application
1100
+ from prompt_toolkit.key_binding import KeyBindings
1101
+ from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window
1102
+ from prompt_toolkit.layout.controls import FormattedTextControl
1103
+ from prompt_toolkit.layout.menus import MultiColumnCompletionsMenu
1104
+ from prompt_toolkit.widgets import Frame, TextArea
1105
+
1106
+ ta = TextArea(prompt="❯ ", multiline=False, wrap_lines=True,
1107
+ history=self._history, completer=self._completer, complete_while_typing=True)
1108
+ toolbar = _toolbar(self.stats)
1109
+ status = Window(FormattedTextControl(lambda: toolbar()), height=1)
1110
+ kb = KeyBindings()
1111
+
1112
+ @kb.add("enter")
1113
+ def _(ev):
1114
+ ev.app.exit(result=ta.text.strip()) # consistent with the live composer (no whitespace-only turns)
1115
+
1116
+ @kb.add("c-j") # Ctrl+J → literal newline (Enter is taken by send)
1117
+ def _(ev):
1118
+ ta.buffer.insert_text("\n")
1119
+
1120
+ @kb.add("c-c")
1121
+ @kb.add("c-d")
1122
+ def _(ev):
1123
+ ev.app.exit(result=None)
1124
+
1125
+ @kb.add("escape")
1126
+ def _(ev): # Esc clears a half-typed line; Esc on an EMPTY line = undo last turn
1127
+ if ta.text.strip():
1128
+ ta.text = ""
1129
+ else:
1130
+ ev.app.exit(result="/undo")
1131
+
1132
+ # Wrap the composer in a FloatContainer with a CompletionsMenu float so the slash-command palette
1133
+ # (and file completions) actually RENDER as a dropdown at the cursor — a bare custom Application
1134
+ # computes completions but, unlike PromptSession, has no built-in menu to draw them.
1135
+ body = FloatContainer(
1136
+ content=HSplit([Frame(ta, title="message"), status]),
1137
+ floats=[Float(xcursor=True, ycursor=True,
1138
+ content=MultiColumnCompletionsMenu(min_rows=3, show_meta=True))],
1139
+ )
1140
+ app = Application(
1141
+ layout=Layout(body, focused_element=ta),
1142
+ key_bindings=kb, full_screen=False, mouse_support=False,
1143
+ # erase the bordered box on submit so the composer is TRANSIENT: after Enter the box (and the text
1144
+ # the user typed in it) is wiped, and user_echo prints the single "▌ you …" line — no duplication
1145
+ # of the message (the box's last frame + the echo). The echo is the persistent scrollback record.
1146
+ erase_when_done=True,
1147
+ input=pt_input, output=pt_output,
1148
+ )
1149
+ return app, ta
1150
+
1151
+ def _pinned_prompt(self) -> str | None:
1152
+ """The bordered, bottom-pinned composer (a non-full-screen prompt_toolkit Application)."""
1153
+ from prompt_toolkit.patch_stdout import patch_stdout
1154
+ app, _ta = self._build_composer()
1155
+ try:
1156
+ with patch_stdout(raw=True):
1157
+ return app.run()
1158
+ finally:
1159
+ _force_cooked_output() # undo any raw-output leak so the turn's reply isn't staircased right
1160
+
1161
+ def _simple_prompt(self) -> str | None:
1162
+ from prompt_toolkit.patch_stdout import patch_stdout
1163
+ cols = max(20, shutil.get_terminal_size((80, 24)).columns)
1164
+ msg = FormattedText([("fg:ansibrightblack", "─" * cols + "\n"), ("fg:ansicyan bold", "❯ ")])
1165
+ try:
1166
+ with patch_stdout(raw=True):
1167
+ return self.session.prompt(msg) # PromptSession.prompt has no erase_when_done kwarg → it raised TypeError, crashing the input fallback
1168
+ except (EOFError, KeyboardInterrupt):
1169
+ return None
1170
+ finally:
1171
+ _force_cooked_output() # undo any raw-output leak so the turn's reply isn't staircased right
1172
+
1173
+
1174
+ def _arrow_select(options: list[str], default: int = 0) -> "int | None":
1175
+ """Single-line, arrow-key selector: ←/→ (or ↑/↓) move, Enter chooses, Esc/Ctrl-C cancels; the
1176
+ first letter of each option is also a hotkey. Returns the chosen index, -1 if cancelled, or None
1177
+ if a selector can't SAFELY run (not a TTY, not the main thread, no termios, raw-mode error) so the
1178
+ caller falls back to typed input. POSIX only — Windows returns None. termios + ANSI on one line."""
1179
+ import sys
1180
+ import threading
1181
+ # Raw mode is process-global terminal state — only ever drive it from the MAIN thread with nothing
1182
+ # else owning the terminal. A worker-thread turn or a live prompt_toolkit app would race and corrupt it.
1183
+ if threading.current_thread() is not threading.main_thread():
1184
+ return None
1185
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
1186
+ return None
1187
+ try:
1188
+ import termios
1189
+ import tty
1190
+ except Exception: # noqa: BLE001 — non-POSIX → caller falls back to typed input
1191
+ return None
1192
+ fd = sys.stdin.fileno()
1193
+ try:
1194
+ old = termios.tcgetattr(fd)
1195
+ except Exception: # noqa: BLE001 — not a real terminal
1196
+ return None
1197
+ idx = default
1198
+ hot = {o[:1].lower(): i for i, o in enumerate(options)} # y/n/a first-letter hotkeys
1199
+
1200
+ def draw() -> None:
1201
+ cells = [(f"\x1b[7m {o} \x1b[0m" if i == idx else f"\x1b[2m {o} \x1b[0m")
1202
+ for i, o in enumerate(options)]
1203
+ sys.stdout.write("\r\x1b[2K " + " ".join(cells) + " \x1b[2m(←/→ then Enter)\x1b[0m")
1204
+ sys.stdout.flush()
1205
+
1206
+ raw_entered = False
1207
+ try:
1208
+ tty.setraw(fd)
1209
+ raw_entered = True
1210
+ try:
1211
+ termios.tcflush(fd, termios.TCIFLUSH) # drain the OS input queue (type-ahead / leftover Enter)
1212
+ except Exception: # noqa: BLE001
1213
+ pass
1214
+ draw()
1215
+ while True:
1216
+ # Read RAW bytes straight off the fd — NOT sys.stdin.read(): that's a BUFFERED TEXT stream, and
1217
+ # its read-ahead buffer (a) hides a leftover Enter that tcflush can't drain and (b) breaks the
1218
+ # select()-based escape probe — which is why an arrow press fell through to picking the default.
1219
+ # In raw mode one keypress, incl. a 3-byte arrow (\x1b[C), arrives in a SINGLE os.read.
1220
+ try:
1221
+ data = os.read(fd, 16)
1222
+ except OSError:
1223
+ idx = -1
1224
+ break
1225
+ if not data: # EOF (stdin closed) → cancel
1226
+ idx = -1
1227
+ break
1228
+ if data[:1] in (b"\r", b"\n"): # Enter → choose the highlighted option
1229
+ break
1230
+ if data[:1] == b"\x03": # Ctrl-C → cancel
1231
+ idx = -1
1232
+ break
1233
+ if data[:1] == b"\x1b": # ESC alone, or a CSI/SS3 escape sequence
1234
+ if len(data) == 1: # bare ESC → cancel
1235
+ idx = -1
1236
+ break
1237
+ if data[1:2] in (b"[", b"O"): # CSI / SS3 arrows: the direction byte is right
1238
+ arrow = data[2:3] # AFTER [ or O (NOT the buffer's last byte — a
1239
+ if arrow in (b"C", b"B"): # trailing Enter in the same read would mask it)
1240
+ idx = (idx + 1) % len(options) # → / ↓
1241
+ elif arrow in (b"D", b"A"): # ← / ↑
1242
+ idx = (idx - 1) % len(options)
1243
+ draw()
1244
+ if b"\r" in data[3:] or b"\n" in data[3:]: # arrow + Enter arrived together → also choose
1245
+ break
1246
+ continue # unknown/partial escape → keep waiting
1247
+ c = data[:1].decode("ascii", "ignore").lower() # printable → first-letter hotkey (y/n/a)
1248
+ if c in hot:
1249
+ idx = hot[c]
1250
+ draw()
1251
+ except Exception: # noqa: BLE001 — any I/O error → fall back to typed input, never corrupt the turn
1252
+ idx = None
1253
+ finally:
1254
+ if raw_entered: # only restore if setraw actually succeeded
1255
+ try:
1256
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
1257
+ except Exception: # noqa: BLE001 — wedged terminal: best-effort immediate restore
1258
+ try:
1259
+ termios.tcsetattr(fd, termios.TCSANOW, old)
1260
+ except Exception: # noqa: BLE001
1261
+ pass
1262
+ try:
1263
+ sys.stdout.write("\r\x1b[2K\n") # wipe the menu line, land on a clean row
1264
+ sys.stdout.flush()
1265
+ except Exception: # noqa: BLE001
1266
+ pass
1267
+ return idx
1268
+
1269
+
1270
+ def confirm(console: Console, name: str, detail: str, reason: str) -> str:
1271
+ """Approval prompt used by the permission hook when the TUI is active. Synchronous (no pt app
1272
+ is live mid-run), returns 'yes' | 'no' | 'always'. Arrow-key selectable; falls back to a typed
1273
+ prompt where no TTY is available."""
1274
+ console.print(Text.assemble(
1275
+ Text(" ⚠ allow ", style=TH["warn"]), Text(name, style=TH["tool"]),
1276
+ Text(f" {_shorten(detail, 60)!r}? ", style=TH["dim"]), Text(f"({reason})", style=TH["dim"])))
1277
+ _pause_active_live() # stop the turn spinner + release the Esc-sentinel's hold on the tty
1278
+ try:
1279
+ console.file.flush() # commit the "allow…" line before the selector's raw ANSI writes (no interleave)
1280
+ except Exception: # noqa: BLE001
1281
+ pass
1282
+ try:
1283
+ idx = _arrow_select(["Yes", "No", "Always"], default=0)
1284
+ if idx is not None: # selector ran (TTY): -1 cancel → no
1285
+ return ("yes", "no", "always")[idx] if idx >= 0 else "no"
1286
+ try: # fallback: \[y] escapes the Rich markup
1287
+ ans = console.input(r" \[y]es / \[n]o / \[a]lways ▸ ").strip().lower()
1288
+ except (EOFError, KeyboardInterrupt):
1289
+ return "no"
1290
+ return {"y": "yes", "yes": "yes", "a": "always", "always": "always"}.get(ans, "no")
1291
+ finally:
1292
+ _resume_active_esc_sentinel() # re-arm Esc watching for the rest of the turn, whichever path returned
1293
+
1294
+
1295
+ def ask_user(console: Console, question: str, options=None) -> str:
1296
+ """The ask_user prompt (the 'come back and ask a follow-up' capability). Synchronous — no pt app
1297
+ is live mid-run — so a Rich prompt is safe. Returns the user's answer (a chosen option or free text)."""
1298
+ console.print(Text.assemble(Text(" ❓ ", style=TH["accent"]), Text(question, style="bold")))
1299
+ if options:
1300
+ for i, o in enumerate(options, 1):
1301
+ console.print(Text(f" {i}. {o}", style=TH["dim"]))
1302
+ console.print(Text(" (type a number, or your own answer)", style=TH["dim"]))
1303
+ _pause_active_live() # stop the turn spinner + release the Esc-sentinel's hold on the tty
1304
+ try:
1305
+ try:
1306
+ ans = console.input(" your answer ▸ ").strip()
1307
+ except (EOFError, KeyboardInterrupt):
1308
+ return "(no answer)"
1309
+ if options and ans.isdigit() and 1 <= int(ans) <= len(options):
1310
+ return options[int(ans) - 1]
1311
+ return ans or "(no answer)"
1312
+ finally:
1313
+ _resume_active_esc_sentinel() # re-arm Esc watching for the rest of the turn
1314
+
1315
+
1316
+ # sliceagent wordmark (figlet "ansi_shadow") + the vertical 3-layer emblem. Identity = the context kernel:
1317
+ # ▓ slice (L1, hot working set) → ▒ cache (L2, sealed episodes) → ░ memory (L3, distilled lessons); the
1318
+ # bright→dim gradient is that hot→cold flow. Art hardcoded (no pyfiglet runtime dependency).
1319
+ _WORDMARK = (
1320
+ "███████╗██╗ ██╗ ██████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗",
1321
+ "██╔════╝██║ ██║██╔════╝██╔════╝██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝",
1322
+ "███████╗██║ ██║██║ █████╗ ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ",
1323
+ "╚════██║██║ ██║██║ ██╔══╝ ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ",
1324
+ "███████║███████╗██║╚██████╗███████╗██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ",
1325
+ "╚══════╝╚══════╝╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ",
1326
+ )
1327
+ _EMBLEM = (("▓▓", "bright_cyan"), ("▓▓", "bright_cyan"), ("▒▒", "cyan"),
1328
+ ("▒▒", "cyan"), ("░░", "grey50"), ("░░", "grey50")) # 2 rows per layer, beside the wordmark
1329
+
1330
+
1331
+ def user_echo(console: Console, text: str) -> None:
1332
+ """Anchor the user's turn with breathing room: a blank line, a colored left-bar 'you' marker with the
1333
+ message, then a blank line — so the prompt and the agent's reply don't run together (fixes cramped
1334
+ spacing between user input and the response)."""
1335
+ console.print()
1336
+ console.print(Text.assemble(("▌ ", f"bold {TH['accent']}"), ("you ", f"bold {TH['accent']}"),
1337
+ (text, "bold")))
1338
+ console.print()
1339
+
1340
+
1341
+ def banner_panel(console: Console, info: str) -> Panel:
1342
+ """The startup logo: the full ansi_shadow BLOCK wordmark, always (per user preference — never a compact
1343
+ fallback). Each art row is no-wrap + crop, so a terminal narrower than the art (~86 cols) clips it
1344
+ cleanly on the right instead of wrapping into a staircase; a normal-width window shows it in full.
1345
+ `console` is kept in the signature for the callers, though the layout is now width-independent."""
1346
+ rows = []
1347
+ for i, word in enumerate(_WORDMARK):
1348
+ blk, col = _EMBLEM[i]
1349
+ t = Text.assemble((" ", ""), (blk, f"bold {col}"), (" ", ""), (word, f"bold {col}"))
1350
+ t.no_wrap = True
1351
+ t.overflow = "crop" # narrow terminal → clip the art, never wrap it into a staircase
1352
+ rows.append(t)
1353
+ rows.append(Text(""))
1354
+ rows.append(Text(" ▓ slice → ▒ cache → ░ memory · memory-native coding agent", style=TH["dim"]))
1355
+ if info:
1356
+ rows.append(Text(" " + info, style=TH["dim"]))
1357
+ return Panel(Group(*rows), border_style=TH["accent"], box=_box.ROUNDED,
1358
+ title=f"[bold {TH['accent']}]sliceagent[/]", title_align="left",
1359
+ subtitle="[grey50]/help · ctrl-d to quit[/]", subtitle_align="right", padding=(1, 2))
1360
+
1361
+
1362
+ def banner(console: Console, info: str) -> None:
1363
+ console.print(banner_panel(console, info))
1364
+
1365
+
1366
+ def tui_enabled() -> bool:
1367
+ """On at a TTY unless AGENT_TUI is explicitly off; never on when piped (eval/headless)."""
1368
+ flag = os.environ.get("AGENT_TUI", "").strip().lower()
1369
+ if flag in ("0", "off", "false", "no"):
1370
+ return False
1371
+ if flag in ("1", "on", "true", "yes"):
1372
+ return True
1373
+ try:
1374
+ import sys
1375
+ return sys.stdout.isatty() and sys.stdin.isatty()
1376
+ except Exception:
1377
+ return False