seedcode-cli 6.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
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
+ ]