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,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)
|