coderio 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -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