coderio 0.4.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 (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
coderio/cli/tui.py ADDED
@@ -0,0 +1,1584 @@
1
+ """Textual-based TUI for coderio — foldable thinking, scrollable history.
2
+
3
+ Implements the StreamHandler protocol (so agent/tools/harness call it
4
+ identically to the Rich version). Each round's thinking is rendered as a
5
+ Collapsible widget — collapsed by default, expandable via Ctrl+O (toggles the
6
+ most recent) or mouse click on the title. This gives true fold/unfold, which
7
+ RichLog (append-only) could not.
8
+
9
+ The agent runs in a background thread; UI updates flow through a thread-safe
10
+ render queue drained by a main-thread timer (see _drain_render_queue).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Any
17
+
18
+ from rich.markdown import Markdown
19
+ from rich.panel import Panel
20
+ from rich.text import Text
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.containers import Horizontal, Vertical, VerticalScroll
24
+ from textual.widgets import Button, Collapsible, Input, RichLog, Static
25
+
26
+ # Single-source version (read from pyproject.toml via importlib.metadata).
27
+ from coderio import __version__ # noqa: E402
28
+
29
+ # OnboardingScreen, _OnboardingApp, and _run_onboarding_tui have been extracted
30
+ # to tui_onboarding.py for modularity.
31
+ from coderio.cli.tui_onboarding import OnboardingScreen, _run_onboarding_tui # noqa: E402
32
+
33
+ # The three modal picker screens (Profile / Mode / Session) live in tui_screens.py.
34
+ from coderio.cli.tui_screens import ( # noqa: E402
35
+ ModePickerScreen,
36
+ ProfilePickerScreen,
37
+ SessionPickerScreen,
38
+ )
39
+
40
+ # CommandMenu, ConfirmMenu, StatusBar and _TASK_PHASE_LABELS have been extracted
41
+ # to tui_widgets.py for modularity.
42
+ from coderio.cli.tui_widgets import ( # noqa: E402
43
+ CommandMenu,
44
+ ConfirmMenu,
45
+ StatusBar,
46
+ )
47
+
48
+
49
+ class CoderioTUI(App):
50
+ """coderio's Textual app. Implements StreamHandler (duck-typed)."""
51
+
52
+ CSS = """
53
+ Screen { layout: vertical; }
54
+ #history { border: round $accent; height: 1fr; min-height: 10; padding: 0 1; }
55
+ #history Static { height: auto; }
56
+ #input-bar { height: auto; dock: bottom; border-top: solid $accent; }
57
+ #input-bar Input { border: none; }
58
+ #status-row { height: auto; }
59
+ /* interrupt-btn: NO border — a border adds 2 rows to the widget's outer
60
+ height, which makes #status-row (height:auto, takes max child height)
61
+ grow from 1 to 2 rows, leaving a black gap between the status bar and
62
+ the input whenever the agent is running. Use a tinted background instead
63
+ so the button stays exactly 1 row tall, matching the StatusBar. */
64
+ #interrupt-btn {
65
+ display: none; height: 1; min-width: 8; padding: 0 1;
66
+ /* Explicitly kill the border — Button(variant="error") injects a 'tall'
67
+ border by default, which adds 2 rows to the outer height and makes
68
+ #status-row grow, leaving a black gap while the agent runs. */
69
+ border: none;
70
+ background: $error 20%;
71
+ color: $error;
72
+ text-style: bold;
73
+ }
74
+ /* interrupt-btn is shown only when the agent is running (via add_class). */
75
+ #interrupt-btn.-visible { display: block; }
76
+ /* Collapsible thinking blocks */
77
+ Collapsible { border: round $boost 50%; margin: 0 0 0 0; }
78
+ Collapsible > .collapsible__title { color: $text-muted; }
79
+ /* NOTE: do NOT define `Screen { layers }` here — it changes how Textual
80
+ renders the scrollable region (bottom rows of scrolled content stop
81
+ rendering). The CommandMenu popup uses display:none/block for show/hide
82
+ and dock:bottom for positioning, so it does NOT need layers. */
83
+ """
84
+
85
+ BINDINGS = [
86
+ Binding("ctrl+o", "toggle_thinking", "展开/收起思考", show=True),
87
+ Binding("escape", "interrupt", "中断任务", show=True),
88
+ Binding("ctrl+c", "interrupt", "中断任务", show=False),
89
+ ]
90
+
91
+ name = "textual_tui"
92
+
93
+ def __init__(self, on_input=None, show_tool_output: bool = True, banner: str | None = None) -> None:
94
+ super().__init__()
95
+ self._on_input = on_input
96
+ self.show_tool_output = show_tool_output
97
+ self._banner = banner
98
+ # StreamHandler state
99
+ self.buffer = ""
100
+ self.usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
101
+ self._round_thinking = ""
102
+ self._round_think_start = 0.0
103
+ self._last_collapsible = None # the most recent thinking Collapsible widget
104
+ # Live thinking: the Static body of the IN-PROGRESS thinking block. While
105
+ # non-None, on_thinking appends to it in real time (the user sees thinking
106
+ # stream live, not dumped all at once when it ends). Set to None when a
107
+ # round's thinking is flushed/folded.
108
+ self._live_think_body: Static | None = None
109
+ self._live_think_chars = 0 # chars shown so far (to append only the delta)
110
+ # Agent-thread-local flag: has a think_start already been queued for the
111
+ # CURRENT round? `_live_think_body` is set by the MAIN thread 60ms later
112
+ # (next _drain_render_queue tick), so reading it from the agent thread is
113
+ # a race — within one drain window many on_thinking chunks arrive before
114
+ # the main thread has had a chance to mount the Collapsible, and each one
115
+ # would otherwise queue a SEPARATE think_start, fragmenting one continuous
116
+ # thinking stream into N tiny Collapsibles ("The" / "The user is" / ...).
117
+ # This flag is owned entirely by the agent thread (set in on_thinking,
118
+ # cleared in _flush_round_thinking / on_finish), so there's no race.
119
+ self._round_think_started: bool = False
120
+ # Live output: a RichLog widget for streaming the answer as it arrives.
121
+ # RichLog is append-only — each token chunk is written once, avoiding the
122
+ # full-widget layout recompute that Static.update(Text(full_buffer)) would
123
+ # trigger on every batch. On finish, the RichLog is replaced by the final
124
+ # Markdown Panel.
125
+ self._live_output: Static | None = None
126
+ self._live_output_chars = 0
127
+ self._live_output_last_flush: float = 0.0 # throttle: only flush >=80ms apart
128
+ # Cached StatusBar reference: query_one() is a main-thread DOM query, but
129
+ # _set_phase runs on the agent's BACKGROUND thread. Cache the widget once
130
+ # (on_mount, main thread) and read the plain attribute thereafter
131
+ # (GIL-safe from any thread).
132
+ self._status_bar: StatusBar | None = None
133
+ # Interrupt support: when the user clicks "中断" or presses Esc during
134
+ # an agent turn, _interrupted is set. The agent checks it between rounds
135
+ # (via stream.is_interrupted()) and exits gracefully. The worker ref is
136
+ # held so we can also call .cancel() as a backup.
137
+ self._interrupted: bool = False
138
+ self._agent_worker = None
139
+ self._is_running: bool = False # True while the agent worker is active
140
+ # Inline confirmation state: when _confirm_event is non-None, the
141
+ # agent thread is blocked waiting for the user to allow/deny a write.
142
+ self._confirm_event = None
143
+ self._confirm_result: bool | str = False
144
+ self._confirm_custom_mode = False # True when user clicked "其他"
145
+ # RENDER QUEUE: the agent's background thread pushes render instructions
146
+ # here (thread-safe deque append/popleft). A main-thread set_interval
147
+ # timer drains the queue and executes the instructions on the main thread.
148
+ # This avoids call_from_thread, whose callbacks are not reliably delivered
149
+ # in a real terminal. The deque + timer pattern matches the StatusBar
150
+ # heartbeat approach.
151
+ import collections
152
+
153
+ self._render_q: collections.deque = collections.deque()
154
+ self._live_out_widget: RichLog | None = None # streaming output RichLog (main thread)
155
+ self._live_rendered_len: int = 0 # chars already written to the RichLog
156
+ # Dynamic TODO widget: mounted once on first write_todos, updated
157
+ # in-place on subsequent calls (Claude Code style). Reset to None on
158
+ # on_finish so the next turn gets a fresh widget.
159
+ self._todo_widget: Static | None = None
160
+
161
+ # ----------------------------------------------------- layout
162
+ def compose(self) -> ComposeResult:
163
+ from coderio.cli.commands import slash_completions
164
+
165
+ yield VerticalScroll(id="history")
166
+ # input-bar holds the CommandMenu + StatusBar + Input. CommandMenu lives
167
+ # INSIDE the bar (not as a separate dock:bottom sibling) so that when it
168
+ # expands it pushes the bar's contents up as a unit — no overlap with
169
+ # the StatusBar (which was hiding "就" in "就绪") and no lost bottom
170
+ # border. The bar is dock:bottom; #history is 1fr and shrinks to fit.
171
+ with Vertical(id="input-bar"):
172
+ yield CommandMenu(slash_completions())
173
+ with Horizontal(id="status-row"):
174
+ yield StatusBar()
175
+ yield Button("⏹ 中断", id="interrupt-btn", variant="error")
176
+ # Vertical permission-confirmation menu (zcode/codex style): floats
177
+ # above the input box, ↑↓ to choose, Enter to confirm. Replaces the
178
+ # old three-button #confirm-row whose Button borders inflated the
179
+ # layout and left a black gap.
180
+ yield ConfirmMenu()
181
+ yield Input(
182
+ placeholder="输入消息, /help 看命令, Esc 中断任务",
183
+ id="msg",
184
+ )
185
+
186
+ def on_mount(self) -> None:
187
+ self.title = "coderio"
188
+ self.sub_title = "skill-driven coding agent"
189
+ if self._banner:
190
+ self._add_static(
191
+ Panel(
192
+ self._banner,
193
+ title="[bold magenta]coderio[/bold magenta]",
194
+ border_style="magenta",
195
+ )
196
+ )
197
+ inp = self.query_one("#msg", Input)
198
+ # Wire the popup command menu to the input.
199
+ self.query_one(CommandMenu).bind_input(inp)
200
+ # Cache the StatusBar reference NOW (main thread) — _set_phase runs on
201
+ # the agent's background thread where query_one() can't run.
202
+ self._status_bar = self.query_one(StatusBar)
203
+ inp.focus()
204
+ # Render-queue drain timer: runs on the MAIN thread (set_interval), pops
205
+ # all queued render instructions and executes them. This is the only path
206
+ # from background-thread data to main-thread widgets.
207
+ self.set_interval(0.06, self._drain_render_queue)
208
+
209
+ # ----------------------------------------------------- cross-thread confirmation
210
+ def request_confirmation(self, tool_name: str, args: dict) -> bool | str:
211
+ """Ask the user to allow/deny/custom-respond to a write operation.
212
+
213
+ Called from the AGENT's background thread. Shows an inline confirmation
214
+ row with three options: ✓ 允许 / ✗ 拒绝 / ✎ 其他.
215
+ - 允许 → True (execute the tool)
216
+ - 拒绝 → False (block, "Permission denied")
217
+ - 其他 → user types free text → str (block, but feed user's instruction
218
+ to the model as a tool result so it can adjust)
219
+
220
+ The "其他" mode hides the buttons and turns #msg into a custom-reply
221
+ input. The user types their instruction and presses Enter to submit.
222
+ """
223
+ import threading
224
+
225
+ args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
226
+ if len(args_str) > 120:
227
+ args_str = args_str[:120] + "…"
228
+ self._confirm_event = threading.Event()
229
+ self._confirm_result: bool | str = False
230
+ self._confirm_custom_mode = False
231
+
232
+ def _show():
233
+ try:
234
+ menu = self.query_one(ConfirmMenu)
235
+ menu.show(tool_name, args_str)
236
+ except Exception:
237
+ pass
238
+
239
+ def _hide():
240
+ try:
241
+ menu = self.query_one(ConfirmMenu)
242
+ menu.hide()
243
+ # Reset #msg placeholder if it was in custom mode.
244
+ inp = self.query_one("#msg", Input)
245
+ inp.placeholder = "输入消息, /help 看命令, Esc 中断任务"
246
+ except Exception:
247
+ pass
248
+
249
+ self.call_from_thread(_show)
250
+ self._confirm_event.wait(timeout=120)
251
+ self.call_from_thread(_hide)
252
+ self._confirm_event = None
253
+ self._confirm_custom_mode = False
254
+ return self._confirm_result
255
+
256
+ def _resolve_confirmation(self, result: bool | str) -> None:
257
+ """MAIN THREAD: resolve the pending confirmation and wake the agent."""
258
+ self._confirm_result = result
259
+ if self._confirm_event is not None:
260
+ self._confirm_event.set()
261
+
262
+ def _enter_custom_mode(self) -> None:
263
+ """MAIN THREAD: switch the input bar to custom-reply mode for '自定义回复'."""
264
+ self._confirm_custom_mode = True
265
+ try:
266
+ # Hide the menu, turn #msg into a custom-reply input.
267
+ self.query_one(ConfirmMenu).hide()
268
+ inp = self.query_one("#msg", Input)
269
+ inp.placeholder = "输入自定义回复,回车提交..."
270
+ inp.value = ""
271
+ inp.focus()
272
+ except Exception:
273
+ pass
274
+
275
+ def _drain_render_queue(self) -> None:
276
+ """MAIN THREAD (set_interval): drain the render queue and execute all
277
+ pending instructions, then scroll to bottom.
278
+
279
+ Dispatch is a dict lookup (self._RENDER_DISPATCH) mapping action name
280
+ to a handler method. Each handler returns a scroll category:
281
+ - "streaming": lightweight live update → single deferred scroll.
282
+ - "final": new widget mounted → multi-stage delayed scroll (layout
283
+ passes need time to settle on large Panels).
284
+ - "none": no scroll trigger (clear_live, exit).
285
+
286
+ This replaces a 9-branch if/elif chain (cyclomatic complexity ~18)
287
+ with a flat table lookup — same behavior, far easier to extend (add a
288
+ new render action by adding one dict entry + one method).
289
+ """
290
+ did_streaming = False
291
+ did_final = False
292
+ while self._render_q:
293
+ action, *args = self._render_q.popleft()
294
+ handler = self._RENDER_DISPATCH.get(action)
295
+ if handler is None:
296
+ continue
297
+ try:
298
+ category = handler(self, args)
299
+ if category == "streaming":
300
+ did_streaming = True
301
+ elif category == "final":
302
+ did_final = True
303
+ except Exception:
304
+ pass
305
+ # Scroll strategy: streaming = single deferred scroll; final = multi-stage
306
+ # delayed scroll (large Panels need multiple layout passes to settle).
307
+ if did_final:
308
+ try:
309
+ self.set_timer(0.15, self._scroll_history_end)
310
+ self.set_timer(0.3, self._scroll_history_end)
311
+ self.set_timer(0.5, self._scroll_history_end)
312
+ except Exception:
313
+ pass
314
+ elif did_streaming:
315
+ try:
316
+ # Single deferred scroll — runs after this tick's layout settles,
317
+ # without piling up timers across 60ms cycles.
318
+ self.call_after_refresh(self._scroll_history_end)
319
+ except Exception:
320
+ pass
321
+
322
+ # --- render-action handlers (each returns "streaming"|"final"|"none") ---
323
+ # Kept as small staticmethod-like functions so the dispatch table can
324
+ # reference them without constructing lambdas on every drain cycle.
325
+
326
+ @staticmethod
327
+ def _h_text(self, args):
328
+ self._render_live_output(args[0])
329
+ return "streaming"
330
+
331
+ @staticmethod
332
+ def _h_finalize(self, args):
333
+ self._render_finalize(*args)
334
+ return "final"
335
+
336
+ @staticmethod
337
+ def _h_think_start(self, args):
338
+ self._render_think_start(args[0])
339
+ return "streaming"
340
+
341
+ @staticmethod
342
+ def _h_think_update(self, args):
343
+ self._render_think_update(args[0])
344
+ return "streaming"
345
+
346
+ @staticmethod
347
+ def _h_think_fold(self, args):
348
+ self._render_think_fold(*args)
349
+ return "final"
350
+
351
+ @staticmethod
352
+ def _h_static(self, args):
353
+ self._add_text_main(args[0], args[1] if len(args) > 1 else "")
354
+ return "final"
355
+
356
+ @staticmethod
357
+ def _h_panel(self, args):
358
+ self._add_static_main(args[0])
359
+ return "final"
360
+
361
+ @staticmethod
362
+ def _h_clear_live(self, args):
363
+ if self._live_out_widget is not None:
364
+ try:
365
+ self._live_out_widget.remove()
366
+ except Exception:
367
+ pass
368
+ self._live_out_widget = None
369
+ self._live_rendered_len = 0
370
+ return "none"
371
+
372
+ @staticmethod
373
+ def _h_exit(self, args):
374
+ self.exit()
375
+ return "none"
376
+
377
+ @staticmethod
378
+ def _h_todo_update(self, args):
379
+ """Render todos as a dynamic checklist in the output area (main thread).
380
+
381
+ Claude Code style: a SINGLE checklist widget is mounted on first
382
+ write_todos, then updated in-place on subsequent calls. Tool output
383
+ (edit_file, execute, etc.) appears BELOW it. The widget stays in
384
+ history as a record of the task's progress.
385
+ """
386
+ todos = args[0] if args else []
387
+ if not todos:
388
+ return "none"
389
+ done = sum(1 for t in todos if t.get("status") == "completed")
390
+ total = len(todos)
391
+ lines = [f"**任务清单 ({done}/{total})**"]
392
+ for t in todos:
393
+ status = t.get("status", "pending")
394
+ content = t.get("content", "")
395
+ if status == "completed":
396
+ lines.append(f"- [x] {content}")
397
+ elif status == "in_progress":
398
+ lines.append(f"- [ ] {content} ←")
399
+ else:
400
+ lines.append(f"- [ ] {content}")
401
+ text = "\n".join(lines)
402
+ panel = Panel(Markdown(text), title="📝 任务清单", border_style="cyan")
403
+
404
+ if self._todo_widget is not None:
405
+ # Update existing widget in-place (no new mount).
406
+ try:
407
+ self._todo_widget.update(panel)
408
+ return "none"
409
+ except Exception:
410
+ pass # widget was removed (scrolled off) → mount a new one
411
+ # First call or widget lost → mount new.
412
+ from textual.widgets import Static
413
+
414
+ self._todo_widget = Static(panel)
415
+ self._add_static_main(panel)
416
+ return "final"
417
+
418
+ # Dispatch table: action name -> handler. Built once at class definition.
419
+ # Handlers are staticmethods taking (self, args) so they can live in the
420
+ # table without binding overhead.
421
+ _RENDER_DISPATCH = {
422
+ "text": _h_text,
423
+ "finalize": _h_finalize,
424
+ "think_start": _h_think_start,
425
+ "think_update": _h_think_update,
426
+ "think_fold": _h_think_fold,
427
+ "static": _h_static,
428
+ "panel": _h_panel,
429
+ "clear_live": _h_clear_live,
430
+ "exit": _h_exit,
431
+ "todo_update": _h_todo_update,
432
+ }
433
+
434
+ # ----------------------------------------------------- render methods (MAIN THREAD, called by _drain_render_queue)
435
+ def _scroll_history_end(self) -> None:
436
+ """Scroll the history pane to the bottom."""
437
+ try:
438
+ h = self.query_one("#history", VerticalScroll)
439
+ h.scroll_end(animate=False)
440
+ except Exception:
441
+ pass
442
+
443
+ def _clear_history(self) -> None:
444
+ """Wipe all widgets from the history pane (used by /clear).
445
+
446
+ The old session's jsonl stays on disk — this only clears what's visible
447
+ on screen. Must run on the main thread (touches the DOM).
448
+ """
449
+ try:
450
+ h = self.query_one("#history", VerticalScroll)
451
+ h.remove_children()
452
+ except Exception:
453
+ pass
454
+
455
+ def _render_live_output(self, full_text: str) -> None:
456
+ """MAIN THREAD: append the NEW part of the streaming text to a RichLog.
457
+
458
+ RichLog is append-only: each call writes just the delta (the new chars
459
+ since the last write), NOT the full buffer re-rendered. This avoids the
460
+ layout recompute that Static.update(Text(full_buffer)) would trigger on
461
+ every batch (re-laying-out the entire history)."""
462
+ if self._live_out_widget is None:
463
+ self._live_out_widget = RichLog(wrap=True, markup=False, auto_scroll=True)
464
+ self._mount_widget_main(self._live_out_widget)
465
+ self._live_rendered_len = 0
466
+ # Write only the delta (new chars since last write).
467
+ delta = full_text[self._live_rendered_len :]
468
+ if delta:
469
+ self._live_rendered_len = len(full_text)
470
+ self._live_out_widget.write(delta)
471
+
472
+ def _render_think_start(self, full_text: str) -> None:
473
+ """MAIN THREAD: mount the live (expanded) thinking block."""
474
+ self._live_think_body = Static(Text(full_text))
475
+ col = Collapsible(
476
+ self._live_think_body,
477
+ title="💭 思考中…",
478
+ collapsed=False,
479
+ collapsed_symbol="▶",
480
+ expanded_symbol="▼",
481
+ classes="think-block",
482
+ )
483
+ self._last_collapsible = col
484
+ self._mount_widget_main(col)
485
+
486
+ def _render_think_update(self, full_text: str) -> None:
487
+ """MAIN THREAD: update the live thinking body."""
488
+ if self._live_think_body is not None:
489
+ self._live_think_body.update(Text(full_text))
490
+
491
+ def _render_think_fold(self, text: str, secs: float, had_live: bool) -> None:
492
+ """MAIN THREAD: fold the thinking Collapsible."""
493
+ chars = len(text)
494
+ title = f"💭 思考 · {secs:.1f}s · {chars} 字 · Ctrl+O / 点击展开"
495
+ if had_live and self._last_collapsible is not None:
496
+ self._last_collapsible.title = title
497
+ self._last_collapsible.collapsed = True
498
+ else:
499
+ body = Static(Text(text))
500
+ col = Collapsible(
501
+ body,
502
+ title=title,
503
+ collapsed=True,
504
+ collapsed_symbol="▶",
505
+ expanded_symbol="▼",
506
+ classes="think-block",
507
+ )
508
+ self._last_collapsible = col
509
+ self._mount_widget_main(col)
510
+
511
+ def _render_finalize(self, buf: str, think_text: str, secs: float, had_live: bool) -> None:
512
+ """MAIN THREAD: fold thinking + replace live output with final Markdown Panel.
513
+
514
+ The final answer is rendered as Static(Panel(Markdown(buf))) (see
515
+ _mount_final_panel). scroll_end lands on the real content bottom once the
516
+ layout settles (note the layers caveat in the CSS).
517
+ """
518
+ if think_text.strip():
519
+ self._render_think_fold(think_text, secs, had_live)
520
+ if buf.strip():
521
+ # Remove the streaming RichLog and replace with the final Markdown Panel.
522
+ if self._live_out_widget is not None:
523
+ try:
524
+ self._live_out_widget.remove()
525
+ except Exception:
526
+ pass
527
+ self._live_out_widget = None
528
+ self._live_rendered_len = 0
529
+ self.call_after_refresh(self._mount_final_panel, buf)
530
+
531
+ def _mount_final_panel(self, buf: str) -> None:
532
+ """MAIN THREAD: mount the final Markdown Panel and scroll to the bottom.
533
+
534
+ Mounts Static(Panel(Markdown(buf))) and schedules a multi-stage delayed
535
+ scroll (the Panel's height needs a few layout passes to settle).
536
+ """
537
+ history = self.query_one("#history")
538
+ widget = Static(Panel(Markdown(buf), border_style="blue", title="coderio"))
539
+ history.mount(widget)
540
+ self.set_timer(0.15, self._scroll_history_end)
541
+ self.set_timer(0.3, self._scroll_history_end)
542
+ self.set_timer(0.5, self._scroll_history_end)
543
+
544
+ # ----------------------------------------------------- status bar (phase routing)
545
+ def _set_phase(
546
+ self,
547
+ phase: str,
548
+ tool_name: str = "",
549
+ step: int = 0,
550
+ tool_index: int = 0,
551
+ tool_total: int = 0,
552
+ ) -> None:
553
+ """Forward a phase change to the StatusBar widget.
554
+
555
+ Called from the agent's BACKGROUND thread (StreamHandler callbacks). Do
556
+ NOT use query_one here — it's a main-thread DOM query. Use the cached
557
+ reference instead (plain attribute read, GIL-safe). StatusBar.set_phase
558
+ also only writes plain attributes.
559
+ """
560
+ bar = self._status_bar
561
+ if bar is None:
562
+ return # not mounted yet
563
+ bar.set_phase(phase, tool_name, step=step, tool_index=tool_index, tool_total=tool_total)
564
+
565
+ def _show_interrupt_btn(self, show: bool) -> None:
566
+ """Show/hide the interrupt button. Safe from any thread."""
567
+ # add_class/remove_class on a Button are plain attribute mutations
568
+ # (GIL-safe). We don't need call_from_thread here — the CSS class
569
+ # toggle is picked up by the next layout pass. This avoids the race
570
+ # where call_from_thread is deferred until after the blocking agent
571
+ # call returns (too late — the button never showed during the turn).
572
+ try:
573
+ btn = self.query_one("#interrupt-btn", Button)
574
+ if show:
575
+ btn.add_class("-visible")
576
+ else:
577
+ btn.remove_class("-visible")
578
+ except Exception:
579
+ pass
580
+
581
+ def on_button_pressed(self, event: Button.Pressed) -> None:
582
+ """Handle button clicks (interrupt only — confirm is now a keyboard menu)."""
583
+ if event.button.id == "interrupt-btn":
584
+ self.action_interrupt()
585
+
586
+ # ----------------------------------------------------- command menu (autocomplete)
587
+ def on_input_changed(self, event: Input.Changed) -> None:
588
+ """Live-filter the command menu as the user types in the main input."""
589
+ if event.input.id != "msg":
590
+ return
591
+ self.query_one(CommandMenu).refresh_for(event.value)
592
+
593
+ def on_key(self, event) -> None:
594
+ """Handle command-menu navigation + inline confirmation keys."""
595
+ # Custom-reply mode: Enter submits the user's text as a str result.
596
+ if self._confirm_custom_mode:
597
+ # Let normal input editing happen; only intercept Enter/Esc.
598
+ if event.key == "enter":
599
+ inp = self.query_one("#msg", Input)
600
+ text = inp.value.strip()
601
+ inp.value = ""
602
+ self._resolve_confirmation(text if text else False)
603
+ event.prevent_default()
604
+ return
605
+ if event.key == "escape":
606
+ self._resolve_confirmation(False)
607
+ event.prevent_default()
608
+ return
609
+ return # let other keys type into the input
610
+ # If the confirmation menu is visible, navigate it with ↑↓ + Enter.
611
+ # (Esc cancels = deny.) This is the zcode/codex vertical-selection model.
612
+ try:
613
+ confirm_menu = self.query_one(ConfirmMenu)
614
+ if confirm_menu.visible():
615
+ if event.key == "up":
616
+ confirm_menu.move(-1)
617
+ event.prevent_default()
618
+ return
619
+ if event.key == "down":
620
+ confirm_menu.move(1)
621
+ event.prevent_default()
622
+ return
623
+ if event.key == "enter":
624
+ choice = confirm_menu.accept()
625
+ if choice == ConfirmMenu.ALLOW:
626
+ self._resolve_confirmation(True)
627
+ elif choice == ConfirmMenu.CUSTOM:
628
+ self._enter_custom_mode()
629
+ else: # DENY or None (nothing selected)
630
+ self._resolve_confirmation(False)
631
+ event.prevent_default()
632
+ return
633
+ if event.key == "escape":
634
+ self._resolve_confirmation(False)
635
+ event.prevent_default()
636
+ return
637
+ except Exception:
638
+ pass
639
+ # Command-menu navigation (only when menu is visible).
640
+ menu = self.query_one(CommandMenu)
641
+ if not menu.visible():
642
+ return
643
+ if event.key == "up":
644
+ menu.move(-1)
645
+ event.prevent_default()
646
+ elif event.key == "down":
647
+ menu.move(1)
648
+ event.prevent_default()
649
+ elif event.key in ("tab", "enter"):
650
+ if menu.accept():
651
+ event.prevent_default()
652
+ elif event.key == "escape":
653
+ menu.hide()
654
+ event.prevent_default()
655
+
656
+ def on_input_submitted(self, event: Input.Submitted) -> None:
657
+ # CRITICAL: only handle the MAIN chat input. Input.Submitted bubbles up
658
+ # the widget tree by default, so a submission in OnboardingScreen's
659
+ # `#onboard-input` (API key / model / base_url fields) or any other
660
+ # modal's Input would also land here and be dispatched to _on_input →
661
+ # run_deep_agent → session.append, leaking sensitive fields like API keys
662
+ # into the session jsonl. Observed in real sessions: a 64-char API
663
+ # key was persisted as a user message. Guard by widget id — only `#msg`
664
+ # is the chat input. (on_input_changed above already has this guard.)
665
+ if event.input.id != "msg":
666
+ return
667
+ line = event.value.strip()
668
+ if not line:
669
+ return
670
+ event.input.value = ""
671
+ self._add_text(f"▸ you {line}", style="bold cyan")
672
+ if self._on_input:
673
+ # Use a Textual WORKER (thread=True), not a raw threading.Thread. A
674
+ # worker is managed by Textual, so the main event loop stays alive and
675
+ # keeps draining pending UI updates while the worker runs AND after it
676
+ # completes — a raw daemon thread would not.
677
+ def _run():
678
+ self._is_running = True
679
+ self._interrupted = False
680
+ self.call_from_thread(self._show_interrupt_btn, True)
681
+ try:
682
+ self._on_input(line)
683
+ except SystemExit:
684
+ self._render_q.append(("exit",))
685
+ except InterruptedError:
686
+ # User pressed Esc / clicked 中断. Show a notice and clean up.
687
+ self._round_thinking = ""
688
+ self._round_think_start = 0.0
689
+ self._live_think_body = None
690
+ self._live_think_chars = 0
691
+ self._round_think_started = False
692
+ self.buffer = ""
693
+ self._live_output_last_flush = 0.0
694
+ if self._status_bar:
695
+ self._status_bar.set_phase("idle")
696
+ self._render_q.append(
697
+ (
698
+ "panel",
699
+ Panel(
700
+ "用户已中断当前任务。已完成的中间结果保留在历史中。\n"
701
+ "输入新消息继续,或按 ↑ 恢复上一条输入。",
702
+ title="⚠ 已中断",
703
+ border_style="yellow",
704
+ ),
705
+ )
706
+ )
707
+ except Exception as e:
708
+ # Reset the streaming state + status bar so the TUI doesn't
709
+ # get stuck in 'thinking' phase when the agent errors out
710
+ # (e.g. API auth failure, network error). on_finish is never
711
+ # called when run_deep_agent raises, so we must clean up here.
712
+ self._round_thinking = ""
713
+ self._round_think_start = 0.0
714
+ self._live_think_body = None
715
+ self._live_think_chars = 0
716
+ self._round_think_started = False
717
+ self.buffer = ""
718
+ self._live_output_last_flush = 0.0
719
+ if self._status_bar:
720
+ self._status_bar.set_phase("idle")
721
+ self._render_q.append(
722
+ (
723
+ "panel",
724
+ Panel(
725
+ Text(f"⚠ {type(e).__name__}: {e}\n\n你的输入已保留在输入框,按 Enter 可重试。"),
726
+ title="⚠ 运行错误",
727
+ border_style="red",
728
+ ),
729
+ )
730
+ )
731
+
732
+ def _refill_input():
733
+ try:
734
+ inp = self.query_one("#msg", Input)
735
+ inp.value = line
736
+ inp.focus()
737
+ except Exception:
738
+ pass
739
+
740
+ self.call_from_thread(_refill_input)
741
+ finally:
742
+ self._is_running = False
743
+ self.call_from_thread(self._show_interrupt_btn, False)
744
+
745
+ self._agent_worker = self.run_worker(
746
+ _run,
747
+ thread=True,
748
+ exclusive=True,
749
+ name="agent_turn",
750
+ exit_on_error=False,
751
+ )
752
+
753
+ # ----------------------------------------------------- binding: Ctrl+O
754
+ def action_toggle_thinking(self) -> None:
755
+ """Ctrl+O — toggle the thinking block at the current focus.
756
+
757
+ Walks up from the focused widget to find the enclosing Collapsible. If
758
+ the focus is on (or inside) a thinking block, toggles THAT one — so the
759
+ user can choose which round to expand by clicking into it first. If no
760
+ thinking block is focused (e.g. focus is on the input bar), falls back
761
+ to the most recent one.
762
+ """
763
+ target = self._focused_collapsible()
764
+ if target is None:
765
+ # Fallback: focus not on a thinking block → toggle the most recent.
766
+ target = self._last_collapsible
767
+ if target is None:
768
+ self._add_text("最近一轮没有思考内容。", style="dim")
769
+ return
770
+ target.collapsed = not target.collapsed
771
+
772
+ # ----------------------------------------------------- binding: Esc = interrupt
773
+ def action_interrupt(self) -> None:
774
+ """Interrupt the currently-running agent turn (bound to Esc + Ctrl+C).
775
+
776
+ Sets the _interrupted flag (checked by is_interrupted() between rounds)
777
+ and cancels the worker as a backup. The agent thread sees the flag at
778
+ the next safe checkpoint (start of a new ReAct round) and raises
779
+ InterruptedError, which _run catches to show a '⚠ 已中断' panel.
780
+
781
+ If no agent is running, this is a no-op (Esc does nothing instead of
782
+ quitting the TUI — the old behavior was ctrl+c=quit which killed the
783
+ app mid-task with no way to stop gracefully).
784
+ """
785
+ if not self._is_running:
786
+ return # nothing to interrupt
787
+ self._interrupted = True
788
+ # Cancel the worker as a backup — this unblocks subprocess.run calls
789
+ # and model.stream() that might be waiting on I/O. The flag handles
790
+ # the clean exit; cancel handles the "stuck in I/O" case.
791
+ if self._agent_worker is not None:
792
+ try:
793
+ self._agent_worker.cancel()
794
+ except Exception:
795
+ pass
796
+
797
+ def _focused_collapsible(self):
798
+ """Find the Collapsible enclosing the currently-focused widget, if any."""
799
+ focused = self.focused
800
+ if focused is None:
801
+ return None
802
+ # Walk up the widget tree looking for a Collapsible ancestor.
803
+ node = focused
804
+ while node is not None:
805
+ if isinstance(node, Collapsible):
806
+ return node
807
+ node = node.parent
808
+ return None
809
+
810
+ # ----------------------------------------------------- StreamHandler protocol
811
+ # ALL callbacks run on the agent's BACKGROUND thread. They ONLY push render
812
+ # instructions to self._render_q (a thread-safe deque). The main-thread timer
813
+ # _drain_render_queue (set_interval 60ms) pops and executes them — no
814
+ # call_from_thread here.
815
+ def on_step_start(self, step: int = 1) -> None:
816
+ self._flush_round_thinking()
817
+ # Reset turn token counter at the start of each turn (step 1 = new turn).
818
+ if step == 1:
819
+ self.usage = {"input_tokens": 0, "output_tokens": 0}
820
+ if self._status_bar:
821
+ self._status_bar.set_turn_tokens(0)
822
+ self._set_phase("thinking", step=step)
823
+
824
+ def is_interrupted(self) -> bool:
825
+ """Check if the user requested an interrupt (agent thread calls this).
826
+
827
+ Called between ReAct rounds by _execute_turn. When True, the turn ends
828
+ gracefully — the user gets whatever partial output exists, plus a
829
+ '⚠ 已中断' panel. The agent thread does NOT raise or crash; it just
830
+ stops looping and returns.
831
+ """
832
+ return self._interrupted
833
+
834
+ def on_token(self, text: str) -> None:
835
+ self._flush_round_thinking()
836
+ bar = self._status_bar
837
+ if bar is None or bar.phase != "responding":
838
+ self._set_phase("responding")
839
+ # Accumulate in buffer (agent thread). Push a "text" render instruction
840
+ # with the FULL buffer so the main thread can update the live widget.
841
+ # Throttle: only push at most once per ~60ms to avoid flooding the queue.
842
+ self.buffer += text
843
+ now = time.monotonic()
844
+ if self._live_output_last_flush == 0.0 or (now - self._live_output_last_flush) >= 0.06:
845
+ self._live_output_last_flush = now
846
+ self._render_q.append(("text", self.buffer))
847
+
848
+ def on_thinking(self, text: str) -> None:
849
+ if not self._round_thinking:
850
+ self._round_think_start = time.monotonic()
851
+ self._round_thinking += text
852
+ now = time.monotonic()
853
+ # Decide think_start vs think_update using an AGENT-THREAD-LOCAL flag,
854
+ # NOT `_live_think_body`. The latter is set by the main thread only after
855
+ # the next _drain_render_queue tick (60ms away), so reading it here is a
856
+ # race: within one drain window many on_thinking chunks arrive, each one
857
+ # would re-enter the "first chunk" branch and queue another think_start,
858
+ # fragmenting one continuous thinking stream into many tiny Collapsibles.
859
+ if not self._round_think_started:
860
+ # First chunk of this round: queue think_start with the FULL text so
861
+ # far, and mark the round as started. The main thread will mount ONE
862
+ # Collapsible; subsequent chunks queue think_update against that same
863
+ # widget.
864
+ self._round_think_started = True
865
+ self._live_think_chars = len(self._round_thinking)
866
+ self._render_q.append(("think_start", self._round_thinking))
867
+ self._live_output_last_flush = now # reuse throttle timer for thinking
868
+ else:
869
+ delta_len = len(self._round_thinking) - self._live_think_chars
870
+ if delta_len > 0 and (now - self._live_output_last_flush) >= 0.06:
871
+ self._live_think_chars = len(self._round_thinking)
872
+ self._live_output_last_flush = now
873
+ self._render_q.append(("think_update", self._round_thinking))
874
+
875
+ def on_tool_start(
876
+ self,
877
+ name: str,
878
+ args: dict[str, Any],
879
+ step: int = 1,
880
+ tool_index: int = 0,
881
+ tool_total: int = 0,
882
+ ) -> None:
883
+ self._flush_round_thinking()
884
+ self._flush_buffer()
885
+ self._set_phase(
886
+ "tool",
887
+ tool_name=name,
888
+ step=step,
889
+ tool_index=tool_index,
890
+ tool_total=tool_total,
891
+ )
892
+ # Special-case the `task` tool (subagent delegation): show a friendly
893
+ # notice instead of the raw (very long) args. The subagent runs
894
+ # synchronously inside the tools node — the main agent blocks until it
895
+ # finishes, which can take minutes. Without this notice the user sees
896
+ # a frozen "执行 task(…)" with no indication that a subagent is working.
897
+ if name == "task":
898
+ subagent = args.get("subagent_type", "general-purpose")
899
+ desc = args.get("description", "") or args.get("instructions", "")
900
+ desc_short = desc.split("\n")[0][:80] if desc else ""
901
+ self._render_q.append(("static", f"🔄 委派子 agent [{subagent}]:{desc_short}…(执行中,请稍候)", "cyan"))
902
+ return
903
+ args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
904
+ if len(args_str) > 100:
905
+ args_str = args_str[:100] + "…"
906
+ self._render_q.append(("static", f"⏺ {name}({args_str})", "green"))
907
+
908
+ # Tools that modify files — shown with a prominent yellow line so the user
909
+ # always knows what changed, even in auto mode (where there's no confirmation).
910
+ _WRITE_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "multi_edit"})
911
+
912
+ def on_tool_end(self, name: str, result: str) -> None:
913
+ self._set_phase("thinking")
914
+ # Write tools get a prominent yellow line so the user sees what was
915
+ # modified — matching the "always show file changes" UX of claude code /
916
+ # zcode. Other tools keep the existing dim/grey output.
917
+ if name in self._WRITE_TOOLS and not result.startswith(("Error", "Permission denied")):
918
+ self._render_q.append(
919
+ ("static", f" 📝 {result.splitlines()[0] if result.splitlines() else name}", "yellow bold")
920
+ )
921
+ return
922
+ if name == "_empty_response":
923
+ # Empty-response exhaustion is a hard interruption, not a normal
924
+ # tool result — show it as a red panel (like on_harness_warn) so
925
+ # it's visible instead of buried in dim grey text.
926
+ self._flush_round_thinking()
927
+ self._flush_buffer()
928
+ self._render_q.append(
929
+ (
930
+ "panel",
931
+ Panel(
932
+ Text(f"⚠ {result}\n\n建议用 /clear 清理上下文后重试,或检查模型状态。"),
933
+ title="⚠ 会话中断",
934
+ border_style="red",
935
+ ),
936
+ )
937
+ )
938
+ return
939
+ if not self.show_tool_output:
940
+ first = result.splitlines()[0][:60] if result.splitlines() else ""
941
+ self._render_q.append(("static", f" → {first}{'…' if len(result) > 60 else ''}", "dim"))
942
+ return
943
+ lines = result.splitlines()
944
+ shown = "\n".join(lines[:3])
945
+ if len(lines) > 3:
946
+ shown += f"\n…({len(lines) - 3} more lines)"
947
+ self._render_q.append(("static", shown, "dim"))
948
+
949
+ def on_truncated(self, stop_reason: str) -> None:
950
+ self._flush_round_thinking()
951
+ self._flush_buffer()
952
+ self._render_q.append(
953
+ (
954
+ "panel",
955
+ Panel(
956
+ Text(f"⚠ 输出被截断 (stop_reason: {stop_reason})。"),
957
+ title="截断警告",
958
+ border_style="yellow",
959
+ ),
960
+ )
961
+ )
962
+
963
+ def on_harness_warn(self, message: str) -> None:
964
+ """Escalation release: the harness allowed a sketchy answer through.
965
+
966
+ IMPORTANT: do NOT call _flush_buffer() here. on_harness_warn is always
967
+ followed by on_finish (see loop.py), which renders self.buffer as the
968
+ final blue 'coderio' Panel. Flushing here would instead render it as a
969
+ cyan '中间输出' Panel AND clear the buffer, so on_finish would have
970
+ nothing left to show — the model's real final answer would appear as
971
+ misleading intermediate output (the exact bug we fixed). Just clear the
972
+ live streaming widget so on_finish can mount the final Panel cleanly."""
973
+ self._flush_round_thinking()
974
+ self._render_q.append(("clear_live",))
975
+ self._render_q.append(
976
+ (
977
+ "panel",
978
+ Panel(
979
+ Text(f"⚠ {message}\n\n产出可能未经验证,请人工复核。"),
980
+ title="⚠ harness 警告",
981
+ border_style="red",
982
+ ),
983
+ )
984
+ )
985
+
986
+ def on_harness_continue(self, reason: str) -> None:
987
+ """Surface a harness force-continue as a dim notice line.
988
+
989
+ The model produced what looked like a final answer but the harness found
990
+ unfinished work and demanded more. Flush the first output as an
991
+ intermediate panel (user can see it) then show a dim notice.
992
+ """
993
+ self._flush_round_thinking()
994
+ self._flush_buffer()
995
+ first_line = reason.splitlines()[0] if reason else ""
996
+ if len(first_line) > 120:
997
+ first_line = first_line[:117] + "…"
998
+ self._render_q.append(
999
+ (
1000
+ "static",
1001
+ f"↻ harness 要求继续:{first_line}",
1002
+ "dim italic",
1003
+ )
1004
+ )
1005
+
1006
+ def on_finish(self) -> None:
1007
+ # Capture everything remaining and push ONE finalize instruction.
1008
+ # The main-thread drain will fold thinking + mount the final Markdown Panel.
1009
+ think_text = self._round_thinking
1010
+ secs = time.monotonic() - self._round_think_start if self._round_think_start else 0.0
1011
+ # had_live = a live Collapsible was mounted for this round. Use the
1012
+ # agent-thread flag (truthful at this moment) rather than _live_think_body
1013
+ # (which the main thread owns and may not have updated yet).
1014
+ had_live = self._round_think_started
1015
+ buf = self.buffer
1016
+ # Reset accumulated state.
1017
+ self._round_thinking = ""
1018
+ self._round_think_start = 0.0
1019
+ self._live_think_body = None
1020
+ self._live_think_chars = 0
1021
+ self._round_think_started = False
1022
+ self.buffer = ""
1023
+ self._live_output_last_flush = 0.0
1024
+ # Reset the todo widget so the next turn mounts a fresh one.
1025
+ self._todo_widget = None
1026
+ self._render_q.append(("finalize", buf, think_text, secs, had_live))
1027
+ if self._status_bar:
1028
+ self._status_bar.set_phase("idle")
1029
+ self._status_bar.set_turn_tokens(0)
1030
+
1031
+ def on_turn_end(self, writes: list[str]) -> None:
1032
+ """Turn-end summary: show a panel listing all files modified this turn.
1033
+
1034
+ Called after on_finish. If the turn modified any files (write_file /
1035
+ edit_file / multi_edit), render a compact summary so the user always
1036
+ knows what changed — even in auto mode where there's no confirmation.
1037
+ Matches the 'always show file changes' UX of claude code / zcode.
1038
+ """
1039
+ if not writes:
1040
+ return
1041
+ body = "\n".join(f"📝 {w}" for w in writes)
1042
+ self._render_q.append(
1043
+ (
1044
+ "panel",
1045
+ Panel(body, title="本轮修改的文件", border_style="yellow"),
1046
+ )
1047
+ )
1048
+
1049
+ def add_usage(self, meta: dict[str, int]) -> None:
1050
+ for k in ("input_tokens", "output_tokens"):
1051
+ if k in meta:
1052
+ self.usage[k] += meta[k]
1053
+ # Push the turn total to the StatusBar so it shows live token consumption.
1054
+ if self._status_bar:
1055
+ total = self.usage["input_tokens"] + self.usage["output_tokens"]
1056
+ self._status_bar.set_turn_tokens(total)
1057
+
1058
+ def on_todos_update(self, todos: list[dict]) -> None:
1059
+ """Push a todo list update to the render queue (agent background thread).
1060
+
1061
+ Called when deepagents' write_todos tool fires. The whole list is
1062
+ replaced each call. The main-thread drain renders it as a Markdown
1063
+ checklist in the output area (Claude Code style).
1064
+ """
1065
+ self._render_q.append(("todo_update", todos))
1066
+
1067
+ def on_phase_change(self, state: str, step: int, hint: str) -> None:
1068
+ """Task-level phase change (explore/plan/implement/verify/...).
1069
+
1070
+ Called from Harness._track_phase on the agent's background thread. Just
1071
+ forwards to StatusBar.set_task_phase (plain attribute write, GIL-safe);
1072
+ the heartbeat repaints within ~100ms. 'complete' clears the tag.
1073
+ """
1074
+ if self._status_bar:
1075
+ self._status_bar.set_task_phase("" if state == "complete" else state)
1076
+
1077
+ # ----------------------------------------------------- thinking fold (true fold/unfold)
1078
+ def _flush_round_thinking(self) -> None:
1079
+ """Push a think_fold instruction to the render queue (agent thread).
1080
+
1081
+ Called whenever a round's thinking needs to be sealed off: before each
1082
+ tool call, before non-thinking output begins, and at turn end. Clears
1083
+ the agent-thread `_round_think_started` flag so the NEXT round's first
1084
+ on_thinking chunk queues a fresh think_start (one Collapsible per round,
1085
+ never per-chunk)."""
1086
+ if not self._round_thinking.strip():
1087
+ # Even if there's no text, drop the started flag so the next round
1088
+ # gets a clean start (a stray True here with no body to fold would
1089
+ # make the next on_thinking skip think_start).
1090
+ self._round_think_started = False
1091
+ return
1092
+ text = self._round_thinking
1093
+ secs = time.monotonic() - self._round_think_start if self._round_think_start else 0.0
1094
+ # had_live = a live Collapsible was mounted for THIS round. Read the
1095
+ # agent-thread flag, not _live_think_body (main-thread state, races).
1096
+ had_live = self._round_think_started
1097
+ self._round_thinking = ""
1098
+ self._round_think_start = 0.0
1099
+ self._live_think_body = None
1100
+ self._live_think_chars = 0
1101
+ self._round_think_started = False
1102
+ self._render_q.append(("think_fold", text, secs, had_live))
1103
+
1104
+ def show_last_thinking(self) -> bool:
1105
+ """Expand the most recent thinking (compat with /think command)."""
1106
+ if self._last_collapsible is None:
1107
+ self._add_text("最近一轮没有思考内容。", style="dim")
1108
+ return False
1109
+ self._last_collapsible.collapsed = False # expand
1110
+ return True
1111
+
1112
+ # ----------------------------------------------------- helpers: add content to history
1113
+ def _flush_buffer(self) -> None:
1114
+ """Push the accumulated output to the render queue, replacing the live
1115
+ streaming RichLog with a Markdown Panel.
1116
+
1117
+ Marked as an INTERMEDIATE panel (distinct title + dim cyan border) so the
1118
+ user can visually tell this is mid-process output, NOT the final answer.
1119
+ The final answer is mounted by _mount_final_panel with title "coderio"
1120
+ and a blue border. Without this distinction, a model that emits text then
1121
+ continues with more tool calls looks like it "finished, then restarted"."""
1122
+ # Remove the live streaming widget first (if present).
1123
+ self._render_q.append(("clear_live",))
1124
+ if self.buffer.strip():
1125
+ self._render_q.append(
1126
+ (
1127
+ "panel",
1128
+ Panel(
1129
+ Markdown(self.buffer),
1130
+ border_style="cyan",
1131
+ title="[dim]中间输出 · agent 仍在运行…[/dim]",
1132
+ ),
1133
+ )
1134
+ )
1135
+ self.buffer = ""
1136
+
1137
+ def _add_text(self, text: str, style: str = "") -> None:
1138
+ """Push a text line to the render queue."""
1139
+ self._render_q.append(("static", text, style))
1140
+
1141
+ def _add_text_main(self, text: str, style: str = "") -> None:
1142
+ try:
1143
+ history = self.query_one("#history", VerticalScroll)
1144
+ history.mount(Static(Text(text, style=style) if style else Text(text)))
1145
+ history.call_after_refresh(history.scroll_end, animate=False)
1146
+ except Exception:
1147
+ pass
1148
+
1149
+ def _add_static(self, renderable) -> None:
1150
+ """Thread-safe: add a Rich renderable (Panel/Markdown) to history."""
1151
+ import threading
1152
+
1153
+ if threading.current_thread() is not threading.main_thread():
1154
+ self.call_from_thread(self._add_static_main, renderable)
1155
+ else:
1156
+ self._add_static_main(renderable)
1157
+
1158
+ def _add_static_main(self, renderable) -> None:
1159
+ try:
1160
+ history = self.query_one("#history", VerticalScroll)
1161
+ widget = Static(renderable)
1162
+ history.mount(widget)
1163
+ # Scroll to end AFTER the mount + layout settle. Calling scroll_end
1164
+ # synchronously right after mount races the layout (the new widget's
1165
+ # height isn't computed yet), so scroll_end lands above the true
1166
+ # bottom. call_after_refresh defers the scroll until the layout pass
1167
+ # finishes.
1168
+ history.call_after_refresh(history.scroll_end, animate=False)
1169
+ except Exception:
1170
+ pass
1171
+
1172
+ def _mount_widget(self, widget) -> None:
1173
+ """Thread-safe: mount an arbitrary widget (e.g. Collapsible) to history."""
1174
+ import threading
1175
+
1176
+ if threading.current_thread() is not threading.main_thread():
1177
+ self.call_from_thread(self._mount_widget_main, widget)
1178
+ else:
1179
+ self._mount_widget_main(widget)
1180
+
1181
+ def _mount_widget_main(self, widget) -> None:
1182
+ try:
1183
+ history = self.query_one("#history", VerticalScroll)
1184
+ history.mount(widget)
1185
+ history.call_after_refresh(history.scroll_end, animate=False)
1186
+ except Exception:
1187
+ pass
1188
+
1189
+
1190
+ def _switch_active_profile(profile_name: str) -> str:
1191
+ """Write the chosen profile name to config.toml as active_profile.
1192
+
1193
+ Read-modify-write so other sections and the profiles array are preserved.
1194
+ Returns the name written (empty string if it couldn't be written). Called by
1195
+ the /profile picker callback after the user picks a profile.
1196
+ """
1197
+ import tomllib
1198
+ from pathlib import Path
1199
+
1200
+ import tomli_w
1201
+
1202
+ config_path = Path.home() / ".coderio" / "config.toml"
1203
+ data: dict = {}
1204
+ if config_path.is_file():
1205
+ try:
1206
+ with open(config_path, "rb") as f:
1207
+ data = tomllib.load(f)
1208
+ except Exception:
1209
+ data = {}
1210
+ data["active_profile"] = profile_name
1211
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1212
+ with open(config_path, "wb") as f:
1213
+ tomli_w.dump(data, f)
1214
+ return profile_name
1215
+
1216
+
1217
+ def run_tui(
1218
+ provider_override: str | None = None,
1219
+ model_override: str | None = None,
1220
+ resume: str | None = None,
1221
+ continue_last: bool = False,
1222
+ ) -> None:
1223
+ """Launch the Textual TUI, wired to coderio's agent runtime.
1224
+
1225
+ Builds the same runtime as the Rich REPL (config, model, tools, skills,
1226
+ session), then runs the CoderioTUI app. Each user submission drives
1227
+ run_agent in a background thread; the TUI stays interactive (Ctrl+O, scroll).
1228
+
1229
+ ``resume`` / ``continue_last`` load a prior session into the conversation
1230
+ history (same semantics as the REPL's --resume/--continue).
1231
+ """
1232
+ from pathlib import Path
1233
+
1234
+ from coderio.cli.repl import _needs_onboarding, _resolve_resume, build_runtime
1235
+ from coderio.config import load_config
1236
+ from coderio.config.bootstrap import ensure_user_dirs
1237
+
1238
+ ensure_user_dirs()
1239
+ search_from = "."
1240
+ creds_path = Path.home() / ".coderio" / "credentials"
1241
+
1242
+ # Run TUI-based onboarding if needed (replaces the old console wizard).
1243
+ if _needs_onboarding(creds_path):
1244
+ result = _run_onboarding_tui()
1245
+ if result is None:
1246
+ return # user cancelled
1247
+
1248
+ # Resolve a session to resume BEFORE building the runtime (so build_runtime
1249
+ # receives it instead of creating a fresh one).
1250
+ session = None
1251
+ if resume or continue_last:
1252
+ cfg = load_config(search_from=search_from)
1253
+ try:
1254
+ session = _resolve_resume(cfg, resume, continue_last)
1255
+ except SystemExit as e:
1256
+ # _resolve_resume raises SystemExit on "no previous session"; surface
1257
+ # it as a clean banner rather than a crash.
1258
+ tui = CoderioTUI()
1259
+ tui._banner = f"[red]{e}[/red]"
1260
+ tui.run()
1261
+ return
1262
+
1263
+ # Repo-config trust confirmation (2026-08-14 v2 audit: cloned malicious
1264
+ # repos could set permission_mode="full"/redirect base_url/spawn MCP
1265
+ # commands with zero prompt). Must run BEFORE build_runtime — that call
1266
+ # loads the repo config AND spawns .mcp.json servers.
1267
+ from coderio.config.loader import _find_project_dir
1268
+ from coderio.config.trust import (
1269
+ existing_repo_configs,
1270
+ is_repo_trusted,
1271
+ mark_repo_trusted,
1272
+ summarize_repo_configs,
1273
+ )
1274
+
1275
+ user_dir = Path.home() / ".coderio"
1276
+ project_dir = _find_project_dir(Path(search_from).resolve())
1277
+ if existing_repo_configs(project_dir) and not is_repo_trusted(project_dir, user_dir):
1278
+ import typer
1279
+
1280
+ typer.secho(
1281
+ "This repository contains coderio configuration files that were\nnot previously trusted:",
1282
+ fg=typer.colors.YELLOW,
1283
+ )
1284
+ typer.echo()
1285
+ typer.echo(summarize_repo_configs(project_dir))
1286
+ typer.echo()
1287
+ typer.secho(
1288
+ "These can change permissions, redirect the model endpoint, or start\n"
1289
+ "local processes (MCP servers). Only trust repositories you control\n"
1290
+ "or have reviewed.",
1291
+ fg=typer.colors.YELLOW,
1292
+ )
1293
+ answer = typer.prompt("Trust this repository's coderio config? [y/N]", default="N")
1294
+ if answer.strip().lower() not in ("y", "yes"):
1295
+ typer.secho("Not trusted — exiting. Re-run and confirm to use this repo's config.", fg=typer.colors.RED)
1296
+ raise typer.Exit(1)
1297
+ mark_repo_trusted(project_dir, user_dir)
1298
+
1299
+ try:
1300
+ cfg, store, model, tools, gate, session, active, _rich_stream = build_runtime(
1301
+ search_from=search_from,
1302
+ console=None,
1303
+ creds_path=creds_path,
1304
+ provider_override=provider_override,
1305
+ model_override=model_override,
1306
+ session=session,
1307
+ )
1308
+ except Exception as e:
1309
+ tui = CoderioTUI()
1310
+ tui._banner = (
1311
+ f"[red]启动失败:[/red] {type(e).__name__}: {e}\n\n"
1312
+ "常见原因: API key 未配置 / provider 无效 / 网络不通。\n"
1313
+ "运行 coderio config 检查配置, 或设置 ANTHROPIC_API_KEY 环境变量。"
1314
+ )
1315
+ tui.run()
1316
+ return
1317
+
1318
+ banner = (
1319
+ f"[bold magenta]coderio[/bold magenta] v{__version__} "
1320
+ f"[dim]profile=[/dim]{cfg.active_profile or 'default'} "
1321
+ f"[dim]model=[/dim]{cfg.model.default} "
1322
+ f"[dim]perm=[/dim]{gate.mode}"
1323
+ "\n[dim]引擎:[/dim] [cyan]deepagents[/cyan] "
1324
+ "[dim]输入 /help 看命令, /exit 退出, Ctrl+O 展开/收起思考[/dim]"
1325
+ )
1326
+
1327
+ # Mutable runtime holder — /model, /mode, /resume rebuild parts in place.
1328
+ rt = {"cfg": cfg, "model": model, "gate": gate, "session": session}
1329
+
1330
+ def on_input(line: str) -> None:
1331
+ if line.startswith("/"):
1332
+ from pathlib import Path as _P
1333
+
1334
+ from coderio.cli.commands import ReplContext, handle_slash
1335
+ from coderio.session.store import Session
1336
+
1337
+ ctx = ReplContext(
1338
+ available_skills=store.names(),
1339
+ active_skills_names={s.name for s in active.all()},
1340
+ permission_mode=rt["gate"].mode,
1341
+ model_name=rt["cfg"].model.default,
1342
+ provider_id=rt["cfg"].model.provider_id,
1343
+ api_key="",
1344
+ base_url=rt["cfg"].model.base_url,
1345
+ recent_sessions=Session.list_recent(_P(rt["cfg"].session.save_dir).expanduser()),
1346
+ session_save_dir=str(_P(rt["cfg"].session.save_dir).expanduser()),
1347
+ session=rt["session"],
1348
+ profiles=rt["cfg"].profiles,
1349
+ active_profile=rt["cfg"].active_profile,
1350
+ usage=tui.usage,
1351
+ stream=tui,
1352
+ )
1353
+ res = handle_slash(line, ctx)
1354
+ # /resume with no arg → open the interactive picker instead of printing.
1355
+ # push_screen MUST run on the main thread (it touches the Textual
1356
+ # event loop); on_input runs in the agent's background thread, so
1357
+ # dispatch via call_from_thread — same pattern as _add_text.
1358
+ if res.message == "__OPEN_PICKER__":
1359
+ summaries = Session.summaries(_P(rt["cfg"].session.save_dir).expanduser())
1360
+
1361
+ def _on_picked(sid):
1362
+ """Picker dismissed: sid is the chosen id, or None if cancelled."""
1363
+ if sid is None:
1364
+ return
1365
+ _load_session(sid)
1366
+
1367
+ tui.call_from_thread(
1368
+ tui.push_screen,
1369
+ SessionPickerScreen(
1370
+ summaries,
1371
+ save_dir=str(_P(rt["cfg"].session.save_dir).expanduser()),
1372
+ active_session_id=getattr(rt["session"], "id", ""),
1373
+ ),
1374
+ _on_picked,
1375
+ )
1376
+ return
1377
+ if res.message == "__OPEN_ONBOARDING__":
1378
+ # /setup → open the OnboardingScreen to reconfigure provider/model.
1379
+ # After it completes, rebuild the runtime with the new config.
1380
+ def _on_reconfigured(result):
1381
+ if result is None:
1382
+ return
1383
+ # Reload config + rebuild model with the new provider/key.
1384
+ from pathlib import Path as _Path
1385
+
1386
+ from coderio.llm import build_chat_model as _build
1387
+
1388
+ creds = _Path.home() / ".coderio" / "credentials"
1389
+ new_cfg = load_config(search_from=".")
1390
+ rt["cfg"] = new_cfg
1391
+ rt["model"] = _build(new_cfg, creds_path=creds)
1392
+ tui._add_text(
1393
+ f"✅ 已重新配置 → {new_cfg.model.default}({new_cfg.model.provider_id})",
1394
+ style="bold green",
1395
+ )
1396
+
1397
+ tui.call_from_thread(tui.push_screen, OnboardingScreen(), _on_reconfigured)
1398
+ return
1399
+ if res.message == "__OPEN_PROFILE_PICKER__":
1400
+ # /profile → open the ProfilePickerScreen. After the user picks,
1401
+ # write active_profile to config.toml and rebuild the model.
1402
+ profiles = rt["cfg"].profiles or []
1403
+ active_name = rt["cfg"].active_profile
1404
+ if not profiles:
1405
+ tui._add_text("[yellow]还没有保存的 profile。用 /setup 添加一个配置。[/yellow]")
1406
+ return
1407
+
1408
+ def _on_profile_picked(name):
1409
+ if name is None or name == active_name:
1410
+ return # cancelled or re-picked the same one
1411
+ _switch_active_profile(name)
1412
+ from coderio.llm import build_chat_model as _build
1413
+
1414
+ new_cfg = load_config(search_from=".")
1415
+ rt["cfg"] = new_cfg
1416
+ rt["model"] = _build(new_cfg, creds_path=creds_path)
1417
+ tui._add_text(f"✅ 已切换到配置 → {name}", style="bold green")
1418
+
1419
+ tui.call_from_thread(
1420
+ tui.push_screen,
1421
+ ProfilePickerScreen(profiles, active_name),
1422
+ _on_profile_picked,
1423
+ )
1424
+ return
1425
+ if res.message == "__OPEN_MODE_PICKER__":
1426
+ # /mode (no arg) → open the ModePickerScreen. After the user
1427
+ # picks, rebuild the gate with the new permission mode.
1428
+ current_mode = rt["gate"].mode
1429
+
1430
+ def _on_mode_picked(mode):
1431
+ if mode is None or mode == current_mode:
1432
+ return # cancelled or re-picked the same one
1433
+ from dataclasses import replace as _replace
1434
+
1435
+ from coderio.cli.repl import build_gate
1436
+
1437
+ c = _replace(rt["cfg"], tools=_replace(rt["cfg"].tools, permission_mode=mode))
1438
+ rt["cfg"] = c
1439
+ rt["gate"] = build_gate(c, console=None, tui=tui)
1440
+ tui._add_text(f"✅ 已切换到 {mode} 模式", style="bold green")
1441
+
1442
+ tui.call_from_thread(
1443
+ tui.push_screen,
1444
+ ModePickerScreen(current_mode),
1445
+ _on_mode_picked,
1446
+ )
1447
+ return
1448
+ if res.message:
1449
+ tui._add_text(res.message)
1450
+ if not res.continue_loop:
1451
+ tui.call_from_thread(tui.exit)
1452
+ return
1453
+ # /resume <explicit-id> path: load straight from the result.
1454
+ if res.new_session_id:
1455
+ _load_session(res.new_session_id)
1456
+ return
1457
+ if res.reset_runtime:
1458
+ from dataclasses import replace as _replace
1459
+
1460
+ from coderio.cli.repl import build_gate
1461
+ from coderio.llm import build_chat_model
1462
+
1463
+ c = rt["cfg"]
1464
+ if res.new_permission_mode:
1465
+ c = _replace(
1466
+ c,
1467
+ tools=_replace(c.tools, permission_mode=res.new_permission_mode),
1468
+ )
1469
+ rt["cfg"] = c
1470
+ rt["gate"] = build_gate(c, console=None, tui=tui)
1471
+ cmd_name = line.strip().split(maxsplit=1)[0]
1472
+ if cmd_name == "/clear":
1473
+ # /clear: start a fresh session + wipe active skills + clear
1474
+ # the history pane. Without this the old session's messages
1475
+ # keep being fed to the model (it reads session.messages).
1476
+ _clear_context()
1477
+ return
1478
+ if cmd_name == "/model":
1479
+ parts = line.strip().split(maxsplit=1)
1480
+ if len(parts) > 1 and parts[1].strip():
1481
+ c = _replace(c, model=_replace(c.model, default=parts[1].strip()))
1482
+ rt["cfg"] = c
1483
+ rt["model"] = build_chat_model(c, creds_path=creds_path)
1484
+ return
1485
+ from coderio.agent.deep_loop import run_deep_agent
1486
+ from coderio.cli.multimodal import build_user_content, extract_images
1487
+ from coderio.tools.command_policy import CommandPolicy
1488
+
1489
+ imgs = extract_images(line)
1490
+ if imgs:
1491
+ tui._add_text(
1492
+ f"📎 已附加 {len(imgs)} 张图片: " + ", ".join(p for p, _, _ in imgs),
1493
+ style="dim",
1494
+ )
1495
+ user_content = build_user_content(line)
1496
+ # deepagents engine: provides context management, subagents, filesystem.
1497
+ # coderio's harness + permission + command review run as middleware.
1498
+ cmd_policy = CommandPolicy(
1499
+ extra_blocked=rt["cfg"].tools.blocked_commands,
1500
+ network_allowed=rt["cfg"].tools.network_allowed,
1501
+ whitelist_mode=rt["cfg"].tools.whitelist_mode,
1502
+ allowed_commands=rt["cfg"].tools.allowed_commands,
1503
+ )
1504
+ run_deep_agent(
1505
+ user_input=user_content,
1506
+ model=rt["model"],
1507
+ session=rt["session"],
1508
+ stream=tui,
1509
+ gate=rt["gate"],
1510
+ skill_store=store,
1511
+ active_skills=active,
1512
+ tools=tools,
1513
+ workdir=rt["cfg"].tools.workspace_root or None,
1514
+ harness_enabled=rt["cfg"].skills.harness,
1515
+ command_policy=cmd_policy,
1516
+ sandbox_mode=rt["cfg"].tools.sandbox_mode,
1517
+ network_allowed=rt["cfg"].tools.network_allowed,
1518
+ fs_config=rt["cfg"].tools.sandbox_fs,
1519
+ bash_shell=rt["cfg"].tools.bash_shell,
1520
+ )
1521
+
1522
+ def _load_session(sid: str) -> None:
1523
+ """Swap the active session to a loaded one, clear skills, render history.
1524
+
1525
+ Called after the picker picks a session (or /resume <id> is given). The
1526
+ old session's jsonl stays on disk; we just point the runtime at the new
1527
+ Session object so subsequent turns continue that conversation.
1528
+ """
1529
+ from pathlib import Path as _P
1530
+
1531
+ from coderio.session.store import Session
1532
+
1533
+ save_dir = _P(rt["cfg"].session.save_dir).expanduser()
1534
+ rt["session"] = Session.load_by_id(save_dir, sid)
1535
+ active.clear()
1536
+ # Render the resumed conversation into the history pane so the user sees
1537
+ # context they're continuing, not a blank screen.
1538
+ # Count only conversation messages (exclude system-role metadata like
1539
+ # phase_timeline / context_summary so the count matches what's displayed).
1540
+ convo_msgs = [m for m in rt["session"].messages if m.role != "system"]
1541
+ tui._add_text(f"↩ 已恢复会话 {sid}({len(convo_msgs)} 条历史消息)", style="bold green")
1542
+ for m in rt["session"].messages:
1543
+ if m.role == "user":
1544
+ c = m.content
1545
+ if isinstance(c, list):
1546
+ c = " ".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text")
1547
+ tui._add_text(f"▸ you {c}", style="bold cyan")
1548
+ elif m.role == "assistant":
1549
+ tui._add_text(f" {m.content[:200]}", style="blue")
1550
+
1551
+ def _clear_context() -> None:
1552
+ """Start a fresh session + clear active skills + wipe the history pane.
1553
+
1554
+ Backs the /clear command. Without this the old session's messages keep
1555
+ being fed to the model (loop.py reads session.messages), so 'context
1556
+ cleared' was previously a lie — the model still saw the full history.
1557
+ """
1558
+ from pathlib import Path as _P
1559
+
1560
+ from coderio.session.store import Session
1561
+
1562
+ save_dir = _P(rt["cfg"].session.save_dir).expanduser()
1563
+ rt["session"] = Session.create(
1564
+ save_dir,
1565
+ {
1566
+ "model": rt["cfg"].model.default,
1567
+ "provider": rt["cfg"].model.provider,
1568
+ },
1569
+ )
1570
+ active.clear()
1571
+ # Wipe the visible history pane so the user sees a clean slate (the old
1572
+ # session's jsonl is preserved on disk — /resume can still get it back).
1573
+ tui._clear_history()
1574
+ tui._add_text("🆕 已开启新会话(历史已清空,可用 /resume 恢复)", style="bold green")
1575
+
1576
+ tui = CoderioTUI(on_input=on_input, show_tool_output=cfg.cli.show_tool_output, banner=banner)
1577
+ # Rebuild the gate with the TUI reference attached. The initial gate from
1578
+ # build_runtime doesn't have the TUI (it was constructed before tui existed).
1579
+ # Without this, confirm mode would use input() which deadlocks against
1580
+ # Textual's terminal takeover.
1581
+ from coderio.cli.repl import build_gate as _bg
1582
+
1583
+ rt["gate"] = _bg(cfg, console=None, tui=tui)
1584
+ tui.run()