windcode 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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,565 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from typing import ClassVar, cast
6
+
7
+ from rich.text import Text
8
+ from textual import on
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.message import Message
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Button, Input, OptionList, Select, Static
15
+ from textual.widgets.option_list import Option
16
+
17
+ from windcode.config import ProviderConfig, ProviderProtocol
18
+ from windcode.providers.catalog import (
19
+ PRESETS_BY_ID,
20
+ PROVIDER_PRESETS,
21
+ ProviderPreset,
22
+ provider_preset,
23
+ )
24
+
25
+ PROTOCOL_LABELS = {
26
+ ProviderProtocol.ANTHROPIC_MESSAGES: "Anthropic",
27
+ ProviderProtocol.OPENAI_RESPONSES: "OpenAI Responses",
28
+ ProviderProtocol.OPENAI_COMPATIBLE: "OpenAI Compatible",
29
+ }
30
+ DEFAULT_KEY_ENV = {
31
+ ProviderProtocol.ANTHROPIC_MESSAGES: "ANTHROPIC_API_KEY",
32
+ ProviderProtocol.OPENAI_RESPONSES: "OPENAI_API_KEY",
33
+ ProviderProtocol.OPENAI_COMPATIBLE: "OPENAI_API_KEY",
34
+ }
35
+ _ALIAS_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
36
+
37
+
38
+ def _model_option(
39
+ alias: str,
40
+ provider: ProviderConfig,
41
+ *,
42
+ selected: str | None,
43
+ primary: str | None,
44
+ connected: bool,
45
+ ) -> Option:
46
+ text = Text()
47
+ text.append("● " if alias == selected else " ", style="cyan")
48
+ text.append(provider.model, style="bold")
49
+ text.append(f" {alias}", style="dim")
50
+ if alias == selected:
51
+ text.append(" 当前", style="bold cyan")
52
+ elif alias == primary:
53
+ text.append(" 默认", style="cyan")
54
+ if not connected:
55
+ text.append(" 未连接", style="yellow")
56
+ return Option(text, id=alias)
57
+
58
+
59
+ def _field_label(title: str, description: str) -> Static:
60
+ text = Text(title, style="bold")
61
+ text.append(f"\n{description}", style="dim")
62
+ return Static(text, classes="provider-field-label")
63
+
64
+
65
+ class ModelManager(ModalScreen[None]):
66
+ BINDINGS: ClassVar[list[Binding]] = [
67
+ Binding("escape", "close", "关闭"),
68
+ Binding("left", "previous_provider", "上一个厂商", priority=True),
69
+ Binding("right", "next_provider", "下一个厂商", priority=True),
70
+ Binding("up", "cursor_up", "上一个模型", priority=True),
71
+ Binding("down", "cursor_down", "下一个模型", priority=True),
72
+ ]
73
+
74
+ class Use(Message):
75
+ def __init__(self, alias: str) -> None:
76
+ super().__init__()
77
+ self.alias = alias
78
+
79
+ class Manage(Message):
80
+ pass
81
+
82
+ class Connect(Message):
83
+ def __init__(self, provider_id: str, alias: str | None = None) -> None:
84
+ super().__init__()
85
+ self.provider_id = provider_id
86
+ self.alias = alias
87
+
88
+ class Closed(Message):
89
+ pass
90
+
91
+ def __init__(
92
+ self,
93
+ profiles: Mapping[str, ProviderConfig],
94
+ *,
95
+ selected: str | None,
96
+ primary: str | None,
97
+ connected: Mapping[str, bool],
98
+ ) -> None:
99
+ super().__init__(id="model-manager")
100
+ self.profiles = dict(profiles)
101
+ self.selected = selected if selected in profiles else primary
102
+ self.primary = primary
103
+ self.connected = dict(connected)
104
+ self._visible_aliases: tuple[str, ...] = ()
105
+ groups: list[tuple[str | None, str]] = [
106
+ (None, "全部"),
107
+ *((preset.id, preset.name) for preset in PROVIDER_PRESETS),
108
+ ]
109
+ seen = {preset.id for preset in PROVIDER_PRESETS}
110
+ for alias, provider in self.profiles.items():
111
+ preset = provider_preset(provider)
112
+ group_id = preset.id if preset is not None else alias
113
+ if group_id not in seen:
114
+ seen.add(group_id)
115
+ groups.append((group_id, preset.name if preset is not None else alias))
116
+ self._groups = tuple(groups)
117
+ self._group_index = 0
118
+
119
+ def compose(self) -> ComposeResult:
120
+ with Vertical(id="model-dialog"):
121
+ yield Static("选择模型", id="model-manager-title")
122
+ yield Input(placeholder="搜索模型或 Provider", id="model-search")
123
+ yield Static("", id="model-provider-tabs")
124
+ yield OptionList(id="model-list")
125
+ yield Static("", id="model-picker-status")
126
+ with Horizontal(classes="dialog-actions"):
127
+ yield Button("使用", id="model-use", variant="primary")
128
+ yield Button("管理 Provider", id="model-manage")
129
+ yield Button("关闭", id="model-close")
130
+
131
+ def on_mount(self) -> None:
132
+ self._filter("")
133
+ self.query_one("#model-search", Input).focus()
134
+
135
+ def _filter(self, query: str) -> None:
136
+ query = query.casefold().strip()
137
+ active_group = self._groups[self._group_index][0]
138
+ matched: list[str] = []
139
+ for alias, provider in self.profiles.items():
140
+ preset = provider_preset(provider)
141
+ group_id = preset.id if preset is not None else alias
142
+ if active_group is not None and group_id != active_group:
143
+ continue
144
+ if query and not any(
145
+ query in candidate.casefold()
146
+ for candidate in (
147
+ alias,
148
+ provider.model,
149
+ PROTOCOL_LABELS[provider.protocol],
150
+ preset.name if preset is not None else "",
151
+ )
152
+ ):
153
+ continue
154
+ matched.append(alias)
155
+ aliases = tuple(matched)
156
+ self._visible_aliases = aliases
157
+ options: list[Option] = []
158
+ last_group: str | None = None
159
+ for alias in aliases:
160
+ provider = self.profiles[alias]
161
+ preset = provider_preset(provider)
162
+ group_name = preset.name if preset is not None else alias
163
+ if not query and group_name != last_group:
164
+ options.append(Option(Text(group_name, style="bold magenta"), disabled=True))
165
+ last_group = group_name
166
+ options.append(
167
+ _model_option(
168
+ alias,
169
+ provider,
170
+ selected=self.selected,
171
+ primary=self.primary,
172
+ connected=self.connected.get(alias, False),
173
+ )
174
+ )
175
+ configured_presets = {
176
+ preset.id
177
+ for provider in self.profiles.values()
178
+ if (preset := provider_preset(provider)) is not None
179
+ }
180
+ available_presets = tuple(
181
+ preset
182
+ for preset in PROVIDER_PRESETS
183
+ if preset.id not in configured_presets
184
+ and (active_group is None or preset.id == active_group)
185
+ and (not query or query in preset.name.casefold() or query in preset.id.casefold())
186
+ )
187
+ if available_presets:
188
+ if active_group is None:
189
+ options.append(Option(Text("连接 Provider", style="bold magenta"), disabled=True))
190
+ for preset in available_presets:
191
+ text = Text(" ")
192
+ text.append(preset.name, style="bold")
193
+ text.append(" 尚未连接", style="dim")
194
+ options.append(Option(text, id=f"connect:{preset.id}"))
195
+ model_list = self.query_one("#model-list", OptionList)
196
+ model_list.clear_options()
197
+ model_list.add_options(options)
198
+ status = "" if options else "没有匹配的模型或 Provider"
199
+ self.query_one("#model-picker-status", Static).update(status)
200
+ target = self.selected if self.selected in aliases else None
201
+ if target is not None:
202
+ model_list.highlighted = model_list.get_option_index(target)
203
+ elif options:
204
+ model_list.action_first()
205
+ self._update_group_tabs()
206
+
207
+ def _update_group_tabs(self) -> None:
208
+ tabs = Text()
209
+ previous = self._groups[(self._group_index - 1) % len(self._groups)][1]
210
+ current = self._groups[self._group_index][1]
211
+ following = self._groups[(self._group_index + 1) % len(self._groups)][1]
212
+ tabs.append(f"← {previous}", style="dim")
213
+ tabs.append(f" {current} ", style="bold cyan")
214
+ tabs.append(f"{following} →", style="dim")
215
+ self.query_one("#model-provider-tabs", Static).update(tabs)
216
+
217
+ def _highlighted_alias(self) -> str | None:
218
+ model_list = self.query_one("#model-list", OptionList)
219
+ option = model_list.highlighted_option
220
+ return option.id if option is not None else None
221
+
222
+ def action_previous_provider(self) -> None:
223
+ self._group_index = (self._group_index - 1) % len(self._groups)
224
+ self._filter(self.query_one("#model-search", Input).value)
225
+
226
+ def action_next_provider(self) -> None:
227
+ self._group_index = (self._group_index + 1) % len(self._groups)
228
+ self._filter(self.query_one("#model-search", Input).value)
229
+
230
+ def action_cursor_up(self) -> None:
231
+ self.query_one("#model-list", OptionList).action_cursor_up()
232
+
233
+ def action_cursor_down(self) -> None:
234
+ self.query_one("#model-list", OptionList).action_cursor_down()
235
+
236
+ def _use(self, alias: str | None = None) -> None:
237
+ target = alias or self._highlighted_alias()
238
+ if target is None:
239
+ return
240
+ if target.startswith("connect:"):
241
+ self.post_message(self.Connect(target.removeprefix("connect:")))
242
+ return
243
+ if not self.connected.get(target, False):
244
+ preset = provider_preset(self.profiles[target])
245
+ if preset is not None:
246
+ self.post_message(self.Connect(preset.id, target))
247
+ else:
248
+ self.query_one("#model-picker-status", Static).update("该 Provider 尚未连接")
249
+ return
250
+ self.post_message(self.Use(target))
251
+
252
+ def show_error(self, message: str) -> None:
253
+ self.query_one("#model-picker-status", Static).update(message)
254
+
255
+ @on(Input.Changed, "#model-search")
256
+ def search_changed(self, event: Input.Changed) -> None:
257
+ self._filter(event.value)
258
+
259
+ @on(Input.Submitted, "#model-search")
260
+ def search_submitted(self) -> None:
261
+ self._use()
262
+
263
+ @on(OptionList.OptionSelected, "#model-list")
264
+ def option_selected(self, event: OptionList.OptionSelected) -> None:
265
+ self._use(event.option.id)
266
+
267
+ @on(Button.Pressed)
268
+ def button_pressed(self, event: Button.Pressed) -> None:
269
+ if event.button.id == "model-use":
270
+ self._use()
271
+ elif event.button.id == "model-manage":
272
+ self.post_message(self.Manage())
273
+ elif event.button.id == "model-close":
274
+ self.action_close()
275
+
276
+ def action_close(self) -> None:
277
+ self.post_message(self.Closed())
278
+
279
+
280
+ class ProviderManager(ModalScreen[None]):
281
+ BINDINGS: ClassVar[list[Binding]] = [Binding("escape", "close_or_cancel", "返回")]
282
+
283
+ class Save(Message):
284
+ def __init__(self, alias: str, provider: ProviderConfig, secret: str | None) -> None:
285
+ super().__init__()
286
+ self.alias = alias
287
+ self.provider = provider
288
+ self.secret = secret
289
+
290
+ class Delete(Message):
291
+ def __init__(self, alias: str) -> None:
292
+ super().__init__()
293
+ self.alias = alias
294
+
295
+ class SetDefault(Message):
296
+ def __init__(self, alias: str) -> None:
297
+ super().__init__()
298
+ self.alias = alias
299
+
300
+ class Closed(Message):
301
+ pass
302
+
303
+ def __init__(
304
+ self,
305
+ profiles: Mapping[str, ProviderConfig],
306
+ *,
307
+ selected: str | None,
308
+ primary: str | None,
309
+ connected: Mapping[str, bool],
310
+ preset_id: str | None = None,
311
+ ) -> None:
312
+ super().__init__(id="provider-manager")
313
+ self.profiles = dict(profiles)
314
+ self.selected = selected if selected in profiles else primary
315
+ self.primary = primary
316
+ self.connected = dict(connected)
317
+ self.initial_preset_id = preset_id
318
+ self._aliases = tuple(self.profiles)
319
+ self._editing_alias: str | None = None
320
+ self._pending_delete: str | None = None
321
+
322
+ def compose(self) -> ComposeResult:
323
+ with Vertical(id="provider-dialog"):
324
+ yield Static("Provider 管理", id="provider-manager-title")
325
+ yield OptionList(id="provider-list")
326
+ yield Static("", id="provider-details")
327
+ with Horizontal(id="provider-actions", classes="dialog-actions"):
328
+ yield Button("新增", id="provider-add", variant="primary")
329
+ yield Button("编辑", id="provider-edit")
330
+ yield Button("设为默认", id="provider-default")
331
+ yield Button("断开", id="provider-delete", variant="error")
332
+ yield Button("确认断开", id="provider-confirm-delete", variant="error")
333
+ yield Button("完成", id="provider-close")
334
+ with Vertical(id="provider-editor"):
335
+ with Horizontal(classes="provider-field"):
336
+ yield _field_label("厂商模板", "预填协议、地址和变量")
337
+ yield Select(
338
+ (
339
+ ("自定义 Provider", "custom"),
340
+ *((preset.name, preset.id) for preset in PROVIDER_PRESETS),
341
+ ),
342
+ allow_blank=False,
343
+ value="openai",
344
+ id="provider-preset",
345
+ )
346
+ with Horizontal(classes="provider-field"):
347
+ yield _field_label("配置别名", "用于 /model 和配置引用")
348
+ yield Input(placeholder="Provider 别名", id="provider-alias")
349
+ with Horizontal(classes="provider-field"):
350
+ yield _field_label("接口协议", "服务端请求格式")
351
+ yield Select(
352
+ tuple(
353
+ (label, protocol.value) for protocol, label in PROTOCOL_LABELS.items()
354
+ ),
355
+ allow_blank=False,
356
+ value=ProviderProtocol.OPENAI_RESPONSES.value,
357
+ id="provider-protocol",
358
+ )
359
+ with Horizontal(classes="provider-field"):
360
+ yield _field_label("模型 ID", "API 接受的模型标识")
361
+ yield Input(placeholder="例如 deepseek-chat", id="provider-model")
362
+ with Horizontal(classes="provider-field"):
363
+ yield _field_label("Base URL", "API 根地址; 模板已预填")
364
+ yield Input(placeholder="https://api.example.com/v1", id="provider-base-url")
365
+ with Horizontal(classes="provider-field"):
366
+ yield _field_label("API Key", "存入密钥库; 留空保留")
367
+ yield Input(placeholder="API Key", password=True, id="provider-api-key")
368
+ with Horizontal(classes="provider-field"):
369
+ yield _field_label("环境变量", "变量名; 其值优先于密钥库")
370
+ yield Input(placeholder="例如 DEEPSEEK_API_KEY", id="provider-api-key-env")
371
+ yield Static("", id="provider-editor-error")
372
+ with Horizontal(classes="dialog-actions"):
373
+ yield Button("保存连接", id="provider-save", variant="primary")
374
+ yield Button("取消", id="provider-cancel")
375
+
376
+ def on_mount(self) -> None:
377
+ self.query_one("#provider-editor", Vertical).display = False
378
+ self.query_one("#provider-confirm-delete", Button).display = False
379
+ self._refresh_options()
380
+ if self._aliases:
381
+ if self.initial_preset_id is not None:
382
+ self._open_editor(None)
383
+ else:
384
+ self.query_one("#provider-list", OptionList).focus()
385
+ else:
386
+ self._open_editor(None)
387
+
388
+ def _refresh_options(self) -> None:
389
+ options: list[Option] = []
390
+ for alias in self._aliases:
391
+ provider = self.profiles[alias]
392
+ preset = provider_preset(provider)
393
+ text = Text()
394
+ text.append(alias, style="bold")
395
+ platform = preset.name if preset is not None else PROTOCOL_LABELS[provider.protocol]
396
+ text.append(f" {provider.model} · {platform}", style="dim")
397
+ text.append(
398
+ " 已连接" if self.connected.get(alias, False) else " 未连接",
399
+ style="green" if self.connected.get(alias, False) else "yellow",
400
+ )
401
+ if alias == self.primary:
402
+ text.append(" 默认", style="cyan")
403
+ options.append(Option(text, id=alias))
404
+ provider_list = self.query_one("#provider-list", OptionList)
405
+ provider_list.clear_options()
406
+ provider_list.add_options(options)
407
+ if self.selected in self._aliases:
408
+ provider_list.highlighted = self._aliases.index(self.selected)
409
+ self._update_details(self._highlighted_alias())
410
+
411
+ def _highlighted_alias(self) -> str | None:
412
+ index = self.query_one("#provider-list", OptionList).highlighted
413
+ return self._aliases[index] if index is not None and index < len(self._aliases) else None
414
+
415
+ def _update_details(self, alias: str | None) -> None:
416
+ if alias is None:
417
+ self.query_one("#provider-details", Static).update("连接 Provider 后即可选择模型")
418
+ return
419
+ provider = self.profiles[alias]
420
+ preset = provider_preset(provider)
421
+ env = f"环境变量: {provider.api_key_env}" if provider.api_key_env else "仅使用已保存密钥"
422
+ endpoint = provider.base_url or "官方端点"
423
+ platform = preset.name if preset is not None else "自定义 Provider"
424
+ self.query_one("#provider-details", Static).update(f"{platform} · {env} · {endpoint}")
425
+
426
+ def show_error(self, message: str) -> None:
427
+ target = (
428
+ "#provider-editor-error"
429
+ if self.query_one("#provider-editor", Vertical).display
430
+ else "#provider-details"
431
+ )
432
+ self.query_one(target, Static).update(message)
433
+
434
+ @on(OptionList.OptionHighlighted, "#provider-list")
435
+ def provider_highlighted(self, event: OptionList.OptionHighlighted) -> None:
436
+ self._update_details(event.option.id)
437
+
438
+ @on(Select.Changed, "#provider-preset")
439
+ def preset_changed(self, event: Select.Changed) -> None:
440
+ if self._editing_alias is not None or not isinstance(event.value, str):
441
+ return
442
+ preset = PRESETS_BY_ID.get(event.value)
443
+ if preset is not None:
444
+ self._apply_preset(preset)
445
+
446
+ @on(Button.Pressed)
447
+ def button_pressed(self, event: Button.Pressed) -> None:
448
+ button_id = event.button.id
449
+ alias = self._highlighted_alias()
450
+ if button_id == "provider-add":
451
+ self._open_editor(None)
452
+ elif button_id == "provider-edit" and alias is not None:
453
+ self._open_editor(alias)
454
+ elif button_id == "provider-default" and alias is not None:
455
+ self.post_message(self.SetDefault(alias))
456
+ elif button_id == "provider-delete" and alias is not None:
457
+ self._pending_delete = alias
458
+ event.button.display = False
459
+ self.query_one("#provider-confirm-delete", Button).display = True
460
+ self.query_one("#provider-details", Static).update(
461
+ f"再次确认将断开 {alias} 并删除已保存密钥"
462
+ )
463
+ elif button_id == "provider-confirm-delete" and self._pending_delete is not None:
464
+ self.post_message(self.Delete(self._pending_delete))
465
+ elif button_id == "provider-close":
466
+ self.post_message(self.Closed())
467
+ elif button_id == "provider-save":
468
+ self._save_editor()
469
+ elif button_id == "provider-cancel":
470
+ self._close_editor()
471
+
472
+ def _open_editor(self, alias: str | None) -> None:
473
+ self._editing_alias = alias
474
+ provider = self.profiles.get(alias) if alias else None
475
+ existing_preset = provider_preset(provider) if provider is not None else None
476
+ new_preset = PRESETS_BY_ID.get(self.initial_preset_id or "openai")
477
+ new_preset = new_preset or PRESETS_BY_ID["openai"]
478
+ preset_select = cast(Select[str], self.query_one("#provider-preset", Select))
479
+ preset_select.value = (
480
+ existing_preset.id
481
+ if existing_preset is not None
482
+ else ("custom" if provider is not None else new_preset.id)
483
+ )
484
+ preset_select.disabled = provider is not None
485
+ alias_input = self.query_one("#provider-alias", Input)
486
+ alias_input.value = alias or new_preset.id
487
+ alias_input.disabled = provider is not None
488
+ protocol = provider.protocol if provider else new_preset.protocol
489
+ self.query_one("#provider-protocol", Select).value = protocol.value
490
+ self.query_one("#provider-model", Input).value = provider.model if provider else ""
491
+ self.query_one("#provider-base-url", Input).value = (
492
+ provider.base_url or "" if provider else new_preset.base_url
493
+ )
494
+ self.query_one("#provider-api-key", Input).value = ""
495
+ self.query_one("#provider-api-key-env", Input).value = (
496
+ provider.api_key_env or DEFAULT_KEY_ENV[protocol]
497
+ if provider
498
+ else new_preset.api_key_env
499
+ )
500
+ self.query_one("#provider-api-key", Input).placeholder = (
501
+ "新 API Key (留空则保留)" if provider else "API Key (也可只使用环境变量)"
502
+ )
503
+ self.query_one("#provider-editor-error", Static).update("")
504
+ self.query_one("#provider-list", OptionList).display = False
505
+ self.query_one("#provider-details", Static).display = False
506
+ self.query_one("#provider-actions", Horizontal).display = False
507
+ self.query_one("#provider-editor", Vertical).display = True
508
+ alias_input.focus()
509
+
510
+ def _apply_preset(self, preset: ProviderPreset) -> None:
511
+ self.query_one("#provider-alias", Input).value = preset.id
512
+ self.query_one("#provider-protocol", Select).value = preset.protocol.value
513
+ self.query_one("#provider-base-url", Input).value = preset.base_url
514
+ self.query_one("#provider-api-key-env", Input).value = preset.api_key_env
515
+
516
+ def _close_editor(self) -> None:
517
+ self.query_one("#provider-editor", Vertical).display = False
518
+ self.query_one("#provider-list", OptionList).display = True
519
+ self.query_one("#provider-details", Static).display = True
520
+ self.query_one("#provider-actions", Horizontal).display = True
521
+ self.query_one("#provider-list", OptionList).focus()
522
+
523
+ def _save_editor(self) -> None:
524
+ alias = self.query_one("#provider-alias", Input).value.strip()
525
+ model = self.query_one("#provider-model", Input).value.strip()
526
+ api_key_env = self.query_one("#provider-api-key-env", Input).value.strip() or None
527
+ secret = self.query_one("#provider-api-key", Input).value.strip() or None
528
+ base_url = self.query_one("#provider-base-url", Input).value.strip() or None
529
+ preset_value = cast(Select[str], self.query_one("#provider-preset", Select)).value
530
+ protocol_value = cast(Select[str], self.query_one("#provider-protocol", Select)).value
531
+ if not _ALIAS_PATTERN.fullmatch(alias):
532
+ self.show_error("别名只能包含字母、数字、点、下划线和连字符")
533
+ return
534
+ if self._editing_alias is None and alias in self.profiles:
535
+ self.show_error(f"Provider 已存在: {alias}")
536
+ return
537
+ if not isinstance(protocol_value, str):
538
+ self.show_error("请选择协议")
539
+ return
540
+ try:
541
+ provider = ProviderConfig(
542
+ protocol=ProviderProtocol(protocol_value),
543
+ model=model,
544
+ provider_id=(
545
+ preset_value
546
+ if isinstance(preset_value, str) and preset_value in PRESETS_BY_ID
547
+ else None
548
+ ),
549
+ api_key_env=api_key_env,
550
+ credential_id=alias,
551
+ base_url=base_url,
552
+ )
553
+ except (ValueError, TypeError) as exc:
554
+ self.show_error(str(exc))
555
+ return
556
+ if provider.protocol is ProviderProtocol.OPENAI_COMPATIBLE and not provider.base_url:
557
+ self.show_error("OpenAI Compatible 协议必须填写 Base URL")
558
+ return
559
+ self.post_message(self.Save(alias, provider, secret))
560
+
561
+ def action_close_or_cancel(self) -> None:
562
+ if self.query_one("#provider-editor", Vertical).display:
563
+ self._close_editor()
564
+ else:
565
+ self.post_message(self.Closed())
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.message import Message
8
+ from textual.widgets import Button, Label, Select
9
+
10
+ from windcode.domain.events import UserInputRequested
11
+
12
+
13
+ class QuestionWidget(Vertical):
14
+ class Submitted(Message):
15
+ def __init__(self, request_id: str, answers: dict[str, str]) -> None:
16
+ super().__init__()
17
+ self.request_id = request_id
18
+ self.answers = answers
19
+
20
+ def __init__(self, request: UserInputRequested) -> None:
21
+ super().__init__(classes="interaction")
22
+ self.request = request
23
+
24
+ def compose(self) -> ComposeResult:
25
+ for question in self.request.questions:
26
+ question_id = str(question.get("id", "question"))
27
+ prompt = str(question.get("prompt", question_id))
28
+ raw_options = question.get("options", ())
29
+ options = tuple(str(option) for option in raw_options)
30
+ yield Label(prompt)
31
+ yield Select(((option, option) for option in options), id=f"question-{question_id}")
32
+ yield Button("提交", id="question-submit", variant="primary")
33
+
34
+ def on_button_pressed(self, event: Button.Pressed) -> None:
35
+ if event.button.id != "question-submit":
36
+ return
37
+ answers: dict[str, str] = {}
38
+ for question in self.request.questions:
39
+ question_id = str(question.get("id", "question"))
40
+ select = cast(Select[str], self.query_one(f"#question-{question_id}", Select))
41
+ value = select.value
42
+ if not isinstance(value, str):
43
+ return
44
+ answers[question_id] = str(value)
45
+ self.post_message(self.Submitted(self.request.request_id, answers))
46
+ self.remove()
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar
4
+
5
+ from textual import on
6
+ from textual.binding import Binding
7
+ from textual.message import Message
8
+ from textual.widgets import Select
9
+ from textual.widgets._select import SelectOverlay
10
+
11
+ from windcode.sessions import SessionMetadata
12
+
13
+
14
+ class SessionSelector(Select[str]):
15
+ BINDINGS: ClassVar[list[Binding]] = [Binding("escape", "cancel", "返回", priority=True)]
16
+
17
+ class Selected(Message):
18
+ def __init__(self, session_id: str) -> None:
19
+ super().__init__()
20
+ self.session_id = session_id
21
+
22
+ class Cancelled(Message):
23
+ pass
24
+
25
+ def __init__(self, sessions: tuple[SessionMetadata, ...]) -> None:
26
+ statuses = {
27
+ "active": "进行中",
28
+ "running": "运行中",
29
+ "completed": "已完成",
30
+ "unverified": "已完成 · 未验证",
31
+ "failed": "失败",
32
+ "cancelled": "已取消",
33
+ "interrupted": "已中断",
34
+ }
35
+ ordered_sessions = sorted(sessions, key=lambda session: session.updated_at, reverse=True)
36
+ options = [
37
+ (
38
+ f"{session.summary or '未命名会话'} · "
39
+ f"{session.updated_at.astimezone().strftime('%Y-%m-%d %H:%M')} · "
40
+ f"{statuses.get(session.status.value, session.status.value)} · "
41
+ f"{session.session_id[:12]}",
42
+ session.session_id,
43
+ )
44
+ for session in ordered_sessions
45
+ ]
46
+ super().__init__(
47
+ options,
48
+ prompt="暂无可恢复的会话",
49
+ allow_blank=not options,
50
+ value=options[0][1] if options else Select.NULL,
51
+ id="sessions",
52
+ )
53
+
54
+ def action_cancel(self) -> None:
55
+ self.expanded = False
56
+ self.post_message(self.Cancelled())
57
+
58
+ @on(SelectOverlay.UpdateSelection)
59
+ def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None:
60
+ """Confirm a session even when the highlighted option is already selected."""
61
+ event.stop()
62
+ value = self._options[event.option_index][1]
63
+ if isinstance(value, str):
64
+ if value != self.value:
65
+ self.value = value
66
+ self.post_message(self.Selected(value))
67
+ self.focus()
68
+ self.expanded = False