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/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()