turnloop 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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The approval modal.
|
|
2
|
+
|
|
3
|
+
Answerable with one key. The options are deliberately asymmetric: approving once
|
|
4
|
+
is `y`, approving a *narrow* rule for the session is `a`, and there is no
|
|
5
|
+
"always allow everything" — a blanket grant made in a hurry is how a permission
|
|
6
|
+
system stops meaning anything.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import Static
|
|
16
|
+
|
|
17
|
+
from turnloop.permissions.engine import PermissionDecision, PermissionRequest, Scope
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PermissionModal(ModalScreen[PermissionDecision]):
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
("y", "approve_once", "Allow once"),
|
|
23
|
+
("enter", "approve_once", "Allow once"),
|
|
24
|
+
("a", "approve_session", "Allow for session"),
|
|
25
|
+
("s", "approve_project", "Save to settings"),
|
|
26
|
+
("n", "reject", "Decline"),
|
|
27
|
+
("escape", "reject", "Decline"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def __init__(self, request: PermissionRequest):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.request = request
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
with Vertical(id="permission-box"):
|
|
36
|
+
yield Static(self._body(), id="permission-body")
|
|
37
|
+
yield Static(self._keys(), id="permission-keys")
|
|
38
|
+
|
|
39
|
+
def _body(self) -> Text:
|
|
40
|
+
req = self.request
|
|
41
|
+
text = Text()
|
|
42
|
+
text.append("Permission required\n\n", style="bold yellow")
|
|
43
|
+
text.append(f"{req.tool_name} ", style="bold")
|
|
44
|
+
text.append(f"{req.summary}\n\n", style="cyan")
|
|
45
|
+
if req.reason:
|
|
46
|
+
text.append(f"{req.reason}\n\n", style="dim")
|
|
47
|
+
preview = req.args_preview.strip()
|
|
48
|
+
if preview and preview != req.summary:
|
|
49
|
+
text.append(preview[:800] + "\n", style="dim")
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
def _keys(self) -> Text:
|
|
53
|
+
text = Text()
|
|
54
|
+
text.append("\n[y] ", style="bold green")
|
|
55
|
+
text.append("allow once ")
|
|
56
|
+
if self.request.suggested_rule:
|
|
57
|
+
text.append("[a] ", style="bold green")
|
|
58
|
+
text.append(f"allow {self.request.suggested_rule} this session ")
|
|
59
|
+
text.append("[s] ", style="bold")
|
|
60
|
+
text.append("save that rule ")
|
|
61
|
+
text.append("[n] ", style="bold red")
|
|
62
|
+
text.append("decline")
|
|
63
|
+
return text
|
|
64
|
+
|
|
65
|
+
# --- actions ----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def action_approve_once(self) -> None:
|
|
68
|
+
self.dismiss(PermissionDecision(approved=True, scope=Scope.ONCE))
|
|
69
|
+
|
|
70
|
+
def action_approve_session(self) -> None:
|
|
71
|
+
self.dismiss(
|
|
72
|
+
PermissionDecision(
|
|
73
|
+
approved=True, scope=Scope.SESSION, rule=self.request.suggested_rule
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def action_approve_project(self) -> None:
|
|
78
|
+
self.dismiss(
|
|
79
|
+
PermissionDecision(
|
|
80
|
+
approved=True, scope=Scope.PROJECT, rule=self.request.suggested_rule
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def action_reject(self) -> None:
|
|
85
|
+
self.dismiss(
|
|
86
|
+
PermissionDecision(approved=False, reason="the user declined this call")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ChoiceModal(ModalScreen[PermissionDecision]):
|
|
91
|
+
"""AskUserQuestion's presentation: numbered options, one keystroke each."""
|
|
92
|
+
|
|
93
|
+
BINDINGS = [("escape", "dismiss_none", "Skip")]
|
|
94
|
+
|
|
95
|
+
def __init__(self, request: PermissionRequest):
|
|
96
|
+
super().__init__()
|
|
97
|
+
self.request = request
|
|
98
|
+
self.options = _parse_options(request.args_preview)
|
|
99
|
+
for index, label in enumerate(self.options[:9], start=1):
|
|
100
|
+
self._bind_option(str(index), label)
|
|
101
|
+
|
|
102
|
+
def _bind_option(self, key: str, label: str) -> None:
|
|
103
|
+
self._bindings.bind(key, f"choose('{label}')", label)
|
|
104
|
+
|
|
105
|
+
def compose(self) -> ComposeResult:
|
|
106
|
+
with Vertical(id="permission-box"):
|
|
107
|
+
yield Static(self._body(), id="permission-body")
|
|
108
|
+
|
|
109
|
+
def _body(self) -> Text:
|
|
110
|
+
text = Text()
|
|
111
|
+
text.append(f"{self.request.summary}\n\n", style="bold")
|
|
112
|
+
for index, label in enumerate(self.options[:9], start=1):
|
|
113
|
+
text.append(f" [{index}] ", style="bold green")
|
|
114
|
+
text.append(f"{label}\n")
|
|
115
|
+
text.append("\n[esc] skip", style="dim")
|
|
116
|
+
return text
|
|
117
|
+
|
|
118
|
+
def action_choose(self, label: str) -> None:
|
|
119
|
+
self.dismiss(PermissionDecision(approved=True, reason=label))
|
|
120
|
+
|
|
121
|
+
def action_dismiss_none(self) -> None:
|
|
122
|
+
self.dismiss(PermissionDecision(approved=False, reason=""))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_options(preview: str) -> list[str]:
|
|
126
|
+
options: list[str] = []
|
|
127
|
+
for line in preview.splitlines():
|
|
128
|
+
stripped = line.strip()
|
|
129
|
+
if stripped[:1].isdigit() and "." in stripped[:3]:
|
|
130
|
+
options.append(stripped.split(".", 1)[1].strip())
|
|
131
|
+
return options
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The status bar.
|
|
2
|
+
|
|
3
|
+
Computes nothing. Everything it shows arrives in a StatusUpdate, so a slow status
|
|
4
|
+
line can never slow the agent loop down.
|
|
5
|
+
|
|
6
|
+
The GPU segment is the part that earns its space: a self-hosted endpoint bills by
|
|
7
|
+
wall clock and only scales down after ten idle minutes, so an open session quietly
|
|
8
|
+
costs $18.16/hour. Showing the running total, in amber, is the difference between
|
|
9
|
+
noticing and finding out from a bill.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.reactive import reactive
|
|
16
|
+
from textual.widgets import Static
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StatusBar(Static):
|
|
20
|
+
provider = reactive("")
|
|
21
|
+
model = reactive("")
|
|
22
|
+
mode = reactive("default")
|
|
23
|
+
context_tokens = reactive(0)
|
|
24
|
+
context_max = reactive(1)
|
|
25
|
+
cost_usd = reactive(0.0)
|
|
26
|
+
tokens_in = reactive(0)
|
|
27
|
+
tokens_out = reactive(0)
|
|
28
|
+
gpu_seconds = reactive(0.0)
|
|
29
|
+
cost_per_hour = reactive(0.0)
|
|
30
|
+
note = reactive("")
|
|
31
|
+
|
|
32
|
+
def render(self) -> Text:
|
|
33
|
+
line = Text()
|
|
34
|
+
line.append(f" {self.provider}", style="bold")
|
|
35
|
+
if self.model:
|
|
36
|
+
line.append(f":{self.model}", style="dim")
|
|
37
|
+
line.append(" ")
|
|
38
|
+
|
|
39
|
+
pressure = self.context_tokens / max(1, self.context_max)
|
|
40
|
+
style = "green" if pressure < 0.5 else ("yellow" if pressure < 0.8 else "red")
|
|
41
|
+
line.append(
|
|
42
|
+
f"ctx {_k(self.context_tokens)}/{_k(self.context_max)} ({pressure * 100:.0f}%)",
|
|
43
|
+
style=style,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
line.append(" ")
|
|
47
|
+
line.append(f"{_k(self.tokens_in)} in / {_k(self.tokens_out)} out", style="dim")
|
|
48
|
+
|
|
49
|
+
if self.cost_per_hour and self.gpu_seconds:
|
|
50
|
+
gpu_cost = self.gpu_seconds / 3600 * self.cost_per_hour
|
|
51
|
+
line.append(" ")
|
|
52
|
+
line.append(
|
|
53
|
+
f"GPU {_clock(self.gpu_seconds)} ≈ ${gpu_cost:.2f} (idle-stop 10m)",
|
|
54
|
+
style="bold yellow",
|
|
55
|
+
)
|
|
56
|
+
elif self.cost_usd:
|
|
57
|
+
line.append(" ")
|
|
58
|
+
line.append(f"${self.cost_usd:.4f}", style="dim")
|
|
59
|
+
|
|
60
|
+
if self.mode != "default":
|
|
61
|
+
line.append(" ")
|
|
62
|
+
line.append(
|
|
63
|
+
f"[{self.mode}]",
|
|
64
|
+
style="bold magenta" if self.mode == "plan" else "bold red",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if self.note:
|
|
68
|
+
line.append(" ")
|
|
69
|
+
line.append(self.note, style="italic dim")
|
|
70
|
+
|
|
71
|
+
return line
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _k(n: int) -> str:
|
|
75
|
+
if n >= 1_000_000:
|
|
76
|
+
return f"{n / 1_000_000:.1f}M"
|
|
77
|
+
if n >= 1_000:
|
|
78
|
+
return f"{n / 1_000:.1f}k"
|
|
79
|
+
return str(n)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _clock(seconds: float) -> str:
|
|
83
|
+
total = int(seconds)
|
|
84
|
+
return f"{total // 3600:02d}:{total % 3600 // 60:02d}:{total % 60:02d}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BootPanel(Static):
|
|
88
|
+
"""Shown while a self-hosted endpoint cold-boots.
|
|
89
|
+
|
|
90
|
+
Twenty-nine minutes of silence is indistinguishable from a hang, and the
|
|
91
|
+
natural reaction is to kill it — which throws away the boot and starts another
|
|
92
|
+
one. So the wait is narrated with elapsed time and an expectation.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def show(self, text: str, elapsed: float) -> None:
|
|
96
|
+
body = Text()
|
|
97
|
+
body.append("Waiting for the model endpoint\n\n", style="bold")
|
|
98
|
+
body.append(text + "\n", style="yellow")
|
|
99
|
+
body.append(f"\nelapsed {_clock(elapsed)}\n", style="dim")
|
|
100
|
+
body.append(
|
|
101
|
+
"\nThis endpoint is a 744B model on 4xH200 that scales to zero when idle.\n"
|
|
102
|
+
"A cold boot loads 388GB of weights and JIT-compiles attention kernels:\n"
|
|
103
|
+
"roughly 29 minutes cold, 13 with a warm compile cache. It is not stuck.\n",
|
|
104
|
+
style="dim",
|
|
105
|
+
)
|
|
106
|
+
self.update(body)
|
|
107
|
+
self.display = True
|
|
108
|
+
|
|
109
|
+
def hide(self) -> None:
|
|
110
|
+
self.display = False
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The conversation view.
|
|
2
|
+
|
|
3
|
+
The one performance decision that matters: **token deltas are buffered and
|
|
4
|
+
flushed on a timer, not applied on arrival.** A model streaming 60 tokens per
|
|
5
|
+
second would otherwise trigger 60 full repaints per second, and the UI ends up
|
|
6
|
+
stuttering worse than the model streams. Buffering at 50 ms makes streaming look
|
|
7
|
+
smooth and costs nothing perceptible.
|
|
8
|
+
|
|
9
|
+
Assistant text is a single Static that gets updated in place, which is what makes
|
|
10
|
+
text appear to grow rather than scrolling line by line.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual.containers import VerticalScroll
|
|
18
|
+
from textual.widgets import Static
|
|
19
|
+
|
|
20
|
+
FLUSH_INTERVAL = 0.05
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Transcript(VerticalScroll):
|
|
24
|
+
"""Append-only conversation log with an in-place streaming block."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, **kwargs):
|
|
27
|
+
super().__init__(**kwargs)
|
|
28
|
+
self._buffer: list[str] = []
|
|
29
|
+
self._live: Static | None = None
|
|
30
|
+
self._live_text = ""
|
|
31
|
+
self._thinking: Static | None = None
|
|
32
|
+
self._thinking_text = ""
|
|
33
|
+
self._flush_timer = None
|
|
34
|
+
self._tool_blocks: dict[str, Static] = {}
|
|
35
|
+
|
|
36
|
+
def on_mount(self) -> None:
|
|
37
|
+
self._flush_timer = self.set_interval(FLUSH_INTERVAL, self._flush, pause=False)
|
|
38
|
+
|
|
39
|
+
# --- user / system lines ----------------------------------------------
|
|
40
|
+
|
|
41
|
+
async def add_user(self, text: str) -> None:
|
|
42
|
+
await self._close_live()
|
|
43
|
+
await self.mount(Static(Text(f"› {text}", style="bold"), classes="msg user"))
|
|
44
|
+
self.scroll_end(animate=False)
|
|
45
|
+
|
|
46
|
+
async def add_note(self, text: str, classes: str = "note") -> None:
|
|
47
|
+
await self._close_live()
|
|
48
|
+
await self.mount(Static(Text(text, style="dim"), classes=f"msg {classes}"))
|
|
49
|
+
self.scroll_end(animate=False)
|
|
50
|
+
|
|
51
|
+
async def add_error(self, text: str) -> None:
|
|
52
|
+
await self._close_live()
|
|
53
|
+
await self.mount(Static(Text(text, style="bold red"), classes="msg error"))
|
|
54
|
+
self.scroll_end(animate=False)
|
|
55
|
+
|
|
56
|
+
# --- streaming --------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def append_delta(self, text: str) -> None:
|
|
59
|
+
"""Called once per token. Must stay cheap — it only appends to a list."""
|
|
60
|
+
self._buffer.append(text)
|
|
61
|
+
|
|
62
|
+
def append_thinking(self, text: str) -> None:
|
|
63
|
+
self._thinking_text += text
|
|
64
|
+
|
|
65
|
+
def _flush(self) -> None:
|
|
66
|
+
if self._thinking_text and self._thinking is None:
|
|
67
|
+
self._thinking = Static(classes="msg thinking")
|
|
68
|
+
self.mount(self._thinking)
|
|
69
|
+
if self._thinking is not None and self._thinking_text:
|
|
70
|
+
self._thinking.update(Text(_collapse(self._thinking_text), style="italic dim"))
|
|
71
|
+
|
|
72
|
+
if not self._buffer:
|
|
73
|
+
return
|
|
74
|
+
chunk = "".join(self._buffer)
|
|
75
|
+
self._buffer.clear()
|
|
76
|
+
self._live_text += chunk
|
|
77
|
+
|
|
78
|
+
if self._live is None:
|
|
79
|
+
self._live = Static(classes="msg assistant")
|
|
80
|
+
self.mount(self._live)
|
|
81
|
+
# Plain Text while streaming: partial markdown (an unclosed code fence) is
|
|
82
|
+
# rendered wrongly and flickers as the fence completes.
|
|
83
|
+
self._live.update(Text(self._live_text))
|
|
84
|
+
self.scroll_end(animate=False)
|
|
85
|
+
|
|
86
|
+
async def _close_live(self) -> None:
|
|
87
|
+
self._flush()
|
|
88
|
+
if self._live is not None:
|
|
89
|
+
# Re-render as markdown now that the text is complete.
|
|
90
|
+
self._live.update(_render_final(self._live_text))
|
|
91
|
+
self._live = None
|
|
92
|
+
self._live_text = ""
|
|
93
|
+
if self._thinking is not None:
|
|
94
|
+
self._thinking.add_class("collapsed")
|
|
95
|
+
self._thinking = None
|
|
96
|
+
self._thinking_text = ""
|
|
97
|
+
|
|
98
|
+
async def end_message(self) -> None:
|
|
99
|
+
await self._close_live()
|
|
100
|
+
|
|
101
|
+
# --- tools ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async def add_tool_start(self, tool_use_id: str, summary: str,
|
|
104
|
+
subagent_id: str | None = None) -> None:
|
|
105
|
+
await self._close_live()
|
|
106
|
+
prefix = " └ " if subagent_id else ""
|
|
107
|
+
block = Static(Text(f"{prefix}⏵ {summary}", style="cyan"), classes="msg tool running")
|
|
108
|
+
self._tool_blocks[tool_use_id] = block
|
|
109
|
+
await self.mount(block)
|
|
110
|
+
self.scroll_end(animate=False)
|
|
111
|
+
|
|
112
|
+
def update_tool(self, tool_use_id: str, name: str, is_error: bool,
|
|
113
|
+
display: str | None, duration: float) -> None:
|
|
114
|
+
block = self._tool_blocks.pop(tool_use_id, None)
|
|
115
|
+
mark = "✗" if is_error else "✓"
|
|
116
|
+
style = "red" if is_error else "green"
|
|
117
|
+
detail = (display or "").strip().splitlines()
|
|
118
|
+
head = detail[0] if detail else name
|
|
119
|
+
text = Text(f"{mark} {head}", style=style)
|
|
120
|
+
if duration > 1:
|
|
121
|
+
text.append(f" ({duration:.1f}s)", style="dim")
|
|
122
|
+
if block is not None:
|
|
123
|
+
block.remove_class("running")
|
|
124
|
+
block.update(text)
|
|
125
|
+
else:
|
|
126
|
+
self.mount(Static(text, classes="msg tool"))
|
|
127
|
+
self.scroll_end(animate=False)
|
|
128
|
+
|
|
129
|
+
def tool_progress(self, tool_use_id: str, text: str) -> None:
|
|
130
|
+
block = self._tool_blocks.get(tool_use_id)
|
|
131
|
+
if block is None:
|
|
132
|
+
return
|
|
133
|
+
# Only the latest line is shown: a build's full output belongs in the
|
|
134
|
+
# transcript's tool result, not scrolling live through the UI.
|
|
135
|
+
block.update(Text(f"⏵ {text[:160]}", style="cyan dim"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _collapse(text: str, limit: int = 400) -> str:
|
|
139
|
+
flat = " ".join(text.split())
|
|
140
|
+
return flat if len(flat) <= limit else "…" + flat[-limit:]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _render_final(text: str):
|
|
144
|
+
if any(marker in text for marker in ("```", "\n- ", "\n* ", "\n#", "**")):
|
|
145
|
+
return Markdown(text)
|
|
146
|
+
return Text(text)
|