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.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- 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()
|