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/dashboard.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The Seed Code startup dashboard.
|
|
2
|
+
|
|
3
|
+
A single branded panel shown exactly once at application startup: the ASCII
|
|
4
|
+
logo, tagline and credits on the left, and a live "Current Session" summary
|
|
5
|
+
(provider, backend, model, status, context, history) on the right. Wide
|
|
6
|
+
terminals get the two-column layout with a vertical divider; narrow terminals
|
|
7
|
+
stack the session block below the logo. Every width decision is derived from
|
|
8
|
+
the live console size and measured content — nothing is padded by hand.
|
|
9
|
+
|
|
10
|
+
Rendering uses plain Rich primitives (Panel, Columns, Table.grid, Rule) so the
|
|
11
|
+
dashboard displays identically in Windows Terminal, PowerShell, CMD, Linux and
|
|
12
|
+
macOS terminals; legacy Windows consoles get pure-ASCII fallbacks.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from rich import box
|
|
18
|
+
from rich.align import Align
|
|
19
|
+
from rich.console import Console, Group, RenderableType
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.rule import Rule
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from .. import APP_NAME, TAGLINE, __version__
|
|
26
|
+
from ..core.models import AppConfig
|
|
27
|
+
from ..core.providers import PROVIDERS, provider_label
|
|
28
|
+
from ..core.providers.base import STATUS_CONNECTED, STATUS_UNKNOWN
|
|
29
|
+
|
|
30
|
+
_LOGO_LINES = [
|
|
31
|
+
r"███████╗███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
|
|
32
|
+
r"██╔════╝██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
|
|
33
|
+
r"███████╗█████╗ █████╗ ██║ ██║ ██║ ██║ ██║██║ ██║█████╗ ",
|
|
34
|
+
r"╚════██║██╔══╝ ██╔══╝ ██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝ ",
|
|
35
|
+
r"███████║███████╗███████╗██████╔╝ ╚██████╗╚██████╔╝██████╔╝███████╗",
|
|
36
|
+
r"╚══════╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Pure-ASCII fallback for legacy Windows consoles (raster-font cmd.exe)
|
|
40
|
+
# where the block/box-drawing glyphs above render as garbage.
|
|
41
|
+
_LOGO_LINES_ASCII = [
|
|
42
|
+
r" ____ _____ _____ ____ ____ ___ ____ _____ ",
|
|
43
|
+
r"/ ___|| ____| ____| _ \ / ___/ _ \| _ \| ____|",
|
|
44
|
+
r"\___ \| _| | _| | | | | | | | | | | | | | _| ",
|
|
45
|
+
r" ___) | |___| |___| |_| | | |__| |_| | |_| | |___ ",
|
|
46
|
+
r"|____/|_____|_____|____/ \____\___/|____/|_____|",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
_CREDIT = "Created by Al Shahriar Sowan"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- session state ----------------------------------------------------------
|
|
53
|
+
def _provider_ready(config: AppConfig) -> bool:
|
|
54
|
+
"""True when the active provider is selected and has what it needs."""
|
|
55
|
+
provider = PROVIDERS.get(config.provider)
|
|
56
|
+
if provider is None:
|
|
57
|
+
return False
|
|
58
|
+
return not provider.requires_key or bool(config.get_api_key().strip())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _provider_value(config: AppConfig) -> str:
|
|
62
|
+
"""Provider display label."""
|
|
63
|
+
if not _provider_ready(config):
|
|
64
|
+
return "Not selected"
|
|
65
|
+
return provider_label(config.provider)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _backend_value(config: AppConfig) -> str:
|
|
69
|
+
"""The API family the active provider talks to (its own backend_label)."""
|
|
70
|
+
if not _provider_ready(config):
|
|
71
|
+
return "--"
|
|
72
|
+
if config.provider == "ollama":
|
|
73
|
+
return config.ollama_host
|
|
74
|
+
provider = PROVIDERS.get(config.provider)
|
|
75
|
+
if provider is not None and provider.backend_label:
|
|
76
|
+
return provider.backend_label
|
|
77
|
+
return f"{provider_label(config.provider)} API"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _model_value(config: AppConfig) -> str:
|
|
81
|
+
if not config.model:
|
|
82
|
+
return "Not selected"
|
|
83
|
+
if config.model == "auto":
|
|
84
|
+
return "Auto (best free model)"
|
|
85
|
+
return config.model
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _status_value(config: AppConfig, marker: str) -> Text:
|
|
89
|
+
"""Connection status from the provider's session cache (no network I/O)."""
|
|
90
|
+
if not config.is_configured():
|
|
91
|
+
return Text("Not configured", style="seed.dim")
|
|
92
|
+
provider = PROVIDERS.get(config.provider)
|
|
93
|
+
status = provider.status if provider is not None else STATUS_UNKNOWN
|
|
94
|
+
if status == STATUS_CONNECTED:
|
|
95
|
+
return Text(f"{marker} Connected", style="seed.success")
|
|
96
|
+
if status == STATUS_UNKNOWN:
|
|
97
|
+
# Configured but not probed yet — ready to chat, not yet verified.
|
|
98
|
+
return Text(f"{marker} Ready", style="seed.success")
|
|
99
|
+
return Text(f"{marker} {status}", style="seed.dim")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _context_value(config: AppConfig) -> str:
|
|
103
|
+
"""Model context window — the live catalogue is not cached at startup."""
|
|
104
|
+
return "--"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _mode_value(config: AppConfig) -> str:
|
|
108
|
+
"""The user-facing mode: Chat or Assist (never Agent/Desktop)."""
|
|
109
|
+
return "Assist" if config.agent_mode else "Chat"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- blocks -----------------------------------------------------------------
|
|
113
|
+
def _brand_block(legacy: bool) -> RenderableType:
|
|
114
|
+
"""Left side: the exact ASCII logo with tagline and author centered under it."""
|
|
115
|
+
lines = _LOGO_LINES_ASCII if legacy else _LOGO_LINES
|
|
116
|
+
logo = Text("\n".join(lines), style="seed.primary", no_wrap=True, overflow="crop")
|
|
117
|
+
tagline = Text(TAGLINE, style="seed.accent")
|
|
118
|
+
credit = Text(_CREDIT, style="seed.dim")
|
|
119
|
+
return Group(
|
|
120
|
+
Align.center(logo),
|
|
121
|
+
Text(),
|
|
122
|
+
Align.center(tagline),
|
|
123
|
+
Text(),
|
|
124
|
+
Align.center(credit),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _session_block(config: AppConfig, legacy: bool) -> RenderableType:
|
|
129
|
+
"""Right side: the dynamic Current Session summary."""
|
|
130
|
+
marker = "*" if legacy else "●"
|
|
131
|
+
grid = Table.grid(padding=(0, 1))
|
|
132
|
+
grid.add_column(style="seed.accent", no_wrap=True)
|
|
133
|
+
grid.add_column(style="seed.dim", no_wrap=True)
|
|
134
|
+
grid.add_column(style="seed.text", overflow="fold")
|
|
135
|
+
rows: list[tuple[str, RenderableType]] = [
|
|
136
|
+
("Provider", Text(_provider_value(config))),
|
|
137
|
+
("Backend", Text(_backend_value(config))),
|
|
138
|
+
("Model", Text(_model_value(config))),
|
|
139
|
+
("Mode", Text(_mode_value(config), style="seed.accent")),
|
|
140
|
+
("Status", _status_value(config, marker)),
|
|
141
|
+
("Context", Text(_context_value(config))),
|
|
142
|
+
("History", Text("Enabled")),
|
|
143
|
+
]
|
|
144
|
+
for label, value in rows:
|
|
145
|
+
grid.add_row(label, ":", value)
|
|
146
|
+
return Group(
|
|
147
|
+
Text("Current Session", style="seed.primary"),
|
|
148
|
+
Rule(style="seed.dim", characters="-" if legacy else "─"),
|
|
149
|
+
Text(),
|
|
150
|
+
grid,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _logo_width(legacy: bool) -> int:
|
|
155
|
+
lines = _LOGO_LINES_ASCII if legacy else _LOGO_LINES
|
|
156
|
+
return max(len(line) for line in lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- dashboard --------------------------------------------------------------
|
|
160
|
+
def render_dashboard(console: Console, config: AppConfig) -> None:
|
|
161
|
+
"""Render the startup dashboard panel, adapting to the terminal width."""
|
|
162
|
+
legacy = console.legacy_windows
|
|
163
|
+
brand = _brand_block(legacy)
|
|
164
|
+
session = _session_block(config, legacy)
|
|
165
|
+
|
|
166
|
+
session_width = console.measure(session).maximum
|
|
167
|
+
# Panel borders/padding (6) + column gutters and divider (5), all fixed
|
|
168
|
+
# rendering overhead — the terminal width itself is never assumed.
|
|
169
|
+
needed = _logo_width(legacy) + session_width + 11
|
|
170
|
+
|
|
171
|
+
if console.size.width >= needed:
|
|
172
|
+
# Two columns with a single vertical divider between them: a Table
|
|
173
|
+
# whose outer edge is hidden renders only the column separator.
|
|
174
|
+
body: RenderableType = Table(
|
|
175
|
+
box=box.SQUARE,
|
|
176
|
+
show_header=False,
|
|
177
|
+
show_edge=False,
|
|
178
|
+
border_style="seed.dim",
|
|
179
|
+
padding=(0, 2),
|
|
180
|
+
pad_edge=False,
|
|
181
|
+
)
|
|
182
|
+
body.add_column()
|
|
183
|
+
body.add_column()
|
|
184
|
+
body.add_row(brand, session)
|
|
185
|
+
else:
|
|
186
|
+
# Narrow terminals: stack the session block below the logo.
|
|
187
|
+
body = Group(brand, Text(), Align.center(session))
|
|
188
|
+
|
|
189
|
+
console.print(
|
|
190
|
+
Panel(
|
|
191
|
+
body,
|
|
192
|
+
title=f"{APP_NAME} v{__version__}",
|
|
193
|
+
title_align="left",
|
|
194
|
+
border_style="seed.primary",
|
|
195
|
+
padding=(1, 2),
|
|
196
|
+
)
|
|
197
|
+
)
|
seedcode/ui/dialog.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Interactive dialogs: confirmations and permission prompts.
|
|
2
|
+
|
|
3
|
+
Replaces every [Y]/[N] text prompt with an arrow-key choice list:
|
|
4
|
+
|
|
5
|
+
❯ Allow Once
|
|
6
|
+
Always Allow
|
|
7
|
+
Deny
|
|
8
|
+
|
|
9
|
+
Esc/Ctrl+C always mean the safe answer (deny/cancel) — an accidental
|
|
10
|
+
cancel can never grant a permission.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .selector import Option, select
|
|
16
|
+
|
|
17
|
+
# Values returned by permission_dialog, aligned with the existing gates.
|
|
18
|
+
ALLOW_ONCE = "y"
|
|
19
|
+
ALLOW_ALWAYS = "a"
|
|
20
|
+
DENY = "n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def permission_dialog(*, allow_always: bool = True) -> str:
|
|
24
|
+
"""Ask Allow Once / Always Allow / Deny; returns 'y', 'a', or 'n'.
|
|
25
|
+
|
|
26
|
+
Cancelling (Esc/Ctrl+C) returns 'n' — never an approval.
|
|
27
|
+
"""
|
|
28
|
+
options = [Option("Allow Once", ALLOW_ONCE, detail="approve this action only")]
|
|
29
|
+
if allow_always:
|
|
30
|
+
options.append(
|
|
31
|
+
Option("Always Allow", ALLOW_ALWAYS, detail="approve for this session")
|
|
32
|
+
)
|
|
33
|
+
options.append(Option("Deny", DENY, detail="block this action"))
|
|
34
|
+
result = select(
|
|
35
|
+
options,
|
|
36
|
+
searchable=False,
|
|
37
|
+
hint="↑↓ move Enter confirm Esc deny",
|
|
38
|
+
)
|
|
39
|
+
return result if result in (ALLOW_ONCE, ALLOW_ALWAYS, DENY) else DENY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def confirm_dialog(
|
|
43
|
+
question: str,
|
|
44
|
+
*,
|
|
45
|
+
yes_label: str = "Yes",
|
|
46
|
+
no_label: str = "No",
|
|
47
|
+
danger: bool = False,
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""A two-option yes/no dialog; Esc/cancel counts as No.
|
|
50
|
+
|
|
51
|
+
``danger=True`` puts No first so Enter-mashing never destroys anything.
|
|
52
|
+
"""
|
|
53
|
+
yes = Option(yes_label, True)
|
|
54
|
+
no = Option(no_label, False)
|
|
55
|
+
options = [no, yes] if danger else [yes, no]
|
|
56
|
+
result = select(
|
|
57
|
+
options,
|
|
58
|
+
title=question,
|
|
59
|
+
searchable=False,
|
|
60
|
+
hint="↑↓ move Enter confirm Esc cancel",
|
|
61
|
+
)
|
|
62
|
+
return bool(result)
|
seedcode/ui/fuzzy.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Fuzzy matching for the interactive components.
|
|
2
|
+
|
|
3
|
+
Subsequence matching with a smallest-window score, tuned for picking model
|
|
4
|
+
ids and command names:
|
|
5
|
+
|
|
6
|
+
* ``cld`` matches **Cl**au**d**e
|
|
7
|
+
* ``gpt55`` matches **GPT-5.5** (separators are skipped freely)
|
|
8
|
+
* ``opus`` matches Claude **Opus**
|
|
9
|
+
|
|
10
|
+
Scoring prefers, in order: exact substring at a word start, exact substring
|
|
11
|
+
anywhere, then the tightest subsequence window with bonuses for matches at
|
|
12
|
+
word boundaries and consecutive runs. Pure Python, no dependencies, fast
|
|
13
|
+
enough for thousand-entry model lists (a single linear scan per candidate).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
_WORD_SEPARATORS = set(" -_./:@")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class FuzzyResult:
|
|
25
|
+
"""Outcome of matching one query against one candidate string."""
|
|
26
|
+
|
|
27
|
+
matched: bool
|
|
28
|
+
score: float
|
|
29
|
+
positions: tuple[int, ...] # candidate indices to highlight
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_NO_MATCH = FuzzyResult(False, float("-inf"), ())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_boundary(text: str, i: int) -> bool:
|
|
36
|
+
"""True when ``text[i]`` starts a word (position 0, after a separator,
|
|
37
|
+
a digit after a letter, or an upper-case letter after a lower-case one)."""
|
|
38
|
+
if i == 0:
|
|
39
|
+
return True
|
|
40
|
+
prev, ch = text[i - 1], text[i]
|
|
41
|
+
if prev in _WORD_SEPARATORS:
|
|
42
|
+
return True
|
|
43
|
+
if ch.isdigit() and prev.isalpha():
|
|
44
|
+
return True
|
|
45
|
+
if ch.isupper() and prev.islower():
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _subsequence_from(low_text: str, low_query: str, start: int) -> list[int] | None:
|
|
51
|
+
"""Greedy left-to-right subsequence match beginning at/after ``start``."""
|
|
52
|
+
positions: list[int] = []
|
|
53
|
+
i = start
|
|
54
|
+
for qc in low_query:
|
|
55
|
+
j = low_text.find(qc, i)
|
|
56
|
+
if j < 0:
|
|
57
|
+
return None
|
|
58
|
+
positions.append(j)
|
|
59
|
+
i = j + 1
|
|
60
|
+
return positions
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fuzzy_match(query: str, text: str) -> FuzzyResult:
|
|
64
|
+
"""Match ``query`` against ``text``; empty queries match everything."""
|
|
65
|
+
if not query:
|
|
66
|
+
return FuzzyResult(True, 0.0, ())
|
|
67
|
+
if not text:
|
|
68
|
+
return _NO_MATCH
|
|
69
|
+
|
|
70
|
+
low_text = text.lower()
|
|
71
|
+
low_query = "".join(ch for ch in query.lower() if not ch.isspace())
|
|
72
|
+
if not low_query:
|
|
73
|
+
return FuzzyResult(True, 0.0, ())
|
|
74
|
+
|
|
75
|
+
# Fast paths: exact substring (best at a word boundary).
|
|
76
|
+
idx = low_text.find(low_query)
|
|
77
|
+
if idx >= 0:
|
|
78
|
+
span = tuple(range(idx, idx + len(low_query)))
|
|
79
|
+
bonus = 200.0 if _is_boundary(text, idx) else 100.0
|
|
80
|
+
return FuzzyResult(True, 1000.0 + bonus - idx * 0.5 - len(text) * 0.01, span)
|
|
81
|
+
|
|
82
|
+
# Subsequence: try anchoring at each occurrence of the first query char
|
|
83
|
+
# and keep the best-scoring window (bounded to a handful of anchors).
|
|
84
|
+
first = low_query[0]
|
|
85
|
+
best: FuzzyResult = _NO_MATCH
|
|
86
|
+
anchor = low_text.find(first)
|
|
87
|
+
tries = 0
|
|
88
|
+
while anchor >= 0 and tries < 8:
|
|
89
|
+
positions = _subsequence_from(low_text, low_query, anchor)
|
|
90
|
+
if positions is None:
|
|
91
|
+
break # later anchors can only fail too
|
|
92
|
+
score = _score(text, positions)
|
|
93
|
+
if score > best.score:
|
|
94
|
+
best = FuzzyResult(True, score, tuple(positions))
|
|
95
|
+
anchor = low_text.find(first, anchor + 1)
|
|
96
|
+
tries += 1
|
|
97
|
+
return best
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _score(text: str, positions: list[int]) -> float:
|
|
101
|
+
window = positions[-1] - positions[0] + 1
|
|
102
|
+
score = 500.0 - (window - len(positions)) * 10.0 # tighter window is better
|
|
103
|
+
for k, pos in enumerate(positions):
|
|
104
|
+
if _is_boundary(text, pos):
|
|
105
|
+
score += 15.0
|
|
106
|
+
if k > 0 and positions[k - 1] == pos - 1:
|
|
107
|
+
score += 5.0 # consecutive run
|
|
108
|
+
score -= positions[0] * 0.5 # earlier start is better
|
|
109
|
+
score -= len(text) * 0.01 # shorter candidates win ties
|
|
110
|
+
return score
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fuzzy_filter(
|
|
114
|
+
query: str, items: list, key=lambda item: str(item)
|
|
115
|
+
) -> list[tuple[object, FuzzyResult]]:
|
|
116
|
+
"""Return ``(item, result)`` for matching items, best score first.
|
|
117
|
+
|
|
118
|
+
Sorting is stable for equal scores, so the caller's original order is
|
|
119
|
+
preserved within ties (important for grouped model lists).
|
|
120
|
+
"""
|
|
121
|
+
scored: list[tuple[object, FuzzyResult]] = []
|
|
122
|
+
for item in items:
|
|
123
|
+
result = fuzzy_match(query, key(item))
|
|
124
|
+
if result.matched:
|
|
125
|
+
scored.append((item, result))
|
|
126
|
+
if query:
|
|
127
|
+
scored.sort(key=lambda pair: -pair[1].score)
|
|
128
|
+
return scored
|
seedcode/ui/layout.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared layout conventions: panels, key-value grids, shortcut tables.
|
|
2
|
+
|
|
3
|
+
Every screen builds its content through these helpers so padding, borders
|
|
4
|
+
and column styles stay identical across the app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Iterable, Sequence
|
|
10
|
+
|
|
11
|
+
from rich.console import RenderableType
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def branded_panel(body: RenderableType, title: str | None = None) -> Panel:
|
|
17
|
+
"""The standard Seed Code panel: primary border, left title, padding."""
|
|
18
|
+
return Panel(
|
|
19
|
+
body,
|
|
20
|
+
title=title,
|
|
21
|
+
border_style="seed.primary",
|
|
22
|
+
title_align="left",
|
|
23
|
+
padding=(1, 2),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def kv_grid(rows: Iterable[tuple[str, RenderableType]]) -> Table:
|
|
28
|
+
"""A two-column label/value grid (labels dimmed right, values plain)."""
|
|
29
|
+
grid = Table.grid(padding=(0, 3))
|
|
30
|
+
grid.add_column(style="seed.dim", justify="right", no_wrap=True)
|
|
31
|
+
grid.add_column(style="seed.text")
|
|
32
|
+
for label, value in rows:
|
|
33
|
+
grid.add_row(label, value)
|
|
34
|
+
return grid
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def columns_grid(rows: Sequence[Sequence[str]], styles: Sequence[str]) -> Table:
|
|
38
|
+
"""An n-column grid with one style per column."""
|
|
39
|
+
grid = Table.grid(padding=(0, 2))
|
|
40
|
+
for style in styles:
|
|
41
|
+
grid.add_column(style=style)
|
|
42
|
+
for row in rows:
|
|
43
|
+
grid.add_row(*row)
|
|
44
|
+
return grid
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def shortcuts_grid(pairs: Sequence[tuple[str, str]]) -> Table:
|
|
48
|
+
"""Keyboard-shortcut table: accent keys, plain descriptions."""
|
|
49
|
+
grid = Table.grid(padding=(0, 3))
|
|
50
|
+
grid.add_column(style="seed.accent", no_wrap=True)
|
|
51
|
+
grid.add_column(style="seed.text")
|
|
52
|
+
for key, action in pairs:
|
|
53
|
+
grid.add_row(key, action)
|
|
54
|
+
return grid
|
seedcode/ui/menu.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Action menus built on the interactive Selector.
|
|
2
|
+
|
|
3
|
+
A menu is a selector over labelled actions: the main menu, the API-key
|
|
4
|
+
menu, and every "pick one of these things to do" screen use this instead
|
|
5
|
+
of numbered rows. Items may show a live status column and a badge.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Sequence
|
|
12
|
+
|
|
13
|
+
from .selector import Option, select
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class MenuItem:
|
|
18
|
+
"""One menu action: a label, the value returned when chosen, and an
|
|
19
|
+
optional status column shown dimmed to the right."""
|
|
20
|
+
|
|
21
|
+
label: str
|
|
22
|
+
value: Any = None
|
|
23
|
+
status: str = ""
|
|
24
|
+
badge: str = ""
|
|
25
|
+
group: str = ""
|
|
26
|
+
disabled: bool = False
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self.value is None:
|
|
30
|
+
self.value = self.label
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_menu(
|
|
34
|
+
items: Sequence[MenuItem],
|
|
35
|
+
*,
|
|
36
|
+
title: str = "",
|
|
37
|
+
breadcrumbs: Sequence[str] = (),
|
|
38
|
+
hint: str = "",
|
|
39
|
+
initial: Any = None,
|
|
40
|
+
searchable: bool = True,
|
|
41
|
+
) -> Any | None:
|
|
42
|
+
"""Show an interactive menu; returns the chosen item's value or None."""
|
|
43
|
+
options = [
|
|
44
|
+
Option(
|
|
45
|
+
label=item.label,
|
|
46
|
+
value=item.value,
|
|
47
|
+
columns=(item.status,) if item.status else (),
|
|
48
|
+
badge=item.badge,
|
|
49
|
+
group=item.group,
|
|
50
|
+
disabled=item.disabled,
|
|
51
|
+
)
|
|
52
|
+
for item in items
|
|
53
|
+
]
|
|
54
|
+
return select(
|
|
55
|
+
options,
|
|
56
|
+
title=title,
|
|
57
|
+
breadcrumbs=breadcrumbs,
|
|
58
|
+
hint=hint,
|
|
59
|
+
initial=initial,
|
|
60
|
+
searchable=searchable,
|
|
61
|
+
)
|
seedcode/ui/palette.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Command palette (Ctrl+K) — searchable actions, VS Code style.
|
|
2
|
+
|
|
3
|
+
The palette is a fuzzy selector over registered actions, opened from the
|
|
4
|
+
chat prompt with Ctrl+K. Actions are plain (label, value) pairs supplied
|
|
5
|
+
by the app layer, so this module stays free of business logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Sequence
|
|
12
|
+
|
|
13
|
+
from .selector import Option, select
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class PaletteAction:
|
|
18
|
+
"""One palette entry: display label, returned value, optional detail."""
|
|
19
|
+
|
|
20
|
+
label: str
|
|
21
|
+
value: Any
|
|
22
|
+
detail: str = ""
|
|
23
|
+
group: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def command_palette(
|
|
27
|
+
actions: Sequence[PaletteAction],
|
|
28
|
+
*,
|
|
29
|
+
title: str = "Command Palette",
|
|
30
|
+
) -> Any | None:
|
|
31
|
+
"""Open the palette; returns the chosen action's value or None."""
|
|
32
|
+
return select(
|
|
33
|
+
[
|
|
34
|
+
Option(a.label, a.value, detail=a.detail, group=a.group)
|
|
35
|
+
for a in actions
|
|
36
|
+
],
|
|
37
|
+
title=title,
|
|
38
|
+
hint="type to search ↑↓ move Enter run Esc close",
|
|
39
|
+
max_rows=12,
|
|
40
|
+
)
|
seedcode/ui/progress.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Progress primitives: themed spinners and step indicators.
|
|
2
|
+
|
|
3
|
+
Kept deliberately small — the heavy lifting is Rich's Live display, which
|
|
4
|
+
already does minimal-redraw updates. This module provides the branded
|
|
5
|
+
wrappers so every wait state looks the same.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from typing import Iterator
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.spinner import Spinner
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def spinner(console: Console, label: str = "Working") -> Iterator[None]:
|
|
21
|
+
"""A transient themed spinner around a blocking operation."""
|
|
22
|
+
view = Spinner("dots", text=Text(f" {label}...", style="seed.accent"))
|
|
23
|
+
with Live(view, console=console, refresh_per_second=12, transient=True):
|
|
24
|
+
yield
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StepProgress:
|
|
28
|
+
"""Sequential step reporting: ⟳ while running, ✓/✗ when finished."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, console: Console) -> None:
|
|
31
|
+
self._console = console
|
|
32
|
+
|
|
33
|
+
def start(self, label: str) -> None:
|
|
34
|
+
self._console.print(Text(f"⟳ {label}...", style="seed.warning"))
|
|
35
|
+
|
|
36
|
+
def done(self, label: str) -> None:
|
|
37
|
+
self._console.print(Text(f"✓ {label}", style="seed.success"))
|
|
38
|
+
|
|
39
|
+
def fail(self, label: str, reason: str = "") -> None:
|
|
40
|
+
suffix = f" — {reason}" if reason else ""
|
|
41
|
+
self._console.print(Text(f"✗ {label}{suffix}", style="seed.error"))
|
seedcode/ui/prompts.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Compatibility shim — the input component moved to :mod:`seedcode.ui.textbox`.
|
|
2
|
+
|
|
3
|
+
Kept so existing imports (``from ..ui.prompts import read_line``) continue
|
|
4
|
+
to work; new code should import from ``textbox`` directly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .textbox import prompt_label, read_line, read_text
|
|
10
|
+
from .theme import pt_style
|
|
11
|
+
|
|
12
|
+
# Legacy name: a static style object matching the current theme at import
|
|
13
|
+
# time. Prefer pt_style() for live-theme correctness.
|
|
14
|
+
PT_STYLE = pt_style()
|
|
15
|
+
|
|
16
|
+
__all__ = ["PT_STYLE", "prompt_label", "read_line", "read_text", "pt_style"]
|
seedcode/ui/renderer.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Live streaming markdown renderer.
|
|
2
|
+
|
|
3
|
+
Accumulates streamed tokens and re-renders them as live markdown so code blocks
|
|
4
|
+
and formatting appear as the assistant types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StreamRenderer:
|
|
15
|
+
"""Accumulates streamed tokens and re-renders them as live markdown."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, console: Console) -> None:
|
|
18
|
+
self._console = console
|
|
19
|
+
self._buffer = ""
|
|
20
|
+
self._live: Live | None = None
|
|
21
|
+
|
|
22
|
+
def bind(self, live: Live) -> None:
|
|
23
|
+
self._live = live
|
|
24
|
+
|
|
25
|
+
def renderable(self):
|
|
26
|
+
# Assistant output rendered as markdown for code blocks and formatting.
|
|
27
|
+
return Markdown(self._buffer or "", code_theme="ansi_dark")
|
|
28
|
+
|
|
29
|
+
def feed(self, chunk: str) -> None:
|
|
30
|
+
self._buffer += chunk
|
|
31
|
+
if self._live is not None:
|
|
32
|
+
self._live.update(self.renderable())
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def text(self) -> str:
|
|
36
|
+
return self._buffer
|