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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
seedcode/ui/searchbox.py
ADDED
|
@@ -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
|
+
)
|
seedcode/ui/selector.py
ADDED
|
@@ -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
|
seedcode/ui/statusbar.py
ADDED
|
@@ -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
|