ccoding 0.1.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.
- ccode/__init__.py +7 -0
- ccode/__main__.py +3 -0
- ccode/api.py +140 -0
- ccode/cli.py +12 -0
- ccode/config.py +145 -0
- ccode/env_schema.json +979 -0
- ccode/logo.py +177 -0
- ccode/ui.py +727 -0
- ccoding-0.1.0.dist-info/METADATA +144 -0
- ccoding-0.1.0.dist-info/RECORD +13 -0
- ccoding-0.1.0.dist-info/WHEEL +4 -0
- ccoding-0.1.0.dist-info/entry_points.txt +2 -0
- ccoding-0.1.0.dist-info/licenses/LICENSE +21 -0
ccode/ui.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
import curses.ascii
|
|
5
|
+
import random
|
|
6
|
+
from .api import (
|
|
7
|
+
build_models_by_owner,
|
|
8
|
+
fetch_models,
|
|
9
|
+
launch_claude,
|
|
10
|
+
model_options,
|
|
11
|
+
owner_options,
|
|
12
|
+
validate_launch_requirements,
|
|
13
|
+
validate_models,
|
|
14
|
+
)
|
|
15
|
+
from .config import (
|
|
16
|
+
MODEL_KEYS,
|
|
17
|
+
MODEL_LABELS,
|
|
18
|
+
env_schema_description,
|
|
19
|
+
env_schema_enum,
|
|
20
|
+
env_schema_keys,
|
|
21
|
+
load_config,
|
|
22
|
+
mask_secret,
|
|
23
|
+
save_config,
|
|
24
|
+
update_model_id,
|
|
25
|
+
update_model_owner,
|
|
26
|
+
)
|
|
27
|
+
from .logo import (
|
|
28
|
+
LOGOS,
|
|
29
|
+
render_style_glitch,
|
|
30
|
+
render_style_pulse,
|
|
31
|
+
render_style_rain,
|
|
32
|
+
render_style_wave,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 配置界面 field 类型标识
|
|
36
|
+
_FIELD_BASE_URL = "base_url"
|
|
37
|
+
_FIELD_API_KEY = "api_key"
|
|
38
|
+
_FIELD_TOGGLE = "toggle:" # 前缀 + key
|
|
39
|
+
_FIELD_ADD_TOGGLE = "__add__" # 新增行
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def addstr_safe(stdscr: curses.window, y: int, x: int, text: str, attr: int = 0) -> None:
|
|
43
|
+
height, width = stdscr.getmaxyx()
|
|
44
|
+
if y < 0 or y >= height:
|
|
45
|
+
return
|
|
46
|
+
if x < 0:
|
|
47
|
+
text = text[-x:]
|
|
48
|
+
x = 0
|
|
49
|
+
if x >= width:
|
|
50
|
+
return
|
|
51
|
+
available = width - x
|
|
52
|
+
if available <= 0:
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
stdscr.addstr(y, x, text[:available], attr)
|
|
56
|
+
except curses.error:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CursesApp:
|
|
61
|
+
def __init__(self, args: list[str]) -> None:
|
|
62
|
+
self.args = args
|
|
63
|
+
self.config = load_config()
|
|
64
|
+
self.models_data: list[dict[str, str]] | None = None
|
|
65
|
+
self.models_by_owner: dict[str, list[str]] = {}
|
|
66
|
+
self.status_message = ""
|
|
67
|
+
self.active_screen = "main"
|
|
68
|
+
self.main_focus_row = 0
|
|
69
|
+
self.main_focus_field = 0
|
|
70
|
+
self.config_focus_index = 0
|
|
71
|
+
# 文本字段光标位置
|
|
72
|
+
self.config_cursor: dict[str, int] = {
|
|
73
|
+
_FIELD_BASE_URL: len(self.config.get("base_url", "")),
|
|
74
|
+
_FIELD_API_KEY: len(self.config.get("api_key", "")),
|
|
75
|
+
}
|
|
76
|
+
# 新增 toggle 时 key / value 的输入状态
|
|
77
|
+
self._add_phase: int = 0 # 0=未激活, 1=输入key, 2=输入value
|
|
78
|
+
self._add_key: str = ""
|
|
79
|
+
self._add_key_cursor: int = 0
|
|
80
|
+
self._add_value: str = ""
|
|
81
|
+
self._add_value_cursor: int = 0
|
|
82
|
+
# Tab 补全:仅追踪当前选中索引,候选列表按需从 _live_matches() 获取
|
|
83
|
+
self._complete_idx: int = -1
|
|
84
|
+
|
|
85
|
+
self.should_exit = False
|
|
86
|
+
self.frame = 0
|
|
87
|
+
self.use_color = False
|
|
88
|
+
self.color_pairs: list[int] = []
|
|
89
|
+
self.logo_palette = [
|
|
90
|
+
(curses.COLOR_CYAN, curses.COLOR_BLUE),
|
|
91
|
+
(curses.COLOR_BLUE, curses.COLOR_MAGENTA),
|
|
92
|
+
(curses.COLOR_MAGENTA, curses.COLOR_RED),
|
|
93
|
+
(curses.COLOR_RED, curses.COLOR_YELLOW),
|
|
94
|
+
(curses.COLOR_YELLOW, curses.COLOR_GREEN),
|
|
95
|
+
]
|
|
96
|
+
self.logo_lines = random.choice(LOGOS)
|
|
97
|
+
self.render_style = random.choice(["wave", "pulse", "glitch", "rain"])
|
|
98
|
+
self.update_models_by_owner()
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# 动态构建配置界面 field 列表
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
def _config_fields(self) -> list[str]:
|
|
104
|
+
toggles = self.config.get("toggles", {})
|
|
105
|
+
fields = [_FIELD_BASE_URL, _FIELD_API_KEY]
|
|
106
|
+
for k in toggles:
|
|
107
|
+
fields.append(f"{_FIELD_TOGGLE}{k}")
|
|
108
|
+
fields.append(_FIELD_ADD_TOGGLE)
|
|
109
|
+
return fields
|
|
110
|
+
|
|
111
|
+
def _toggle_key_from_field(self, field: str) -> str:
|
|
112
|
+
return field[len(_FIELD_TOGGLE):]
|
|
113
|
+
|
|
114
|
+
def update_models_by_owner(self) -> None:
|
|
115
|
+
self.models_by_owner = build_models_by_owner(self.models_data)
|
|
116
|
+
|
|
117
|
+
def fetch_and_store_models(
|
|
118
|
+
self, base_url: str, api_key: str, save: bool = True
|
|
119
|
+
) -> str | None:
|
|
120
|
+
try:
|
|
121
|
+
models = fetch_models(base_url, api_key)
|
|
122
|
+
except RuntimeError as exc:
|
|
123
|
+
return str(exc)
|
|
124
|
+
self.models_data = models
|
|
125
|
+
self.config["base_url"] = base_url
|
|
126
|
+
self.config["api_key"] = api_key
|
|
127
|
+
changed = validate_models(self.config, models)
|
|
128
|
+
if save or changed:
|
|
129
|
+
save_config(self.config)
|
|
130
|
+
self.update_models_by_owner()
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def refresh_models(self) -> None:
|
|
134
|
+
self.status_message = ""
|
|
135
|
+
base_url = self.config.get("base_url", "").strip()
|
|
136
|
+
api_key = self.config.get("api_key", "").strip()
|
|
137
|
+
if not base_url or not api_key:
|
|
138
|
+
self.status_message = "Base URL and API key are required. Open config with c."
|
|
139
|
+
return
|
|
140
|
+
error = self.fetch_and_store_models(base_url, api_key)
|
|
141
|
+
if error:
|
|
142
|
+
self.status_message = error
|
|
143
|
+
return
|
|
144
|
+
self.status_message = "Models refreshed."
|
|
145
|
+
|
|
146
|
+
def init_colors(self) -> None:
|
|
147
|
+
if not curses.has_colors():
|
|
148
|
+
return
|
|
149
|
+
try:
|
|
150
|
+
curses.start_color()
|
|
151
|
+
curses.use_default_colors()
|
|
152
|
+
except curses.error:
|
|
153
|
+
return
|
|
154
|
+
self.use_color = True
|
|
155
|
+
self.color_pairs = []
|
|
156
|
+
pair_id = 1
|
|
157
|
+
for fg, bg in self.logo_palette:
|
|
158
|
+
try:
|
|
159
|
+
curses.init_pair(pair_id, fg, bg)
|
|
160
|
+
except curses.error:
|
|
161
|
+
break
|
|
162
|
+
self.color_pairs.append(pair_id)
|
|
163
|
+
pair_id += 1
|
|
164
|
+
|
|
165
|
+
def render_logo(self, stdscr: curses.window, start_y: int) -> None:
|
|
166
|
+
kwargs = dict(
|
|
167
|
+
stdscr=stdscr,
|
|
168
|
+
lines=self.logo_lines,
|
|
169
|
+
start_y=start_y,
|
|
170
|
+
frame=self.frame,
|
|
171
|
+
use_color=self.use_color,
|
|
172
|
+
color_pairs=self.color_pairs,
|
|
173
|
+
)
|
|
174
|
+
if self.render_style == "wave":
|
|
175
|
+
render_style_wave(**kwargs)
|
|
176
|
+
elif self.render_style == "pulse":
|
|
177
|
+
render_style_pulse(**kwargs)
|
|
178
|
+
elif self.render_style == "glitch":
|
|
179
|
+
render_style_glitch(**kwargs)
|
|
180
|
+
elif self.render_style == "rain":
|
|
181
|
+
render_style_rain(**kwargs)
|
|
182
|
+
else:
|
|
183
|
+
render_style_wave(**kwargs)
|
|
184
|
+
|
|
185
|
+
def run(self, stdscr: curses.window) -> None:
|
|
186
|
+
stdscr.keypad(True)
|
|
187
|
+
curses.noecho()
|
|
188
|
+
curses.cbreak()
|
|
189
|
+
stdscr.timeout(50)
|
|
190
|
+
self.init_colors()
|
|
191
|
+
if self.active_screen == "main":
|
|
192
|
+
self.refresh_models()
|
|
193
|
+
while not self.should_exit:
|
|
194
|
+
stdscr.erase()
|
|
195
|
+
if self.active_screen == "main":
|
|
196
|
+
self.render_main(stdscr)
|
|
197
|
+
else:
|
|
198
|
+
self.render_config(stdscr)
|
|
199
|
+
stdscr.refresh()
|
|
200
|
+
key = stdscr.getch()
|
|
201
|
+
if key == curses.KEY_RESIZE:
|
|
202
|
+
continue
|
|
203
|
+
if key == -1:
|
|
204
|
+
self.frame += 1
|
|
205
|
+
continue
|
|
206
|
+
if self.active_screen == "main":
|
|
207
|
+
self.handle_main_key(stdscr, key)
|
|
208
|
+
else:
|
|
209
|
+
self.handle_config_key(key)
|
|
210
|
+
self.frame += 1
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Main screen
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
def render_main(self, stdscr: curses.window) -> None:
|
|
216
|
+
try:
|
|
217
|
+
curses.curs_set(0)
|
|
218
|
+
except curses.error:
|
|
219
|
+
pass
|
|
220
|
+
y = 1
|
|
221
|
+
start_y = y
|
|
222
|
+
self.render_logo(stdscr, start_y)
|
|
223
|
+
y = start_y + len(self.logo_lines) + 1
|
|
224
|
+
_height, width = stdscr.getmaxyx()
|
|
225
|
+
rows: list[tuple[str, str, str, int]] = []
|
|
226
|
+
max_row = 0
|
|
227
|
+
for key in MODEL_KEYS:
|
|
228
|
+
label = f"{MODEL_LABELS[key]}:".ljust(8)
|
|
229
|
+
owner = self.config["models"].get(key, {}).get("owned_by") or "owned_by"
|
|
230
|
+
model_id = self.config["models"].get(key, {}).get("id") or "model_id"
|
|
231
|
+
row_width = len(label) + 1 + len(owner) + len(" | ") + len(model_id)
|
|
232
|
+
max_row = max(max_row, row_width)
|
|
233
|
+
rows.append((label, owner, model_id, row_width))
|
|
234
|
+
hint = "enter to start, c to config, b to refresh, a/d to change owned_by/model_id, q to quit"
|
|
235
|
+
content_width = max(max_row, len(hint), len(self.status_message))
|
|
236
|
+
x = max(2, (width - content_width) // 2)
|
|
237
|
+
for idx, (label, owner, model_id, _row_width) in enumerate(rows):
|
|
238
|
+
addstr_safe(stdscr, y, x, label)
|
|
239
|
+
owner_x = x + len(label) + 1
|
|
240
|
+
owner_attr = (
|
|
241
|
+
curses.A_REVERSE
|
|
242
|
+
if (idx == self.main_focus_row and self.main_focus_field == 0)
|
|
243
|
+
else 0
|
|
244
|
+
)
|
|
245
|
+
addstr_safe(stdscr, y, owner_x, owner, owner_attr)
|
|
246
|
+
sep = " | "
|
|
247
|
+
sep_x = owner_x + len(owner)
|
|
248
|
+
addstr_safe(stdscr, y, sep_x, sep)
|
|
249
|
+
model_x = sep_x + len(sep)
|
|
250
|
+
model_attr = (
|
|
251
|
+
curses.A_REVERSE
|
|
252
|
+
if (idx == self.main_focus_row and self.main_focus_field == 1)
|
|
253
|
+
else 0
|
|
254
|
+
)
|
|
255
|
+
addstr_safe(stdscr, y, model_x, model_id, model_attr)
|
|
256
|
+
y += 1
|
|
257
|
+
y += 1
|
|
258
|
+
addstr_safe(stdscr, y, x, hint)
|
|
259
|
+
y += 1
|
|
260
|
+
if self.status_message:
|
|
261
|
+
addstr_safe(stdscr, y, x, self.status_message)
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Config screen
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
def render_config(self, stdscr: curses.window) -> None:
|
|
267
|
+
_height, width = stdscr.getmaxyx()
|
|
268
|
+
x = max(2, (width - 72) // 2)
|
|
269
|
+
fields = self._config_fields()
|
|
270
|
+
# 防止 focus 越界
|
|
271
|
+
self.config_focus_index = max(0, min(self.config_focus_index, len(fields) - 1))
|
|
272
|
+
focused = fields[self.config_focus_index]
|
|
273
|
+
|
|
274
|
+
y = 1
|
|
275
|
+
addstr_safe(stdscr, y, x, "─── Credentials ───────────────────────────────────────────────────────")
|
|
276
|
+
y += 1
|
|
277
|
+
|
|
278
|
+
# BASE_URL
|
|
279
|
+
base_url_value = self.config.get("base_url", "")
|
|
280
|
+
base_label = "BASE_URL:"
|
|
281
|
+
base_x = x + len(base_label) + 1
|
|
282
|
+
base_focus = focused == _FIELD_BASE_URL
|
|
283
|
+
base_display = base_url_value if base_focus else (base_url_value or "<unset>")
|
|
284
|
+
addstr_safe(stdscr, y, x, base_label)
|
|
285
|
+
addstr_safe(stdscr, y, base_x, base_display, curses.A_REVERSE if base_focus else 0)
|
|
286
|
+
base_y = y
|
|
287
|
+
y += 1
|
|
288
|
+
|
|
289
|
+
# API_KEY
|
|
290
|
+
api_key_value = self.config.get("api_key", "")
|
|
291
|
+
api_label = "API_KEY: "
|
|
292
|
+
api_x = x + len(api_label) + 1
|
|
293
|
+
api_focus = focused == _FIELD_API_KEY
|
|
294
|
+
api_display = api_key_value if api_focus else mask_secret(api_key_value)
|
|
295
|
+
addstr_safe(stdscr, y, x, api_label)
|
|
296
|
+
addstr_safe(stdscr, y, api_x, api_display, curses.A_REVERSE if api_focus else 0)
|
|
297
|
+
api_y = y
|
|
298
|
+
y += 1
|
|
299
|
+
|
|
300
|
+
y += 1
|
|
301
|
+
addstr_safe(stdscr, y, x, "─── Toggles (env vars) [n]=new [d]=delete [ESC]=save & back ────────")
|
|
302
|
+
y += 1
|
|
303
|
+
|
|
304
|
+
toggles = self.config.get("toggles", {})
|
|
305
|
+
toggle_rows: dict[str, int] = {} # key -> screen y
|
|
306
|
+
for tk, tv in toggles.items():
|
|
307
|
+
tf = f"{_FIELD_TOGGLE}{tk}"
|
|
308
|
+
is_focused = focused == tf
|
|
309
|
+
key_disp = tk.ljust(48)
|
|
310
|
+
val_disp = str(tv)
|
|
311
|
+
desc = env_schema_description(tk)
|
|
312
|
+
line = f" {key_disp} = {val_disp}"
|
|
313
|
+
addstr_safe(stdscr, y, x, line, curses.A_REVERSE if is_focused else 0)
|
|
314
|
+
if is_focused and desc:
|
|
315
|
+
hint_y = y + 1
|
|
316
|
+
addstr_safe(stdscr, hint_y, x + 2, f"[{desc[:66]}]")
|
|
317
|
+
toggle_rows[tk] = y
|
|
318
|
+
y += 1
|
|
319
|
+
if is_focused and desc:
|
|
320
|
+
y += 1 # 占用提示行
|
|
321
|
+
|
|
322
|
+
# Add toggle 行
|
|
323
|
+
add_focused = focused == _FIELD_ADD_TOGGLE
|
|
324
|
+
if self._add_phase == 0:
|
|
325
|
+
add_label = " [ + Add toggle ]"
|
|
326
|
+
addstr_safe(stdscr, y, x, add_label, curses.A_REVERSE if add_focused else 0)
|
|
327
|
+
add_y = y
|
|
328
|
+
y += 1
|
|
329
|
+
else:
|
|
330
|
+
# phase 1: 输入 key
|
|
331
|
+
if self._add_phase == 1:
|
|
332
|
+
prefix_label = " KEY: "
|
|
333
|
+
pk_x = x + len(prefix_label)
|
|
334
|
+
addstr_safe(stdscr, y, x, prefix_label)
|
|
335
|
+
addstr_safe(stdscr, y, pk_x, self._add_key or "", curses.A_REVERSE)
|
|
336
|
+
add_y = y
|
|
337
|
+
y += 1
|
|
338
|
+
# 实时候选列表
|
|
339
|
+
matches = self._live_matches()
|
|
340
|
+
if matches:
|
|
341
|
+
shown = matches[:8]
|
|
342
|
+
for i, m in enumerate(shown):
|
|
343
|
+
is_sel = (i == self._complete_idx)
|
|
344
|
+
addstr_safe(stdscr, y, x + 4, m, curses.A_REVERSE if is_sel else 0)
|
|
345
|
+
y += 1
|
|
346
|
+
if len(matches) > 8:
|
|
347
|
+
addstr_safe(stdscr, y, x + 4, f" ... (+{len(matches)-8} more)")
|
|
348
|
+
y += 1
|
|
349
|
+
addstr_safe(stdscr, y, x + 2, "[Tab] select [Enter] confirm")
|
|
350
|
+
y += 1
|
|
351
|
+
else:
|
|
352
|
+
addstr_safe(stdscr, y, x + 2, "[Tab] autocomplete from 217 known env vars")
|
|
353
|
+
y += 1
|
|
354
|
+
# schema description hint(精确匹配时显示)
|
|
355
|
+
sel_key = matches[self._complete_idx] if matches and 0 <= self._complete_idx < len(matches) else self._add_key
|
|
356
|
+
desc = env_schema_description(sel_key)
|
|
357
|
+
if desc:
|
|
358
|
+
addstr_safe(stdscr, y, x + 2, f"[{desc[:68]}]")
|
|
359
|
+
y += 1
|
|
360
|
+
# phase 2: 输入 value
|
|
361
|
+
else:
|
|
362
|
+
kl = " KEY: "
|
|
363
|
+
pk_x = x + len(kl)
|
|
364
|
+
addstr_safe(stdscr, y, x, kl + self._add_key)
|
|
365
|
+
y += 1
|
|
366
|
+
enums = env_schema_enum(self._add_key)
|
|
367
|
+
if enums:
|
|
368
|
+
addstr_safe(stdscr, y, x + 2, "values: " + " ".join(enums))
|
|
369
|
+
y += 1
|
|
370
|
+
vl = " VALUE: "
|
|
371
|
+
pv_x = x + len(vl)
|
|
372
|
+
addstr_safe(stdscr, y, x, vl)
|
|
373
|
+
addstr_safe(stdscr, y, pv_x, self._add_value or "", curses.A_REVERSE)
|
|
374
|
+
add_y = y
|
|
375
|
+
y += 1
|
|
376
|
+
|
|
377
|
+
y += 1
|
|
378
|
+
hint_line = "↑↓ navigate [d] delete toggle [n] new toggle [ESC] save & back"
|
|
379
|
+
addstr_safe(stdscr, y, x, hint_line)
|
|
380
|
+
y += 1
|
|
381
|
+
if self.status_message:
|
|
382
|
+
addstr_safe(stdscr, y, x, self.status_message)
|
|
383
|
+
|
|
384
|
+
# 光标放置
|
|
385
|
+
if focused == _FIELD_BASE_URL:
|
|
386
|
+
cursor_pos = min(self.config_cursor.get(_FIELD_BASE_URL, 0), len(base_url_value))
|
|
387
|
+
self.place_cursor(stdscr, base_y, base_x + cursor_pos)
|
|
388
|
+
elif focused == _FIELD_API_KEY:
|
|
389
|
+
cursor_pos = min(self.config_cursor.get(_FIELD_API_KEY, 0), len(api_key_value))
|
|
390
|
+
self.place_cursor(stdscr, api_y, api_x + cursor_pos)
|
|
391
|
+
elif focused == _FIELD_ADD_TOGGLE and self._add_phase == 1:
|
|
392
|
+
self.place_cursor(stdscr, add_y, x + len(" KEY: ") + self._add_key_cursor)
|
|
393
|
+
elif focused == _FIELD_ADD_TOGGLE and self._add_phase == 2:
|
|
394
|
+
self.place_cursor(stdscr, add_y, x + len(" VALUE: ") + self._add_value_cursor)
|
|
395
|
+
else:
|
|
396
|
+
try:
|
|
397
|
+
curses.curs_set(0)
|
|
398
|
+
except curses.error:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
def place_cursor(self, stdscr: curses.window, y: int, x: int) -> None:
|
|
402
|
+
height, width = stdscr.getmaxyx()
|
|
403
|
+
if y < 0 or y >= height:
|
|
404
|
+
return
|
|
405
|
+
x = max(0, min(x, width - 1))
|
|
406
|
+
try:
|
|
407
|
+
curses.curs_set(1)
|
|
408
|
+
except curses.error:
|
|
409
|
+
pass
|
|
410
|
+
try:
|
|
411
|
+
stdscr.move(y, x)
|
|
412
|
+
except curses.error:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
# ------------------------------------------------------------------
|
|
416
|
+
# Config key handling
|
|
417
|
+
# ------------------------------------------------------------------
|
|
418
|
+
def handle_config_key(self, key: int) -> None:
|
|
419
|
+
fields = self._config_fields()
|
|
420
|
+
focused = fields[self.config_focus_index]
|
|
421
|
+
|
|
422
|
+
# ESC: 保存返回
|
|
423
|
+
if key == 27:
|
|
424
|
+
self._cancel_add()
|
|
425
|
+
save_config(self.config)
|
|
426
|
+
self.active_screen = "main"
|
|
427
|
+
self.refresh_models()
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
# 处于新增输入模式时,单独路由
|
|
431
|
+
if focused == _FIELD_ADD_TOGGLE and self._add_phase > 0:
|
|
432
|
+
self._handle_add_input(key)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# 上下导航
|
|
436
|
+
if key == curses.KEY_UP:
|
|
437
|
+
save_config(self.config)
|
|
438
|
+
self._cancel_add()
|
|
439
|
+
self.config_focus_index = max(0, self.config_focus_index - 1)
|
|
440
|
+
return
|
|
441
|
+
if key == curses.KEY_DOWN:
|
|
442
|
+
save_config(self.config)
|
|
443
|
+
self._cancel_add()
|
|
444
|
+
self.config_focus_index = min(len(fields) - 1, self.config_focus_index + 1)
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
# 文本字段
|
|
448
|
+
if focused in (_FIELD_BASE_URL, _FIELD_API_KEY):
|
|
449
|
+
self._handle_text_field(focused, key)
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
# Toggle 行
|
|
453
|
+
if focused.startswith(_FIELD_TOGGLE):
|
|
454
|
+
tk = self._toggle_key_from_field(focused)
|
|
455
|
+
# d / Delete: 删除
|
|
456
|
+
if key in (ord("d"), ord("D"), curses.KEY_DC):
|
|
457
|
+
toggles = self.config.get("toggles", {})
|
|
458
|
+
if tk in toggles:
|
|
459
|
+
del toggles[tk]
|
|
460
|
+
save_config(self.config)
|
|
461
|
+
# focus 上移一行避免越界
|
|
462
|
+
self.config_focus_index = max(0, self.config_focus_index - 1)
|
|
463
|
+
return
|
|
464
|
+
# Enter/Space: 开始编辑 value
|
|
465
|
+
if key in (curses.KEY_ENTER, 10, 13, ord(" ")):
|
|
466
|
+
self._start_edit_toggle_value(tk)
|
|
467
|
+
return
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
# Add toggle 行
|
|
471
|
+
if focused == _FIELD_ADD_TOGGLE:
|
|
472
|
+
if key in (ord("n"), ord("N"), curses.KEY_ENTER, 10, 13):
|
|
473
|
+
self._add_phase = 1
|
|
474
|
+
self._add_key = ""
|
|
475
|
+
self._add_key_cursor = 0
|
|
476
|
+
self._complete_idx = -1
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
# 任意位置按 n 跳到新增行
|
|
480
|
+
if key in (ord("n"), ord("N")):
|
|
481
|
+
add_idx = fields.index(_FIELD_ADD_TOGGLE)
|
|
482
|
+
self.config_focus_index = add_idx
|
|
483
|
+
self._add_phase = 1
|
|
484
|
+
self._add_key = ""
|
|
485
|
+
self._add_key_cursor = 0
|
|
486
|
+
self._complete_idx = -1
|
|
487
|
+
|
|
488
|
+
def _handle_text_field(self, field: str, key: int) -> None:
|
|
489
|
+
"""处理 base_url / api_key 文本输入。"""
|
|
490
|
+
cfg_key = field # base_url 或 api_key
|
|
491
|
+
value = self.config.get(cfg_key, "")
|
|
492
|
+
cursor = min(self.config_cursor.get(field, 0), len(value))
|
|
493
|
+
if key == curses.KEY_LEFT:
|
|
494
|
+
cursor = max(0, cursor - 1)
|
|
495
|
+
elif key == curses.KEY_RIGHT:
|
|
496
|
+
cursor = min(len(value), cursor + 1)
|
|
497
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
498
|
+
if cursor > 0:
|
|
499
|
+
value = value[:cursor - 1] + value[cursor:]
|
|
500
|
+
cursor -= 1
|
|
501
|
+
self.config[cfg_key] = value
|
|
502
|
+
elif 0 <= key <= 255 and curses.ascii.isprint(key):
|
|
503
|
+
value = value[:cursor] + chr(key) + value[cursor:]
|
|
504
|
+
cursor += 1
|
|
505
|
+
self.config[cfg_key] = value
|
|
506
|
+
self.config_cursor[field] = cursor
|
|
507
|
+
|
|
508
|
+
def _start_edit_toggle_value(self, tk: str) -> None:
|
|
509
|
+
"""激活 add 流程的 phase 2 来就地编辑已有 toggle 的值(复用输入框)。"""
|
|
510
|
+
# 用 add 流程的 phase 2 复用,但不走 _FIELD_ADD_TOGGLE,而是直接内联
|
|
511
|
+
# 简化实现:直接把现有值放进 add_value 做内联编辑,保存后替换
|
|
512
|
+
current_val = str(self.config.get("toggles", {}).get(tk, ""))
|
|
513
|
+
self._add_key = tk
|
|
514
|
+
self._add_value = current_val
|
|
515
|
+
self._add_value_cursor = len(current_val)
|
|
516
|
+
self._add_phase = 2
|
|
517
|
+
# 把 focus 移到 add toggle 行
|
|
518
|
+
fields = self._config_fields()
|
|
519
|
+
self.config_focus_index = fields.index(_FIELD_ADD_TOGGLE)
|
|
520
|
+
|
|
521
|
+
def _handle_add_input(self, key: int) -> None:
|
|
522
|
+
"""处理新增/编辑 toggle 时的键盘输入。"""
|
|
523
|
+
if self._add_phase == 1:
|
|
524
|
+
self._handle_add_key_input(key)
|
|
525
|
+
elif self._add_phase == 2:
|
|
526
|
+
self._handle_add_value_input(key)
|
|
527
|
+
|
|
528
|
+
def _handle_add_key_input(self, key: int) -> None:
|
|
529
|
+
"""phase 1: 输入环境变量名,Tab 触发补全。"""
|
|
530
|
+
if key == 27: # ESC 取消
|
|
531
|
+
self._cancel_add()
|
|
532
|
+
return
|
|
533
|
+
if key == ord("\t"): # Tab 补全
|
|
534
|
+
self._do_complete()
|
|
535
|
+
return
|
|
536
|
+
if key in (curses.KEY_ENTER, 10, 13):
|
|
537
|
+
k = self._add_key.strip()
|
|
538
|
+
if k:
|
|
539
|
+
self._add_phase = 2
|
|
540
|
+
# 预填已知 enum 的第一个值
|
|
541
|
+
enums = env_schema_enum(k)
|
|
542
|
+
self._add_value = enums[0] if enums else ""
|
|
543
|
+
self._add_value_cursor = len(self._add_value)
|
|
544
|
+
return
|
|
545
|
+
# 普通文本编辑
|
|
546
|
+
cursor = self._add_key_cursor
|
|
547
|
+
value = self._add_key
|
|
548
|
+
cursor = min(cursor, len(value))
|
|
549
|
+
if key == curses.KEY_LEFT:
|
|
550
|
+
cursor = max(0, cursor - 1)
|
|
551
|
+
elif key == curses.KEY_RIGHT:
|
|
552
|
+
cursor = min(len(value), cursor + 1)
|
|
553
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
554
|
+
if cursor > 0:
|
|
555
|
+
value = value[:cursor - 1] + value[cursor:]
|
|
556
|
+
cursor -= 1
|
|
557
|
+
elif 0 <= key <= 255 and curses.ascii.isprint(key):
|
|
558
|
+
ch = chr(key).upper()
|
|
559
|
+
value = value[:cursor] + ch + value[cursor:]
|
|
560
|
+
cursor += 1
|
|
561
|
+
self._add_key = value
|
|
562
|
+
self._add_key_cursor = cursor
|
|
563
|
+
# 输入变化后重置选中索引
|
|
564
|
+
matches = self._live_matches()
|
|
565
|
+
self._complete_idx = 0 if matches else -1
|
|
566
|
+
|
|
567
|
+
def _handle_add_value_input(self, key: int) -> None:
|
|
568
|
+
"""phase 2: 输入环境变量值,Enter 确认保存。"""
|
|
569
|
+
if key == 27: # ESC 取消
|
|
570
|
+
self._cancel_add()
|
|
571
|
+
return
|
|
572
|
+
if key in (curses.KEY_ENTER, 10, 13):
|
|
573
|
+
k = self._add_key.strip()
|
|
574
|
+
v = self._add_value
|
|
575
|
+
if k:
|
|
576
|
+
toggles = self.config.setdefault("toggles", {})
|
|
577
|
+
toggles[k] = v
|
|
578
|
+
save_config(self.config)
|
|
579
|
+
self.status_message = f"Saved {k}={v}"
|
|
580
|
+
self._cancel_add()
|
|
581
|
+
return
|
|
582
|
+
# Tab: 在 enum 候选值间循环
|
|
583
|
+
if key == ord("\t"):
|
|
584
|
+
enums = env_schema_enum(self._add_key)
|
|
585
|
+
if enums:
|
|
586
|
+
try:
|
|
587
|
+
idx = enums.index(self._add_value)
|
|
588
|
+
self._add_value = enums[(idx + 1) % len(enums)]
|
|
589
|
+
except ValueError:
|
|
590
|
+
self._add_value = enums[0]
|
|
591
|
+
self._add_value_cursor = len(self._add_value)
|
|
592
|
+
return
|
|
593
|
+
# 普通文本编辑
|
|
594
|
+
cursor = self._add_value_cursor
|
|
595
|
+
value = self._add_value
|
|
596
|
+
cursor = min(cursor, len(value))
|
|
597
|
+
if key == curses.KEY_LEFT:
|
|
598
|
+
cursor = max(0, cursor - 1)
|
|
599
|
+
elif key == curses.KEY_RIGHT:
|
|
600
|
+
cursor = min(len(value), cursor + 1)
|
|
601
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
602
|
+
if cursor > 0:
|
|
603
|
+
value = value[:cursor - 1] + value[cursor:]
|
|
604
|
+
cursor -= 1
|
|
605
|
+
elif 0 <= key <= 255 and curses.ascii.isprint(key):
|
|
606
|
+
value = value[:cursor] + chr(key) + value[cursor:]
|
|
607
|
+
cursor += 1
|
|
608
|
+
self._add_value = value
|
|
609
|
+
self._add_value_cursor = cursor
|
|
610
|
+
|
|
611
|
+
def _live_matches(self) -> list[str]:
|
|
612
|
+
"""根据当前输入前缀实时计算候选列表。"""
|
|
613
|
+
prefix = self._add_key.upper()
|
|
614
|
+
all_keys = env_schema_keys()
|
|
615
|
+
existing = list(self.config.get("toggles", {}).keys())
|
|
616
|
+
candidates = sorted(set(all_keys) | set(existing))
|
|
617
|
+
if not prefix:
|
|
618
|
+
return candidates
|
|
619
|
+
return [k for k in candidates if k.startswith(prefix)]
|
|
620
|
+
|
|
621
|
+
def _do_complete(self) -> None:
|
|
622
|
+
"""Tab:在候选列表中选中当前项并填入输入框,再按移到下一项。"""
|
|
623
|
+
matches = self._live_matches()
|
|
624
|
+
if not matches:
|
|
625
|
+
return
|
|
626
|
+
if self._complete_idx < 0 or self._complete_idx >= len(matches):
|
|
627
|
+
self._complete_idx = 0
|
|
628
|
+
else:
|
|
629
|
+
self._complete_idx = (self._complete_idx + 1) % len(matches)
|
|
630
|
+
self._add_key = matches[self._complete_idx]
|
|
631
|
+
self._add_key_cursor = len(self._add_key)
|
|
632
|
+
|
|
633
|
+
def _cancel_add(self) -> None:
|
|
634
|
+
self._add_phase = 0
|
|
635
|
+
self._add_key = ""
|
|
636
|
+
self._add_key_cursor = 0
|
|
637
|
+
self._add_value = ""
|
|
638
|
+
self._add_value_cursor = 0
|
|
639
|
+
self._complete_idx = -1
|
|
640
|
+
|
|
641
|
+
# ------------------------------------------------------------------
|
|
642
|
+
# Main screen key handling
|
|
643
|
+
# ------------------------------------------------------------------
|
|
644
|
+
def handle_main_key(self, stdscr: curses.window, key: int) -> None:
|
|
645
|
+
if key == curses.KEY_UP:
|
|
646
|
+
self.main_focus_row = max(0, self.main_focus_row - 1)
|
|
647
|
+
return
|
|
648
|
+
if key == curses.KEY_DOWN:
|
|
649
|
+
self.main_focus_row = min(len(MODEL_KEYS) - 1, self.main_focus_row + 1)
|
|
650
|
+
return
|
|
651
|
+
if key == curses.KEY_LEFT:
|
|
652
|
+
self.main_focus_field = 0
|
|
653
|
+
return
|
|
654
|
+
if key == curses.KEY_RIGHT:
|
|
655
|
+
self.main_focus_field = 1
|
|
656
|
+
return
|
|
657
|
+
if key in (ord("["), ord("a")):
|
|
658
|
+
self.cycle_main_option(-1)
|
|
659
|
+
return
|
|
660
|
+
if key in (ord("]"), ord("d")):
|
|
661
|
+
self.cycle_main_option(1)
|
|
662
|
+
return
|
|
663
|
+
if key in (ord("c"), ord("C")):
|
|
664
|
+
self.active_screen = "config"
|
|
665
|
+
self.status_message = ""
|
|
666
|
+
return
|
|
667
|
+
if key in (ord("b"), ord("B")):
|
|
668
|
+
self.refresh_models()
|
|
669
|
+
return
|
|
670
|
+
if key in (ord("q"), ord("Q")):
|
|
671
|
+
self.should_exit = True
|
|
672
|
+
return
|
|
673
|
+
if key in (curses.KEY_ENTER, 10, 13):
|
|
674
|
+
self.status_message = ""
|
|
675
|
+
error = validate_launch_requirements(self.config)
|
|
676
|
+
if error:
|
|
677
|
+
self.status_message = error
|
|
678
|
+
return
|
|
679
|
+
error = self.launch_with_curses(stdscr)
|
|
680
|
+
if error:
|
|
681
|
+
self.status_message = error
|
|
682
|
+
else:
|
|
683
|
+
self.should_exit = True
|
|
684
|
+
|
|
685
|
+
def cycle_main_option(self, direction: int) -> None:
|
|
686
|
+
key = MODEL_KEYS[self.main_focus_row]
|
|
687
|
+
if self.main_focus_field == 0:
|
|
688
|
+
owners = owner_options(self.models_by_owner)
|
|
689
|
+
if not owners:
|
|
690
|
+
return
|
|
691
|
+
current = self.config["models"].get(key, {}).get("owned_by")
|
|
692
|
+
if current in owners:
|
|
693
|
+
index = owners.index(current)
|
|
694
|
+
else:
|
|
695
|
+
index = -1 if direction > 0 else 0
|
|
696
|
+
new_owner = owners[(index + direction) % len(owners)]
|
|
697
|
+
update_model_owner(self.config, key, new_owner)
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
owner = self.config["models"].get(key, {}).get("owned_by")
|
|
701
|
+
if not owner:
|
|
702
|
+
return
|
|
703
|
+
models = model_options(self.models_by_owner, owner)
|
|
704
|
+
if not models:
|
|
705
|
+
return
|
|
706
|
+
current_id = self.config["models"].get(key, {}).get("id")
|
|
707
|
+
if current_id in models:
|
|
708
|
+
index = models.index(current_id)
|
|
709
|
+
else:
|
|
710
|
+
index = -1 if direction > 0 else 0
|
|
711
|
+
new_id = models[(index + direction) % len(models)]
|
|
712
|
+
update_model_id(self.config, key, owner, new_id)
|
|
713
|
+
|
|
714
|
+
def launch_with_curses(self, stdscr: curses.window) -> str | None:
|
|
715
|
+
try:
|
|
716
|
+
curses.def_prog_mode()
|
|
717
|
+
curses.endwin()
|
|
718
|
+
return launch_claude(self.config, self.args)
|
|
719
|
+
finally:
|
|
720
|
+
curses.reset_prog_mode()
|
|
721
|
+
try:
|
|
722
|
+
curses.curs_set(0)
|
|
723
|
+
except curses.error:
|
|
724
|
+
pass
|
|
725
|
+
stdscr.keypad(True)
|
|
726
|
+
stdscr.clear()
|
|
727
|
+
stdscr.refresh()
|