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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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
@@ -0,0 +1,12 @@
1
+ /* Page spacing. Reusable component appearance lives in DEFAULT_CSS. */
2
+ #transcript {
3
+ padding: 0 2;
4
+ }
5
+
6
+ #cards, #prompt {
7
+ margin: 0 2;
8
+ }
9
+
10
+ #status {
11
+ padding: 0 3;
12
+ }