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
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""TUI widgets — extracted from tui.py for modularity.
|
|
2
|
+
|
|
3
|
+
Contains the popup slash-command menu (CommandMenu), the permission-confirmation
|
|
4
|
+
menu (ConfirmMenu), and the live status bar (StatusBar) with its animated
|
|
5
|
+
spinner + phase/timer display. These are self-contained widgets with no
|
|
6
|
+
top-level coupling to the rest of coderio.
|
|
7
|
+
|
|
8
|
+
StatusBar owns a background heartbeat thread (~80ms) that drives both the
|
|
9
|
+
spinner animation and the elapsed-timer refresh via call_from_thread.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from rich.console import RenderableType
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from textual.app import ComposeResult
|
|
20
|
+
from textual.containers import Vertical
|
|
21
|
+
from textual.widget import Widget
|
|
22
|
+
from textual.widgets import Input, ListItem, ListView, Static
|
|
23
|
+
|
|
24
|
+
# Human-readable labels for AgentState task-phase values (shown in StatusBar).
|
|
25
|
+
# Mirrors _PHASE_LABELS in cli/stream.py — duplicated rather than imported to
|
|
26
|
+
# keep this module decoupled from the Rich CLI stream module.
|
|
27
|
+
_TASK_PHASE_LABELS: dict[str, str] = {
|
|
28
|
+
"explore": "探索",
|
|
29
|
+
"plan": "规划",
|
|
30
|
+
"implement": "实现",
|
|
31
|
+
"verify": "验证",
|
|
32
|
+
"complete": "完成",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CommandMenu(Vertical):
|
|
37
|
+
"""Popup slash-command menu (Claude-Code-style autocomplete).
|
|
38
|
+
|
|
39
|
+
Shown when the input starts with "/". Lists matching commands, filtered live
|
|
40
|
+
as the user types. ↑↓ navigates, Tab/Enter fills the chosen command into the
|
|
41
|
+
input, Esc or clearing "/" hides it. Unlike SuggestFromList (which shows a
|
|
42
|
+
single inline grey suggestion), this is a visible, browsable menu — the user
|
|
43
|
+
can see all candidates and pick one with the keyboard.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
DEFAULT_CSS = """
|
|
47
|
+
CommandMenu {
|
|
48
|
+
/* Lives INSIDE #input-bar (above StatusBar). When visible it expands
|
|
49
|
+
the input-bar upward, pushing into the history pane's area — but
|
|
50
|
+
since #history is height:1fr it shrinks to accommodate, so there's
|
|
51
|
+
no overlap (unlike the old dock:bottom approach where the menu
|
|
52
|
+
floated over and hid the StatusBar's first char + lost its own
|
|
53
|
+
bottom border). height is fixed so the ListView gets a real
|
|
54
|
+
viewport (height:auto collapses to ~4 rows in practice). */
|
|
55
|
+
display: none; /* hidden until input starts with "/" */
|
|
56
|
+
height: 10;
|
|
57
|
+
background: $surface;
|
|
58
|
+
border: round $accent;
|
|
59
|
+
padding: 0;
|
|
60
|
+
margin: 0;
|
|
61
|
+
}
|
|
62
|
+
CommandMenu.-visible { display: block; }
|
|
63
|
+
CommandMenu ListView { background: $surface; }
|
|
64
|
+
CommandMenu ListItem { padding: 0 1; }
|
|
65
|
+
CommandMenu ListItem > Widget :hover { background: $boost; }
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, completions: list[str]) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self._all = completions
|
|
71
|
+
self._input: Input | None = None # the Input this menu feeds
|
|
72
|
+
# Value last accepted by Tab/Enter. refresh_for skips reopening while the
|
|
73
|
+
# input still equals this — otherwise setting .value in accept() retriggers
|
|
74
|
+
# on_input_changed and the menu pops right back open.
|
|
75
|
+
self._accepted_value: str | None = None
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
yield ListView(id="cmd-list")
|
|
79
|
+
|
|
80
|
+
def bind_input(self, inp: Input) -> None:
|
|
81
|
+
self._input = inp
|
|
82
|
+
|
|
83
|
+
def _matches(self, prefix: str) -> list[str]:
|
|
84
|
+
if not prefix:
|
|
85
|
+
return []
|
|
86
|
+
p = prefix.lower()
|
|
87
|
+
# rank: exact prefix match first, then substring contains.
|
|
88
|
+
exact = [c for c in self._all if c.lower().startswith(p)]
|
|
89
|
+
sub = [c for c in self._all if p in c.lower() and c not in exact]
|
|
90
|
+
return exact + sub
|
|
91
|
+
|
|
92
|
+
def refresh_for(self, value: str) -> None:
|
|
93
|
+
"""Re-filter and show/hide based on the current input value."""
|
|
94
|
+
# If the user just accepted a command (Tab/Enter), the value now equals
|
|
95
|
+
# the chosen command. Don't reopen the menu for that exact value — it
|
|
96
|
+
# was set programmatically by accept(), not typed. Once the user edits
|
|
97
|
+
# further (value differs), normal filtering resumes.
|
|
98
|
+
if value == self._accepted_value:
|
|
99
|
+
return
|
|
100
|
+
self._accepted_value = None # value changed -> clear the guard
|
|
101
|
+
if not value.startswith("/"):
|
|
102
|
+
self.remove_class("-visible")
|
|
103
|
+
return
|
|
104
|
+
matches = self._matches(value)
|
|
105
|
+
if not matches:
|
|
106
|
+
self.remove_class("-visible")
|
|
107
|
+
return
|
|
108
|
+
lv = self.query_one("#cmd-list", ListView)
|
|
109
|
+
lv.clear()
|
|
110
|
+
for c in matches:
|
|
111
|
+
lv.append(ListItem(Static(c), name=c))
|
|
112
|
+
self.add_class("-visible")
|
|
113
|
+
# auto-select the first (top) match so Enter is immediately meaningful
|
|
114
|
+
try:
|
|
115
|
+
lv.index = 0
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def visible(self) -> bool:
|
|
120
|
+
return self.has_class("-visible")
|
|
121
|
+
|
|
122
|
+
def move(self, delta: int) -> None:
|
|
123
|
+
"""Move the selection by delta (+1 down, -1 up); wraps around.
|
|
124
|
+
|
|
125
|
+
After moving, proactively scrolls so the highlight stays at least two
|
|
126
|
+
rows inside the visible viewport (not flush against the edge). Textual's
|
|
127
|
+
default scroll_to_widget only reacts once the highlighted item is FULLY
|
|
128
|
+
off-screen, which makes the menu feel unresponsive — the user presses
|
|
129
|
+
down several times before the view catches up. We compute the target
|
|
130
|
+
scroll_y directly to trigger earlier.
|
|
131
|
+
|
|
132
|
+
Robustness note: we do NOT trust lv.size.height blindly. In a real
|
|
133
|
+
terminal the CommandMenu (dock:bottom, max-height:12) overlaps the
|
|
134
|
+
history pane, and the *effective* visible row count can be smaller
|
|
135
|
+
than what the ListView reports (border/padding/overlap). So we clamp
|
|
136
|
+
the assumed viewport height to a conservative cap and keep a 2-row
|
|
137
|
+
margin on both sides — even if the reported height is slightly off,
|
|
138
|
+
the highlight stays in the safe zone.
|
|
139
|
+
"""
|
|
140
|
+
if not self.visible():
|
|
141
|
+
return
|
|
142
|
+
lv = self.query_one("#cmd-list", ListView)
|
|
143
|
+
if not lv.children:
|
|
144
|
+
return
|
|
145
|
+
n = len(lv.children)
|
|
146
|
+
idx = lv.index or 0
|
|
147
|
+
new_idx = (idx + delta) % n
|
|
148
|
+
lv.index = new_idx
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Effective viewport height for scroll math. The ListView reports
|
|
152
|
+
# its computed height, but in a real terminal the menu's visible
|
|
153
|
+
# area can be smaller (overlap with history border, conpty quirks).
|
|
154
|
+
# Cap to a conservative value and keep a 2-row margin on each side
|
|
155
|
+
# so the highlight never sits on the very edge.
|
|
156
|
+
vp_h = lv.size.height
|
|
157
|
+
if vp_h <= 0:
|
|
158
|
+
vp_h = 8 # fallback if layout hasn't settled yet
|
|
159
|
+
margin = 2 # rows of breathing room above AND below the highlight
|
|
160
|
+
row = new_idx
|
|
161
|
+
if delta > 0:
|
|
162
|
+
# Moving DOWN: keep the highlight within the bottom margin.
|
|
163
|
+
# Target scroll_y so row sits at most (vp_h - margin - 1) rows
|
|
164
|
+
# below the viewport top.
|
|
165
|
+
target = max(0, row - (vp_h - margin - 1))
|
|
166
|
+
else:
|
|
167
|
+
# Moving UP: keep the highlight within the top margin.
|
|
168
|
+
# Target scroll_y so row sits at least `margin` rows below top.
|
|
169
|
+
target = max(0, row - margin)
|
|
170
|
+
# Clamp to valid range and apply if it actually changes the view.
|
|
171
|
+
target = max(0, min(lv.max_scroll_y, target))
|
|
172
|
+
if target != lv.scroll_y:
|
|
173
|
+
lv.scroll_y = target
|
|
174
|
+
# Belt-and-suspenders: also ask Textual to ensure visibility. This
|
|
175
|
+
# is a no-op when already visible, but catches any edge case the
|
|
176
|
+
# manual computation missed (e.g. vp_h reporting a stale value).
|
|
177
|
+
lv.scroll_to_widget(lv.children[new_idx], animate=False, top=False)
|
|
178
|
+
except Exception:
|
|
179
|
+
try:
|
|
180
|
+
lv.scroll_to_widget(lv.children[new_idx], animate=False)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def accept(self) -> bool:
|
|
185
|
+
"""Fill the selected command into the bound Input. Returns True if accepted."""
|
|
186
|
+
if not self.visible() or self._input is None:
|
|
187
|
+
return False
|
|
188
|
+
lv = self.query_one("#cmd-list", ListView)
|
|
189
|
+
if not lv.children or lv.index is None:
|
|
190
|
+
return False
|
|
191
|
+
chosen = lv.children[lv.index].name or ""
|
|
192
|
+
if not chosen:
|
|
193
|
+
return False
|
|
194
|
+
# Record the accepted value so the resulting on_input_changed doesn't
|
|
195
|
+
# reopen the menu (setting .value fires changed, value still starts "/").
|
|
196
|
+
self._accepted_value = chosen
|
|
197
|
+
self._input.value = chosen
|
|
198
|
+
self.remove_class("-visible")
|
|
199
|
+
self._input.focus()
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def hide(self) -> None:
|
|
203
|
+
self.remove_class("-visible")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ConfirmMenu(Vertical):
|
|
207
|
+
"""Permission-confirmation menu (zcode/codex-style vertical selection).
|
|
208
|
+
|
|
209
|
+
Replaces the old three-button #confirm-row (whose Button borders inflated
|
|
210
|
+
the layout and left a black gap). This is a vertical ListView floating
|
|
211
|
+
above the input box: ↑↓ moves the highlight, Enter confirms the choice,
|
|
212
|
+
Esc cancels (= deny). The user sees all options at once and picks with
|
|
213
|
+
the keyboard — same interaction model as the slash-command CommandMenu.
|
|
214
|
+
|
|
215
|
+
Lifecycle: the agent's background thread calls show() (via call_from_thread);
|
|
216
|
+
the main thread's on_key drives move()/accept(); accept() returns the chosen
|
|
217
|
+
option name ("allow"/"deny"/"custom") so on_key can dispatch to
|
|
218
|
+
_resolve_confirmation / _enter_custom_mode. hide() is called when the agent
|
|
219
|
+
thread resumes.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
DEFAULT_CSS = """
|
|
223
|
+
ConfirmMenu {
|
|
224
|
+
/* Hidden by default. When visible it sits between #status-row and #msg,
|
|
225
|
+
expanding the input-bar upward (history shrinks to fit). Fixed height
|
|
226
|
+
= 1 prompt row + 3 option rows; height:auto lets the ListView grow
|
|
227
|
+
unbounded and fill the whole screen. */
|
|
228
|
+
display: none;
|
|
229
|
+
height: 5;
|
|
230
|
+
background: $surface;
|
|
231
|
+
border: round $accent;
|
|
232
|
+
padding: 0;
|
|
233
|
+
margin: 0;
|
|
234
|
+
}
|
|
235
|
+
ConfirmMenu.-visible { display: block; }
|
|
236
|
+
ConfirmMenu ListView { background: $surface; height: 3; }
|
|
237
|
+
ConfirmMenu ListItem { padding: 0 1; }
|
|
238
|
+
/* Prompt line at the top showing the tool + args. */
|
|
239
|
+
ConfirmMenu #confirm-prompt { color: $text; padding: 0 1; height: 1; }
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
# Choice constants — accept() returns one of these.
|
|
243
|
+
ALLOW = "allow"
|
|
244
|
+
DENY = "deny"
|
|
245
|
+
CUSTOM = "custom"
|
|
246
|
+
|
|
247
|
+
def compose(self) -> ComposeResult:
|
|
248
|
+
yield Static("⚠", id="confirm-prompt")
|
|
249
|
+
yield ListView(id="confirm-list")
|
|
250
|
+
|
|
251
|
+
def visible(self) -> bool:
|
|
252
|
+
return self.has_class("-visible")
|
|
253
|
+
|
|
254
|
+
def show(self, tool_name: str, args_str: str) -> None:
|
|
255
|
+
"""Populate options and reveal the menu. MAIN THREAD (call_from_thread)."""
|
|
256
|
+
prompt = self.query_one("#confirm-prompt", Static)
|
|
257
|
+
prompt.update(f"⚠ {tool_name}({args_str})")
|
|
258
|
+
lv = self.query_one("#confirm-list", ListView)
|
|
259
|
+
lv.clear()
|
|
260
|
+
lv.append(ListItem(Static("✅ 允许执行"), name=self.ALLOW))
|
|
261
|
+
lv.append(ListItem(Static("❌ 拒绝"), name=self.DENY))
|
|
262
|
+
lv.append(ListItem(Static("✎ 自定义回复"), name=self.CUSTOM))
|
|
263
|
+
self.add_class("-visible")
|
|
264
|
+
try:
|
|
265
|
+
lv.index = 0 # default-select the first (allow)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
def hide(self) -> None:
|
|
270
|
+
self.remove_class("-visible")
|
|
271
|
+
try:
|
|
272
|
+
self.query_one("#confirm-list", ListView).clear()
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
def move(self, delta: int) -> None:
|
|
277
|
+
"""Move the selection by delta (+1 down, -1 up); wraps around."""
|
|
278
|
+
if not self.visible():
|
|
279
|
+
return
|
|
280
|
+
lv = self.query_one("#confirm-list", ListView)
|
|
281
|
+
if not lv.children:
|
|
282
|
+
return
|
|
283
|
+
n = len(lv.children)
|
|
284
|
+
idx = lv.index or 0
|
|
285
|
+
lv.index = (idx + delta) % n
|
|
286
|
+
|
|
287
|
+
def accept(self) -> str | None:
|
|
288
|
+
"""Return the selected option's name, or None if nothing valid.
|
|
289
|
+
|
|
290
|
+
Does NOT write to any Input — the caller (on_key) decides what to do
|
|
291
|
+
with the returned choice.
|
|
292
|
+
"""
|
|
293
|
+
if not self.visible():
|
|
294
|
+
return None
|
|
295
|
+
lv = self.query_one("#confirm-list", ListView)
|
|
296
|
+
if not lv.children or lv.index is None:
|
|
297
|
+
return None
|
|
298
|
+
return lv.children[lv.index].name
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class StatusBar(Widget):
|
|
302
|
+
"""Live status bar: animated spinner + phase + step + elapsed timer.
|
|
303
|
+
|
|
304
|
+
Modeled on Claude Code's bottom indicator: a braille-dot spinner that
|
|
305
|
+
ANIMATES while the agent is working (cycling ⠋⠙⠹⠸⠼⠴⠦⠧ at ~12fps), followed by
|
|
306
|
+
a concrete phase label ("步骤1 · 执行 read_file(1/3)") and a live elapsed
|
|
307
|
+
timer. When idle the spinner stops and shows a static "(就绪)".
|
|
308
|
+
|
|
309
|
+
Owns its own background-thread heartbeat (~80ms) that drives BOTH the spinner
|
|
310
|
+
animation and the timer refresh via call_from_thread(refresh, layout=False).
|
|
311
|
+
The phase/tool/step attributes are written by the agent thread (plain
|
|
312
|
+
attribute writes, GIL-safe); render() reads them on the main thread.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
# Claude Code's spinner frames (reverse-engineered, ~80ms each).
|
|
316
|
+
_SPINNER = "⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏".split()
|
|
317
|
+
_BEAT_MS = 0.08 # ~12.5fps — smooth animation, matches Claude Code
|
|
318
|
+
|
|
319
|
+
DEFAULT_CSS = """
|
|
320
|
+
StatusBar {
|
|
321
|
+
height: 1; padding: 0 1;
|
|
322
|
+
background: $boost; color: $text-muted;
|
|
323
|
+
}
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(self) -> None:
|
|
327
|
+
super().__init__()
|
|
328
|
+
self.phase: str = "idle"
|
|
329
|
+
self.phase_start: float = 0.0
|
|
330
|
+
self.tool_name: str = ""
|
|
331
|
+
self.step: int = 0
|
|
332
|
+
self.tool_index: int = 0
|
|
333
|
+
self.tool_total: int = 0
|
|
334
|
+
# Task-level phase (explore/plan/implement/verify/complete), orthogonal
|
|
335
|
+
# to self.phase (which tracks model micro-activity: thinking/responding/tool).
|
|
336
|
+
# Derived from harness ground truth via AgentStateTracker. Empty = unknown.
|
|
337
|
+
self.task_phase: str = ""
|
|
338
|
+
# Turn-level token consumption (input + output). Reset on step_start,
|
|
339
|
+
# accumulated via add_usage, cleared on finish. 0 = not shown (idle).
|
|
340
|
+
self.turn_tokens: int = 0
|
|
341
|
+
self._app = None
|
|
342
|
+
self._spin_frame = 0 # cycles through _SPINNER each heartbeat while active
|
|
343
|
+
|
|
344
|
+
def on_mount(self) -> None:
|
|
345
|
+
self._app = self.app
|
|
346
|
+
self._beat_stop = threading.Event()
|
|
347
|
+
t = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
|
348
|
+
t.start()
|
|
349
|
+
|
|
350
|
+
def _heartbeat_loop(self) -> None:
|
|
351
|
+
"""Background thread: wake ~12x/sec, advance the spinner frame, and force
|
|
352
|
+
a repaint. This drives the ANIMATION (the spinner visibly cycles) and the
|
|
353
|
+
elapsed timer — both update in lockstep. Runs off the main thread; the
|
|
354
|
+
only main-thread touch is call_from_thread(refresh)."""
|
|
355
|
+
while not self._beat_stop.wait(self._BEAT_MS):
|
|
356
|
+
try:
|
|
357
|
+
if self._app is None or not self._app.is_running:
|
|
358
|
+
break
|
|
359
|
+
if self.phase != "idle":
|
|
360
|
+
self._spin_frame = (self._spin_frame + 1) % len(self._SPINNER)
|
|
361
|
+
self._app.call_from_thread(self.refresh, layout=False)
|
|
362
|
+
except Exception:
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
def on_unmount(self) -> None:
|
|
366
|
+
self._beat_stop.set()
|
|
367
|
+
|
|
368
|
+
def set_phase(
|
|
369
|
+
self,
|
|
370
|
+
phase: str,
|
|
371
|
+
tool_name: str = "",
|
|
372
|
+
step: int = 0,
|
|
373
|
+
tool_index: int = 0,
|
|
374
|
+
tool_total: int = 0,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Update the displayed phase (safe to call from ANY thread).
|
|
377
|
+
|
|
378
|
+
Only mutates plain attributes (GIL-safe). Does NOT call refresh() — that
|
|
379
|
+
is widget-state mutation requiring the main thread. The background
|
|
380
|
+
heartbeat picks up the new phase within ~100ms and repaints.
|
|
381
|
+
"""
|
|
382
|
+
self.phase = phase
|
|
383
|
+
self.phase_start = time.monotonic() if phase != "idle" else 0.0
|
|
384
|
+
if tool_name:
|
|
385
|
+
self.tool_name = tool_name
|
|
386
|
+
if step:
|
|
387
|
+
self.step = step
|
|
388
|
+
if tool_total:
|
|
389
|
+
self.tool_index = tool_index
|
|
390
|
+
self.tool_total = tool_total
|
|
391
|
+
|
|
392
|
+
def set_task_phase(self, task_phase: str) -> None:
|
|
393
|
+
"""Update the task-level phase tag (explore/plan/implement/verify/...).
|
|
394
|
+
|
|
395
|
+
Safe from any thread (plain attribute write, GIL-safe). Repainted by the
|
|
396
|
+
heartbeat. Pass "" to clear.
|
|
397
|
+
"""
|
|
398
|
+
self.task_phase = task_phase
|
|
399
|
+
|
|
400
|
+
def set_turn_tokens(self, tokens: int) -> None:
|
|
401
|
+
"""Update the turn-level token count shown in the status bar.
|
|
402
|
+
|
|
403
|
+
Safe from any thread (plain attribute write). Repainted by the heartbeat.
|
|
404
|
+
Pass 0 to hide (idle state).
|
|
405
|
+
"""
|
|
406
|
+
self.turn_tokens = tokens
|
|
407
|
+
|
|
408
|
+
def render(self) -> RenderableType:
|
|
409
|
+
# Build a phase label that shows WHERE in the task the agent is, so the
|
|
410
|
+
# user can distinguish "still working, step 3" from "frozen". The step
|
|
411
|
+
# number + tool index give concrete progress, not just a vague spinner.
|
|
412
|
+
step_tag = f"步骤{self.step}" if self.step else ""
|
|
413
|
+
if self.phase == "tool" and self.tool_total > 1:
|
|
414
|
+
tool_tag = f"{self.tool_name}({self.tool_index + 1}/{self.tool_total})"
|
|
415
|
+
elif self.phase == "tool":
|
|
416
|
+
tool_tag = self.tool_name or "工具"
|
|
417
|
+
else:
|
|
418
|
+
tool_tag = ""
|
|
419
|
+
labels = {
|
|
420
|
+
"idle": "(就绪)",
|
|
421
|
+
"thinking": "思考中",
|
|
422
|
+
"responding": "输出中",
|
|
423
|
+
"tool": f"执行 {tool_tag}",
|
|
424
|
+
}
|
|
425
|
+
if self.phase == "idle":
|
|
426
|
+
return Text(labels["idle"], style="dim")
|
|
427
|
+
elapsed = time.monotonic() - self.phase_start if self.phase_start else 0.0
|
|
428
|
+
label = labels.get(self.phase, self.phase)
|
|
429
|
+
# The spinner ANIMATES: each heartbeat advances _spin_frame.
|
|
430
|
+
spin = self._SPINNER[self._spin_frame]
|
|
431
|
+
# Build the Text by APPENDING separate spans. The braille spinner and the
|
|
432
|
+
# CJK text are in different segments so Textual computes cell widths
|
|
433
|
+
# independently (mixing them in one f-string miscalculates width and can
|
|
434
|
+
# eat the adjacent CJK char). Separate spans + overflow='ellipsis' + no_wrap.
|
|
435
|
+
parts = []
|
|
436
|
+
if step_tag:
|
|
437
|
+
parts.append(step_tag)
|
|
438
|
+
# Task-level phase tag (e.g. [实现]) derived from harness ground truth.
|
|
439
|
+
# Shown before the micro-activity label so the user sees both axes:
|
|
440
|
+
# "步骤3 · [实现] 思考中 · 12.4s".
|
|
441
|
+
if self.task_phase:
|
|
442
|
+
task_label = _TASK_PHASE_LABELS.get(self.task_phase, self.task_phase)
|
|
443
|
+
parts.append(f"[{task_label}]")
|
|
444
|
+
parts.append(label)
|
|
445
|
+
parts.append(f"{elapsed:.1f}s")
|
|
446
|
+
# Turn-level token consumption (only while a turn is active).
|
|
447
|
+
if self.turn_tokens > 0:
|
|
448
|
+
parts.append(f"{self.turn_tokens} tok")
|
|
449
|
+
body = " · ".join(parts)
|
|
450
|
+
t = Text(no_wrap=True, overflow="ellipsis")
|
|
451
|
+
t.append(spin + " ", style="bold cyan")
|
|
452
|
+
t.append(body)
|
|
453
|
+
return t
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from coderio.config.loader import load_config
|
|
2
|
+
from coderio.config.models import (
|
|
3
|
+
Config,
|
|
4
|
+
ContextConfig,
|
|
5
|
+
ModelConfig,
|
|
6
|
+
Profile,
|
|
7
|
+
SessionConfig,
|
|
8
|
+
SkillsConfig,
|
|
9
|
+
ToolsConfig,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Config",
|
|
14
|
+
"ModelConfig",
|
|
15
|
+
"Profile",
|
|
16
|
+
"ToolsConfig",
|
|
17
|
+
"SkillsConfig",
|
|
18
|
+
"ContextConfig",
|
|
19
|
+
"SessionConfig",
|
|
20
|
+
"load_config",
|
|
21
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
STRUCTURE = (".coderio", ".coderio/skills", ".coderio/sessions", ".coderio/logs")
|
|
6
|
+
|
|
7
|
+
_SAMPLE_CONFIG = """# coderio configuration.
|
|
8
|
+
# The [model] section is populated by the onboarding wizard on first run.
|
|
9
|
+
# You can also edit it manually. API keys are stored in ~/.coderio/credentials.
|
|
10
|
+
|
|
11
|
+
[tools]
|
|
12
|
+
bash_shell = "" # empty = auto-detect (Git Bash on Windows, bash on Linux/macOS)
|
|
13
|
+
permission_mode = "confirm" # confirm | plan | auto_edit | full
|
|
14
|
+
|
|
15
|
+
[skills]
|
|
16
|
+
auto_load = true
|
|
17
|
+
|
|
18
|
+
[session]
|
|
19
|
+
save_dir = "~/.coderio/sessions"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ensure_user_dirs(user_dir: Path | str | None = None) -> Path:
|
|
24
|
+
"""Create ~/.coderio skeleton if missing. Idempotent: never overwrites config.toml.
|
|
25
|
+
|
|
26
|
+
Returns the .coderio directory path.
|
|
27
|
+
"""
|
|
28
|
+
if user_dir is None:
|
|
29
|
+
base = Path.home()
|
|
30
|
+
else:
|
|
31
|
+
base = Path(user_dir)
|
|
32
|
+
root = base / ".coderio"
|
|
33
|
+
for sub in ("skills", "sessions", "logs"):
|
|
34
|
+
(root / sub).mkdir(parents=True, exist_ok=True)
|
|
35
|
+
cfg = root / "config.toml"
|
|
36
|
+
if not cfg.is_file():
|
|
37
|
+
cfg.write_text(_SAMPLE_CONFIG, encoding="utf-8")
|
|
38
|
+
return root
|