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.
Files changed (116) hide show
  1. mindcode/__init__.py +5 -0
  2. mindcode/__main__.py +4 -0
  3. mindcode/__pycache__/__init__.cpython-313.pyc +0 -0
  4. mindcode/__pycache__/__main__.cpython-313.pyc +0 -0
  5. mindcode/__pycache__/_version.cpython-313.pyc +0 -0
  6. mindcode/__pycache__/config.cpython-313.pyc +0 -0
  7. mindcode/__pycache__/policy.cpython-313.pyc +0 -0
  8. mindcode/__pycache__/remote.cpython-313.pyc +0 -0
  9. mindcode/__pycache__/render.cpython-313.pyc +0 -0
  10. mindcode/__pycache__/runtime.cpython-313.pyc +0 -0
  11. mindcode/__pycache__/tasking.cpython-313.pyc +0 -0
  12. mindcode/__pycache__/terminal_core.cpython-313.pyc +0 -0
  13. mindcode/_version.py +11 -0
  14. mindcode/approval.py +105 -0
  15. mindcode/bench/__init__.py +19 -0
  16. mindcode/bench/__pycache__/__init__.cpython-313.pyc +0 -0
  17. mindcode/bench/__pycache__/fake_tools.cpython-313.pyc +0 -0
  18. mindcode/bench/__pycache__/jsonutil.cpython-313.pyc +0 -0
  19. mindcode/bench/__pycache__/paths.cpython-313.pyc +0 -0
  20. mindcode/bench/__pycache__/predictions.cpython-313.pyc +0 -0
  21. mindcode/bench/__pycache__/report.cpython-313.pyc +0 -0
  22. mindcode/bench/__pycache__/runner.cpython-313.pyc +0 -0
  23. mindcode/bench/__pycache__/schema.cpython-313.pyc +0 -0
  24. mindcode/bench/__pycache__/trace.cpython-313.pyc +0 -0
  25. mindcode/bench/__pycache__/workspace.cpython-313.pyc +0 -0
  26. mindcode/bench/adapters/__init__.py +22 -0
  27. mindcode/bench/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
  28. mindcode/bench/adapters/__pycache__/base.cpython-313.pyc +0 -0
  29. mindcode/bench/adapters/__pycache__/mini_bfcl.cpython-313.pyc +0 -0
  30. mindcode/bench/adapters/__pycache__/mini_gaia.cpython-313.pyc +0 -0
  31. mindcode/bench/adapters/__pycache__/mini_terminal.cpython-313.pyc +0 -0
  32. mindcode/bench/adapters/__pycache__/swe_bench.cpython-313.pyc +0 -0
  33. mindcode/bench/adapters/__pycache__/terminal_bench.cpython-313.pyc +0 -0
  34. mindcode/bench/adapters/base.py +45 -0
  35. mindcode/bench/adapters/mini_bfcl.py +18 -0
  36. mindcode/bench/adapters/mini_gaia.py +18 -0
  37. mindcode/bench/adapters/mini_terminal.py +18 -0
  38. mindcode/bench/adapters/swe_bench.py +86 -0
  39. mindcode/bench/adapters/terminal_bench.py +107 -0
  40. mindcode/bench/fake_tools.py +73 -0
  41. mindcode/bench/jsonutil.py +20 -0
  42. mindcode/bench/paths.py +10 -0
  43. mindcode/bench/predictions.py +65 -0
  44. mindcode/bench/report.py +48 -0
  45. mindcode/bench/runner.py +250 -0
  46. mindcode/bench/schema.py +78 -0
  47. mindcode/bench/scorers/__pycache__/base.cpython-313.pyc +0 -0
  48. mindcode/bench/scorers/__pycache__/composite.cpython-313.pyc +0 -0
  49. mindcode/bench/scorers/__pycache__/exact.cpython-313.pyc +0 -0
  50. mindcode/bench/scorers/__pycache__/json_call.cpython-313.pyc +0 -0
  51. mindcode/bench/scorers/__pycache__/swe.cpython-313.pyc +0 -0
  52. mindcode/bench/scorers/__pycache__/terminal.cpython-313.pyc +0 -0
  53. mindcode/bench/scorers/__pycache__/terminal_bench.cpython-313.pyc +0 -0
  54. mindcode/bench/scorers/base.py +22 -0
  55. mindcode/bench/scorers/composite.py +57 -0
  56. mindcode/bench/scorers/exact.py +30 -0
  57. mindcode/bench/scorers/json_call.py +44 -0
  58. mindcode/bench/scorers/swe.py +29 -0
  59. mindcode/bench/scorers/terminal.py +43 -0
  60. mindcode/bench/scorers/terminal_bench.py +25 -0
  61. mindcode/bench/trace.py +34 -0
  62. mindcode/bench/workspace.py +127 -0
  63. mindcode/cli/__init__.py +112 -0
  64. mindcode/cli/__pycache__/__init__.cpython-313.pyc +0 -0
  65. mindcode/cli/__pycache__/_shared.cpython-313.pyc +0 -0
  66. mindcode/cli/_shared.py +43 -0
  67. mindcode/cli/commands/__init__.py +1 -0
  68. mindcode/cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  69. mindcode/cli/commands/__pycache__/bench.cpython-313.pyc +0 -0
  70. mindcode/cli/commands/__pycache__/chat.cpython-313.pyc +0 -0
  71. mindcode/cli/commands/__pycache__/config_cmd.cpython-313.pyc +0 -0
  72. mindcode/cli/commands/__pycache__/remote.cpython-313.pyc +0 -0
  73. mindcode/cli/commands/__pycache__/shell.cpython-313.pyc +0 -0
  74. mindcode/cli/commands/__pycache__/status.cpython-313.pyc +0 -0
  75. mindcode/cli/commands/__pycache__/task.cpython-313.pyc +0 -0
  76. mindcode/cli/commands/__pycache__/terminal.cpython-313.pyc +0 -0
  77. mindcode/cli/commands/bench.py +365 -0
  78. mindcode/cli/commands/chat.py +86 -0
  79. mindcode/cli/commands/config_cmd.py +197 -0
  80. mindcode/cli/commands/remote.py +146 -0
  81. mindcode/cli/commands/shell.py +108 -0
  82. mindcode/cli/commands/status.py +37 -0
  83. mindcode/cli/commands/task.py +417 -0
  84. mindcode/cli/commands/terminal.py +124 -0
  85. mindcode/cli/shell/__init__.py +5 -0
  86. mindcode/cli/shell/__pycache__/__init__.cpython-313.pyc +0 -0
  87. mindcode/cli/shell/__pycache__/completion.cpython-313.pyc +0 -0
  88. mindcode/cli/shell/__pycache__/menu.cpython-313.pyc +0 -0
  89. mindcode/cli/shell/__pycache__/repl.cpython-313.pyc +0 -0
  90. mindcode/cli/shell/__pycache__/slash.cpython-313.pyc +0 -0
  91. mindcode/cli/shell/__pycache__/startup.cpython-313.pyc +0 -0
  92. mindcode/cli/shell/completion.py +72 -0
  93. mindcode/cli/shell/menu.py +88 -0
  94. mindcode/cli/shell/repl.py +597 -0
  95. mindcode/cli/shell/slash.py +480 -0
  96. mindcode/cli/shell/startup.py +55 -0
  97. mindcode/cli/shell/tui.py +897 -0
  98. mindcode/config.py +173 -0
  99. mindcode/mcp.py +205 -0
  100. mindcode/policy.py +173 -0
  101. mindcode/remote.py +216 -0
  102. mindcode/render.py +458 -0
  103. mindcode/runtime.py +541 -0
  104. mindcode/skills.py +154 -0
  105. mindcode/subagents.py +43 -0
  106. mindcode/tasking.py +281 -0
  107. mindcode/terminal/__init__.py +1 -0
  108. mindcode/terminal/__main__.py +17 -0
  109. mindcode/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
  110. mindcode/terminal/__pycache__/__main__.cpython-313.pyc +0 -0
  111. mindcode/terminal_core.py +264 -0
  112. mindcode-0.2.0.dist-info/METADATA +244 -0
  113. mindcode-0.2.0.dist-info/RECORD +116 -0
  114. mindcode-0.2.0.dist-info/WHEEL +5 -0
  115. mindcode-0.2.0.dist-info/entry_points.txt +2 -0
  116. 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"