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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Terminal execution tool: run a shell command in the workspace, live.
|
|
2
|
+
|
|
3
|
+
Commands run through ``subprocess.Popen`` with a reader thread so output
|
|
4
|
+
streams line-by-line while the process is still running (Windows pipes have
|
|
5
|
+
no ``select``, hence the thread + queue). The agent layer can attach a
|
|
6
|
+
per-line callback via ``PermissionManager.on_output`` to echo progress to
|
|
7
|
+
the user as it happens.
|
|
8
|
+
|
|
9
|
+
Shell selection: ``cmd``, ``powershell`` and ``bash`` are supported
|
|
10
|
+
explicitly; the default ("") is the platform shell (cmd on Windows,
|
|
11
|
+
/bin/sh elsewhere) via ``shell=True``.
|
|
12
|
+
|
|
13
|
+
Cancellation: pressing Ctrl+C while a command is running kills that
|
|
14
|
+
command's whole process tree (``taskkill /T /F`` on Windows, process-group
|
|
15
|
+
SIGKILL elsewhere) and reports the cancellation as a failed tool result —
|
|
16
|
+
the agent turn itself continues, so the model learns the command was
|
|
17
|
+
cancelled instead of the loop dying.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import queue
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from typing import IO, TYPE_CHECKING, Any, Callable
|
|
30
|
+
|
|
31
|
+
from .base import MAX_OUTPUT_CHARS, ToolResult, int_arg, register
|
|
32
|
+
from .permissions import CATEGORY_SHELL
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .permissions import PermissionManager
|
|
36
|
+
|
|
37
|
+
_DEFAULT_TIMEOUT_S = 60
|
|
38
|
+
_MAX_TIMEOUT_S = 300
|
|
39
|
+
|
|
40
|
+
# Explicit shell -> argv builder. "" (default) uses shell=True instead.
|
|
41
|
+
_SHELLS: dict[str, Callable[[str], list[str]]] = {
|
|
42
|
+
"cmd": lambda c: ["cmd", "/d", "/c", c],
|
|
43
|
+
"powershell": lambda c: [
|
|
44
|
+
"powershell", "-NoProfile", "-NonInteractive", "-Command", c
|
|
45
|
+
],
|
|
46
|
+
"bash": lambda c: ["bash", "-lc", c],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_EOF = object() # sentinel the reader thread puts when the pipe closes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
53
|
+
"""Kill a process and all of its children; never raises."""
|
|
54
|
+
try:
|
|
55
|
+
if sys.platform == "win32":
|
|
56
|
+
subprocess.run(
|
|
57
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
try:
|
|
65
|
+
proc.wait(timeout=5)
|
|
66
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reader(pipe: IO[str], out: "queue.Queue[object]") -> None:
|
|
71
|
+
"""Pump lines from the child's merged stdout into the queue."""
|
|
72
|
+
try:
|
|
73
|
+
for line in pipe:
|
|
74
|
+
out.put(line)
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
pass # pipe closed mid-read; treat as EOF
|
|
77
|
+
out.put(_EOF)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_command(
|
|
81
|
+
perm: "PermissionManager",
|
|
82
|
+
command: str,
|
|
83
|
+
timeout_s: int,
|
|
84
|
+
*,
|
|
85
|
+
shell: str = "",
|
|
86
|
+
on_line: Callable[[str], None] | None = None,
|
|
87
|
+
) -> ToolResult:
|
|
88
|
+
"""Shared runner (the git tool reuses it for real git invocations).
|
|
89
|
+
|
|
90
|
+
``on_line`` receives each output line (rstripped) as it arrives; when
|
|
91
|
+
None, ``perm.on_output`` is used so the app layer's live echo applies
|
|
92
|
+
everywhere.
|
|
93
|
+
"""
|
|
94
|
+
timeout_s = max(1, min(int(timeout_s), _MAX_TIMEOUT_S))
|
|
95
|
+
on_line = on_line or perm.on_output
|
|
96
|
+
|
|
97
|
+
if shell:
|
|
98
|
+
builder = _SHELLS.get(shell.strip().lower())
|
|
99
|
+
if builder is None:
|
|
100
|
+
choices = ", ".join(sorted(_SHELLS))
|
|
101
|
+
return ToolResult(False, f"Unknown shell '{shell}'. Choose one of: {choices}.")
|
|
102
|
+
popen_args: str | list[str] = builder(command)
|
|
103
|
+
use_shell = False
|
|
104
|
+
else:
|
|
105
|
+
popen_args = command
|
|
106
|
+
use_shell = True
|
|
107
|
+
|
|
108
|
+
kwargs: dict[str, Any] = {}
|
|
109
|
+
if sys.platform == "win32":
|
|
110
|
+
# Own process group so Ctrl+C in our console doesn't ambiguously
|
|
111
|
+
# signal the child; taskkill /T handles the whole tree regardless.
|
|
112
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
113
|
+
else:
|
|
114
|
+
kwargs["start_new_session"] = True # killpg needs its own group
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
proc = subprocess.Popen(
|
|
118
|
+
popen_args,
|
|
119
|
+
shell=use_shell,
|
|
120
|
+
cwd=perm.workspace,
|
|
121
|
+
stdout=subprocess.PIPE,
|
|
122
|
+
stderr=subprocess.STDOUT, # merged: live ordering stays sane
|
|
123
|
+
stdin=subprocess.DEVNULL,
|
|
124
|
+
text=True,
|
|
125
|
+
encoding="utf-8",
|
|
126
|
+
errors="replace",
|
|
127
|
+
**kwargs,
|
|
128
|
+
)
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
return ToolResult(False, f"Could not run command: {exc}")
|
|
131
|
+
|
|
132
|
+
lines: "queue.Queue[object]" = queue.Queue()
|
|
133
|
+
thread = threading.Thread(target=_reader, args=(proc.stdout, lines), daemon=True)
|
|
134
|
+
thread.start()
|
|
135
|
+
|
|
136
|
+
chunks: list[str] = []
|
|
137
|
+
collected = 0
|
|
138
|
+
deadline = time.monotonic() + timeout_s
|
|
139
|
+
eof = False
|
|
140
|
+
try:
|
|
141
|
+
while not eof:
|
|
142
|
+
if time.monotonic() > deadline:
|
|
143
|
+
_kill_tree(proc)
|
|
144
|
+
partial = "".join(chunks).rstrip()
|
|
145
|
+
tail = f"\nOutput before timeout:\n{partial}" if partial else ""
|
|
146
|
+
return ToolResult(
|
|
147
|
+
False, f"Command timed out after {timeout_s}s: {command}{tail}"
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
item = lines.get(timeout=0.2)
|
|
151
|
+
except queue.Empty:
|
|
152
|
+
continue
|
|
153
|
+
if item is _EOF:
|
|
154
|
+
eof = True
|
|
155
|
+
continue
|
|
156
|
+
line = str(item)
|
|
157
|
+
if collected < MAX_OUTPUT_CHARS:
|
|
158
|
+
chunks.append(line)
|
|
159
|
+
collected += len(line)
|
|
160
|
+
if on_line is not None:
|
|
161
|
+
try:
|
|
162
|
+
on_line(line.rstrip("\r\n"))
|
|
163
|
+
except Exception:
|
|
164
|
+
pass # a UI echo bug must never kill the command
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
# Ctrl+C cancels THIS command, not the agent turn.
|
|
167
|
+
_kill_tree(proc)
|
|
168
|
+
partial = "".join(chunks).rstrip()
|
|
169
|
+
tail = f"\nOutput before cancel:\n{partial}" if partial else ""
|
|
170
|
+
return ToolResult(False, f"Command cancelled by user (Ctrl+C): {command}{tail}")
|
|
171
|
+
|
|
172
|
+
proc.wait()
|
|
173
|
+
body = "".join(chunks).rstrip() or "(no output)"
|
|
174
|
+
|
|
175
|
+
# Make exit code failure explicit
|
|
176
|
+
success = proc.returncode == 0
|
|
177
|
+
status = "✓ Success" if success else f"✗ FAILED (exit code {proc.returncode})"
|
|
178
|
+
|
|
179
|
+
return ToolResult(success, f"{status}\n{body}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@register(
|
|
183
|
+
"run_command",
|
|
184
|
+
"Run a shell command in the workspace; output streams live to the user.",
|
|
185
|
+
{
|
|
186
|
+
"command": "the shell command to run",
|
|
187
|
+
"timeout": "(optional) seconds before the command is killed (default 60)",
|
|
188
|
+
"shell": "(optional) cmd, powershell, or bash (default: system shell)",
|
|
189
|
+
},
|
|
190
|
+
mutates=True,
|
|
191
|
+
types={"timeout": "integer"},
|
|
192
|
+
)
|
|
193
|
+
def _run_command(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
194
|
+
command = str(args["command"]).strip()
|
|
195
|
+
if not command:
|
|
196
|
+
return ToolResult(False, "Command is empty.")
|
|
197
|
+
timeout_s = int_arg(args, "timeout", _DEFAULT_TIMEOUT_S, 1, _MAX_TIMEOUT_S)
|
|
198
|
+
perm.check_execute(command)
|
|
199
|
+
perm.confirm_action(CATEGORY_SHELL, command)
|
|
200
|
+
return run_command(perm, command, timeout_s, shell=str(args.get("shell", "")))
|
seedcode/tools/textio.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Encoding- and newline-preserving text file I/O for edit-path tools.
|
|
2
|
+
|
|
3
|
+
Editing a latin-1 or CRLF file must not silently convert it to UTF-8/LF —
|
|
4
|
+
that corrupts files the user never asked to touch. Every tool that edits an
|
|
5
|
+
EXISTING file reads it through :func:`read_text_file` (which remembers the
|
|
6
|
+
encoding and dominant newline) and writes it back through
|
|
7
|
+
:func:`write_text_file` (which re-applies both). Matching and editing happen
|
|
8
|
+
on ``\\n``-normalized text, so tools never worry about CRLF.
|
|
9
|
+
|
|
10
|
+
Decoding order: UTF-8 with BOM ("utf-8-sig"), then strict UTF-8, then
|
|
11
|
+
latin-1 (which cannot fail — every byte is valid — so nothing is ever lossy
|
|
12
|
+
on the read side).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class TextFile:
|
|
23
|
+
"""A decoded text file: normalized text plus how to write it back."""
|
|
24
|
+
|
|
25
|
+
text: str # normalized to \n line endings
|
|
26
|
+
encoding: str # "utf-8-sig" | "utf-8" | "latin-1"
|
|
27
|
+
newline: str # "\r\n" | "\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_text_file(path: Path) -> TextFile:
|
|
31
|
+
"""Read and decode, remembering encoding and dominant newline.
|
|
32
|
+
|
|
33
|
+
Raises OSError on filesystem problems (callers turn that into a
|
|
34
|
+
friendly ToolResult).
|
|
35
|
+
"""
|
|
36
|
+
raw = path.read_bytes()
|
|
37
|
+
|
|
38
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
39
|
+
encoding = "utf-8-sig"
|
|
40
|
+
text = raw.decode("utf-8-sig")
|
|
41
|
+
else:
|
|
42
|
+
try:
|
|
43
|
+
text = raw.decode("utf-8")
|
|
44
|
+
encoding = "utf-8"
|
|
45
|
+
except UnicodeDecodeError:
|
|
46
|
+
text = raw.decode("latin-1")
|
|
47
|
+
encoding = "latin-1"
|
|
48
|
+
|
|
49
|
+
crlf = text.count("\r\n")
|
|
50
|
+
bare_lf = text.count("\n") - crlf
|
|
51
|
+
newline = "\r\n" if crlf > bare_lf else "\n"
|
|
52
|
+
|
|
53
|
+
return TextFile(text=text.replace("\r\n", "\n"), encoding=encoding, newline=newline)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_text_file(path: Path, tf: TextFile) -> None:
|
|
57
|
+
"""Write normalized text back with the original newline and encoding."""
|
|
58
|
+
text = tf.text.replace("\n", tf.newline) if tf.newline != "\n" else tf.text
|
|
59
|
+
path.write_bytes(text.encode(tf.encoding))
|
seedcode/ui/__init__.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Rich-based presentation layer for Seed Code.
|
|
2
|
+
|
|
3
|
+
Everything the user sees on screen is produced here so the visual identity —
|
|
4
|
+
the Seed theme system, the startup dashboard, panels, spinners, and the
|
|
5
|
+
interactive component library (selector, menus, dialogs, palette) — stays in
|
|
6
|
+
one place. Business logic lives elsewhere and calls into these helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.spinner import Spinner
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from ..core.models import AppConfig
|
|
21
|
+
from .dashboard import render_dashboard
|
|
22
|
+
from .renderer import StreamRenderer
|
|
23
|
+
from .theme import SEED_THEME, rich_theme, set_active_theme
|
|
24
|
+
|
|
25
|
+
__all__ = ["UI", "StreamRenderer", "SEED_THEME"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UI:
|
|
29
|
+
"""Thin wrapper around a Rich console with Seed Code styling helpers."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.console = Console(theme=rich_theme(), highlight=False)
|
|
33
|
+
# The active Live display (spinner/stream), if any — permission
|
|
34
|
+
# dialogs pause it so interactive input works cleanly.
|
|
35
|
+
self._live: Live | None = None
|
|
36
|
+
# Legacy Windows consoles (pre-Windows-Terminal cmd.exe with raster
|
|
37
|
+
# fonts) can't render ✔/✖ — fall back to pure-ASCII markers there.
|
|
38
|
+
if self.console.legacy_windows:
|
|
39
|
+
self._ok_mark, self._err_mark = "[OK]", "[X]"
|
|
40
|
+
else:
|
|
41
|
+
self._ok_mark, self._err_mark = "✔", "✖"
|
|
42
|
+
self._theme_pushed = False
|
|
43
|
+
|
|
44
|
+
def apply_theme(self, name: str) -> None:
|
|
45
|
+
"""Switch the active theme everywhere (console + interactive styles).
|
|
46
|
+
|
|
47
|
+
Keeps exactly one theme overlay on the console so live-preview
|
|
48
|
+
arrowing through the picker never stacks themes.
|
|
49
|
+
"""
|
|
50
|
+
set_active_theme(name)
|
|
51
|
+
if self._theme_pushed:
|
|
52
|
+
try:
|
|
53
|
+
self.console.pop_theme()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
self.console.push_theme(rich_theme(name))
|
|
57
|
+
self._theme_pushed = True
|
|
58
|
+
|
|
59
|
+
# --- primitives --------------------------------------------------------
|
|
60
|
+
def print(self, *args, **kwargs) -> None:
|
|
61
|
+
self.console.print(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
def blank(self) -> None:
|
|
64
|
+
self.console.print()
|
|
65
|
+
|
|
66
|
+
# --- startup -----------------------------------------------------------
|
|
67
|
+
def banner(self, config: AppConfig) -> None:
|
|
68
|
+
"""Render the startup dashboard (shown exactly once at launch)."""
|
|
69
|
+
render_dashboard(self.console, config)
|
|
70
|
+
|
|
71
|
+
# --- chat rendering ----------------------------------------------------
|
|
72
|
+
@contextmanager
|
|
73
|
+
def thinking(self, label: str = "Thinking") -> Iterator[None]:
|
|
74
|
+
"""Show a spinner while awaiting the first streamed token."""
|
|
75
|
+
spinner = Spinner("dots", text=Text(f" {label}...", style="seed.accent"))
|
|
76
|
+
with Live(
|
|
77
|
+
spinner, console=self.console, refresh_per_second=12, transient=True
|
|
78
|
+
) as live:
|
|
79
|
+
self._live = live
|
|
80
|
+
try:
|
|
81
|
+
yield
|
|
82
|
+
finally:
|
|
83
|
+
self._live = None
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def streaming(self) -> Iterator["StreamRenderer"]:
|
|
87
|
+
"""Provide a live, incrementally-updating markdown renderer."""
|
|
88
|
+
renderer = StreamRenderer(self.console)
|
|
89
|
+
with Live(
|
|
90
|
+
renderer.renderable(),
|
|
91
|
+
console=self.console,
|
|
92
|
+
refresh_per_second=15,
|
|
93
|
+
transient=False,
|
|
94
|
+
) as live:
|
|
95
|
+
renderer.bind(live)
|
|
96
|
+
yield renderer
|
|
97
|
+
self.console.print()
|
|
98
|
+
|
|
99
|
+
# --- messaging ---------------------------------------------------------
|
|
100
|
+
def info(self, message: str) -> None:
|
|
101
|
+
self.console.print(Text(message, style="seed.text"))
|
|
102
|
+
|
|
103
|
+
def dim(self, message: str) -> None:
|
|
104
|
+
self.console.print(Text(message, style="seed.dim"))
|
|
105
|
+
|
|
106
|
+
def success(self, message: str) -> None:
|
|
107
|
+
self.console.print(Text(f"{self._ok_mark} {message}", style="seed.success"))
|
|
108
|
+
|
|
109
|
+
def warning(self, message: str) -> None:
|
|
110
|
+
self.console.print(Text(f"! {message}", style="seed.warning"))
|
|
111
|
+
|
|
112
|
+
def error(self, message: str) -> None:
|
|
113
|
+
self.console.print(Text(f"{self._err_mark} {message}", style="seed.error"))
|
|
114
|
+
|
|
115
|
+
def panel(self, body, title: str | None = None) -> None:
|
|
116
|
+
self.console.print(
|
|
117
|
+
Panel(
|
|
118
|
+
body,
|
|
119
|
+
title=title,
|
|
120
|
+
border_style="seed.primary",
|
|
121
|
+
title_align="left",
|
|
122
|
+
padding=(1, 2),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# --- action confirmation ------------------------------------------------
|
|
127
|
+
def _confirm(self, title: str, category_label: str, description: str) -> str:
|
|
128
|
+
"""Ask the user to approve an action; returns 'y', 'a', or 'n'.
|
|
129
|
+
|
|
130
|
+
Shows the action details in a warning panel, then an interactive
|
|
131
|
+
Allow Once / Always Allow / Deny dialog. Pauses any live spinner so
|
|
132
|
+
the dialog renders cleanly, then resumes it. Cancelling (Esc or
|
|
133
|
+
Ctrl+C) counts as deny — never approve by accident.
|
|
134
|
+
"""
|
|
135
|
+
from .dialog import permission_dialog
|
|
136
|
+
|
|
137
|
+
live = self._live
|
|
138
|
+
if live is not None:
|
|
139
|
+
live.stop()
|
|
140
|
+
try:
|
|
141
|
+
body = Text()
|
|
142
|
+
body.append(f"{category_label}\n", style="seed.warning")
|
|
143
|
+
body.append(description, style="seed.text")
|
|
144
|
+
self.console.print(
|
|
145
|
+
Panel(
|
|
146
|
+
body,
|
|
147
|
+
title=title,
|
|
148
|
+
border_style="seed.warning",
|
|
149
|
+
title_align="left",
|
|
150
|
+
padding=(1, 2),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return permission_dialog()
|
|
154
|
+
finally:
|
|
155
|
+
if live is not None:
|
|
156
|
+
live.start()
|
|
157
|
+
|
|
158
|
+
def confirm_desktop(self, category_label: str, description: str) -> str:
|
|
159
|
+
"""Approve a desktop action; returns 'y', 'a', or 'n'."""
|
|
160
|
+
return self._confirm("Desktop Control", category_label, description)
|
|
161
|
+
|
|
162
|
+
def confirm_tool_action(self, category_label: str, description: str) -> str:
|
|
163
|
+
"""Approve a dangerous agent tool action; returns 'y', 'a', or 'n'."""
|
|
164
|
+
return self._confirm("Assist Action", category_label, description)
|
seedcode/ui/badges.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Status badges — the one consistent indicator vocabulary for Seed Code.
|
|
2
|
+
|
|
3
|
+
Every screen that shows a status uses these markers so the language stays
|
|
4
|
+
uniform:
|
|
5
|
+
|
|
6
|
+
● Connected ◐ Connecting ○ Offline ⚠ Error
|
|
7
|
+
⟳ Loading ✓ Ready ✗ Failed
|
|
8
|
+
|
|
9
|
+
Badges exist in two renderings: prompt_toolkit fragments (interactive
|
|
10
|
+
selectors) and Rich markup (panels, dashboard).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ..core.providers.base import (
|
|
16
|
+
STATUS_BAD_KEY,
|
|
17
|
+
STATUS_CONNECTED,
|
|
18
|
+
STATUS_NO_KEY,
|
|
19
|
+
STATUS_OFFLINE,
|
|
20
|
+
STATUS_UNKNOWN,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# badge key -> (marker, label, pt style class, rich style)
|
|
24
|
+
BADGES: dict[str, tuple[str, str, str, str]] = {
|
|
25
|
+
"connected": ("●", "Connected", "class:sel.ok", "seed.success"),
|
|
26
|
+
"connecting": ("◐", "Connecting", "class:sel.warn", "seed.warning"),
|
|
27
|
+
"offline": ("○", "Offline", "class:sel.off", "seed.dim"),
|
|
28
|
+
"error": ("⚠", "Error", "class:sel.err", "seed.error"),
|
|
29
|
+
"loading": ("⟳", "Loading", "class:sel.warn", "seed.warning"),
|
|
30
|
+
"ready": ("✓", "Ready", "class:sel.ok", "seed.success"),
|
|
31
|
+
"failed": ("✗", "Failed", "class:sel.err", "seed.error"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Provider session status -> badge key.
|
|
35
|
+
_STATUS_TO_BADGE = {
|
|
36
|
+
STATUS_CONNECTED: "connected",
|
|
37
|
+
STATUS_UNKNOWN: "ready",
|
|
38
|
+
STATUS_OFFLINE: "offline",
|
|
39
|
+
STATUS_NO_KEY: "offline",
|
|
40
|
+
STATUS_BAD_KEY: "error",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def badge_for_status(status: str) -> str:
|
|
45
|
+
"""Map a provider connection status string to a badge key."""
|
|
46
|
+
return _STATUS_TO_BADGE.get(status, "offline")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def badge_fragment(key: str) -> tuple[str, str]:
|
|
50
|
+
"""(pt style, text) for one badge, e.g. ('class:sel.ok', '● Connected')."""
|
|
51
|
+
marker, label, style, _ = BADGES.get(key, BADGES["offline"])
|
|
52
|
+
return style, f"{marker} {label}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def badge_markup(key: str) -> str:
|
|
56
|
+
"""Rich markup for one badge, e.g. '[seed.success]● Connected[/]'."""
|
|
57
|
+
marker, label, _, rich_style = BADGES.get(key, BADGES["offline"])
|
|
58
|
+
return f"[{rich_style}]{marker} {label}[/{rich_style}]"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def badge_text(key: str) -> str:
|
|
62
|
+
"""Plain '● Connected' text for one badge."""
|
|
63
|
+
marker, label, _, _ = BADGES.get(key, BADGES["offline"])
|
|
64
|
+
return f"{marker} {label}"
|
seedcode/ui/banner.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The Seed Code startup banner.
|
|
2
|
+
|
|
3
|
+
A single branded screen: centered ASCII logo, wordmark, tagline and credits,
|
|
4
|
+
followed by a divider. Every line is centered dynamically against the live
|
|
5
|
+
terminal width (no hardcoded padding), rendered once at startup with plain
|
|
6
|
+
Text — no panels, boxes or animation — so it displays identically in Windows
|
|
7
|
+
Terminal, PowerShell, CMD and the VS Code terminal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ..core.models import AppConfig
|
|
16
|
+
|
|
17
|
+
_LOGO_LINES = [
|
|
18
|
+
r" ███████╗███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
|
|
19
|
+
r" ██╔════╝██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
|
|
20
|
+
r" ███████╗█████╗ █████╗ ██║ ██║ ██║ ██║ ██║██║ ██║█████╗ ",
|
|
21
|
+
r" ╚════██║██╔══╝ ██╔══╝ ██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝ ",
|
|
22
|
+
r" ███████║███████╗███████╗██████╔╝ ╚██████╗╚██████╔╝██████╔╝███████╗",
|
|
23
|
+
r" ╚══════╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Pure-ASCII fallback for legacy Windows consoles (raster-font cmd.exe)
|
|
27
|
+
# where the block/box-drawing glyphs above render as garbage.
|
|
28
|
+
_LOGO_LINES_ASCII = [
|
|
29
|
+
r" ____ _____ _____ ____ ____ ___ ____ _____ ",
|
|
30
|
+
r" / ___|| ____| ____| _ \ / ___/ _ \| _ \| ____|",
|
|
31
|
+
r" \___ \| _| | _| | | | | | | | | | | | | | _| ",
|
|
32
|
+
r" ___) | |___| |___| |_| | | |__| |_| | |_| | |___ ",
|
|
33
|
+
r" |____/|_____|_____|____/ \____\___/|____/|_____|",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Monochrome green identity: bright bold logo, dimmer green for everything else.
|
|
37
|
+
_LOGO_STYLE = "bold #2ecc71"
|
|
38
|
+
_SOFT_STYLE = "#1cbf63"
|
|
39
|
+
_DIM_STYLE = "#159a4f"
|
|
40
|
+
|
|
41
|
+
_WORDMARK = "S E E D C O D E"
|
|
42
|
+
_TAGLINE = "Plant ideas. Grow code."
|
|
43
|
+
_CREDIT_HEADING = "Created by"
|
|
44
|
+
_CREDIT_NAME = "Al Shahriar Sowan"
|
|
45
|
+
_CREDIT_TOOLS = "Vibe coded with GPT-5.5 + Claude Opus 4.8"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _centered(console: Console, text: str, style: str) -> None:
|
|
49
|
+
"""Print one line centered against the current terminal width."""
|
|
50
|
+
pad = max((console.size.width - len(text)) // 2, 0)
|
|
51
|
+
line = Text(" " * pad + text, style=style)
|
|
52
|
+
line.no_wrap = True
|
|
53
|
+
console.print(line, overflow="crop")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_banner(console: Console, config: AppConfig) -> None:
|
|
57
|
+
"""Render the branded startup banner to ``console``."""
|
|
58
|
+
logo = _LOGO_LINES_ASCII if console.legacy_windows else _LOGO_LINES
|
|
59
|
+
console.print()
|
|
60
|
+
for line in logo:
|
|
61
|
+
_centered(console, line, _LOGO_STYLE)
|
|
62
|
+
|
|
63
|
+
console.print()
|
|
64
|
+
_centered(console, _WORDMARK, _LOGO_STYLE)
|
|
65
|
+
console.print()
|
|
66
|
+
_centered(console, _TAGLINE, _SOFT_STYLE)
|
|
67
|
+
console.print()
|
|
68
|
+
_centered(console, _CREDIT_HEADING, _DIM_STYLE)
|
|
69
|
+
_centered(console, _CREDIT_NAME, _SOFT_STYLE)
|
|
70
|
+
console.print()
|
|
71
|
+
_centered(console, _CREDIT_TOOLS, _DIM_STYLE)
|
|
72
|
+
|
|
73
|
+
console.print()
|
|
74
|
+
divider_char = "-" if console.legacy_windows else "─"
|
|
75
|
+
divider = Text(divider_char * console.size.width, style=_DIM_STYLE)
|
|
76
|
+
divider.no_wrap = True
|
|
77
|
+
console.print(divider, overflow="crop")
|
|
78
|
+
console.print()
|