mindcode 0.2.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.
- mindcode/__init__.py +5 -0
- mindcode/__main__.py +4 -0
- mindcode/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/__pycache__/__main__.cpython-313.pyc +0 -0
- mindcode/__pycache__/_version.cpython-313.pyc +0 -0
- mindcode/__pycache__/config.cpython-313.pyc +0 -0
- mindcode/__pycache__/policy.cpython-313.pyc +0 -0
- mindcode/__pycache__/remote.cpython-313.pyc +0 -0
- mindcode/__pycache__/render.cpython-313.pyc +0 -0
- mindcode/__pycache__/runtime.cpython-313.pyc +0 -0
- mindcode/__pycache__/tasking.cpython-313.pyc +0 -0
- mindcode/__pycache__/terminal_core.cpython-313.pyc +0 -0
- mindcode/_version.py +11 -0
- mindcode/approval.py +105 -0
- mindcode/bench/__init__.py +19 -0
- mindcode/bench/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/fake_tools.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/jsonutil.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/paths.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/predictions.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/report.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/runner.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/schema.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/trace.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/workspace.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__init__.py +22 -0
- mindcode/bench/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/base.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_bfcl.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_gaia.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_terminal.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/swe_bench.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/terminal_bench.cpython-313.pyc +0 -0
- mindcode/bench/adapters/base.py +45 -0
- mindcode/bench/adapters/mini_bfcl.py +18 -0
- mindcode/bench/adapters/mini_gaia.py +18 -0
- mindcode/bench/adapters/mini_terminal.py +18 -0
- mindcode/bench/adapters/swe_bench.py +86 -0
- mindcode/bench/adapters/terminal_bench.py +107 -0
- mindcode/bench/fake_tools.py +73 -0
- mindcode/bench/jsonutil.py +20 -0
- mindcode/bench/paths.py +10 -0
- mindcode/bench/predictions.py +65 -0
- mindcode/bench/report.py +48 -0
- mindcode/bench/runner.py +250 -0
- mindcode/bench/schema.py +78 -0
- mindcode/bench/scorers/__pycache__/base.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/composite.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/exact.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/json_call.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/swe.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/terminal.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/terminal_bench.cpython-313.pyc +0 -0
- mindcode/bench/scorers/base.py +22 -0
- mindcode/bench/scorers/composite.py +57 -0
- mindcode/bench/scorers/exact.py +30 -0
- mindcode/bench/scorers/json_call.py +44 -0
- mindcode/bench/scorers/swe.py +29 -0
- mindcode/bench/scorers/terminal.py +43 -0
- mindcode/bench/scorers/terminal_bench.py +25 -0
- mindcode/bench/trace.py +34 -0
- mindcode/bench/workspace.py +127 -0
- mindcode/cli/__init__.py +112 -0
- mindcode/cli/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/__pycache__/_shared.cpython-313.pyc +0 -0
- mindcode/cli/_shared.py +43 -0
- mindcode/cli/commands/__init__.py +1 -0
- mindcode/cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/bench.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/chat.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/config_cmd.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/remote.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/shell.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/status.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/task.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/terminal.cpython-313.pyc +0 -0
- mindcode/cli/commands/bench.py +365 -0
- mindcode/cli/commands/chat.py +86 -0
- mindcode/cli/commands/config_cmd.py +197 -0
- mindcode/cli/commands/remote.py +146 -0
- mindcode/cli/commands/shell.py +108 -0
- mindcode/cli/commands/status.py +37 -0
- mindcode/cli/commands/task.py +417 -0
- mindcode/cli/commands/terminal.py +124 -0
- mindcode/cli/shell/__init__.py +5 -0
- mindcode/cli/shell/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/completion.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/menu.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/repl.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/slash.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/startup.cpython-313.pyc +0 -0
- mindcode/cli/shell/completion.py +72 -0
- mindcode/cli/shell/menu.py +88 -0
- mindcode/cli/shell/repl.py +597 -0
- mindcode/cli/shell/slash.py +480 -0
- mindcode/cli/shell/startup.py +55 -0
- mindcode/cli/shell/tui.py +897 -0
- mindcode/config.py +173 -0
- mindcode/mcp.py +205 -0
- mindcode/policy.py +173 -0
- mindcode/remote.py +216 -0
- mindcode/render.py +458 -0
- mindcode/runtime.py +541 -0
- mindcode/skills.py +154 -0
- mindcode/subagents.py +43 -0
- mindcode/tasking.py +281 -0
- mindcode/terminal/__init__.py +1 -0
- mindcode/terminal/__main__.py +17 -0
- mindcode/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/terminal/__pycache__/__main__.cpython-313.pyc +0 -0
- mindcode/terminal_core.py +264 -0
- mindcode-0.2.0.dist-info/METADATA +244 -0
- mindcode-0.2.0.dist-info/RECORD +116 -0
- mindcode-0.2.0.dist-info/WHEEL +5 -0
- mindcode-0.2.0.dist-info/entry_points.txt +2 -0
- mindcode-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""Mindcode shell 的全屏展示层。
|
|
2
|
+
|
|
3
|
+
这里仅负责布局与渲染:既有的命令分发、队列和 runtime 事件语义仍由
|
|
4
|
+
``repl.Shell`` 保持。消息区使用 Rich 将 Markdown 预渲染成 ANSI,再交给
|
|
5
|
+
Prompt Toolkit 管理固定头尾和滚动视图。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import io
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Awaitable, Callable
|
|
18
|
+
|
|
19
|
+
from prompt_toolkit.application import Application
|
|
20
|
+
from prompt_toolkit.buffer import Buffer
|
|
21
|
+
from prompt_toolkit.filters import Condition, has_completions, is_done
|
|
22
|
+
from prompt_toolkit.formatted_text import (
|
|
23
|
+
ANSI,
|
|
24
|
+
StyleAndTextTuples,
|
|
25
|
+
to_formatted_text,
|
|
26
|
+
)
|
|
27
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
28
|
+
from prompt_toolkit.layout import (
|
|
29
|
+
Float,
|
|
30
|
+
FloatContainer,
|
|
31
|
+
HSplit,
|
|
32
|
+
Layout,
|
|
33
|
+
)
|
|
34
|
+
from prompt_toolkit.layout.containers import ConditionalContainer, Window
|
|
35
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
36
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
37
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
38
|
+
from prompt_toolkit.layout.processors import (
|
|
39
|
+
AfterInput,
|
|
40
|
+
BeforeInput,
|
|
41
|
+
ConditionalProcessor,
|
|
42
|
+
)
|
|
43
|
+
from prompt_toolkit.mouse_events import MouseButton, MouseEventType
|
|
44
|
+
from prompt_toolkit.styles import Style
|
|
45
|
+
from prompt_toolkit.utils import get_cwidth
|
|
46
|
+
from prompt_toolkit.widgets import Box, Frame, RadioList
|
|
47
|
+
from rich.console import Console
|
|
48
|
+
from rich.markdown import Markdown
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
52
|
+
COMPOSER_PREFIX = "› "
|
|
53
|
+
COMPOSER_HINT = "Let me help you with that"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TranscriptEntry:
|
|
58
|
+
"""A single display item in the scrollable transcript."""
|
|
59
|
+
|
|
60
|
+
role: str
|
|
61
|
+
content: str
|
|
62
|
+
streaming: bool = False
|
|
63
|
+
label: str = ""
|
|
64
|
+
status: str = ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Transcript:
|
|
68
|
+
"""Small in-memory transcript with one replaceable streaming assistant row."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self.entries: list[TranscriptEntry] = []
|
|
72
|
+
self.on_change: Callable[[], None] | None = None
|
|
73
|
+
self.version = 0
|
|
74
|
+
|
|
75
|
+
def add_user(self, content: str) -> None:
|
|
76
|
+
self.entries.append(TranscriptEntry("user", content))
|
|
77
|
+
self._changed()
|
|
78
|
+
|
|
79
|
+
def add_assistant(self, content: str) -> None:
|
|
80
|
+
self.entries.append(TranscriptEntry("assistant", content))
|
|
81
|
+
self._changed()
|
|
82
|
+
|
|
83
|
+
def add_system(self, content: str) -> None:
|
|
84
|
+
content = content.strip("\n")
|
|
85
|
+
if content:
|
|
86
|
+
self.entries.append(TranscriptEntry("system", content))
|
|
87
|
+
self._changed()
|
|
88
|
+
|
|
89
|
+
def add_tool(self, label: str, detail: str) -> None:
|
|
90
|
+
self.entries.append(
|
|
91
|
+
TranscriptEntry("tool", detail, label=label, status="running")
|
|
92
|
+
)
|
|
93
|
+
self._changed()
|
|
94
|
+
|
|
95
|
+
def finish_tool(self, ok: bool, detail: str) -> None:
|
|
96
|
+
for entry in reversed(self.entries):
|
|
97
|
+
if entry.role == "tool" and entry.status == "running":
|
|
98
|
+
entry.status = "ok" if ok else "error"
|
|
99
|
+
entry.content = detail
|
|
100
|
+
self._changed()
|
|
101
|
+
return
|
|
102
|
+
self.entries.append(
|
|
103
|
+
TranscriptEntry(
|
|
104
|
+
"tool",
|
|
105
|
+
detail,
|
|
106
|
+
label="Tool",
|
|
107
|
+
status="ok" if ok else "error",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
self._changed()
|
|
111
|
+
|
|
112
|
+
def add_tool_detail(self, markdown: str) -> None:
|
|
113
|
+
self.entries.append(TranscriptEntry("tool_detail", markdown))
|
|
114
|
+
self._changed()
|
|
115
|
+
|
|
116
|
+
def add_thinking(self, summary: str) -> None:
|
|
117
|
+
if self.entries and self.entries[-1].role == "thinking":
|
|
118
|
+
self.entries[-1].content = summary
|
|
119
|
+
else:
|
|
120
|
+
self.entries.append(TranscriptEntry("thinking", summary))
|
|
121
|
+
self._changed()
|
|
122
|
+
|
|
123
|
+
def append_delta(self, delta: str) -> None:
|
|
124
|
+
if not delta:
|
|
125
|
+
return
|
|
126
|
+
if (
|
|
127
|
+
self.entries
|
|
128
|
+
and self.entries[-1].role == "assistant"
|
|
129
|
+
and self.entries[-1].streaming
|
|
130
|
+
):
|
|
131
|
+
self.entries[-1].content += delta
|
|
132
|
+
else:
|
|
133
|
+
self.entries.append(TranscriptEntry("assistant", delta, streaming=True))
|
|
134
|
+
self._changed()
|
|
135
|
+
|
|
136
|
+
def finish_stream(self) -> None:
|
|
137
|
+
if self.entries and self.entries[-1].role == "assistant":
|
|
138
|
+
self.entries[-1].streaming = False
|
|
139
|
+
self._changed()
|
|
140
|
+
|
|
141
|
+
def _changed(self) -> None:
|
|
142
|
+
self.version += 1
|
|
143
|
+
if self.on_change is not None:
|
|
144
|
+
self.on_change()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def user_card_lines(content: str, width: int) -> list[str]:
|
|
148
|
+
"""Return a bordered, fixed-width user card.
|
|
149
|
+
|
|
150
|
+
A one-line message has exactly one content row between the top and bottom
|
|
151
|
+
rules, so its first line is vertically centered in the box. Additional
|
|
152
|
+
lines expand the card downward without adding a leading blank row.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
inner_width = max(12, width - 4)
|
|
156
|
+
wrapped = _wrap_display_text(content, inner_width - 2)
|
|
157
|
+
top = "┌" + "─" * (inner_width + 2) + "┐"
|
|
158
|
+
body = [
|
|
159
|
+
"│ "
|
|
160
|
+
+ ("> " if index == 0 else " ")
|
|
161
|
+
+ line
|
|
162
|
+
+ " " * (inner_width - 2 - get_cwidth(line))
|
|
163
|
+
+ " │"
|
|
164
|
+
for index, line in enumerate(wrapped)
|
|
165
|
+
]
|
|
166
|
+
bottom = "└" + "─" * (inner_width + 2) + "┘"
|
|
167
|
+
return [top, *body, bottom]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _wrap_display_text(text: str, width: int) -> list[str]:
|
|
171
|
+
"""Wrap text by terminal cell width while retaining explicit blank lines."""
|
|
172
|
+
|
|
173
|
+
lines: list[str] = []
|
|
174
|
+
for source_line in text.splitlines() or [""]:
|
|
175
|
+
current = ""
|
|
176
|
+
for char in source_line:
|
|
177
|
+
if get_cwidth(current + char) > width and current:
|
|
178
|
+
lines.append(current)
|
|
179
|
+
current = char
|
|
180
|
+
else:
|
|
181
|
+
current += char
|
|
182
|
+
lines.append(current)
|
|
183
|
+
return lines or [""]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def markdown_to_formatted_text(content: str, width: int) -> StyleAndTextTuples:
|
|
187
|
+
"""Render Markdown through Rich and preserve its ANSI styling in the TUI."""
|
|
188
|
+
|
|
189
|
+
stream = io.StringIO()
|
|
190
|
+
Console(
|
|
191
|
+
file=stream,
|
|
192
|
+
force_terminal=True,
|
|
193
|
+
no_color=True,
|
|
194
|
+
width=max(20, width),
|
|
195
|
+
highlight=False,
|
|
196
|
+
).print(Markdown(content, code_theme="ansi_dark"))
|
|
197
|
+
return list(to_formatted_text(ANSI(stream.getvalue().rstrip("\n"))))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TuiOutput(io.TextIOBase):
|
|
201
|
+
"""Redirect console output such as slash-command results into the transcript."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, transcript: Transcript) -> None:
|
|
204
|
+
self.transcript = transcript
|
|
205
|
+
self._buffer = ""
|
|
206
|
+
|
|
207
|
+
def write(self, data: str) -> int:
|
|
208
|
+
if not data:
|
|
209
|
+
return 0
|
|
210
|
+
self._buffer += _ANSI_ESCAPE.sub("", data).replace("\r", "")
|
|
211
|
+
while "\n" in self._buffer:
|
|
212
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
213
|
+
self.transcript.add_system(line)
|
|
214
|
+
return len(data)
|
|
215
|
+
|
|
216
|
+
def flush(self) -> None:
|
|
217
|
+
if self._buffer:
|
|
218
|
+
self.transcript.add_system(self._buffer)
|
|
219
|
+
self._buffer = ""
|
|
220
|
+
|
|
221
|
+
def isatty(self) -> bool:
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _TranscriptControl(FormattedTextControl):
|
|
226
|
+
"""A viewport control with explicit scroll offsets, independent of cursor."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, tui: "MindcodeTui") -> None:
|
|
229
|
+
self._tui = tui
|
|
230
|
+
super().__init__(text=tui._render_transcript_view, focusable=False)
|
|
231
|
+
|
|
232
|
+
def mouse_handler(self, mouse_event: Any) -> Any:
|
|
233
|
+
if mouse_event.event_type == MouseEventType.SCROLL_UP:
|
|
234
|
+
self._tui._scroll_transcript(-3)
|
|
235
|
+
return None
|
|
236
|
+
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
|
237
|
+
self._tui._scroll_transcript(3)
|
|
238
|
+
return None
|
|
239
|
+
if mouse_event.button == MouseButton.LEFT:
|
|
240
|
+
if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
|
|
241
|
+
self._tui._start_selection(
|
|
242
|
+
mouse_event.position.y, mouse_event.position.x
|
|
243
|
+
)
|
|
244
|
+
return None
|
|
245
|
+
if mouse_event.event_type == MouseEventType.MOUSE_MOVE:
|
|
246
|
+
self._tui._update_selection(
|
|
247
|
+
mouse_event.position.y, mouse_event.position.x
|
|
248
|
+
)
|
|
249
|
+
return None
|
|
250
|
+
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
251
|
+
self._tui._finish_selection(
|
|
252
|
+
mouse_event.position.y, mouse_event.position.x
|
|
253
|
+
)
|
|
254
|
+
return None
|
|
255
|
+
return super().mouse_handler(mouse_event)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class MindcodeTui:
|
|
259
|
+
"""Fullscreen frame: header, transcript, bordered composer and status bar."""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
state: Any,
|
|
264
|
+
transcript: Transcript,
|
|
265
|
+
*,
|
|
266
|
+
on_submit: Callable[[str], Awaitable[None]],
|
|
267
|
+
on_exit: Callable[[], Awaitable[None]],
|
|
268
|
+
status_provider: Callable[[], str],
|
|
269
|
+
) -> None:
|
|
270
|
+
self.state = state
|
|
271
|
+
self.transcript = transcript
|
|
272
|
+
self.on_submit = on_submit
|
|
273
|
+
self.on_exit = on_exit
|
|
274
|
+
self.status_provider = status_provider
|
|
275
|
+
self._follow_transcript = True
|
|
276
|
+
self._scroll_offset = 0
|
|
277
|
+
self._active_dialog: Float | None = None
|
|
278
|
+
self._working = False
|
|
279
|
+
self._selection_start: tuple[int, int] | None = None
|
|
280
|
+
self._copy_notice: tuple[str, float] | None = None
|
|
281
|
+
self._layout_cache: tuple[int, int, int, list[StyleAndTextTuples]] | None = (
|
|
282
|
+
None
|
|
283
|
+
)
|
|
284
|
+
self._layout_dirty = False
|
|
285
|
+
|
|
286
|
+
self.buffer = Buffer(
|
|
287
|
+
multiline=True,
|
|
288
|
+
completer=getattr(state, "completer", None),
|
|
289
|
+
complete_while_typing=True,
|
|
290
|
+
)
|
|
291
|
+
self._transcript_window = Window(
|
|
292
|
+
_TranscriptControl(self),
|
|
293
|
+
wrap_lines=False,
|
|
294
|
+
always_hide_cursor=True,
|
|
295
|
+
style="class:transcript",
|
|
296
|
+
)
|
|
297
|
+
self._composer_body = Window(
|
|
298
|
+
BufferControl(
|
|
299
|
+
buffer=self.buffer,
|
|
300
|
+
focusable=True,
|
|
301
|
+
focus_on_click=True,
|
|
302
|
+
input_processors=[
|
|
303
|
+
BeforeInput(
|
|
304
|
+
COMPOSER_PREFIX,
|
|
305
|
+
style="class:composer.hint",
|
|
306
|
+
),
|
|
307
|
+
ConditionalProcessor(
|
|
308
|
+
AfterInput(
|
|
309
|
+
COMPOSER_HINT,
|
|
310
|
+
style="class:composer.hint",
|
|
311
|
+
),
|
|
312
|
+
filter=Condition(lambda: not self.buffer.text),
|
|
313
|
+
)
|
|
314
|
+
],
|
|
315
|
+
),
|
|
316
|
+
height=Dimension(min=1, max=1, preferred=1),
|
|
317
|
+
wrap_lines=True,
|
|
318
|
+
style="class:composer.body",
|
|
319
|
+
)
|
|
320
|
+
self._composer = Frame(
|
|
321
|
+
self._composer_body,
|
|
322
|
+
height=Dimension(min=3, max=3, preferred=3),
|
|
323
|
+
style="class:composer",
|
|
324
|
+
)
|
|
325
|
+
header = Frame(
|
|
326
|
+
Window(
|
|
327
|
+
FormattedTextControl(self._render_header),
|
|
328
|
+
height=2,
|
|
329
|
+
style="class:header.body",
|
|
330
|
+
),
|
|
331
|
+
height=4,
|
|
332
|
+
style="class:header",
|
|
333
|
+
)
|
|
334
|
+
status = Window(
|
|
335
|
+
FormattedTextControl(self._render_status),
|
|
336
|
+
height=1,
|
|
337
|
+
style="class:status",
|
|
338
|
+
)
|
|
339
|
+
self._root = FloatContainer(
|
|
340
|
+
HSplit([header, self._transcript_window, self._composer, status]),
|
|
341
|
+
floats=[
|
|
342
|
+
Float(
|
|
343
|
+
xcursor=True,
|
|
344
|
+
ycursor=True,
|
|
345
|
+
content=self._build_completion_popup(),
|
|
346
|
+
)
|
|
347
|
+
],
|
|
348
|
+
)
|
|
349
|
+
self.app = Application(
|
|
350
|
+
layout=Layout(self._root, focused_element=self._composer_body),
|
|
351
|
+
key_bindings=self._build_key_bindings(),
|
|
352
|
+
full_screen=True,
|
|
353
|
+
mouse_support=True,
|
|
354
|
+
style=_STYLE,
|
|
355
|
+
refresh_interval=0.25,
|
|
356
|
+
terminal_size_polling_interval=0.25,
|
|
357
|
+
)
|
|
358
|
+
self.transcript.on_change = self._on_transcript_changed
|
|
359
|
+
self.buffer.on_text_changed += lambda _: self._sync_composer_height()
|
|
360
|
+
|
|
361
|
+
async def run(self) -> None:
|
|
362
|
+
await self.app.run_async()
|
|
363
|
+
|
|
364
|
+
def exit(self) -> None:
|
|
365
|
+
if self.app.is_running:
|
|
366
|
+
self.app.exit()
|
|
367
|
+
|
|
368
|
+
def add_user_message(self, content: str) -> None:
|
|
369
|
+
self.transcript.add_user(content)
|
|
370
|
+
|
|
371
|
+
def set_working(self, working: bool) -> None:
|
|
372
|
+
"""Show a transient activity row while an agent turn is running."""
|
|
373
|
+
if self._working == working:
|
|
374
|
+
return
|
|
375
|
+
self._working = working
|
|
376
|
+
if self._follow_transcript:
|
|
377
|
+
self._scroll_offset = self._bottom_scroll_offset()
|
|
378
|
+
self.app.invalidate()
|
|
379
|
+
|
|
380
|
+
async def choose(
|
|
381
|
+
self,
|
|
382
|
+
title: str,
|
|
383
|
+
options: list[tuple[str, str]],
|
|
384
|
+
default: str | None,
|
|
385
|
+
) -> str | None:
|
|
386
|
+
"""Show the existing slash-command picker inside the current TUI."""
|
|
387
|
+
|
|
388
|
+
if not self.app.is_running:
|
|
389
|
+
return None
|
|
390
|
+
loop = asyncio.get_running_loop()
|
|
391
|
+
result: asyncio.Future[str | None] = loop.create_future()
|
|
392
|
+
choices = RadioList(
|
|
393
|
+
options,
|
|
394
|
+
default=default,
|
|
395
|
+
open_character="",
|
|
396
|
+
select_character="•",
|
|
397
|
+
close_character="",
|
|
398
|
+
container_style="class:popup.list",
|
|
399
|
+
default_style="class:popup.option",
|
|
400
|
+
selected_style="class:popup.option.current",
|
|
401
|
+
checked_style="class:popup.option.checked",
|
|
402
|
+
select_on_focus=True,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def close(value: str | None) -> None:
|
|
406
|
+
if not result.done():
|
|
407
|
+
result.set_result(value)
|
|
408
|
+
|
|
409
|
+
popup_bindings = KeyBindings()
|
|
410
|
+
|
|
411
|
+
@popup_bindings.add("enter", eager=True)
|
|
412
|
+
def select_option(event) -> None:
|
|
413
|
+
close(choices.current_value)
|
|
414
|
+
|
|
415
|
+
@popup_bindings.add("escape", eager=True)
|
|
416
|
+
def cancel_option(event) -> None:
|
|
417
|
+
close(None)
|
|
418
|
+
|
|
419
|
+
dialog = Frame(
|
|
420
|
+
HSplit(
|
|
421
|
+
[
|
|
422
|
+
Window(
|
|
423
|
+
FormattedTextControl(
|
|
424
|
+
[("class:popup.title", f" {title}")]
|
|
425
|
+
),
|
|
426
|
+
height=1,
|
|
427
|
+
style="class:popup.title",
|
|
428
|
+
),
|
|
429
|
+
Box(choices, padding=1),
|
|
430
|
+
Window(
|
|
431
|
+
FormattedTextControl(
|
|
432
|
+
[("class:popup.hint", " ↑↓ Select · Enter Apply · Esc Cancel")]
|
|
433
|
+
),
|
|
434
|
+
height=1,
|
|
435
|
+
style="class:popup.hint",
|
|
436
|
+
),
|
|
437
|
+
],
|
|
438
|
+
style="class:popup.body",
|
|
439
|
+
),
|
|
440
|
+
width=Dimension(preferred=58, max=72),
|
|
441
|
+
style="class:popup",
|
|
442
|
+
key_bindings=popup_bindings,
|
|
443
|
+
modal=True,
|
|
444
|
+
)
|
|
445
|
+
floating = Float(content=dialog)
|
|
446
|
+
self._active_dialog = floating
|
|
447
|
+
self._root.floats.append(floating)
|
|
448
|
+
self.app.layout.focus(choices)
|
|
449
|
+
self.app.invalidate()
|
|
450
|
+
try:
|
|
451
|
+
return await result
|
|
452
|
+
finally:
|
|
453
|
+
if floating in self._root.floats:
|
|
454
|
+
self._root.floats.remove(floating)
|
|
455
|
+
self._active_dialog = None
|
|
456
|
+
self.app.layout.focus(self._composer_body)
|
|
457
|
+
self.app.invalidate()
|
|
458
|
+
|
|
459
|
+
def _build_completion_popup(self) -> ConditionalContainer:
|
|
460
|
+
"""The shared, compact popup used for slash commands and arguments."""
|
|
461
|
+
return ConditionalContainer(
|
|
462
|
+
Frame(
|
|
463
|
+
HSplit(
|
|
464
|
+
[
|
|
465
|
+
Window(
|
|
466
|
+
FormattedTextControl(
|
|
467
|
+
[("class:popup.title", " Suggestions")]
|
|
468
|
+
),
|
|
469
|
+
height=1,
|
|
470
|
+
style="class:popup.title",
|
|
471
|
+
),
|
|
472
|
+
CompletionsMenu(max_height=8, scroll_offset=1),
|
|
473
|
+
Window(
|
|
474
|
+
FormattedTextControl(
|
|
475
|
+
[("class:popup.hint", " ↑↓ Select · Enter Insert")]
|
|
476
|
+
),
|
|
477
|
+
height=1,
|
|
478
|
+
style="class:popup.hint",
|
|
479
|
+
),
|
|
480
|
+
],
|
|
481
|
+
style="class:popup.body",
|
|
482
|
+
),
|
|
483
|
+
width=Dimension(preferred=48, max=72),
|
|
484
|
+
style="class:popup",
|
|
485
|
+
),
|
|
486
|
+
filter=has_completions & ~is_done,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def _build_key_bindings(self) -> KeyBindings:
|
|
490
|
+
bindings = KeyBindings()
|
|
491
|
+
|
|
492
|
+
@bindings.add(
|
|
493
|
+
"enter", filter=Condition(lambda: self._active_dialog is None)
|
|
494
|
+
)
|
|
495
|
+
def submit(event) -> None:
|
|
496
|
+
if self._accept_completion():
|
|
497
|
+
event.app.invalidate()
|
|
498
|
+
return
|
|
499
|
+
text = self.buffer.text.strip()
|
|
500
|
+
if not text:
|
|
501
|
+
return
|
|
502
|
+
self.buffer.reset()
|
|
503
|
+
self.app.create_background_task(self.on_submit(text))
|
|
504
|
+
|
|
505
|
+
@bindings.add(
|
|
506
|
+
"up", filter=Condition(self._completion_is_visible), eager=True
|
|
507
|
+
)
|
|
508
|
+
def completion_previous(event) -> None:
|
|
509
|
+
self.buffer.complete_previous(disable_wrap_around=True)
|
|
510
|
+
|
|
511
|
+
@bindings.add(
|
|
512
|
+
"down", filter=Condition(self._completion_is_visible), eager=True
|
|
513
|
+
)
|
|
514
|
+
def completion_next(event) -> None:
|
|
515
|
+
self.buffer.complete_next(disable_wrap_around=True)
|
|
516
|
+
|
|
517
|
+
@bindings.add(
|
|
518
|
+
"escape", "enter",
|
|
519
|
+
filter=Condition(lambda: self._active_dialog is None),
|
|
520
|
+
)
|
|
521
|
+
def insert_newline(event) -> None:
|
|
522
|
+
event.current_buffer.insert_text("\n")
|
|
523
|
+
|
|
524
|
+
@bindings.add(
|
|
525
|
+
"c-d", filter=Condition(lambda: self._active_dialog is None)
|
|
526
|
+
)
|
|
527
|
+
def exit_shell(event) -> None:
|
|
528
|
+
self.app.create_background_task(self.on_exit())
|
|
529
|
+
|
|
530
|
+
@bindings.add(
|
|
531
|
+
"c-c", filter=Condition(lambda: self._active_dialog is None)
|
|
532
|
+
)
|
|
533
|
+
def interrupt_shell(event) -> None:
|
|
534
|
+
self.app.create_background_task(self.on_exit())
|
|
535
|
+
|
|
536
|
+
@bindings.add(
|
|
537
|
+
"c-y", filter=Condition(lambda: self._active_dialog is None)
|
|
538
|
+
)
|
|
539
|
+
def copy_last_assistant(event) -> None:
|
|
540
|
+
latest = next(
|
|
541
|
+
(
|
|
542
|
+
entry.content
|
|
543
|
+
for entry in reversed(self.transcript.entries)
|
|
544
|
+
if entry.role == "assistant"
|
|
545
|
+
),
|
|
546
|
+
"",
|
|
547
|
+
)
|
|
548
|
+
if latest:
|
|
549
|
+
self._copy_to_clipboard(latest, "已复制最新回复")
|
|
550
|
+
else:
|
|
551
|
+
self._set_copy_notice("暂无可复制的助手回复")
|
|
552
|
+
|
|
553
|
+
@bindings.add("pageup", eager=True)
|
|
554
|
+
def page_up(event) -> None:
|
|
555
|
+
self._scroll_transcript(-max(1, event.app.output.get_size().rows - 6))
|
|
556
|
+
|
|
557
|
+
@bindings.add("pagedown", eager=True)
|
|
558
|
+
def page_down(event) -> None:
|
|
559
|
+
self._scroll_transcript(max(1, event.app.output.get_size().rows - 6))
|
|
560
|
+
|
|
561
|
+
@bindings.add("end", eager=True)
|
|
562
|
+
def follow(event) -> None:
|
|
563
|
+
self._follow_transcript = True
|
|
564
|
+
self._scroll_offset = self._bottom_scroll_offset()
|
|
565
|
+
self.app.invalidate()
|
|
566
|
+
|
|
567
|
+
return bindings
|
|
568
|
+
|
|
569
|
+
def _scroll_transcript(self, amount: int) -> None:
|
|
570
|
+
bottom = self._bottom_scroll_offset()
|
|
571
|
+
self._scroll_offset = min(
|
|
572
|
+
bottom, max(0, self._scroll_offset + amount)
|
|
573
|
+
)
|
|
574
|
+
self._follow_transcript = self._scroll_offset >= bottom
|
|
575
|
+
self.app.invalidate()
|
|
576
|
+
|
|
577
|
+
def _completion_is_visible(self) -> bool:
|
|
578
|
+
state = self.buffer.complete_state
|
|
579
|
+
return state is not None and bool(state.completions)
|
|
580
|
+
|
|
581
|
+
def _accept_completion(self) -> bool:
|
|
582
|
+
state = self.buffer.complete_state
|
|
583
|
+
if state is None or not state.completions:
|
|
584
|
+
return False
|
|
585
|
+
if state.current_completion is None:
|
|
586
|
+
self.buffer.go_to_completion(0)
|
|
587
|
+
state = self.buffer.complete_state
|
|
588
|
+
assert state is not None and state.current_completion is not None
|
|
589
|
+
self.buffer.apply_completion(state.current_completion)
|
|
590
|
+
return True
|
|
591
|
+
|
|
592
|
+
def _on_transcript_changed(self) -> None:
|
|
593
|
+
if self._follow_transcript:
|
|
594
|
+
self._scroll_offset = self._bottom_scroll_offset()
|
|
595
|
+
self.app.invalidate()
|
|
596
|
+
|
|
597
|
+
def _render_header(self) -> StyleAndTextTuples:
|
|
598
|
+
workspace = str(getattr(self.state, "workspace", ""))
|
|
599
|
+
model = getattr(self.state, "active_model", "") or "未选择模型"
|
|
600
|
+
provider = getattr(self.state, "active_provider", "") or "未选择 provider"
|
|
601
|
+
session_id = getattr(self.state, "session_id", "")
|
|
602
|
+
renderer = getattr(self.state, "renderer", None)
|
|
603
|
+
token_display = _format_token_count(
|
|
604
|
+
getattr(renderer, "total_tokens", None)
|
|
605
|
+
)
|
|
606
|
+
return [
|
|
607
|
+
("class:header.logo", " MINDCODE "),
|
|
608
|
+
("class:header.detail", f"{provider}/{model} · {token_display}\n"),
|
|
609
|
+
(
|
|
610
|
+
"class:header.detail",
|
|
611
|
+
f" {workspace} · session {session_id}",
|
|
612
|
+
),
|
|
613
|
+
]
|
|
614
|
+
|
|
615
|
+
def _render_status(self) -> StyleAndTextTuples:
|
|
616
|
+
status = self.status_provider()
|
|
617
|
+
if self._copy_notice is not None:
|
|
618
|
+
message, expires_at = self._copy_notice
|
|
619
|
+
if time.monotonic() < expires_at:
|
|
620
|
+
status += f" · {message}"
|
|
621
|
+
else:
|
|
622
|
+
self._copy_notice = None
|
|
623
|
+
return [("class:status", " " + status)]
|
|
624
|
+
|
|
625
|
+
def _render_transcript_view(self) -> StyleAndTextTuples:
|
|
626
|
+
lines = self._transcript_visual_lines()
|
|
627
|
+
height = self._transcript_height()
|
|
628
|
+
bottom = max(0, len(lines) - height)
|
|
629
|
+
if self._follow_transcript:
|
|
630
|
+
self._scroll_offset = bottom
|
|
631
|
+
else:
|
|
632
|
+
self._scroll_offset = min(self._scroll_offset, bottom)
|
|
633
|
+
visible = lines[self._scroll_offset : self._scroll_offset + height]
|
|
634
|
+
rendered: StyleAndTextTuples = []
|
|
635
|
+
for line in visible:
|
|
636
|
+
rendered.extend(line)
|
|
637
|
+
rendered.append(("", "\n"))
|
|
638
|
+
return rendered
|
|
639
|
+
|
|
640
|
+
def _transcript_visual_lines(self) -> list[StyleAndTextTuples]:
|
|
641
|
+
width = max(24, self.app.output.get_size().columns - 2)
|
|
642
|
+
cache_key = (self.transcript.version, width, self._working)
|
|
643
|
+
if (
|
|
644
|
+
not self._layout_dirty
|
|
645
|
+
and self._layout_cache is not None
|
|
646
|
+
and self._layout_cache[:3] == cache_key
|
|
647
|
+
):
|
|
648
|
+
return self._layout_cache[3]
|
|
649
|
+
self._layout_dirty = False
|
|
650
|
+
lines = _wrap_formatted_text(self._render_transcript_content(width), width)
|
|
651
|
+
self._layout_cache = (*cache_key, lines)
|
|
652
|
+
return lines
|
|
653
|
+
|
|
654
|
+
def _render_transcript_content(self, width: int) -> StyleAndTextTuples:
|
|
655
|
+
rendered: StyleAndTextTuples = []
|
|
656
|
+
for entry in self.transcript.entries:
|
|
657
|
+
if rendered:
|
|
658
|
+
rendered.append(("", "\n\n"))
|
|
659
|
+
if entry.role == "user":
|
|
660
|
+
for line in user_card_lines(entry.content, width):
|
|
661
|
+
rendered.append(("class:user.border", line[0]))
|
|
662
|
+
rendered.append(("class:user.message", line[1:-1]))
|
|
663
|
+
rendered.append(("class:user.border", line[-1:]))
|
|
664
|
+
rendered.append(("", "\n"))
|
|
665
|
+
rendered.pop()
|
|
666
|
+
elif entry.role == "assistant":
|
|
667
|
+
rendered.extend(
|
|
668
|
+
_assistant_reply_formatted_text(
|
|
669
|
+
markdown_to_formatted_text(entry.content, width - 4),
|
|
670
|
+
)
|
|
671
|
+
)
|
|
672
|
+
if entry.streaming:
|
|
673
|
+
rendered.append(("class:streaming", " ▍"))
|
|
674
|
+
elif entry.role == "tool":
|
|
675
|
+
symbol, style = {
|
|
676
|
+
"running": ("●", "class:tool.running"),
|
|
677
|
+
"ok": ("✓", "class:tool.ok"),
|
|
678
|
+
"error": ("✗", "class:tool.error"),
|
|
679
|
+
}.get(entry.status, ("•", "class:tool.running"))
|
|
680
|
+
rendered.append((style, f" {symbol} {entry.label}"))
|
|
681
|
+
if entry.content:
|
|
682
|
+
rendered.append(("class:tool.detail", f" {entry.content}"))
|
|
683
|
+
elif entry.role == "tool_detail":
|
|
684
|
+
rendered.extend(markdown_to_formatted_text(entry.content, width))
|
|
685
|
+
elif entry.role == "thinking":
|
|
686
|
+
rendered.append(("class:thinking.label", " Thinking"))
|
|
687
|
+
rendered.append(("class:thinking.detail", f" {entry.content}"))
|
|
688
|
+
else:
|
|
689
|
+
rendered.append(("class:system", entry.content))
|
|
690
|
+
if self._working:
|
|
691
|
+
if rendered:
|
|
692
|
+
rendered.append(("", "\n\n"))
|
|
693
|
+
rendered.append(("class:working", " ● Working…"))
|
|
694
|
+
return rendered or [("class:empty", "\n 发送消息开始对话。")]
|
|
695
|
+
|
|
696
|
+
def _transcript_height(self) -> int:
|
|
697
|
+
terminal_rows = self.app.output.get_size().rows
|
|
698
|
+
return max(1, terminal_rows - self._composer_height() - 7)
|
|
699
|
+
|
|
700
|
+
def _bottom_scroll_offset(self) -> int:
|
|
701
|
+
total_lines = len(self._transcript_visual_lines())
|
|
702
|
+
return max(0, total_lines - self._transcript_height())
|
|
703
|
+
|
|
704
|
+
def _start_selection(self, row: int, column: int) -> None:
|
|
705
|
+
self._selection_start = (self._scroll_offset + max(0, row), max(0, column))
|
|
706
|
+
|
|
707
|
+
def _update_selection(self, row: int, column: int) -> None:
|
|
708
|
+
if self._selection_start is not None:
|
|
709
|
+
self.app.invalidate()
|
|
710
|
+
|
|
711
|
+
def _finish_selection(self, row: int, column: int) -> None:
|
|
712
|
+
start = self._selection_start
|
|
713
|
+
self._selection_start = None
|
|
714
|
+
if start is None:
|
|
715
|
+
return
|
|
716
|
+
end = (self._scroll_offset + max(0, row), max(0, column))
|
|
717
|
+
if start == end:
|
|
718
|
+
return
|
|
719
|
+
selected = _selection_text(self._transcript_visual_lines(), start, end)
|
|
720
|
+
if selected:
|
|
721
|
+
self._copy_to_clipboard(selected, "已复制所选文本")
|
|
722
|
+
|
|
723
|
+
def _copy_to_clipboard(self, text: str, notice: str) -> None:
|
|
724
|
+
self.app.clipboard.set_text(text)
|
|
725
|
+
if self.app.is_running:
|
|
726
|
+
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
727
|
+
self.app.output.write_raw(f"\x1b]52;c;{encoded}\a")
|
|
728
|
+
self.app.output.flush()
|
|
729
|
+
self._set_copy_notice(notice)
|
|
730
|
+
|
|
731
|
+
def _set_copy_notice(self, message: str) -> None:
|
|
732
|
+
self._copy_notice = (message, time.monotonic() + 2)
|
|
733
|
+
self.app.invalidate()
|
|
734
|
+
|
|
735
|
+
def _composer_height(self) -> int:
|
|
736
|
+
width = max(20, self.app.output.get_size().columns - 6)
|
|
737
|
+
rows = len(_wrap_display_text(self.buffer.text or "", width))
|
|
738
|
+
return min(6, max(1, rows))
|
|
739
|
+
|
|
740
|
+
def _sync_composer_height(self) -> None:
|
|
741
|
+
height = self._composer_height()
|
|
742
|
+
self._composer_body.height = Dimension(
|
|
743
|
+
min=height, max=height, preferred=height
|
|
744
|
+
)
|
|
745
|
+
outer_height = height + 2
|
|
746
|
+
self._composer.height = Dimension(
|
|
747
|
+
min=outer_height, max=outer_height, preferred=outer_height
|
|
748
|
+
)
|
|
749
|
+
self._layout_dirty = True
|
|
750
|
+
self.app.invalidate()
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
_STYLE = Style.from_dict(
|
|
754
|
+
{
|
|
755
|
+
"header": "bg:#1b1b1b",
|
|
756
|
+
"header.body": "bg:#1b1b1b #d0d0d0",
|
|
757
|
+
"header.logo": "bold #e2e2e2",
|
|
758
|
+
"header.detail": "#b8b8b8",
|
|
759
|
+
"transcript": "bg:#121212 #d6d6d6",
|
|
760
|
+
"composer": "bg:#1b1b1b",
|
|
761
|
+
"composer.body": "bg:#1b1b1b #e6e6e6",
|
|
762
|
+
"composer.hint": "#858585",
|
|
763
|
+
"frame.border": "#727272",
|
|
764
|
+
"frame.label": "bold #d0d0d0",
|
|
765
|
+
"status": "bg:#244766 #e5f0f8",
|
|
766
|
+
"popup": "bg:#202020 #d7d7d7",
|
|
767
|
+
"popup.body": "bg:#202020 #d7d7d7",
|
|
768
|
+
"popup.title": "bold bg:#202020 #e3e3e3",
|
|
769
|
+
"popup.hint": "bg:#202020 #969696",
|
|
770
|
+
"popup.list": "bg:#202020 #d7d7d7",
|
|
771
|
+
"popup.option": "bg:#202020 #d7d7d7",
|
|
772
|
+
"popup.option.current": "bg:#5a5a5a #ffffff",
|
|
773
|
+
"popup.option.checked": "#cfcfcf",
|
|
774
|
+
"completion-menu": "bg:#202020 #d7d7d7",
|
|
775
|
+
"completion-menu.completion": "bg:#202020 #d7d7d7",
|
|
776
|
+
"completion-menu.completion.current": "bg:#5a5a5a #ffffff",
|
|
777
|
+
"completion-menu.meta.completion": "bg:#202020 #9d9d9d",
|
|
778
|
+
"completion-menu.meta.completion.current": "bg:#5a5a5a #f0f0f0",
|
|
779
|
+
"shadow": "bg:#0b0b0b",
|
|
780
|
+
"user.border": "#858585",
|
|
781
|
+
"user.message": "#e6e6e6",
|
|
782
|
+
"assistant.marker": "#bdbdbd",
|
|
783
|
+
"system": "#9c9c9c",
|
|
784
|
+
"tool.running": "bold #bcbcbc",
|
|
785
|
+
"tool.ok": "bold #d0d0d0",
|
|
786
|
+
"tool.error": "bold #b3b3b3",
|
|
787
|
+
"tool.detail": "#ababab",
|
|
788
|
+
"thinking.label": "bold #c9c9c9",
|
|
789
|
+
"thinking.detail": "italic #acacac",
|
|
790
|
+
"working": "bold #d0d0d0",
|
|
791
|
+
"streaming": "#c8c8c8",
|
|
792
|
+
"empty": "#888888",
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@contextlib.contextmanager
|
|
798
|
+
def redirect_console_output(transcript: Transcript):
|
|
799
|
+
"""Route the shared Rich console's stdout proxy into the TUI transcript."""
|
|
800
|
+
|
|
801
|
+
output = TuiOutput(transcript)
|
|
802
|
+
with contextlib.redirect_stdout(output):
|
|
803
|
+
yield
|
|
804
|
+
output.flush()
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _wrap_formatted_text(
|
|
808
|
+
fragments: StyleAndTextTuples,
|
|
809
|
+
width: int,
|
|
810
|
+
) -> list[StyleAndTextTuples]:
|
|
811
|
+
"""Split rendered Rich fragments into terminal-width visual lines."""
|
|
812
|
+
|
|
813
|
+
lines: list[StyleAndTextTuples] = [[]]
|
|
814
|
+
line_width = 0
|
|
815
|
+
for style, text, *_ in fragments:
|
|
816
|
+
for char in text:
|
|
817
|
+
if char == "\n":
|
|
818
|
+
lines.append([])
|
|
819
|
+
line_width = 0
|
|
820
|
+
continue
|
|
821
|
+
char_width = get_cwidth(char)
|
|
822
|
+
if line_width + char_width > width and lines[-1]:
|
|
823
|
+
lines.append([])
|
|
824
|
+
line_width = 0
|
|
825
|
+
_append_fragment(lines[-1], style, char)
|
|
826
|
+
line_width += char_width
|
|
827
|
+
return lines
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _append_fragment(
|
|
831
|
+
line: StyleAndTextTuples,
|
|
832
|
+
style: str,
|
|
833
|
+
text: str,
|
|
834
|
+
) -> None:
|
|
835
|
+
if line and line[-1][0] == style:
|
|
836
|
+
previous_style, previous_text, *rest = line[-1]
|
|
837
|
+
line[-1] = (previous_style, previous_text + text, *rest)
|
|
838
|
+
else:
|
|
839
|
+
line.append((style, text))
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _assistant_reply_formatted_text(
|
|
843
|
+
fragments: StyleAndTextTuples,
|
|
844
|
+
) -> StyleAndTextTuples:
|
|
845
|
+
"""Prefix assistant replies with a bullet and align continuation lines."""
|
|
846
|
+
|
|
847
|
+
indented: StyleAndTextTuples = [("class:assistant.marker", " • ")]
|
|
848
|
+
for style, text, *_ in fragments:
|
|
849
|
+
parts = text.splitlines(keepends=True)
|
|
850
|
+
for part in parts:
|
|
851
|
+
indented.append((style, part))
|
|
852
|
+
if part.endswith("\n"):
|
|
853
|
+
indented.append(("", " "))
|
|
854
|
+
return indented
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _selection_text(
|
|
858
|
+
lines: list[StyleAndTextTuples],
|
|
859
|
+
start: tuple[int, int],
|
|
860
|
+
end: tuple[int, int],
|
|
861
|
+
) -> str:
|
|
862
|
+
"""Extract an inclusive terminal-cell selection from rendered lines."""
|
|
863
|
+
|
|
864
|
+
if end < start:
|
|
865
|
+
start, end = end, start
|
|
866
|
+
start_row, start_column = start
|
|
867
|
+
end_row, end_column = end
|
|
868
|
+
selected: list[str] = []
|
|
869
|
+
for row in range(start_row, min(end_row + 1, len(lines))):
|
|
870
|
+
text = "".join(fragment[1] for fragment in lines[row])
|
|
871
|
+
left = start_column if row == start_row else 0
|
|
872
|
+
right = end_column + 1 if row == end_row else get_cwidth(text)
|
|
873
|
+
selected.append(_slice_terminal_cells(text, left, right).rstrip())
|
|
874
|
+
return "\n".join(selected).strip("\n")
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _slice_terminal_cells(text: str, left: int, right: int) -> str:
|
|
878
|
+
result: list[str] = []
|
|
879
|
+
offset = 0
|
|
880
|
+
for char in text:
|
|
881
|
+
char_width = get_cwidth(char)
|
|
882
|
+
if offset + char_width > left and offset < right:
|
|
883
|
+
result.append(char)
|
|
884
|
+
offset += char_width
|
|
885
|
+
if offset >= right:
|
|
886
|
+
break
|
|
887
|
+
return "".join(result)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _format_token_count(value: int | None) -> str:
|
|
891
|
+
if value is None:
|
|
892
|
+
return "tokens —"
|
|
893
|
+
if value < 1_000:
|
|
894
|
+
return f"tokens {value}"
|
|
895
|
+
if value < 1_000_000:
|
|
896
|
+
return f"tokens {value / 1_000:.1f}k"
|
|
897
|
+
return f"tokens {value / 1_000_000:.1f}M"
|