seedcode-cli 6.1.5__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 (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,70 @@
1
+ """Search boxes: fuzzy pickers over large collections.
2
+
3
+ Thin task-specific wrappers around the Selector:
4
+
5
+ * :func:`search_list` — fuzzy-pick a value from any list of strings.
6
+ * :func:`search_files` — the Ctrl+P project file search (walks the
7
+ workspace lazily, skips noise directories, caps the walk so huge repos
8
+ stay instant).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Sequence
16
+
17
+ from .selector import Option, select
18
+
19
+ # Directories that add noise, not results (mirrors core.project's skip list).
20
+ _SKIP_DIRS = {
21
+ ".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv",
22
+ "dist", "build", ".mypy_cache", ".ruff_cache", ".idea", ".vscode",
23
+ ".tox", ".eggs",
24
+ }
25
+ _MAX_FILES = 5000
26
+
27
+
28
+ def search_list(
29
+ values: Sequence[str],
30
+ *,
31
+ title: str = "",
32
+ hint: str = "",
33
+ ) -> str | None:
34
+ """Fuzzy-pick one string from ``values``; None when cancelled."""
35
+ return select(
36
+ [Option(v) for v in values],
37
+ title=title,
38
+ hint=hint,
39
+ max_rows=14,
40
+ )
41
+
42
+
43
+ def project_files(root: Path | None = None, limit: int = _MAX_FILES) -> list[str]:
44
+ """Collect project-relative file paths, skipping noise directories."""
45
+ base = root or Path.cwd()
46
+ found: list[str] = []
47
+ for dirpath, dirnames, filenames in os.walk(base):
48
+ dirnames[:] = sorted(
49
+ d for d in dirnames if d not in _SKIP_DIRS and not d.startswith(".")
50
+ )
51
+ rel_dir = os.path.relpath(dirpath, base)
52
+ for name in sorted(filenames):
53
+ rel = name if rel_dir == "." else os.path.join(rel_dir, name)
54
+ found.append(rel.replace(os.sep, "/"))
55
+ if len(found) >= limit:
56
+ return found
57
+ return found
58
+
59
+
60
+ def search_files(root: Path | None = None) -> str | None:
61
+ """Ctrl+P — fuzzy project file search; returns the chosen relative path."""
62
+ files = project_files(root)
63
+ if not files:
64
+ return None
65
+ return select(
66
+ [Option(f) for f in files],
67
+ title="Search Project Files",
68
+ hint="type to filter ↑↓ move Enter open Esc cancel",
69
+ max_rows=14,
70
+ )
@@ -0,0 +1,514 @@
1
+ """The interactive list selector — the core Seed Code UI component.
2
+
3
+ One keyboard-first widget replaces every numeric menu in the app:
4
+
5
+ * ``↑ ↓`` (and ``Tab``/``Shift+Tab``) move, ``Enter`` confirms, ``Esc`` and
6
+ ``Ctrl+C`` cancel — the user is never trapped.
7
+ * ``Home``/``End``/``PageUp``/``PageDown`` jump; long lists scroll inside a
8
+ fixed viewport so only changed rows are redrawn (prompt_toolkit renders
9
+ differentially — no flicker, no full-screen repaints).
10
+ * Typing filters instantly with fuzzy matching (``cld`` → Claude,
11
+ ``gpt55`` → GPT-5.5); ``Backspace`` restores; ``Ctrl+L`` clears the query.
12
+ * Items can carry status badges, extra columns, and group headers.
13
+ * Mouse (where the terminal supports it): click moves the cursor, clicking
14
+ the highlighted row confirms, the scroll wheel scrolls.
15
+ * ``Delete`` can be wired to remove an entry (history browser).
16
+ * An ``on_highlight`` hook fires on every cursor move (live theme preview).
17
+
18
+ Non-interactive streams (pipes, tests, dumb terminals) fall back to a plain
19
+ typed prompt matched with the same fuzzy rules — never a numbered list.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Callable, Sequence
27
+
28
+ from prompt_toolkit.application import Application
29
+ from prompt_toolkit.formatted_text import StyleAndTextTuples
30
+ from prompt_toolkit.key_binding import KeyBindings
31
+ from prompt_toolkit.keys import Keys
32
+ from prompt_toolkit.layout import Layout, Window
33
+ from prompt_toolkit.layout.controls import FormattedTextControl
34
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
35
+ from prompt_toolkit.styles import DynamicStyle
36
+
37
+ from .badges import badge_fragment
38
+ from .fuzzy import FuzzyResult, fuzzy_match
39
+ from .theme import pt_style
40
+
41
+ # Marker used for the highlighted row (the Seed pointer).
42
+ POINTER = "❯" # ❯
43
+ _PAGE = 10
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Option:
48
+ """One selectable entry.
49
+
50
+ ``columns`` are extra aligned display columns (provider selector shows
51
+ status/backend/model there). ``badge`` is a status key from
52
+ :mod:`seedcode.ui.badges`. ``group`` clusters entries under a header.
53
+ ``search_text`` (defaults to the label) is what fuzzy filtering sees.
54
+ """
55
+
56
+ label: str
57
+ value: Any = None
58
+ detail: str = ""
59
+ columns: tuple[str, ...] = ()
60
+ badge: str = ""
61
+ group: str = ""
62
+ disabled: bool = False
63
+ search_text: str = ""
64
+
65
+ def __post_init__(self) -> None:
66
+ if self.value is None:
67
+ self.value = self.label
68
+ if not self.search_text:
69
+ self.search_text = (
70
+ f"{self.label} {self.detail} {' '.join(self.columns)}".strip()
71
+ )
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class _Row:
76
+ """One rendered line: a group header or a selectable option."""
77
+
78
+ option: Option | None # None => group header
79
+ header: str = ""
80
+ match: FuzzyResult | None = None
81
+
82
+
83
+ def _interactive() -> bool:
84
+ try:
85
+ return sys.stdin.isatty() and sys.stdout.isatty()
86
+ except (AttributeError, ValueError):
87
+ return False
88
+
89
+
90
+ class Selector:
91
+ """Interactive selector application. Use :func:`select` unless you need
92
+ the extra hooks."""
93
+
94
+ def __init__(
95
+ self,
96
+ options: Sequence[Option],
97
+ *,
98
+ title: str = "",
99
+ breadcrumbs: Sequence[str] = (),
100
+ placeholder: str = "type to filter",
101
+ hint: str = "",
102
+ initial: Any = None,
103
+ searchable: bool = True,
104
+ on_highlight: Callable[[Option], None] | None = None,
105
+ on_delete: Callable[[Option], bool] | None = None,
106
+ max_rows: int = 12,
107
+ ) -> None:
108
+ self._all = list(options)
109
+ self._title = title
110
+ self._breadcrumbs = list(breadcrumbs)
111
+ self._placeholder = placeholder
112
+ self._hint = hint
113
+ self._searchable = searchable
114
+ self._on_highlight = on_highlight
115
+ self._on_delete = on_delete
116
+ self._max_rows = max_rows
117
+ self._query = ""
118
+ self._rows: list[_Row] = []
119
+ self._cursor = 0 # index into self._rows (always on a selectable row)
120
+ self._offset = 0 # first visible row
121
+ self._widths: tuple[int, ...] = ()
122
+ self._rebuild()
123
+ if initial is not None:
124
+ for i, row in enumerate(self._rows):
125
+ if row.option is not None and row.option.value == initial:
126
+ self._cursor = i
127
+ break
128
+ self._scroll_into_view()
129
+
130
+ # --- filtering / row model ----------------------------------------------
131
+ def _rebuild(self) -> None:
132
+ """Recompute visible rows for the current query."""
133
+ if self._query:
134
+ scored = []
135
+ for opt in self._all:
136
+ result = fuzzy_match(self._query, opt.search_text)
137
+ if result.matched:
138
+ scored.append((opt, result))
139
+ scored.sort(key=lambda pair: -pair[1].score)
140
+ self._rows = [_Row(opt, match=m) for opt, m in scored]
141
+ else:
142
+ self._rows = []
143
+ seen_group = ""
144
+ for opt in self._all:
145
+ if opt.group and opt.group != seen_group:
146
+ self._rows.append(_Row(None, header=opt.group))
147
+ seen_group = opt.group
148
+ self._rows.append(_Row(opt))
149
+ self._widths = self._column_widths()
150
+ self._cursor = self._first_selectable(0)
151
+ self._offset = 0
152
+
153
+ def _column_widths(self) -> tuple[int, ...]:
154
+ opts = [r.option for r in self._rows if r.option is not None]
155
+ if not opts:
156
+ return ()
157
+ label_w = max(len(o.label) for o in opts)
158
+ ncols = max((len(o.columns) for o in opts), default=0)
159
+ col_w = [
160
+ max((len(o.columns[i]) if i < len(o.columns) else 0) for o in opts)
161
+ for i in range(ncols)
162
+ ]
163
+ return (label_w, *col_w)
164
+
165
+ def _first_selectable(self, start: int, step: int = 1) -> int:
166
+ i = start
167
+ while 0 <= i < len(self._rows):
168
+ row = self._rows[i]
169
+ if row.option is not None and not row.option.disabled:
170
+ return i
171
+ i += step
172
+ return -1 if not self._rows else max(0, min(start, len(self._rows) - 1))
173
+
174
+ def _selectable_indices(self) -> list[int]:
175
+ return [
176
+ i
177
+ for i, r in enumerate(self._rows)
178
+ if r.option is not None and not r.option.disabled
179
+ ]
180
+
181
+ @property
182
+ def current(self) -> Option | None:
183
+ if 0 <= self._cursor < len(self._rows):
184
+ return self._rows[self._cursor].option
185
+ return None
186
+
187
+ # --- movement -------------------------------------------------------------
188
+ def _move(self, step: int) -> None:
189
+ sel = self._selectable_indices()
190
+ if not sel:
191
+ return
192
+ try:
193
+ pos = sel.index(self._cursor)
194
+ except ValueError:
195
+ pos = 0
196
+ pos = max(0, min(len(sel) - 1, pos + step))
197
+ self._cursor = sel[pos]
198
+ self._scroll_into_view()
199
+ self._fire_highlight()
200
+
201
+ def _move_edge(self, end: bool) -> None:
202
+ sel = self._selectable_indices()
203
+ if not sel:
204
+ return
205
+ self._cursor = sel[-1] if end else sel[0]
206
+ self._scroll_into_view()
207
+ self._fire_highlight()
208
+
209
+ def _fire_highlight(self) -> None:
210
+ if self._on_highlight is not None and self.current is not None:
211
+ try:
212
+ self._on_highlight(self.current)
213
+ except Exception:
214
+ pass # a preview hook must never break navigation
215
+
216
+ def _viewport(self) -> int:
217
+ return max(3, min(self._max_rows, len(self._rows)))
218
+
219
+ def _scroll_into_view(self) -> None:
220
+ height = self._viewport()
221
+ if self._cursor < self._offset:
222
+ self._offset = self._cursor
223
+ elif self._cursor >= self._offset + height:
224
+ self._offset = self._cursor - height + 1
225
+ self._offset = max(0, min(self._offset, max(0, len(self._rows) - height)))
226
+
227
+ # --- mouse -----------------------------------------------------------------
228
+ def _mouse_for(self, row_index: int) -> Callable[[MouseEvent], object]:
229
+ def handler(event: MouseEvent) -> object:
230
+ if event.event_type == MouseEventType.SCROLL_UP:
231
+ self._move(-1)
232
+ return None
233
+ if event.event_type == MouseEventType.SCROLL_DOWN:
234
+ self._move(1)
235
+ return None
236
+ if event.event_type == MouseEventType.MOUSE_UP:
237
+ row = self._rows[row_index] if 0 <= row_index < len(self._rows) else None
238
+ if row is None or row.option is None or row.option.disabled:
239
+ return NotImplemented
240
+ if self._cursor == row_index:
241
+ # Second click on the highlighted row confirms it.
242
+ self._app.exit(result=row.option)
243
+ else:
244
+ self._cursor = row_index
245
+ self._scroll_into_view()
246
+ self._fire_highlight()
247
+ return None
248
+ return NotImplemented
249
+
250
+ return handler
251
+
252
+ # --- rendering ---------------------------------------------------------------
253
+ def _fragments(self) -> StyleAndTextTuples:
254
+ out: StyleAndTextTuples = []
255
+ if self._breadcrumbs:
256
+ for i, crumb in enumerate(self._breadcrumbs):
257
+ if i:
258
+ out.append(("class:sel.breadcrumb", " › ")) # ›
259
+ style = (
260
+ "class:sel.breadcrumb.here"
261
+ if i == len(self._breadcrumbs) - 1
262
+ else "class:sel.breadcrumb"
263
+ )
264
+ out.append((style, crumb))
265
+ out.append(("", "\n"))
266
+ if self._title:
267
+ out.append(("class:sel.title", self._title))
268
+ out.append(("", "\n"))
269
+ if self._searchable:
270
+ out.append(("class:sel.searchlabel", " ⚲ ")) # ⚲ search glyph
271
+ if self._query:
272
+ out.append(("class:sel.query", self._query))
273
+ else:
274
+ out.append(("class:sel.placeholder", self._placeholder))
275
+ total = len(self._selectable_indices())
276
+ out.append(("class:sel.counter", f" {total}/{len(self._all)}"))
277
+ out.append(("", "\n"))
278
+
279
+ height = self._viewport()
280
+ visible = self._rows[self._offset : self._offset + height]
281
+ if not visible:
282
+ out.append(("class:sel.dim", " (no matches — Backspace to widen)\n"))
283
+ for k, row in enumerate(visible):
284
+ idx = self._offset + k
285
+ out.extend(self._row_fragments(row, idx))
286
+ out.append(("", "\n"))
287
+
288
+ # Scroll indicator for long lists.
289
+ if len(self._rows) > height:
290
+ above, below = self._offset, len(self._rows) - height - self._offset
291
+ marks = []
292
+ if above:
293
+ marks.append(f"↑ {above} more")
294
+ if below:
295
+ marks.append(f"↓ {below} more")
296
+ out.append(("class:sel.scroll", " " + " ".join(marks) + "\n"))
297
+
298
+ hint = self._hint or "↑↓ move Enter select Esc cancel"
299
+ out.append(("class:sel.hint", f" {hint}"))
300
+ return out
301
+
302
+ def _row_fragments(self, row: _Row, idx: int) -> StyleAndTextTuples:
303
+ handler = self._mouse_for(idx)
304
+ if row.option is None:
305
+ return [("class:sel.group", f" {row.header}", handler)]
306
+ opt = row.option
307
+ selected = idx == self._cursor
308
+ line = "class:sel.cursorline " if selected else ""
309
+ frags: StyleAndTextTuples = []
310
+ pointer = f"{POINTER} " if selected else " "
311
+ frags.append((f"{line}class:sel.pointer" if selected else line, pointer, handler))
312
+ if opt.group and self._query == "":
313
+ frags.append((line, " ", handler))
314
+
315
+ base = "class:sel.dim" if opt.disabled else "class:sel.text"
316
+ label_w = self._widths[0] if self._widths else len(opt.label)
317
+ frags.extend(
318
+ _highlighted(opt.label, row.match, f"{line}{base}", f"{line}class:sel.match", handler)
319
+ )
320
+ frags.append((line, " " * max(0, label_w - len(opt.label)), handler))
321
+ if opt.badge:
322
+ frags.append((line, " ", handler))
323
+ style, text = badge_fragment(opt.badge)
324
+ frags.append((f"{line}{style}", text, handler))
325
+ for i, col in enumerate(opt.columns):
326
+ width = self._widths[i + 1] if i + 1 < len(self._widths) else len(col)
327
+ frags.append((f"{line}class:sel.dim", " " + col.ljust(width), handler))
328
+ if opt.detail:
329
+ frags.append((f"{line}class:sel.dim", f" {opt.detail}", handler))
330
+ return frags
331
+
332
+ # --- application ---------------------------------------------------------------
333
+ def _build_app(self) -> Application:
334
+ kb = KeyBindings()
335
+
336
+ @kb.add("up")
337
+ def _(event) -> None:
338
+ self._move(-1)
339
+
340
+ @kb.add("down")
341
+ def _(event) -> None:
342
+ self._move(1)
343
+
344
+ kb.add("s-tab")(lambda e: self._move(-1))
345
+ kb.add("tab")(lambda e: self._move(1))
346
+ kb.add("left")(lambda e: self._move(-1))
347
+ kb.add("right")(lambda e: self._move(1))
348
+ kb.add("pageup")(lambda e: self._move(-_PAGE))
349
+ kb.add("pagedown")(lambda e: self._move(_PAGE))
350
+ kb.add("home")(lambda e: self._move_edge(False))
351
+ kb.add("end")(lambda e: self._move_edge(True))
352
+
353
+ @kb.add("enter")
354
+ def _(event) -> None:
355
+ current = self.current
356
+ if current is not None and not current.disabled:
357
+ event.app.exit(result=current)
358
+
359
+ @kb.add("escape", eager=True)
360
+ @kb.add("c-c")
361
+ def _(event) -> None:
362
+ event.app.exit(result=None)
363
+
364
+ @kb.add("backspace")
365
+ def _(event) -> None:
366
+ if self._searchable and self._query:
367
+ self._query = self._query[:-1]
368
+ self._rebuild()
369
+ self._fire_highlight()
370
+
371
+ @kb.add("c-l")
372
+ def _(event) -> None:
373
+ if self._searchable and self._query:
374
+ self._query = ""
375
+ self._rebuild()
376
+ self._fire_highlight()
377
+
378
+ @kb.add("delete")
379
+ def _(event) -> None:
380
+ current = self.current
381
+ if self._on_delete is None or current is None:
382
+ return
383
+ try:
384
+ removed = self._on_delete(current)
385
+ except Exception:
386
+ removed = False
387
+ if removed:
388
+ self._all = [o for o in self._all if o is not current]
389
+ query, self._query = self._query, ""
390
+ self._query = query
391
+ self._rebuild()
392
+ if not self._all:
393
+ event.app.exit(result=None)
394
+
395
+ @kb.add(Keys.Any)
396
+ def _(event) -> None:
397
+ ch = event.data
398
+ if self._searchable and ch and ch.isprintable():
399
+ self._query += ch
400
+ self._rebuild()
401
+ self._fire_highlight()
402
+
403
+ window = Window(
404
+ FormattedTextControl(self._fragments, focusable=True, show_cursor=False),
405
+ always_hide_cursor=True,
406
+ wrap_lines=False,
407
+ )
408
+ self._app: Application = Application(
409
+ layout=Layout(window),
410
+ key_bindings=kb,
411
+ # DynamicStyle re-reads the active theme every render, so the
412
+ # theme picker's live preview recolours this very selector.
413
+ style=DynamicStyle(lambda: pt_style()),
414
+ mouse_support=True,
415
+ full_screen=False,
416
+ erase_when_done=True,
417
+ )
418
+ return self._app
419
+
420
+ def run(self) -> Option | None:
421
+ if not self._all:
422
+ return None
423
+ try:
424
+ return self._build_app().run()
425
+ except (EOFError, KeyboardInterrupt):
426
+ return None
427
+
428
+
429
+ def _highlighted(
430
+ text: str,
431
+ match: FuzzyResult | None,
432
+ base_style: str,
433
+ match_style: str,
434
+ handler,
435
+ ) -> StyleAndTextTuples:
436
+ """Split ``text`` into fragments, highlighting fuzzy-matched positions."""
437
+ if match is None or not match.positions:
438
+ return [(base_style, text, handler)]
439
+ marked = set(match.positions)
440
+ frags: StyleAndTextTuples = []
441
+ run, run_marked = "", False
442
+ for i, ch in enumerate(text):
443
+ m = i in marked
444
+ if run and m != run_marked:
445
+ frags.append((match_style if run_marked else base_style, run, handler))
446
+ run = ""
447
+ run += ch
448
+ run_marked = m
449
+ if run:
450
+ frags.append((match_style if run_marked else base_style, run, handler))
451
+ return frags
452
+
453
+
454
+ # --- plain-stream fallback -------------------------------------------------------
455
+ def _fallback_select(options: Sequence[Option], title: str) -> Option | None:
456
+ """Typed selection for non-interactive streams: fuzzy text, no numbers."""
457
+ enabled = [o for o in options if not o.disabled]
458
+ if not enabled:
459
+ return None
460
+ if title:
461
+ print(title)
462
+ for opt in enabled:
463
+ extra = f" {opt.detail}" if opt.detail else ""
464
+ print(f" {opt.label}{extra}")
465
+ try:
466
+ raw = input("> ").strip()
467
+ except (EOFError, KeyboardInterrupt):
468
+ return None
469
+ if not raw:
470
+ return None
471
+ best: tuple[float, Option] | None = None
472
+ for opt in enabled:
473
+ result = fuzzy_match(raw, opt.search_text)
474
+ if result.matched and (best is None or result.score > best[0]):
475
+ best = (result.score, opt)
476
+ return best[1] if best else None
477
+
478
+
479
+ def select(
480
+ options: Sequence[Option],
481
+ *,
482
+ title: str = "",
483
+ breadcrumbs: Sequence[str] = (),
484
+ hint: str = "",
485
+ initial: Any = None,
486
+ searchable: bool = True,
487
+ on_highlight: Callable[[Option], None] | None = None,
488
+ on_delete: Callable[[Option], bool] | None = None,
489
+ max_rows: int = 12,
490
+ ) -> Any | None:
491
+ """Run the interactive selector and return the chosen option's value.
492
+
493
+ Returns ``None`` when cancelled (Esc/Ctrl+C) or when there is nothing to
494
+ choose from. On non-interactive streams a typed fuzzy prompt is used.
495
+ """
496
+ opts = list(options)
497
+ if not opts:
498
+ return None
499
+ if not _interactive():
500
+ chosen = _fallback_select(opts, title)
501
+ return chosen.value if chosen else None
502
+ selector = Selector(
503
+ opts,
504
+ title=title,
505
+ breadcrumbs=breadcrumbs,
506
+ hint=hint,
507
+ initial=initial,
508
+ searchable=searchable,
509
+ on_highlight=on_highlight,
510
+ on_delete=on_delete,
511
+ max_rows=max_rows,
512
+ )
513
+ chosen = selector.run()
514
+ return chosen.value if chosen else None
@@ -0,0 +1,38 @@
1
+ """Status bar and layout helpers.
2
+
3
+ :func:`session_statusbar` renders the one-line session summary (provider,
4
+ model, mode, connection badge) shown under interactive screens and after
5
+ mode switches. :mod:`layout` keeps the shared panel/column conventions in
6
+ one place so every screen composes the same way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rich.console import Console
12
+ from rich.text import Text
13
+
14
+ from ..core.models import AppConfig
15
+ from ..core.providers import PROVIDERS, provider_label
16
+ from .badges import badge_for_status, badge_text
17
+
18
+
19
+ def mode_label(config: AppConfig) -> str:
20
+ """The user-facing mode: Chat or Assist (never Agent/Desktop)."""
21
+ return "Assist" if config.agent_mode else "Chat"
22
+
23
+
24
+ def session_statusbar(console: Console, config: AppConfig) -> None:
25
+ """Print the one-line session status: provider · model · mode · badge."""
26
+ provider = PROVIDERS.get(config.provider)
27
+ status = provider.status if provider is not None else ""
28
+ badge = badge_text(badge_for_status(status))
29
+
30
+ bar = Text()
31
+ bar.append(" " + provider_label(config.provider), style="seed.primary")
32
+ bar.append(" · ", style="seed.dim")
33
+ bar.append(config.model or "no model", style="seed.text")
34
+ bar.append(" · ", style="seed.dim")
35
+ bar.append(f"Mode: {mode_label(config)}", style="seed.accent")
36
+ bar.append(" · ", style="seed.dim")
37
+ bar.append(badge, style="seed.dim")
38
+ console.print(bar)
seedcode/ui/textbox.py ADDED
@@ -0,0 +1,61 @@
1
+ """Styled text input: the one line-input component.
2
+
3
+ Wraps prompt_toolkit's PromptSession with the active theme, Esc-to-cancel,
4
+ and password masking. Everything that needs typed input (API keys, setting
5
+ values, chat itself is separate) goes through here so behaviour stays
6
+ identical everywhere: Enter submits, Esc/Ctrl+C/Ctrl+D cancel (None).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.formatted_text import FormattedText
13
+ from prompt_toolkit.key_binding import KeyBindings
14
+
15
+ from .theme import pt_style
16
+
17
+
18
+ def prompt_label(text: str) -> FormattedText:
19
+ """Build a themed prompt label for prompt_toolkit."""
20
+ return FormattedText([("class:prompt", text)])
21
+
22
+
23
+ def _escape_bindings() -> KeyBindings:
24
+ kb = KeyBindings()
25
+
26
+ @kb.add("escape", eager=True)
27
+ def _(event) -> None:
28
+ event.app.exit(exception=EOFError)
29
+
30
+ return kb
31
+
32
+
33
+ def read_text(
34
+ label: str,
35
+ *,
36
+ password: bool = False,
37
+ default: str = "",
38
+ placeholder: str = "",
39
+ ) -> str | None:
40
+ """Read one line of themed input; ``None`` means cancelled.
41
+
42
+ Esc, Ctrl+C and Ctrl+D all cancel — the user is never trapped.
43
+ """
44
+ session: PromptSession = PromptSession(key_bindings=_escape_bindings())
45
+ kwargs: dict = {}
46
+ if placeholder:
47
+ kwargs["placeholder"] = FormattedText([("class:sel.placeholder", placeholder)])
48
+ try:
49
+ return session.prompt(
50
+ prompt_label(label),
51
+ is_password=password,
52
+ style=pt_style(),
53
+ default=default,
54
+ **kwargs,
55
+ ).strip()
56
+ except (EOFError, KeyboardInterrupt):
57
+ return None
58
+
59
+
60
+ # Backwards-compatible alias (old prompts.read_line callers).
61
+ read_line = read_text