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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|