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/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Seed Code — a premium terminal-based AI coding assistant.
|
|
2
|
+
|
|
3
|
+
Plant ideas. Grow code.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Single source of truth for the application version. Every consumer reads it:
|
|
7
|
+
# the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
|
|
8
|
+
# Windows installer metadata, the GitHub release tag, and the WinGet manifest.
|
|
9
|
+
__version__ = "6.1.5"
|
|
10
|
+
__author__ = "Al shahriar sowan"
|
|
11
|
+
__publisher__ = "Eagox Studio"
|
|
12
|
+
|
|
13
|
+
APP_NAME = "Seed Code"
|
|
14
|
+
TAGLINE = "Plant ideas. Grow code."
|
seedcode/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Enable ``python -m seedcode``.
|
|
2
|
+
|
|
3
|
+
Uses an absolute import so this module also works as a PyInstaller entry
|
|
4
|
+
script, where it runs as top-level ``__main__`` with no parent package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from seedcode.cli import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
main()
|
seedcode/app.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Application controller: startup dashboard, chat REPL, and the main menu.
|
|
2
|
+
|
|
3
|
+
Startup renders the dashboard once and drops straight into the chat prompt;
|
|
4
|
+
/exit from chat reaches the interactive main menu (arrow keys + fuzzy
|
|
5
|
+
filter — no numbers anywhere). Chat can only begin once setup is complete —
|
|
6
|
+
otherwise the guided chain provider -> API key -> validate -> fetch models
|
|
7
|
+
-> select -> save runs first. All actions are guarded: no failure may crash
|
|
8
|
+
the application.
|
|
9
|
+
|
|
10
|
+
Global shortcuts at the chat prompt: Ctrl+K command palette, Ctrl+P project
|
|
11
|
+
file search, Ctrl+R history, Ctrl+/ shortcut reference, Ctrl+, settings,
|
|
12
|
+
Ctrl+L clear screen.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit import PromptSession
|
|
18
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
19
|
+
|
|
20
|
+
from .commands import CommandContext, dispatch, is_command
|
|
21
|
+
from .commands.about import show_about
|
|
22
|
+
from .commands.help import show_shortcuts
|
|
23
|
+
from .commands.history import browse_history, settings_menu
|
|
24
|
+
from .commands.palette import open_file_search, open_palette
|
|
25
|
+
from .commands.provider import apikey_menu, select_model, select_provider
|
|
26
|
+
from .commands.theme import pick_theme
|
|
27
|
+
from .config import load_config
|
|
28
|
+
from .core.agent import AgentEngine, strip_tool_blocks
|
|
29
|
+
from .core.chat import ChatEngine, ChatError
|
|
30
|
+
from .core.lifecycle import lifecycle
|
|
31
|
+
from .core.models import AppConfig
|
|
32
|
+
from .core.providers import PROVIDERS, provider_label
|
|
33
|
+
from .core.providers.freemodel import AUTO_MODEL
|
|
34
|
+
from .memory import HistoryStore
|
|
35
|
+
from .tools import PermissionManager, PermissionMode
|
|
36
|
+
from .ui import UI
|
|
37
|
+
from .ui.badges import badge_for_status
|
|
38
|
+
from .ui.menu import MenuItem, run_menu
|
|
39
|
+
from .ui.textbox import prompt_label
|
|
40
|
+
from .ui.theme import pt_style, set_active_theme
|
|
41
|
+
from .utils.logger import get_logger
|
|
42
|
+
|
|
43
|
+
_log = get_logger("app")
|
|
44
|
+
|
|
45
|
+
# Sentinels returned by chat-prompt key bindings (never valid user text).
|
|
46
|
+
_KEY_ACTIONS = {
|
|
47
|
+
"__palette__": "palette",
|
|
48
|
+
"__files__": "files",
|
|
49
|
+
"__history__": "history",
|
|
50
|
+
"__shortcuts__": "shortcuts",
|
|
51
|
+
"__settings__": "settings",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _exit_application(reason: str) -> None:
|
|
56
|
+
"""The single, explicit application-exit decision.
|
|
57
|
+
|
|
58
|
+
Called only from unambiguous user actions (the menu's Exit item, leaving
|
|
59
|
+
the menu with Esc/Ctrl+C/Ctrl+D). Marks the lifecycle, enters SHUTDOWN
|
|
60
|
+
(running teardown hooks), and returns; :func:`run` then unwinds and
|
|
61
|
+
``main`` finishes normally. Task completion and errors never reach here.
|
|
62
|
+
"""
|
|
63
|
+
lc = lifecycle()
|
|
64
|
+
lc.request_exit(reason)
|
|
65
|
+
if lc.phase.value != "shutdown":
|
|
66
|
+
try:
|
|
67
|
+
lc.shutdown()
|
|
68
|
+
except Exception: # a teardown hook must never block exiting
|
|
69
|
+
pass
|
|
70
|
+
ui.dim("Goodbye — plant ideas, grow code.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _release_desktop_resources() -> None:
|
|
74
|
+
"""Teardown hook: release desktop/session resources, never the process.
|
|
75
|
+
|
|
76
|
+
Cleanup clears caches and closes driver connections only. It must never
|
|
77
|
+
terminate SeedCode — historically a finally-block that "cleaned up" by
|
|
78
|
+
exiting was one of the auto-exit paths.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
from .computer.browser_cdp import reset as _cdp_reset
|
|
82
|
+
|
|
83
|
+
_cdp_reset()
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
from .computer import browser_skills
|
|
88
|
+
|
|
89
|
+
browser_skills.reset_engines()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
try:
|
|
93
|
+
from .computer.permissions import session_permissions
|
|
94
|
+
|
|
95
|
+
session_permissions().reset()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _provider_status(config: AppConfig) -> str:
|
|
101
|
+
"""Menu status line: the active provider, or 'Not Configured'."""
|
|
102
|
+
ready = config.provider == "ollama" or bool(config.get_api_key().strip())
|
|
103
|
+
return provider_label(config.provider) if ready else "Not Configured"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _model_status(config: AppConfig) -> str:
|
|
107
|
+
"""Menu status line: the selected model, or 'Not Selected'."""
|
|
108
|
+
if not config.model:
|
|
109
|
+
return "Not Selected"
|
|
110
|
+
if config.model == AUTO_MODEL:
|
|
111
|
+
return "Auto (best free model)"
|
|
112
|
+
return config.model
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _key_status(config: AppConfig) -> str:
|
|
116
|
+
"""Menu status line: the active provider's masked key."""
|
|
117
|
+
provider = PROVIDERS.get(config.provider)
|
|
118
|
+
if provider is not None and not provider.requires_key:
|
|
119
|
+
return "(not required)"
|
|
120
|
+
return config.masked_key()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _main_menu(config: AppConfig):
|
|
124
|
+
"""The interactive main menu; returns an action id or None (exit)."""
|
|
125
|
+
provider = PROVIDERS.get(config.provider)
|
|
126
|
+
badge = badge_for_status(provider.status if provider is not None else "")
|
|
127
|
+
return run_menu(
|
|
128
|
+
[
|
|
129
|
+
MenuItem("Start Chat", "chat", status=_model_status(config), badge=badge),
|
|
130
|
+
MenuItem("Provider", "provider", status=_provider_status(config)),
|
|
131
|
+
MenuItem("API Key", "apikey", status=_key_status(config)),
|
|
132
|
+
MenuItem("Model", "model", status=_model_status(config)),
|
|
133
|
+
MenuItem("Settings", "settings"),
|
|
134
|
+
MenuItem("Theme", "theme", status=config.theme),
|
|
135
|
+
MenuItem("About", "about"),
|
|
136
|
+
MenuItem("Exit", "exit"),
|
|
137
|
+
],
|
|
138
|
+
title="Seed Code",
|
|
139
|
+
hint="↑↓ move type to filter Enter select Esc exit",
|
|
140
|
+
initial="chat",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _guided_setup(ui: UI, config: AppConfig) -> bool:
|
|
145
|
+
"""Provider -> API key -> validate -> fetch models -> select -> save.
|
|
146
|
+
|
|
147
|
+
Reuses the exact /provider and /model flows so setup and mid-session
|
|
148
|
+
switching behave identically. Returns True once chat is possible.
|
|
149
|
+
"""
|
|
150
|
+
ui.info("Setup: choose a provider to get started.")
|
|
151
|
+
if not select_provider(ui, config):
|
|
152
|
+
return False
|
|
153
|
+
if not config.model:
|
|
154
|
+
select_model(ui, config)
|
|
155
|
+
return config.is_configured()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _handle_chat(ui: UI, engine: ChatEngine, history: HistoryStore, text: str) -> None:
|
|
159
|
+
"""Send a user turn to the model and stream the reply to screen."""
|
|
160
|
+
engine.add_user(text)
|
|
161
|
+
renderer = None
|
|
162
|
+
try:
|
|
163
|
+
chunks = engine.stream_reply()
|
|
164
|
+
# Spinner until the first token, then hand off to the live renderer.
|
|
165
|
+
first = ""
|
|
166
|
+
with ui.thinking():
|
|
167
|
+
for piece in chunks:
|
|
168
|
+
first = piece
|
|
169
|
+
break
|
|
170
|
+
with ui.streaming() as renderer:
|
|
171
|
+
if first:
|
|
172
|
+
renderer.feed(first)
|
|
173
|
+
for piece in chunks:
|
|
174
|
+
renderer.feed(piece)
|
|
175
|
+
except ChatError as exc:
|
|
176
|
+
# Drop the unanswered user turn so a retry doesn't send two
|
|
177
|
+
# consecutive user messages (strict APIs reject that shape).
|
|
178
|
+
engine.drop_last_user()
|
|
179
|
+
ui.error(str(exc))
|
|
180
|
+
return
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
# Ctrl+C cancels this response only — the session keeps going.
|
|
183
|
+
ui.blank()
|
|
184
|
+
ui.dim("(response cancelled)")
|
|
185
|
+
|
|
186
|
+
reply = renderer.text if renderer is not None else ""
|
|
187
|
+
if reply.strip():
|
|
188
|
+
engine.add_assistant(reply)
|
|
189
|
+
history.save(engine.transcript)
|
|
190
|
+
else:
|
|
191
|
+
# No reply (empty response, or cancelled before the first token):
|
|
192
|
+
# forget the user turn so the transcript stays alternating.
|
|
193
|
+
engine.drop_last_user()
|
|
194
|
+
if renderer is not None:
|
|
195
|
+
ui.dim("(no response)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _handle_agent(ui: UI, agent: AgentEngine, history: HistoryStore, text: str) -> None:
|
|
199
|
+
"""Run one full Assist turn (tool loop) and render the final answer.
|
|
200
|
+
|
|
201
|
+
Lifecycle note: every outcome — success, tool failure, provider error,
|
|
202
|
+
Ctrl+C — ends with a rendered message and a return. Nothing here may
|
|
203
|
+
terminate the process; the surrounding ``task_span`` guarantees the
|
|
204
|
+
lifecycle returns to IDLE and the REPL keeps prompting.
|
|
205
|
+
"""
|
|
206
|
+
lc = lifecycle()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with ui.thinking("Working"):
|
|
210
|
+
lc.to_executing()
|
|
211
|
+
reply = agent.run_turn(text)
|
|
212
|
+
lc.to_verifying()
|
|
213
|
+
except ChatError as exc:
|
|
214
|
+
ui.error(str(exc))
|
|
215
|
+
return
|
|
216
|
+
except KeyboardInterrupt:
|
|
217
|
+
# Ctrl+C aborts the remaining Assist steps; work already done stays.
|
|
218
|
+
ui.blank()
|
|
219
|
+
ui.dim("(assist turn cancelled — completed tool actions were kept)")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
lc.to_responding()
|
|
223
|
+
final = strip_tool_blocks(reply)
|
|
224
|
+
if final.strip():
|
|
225
|
+
with ui.streaming() as renderer:
|
|
226
|
+
renderer.feed(final)
|
|
227
|
+
else:
|
|
228
|
+
ui.dim("(no response)")
|
|
229
|
+
history.save(agent.transcript)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _make_agent(ui: UI, config: AppConfig) -> AgentEngine:
|
|
233
|
+
"""Build an Assist engine bound to the CWD and the configured permissions."""
|
|
234
|
+
# Lazy import (matching _make_desktop_session): the optional, platform-
|
|
235
|
+
# specific computer package stays out of app.py's top-level import graph.
|
|
236
|
+
from .computer import is_available
|
|
237
|
+
|
|
238
|
+
permissions = PermissionManager(level=PermissionMode.parse(config.permission_mode))
|
|
239
|
+
# Desktop capability is a property of the permission level now: attach the
|
|
240
|
+
# Computer Engine gate whenever the level is Desktop or higher and the
|
|
241
|
+
# engine is actually available on this machine.
|
|
242
|
+
if permissions.level.allows_desktop and is_available()[0]:
|
|
243
|
+
permissions.desktop = _make_desktop_session(ui)
|
|
244
|
+
permissions.gate = _make_action_gate(ui)
|
|
245
|
+
# Live terminal output: echo each line a running command prints.
|
|
246
|
+
permissions.on_output = lambda line: ui.dim(f" │ {line[:200]}")
|
|
247
|
+
|
|
248
|
+
def narrate(kind: str, detail: str) -> None:
|
|
249
|
+
if kind == "call":
|
|
250
|
+
ui.dim(f" ⚒ {detail}")
|
|
251
|
+
elif kind == "error":
|
|
252
|
+
ui.dim(f" ✖ {detail.splitlines()[0][:120]}")
|
|
253
|
+
elif kind == "limit":
|
|
254
|
+
ui.warning(f"Assist stopped: {detail}")
|
|
255
|
+
|
|
256
|
+
return AgentEngine(config, permissions, on_event=narrate)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _make_action_gate(ui: UI):
|
|
260
|
+
"""Dangerous-action gate wired to the interactive permission dialog.
|
|
261
|
+
|
|
262
|
+
Note: the Assist engine (and thus this gate) is rebuilt on permission-mode
|
|
263
|
+
changes, so session "Always" grants reset then — conservative on purpose.
|
|
264
|
+
"""
|
|
265
|
+
from .tools.permissions import ACTION_LABELS, ActionGate, ActionGrant
|
|
266
|
+
|
|
267
|
+
def confirm(category: str, description: str) -> ActionGrant:
|
|
268
|
+
label = ACTION_LABELS.get(category, category)
|
|
269
|
+
answer = ui.confirm_tool_action(label, description)
|
|
270
|
+
return {
|
|
271
|
+
"y": ActionGrant.ONCE,
|
|
272
|
+
"a": ActionGrant.ALWAYS,
|
|
273
|
+
}.get(answer, ActionGrant.DENY)
|
|
274
|
+
|
|
275
|
+
return ActionGate(confirm=confirm)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _make_desktop_session(ui: UI):
|
|
279
|
+
"""Desktop Control gate wired to the interactive permission dialog.
|
|
280
|
+
|
|
281
|
+
The session is bound to the process-wide
|
|
282
|
+
:class:`~seedcode.computer.SessionPermissionManager` so a permission the
|
|
283
|
+
user granted once at ``/assist on`` keeps holding even though this gate is
|
|
284
|
+
rebuilt whenever the permission level changes.
|
|
285
|
+
"""
|
|
286
|
+
from .computer import DesktopGrant, DesktopSession, session_permissions
|
|
287
|
+
from .computer.permissions import CATEGORY_LABELS
|
|
288
|
+
|
|
289
|
+
def confirm(category: str, description: str) -> DesktopGrant:
|
|
290
|
+
label = CATEGORY_LABELS.get(category, category)
|
|
291
|
+
answer = ui.confirm_desktop(label, description)
|
|
292
|
+
return {
|
|
293
|
+
"y": DesktopGrant.ONCE,
|
|
294
|
+
"a": DesktopGrant.ALWAYS,
|
|
295
|
+
}.get(answer, DesktopGrant.DENY)
|
|
296
|
+
|
|
297
|
+
return DesktopSession(
|
|
298
|
+
enabled=True, confirm=confirm, session=session_permissions()
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _run_key_action(ui: UI, ctx: CommandContext, config: AppConfig, action: str) -> None:
|
|
303
|
+
"""Dispatch one chat-prompt shortcut sentinel."""
|
|
304
|
+
try:
|
|
305
|
+
if action == "palette":
|
|
306
|
+
open_palette(ctx)
|
|
307
|
+
elif action == "files":
|
|
308
|
+
open_file_search(ctx)
|
|
309
|
+
elif action == "history":
|
|
310
|
+
browse_history(ui, config)
|
|
311
|
+
elif action == "shortcuts":
|
|
312
|
+
show_shortcuts(ui)
|
|
313
|
+
elif action == "settings":
|
|
314
|
+
settings_menu(ui, config)
|
|
315
|
+
except (KeyboardInterrupt, EOFError):
|
|
316
|
+
ui.dim("Cancelled.")
|
|
317
|
+
except Exception as exc: # a broken picker must not kill the REPL
|
|
318
|
+
_log.exception("shortcut action failed: %s", action)
|
|
319
|
+
ui.error(f"Something went wrong: {exc}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _chat_loop(
|
|
323
|
+
ui: UI,
|
|
324
|
+
config: AppConfig,
|
|
325
|
+
engine: ChatEngine,
|
|
326
|
+
history: HistoryStore,
|
|
327
|
+
session: PromptSession,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Interactive chat until /exit (returns to the main menu)."""
|
|
330
|
+
ctx = CommandContext(ui=ui, config=config, engine=engine)
|
|
331
|
+
ui.dim(
|
|
332
|
+
"Type /help for commands, /exit for the menu — "
|
|
333
|
+
"Ctrl+K palette, Ctrl+P files, Ctrl+/ shortcuts."
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# The Assist engine is built lazily on the first assist-mode turn and
|
|
337
|
+
# rebuilt when the permission or desktop mode changes (its system
|
|
338
|
+
# prompt and permission gates reflect both).
|
|
339
|
+
agent: AgentEngine | None = None
|
|
340
|
+
agent_perm = config.permission_mode
|
|
341
|
+
|
|
342
|
+
while True:
|
|
343
|
+
try:
|
|
344
|
+
raw = session.prompt(
|
|
345
|
+
prompt_label(f"{config.username} > "),
|
|
346
|
+
style=pt_style(),
|
|
347
|
+
)
|
|
348
|
+
except KeyboardInterrupt:
|
|
349
|
+
# Ctrl+C cancels the current line, does not quit.
|
|
350
|
+
ui.dim("(use /exit for the menu)")
|
|
351
|
+
continue
|
|
352
|
+
except EOFError:
|
|
353
|
+
# Ctrl+D returns to the menu.
|
|
354
|
+
ui.blank()
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
if raw in _KEY_ACTIONS:
|
|
358
|
+
_run_key_action(ui, ctx, config, _KEY_ACTIONS[raw])
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
text = raw.strip()
|
|
362
|
+
if not text:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
if is_command(text):
|
|
366
|
+
backend_before = config.provider
|
|
367
|
+
try:
|
|
368
|
+
result = dispatch(ctx, text)
|
|
369
|
+
except (KeyboardInterrupt, EOFError):
|
|
370
|
+
ui.dim("Cancelled.")
|
|
371
|
+
continue
|
|
372
|
+
except Exception as exc: # a broken command must not kill the REPL
|
|
373
|
+
_log.exception("command failed: %s", text.split()[0])
|
|
374
|
+
ui.error(f"Command failed: {exc}")
|
|
375
|
+
continue
|
|
376
|
+
if result.should_exit:
|
|
377
|
+
return
|
|
378
|
+
if config.provider != backend_before:
|
|
379
|
+
# Provider switched mid-chat: the whole backend state
|
|
380
|
+
# (client, models, history) refreshes — start fresh.
|
|
381
|
+
ui.dim("(provider changed — returning to the menu)")
|
|
382
|
+
return
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
ui.blank()
|
|
386
|
+
# One whole user turn inside the lifecycle span: whatever happens —
|
|
387
|
+
# success, failure, cancellation, even an unexpected crash — the
|
|
388
|
+
# span's ``finally`` returns the state machine to IDLE and the REPL
|
|
389
|
+
# prompts again. A task can never end the app.
|
|
390
|
+
with lifecycle().task_span():
|
|
391
|
+
if config.agent_mode:
|
|
392
|
+
if agent is None or agent_perm != config.permission_mode:
|
|
393
|
+
agent = _make_agent(ui, config)
|
|
394
|
+
agent_perm = config.permission_mode
|
|
395
|
+
_handle_agent(ui, agent, history, text)
|
|
396
|
+
else:
|
|
397
|
+
_handle_chat(ui, engine, history, text)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build_chat_session(ui: UI) -> PromptSession:
|
|
401
|
+
"""The chat PromptSession with the global shortcut bindings attached."""
|
|
402
|
+
kb = KeyBindings()
|
|
403
|
+
|
|
404
|
+
kb.add("c-k")(lambda e: e.app.exit(result="__palette__"))
|
|
405
|
+
kb.add("c-p")(lambda e: e.app.exit(result="__files__"))
|
|
406
|
+
kb.add("c-r")(lambda e: e.app.exit(result="__history__"))
|
|
407
|
+
# Ctrl+/ reaches terminals as Ctrl+_; bind both spellings.
|
|
408
|
+
kb.add("c-_")(lambda e: e.app.exit(result="__shortcuts__"))
|
|
409
|
+
|
|
410
|
+
@kb.add("c-l")
|
|
411
|
+
def _(event) -> None:
|
|
412
|
+
ui.console.clear()
|
|
413
|
+
event.app.renderer.clear()
|
|
414
|
+
|
|
415
|
+
# Ctrl+, (settings) — where the terminal delivers it distinctly.
|
|
416
|
+
try:
|
|
417
|
+
kb.add("c-,")(lambda e: e.app.exit(result="__settings__"))
|
|
418
|
+
except (ValueError, KeyError):
|
|
419
|
+
pass # terminals without a distinct Ctrl+, sequence
|
|
420
|
+
|
|
421
|
+
return PromptSession(key_bindings=kb)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def run(ui: UI) -> None:
|
|
425
|
+
"""Show the startup dashboard, drop straight into chat, then the menu.
|
|
426
|
+
|
|
427
|
+
The dashboard renders exactly once at launch; the chat prompt follows
|
|
428
|
+
immediately (after guided setup when nothing is configured yet). The
|
|
429
|
+
interactive menu remains available via /exit for provider/model/settings.
|
|
430
|
+
"""
|
|
431
|
+
config = load_config()
|
|
432
|
+
set_active_theme(config.theme)
|
|
433
|
+
ui.apply_theme(config.theme)
|
|
434
|
+
ui.banner(config)
|
|
435
|
+
_log.info(
|
|
436
|
+
"started: provider=%s model=%s configured=%s",
|
|
437
|
+
config.provider,
|
|
438
|
+
config.model or "(none)",
|
|
439
|
+
config.is_configured(),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
active_backend = config.provider
|
|
443
|
+
engine = ChatEngine(config)
|
|
444
|
+
history = HistoryStore(provider_id=active_backend)
|
|
445
|
+
chat_session = _build_chat_session(ui)
|
|
446
|
+
|
|
447
|
+
# Best-effort teardown for the one legitimate shutdown path.
|
|
448
|
+
lifecycle().on_shutdown(_release_desktop_resources)
|
|
449
|
+
|
|
450
|
+
# Straight into chat after the dashboard — the menu is one /exit away.
|
|
451
|
+
try:
|
|
452
|
+
if config.is_configured() or _guided_setup(ui, config):
|
|
453
|
+
if config.provider != active_backend:
|
|
454
|
+
# Guided setup switched providers: rebuild the backend state.
|
|
455
|
+
active_backend = config.provider
|
|
456
|
+
engine = ChatEngine(config)
|
|
457
|
+
history = HistoryStore(provider_id=active_backend)
|
|
458
|
+
_chat_loop(ui, config, engine, history, chat_session)
|
|
459
|
+
else:
|
|
460
|
+
ui.dim("Setup incomplete — chat needs a provider and a model.")
|
|
461
|
+
except (KeyboardInterrupt, EOFError):
|
|
462
|
+
ui.dim("Cancelled.")
|
|
463
|
+
except Exception as exc: # startup chat must never crash the app
|
|
464
|
+
_log.exception("startup chat failed")
|
|
465
|
+
ui.error(f"Something went wrong: {exc}")
|
|
466
|
+
|
|
467
|
+
while True:
|
|
468
|
+
if config.provider != active_backend:
|
|
469
|
+
# Provider switched: rebuild everything below it — fresh chat
|
|
470
|
+
# backend/context and the new provider's own history store.
|
|
471
|
+
_log.info("backend switched: %s -> %s", active_backend, config.provider)
|
|
472
|
+
active_backend = config.provider
|
|
473
|
+
engine = ChatEngine(config)
|
|
474
|
+
history = HistoryStore(provider_id=active_backend)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
choice = _main_menu(config)
|
|
478
|
+
except (KeyboardInterrupt, EOFError):
|
|
479
|
+
# The user explicitly left the menu — the app-exit decision.
|
|
480
|
+
_exit_application("menu interrupt")
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
if choice == "chat":
|
|
485
|
+
if not config.is_configured() and not _guided_setup(ui, config):
|
|
486
|
+
ui.dim("Setup incomplete — chat needs a provider and a model.")
|
|
487
|
+
continue
|
|
488
|
+
_chat_loop(ui, config, engine, history, chat_session)
|
|
489
|
+
elif choice == "provider":
|
|
490
|
+
select_provider(ui, config)
|
|
491
|
+
elif choice == "apikey":
|
|
492
|
+
apikey_menu(ui, config)
|
|
493
|
+
elif choice == "model":
|
|
494
|
+
select_model(ui, config)
|
|
495
|
+
elif choice == "settings":
|
|
496
|
+
settings_menu(ui, config)
|
|
497
|
+
elif choice == "theme":
|
|
498
|
+
pick_theme(ui, config)
|
|
499
|
+
elif choice == "about":
|
|
500
|
+
show_about(ui, config)
|
|
501
|
+
elif choice in ("exit", None):
|
|
502
|
+
_exit_application("menu exit")
|
|
503
|
+
return
|
|
504
|
+
except (KeyboardInterrupt, EOFError):
|
|
505
|
+
ui.dim("Cancelled.")
|
|
506
|
+
except Exception as exc: # menu actions must never crash the app
|
|
507
|
+
_log.exception("menu action failed: %s", choice)
|
|
508
|
+
ui.error(f"Something went wrong: {exc}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Application controller: trusted Windows app discovery, launch, verify.
|
|
2
|
+
|
|
3
|
+
The package turns "open Spotify" into a structured, verifiable workflow:
|
|
4
|
+
|
|
5
|
+
find_app ("Spotify") → AppInfo (or ApplicationNotFoundError)
|
|
6
|
+
is_running → reuse/focus instead of spawning duplicates
|
|
7
|
+
launch → Start Menu shortcut / shell-resolved target,
|
|
8
|
+
then state-based wait for a real window
|
|
9
|
+
verify → process + window evidence, not assumptions
|
|
10
|
+
install (missing apps) → gated behind the INSTALL permission category
|
|
11
|
+
and always confirmed by the user
|
|
12
|
+
|
|
13
|
+
Discovery walks trusted Windows mechanisms only — Start Menu shortcuts,
|
|
14
|
+
App Paths, uninstall registrations, PATH — never blind ``subprocess`` calls
|
|
15
|
+
with guessed exe names. All OS access is injectable/lazy so the whole
|
|
16
|
+
package unit-tests without Windows.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .discovery import AppInfo, find_app, installed_apps
|
|
22
|
+
from .launcher import launch_app
|
|
23
|
+
from .verifier import app_running, wait_for_app_window
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AppInfo",
|
|
27
|
+
"find_app",
|
|
28
|
+
"installed_apps",
|
|
29
|
+
"launch_app",
|
|
30
|
+
"app_running",
|
|
31
|
+
"wait_for_app_window",
|
|
32
|
+
]
|