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,465 @@
1
+ """TUI-based onboarding wizard — extracted from tui.py for modularity.
2
+
3
+ Contains OnboardingScreen (multi-step ModalScreen for provider/model/key setup),
4
+ _OnboardingApp (minimal App wrapper), and _run_onboarding_tui (entry point).
5
+
6
+ All coderio dependencies are lazy imports inside methods, so this module has
7
+ zero top-level coupling to the rest of coderio — it can be imported standalone.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Vertical
15
+ from textual.screen import ModalScreen
16
+ from textual.widgets import Input, ListItem, ListView, Static
17
+
18
+
19
+ class OnboardingScreen(ModalScreen[dict | None]):
20
+ """TUI-based onboarding wizard (multi-step ModalScreen).
21
+
22
+ Uses ListView (↑↓ + Enter) for provider/model selection, Input with
23
+ password masking for API key. Dismisses with a result dict on success,
24
+ or None if cancelled.
25
+ """
26
+
27
+ CSS = """
28
+ OnboardingScreen { align: center middle; }
29
+ #onboard-box {
30
+ width: 72%; height: auto; max-height: 82%; border: round $accent;
31
+ background: $surface; padding: 1 2;
32
+ }
33
+ #onboard-title { text-align: center; margin-bottom: 1; }
34
+ #onboard-hint { color: $text-muted; }
35
+ #onboard-status { color: $text-muted; margin-top: 1; }
36
+ #onboard-input { margin-top: 1; border: round $accent; }
37
+ #onboard-input:focus { border: round $accent; }
38
+ #onboard-list { height: auto; max-height: 16; margin-top: 1; }
39
+ OnboardingScreen ListItem { padding: 0 1; }
40
+ OnboardingScreen ListItem > Widget :hover { background: $boost; }
41
+ """
42
+
43
+ BINDINGS = [
44
+ Binding("escape", "cancel", "取消", show=True),
45
+ ]
46
+
47
+ def __init__(self) -> None:
48
+ super().__init__()
49
+ from coderio.cli.providers import PROVIDERS
50
+
51
+ self._providers = PROVIDERS
52
+ self._step = "provider"
53
+ self._chosen_provider = None
54
+ self._chosen_model = ""
55
+ self._base_url = ""
56
+ self._api_key = ""
57
+ self._profile_name = ""
58
+ # Context window size probed at verify time (0 = not probed / failed).
59
+ # Persisted into the profile's context_limit so compaction uses the real
60
+ # threshold — without this, a 256K model is mistreated as the 200K default.
61
+ self._context_limit: int = 0
62
+ self._provider_items: list = [] # parallel to ListView items
63
+ # Which providers already have a saved key? (read once at open time)
64
+ from coderio.cli.credentials import read_credentials
65
+
66
+ try:
67
+ self._configured = set(read_credentials().keys())
68
+ except Exception:
69
+ self._configured = set()
70
+ # Existing profiles (for the new/edit action choice). Empty on first run.
71
+ self._existing_profiles = self._load_existing_profiles()
72
+ # When editing, the Profile being modified (None = creating new).
73
+ self._editing_profile = None
74
+
75
+ def compose(self) -> ComposeResult:
76
+ with Vertical(id="onboard-box"):
77
+ yield Static("[bold magenta]coderio 配置向导[/bold magenta]", id="onboard-title")
78
+ yield Static("", id="onboard-hint")
79
+ yield ListView(id="onboard-list")
80
+ yield Input(id="onboard-input")
81
+ yield Static("", id="onboard-status")
82
+
83
+ @staticmethod
84
+ def _load_existing_profiles() -> list:
85
+ """Read [[profiles]] from config.toml so /setup can offer to edit them.
86
+
87
+ Returns an empty list on first run or any read error — the wizard then
88
+ skips the new/edit choice and goes straight to provider selection.
89
+ """
90
+ try:
91
+ from coderio.config import load_config
92
+
93
+ cfg = load_config(search_from=".")
94
+ return list(cfg.profiles or [])
95
+ except Exception:
96
+ return []
97
+
98
+ def on_mount(self) -> None:
99
+ if self._existing_profiles:
100
+ self._show_action_step()
101
+ else:
102
+ self._show_provider_step()
103
+
104
+ def _show_action_step(self) -> None:
105
+ """Step 0 (only when profiles exist): choose 新建 or 修改 an existing one.
106
+
107
+ One row to create a new profile, then one row per existing profile (with
108
+ provider/model as a dim subtitle) to edit it. This is the /setup entry
109
+ point when the user already has at least one configured profile.
110
+ """
111
+ self._step = "action"
112
+ self.query_one("#onboard-input", Input).visible = False
113
+ self.query_one("#onboard-hint").update("选择操作(↑↓ · Enter 确认 · Esc 取消)")
114
+ lv = self.query_one("#onboard-list", ListView)
115
+ lv.display = True
116
+ lv.clear()
117
+ self._action_items: list = [] # None=new, else the Profile to edit
118
+ lv.append(ListItem(Static(" [green]➕[/green] 新建配置")))
119
+ self._action_items.append(None)
120
+ for p in self._existing_profiles:
121
+ lv.append(ListItem(Static(f" [yellow]✎[/yellow] 修改 {p.name} [dim]{p.provider_id} · {p.model}[/dim]")))
122
+ self._action_items.append(p)
123
+ try:
124
+ lv.index = 0
125
+ except Exception:
126
+ pass
127
+ lv.focus()
128
+
129
+ def _start_edit(self, profile) -> None:
130
+ """Pre-fill the wizard with an existing profile's values.
131
+
132
+ Resolves the provider from the registry (so model/key steps behave the
133
+ same as a new config), carries over the profile's base_url for custom
134
+ providers, and sets the profile name so the final name step shows it.
135
+ Then jumps to model selection — the most common edit is changing the
136
+ model or re-entering the key, not switching providers.
137
+ """
138
+ from coderio.cli.providers import get_provider
139
+
140
+ info = get_provider(profile.provider_id)
141
+ if info is not None:
142
+ self._chosen_provider = info
143
+ else:
144
+ # Provider no longer in the registry — can't offer model presets.
145
+ # Fall back to text-input model step with the profile's current model.
146
+ self._chosen_provider = type(
147
+ "_P",
148
+ (),
149
+ {
150
+ "id": profile.provider_id,
151
+ "label": profile.name,
152
+ "kind": profile.kind,
153
+ "base_url": profile.base_url,
154
+ "models": (),
155
+ "default_model": "",
156
+ "api_key_hint": "",
157
+ "plan": False,
158
+ },
159
+ )()
160
+ self._base_url = profile.base_url or (info.base_url if info else "")
161
+ self._chosen_model = profile.model
162
+ self._profile_name = profile.name
163
+ self._editing_profile = profile
164
+ self._show_model_step()
165
+
166
+ # --- step transitions ---
167
+
168
+ def _show_provider_step(self) -> None:
169
+ """Step 1: provider selection via ListView (↑↓ + Enter).
170
+
171
+ All items are real providers (no header rows) — every selectable item
172
+ maps to a ProviderInfo, so ListView navigation never lands on a dead row.
173
+ Group context is shown via a dim prefix on each label; providers that
174
+ already have a saved key are marked ✓."""
175
+ self._step = "provider"
176
+ configured_count = len(self._configured)
177
+ hint = "选择模型 provider(↑↓ 选择 · Enter 确认 · Esc 取消)" + (
178
+ f" [green]{configured_count} 个已配置[/green]" if configured_count else ""
179
+ )
180
+ self.query_one("#onboard-hint").update(hint)
181
+ self.query_one("#onboard-input", Input).visible = False
182
+ lv = self.query_one("#onboard-list", ListView)
183
+ lv.display = True
184
+ lv.clear()
185
+ self._provider_items = []
186
+
187
+ # Build a flat list with group labels. Each item is a real provider.
188
+ groups = [
189
+ ("订阅制", [p for p in self._providers if p.plan]),
190
+ (
191
+ "国内直连",
192
+ [p for p in self._providers if not p.plan and p.id in ("bigmodel_api", "stepfun_api")],
193
+ ),
194
+ ("国际", [p for p in self._providers if p.id in ("openai", "anthropic")]),
195
+ ("本地", [p for p in self._providers if p.id == "ollama"]),
196
+ ("自定义", [p for p in self._providers if p.id == "openai_custom"]),
197
+ ]
198
+ for group_name, providers in groups:
199
+ for p in providers:
200
+ ms = f" ({' / '.join(p.models[:2])}{'...' if len(p.models) > 2 else ''})" if p.models else ""
201
+ check = " [green]✓[/green]" if p.id in self._configured else " "
202
+ lv.append(ListItem(Static(f" [dim]{group_name}[/dim] {p.label}{ms}{check}")))
203
+ self._provider_items.append(p)
204
+ try:
205
+ lv.index = 0
206
+ except Exception:
207
+ pass
208
+ lv.focus()
209
+
210
+ def _show_model_step(self) -> None:
211
+ """Step 2: model selection via ListView."""
212
+ p = self._chosen_provider
213
+ if not p.models:
214
+ # No preset models (ollama/custom) — text input
215
+ self._step = "model_input"
216
+ self.query_one("#onboard-list", ListView).display = False
217
+ self.query_one("#onboard-hint").update("输入模型名(例如 qwen2.5-coder / gpt-4o):")
218
+ inp = self.query_one("#onboard-input", Input)
219
+ inp.visible = True
220
+ inp.password = False
221
+ inp.value = ""
222
+ inp.focus()
223
+ return
224
+ self._step = "model"
225
+ self.query_one("#onboard-input", Input).visible = False
226
+ lv = self.query_one("#onboard-list", ListView)
227
+ lv.display = True
228
+ lv.clear()
229
+ self._model_items = list(p.models)
230
+ for m in p.models:
231
+ star = " ★" if m == p.default_model else ""
232
+ lv.append(ListItem(Static(f" {m}{star}")))
233
+ # When editing, highlight the profile's current model (if it's in the
234
+ # preset list) so the user can just press Enter to keep it.
235
+ start_idx = 0
236
+ if self._editing_profile and self._editing_profile.model in self._model_items:
237
+ start_idx = self._model_items.index(self._editing_profile.model)
238
+ try:
239
+ lv.index = start_idx
240
+ except Exception:
241
+ pass
242
+ self.query_one("#onboard-hint").update(f"选择模型({p.label})— ★ = 推荐(↑↓ · Enter)")
243
+ lv.focus()
244
+
245
+ def _show_base_url_step(self) -> None:
246
+ """Step 2b: base_url input (openai_custom only)."""
247
+ self._step = "base_url"
248
+ self.query_one("#onboard-list", ListView).display = False
249
+ self.query_one("#onboard-hint").update("输入 base_url(例如 https://api.example.com/v1):")
250
+ inp = self.query_one("#onboard-input", Input)
251
+ inp.visible = True
252
+ inp.password = False
253
+ inp.value = ""
254
+ inp.focus()
255
+
256
+ def _show_key_step(self) -> None:
257
+ """Step 3: API key input with password masking (dots)."""
258
+ p = self._chosen_provider
259
+ if p.id == "ollama":
260
+ self._api_key = "ollama"
261
+ self._show_name_step()
262
+ return
263
+ self._step = "key"
264
+ self.query_one("#onboard-list", ListView).display = False
265
+ if self._editing_profile:
266
+ # Editing: key is optional — empty input keeps the existing key.
267
+ from coderio.cli.credentials import get_key
268
+
269
+ existing = get_key(p.id) or ""
270
+ self._api_key = existing # carry over until the user types a new one
271
+ self.query_one("#onboard-hint").update(f"输入新 API key(留空保留现有 key)— {p.api_key_hint}:")
272
+ else:
273
+ self.query_one("#onboard-hint").update(f"输入 API key({p.api_key_hint}):")
274
+ inp = self.query_one("#onboard-input", Input)
275
+ inp.visible = True
276
+ inp.password = True # masked — shows dots
277
+ inp.value = ""
278
+ inp.focus()
279
+
280
+ def _show_name_step(self) -> None:
281
+ """Step 4: name this profile (so multiple configs can coexist).
282
+
283
+ Pre-fills with the existing profile name when editing, or the provider's
284
+ label when creating new — most users will just press Enter. The name is
285
+ how /profile lists and switches between configs.
286
+ """
287
+ self._step = "name"
288
+ p = self._chosen_provider
289
+ self.query_one("#onboard-list", ListView).display = False
290
+ inp = self.query_one("#onboard-input", Input)
291
+ inp.visible = True
292
+ inp.password = False
293
+ # Editing: show the current name; new: default to the provider label.
294
+ inp.value = self._profile_name or p.label
295
+ inp.focus()
296
+ self.query_one("#onboard-hint").update("给这套配置起个名字(回车确认,稍后可用 /profile 切换):")
297
+
298
+ def _start_verification(self) -> None:
299
+ """Step 4: verify the key via a minimal API request + probe context_limit."""
300
+ self._step = "verifying"
301
+ self.query_one("#onboard-input", Input).visible = False
302
+ self.query_one("#onboard-hint").update("[bold cyan]正在验证连接...[/bold cyan]")
303
+
304
+ def _verify():
305
+ from coderio.cli.onboarding import _verify_and_probe
306
+
307
+ ok, msg, context_limit = _verify_and_probe(
308
+ self._chosen_provider, self._api_key, self._chosen_model, self._base_url
309
+ )
310
+ self.app.call_from_thread(self._on_verify_result, ok, msg, context_limit)
311
+
312
+ import threading
313
+
314
+ threading.Thread(target=_verify, daemon=True).start()
315
+
316
+ def _on_verify_result(self, ok: bool, msg: str, context_limit: int = 0) -> None:
317
+ if ok:
318
+ # Store the probed context_limit so _finish can persist it into the
319
+ # profile. Without this, /setup-configured models miss the compaction
320
+ # threshold optimization (only CLI onboarding was probing).
321
+ self._context_limit = context_limit
322
+ self.query_one("#onboard-status").update(f"[green]✅ {msg}[/green]")
323
+ self._show_name_step()
324
+ else:
325
+ self.query_one("#onboard-status").update(f"[red]❌ {msg}[/red]")
326
+ self._step = "key"
327
+ self.query_one("#onboard-hint").update("验证失败,重新输入 API key(或 Esc 取消):")
328
+ inp = self.query_one("#onboard-input", Input)
329
+ inp.password = True
330
+ inp.visible = True
331
+ inp.value = ""
332
+ inp.focus()
333
+
334
+ def _finish(self) -> None:
335
+ """Save credentials + profile, then dismiss with result."""
336
+ from pathlib import Path
337
+
338
+ from coderio.cli.credentials import write_credentials
339
+ from coderio.cli.onboarding import OnboardingResult, _save_profile_to_config
340
+
341
+ creds_path = Path.home() / ".coderio" / "credentials"
342
+ write_credentials({self._chosen_provider.id: self._api_key}, creds_path)
343
+ result = OnboardingResult(
344
+ provider_id=self._chosen_provider.id,
345
+ model=self._chosen_model,
346
+ base_url=self._base_url,
347
+ kind=self._chosen_provider.kind,
348
+ api_key=self._api_key,
349
+ context_limit=self._context_limit,
350
+ )
351
+ config_path = creds_path.parent / "config.toml"
352
+ name = self._profile_name or self._chosen_provider.label
353
+ _save_profile_to_config(result, name, config_path)
354
+ self.query_one("#onboard-status").update("[green]配置完成![/green]")
355
+ self.set_timer(
356
+ 0.8,
357
+ lambda: self.dismiss(
358
+ {
359
+ "provider_id": result.provider_id,
360
+ "model": result.model,
361
+ "profile_name": name,
362
+ }
363
+ ),
364
+ )
365
+
366
+ # --- event handlers ---
367
+
368
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
369
+ """ListView item selected (Enter pressed)."""
370
+ lv = self.query_one("#onboard-list", ListView)
371
+ idx = lv.index
372
+ if idx is None:
373
+ return
374
+ if self._step == "action":
375
+ if idx < len(self._action_items):
376
+ chosen = self._action_items[idx]
377
+ self.query_one("#onboard-status").update("")
378
+ if chosen is None:
379
+ # New profile — fresh wizard from provider selection.
380
+ self._editing_profile = None
381
+ self._show_provider_step()
382
+ else:
383
+ # Edit existing — pre-fill its values, jump to model step
384
+ # (provider stays the same in the common case).
385
+ self._start_edit(chosen)
386
+ elif self._step == "provider":
387
+ if idx < len(self._provider_items):
388
+ p = self._provider_items[idx]
389
+ self._chosen_provider = p
390
+ self.query_one("#onboard-status").update("")
391
+ if p.id == "openai_custom":
392
+ self._show_base_url_step()
393
+ else:
394
+ self._base_url = p.base_url
395
+ self._show_model_step()
396
+ elif self._step == "model":
397
+ if idx < len(self._model_items):
398
+ self._chosen_model = self._model_items[idx]
399
+ self.query_one("#onboard-status").update("")
400
+ self._show_key_step()
401
+
402
+ def on_input_submitted(self, event: Input.Submitted) -> None:
403
+ if event.input.id != "onboard-input":
404
+ return
405
+ val = event.value.strip()
406
+ if self._step == "base_url":
407
+ if val:
408
+ self._base_url = val
409
+ self._show_model_step()
410
+ else:
411
+ self.query_one("#onboard-status").update("[red]请输入 base_url[/red]")
412
+ elif self._step == "model_input":
413
+ if val:
414
+ self._chosen_model = val
415
+ self.query_one("#onboard-status").update("")
416
+ self._show_key_step()
417
+ else:
418
+ self.query_one("#onboard-status").update("[red]请输入模型名[/red]")
419
+ elif self._step == "key":
420
+ if val:
421
+ self._api_key = val
422
+ self.query_one("#onboard-status").update("")
423
+ self._start_verification()
424
+ elif self._editing_profile:
425
+ # Editing + empty input → keep the existing key, skip verification
426
+ # (it was already verified when first configured).
427
+ self.query_one("#onboard-status").update("")
428
+ self._show_name_step()
429
+ else:
430
+ self.query_one("#onboard-status").update("[red]请输入 API key[/red]")
431
+ elif self._step == "name":
432
+ self._profile_name = val or self._chosen_provider.label
433
+ self.query_one("#onboard-input", Input).visible = False
434
+ self.query_one("#onboard-hint").update("[bold cyan]正在保存...[/bold cyan]")
435
+ self._finish()
436
+
437
+ def action_cancel(self) -> None:
438
+ self.dismiss(None)
439
+
440
+
441
+ class _OnboardingApp(App):
442
+ """Minimal app that just shows the OnboardingScreen and exits.
443
+
444
+ Runs before the main CoderioTUI so the terminal is in Textual mode during
445
+ onboarding (masked key input, proper rendering) rather than raw console.
446
+ """
447
+
448
+ CSS = """
449
+ Screen { background: $surface; }
450
+ """
451
+
452
+ def on_mount(self) -> None:
453
+ def _on_done(result):
454
+ self._result = result
455
+ self.exit()
456
+
457
+ self._result = None
458
+ self.push_screen(OnboardingScreen(), _on_done)
459
+
460
+
461
+ def _run_onboarding_tui() -> dict | None:
462
+ """Run the TUI onboarding wizard. Returns the result dict or None if cancelled."""
463
+ app = _OnboardingApp()
464
+ app.run()
465
+ return getattr(app, "_result", None)