@agxnte/reidx 2.0.2

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 (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. package/src/reidx/workflows/store.py +56 -0
@@ -0,0 +1,831 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import uuid
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from prompt_toolkit.buffer import Buffer
10
+
11
+ from reidx.diagnostics.logger import get_logger
12
+ from reidx.provider.store import ProviderRecord, build_provider
13
+ from reidx.provider_manager.catalog import (
14
+ ProviderDefinition,
15
+ all_providers,
16
+ )
17
+ from reidx.provider_manager.catalog import (
18
+ search as catalog_search,
19
+ )
20
+ from reidx.provider_manager.database import ProviderDatabase, StoredKey, StoredProvider
21
+
22
+ log = get_logger("reidx.provider_manager.palette")
23
+
24
+ SEL_BG = "#2d1820"
25
+ SEL_FG = "#ffaaaa"
26
+ SEL_ICON = "#ff6b6b"
27
+ DIM = "#6b5b5b"
28
+ ACCENT = "#ff6b6b"
29
+ OK = "#7ddb7d"
30
+ WARN = "#ffdb6b"
31
+ ERR = "#ff6b6b"
32
+ BORDER = "#3d2a2a"
33
+ BORDER_SEL = "#ff6b6b"
34
+ BG = "#131313"
35
+ BG_ALT = "#1c1818"
36
+ BG_ROW = "#181616"
37
+ SCROLL_IND = "#2a2a2a"
38
+ HEADER_BG = "#1a1010"
39
+ ITEM_FG = "#d0d0d0"
40
+
41
+
42
+ @dataclass
43
+ class PaletteItem:
44
+ label: str
45
+ description: str = ""
46
+ icon: str = " "
47
+ kind: str = "action"
48
+ data: Any = None
49
+
50
+
51
+ @dataclass
52
+ class WizardStep:
53
+ field: str
54
+ prompt: str
55
+ is_text: bool = True
56
+ optional: bool = False
57
+ is_password: bool = False
58
+
59
+
60
+ WIZARD_STEPS: list[WizardStep] = [
61
+ WizardStep("name", "Provider Name"),
62
+ WizardStep("base_url", "API Base URL"),
63
+ WizardStep("kind", "Provider Kind", is_text=False),
64
+ WizardStep("default_model", "Default Model", optional=True),
65
+ WizardStep("auth_method", "Authentication Method", is_text=False),
66
+ WizardStep("api_key", "API Key", optional=True, is_password=True),
67
+ ]
68
+
69
+ KIND_OPTIONS = [
70
+ PaletteItem("anthropic", "Anthropic Messages API", icon="A"),
71
+ PaletteItem("openai", "OpenAI Chat Completions API", icon="O"),
72
+ PaletteItem("openai-compatible", "OpenAI-compatible (vLLM, LM Studio, etc.)", icon="C"),
73
+ PaletteItem("ollama", "Ollama native API", icon="L"),
74
+ ]
75
+
76
+ AUTH_OPTIONS = [
77
+ PaletteItem("bearer", "Authorization: Bearer <key>", icon="B"),
78
+ PaletteItem("x-api-key", "x-api-key: <key> header", icon="X"),
79
+ PaletteItem("none", "No authentication required", icon="N"),
80
+ ]
81
+
82
+
83
+ class ProviderPalette:
84
+ LIST = "list"
85
+ KEYS = "keys"
86
+ MANAGE = "manage"
87
+ KEY_LABEL = "key_label"
88
+ KEY_INPUT = "key_input"
89
+ RENAME_SEL = "rename_sel"
90
+ RENAME_INPUT = "rename_input"
91
+ DELETE_SEL = "delete_sel"
92
+ CONFIRM = "confirm"
93
+ WIZARD = "wizard"
94
+ MESSAGE = "message"
95
+
96
+ def __init__(
97
+ self,
98
+ db: ProviderDatabase,
99
+ orchestrator: Any,
100
+ on_close: Callable[[str], None],
101
+ on_invalidate: Callable[[], None] | None = None,
102
+ ) -> None:
103
+ self.db = db
104
+ self.orchestrator = orchestrator
105
+ self.on_close = on_close
106
+ self._on_invalidate = on_invalidate or (lambda: None)
107
+ self._active = False
108
+ self.screen = self.LIST
109
+ self.selected_index = 0
110
+ self._scroll_offset = 0
111
+ self.current_provider: StoredProvider | None = None
112
+ self.current_def: ProviderDefinition | None = None
113
+ self._message_text = ""
114
+ self._message_next = self.LIST
115
+ self._pending_label = ""
116
+ self._pending_action: Callable[[], str] | None = None
117
+ self._confirm_text = ""
118
+ self.wizard_step_idx = 0
119
+ self.wizard_data: dict[str, str] = {}
120
+ self.input_is_password = False
121
+ self.input_prompt_text = ""
122
+
123
+ self.search_buf = Buffer(multiline=False)
124
+ self.input_buf = Buffer(multiline=False)
125
+ self.search_buf.on_text_changed += self._on_search_changed
126
+
127
+ @property
128
+ def active(self) -> bool:
129
+ return self._active
130
+
131
+ def term_cols(self) -> int:
132
+ try:
133
+ cols, _ = shutil.get_terminal_size(fallback=(80, 24))
134
+ return max(40, cols)
135
+ except Exception:
136
+ return 80
137
+
138
+ def term_rows(self) -> int:
139
+ try:
140
+ _, rows = shutil.get_terminal_size(fallback=(80, 24))
141
+ return max(10, rows)
142
+ except Exception:
143
+ return 24
144
+
145
+ def inner_width(self) -> int:
146
+ return self.term_cols() - 2
147
+
148
+ def max_content_lines(self) -> int:
149
+ return max(3, self.term_rows() - 8)
150
+
151
+ def is_search_screen(self) -> bool:
152
+ return self._active and self.screen == self.LIST
153
+
154
+ def is_input_screen(self) -> bool:
155
+ return self._active and self.screen in (
156
+ self.KEY_LABEL, self.KEY_INPUT, self.RENAME_INPUT, self.WIZARD,
157
+ )
158
+
159
+ def is_list_screen(self) -> bool:
160
+ return self._active and self.screen in (
161
+ self.LIST, self.KEYS, self.MANAGE, self.RENAME_SEL,
162
+ self.DELETE_SEL, self.CONFIRM, self.WIZARD,
163
+ ) and not self.is_input_screen()
164
+
165
+ def activate(self) -> None:
166
+ self._active = True
167
+ self.screen = self.LIST
168
+ self.selected_index = 0
169
+ self._scroll_offset = 0
170
+ self.search_buf.text = ""
171
+ self.current_provider = None
172
+ self.current_def = None
173
+ self._invalidate()
174
+
175
+ def deactivate(self) -> None:
176
+ self._active = False
177
+ self.screen = self.LIST
178
+ self.search_buf.text = ""
179
+ self.input_buf.text = ""
180
+ self._scroll_offset = 0
181
+ self._invalidate()
182
+
183
+ def _invalidate(self) -> None:
184
+ self._on_invalidate()
185
+
186
+ def _close(self, message: str = "") -> None:
187
+ self._active = False
188
+ self.on_close(message)
189
+
190
+ def _go_back(self) -> None:
191
+ if self.screen == self.LIST:
192
+ self._close()
193
+ elif self.screen in (self.KEYS, self.WIZARD):
194
+ self.screen = self.LIST
195
+ self.selected_index = 0
196
+ self._scroll_offset = 0
197
+ elif self.screen == self.MANAGE:
198
+ self.screen = self.LIST
199
+ self.selected_index = 0
200
+ self._scroll_offset = 0
201
+ elif self.screen in (self.KEY_LABEL, self.KEY_INPUT):
202
+ self.screen = self.KEYS if self.current_provider and not self.current_provider.keys else self.MANAGE
203
+ self.input_buf.text = ""
204
+ self.input_is_password = False
205
+ elif self.screen == self.RENAME_INPUT:
206
+ self.screen = self.RENAME_SEL
207
+ self.input_buf.text = ""
208
+ elif self.screen in (self.RENAME_SEL, self.DELETE_SEL):
209
+ self.screen = self.MANAGE
210
+ self.selected_index = 0
211
+ self._scroll_offset = 0
212
+ elif self.screen == self.CONFIRM:
213
+ self.screen = self.MANAGE
214
+ self.selected_index = 0
215
+ self._scroll_offset = 0
216
+ elif self.screen == self.MESSAGE:
217
+ self.screen = self._message_next
218
+ self.selected_index = 0
219
+ self._scroll_offset = 0
220
+ elif self.screen == self.WIZARD:
221
+ if self.wizard_step_idx > 0:
222
+ self.wizard_step_idx -= 1
223
+ step = WIZARD_STEPS[self.wizard_step_idx]
224
+ if step.is_text:
225
+ self.input_buf.text = self.wizard_data.get(step.field, "")
226
+ self.input_is_password = step.is_password
227
+ else:
228
+ self.screen = self.LIST
229
+ self.selected_index = 0
230
+ self._scroll_offset = 0
231
+ self.wizard_data = {}
232
+ self.wizard_step_idx = 0
233
+ self._invalidate()
234
+
235
+ def _on_search_changed(self, _buf: Buffer | None = None) -> None:
236
+ self.selected_index = 0
237
+ self._scroll_offset = 0
238
+ self._invalidate()
239
+
240
+ def _adjust_scroll(self, item_count: int) -> None:
241
+ max_lines = self.max_content_lines()
242
+ if item_count <= max_lines:
243
+ self._scroll_offset = 0
244
+ return
245
+ if self.selected_index < self._scroll_offset:
246
+ self._scroll_offset = self.selected_index
247
+ elif self.selected_index >= self._scroll_offset + max_lines:
248
+ self._scroll_offset = self.selected_index - max_lines + 1
249
+ self._scroll_offset = max(0, min(self._scroll_offset, item_count - max_lines))
250
+
251
+ def on_up(self) -> None:
252
+ items = self._build_items()
253
+ if items:
254
+ self.selected_index = (self.selected_index - 1) % len(items)
255
+ self._adjust_scroll(len(items))
256
+ self._invalidate()
257
+
258
+ def on_down(self) -> None:
259
+ items = self._build_items()
260
+ if items:
261
+ self.selected_index = (self.selected_index + 1) % len(items)
262
+ self._adjust_scroll(len(items))
263
+ self._invalidate()
264
+
265
+ def on_enter(self) -> None:
266
+ if self.is_input_screen():
267
+ self._handle_input_enter()
268
+ self._invalidate()
269
+ return
270
+ items = self._build_items()
271
+ if not items:
272
+ return
273
+ idx = min(self.selected_index, len(items) - 1)
274
+ self._handle_list_enter(items[idx])
275
+ self._invalidate()
276
+
277
+ def on_escape(self) -> None:
278
+ self._go_back()
279
+
280
+ def _build_items(self) -> list[PaletteItem]:
281
+ if self.screen == self.LIST:
282
+ return self._list_items()
283
+ if self.screen == self.KEYS:
284
+ return self._keys_items()
285
+ if self.screen == self.MANAGE:
286
+ return self._manage_items()
287
+ if self.screen == self.RENAME_SEL:
288
+ return self._key_select_items("rename")
289
+ if self.screen == self.DELETE_SEL:
290
+ return self._key_select_items("delete")
291
+ if self.screen == self.CONFIRM:
292
+ return [
293
+ PaletteItem("Confirm", icon="Y", kind="action"),
294
+ PaletteItem("Cancel", icon="N", kind="action"),
295
+ ]
296
+ if self.screen == self.WIZARD:
297
+ step = WIZARD_STEPS[self.wizard_step_idx]
298
+ if not step.is_text:
299
+ if step.field == "kind":
300
+ return KIND_OPTIONS
301
+ if step.field == "auth_method":
302
+ return AUTH_OPTIONS
303
+ return []
304
+ return []
305
+
306
+ def _list_items(self) -> list[PaletteItem]:
307
+ q = self.search_buf.text.strip().lower()
308
+ stored = {p.name.lower(): p for p in self.db.list_providers()}
309
+ items: list[PaletteItem] = []
310
+
311
+ if q:
312
+ defs = catalog_search(q)
313
+ else:
314
+ defs = all_providers()
315
+
316
+ for d in defs:
317
+ if d.name.lower() in stored:
318
+ sp = stored[d.name.lower()]
319
+ active_key = sp.active_key()
320
+ icon = "●" if active_key else "○"
321
+ items.append(PaletteItem(
322
+ label=d.name, description=d.description, icon=icon,
323
+ kind="stored", data=sp,
324
+ ))
325
+ else:
326
+ items.append(PaletteItem(
327
+ label=d.name, description=d.description, icon="◆",
328
+ kind="catalog", data=d,
329
+ ))
330
+
331
+ for sp in self.db.list_providers():
332
+ if sp.name.lower() not in {d.name.lower() for d in defs}:
333
+ if not q or q in sp.name.lower() or q in sp.kind.lower() or q in sp.base_url.lower():
334
+ active_key = sp.active_key()
335
+ icon = "●" if active_key else "○"
336
+ items.append(PaletteItem(
337
+ label=sp.name, description=f"{sp.kind} {sp.base_url}", icon=icon,
338
+ kind="stored", data=sp,
339
+ ))
340
+
341
+ items.append(PaletteItem(
342
+ label="Add Custom Provider", description="Manually configure a provider",
343
+ icon="+", kind="action",
344
+ ))
345
+ return items
346
+
347
+ def _keys_items(self) -> list[PaletteItem]:
348
+ items: list[PaletteItem] = []
349
+ if self.current_provider:
350
+ for k in self.current_provider.keys:
351
+ is_active = k.id == self.current_provider.active_key_id
352
+ icon = "●" if is_active else "○"
353
+ desc = "active" if is_active else ""
354
+ items.append(PaletteItem(label=k.label, description=desc, icon=icon, kind="action", data=k))
355
+ items.append(PaletteItem(label="Add API Key", icon="+", kind="action"))
356
+ return items
357
+
358
+ def _manage_items(self) -> list[PaletteItem]:
359
+ items: list[PaletteItem] = []
360
+ sp = self.current_provider
361
+ if sp and len(sp.keys) > 1:
362
+ items.append(PaletteItem("Switch Key", "Change the active API key", icon="→"))
363
+ items.append(PaletteItem("Add Key", "Add another API key", icon="+"))
364
+ if sp and sp.keys:
365
+ items.append(PaletteItem("Rename Key", "Rename a key label", icon="✎"))
366
+ items.append(PaletteItem("Delete Key", "Remove a key", icon="✕"))
367
+ items.append(PaletteItem("Edit Provider", "Change base URL, model, etc.", icon="⚙"))
368
+ items.append(PaletteItem("Remove Provider", "Delete provider and all keys", icon="✕", kind="action"))
369
+ return items
370
+
371
+ def _key_select_items(self, _mode: str) -> list[PaletteItem]:
372
+ items: list[PaletteItem] = []
373
+ if self.current_provider:
374
+ for k in self.current_provider.keys:
375
+ is_active = k.id == self.current_provider.active_key_id
376
+ icon = "●" if is_active else "○"
377
+ items.append(PaletteItem(label=k.label, icon=icon, kind="action", data=k))
378
+ return items
379
+
380
+ def _handle_list_enter(self, item: PaletteItem) -> None:
381
+ if self.screen == self.LIST:
382
+ self._on_list_select(item)
383
+ elif self.screen == self.KEYS:
384
+ self._on_keys_select(item)
385
+ elif self.screen == self.MANAGE:
386
+ self._on_manage_select(item)
387
+ elif self.screen == self.RENAME_SEL:
388
+ self._on_rename_select(item)
389
+ elif self.screen == self.DELETE_SEL:
390
+ self._on_delete_select(item)
391
+ elif self.screen == self.CONFIRM:
392
+ self._on_confirm_select(item)
393
+ elif self.screen == self.WIZARD:
394
+ self._on_wizard_select(item)
395
+
396
+ def _on_list_select(self, item: PaletteItem) -> None:
397
+ if item.kind == "action":
398
+ self._start_wizard()
399
+ return
400
+ if item.kind == "catalog":
401
+ d = item.data
402
+ self.current_def = d
403
+ existing = self.db.get_provider(d.name)
404
+ if existing:
405
+ self.current_provider = existing
406
+ self.screen = self.MANAGE
407
+ else:
408
+ sp = StoredProvider(
409
+ name=d.name, kind=d.kind, base_url=d.base_url,
410
+ default_model=d.default_model, auth_method=d.auth_method,
411
+ extra_headers=dict(d.extra_headers), catalog_id=d.id,
412
+ )
413
+ self.current_provider = sp
414
+ self.screen = self.KEYS
415
+ self.selected_index = 0
416
+ self._scroll_offset = 0
417
+ self._invalidate()
418
+ return
419
+ if item.kind == "stored":
420
+ self.current_provider = item.data
421
+ self.current_def = None
422
+ self.screen = self.MANAGE
423
+ self.selected_index = 0
424
+ self._scroll_offset = 0
425
+ self._invalidate()
426
+
427
+ def _on_keys_select(self, item: PaletteItem) -> None:
428
+ if item.label == "Add API Key":
429
+ self.screen = self.KEY_LABEL
430
+ self.input_buf.text = ""
431
+ self.input_is_password = False
432
+ self.input_prompt_text = "Label for this key (e.g. Personal, Work)"
433
+ self._invalidate()
434
+ return
435
+ for k in self.current_provider.keys:
436
+ if k.label == item.label:
437
+ self.db.set_active_key(self.current_provider.name, k.id)
438
+ self.current_provider = self.db.get_provider(self.current_provider.name)
439
+ self._register_current()
440
+ self._show_message(f"Switched to key '{k.label}'", self.KEYS)
441
+
442
+ def _on_manage_select(self, item: PaletteItem) -> None:
443
+ label = item.label
444
+ if label == "Switch Key":
445
+ self.screen = self.KEYS
446
+ self.selected_index = 0
447
+ self._scroll_offset = 0
448
+ self._invalidate()
449
+ elif label == "Add Key":
450
+ self.screen = self.KEY_LABEL
451
+ self.input_buf.text = ""
452
+ self.input_is_password = False
453
+ self.input_prompt_text = "Label for this key (e.g. Personal, Work)"
454
+ self._invalidate()
455
+ elif label == "Rename Key":
456
+ self.screen = self.RENAME_SEL
457
+ self.selected_index = 0
458
+ self._scroll_offset = 0
459
+ self._invalidate()
460
+ elif label == "Delete Key":
461
+ self.screen = self.DELETE_SEL
462
+ self.selected_index = 0
463
+ self._scroll_offset = 0
464
+ self._invalidate()
465
+ elif label == "Edit Provider":
466
+ self._start_wizard(edit=True)
467
+ elif label == "Remove Provider":
468
+ name = self.current_provider.name if self.current_provider else ""
469
+ self._confirm_text = f"Remove provider '{name}' and all encrypted keys?"
470
+ self._pending_action = lambda: self._do_remove_provider()
471
+ self.screen = self.CONFIRM
472
+ self.selected_index = 0
473
+ self._scroll_offset = 0
474
+ self._invalidate()
475
+
476
+ def _on_rename_select(self, item: PaletteItem) -> None:
477
+ k = item.data
478
+ if k:
479
+ self._pending_label = k.id
480
+ self.input_buf.text = k.label
481
+ self.input_prompt_text = "New label"
482
+ self.screen = self.RENAME_INPUT
483
+ self._invalidate()
484
+
485
+ def _on_delete_select(self, item: PaletteItem) -> None:
486
+ k = item.data
487
+ if k and self.current_provider:
488
+ name = self.current_provider.name
489
+ self._confirm_text = f"Delete key '{k.label}' from provider '{name}'?"
490
+ self._pending_action = lambda: self._do_delete_key(k.id, k.label)
491
+ self.screen = self.CONFIRM
492
+ self.selected_index = 0
493
+ self._scroll_offset = 0
494
+ self._invalidate()
495
+
496
+ def _on_confirm_select(self, item: PaletteItem) -> None:
497
+ if item.label == "Confirm":
498
+ if self._pending_action:
499
+ msg = self._pending_action()
500
+ self._pending_action = None
501
+ self._show_message(msg, self.MANAGE)
502
+ else:
503
+ self.screen = self.MANAGE
504
+ self._invalidate()
505
+ else:
506
+ self._pending_action = None
507
+ self.screen = self.MANAGE
508
+ self.selected_index = 0
509
+ self._scroll_offset = 0
510
+ self._invalidate()
511
+
512
+ def _on_wizard_select(self, item: PaletteItem) -> None:
513
+ step = WIZARD_STEPS[self.wizard_step_idx]
514
+ self.wizard_data[step.field] = item.label
515
+ self._wizard_advance()
516
+
517
+ def _handle_input_enter(self) -> None:
518
+ text = self.input_buf.text
519
+ if self.screen == self.KEY_LABEL:
520
+ if not text.strip():
521
+ return
522
+ self._pending_label = text.strip()
523
+ self.screen = self.KEY_INPUT
524
+ self.input_buf.text = ""
525
+ self.input_is_password = True
526
+ self.input_prompt_text = "Paste your API key"
527
+ self._invalidate()
528
+ return
529
+ if self.screen == self.KEY_INPUT:
530
+ self._do_add_key(self._pending_label, text)
531
+ self.input_buf.text = ""
532
+ self.input_is_password = False
533
+ self._invalidate()
534
+ return
535
+ if self.screen == self.RENAME_INPUT:
536
+ if not text.strip():
537
+ return
538
+ if self.current_provider:
539
+ self.db.rename_key(self.current_provider.name, self._pending_label, text.strip())
540
+ self.current_provider = self.db.get_provider(self.current_provider.name)
541
+ self._show_message(f"Renamed key to '{text.strip()}'", self.MANAGE)
542
+ self.input_buf.text = ""
543
+ self._invalidate()
544
+ return
545
+ if self.screen == self.WIZARD:
546
+ step = WIZARD_STEPS[self.wizard_step_idx]
547
+ if not text.strip() and not step.optional:
548
+ return
549
+ self.wizard_data[step.field] = text.strip()
550
+ self.input_buf.text = ""
551
+ self._wizard_advance()
552
+ self._invalidate()
553
+
554
+ def _start_wizard(self, edit: bool = False) -> None:
555
+ self.wizard_step_idx = 0
556
+ self.wizard_data = {}
557
+ if edit and self.current_provider:
558
+ sp = self.current_provider
559
+ self.wizard_data = {
560
+ "name": sp.name,
561
+ "base_url": sp.base_url,
562
+ "kind": sp.kind,
563
+ "default_model": sp.default_model,
564
+ "auth_method": sp.auth_method,
565
+ }
566
+ self.wizard_step_idx = 5
567
+ self.screen = self.WIZARD
568
+ step = WIZARD_STEPS[self.wizard_step_idx]
569
+ if step.is_text:
570
+ self.input_buf.text = self.wizard_data.get(step.field, "")
571
+ self.input_is_password = step.is_password
572
+ self.input_prompt_text = step.prompt
573
+ self.selected_index = 0
574
+ self._scroll_offset = 0
575
+ self._invalidate()
576
+
577
+ def _wizard_advance(self) -> None:
578
+ self.wizard_step_idx += 1
579
+ if self.wizard_step_idx >= len(WIZARD_STEPS):
580
+ self._wizard_complete()
581
+ return
582
+ step = WIZARD_STEPS[self.wizard_step_idx]
583
+ if step.is_text:
584
+ self.input_buf.text = self.wizard_data.get(step.field, "")
585
+ self.input_is_password = step.is_password
586
+ self.input_prompt_text = step.prompt
587
+ else:
588
+ self.input_buf.text = ""
589
+ self.selected_index = 0
590
+ self._scroll_offset = 0
591
+ self._invalidate()
592
+
593
+ def _wizard_complete(self) -> None:
594
+ d = self.wizard_data
595
+ name = d.get("name", "").strip()
596
+ if not name:
597
+ self._show_message("Provider name is required", self.LIST)
598
+ return
599
+ kind = d.get("kind", "openai-compatible")
600
+ base_url = d.get("base_url", "").strip()
601
+ model = d.get("default_model", "").strip()
602
+ auth = d.get("auth_method", "bearer")
603
+ api_key = d.get("api_key", "").strip()
604
+
605
+ existing = self.db.get_provider(name)
606
+ if existing:
607
+ existing.kind = kind
608
+ existing.base_url = base_url
609
+ existing.default_model = model
610
+ existing.auth_method = auth
611
+ if api_key:
612
+ existing.keys.append(StoredKey(
613
+ id=uuid.uuid4().hex[:12],
614
+ label="Default",
615
+ encrypted_key="",
616
+ ))
617
+ from reidx.provider_manager import keychain
618
+ existing.keys[-1].encrypted_key = keychain.encrypt(api_key)
619
+ if existing.active_key_id is None:
620
+ existing.active_key_id = existing.keys[-1].id
621
+ self.db.save_provider(existing)
622
+ self.current_provider = existing
623
+ else:
624
+ sp = StoredProvider(
625
+ name=name, kind=kind, base_url=base_url,
626
+ default_model=model, auth_method=auth,
627
+ )
628
+ if api_key:
629
+ from reidx.provider_manager import keychain
630
+ k = StoredKey(
631
+ id=uuid.uuid4().hex[:12],
632
+ label="Default",
633
+ encrypted_key=keychain.encrypt(api_key),
634
+ )
635
+ sp.keys.append(k)
636
+ sp.active_key_id = k.id
637
+ self.db.save_provider(sp)
638
+ self.current_provider = sp
639
+
640
+ self._register_current()
641
+ self.wizard_data = {}
642
+ self.wizard_step_idx = 0
643
+ self.input_is_password = False
644
+ self._close(f"Saved provider '{name}' ({kind})")
645
+
646
+ def _do_add_key(self, label: str, api_key: str) -> None:
647
+ if not self.current_provider:
648
+ return
649
+ name = self.current_provider.name
650
+ self.db.add_key(name, label, api_key)
651
+ self.current_provider = self.db.get_provider(name)
652
+ self._register_current()
653
+ self._show_message(f"Added key '{label}'", self.MANAGE)
654
+
655
+ def _do_delete_key(self, key_id: str, label: str) -> str:
656
+ if not self.current_provider:
657
+ return "Error: no provider"
658
+ name = self.current_provider.name
659
+ self.db.remove_key(name, key_id)
660
+ self.current_provider = self.db.get_provider(name)
661
+ self._register_current()
662
+ return f"Deleted key '{label}'"
663
+
664
+ def _do_remove_provider(self) -> str:
665
+ if not self.current_provider:
666
+ return "Error: no provider"
667
+ name = self.current_provider.name
668
+ self.db.remove_provider(name)
669
+ if self.orchestrator and self.orchestrator.providers:
670
+ self.orchestrator.providers.unregister(name)
671
+ self.current_provider = None
672
+ return f"Removed provider '{name}'"
673
+
674
+ def _register_current(self) -> None:
675
+ if not self.current_provider or not self.orchestrator or not self.orchestrator.providers:
676
+ return
677
+ sp = self.current_provider
678
+ api_key = sp.decrypted_api_key()
679
+ try:
680
+ record = ProviderRecord(
681
+ name=sp.name, kind=sp.kind, base_url=sp.base_url,
682
+ api_key=api_key, default_model=sp.default_model,
683
+ )
684
+ provider = build_provider(record)
685
+ self.orchestrator.providers.register(sp.name, provider)
686
+ except (ValueError, TypeError):
687
+ log.exception("failed to register provider %s", sp.name)
688
+
689
+ def _show_message(self, text: str, next_screen: str) -> None:
690
+ self._message_text = text
691
+ self._message_next = next_screen
692
+ self.screen = self.MESSAGE
693
+ self._invalidate()
694
+
695
+ def border_top_fragments(self) -> list[tuple[str, str]]:
696
+ inner = self.inner_width()
697
+ return [("class:palette-border", f"╭{'─' * inner}╮")]
698
+
699
+ def border_bottom_fragments(self) -> list[tuple[str, str]]:
700
+ inner = self.inner_width()
701
+ return [("class:palette-border", f"╰{'─' * inner}╯")]
702
+
703
+ def separator_fragments(self) -> list[tuple[str, str]]:
704
+ inner = self.inner_width()
705
+ return [("class:palette-sep", f"├{'─' * inner}┤")]
706
+
707
+ def header_fragments(self) -> list[tuple[str, str]]:
708
+ if self.screen == self.LIST:
709
+ return [(f"bold {ACCENT}", " ✻ Connect Provider")]
710
+ if self.screen == self.KEYS:
711
+ name = self.current_provider.name if self.current_provider else ""
712
+ return [(f"bold {ACCENT}", f" {name}"), ("", " "), (DIM, "Keys")]
713
+ if self.screen == self.MANAGE:
714
+ name = self.current_provider.name if self.current_provider else ""
715
+ sp = self.current_provider
716
+ active = ""
717
+ if sp and sp.active_key():
718
+ active = f" Active: {sp.active_key().label}"
719
+ return [(f"bold {ACCENT}", f" {name}"), (DIM, active)]
720
+ if self.screen == self.KEY_LABEL:
721
+ return [(f"bold {ACCENT}", " Add API Key"), ("", " "), (DIM, "Step 1/2: Label")]
722
+ if self.screen == self.KEY_INPUT:
723
+ return [(f"bold {ACCENT}", " Add API Key"), ("", " "), (DIM, f"Step 2/2: Key for '{self._pending_label}'")]
724
+ if self.screen == self.RENAME_SEL:
725
+ return [(f"bold {ACCENT}", " Rename Key"), ("", " "), (DIM, "Select a key")]
726
+ if self.screen == self.RENAME_INPUT:
727
+ return [(f"bold {ACCENT}", " Rename Key"), ("", " "), (DIM, "Enter new label")]
728
+ if self.screen == self.DELETE_SEL:
729
+ return [(f"bold {ACCENT}", " Delete Key"), ("", " "), (DIM, "Select a key")]
730
+ if self.screen == self.CONFIRM:
731
+ return [(f"bold {WARN}", " Confirm")]
732
+ if self.screen == self.WIZARD:
733
+ step = WIZARD_STEPS[self.wizard_step_idx]
734
+ total = len(WIZARD_STEPS)
735
+ return [(f"bold {ACCENT}", " + Custom Provider"), ("", " "), (DIM, f"Step {self.wizard_step_idx + 1}/{total}: {step.prompt}")]
736
+ if self.screen == self.MESSAGE:
737
+ return [(f"bold {OK}", " ✓ Done")]
738
+ return [(f"bold {ACCENT}", " Provider")]
739
+
740
+ def content_fragments(self) -> list[tuple[str, str]]:
741
+ if self.screen == self.MESSAGE:
742
+ return [(f"{OK}", f" {self._message_text}")]
743
+ if self.screen == self.CONFIRM:
744
+ return [(f"{WARN}", f" {self._confirm_text}")]
745
+ if self.screen == self.WIZARD and self.is_input_screen():
746
+ step = WIZARD_STEPS[self.wizard_step_idx]
747
+ hint = " (optional — press Enter to skip)" if step.optional else ""
748
+ return [(DIM, f" {step.prompt}{hint}")]
749
+ if self.is_input_screen():
750
+ return [(DIM, f" {self.input_prompt_text}")]
751
+ items = self._build_items()
752
+ if not items:
753
+ return [(DIM, " (no items)")]
754
+ inner = self.inner_width()
755
+ max_lines = self.max_content_lines()
756
+ self._adjust_scroll(len(items))
757
+ visible_start = self._scroll_offset
758
+ visible_end = min(visible_start + max_lines, len(items))
759
+ frags: list[tuple[str, str]] = []
760
+ for i in range(visible_start, visible_end):
761
+ item = items[i]
762
+ is_sel = i == self.selected_index
763
+ row_bg = SEL_BG if is_sel else (BG_ALT if i % 2 else BG_ROW)
764
+ label_part = f" {item.icon} {item.label}"
765
+ desc = item.description
766
+ remaining = max(1, inner - len(label_part) - 1)
767
+ if len(desc) > remaining:
768
+ desc = desc[: remaining - 1] + "…"
769
+ desc_part = f" {desc}" if desc else ""
770
+ pad = max(0, inner - len(label_part) - len(desc_part))
771
+ if is_sel:
772
+ frags.append((f"bg:{row_bg} {SEL_FG} bold", label_part))
773
+ if desc_part:
774
+ frags.append((f"bg:{row_bg} {DIM}", desc_part + " " * pad))
775
+ else:
776
+ frags.append((f"bg:{row_bg}", " " * pad))
777
+ else:
778
+ frags.append((f"bg:{row_bg} {ITEM_FG}", label_part))
779
+ if desc_part:
780
+ frags.append((f"bg:{row_bg} {DIM}", desc_part + " " * pad))
781
+ else:
782
+ frags.append((f"bg:{row_bg}", " " * pad))
783
+ if i != visible_end - 1:
784
+ frags.append((f"bg:{row_bg}", "\n"))
785
+ remaining_lines = max_lines - (visible_end - visible_start)
786
+ for _ in range(remaining_lines):
787
+ if frags:
788
+ frags.append((f"bg:{BG}", "\n"))
789
+ frags.append((f"bg:{BG}", " " * inner))
790
+ return frags
791
+
792
+ def footer_fragments(self) -> list[tuple[str, str]]:
793
+ if self.is_input_screen():
794
+ return [(DIM, " Enter to continue Esc to go back")]
795
+ if self.screen == self.CONFIRM:
796
+ return [(DIM, " ↑↓ Select Enter Confirm Esc Cancel")]
797
+ if self.screen == self.MESSAGE:
798
+ return [(DIM, " Enter to continue")]
799
+ if self.screen == self.LIST:
800
+ return [(DIM, " ↑↓ Navigate Enter Select Esc Close Type to search")]
801
+ return [(DIM, " ↑↓ Navigate Enter Select Esc Back")]
802
+
803
+ def input_label(self) -> str:
804
+ if self.screen == self.KEY_LABEL:
805
+ return " Label: "
806
+ if self.screen == self.KEY_INPUT:
807
+ return " Key: "
808
+ if self.screen == self.RENAME_INPUT:
809
+ return " New: "
810
+ if self.screen == self.WIZARD:
811
+ step = WIZARD_STEPS[self.wizard_step_idx]
812
+ return f" {step.prompt}: "
813
+ return " > "
814
+
815
+ def search_label(self) -> str:
816
+ if self.screen == self.LIST:
817
+ return " ⌕ "
818
+ return " › "
819
+
820
+ def content_height(self) -> int:
821
+ if self.screen == self.MESSAGE:
822
+ return 1
823
+ if self.screen == self.CONFIRM:
824
+ return 1
825
+ if self.is_input_screen():
826
+ return 1
827
+ items = self._build_items()
828
+ return max(1, min(len(items), self.max_content_lines()))
829
+
830
+ def total_height(self) -> int:
831
+ return self.content_height() + 6