stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Session status and its presentation-only spinner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import contextlib
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.timer import Timer
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
from stcode.cli import labels
|
|
10
|
+
from stcode.cli.logic.state import ClientState
|
|
11
|
+
from stcode.cli.ui.theme import PRIMARY
|
|
12
|
+
|
|
13
|
+
SPINNER_INTERVAL = 0.1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Status(Static):
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
Status { height: 1; color: $text-muted; }
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self, state: ClientState, *, daemonless: bool, address: Callable[[], str]
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__("", id="status")
|
|
25
|
+
self.state = state
|
|
26
|
+
self.daemonless = daemonless
|
|
27
|
+
self.address = address
|
|
28
|
+
self.working = False
|
|
29
|
+
self._spinner = 0
|
|
30
|
+
self._spinner_timer: Timer | None = None
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self.refresh_display()
|
|
34
|
+
|
|
35
|
+
def on_unmount(self) -> None:
|
|
36
|
+
self.stop_working()
|
|
37
|
+
|
|
38
|
+
def refresh_display(self) -> None:
|
|
39
|
+
effective, config = self.state.effective, self.state.config
|
|
40
|
+
mode = effective.get("mode") or str(config.get("approval_mode", ""))
|
|
41
|
+
model = effective.get("model") or str(config.get("model", ""))
|
|
42
|
+
provider = self.state.provider
|
|
43
|
+
|
|
44
|
+
text = Text()
|
|
45
|
+
if self.working:
|
|
46
|
+
frame = labels.SPINNER_FRAMES[self._spinner % len(labels.SPINNER_FRAMES)]
|
|
47
|
+
text.append(labels.working_status(frame), style=f"bold {PRIMARY}")
|
|
48
|
+
text.append("model ", style="dim")
|
|
49
|
+
text.append(model or labels.STATUS_NO_MODEL, style="bold" if model else "dim")
|
|
50
|
+
text.append(" provider ", style="dim")
|
|
51
|
+
text.append(provider)
|
|
52
|
+
if mode:
|
|
53
|
+
text.append(" mode ", style="dim")
|
|
54
|
+
text.append(
|
|
55
|
+
mode, style=f"bold {labels.APPROVAL_MODE_COLOR.get(mode, 'white')}"
|
|
56
|
+
) # type: ignore[call-overload]
|
|
57
|
+
effort = self.state.effort
|
|
58
|
+
if effort:
|
|
59
|
+
text.append(" effort ", style="dim")
|
|
60
|
+
text.append(effort)
|
|
61
|
+
if self.daemonless:
|
|
62
|
+
# The one shape where "which agent am I talking to" is a live question.
|
|
63
|
+
text.append(" daemon ", style="dim")
|
|
64
|
+
text.append(self.address())
|
|
65
|
+
with contextlib.suppress(Exception):
|
|
66
|
+
self.update(text)
|
|
67
|
+
|
|
68
|
+
def start_working(self) -> None:
|
|
69
|
+
"""Spin, from the moment the message is queued.
|
|
70
|
+
|
|
71
|
+
The first token can be seconds away — a cold local model, a long system prompt,
|
|
72
|
+
a retry — and until it arrives the screen is identical to one where nothing
|
|
73
|
+
happened. A spinner in the status line is the difference between "it is
|
|
74
|
+
thinking" and "did that send?", which is the only question anybody has in that
|
|
75
|
+
gap.
|
|
76
|
+
"""
|
|
77
|
+
if self.working:
|
|
78
|
+
return
|
|
79
|
+
self.working = True
|
|
80
|
+
self._spinner = 0
|
|
81
|
+
self._spinner_timer = self.set_interval(SPINNER_INTERVAL, self._tick_spinner)
|
|
82
|
+
self.refresh_display()
|
|
83
|
+
|
|
84
|
+
def _tick_spinner(self) -> None:
|
|
85
|
+
self._spinner += 1
|
|
86
|
+
self.refresh_display()
|
|
87
|
+
|
|
88
|
+
def stop_working(self) -> None:
|
|
89
|
+
if not self.working:
|
|
90
|
+
return
|
|
91
|
+
self.working = False
|
|
92
|
+
if self._spinner_timer is not None:
|
|
93
|
+
self._spinner_timer.stop()
|
|
94
|
+
self._spinner_timer = None
|
|
95
|
+
self.refresh_display()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Transcript components and formatting helpers."""
|
|
2
|
+
|
|
3
|
+
from .entries import (
|
|
4
|
+
AgentRow,
|
|
5
|
+
Message,
|
|
6
|
+
Notice,
|
|
7
|
+
PlatformNote,
|
|
8
|
+
Progress,
|
|
9
|
+
ShellOutput,
|
|
10
|
+
Thinking,
|
|
11
|
+
ToolCall,
|
|
12
|
+
)
|
|
13
|
+
from .view import Transcript
|
|
14
|
+
from .formatting import (
|
|
15
|
+
AGENT_COLOURS,
|
|
16
|
+
ARGUMENT_CHARS,
|
|
17
|
+
PROGRESS_LINES,
|
|
18
|
+
SHELL_OUTPUT_LINES,
|
|
19
|
+
THINKING_LINES,
|
|
20
|
+
TOOL_RESULT_LINES,
|
|
21
|
+
agent_colour,
|
|
22
|
+
format_arguments,
|
|
23
|
+
platform_rule,
|
|
24
|
+
shell_render,
|
|
25
|
+
thinking_tail,
|
|
26
|
+
tool_render,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AGENT_COLOURS",
|
|
31
|
+
"ARGUMENT_CHARS",
|
|
32
|
+
"PROGRESS_LINES",
|
|
33
|
+
"SHELL_OUTPUT_LINES",
|
|
34
|
+
"THINKING_LINES",
|
|
35
|
+
"TOOL_RESULT_LINES",
|
|
36
|
+
"AgentRow",
|
|
37
|
+
"Message",
|
|
38
|
+
"Notice",
|
|
39
|
+
"PlatformNote",
|
|
40
|
+
"Progress",
|
|
41
|
+
"ShellOutput",
|
|
42
|
+
"Thinking",
|
|
43
|
+
"ToolCall",
|
|
44
|
+
"Transcript",
|
|
45
|
+
"agent_colour",
|
|
46
|
+
"format_arguments",
|
|
47
|
+
"platform_rule",
|
|
48
|
+
"shell_render",
|
|
49
|
+
"thinking_tail",
|
|
50
|
+
"tool_render",
|
|
51
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Transcript entry widgets; update in place as frames arrive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
from stcode.cli.ui.theme import brand_text
|
|
10
|
+
from stcode.cli.ui.components.transcript.formatting import (
|
|
11
|
+
PROGRESS_LINES,
|
|
12
|
+
agent_colour,
|
|
13
|
+
thinking_tail,
|
|
14
|
+
tool_render,
|
|
15
|
+
shell_render,
|
|
16
|
+
platform_rule,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Message(Static):
|
|
21
|
+
"""One block of text — the user's, or the model's answer.
|
|
22
|
+
|
|
23
|
+
`you` is labelled and the model is not: on a screen where one side is the one
|
|
24
|
+
typing, only one of them needs saying.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, role: str, body: str = "") -> None:
|
|
28
|
+
super().__init__(classes=f"entry message {role}", markup=False)
|
|
29
|
+
self._role = role
|
|
30
|
+
self.body = body
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self._refresh()
|
|
34
|
+
|
|
35
|
+
def append(self, text: str) -> None:
|
|
36
|
+
self.body += text
|
|
37
|
+
self._refresh()
|
|
38
|
+
|
|
39
|
+
def _refresh(self) -> None:
|
|
40
|
+
if self._role == "user":
|
|
41
|
+
text = Text()
|
|
42
|
+
# Resolved here rather than written as `$text-primary`: a Rich style string
|
|
43
|
+
# is not CSS and cannot reference a theme variable.
|
|
44
|
+
text.append(
|
|
45
|
+
"you ", style=f"bold {brand_text(self.app.current_theme.dark)}"
|
|
46
|
+
)
|
|
47
|
+
text.append(self.body.replace("\n", "\n "))
|
|
48
|
+
self.update(text)
|
|
49
|
+
else:
|
|
50
|
+
self.update(Text(self.body))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Thinking(VerticalScroll):
|
|
54
|
+
"""Reasoning, quoted and dim, showing the last six lines and scrollable.
|
|
55
|
+
|
|
56
|
+
Its own scroller rather than a growing block: reasoning can be longer than the
|
|
57
|
+
answer, and a model that thought for two pages must not push the answer it then
|
|
58
|
+
gave off the screen.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, body: str = "") -> None:
|
|
62
|
+
super().__init__(classes="entry thinking")
|
|
63
|
+
self.body = body
|
|
64
|
+
self._text = Static(markup=False)
|
|
65
|
+
|
|
66
|
+
def compose(self) -> Any:
|
|
67
|
+
yield self._text
|
|
68
|
+
|
|
69
|
+
def on_mount(self) -> None:
|
|
70
|
+
self._refresh()
|
|
71
|
+
|
|
72
|
+
def append(self, text: str) -> None:
|
|
73
|
+
self.body += text
|
|
74
|
+
self._refresh()
|
|
75
|
+
|
|
76
|
+
def _refresh(self) -> None:
|
|
77
|
+
quoted = "\n".join(
|
|
78
|
+
f"│ {line}" for line in thinking_tail(self.body).splitlines()
|
|
79
|
+
)
|
|
80
|
+
self._text.update(Text(quoted, style="italic dim"))
|
|
81
|
+
self.scroll_end(animate=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Progress(VerticalScroll):
|
|
85
|
+
"""A long-running tool's output while it is still running — `repl`'s cell printing,
|
|
86
|
+
`bash`'s description, a web fetch saying which URL it is on.
|
|
87
|
+
|
|
88
|
+
Plain dim text, in a scroller that shows the last few lines. Deliberately *not* a
|
|
89
|
+
platform rule: a rule is one line saying what the platform did, and a REPL cell that
|
|
90
|
+
prints forty lines of an MCP result is neither one line nor the platform. Rendering
|
|
91
|
+
it as one produced forty dashed rules with a word centred in each.
|
|
92
|
+
|
|
93
|
+
It is also not the model talking, so it stays dim and unattributed, and it is never
|
|
94
|
+
written to the session — `Progress` is advisory, and the tool's own result is the
|
|
95
|
+
record.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, body: str = "") -> None:
|
|
99
|
+
super().__init__(classes="entry progress")
|
|
100
|
+
self.body = body
|
|
101
|
+
self._text = Static(markup=False)
|
|
102
|
+
|
|
103
|
+
def compose(self) -> Any:
|
|
104
|
+
yield self._text
|
|
105
|
+
|
|
106
|
+
def on_mount(self) -> None:
|
|
107
|
+
self._refresh()
|
|
108
|
+
|
|
109
|
+
def append(self, text: str) -> None:
|
|
110
|
+
# One frame per line, so the lines have to be rejoined here rather than run on.
|
|
111
|
+
self.body = f"{self.body}\n{text}" if self.body else text
|
|
112
|
+
self._refresh()
|
|
113
|
+
|
|
114
|
+
def _refresh(self) -> None:
|
|
115
|
+
self._text.update(Text(thinking_tail(self.body, PROGRESS_LINES), style="dim"))
|
|
116
|
+
self.scroll_end(animate=False)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ToolCall(Static):
|
|
120
|
+
"""A tool call, updated in place when its result arrives.
|
|
121
|
+
|
|
122
|
+
In place, because `tool_started` and `tool_finished` are the same event to a
|
|
123
|
+
reader: two boxes for one call would double the transcript and say nothing more.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, name: str, arguments: Mapping[str, Any]) -> None:
|
|
127
|
+
super().__init__(classes="entry tool", markup=False)
|
|
128
|
+
self._name = name
|
|
129
|
+
self._arguments = dict(arguments)
|
|
130
|
+
self._result = ""
|
|
131
|
+
self._ok = True
|
|
132
|
+
self._finished = False
|
|
133
|
+
|
|
134
|
+
def on_mount(self) -> None:
|
|
135
|
+
self._refresh()
|
|
136
|
+
|
|
137
|
+
def finish(self, *, ok: bool, preview: str) -> None:
|
|
138
|
+
self._ok, self._result, self._finished = ok, preview, True
|
|
139
|
+
self._refresh()
|
|
140
|
+
|
|
141
|
+
def _refresh(self) -> None:
|
|
142
|
+
self.update(
|
|
143
|
+
tool_render(
|
|
144
|
+
self._name,
|
|
145
|
+
self._arguments,
|
|
146
|
+
result=self._result,
|
|
147
|
+
ok=self._ok,
|
|
148
|
+
finished=self._finished,
|
|
149
|
+
colour=self._colour(),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _colour(self) -> str:
|
|
154
|
+
return brand_text(self.app.current_theme.dark)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ShellOutput(VerticalScroll):
|
|
158
|
+
"""A `!` command and its output, marked by a rule down the left.
|
|
159
|
+
|
|
160
|
+
A rule rather than a box, because this is the one thing on the screen that is
|
|
161
|
+
neither the model's nor the platform's: **you** ran it, and none of it is in the
|
|
162
|
+
session. It should not look like anything the agent did.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, command: str) -> None:
|
|
166
|
+
super().__init__(classes="entry shell")
|
|
167
|
+
self._text = Static(markup=False)
|
|
168
|
+
self._command = command
|
|
169
|
+
self._output = ""
|
|
170
|
+
self._exit_code = 0
|
|
171
|
+
|
|
172
|
+
def compose(self) -> Any:
|
|
173
|
+
yield self._text
|
|
174
|
+
|
|
175
|
+
def on_mount(self) -> None:
|
|
176
|
+
self._refresh()
|
|
177
|
+
|
|
178
|
+
def finish(self, output: str, exit_code: int) -> None:
|
|
179
|
+
self._output, self._exit_code = output, exit_code
|
|
180
|
+
self._refresh()
|
|
181
|
+
|
|
182
|
+
def _refresh(self) -> None:
|
|
183
|
+
self._text.update(
|
|
184
|
+
shell_render(self._command, self._output, exit_code=self._exit_code)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PlatformNote(Static):
|
|
189
|
+
"""What the platform did, as a full-width rule."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, text: str) -> None:
|
|
192
|
+
super().__init__(classes="entry platform", markup=False)
|
|
193
|
+
self._text = text
|
|
194
|
+
|
|
195
|
+
def on_mount(self) -> None:
|
|
196
|
+
self.update(platform_rule(self._text))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Notice(Static):
|
|
200
|
+
"""A warning or an error: a tinted, full-width box.
|
|
201
|
+
|
|
202
|
+
Loud on purpose. "No API key" printed dim among tool output is a message people
|
|
203
|
+
read after twenty minutes of wondering why nothing happens.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self, text: str, *, level: str = "error") -> None:
|
|
207
|
+
super().__init__(classes=f"entry notice {level}", markup=False)
|
|
208
|
+
self._text = text
|
|
209
|
+
|
|
210
|
+
def on_mount(self) -> None:
|
|
211
|
+
self.update(Text(self._text))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class AgentRow(Horizontal):
|
|
215
|
+
"""A sub-agent's entry: its name in the gutter, its content indented."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, agent: str, content: Widget) -> None:
|
|
218
|
+
super().__init__(classes="entry agent-row")
|
|
219
|
+
self._agent = agent
|
|
220
|
+
self.content = content
|
|
221
|
+
|
|
222
|
+
def compose(self) -> Any:
|
|
223
|
+
label = Static(
|
|
224
|
+
Text(self._agent, style=f"bold {agent_colour(self._agent)}"), markup=False
|
|
225
|
+
)
|
|
226
|
+
label.add_class("agent-name")
|
|
227
|
+
label.styles.width = len(self._agent) + 1
|
|
228
|
+
yield label
|
|
229
|
+
# The gutter is fixed, so the content takes what is left. Without this the
|
|
230
|
+
# content keeps its full width and the right-hand border is clipped off.
|
|
231
|
+
self.content.styles.width = "1fr"
|
|
232
|
+
yield self.content
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The transcript — eight shapes, each one because it has to be told apart at a glance.
|
|
3
|
+
|
|
4
|
+
| | |
|
|
5
|
+
| --- | --- |
|
|
6
|
+
| `───── attached to … ─────` | the platform acted. Not the model talking |
|
|
7
|
+
| a tinted block | what **you** said |
|
|
8
|
+
| `│ quoted, dim` | thinking. Capped at the last six lines while it streams |
|
|
9
|
+
| dim, unquoted | a running tool's output. Advisory, never in the session |
|
|
10
|
+
| plain text | the model's answer |
|
|
11
|
+
| a gray block with a green left border | a tool call and its result |
|
|
12
|
+
| a rule down the left | a `!` command you ran. Never in the session |
|
|
13
|
+
| a tinted full-width box | a warning or an error |
|
|
14
|
+
|
|
15
|
+
A sub-agent renders in exactly the same shapes, with its name in a left gutter and its
|
|
16
|
+
content indented. The colour is **hashed from the name**, not drawn at random: the same
|
|
17
|
+
sub-agent is the same colour in every session and after every restart, which is the only
|
|
18
|
+
way the colour tells you anything.
|
|
19
|
+
|
|
20
|
+
Nothing here parses markup. Every string in this module came from a model, a file or a
|
|
21
|
+
shell, and a stray `[` in one must not be read as a style tag.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import zlib
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Mapping
|
|
29
|
+
|
|
30
|
+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
31
|
+
from rich.rule import Rule
|
|
32
|
+
from rich.text import Text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
THINKING_LINES = 6
|
|
36
|
+
"""How much reasoning stays on screen while it streams. Six is what fits without the
|
|
37
|
+
answer sliding off the bottom; the rest is scrollable, and all of it is in the session."""
|
|
38
|
+
|
|
39
|
+
TOOL_RESULT_LINES = 2
|
|
40
|
+
"""Lines of a tool result kept in the box.
|
|
41
|
+
|
|
42
|
+
Two, because the box is a *receipt* and not a viewer: the interesting thing about a
|
|
43
|
+
finished tool call is that it ran and whether it worked, and a turn with six calls each
|
|
44
|
+
showing six lines is a screen of output with the conversation pushed off the top. The
|
|
45
|
+
full value is in the session, and `ToolFinished` only carries 240 characters of it
|
|
46
|
+
anyway. `todo_write` is the exception: its complete plan is sent and displayed.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
PROGRESS_LINES = 6
|
|
50
|
+
"""Lines of a running tool's output kept on screen.
|
|
51
|
+
|
|
52
|
+
The same six as `Thinking`, for the same reason: it is a sign of life, not a viewer.
|
|
53
|
+
The tool's result is what gets a box, and the whole value is in the session.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
SHELL_OUTPUT_LINES = 200
|
|
57
|
+
"""Lines of a `!` command's output kept on screen. Generous, because you asked for this
|
|
58
|
+
one by hand — but bounded, because `!cat` on the wrong file should not be how a session
|
|
59
|
+
scrolls away."""
|
|
60
|
+
|
|
61
|
+
ARGUMENT_CHARS = 160
|
|
62
|
+
|
|
63
|
+
AGENT_COLOURS = (
|
|
64
|
+
"cyan",
|
|
65
|
+
"magenta",
|
|
66
|
+
"yellow",
|
|
67
|
+
"bright_blue",
|
|
68
|
+
"bright_magenta",
|
|
69
|
+
"bright_cyan",
|
|
70
|
+
"orange3",
|
|
71
|
+
"spring_green3",
|
|
72
|
+
)
|
|
73
|
+
"""Eight, none of them the brand green — that one means *the platform*, and a sub-agent
|
|
74
|
+
wearing it would read as one."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def agent_colour(name: str) -> str:
|
|
78
|
+
"""A stable colour for a sub-agent name.
|
|
79
|
+
|
|
80
|
+
`crc32` rather than `hash()`: Python salts string hashing per process, so `hash()`
|
|
81
|
+
would give the same agent a different colour every time stcode starts.
|
|
82
|
+
"""
|
|
83
|
+
return AGENT_COLOURS[zlib.crc32(name.encode("utf-8")) % len(AGENT_COLOURS)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_arguments(arguments: Mapping[str, Any]) -> str:
|
|
87
|
+
"""A tool's arguments as one line, in the terms the tool works in.
|
|
88
|
+
|
|
89
|
+
One line, because it is the summary at the top of the box: a `bash` heredoc that
|
|
90
|
+
wrapped over twelve rows would push the result out of sight.
|
|
91
|
+
"""
|
|
92
|
+
rendered = " ".join(f"{key}={_flatten(value)}" for key, value in arguments.items())
|
|
93
|
+
return _shorten(rendered, ARGUMENT_CHARS)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def thinking_tail(text: str, limit: int = THINKING_LINES) -> str:
|
|
97
|
+
"""The last `limit` lines. Deltas arrive mid-line, so the line being written counts."""
|
|
98
|
+
lines = text.splitlines() or [text]
|
|
99
|
+
return "\n".join(lines[-limit:])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _flatten(value: Any) -> str:
|
|
103
|
+
return " ".join(str(value).split())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _shorten(text: str, limit: int) -> str:
|
|
107
|
+
flat = " ".join(text.split())
|
|
108
|
+
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- renderables ----------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def platform_rule(text: str) -> RenderableType:
|
|
115
|
+
"""`───── mode → auto-edit ─────`, full width, light gray.
|
|
116
|
+
|
|
117
|
+
A rule rather than a line of text: what the *platform* did is not part of the
|
|
118
|
+
conversation, and it should not be possible to mistake one for the other.
|
|
119
|
+
|
|
120
|
+
Flattened to one line first. Rich draws a rule **per line** of its title, so a
|
|
121
|
+
multi-line string here comes out as a screen of dashes with a word centred in each
|
|
122
|
+
— which is what a tool's output looked like when it arrived as progress.
|
|
123
|
+
"""
|
|
124
|
+
return Rule(
|
|
125
|
+
Text(" ".join(text.split()), style="#999999"), characters="─", style="#bbbbbb"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class ToolPreview:
|
|
131
|
+
name: str
|
|
132
|
+
arguments: Mapping[str, Any]
|
|
133
|
+
result: str
|
|
134
|
+
ok: bool
|
|
135
|
+
finished: bool
|
|
136
|
+
colour: str
|
|
137
|
+
|
|
138
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
139
|
+
width = max(1, options.max_width)
|
|
140
|
+
heading = self.name + (" · failed" if self.finished and not self.ok else "")
|
|
141
|
+
yield Text(heading, style=f"bold {self.colour}", no_wrap=True, overflow="ellipsis")
|
|
142
|
+
arguments = Text(format_arguments(self.arguments))
|
|
143
|
+
arguments.truncate(width, overflow="ellipsis")
|
|
144
|
+
yield arguments
|
|
145
|
+
if self.result.strip():
|
|
146
|
+
lines = Text(self.result.strip(), style="dim").wrap(console, width)
|
|
147
|
+
if self.name != "todo_write" and len(lines) > TOOL_RESULT_LINES:
|
|
148
|
+
lines = lines[:TOOL_RESULT_LINES]
|
|
149
|
+
lines[-1].truncate(max(0, width - 1))
|
|
150
|
+
lines[-1].append("…")
|
|
151
|
+
for line in lines:
|
|
152
|
+
yield line
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def tool_render(
|
|
156
|
+
name: str,
|
|
157
|
+
arguments: Mapping[str, Any],
|
|
158
|
+
*,
|
|
159
|
+
result: str = "",
|
|
160
|
+
ok: bool = True,
|
|
161
|
+
finished: bool = True,
|
|
162
|
+
colour: str = "",
|
|
163
|
+
) -> RenderableType:
|
|
164
|
+
"""One argument row and two result rows, except todos show their full result."""
|
|
165
|
+
return ToolPreview(name, arguments, result, ok, finished, colour or "green")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def shell_render(command: str, output: str, *, exit_code: int = 0) -> RenderableType:
|
|
169
|
+
"""A `!` command and what it printed. The command bold, the output plain.
|
|
170
|
+
|
|
171
|
+
No border of its own — the widget draws the left rule in CSS, where it can be the
|
|
172
|
+
theme's colour. This is only the contents.
|
|
173
|
+
"""
|
|
174
|
+
text = Text()
|
|
175
|
+
text.append(f"! {command}", style="bold")
|
|
176
|
+
if exit_code:
|
|
177
|
+
text.append(f" (exit {exit_code})", style="bold")
|
|
178
|
+
lines = output.rstrip().splitlines()
|
|
179
|
+
if len(lines) > SHELL_OUTPUT_LINES:
|
|
180
|
+
hidden = len(lines) - SHELL_OUTPUT_LINES
|
|
181
|
+
lines = [*lines[:SHELL_OUTPUT_LINES], f"… {hidden} more lines, not shown"]
|
|
182
|
+
for line in lines:
|
|
183
|
+
text.append("\n")
|
|
184
|
+
text.append(line.rstrip())
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---- widgets --------------------------------------------------------------------
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Scrollable transcript container."""
|
|
2
|
+
|
|
3
|
+
from textual.containers import VerticalScroll
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from .entries import AgentRow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Transcript(VerticalScroll):
|
|
9
|
+
"""The conversation. Entries are widgets; a sub-agent's are wrapped in a gutter."""
|
|
10
|
+
|
|
11
|
+
DEFAULT_CSS = """
|
|
12
|
+
Transcript { height: 1fr; scrollbar-size-vertical: 1; }
|
|
13
|
+
|
|
14
|
+
.entry {
|
|
15
|
+
height: auto;
|
|
16
|
+
margin-bottom: 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.entry.platform {
|
|
20
|
+
margin-bottom: 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* What you said, tinted. Without it a short prompt is three words of plain text in
|
|
24
|
+
a column of plain text, and scrolling back to find where you asked something
|
|
25
|
+
means reading rather than looking. */
|
|
26
|
+
.entry.message.user {
|
|
27
|
+
width: 1fr;
|
|
28
|
+
padding: 1 1;
|
|
29
|
+
background: $primary 12%;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* A `!` command: a rule down the left and nothing else. It is neither the model's
|
|
33
|
+
nor the platform's, and none of it is in the session. */
|
|
34
|
+
.entry.shell {
|
|
35
|
+
width: 1fr;
|
|
36
|
+
padding: 0 1;
|
|
37
|
+
height: 6;
|
|
38
|
+
border-left: solid $primary;
|
|
39
|
+
scrollbar-size-vertical: 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.entry.tool {
|
|
43
|
+
background: $foreground 6%;
|
|
44
|
+
color: $foreground;
|
|
45
|
+
border-left: solid $primary;
|
|
46
|
+
padding: 1 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.entry.thinking, .entry.progress {
|
|
50
|
+
max-height: 6;
|
|
51
|
+
scrollbar-size-vertical: 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Full width and tinted: "no API key" printed dim among tool output is a message
|
|
55
|
+
people read twenty minutes after they needed it. */
|
|
56
|
+
.entry.notice {
|
|
57
|
+
width: 1fr;
|
|
58
|
+
padding: 1 2;
|
|
59
|
+
color: $foreground;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.entry.notice.error {
|
|
63
|
+
background: $error 12%;
|
|
64
|
+
border-left: solid $error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.entry.notice.warning {
|
|
68
|
+
background: $warning 12%;
|
|
69
|
+
border-left: solid $warning;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.entry.agent-row {
|
|
73
|
+
height: auto;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* The row already carries the gap. Without this the entry inside it adds a second
|
|
77
|
+
one and a sub-agent's output reads as twice as far apart as the main agent's. */
|
|
78
|
+
.agent-row .entry {
|
|
79
|
+
margin-bottom: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.agent-name {
|
|
83
|
+
height: auto;
|
|
84
|
+
padding-right: 1;
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def add(self, entry: Widget, *, agent: str = "") -> Widget:
|
|
89
|
+
"""Mount one entry and scroll to it. Returns the entry, not the wrapper, so a
|
|
90
|
+
streaming caller keeps a handle on the thing it appends to."""
|
|
91
|
+
self.mount(AgentRow(agent, entry) if agent else entry)
|
|
92
|
+
self.scroll_end(animate=False)
|
|
93
|
+
return entry
|
|
94
|
+
|
|
95
|
+
def clear(self) -> None:
|
|
96
|
+
self.remove_children()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def scrolls(self) -> bool:
|
|
100
|
+
"""Whether there is more transcript than screen — what hides the banner."""
|
|
101
|
+
return self.max_scroll_y > 0
|
|
File without changes
|
|
File without changes
|