seedcode-cli 8.2.5__py3-none-any.whl → 9.1.0__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 CHANGED
@@ -6,7 +6,7 @@ Plant ideas. Grow code.
6
6
  # Single source of truth for the application version. Every consumer reads it:
7
7
  # the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
8
8
  # Windows installer metadata, the GitHub release tag, and the WinGet manifest.
9
- __version__ = "8.2.5"
9
+ __version__ = "9.1.0"
10
10
  __author__ = "Al Shahriar Sowan"
11
11
  __publisher__ = "Eagox Studio"
12
12
  # Current author/company links, surfaced by /about and the README credits.
@@ -1,4 +1,4 @@
1
- """Embedded built-in credential for the Default provider (v7.1.0 artifact).
1
+ """Embedded built-in credential for the Default provider (v9.1.0 artifact).
2
2
 
3
3
  This file is NOT committed to source control. The release build generates
4
4
  ``seedcode/_default_key.py`` from the local ``.env`` (see
seedcode/app.py CHANGED
@@ -15,12 +15,15 @@ Ctrl+L clear screen.
15
15
  from __future__ import annotations
16
16
 
17
17
  import sys
18
+ import threading
19
+ import time
20
+ from pathlib import Path
18
21
 
19
22
  from prompt_toolkit import PromptSession
20
23
  from prompt_toolkit.key_binding import KeyBindings
21
24
 
22
25
  from . import __version__
23
- from .commands import CommandContext, dispatch, is_command
26
+ from .commands import CommandContext, CommandResult, dispatch, is_command
24
27
  from .commands.about import show_about
25
28
  from .commands.help import show_shortcuts
26
29
  from .commands.history import browse_history, settings_menu
@@ -44,6 +47,7 @@ from .tools import PermissionManager, PermissionMode
44
47
  from .ui import UI
45
48
  from .ui.badges import badge_for_status
46
49
  from .ui.menu import MenuItem, run_menu
50
+ from .ui.mode_switch import ModeSwitcher
47
51
  from .ui.reference import CONTROLS_HINT, INPUT_HINT
48
52
  from .ui.state import AppState, Status
49
53
  from .ui.tasks import TaskFlow, TaskState, activity_for
@@ -168,7 +172,7 @@ def _workspace_status(config: AppConfig) -> str:
168
172
  def _main_menu(config: AppConfig):
169
173
  """The interactive main menu; returns an action id or None (exit).
170
174
 
171
- v8.2.5 layout: the mode/setup actions carry Ctrl+1..Ctrl+4 shortcuts that
175
+ v9.1.0 layout: the mode/setup actions carry Ctrl+1..Ctrl+4 shortcuts that
172
176
  execute the real handlers (no decorative items), followed by the
173
177
  chat/setup actions kept from earlier releases. There are exactly two
174
178
  modes — Chat and Agent — and Code Mode's workspace capability lives inside
@@ -487,6 +491,24 @@ def _advance(lc, method: str) -> None:
487
491
  _log.debug("lifecycle transition %s skipped", method)
488
492
 
489
493
 
494
+ #: How long an Agent Mode turn waits for a background mode initialization that
495
+ #: is still running before it gets on with building its own engine. The wait
496
+ #: happens on the turn's worker thread, so nothing on screen is ever held up.
497
+ _AGENT_INIT_WAIT_S = 20.0
498
+
499
+
500
+ def _is_mode_command(text: str) -> bool:
501
+ """Whether ``text`` is an in-place mode transition (single source: the TUI).
502
+
503
+ Imported lazily so the controller can route a mode command to the
504
+ background worker without pulling prompt_toolkit into every entry point
505
+ that merely imports this module.
506
+ """
507
+ from .ui.tui import _is_mode_command as _is
508
+
509
+ return _is(text)
510
+
511
+
490
512
  def _codemode_enabled() -> bool:
491
513
  """Whether Code Mode is active (the persistent-session path)."""
492
514
  try:
@@ -698,15 +720,23 @@ def _report_session_summary(ui: UI, session) -> None:
698
720
  # A skipped task is resolved, never counted as verified work.
699
721
  ui.dim(f" • Skipped: {skipped} task(s) not needed")
700
722
  evidence = session.session_evidence
723
+ changed = list(session.state.changed_files)
701
724
  if evidence.tests:
702
725
  # What matters is the final state: the last test run passed.
703
726
  passed = evidence.tests[-1].ok
704
727
  ui.dim(f" ✓ Verification: {'accepted' if passed else 'tests NOT passing'}")
705
728
  ui.dim(f" ✓ Tests: {'passed' if passed else 'FAILED'}")
729
+ elif changed:
730
+ # Completion is evidence-gated (a task never verifies with zero
731
+ # observed work), so "accepted" here means files were really written.
732
+ ui.dim(
733
+ f" ✓ Verification: accepted ({len(changed)} file(s) created/modified)"
734
+ )
706
735
  else:
707
- ui.dim(" ✓ Verification: accepted (no test run in this project)")
708
- if session.state.changed_files:
709
- ui.dim(f" ✓ Files: {len(session.state.changed_files)} affected")
736
+ ui.dim(" ✓ Verification: accepted (commands ran and succeeded)")
737
+ if changed:
738
+ shown = ", ".join(changed[-5:])
739
+ ui.dim(f" ✓ Files: {len(changed)} affected — {shown}")
710
740
  commands = evidence.commands
711
741
  if commands:
712
742
  ok = sum(1 for record in commands if record.ok)
@@ -807,12 +837,22 @@ def _handle_agent(
807
837
  def _make_agent(
808
838
  ui: UI, config: AppConfig, presenter: "_TaskPresenter | None" = None
809
839
  ) -> AgentEngine:
810
- """Build an Agent Mode engine bound to the CWD and configured permissions."""
840
+ """Build an Agent Mode engine bound to the ACTIVE WORKSPACE and permissions.
841
+
842
+ The workspace is the folder the user selected at startup (or via
843
+ ``/workspace``), so every file operation the engine performs resolves
844
+ against it and stays inside it unless the permission system explicitly
845
+ authorizes an outside write.
846
+ """
811
847
  # Lazy import (matching _make_desktop_session): the optional, platform-
812
848
  # specific computer package stays out of app.py's top-level import graph.
813
849
  from .computer import is_available
850
+ from .workspace import active_workspace
814
851
 
815
- permissions = PermissionManager(level=PermissionMode.parse(config.permission_mode))
852
+ permissions = PermissionManager(
853
+ level=PermissionMode.parse(config.permission_mode),
854
+ workspace=active_workspace(),
855
+ )
816
856
  # Desktop capability is a property of the permission level now: attach the
817
857
  # Computer Engine gate whenever the level is Desktop or higher and the
818
858
  # engine is actually available on this machine.
@@ -1077,7 +1117,7 @@ def _build_chat_session(ui: UI) -> PromptSession:
1077
1117
 
1078
1118
 
1079
1119
  # ---------------------------------------------------------------------------
1080
- # Persistent TUI session (v8.2.5)
1120
+ # Persistent TUI session (v9.1.0)
1081
1121
  # ---------------------------------------------------------------------------
1082
1122
 
1083
1123
 
@@ -1112,10 +1152,26 @@ class _TuiController:
1112
1152
  self.agent: AgentEngine | None = None
1113
1153
  self.agent_perm = config.permission_mode
1114
1154
  self.agent_codemode = _codemode_enabled()
1155
+ #: The workspace the live engine was built for. A `/workspace` change
1156
+ #: moves the process working directory, so the engine (whose permission
1157
+ #: manager scopes every file operation to one root) must be rebuilt —
1158
+ #: otherwise writes would keep resolving against the old folder.
1159
+ self.agent_workspace = str(Path.cwd())
1115
1160
  self._backend = config.provider
1161
+ #: Guards the agent engine pointer against a mode switch rebuilding it
1162
+ #: while a turn is reading it (both run on worker threads).
1163
+ self._agent_lock = threading.RLock()
1164
+ #: The single owner of every background mode transition. A switch is a
1165
+ #: state change plus a worker; it is never a bare thread started from a
1166
+ #: UI callback (which is how a switch could freeze the terminal).
1167
+ self.switcher = ModeSwitcher(
1168
+ on_finished=self._switch_finished,
1169
+ on_failed=self._switch_failed,
1170
+ )
1116
1171
  tui.on_submit = self.submit
1117
1172
  # Mode switches run in place: no application exit, no rebuild.
1118
- tui.on_command = self._inplace_command
1173
+ tui.on_command = self._mode_command
1174
+ tui.on_cancel_init = self._cancel_initialization
1119
1175
 
1120
1176
  # --- provider/backend --------------------------------------------------
1121
1177
  def _on_provider_event(self, kind: str, detail: str) -> None:
@@ -1129,7 +1185,9 @@ class _TuiController:
1129
1185
  self.engine = ChatEngine(self.config, on_event=self._on_provider_event)
1130
1186
  self.history = HistoryStore(provider_id=self.config.provider)
1131
1187
  self.ctx = CommandContext(ui=self.ui, config=self.config, engine=self.engine)
1132
- self.agent = None
1188
+ with self._agent_lock:
1189
+ # A new backend means a new engine; drop the old one.
1190
+ self.agent = None
1133
1191
  self._backend = self.config.provider
1134
1192
 
1135
1193
  # --- one turn (worker thread) ------------------------------------------
@@ -1148,14 +1206,162 @@ class _TuiController:
1148
1206
  except Exception:
1149
1207
  pass
1150
1208
 
1209
+ # --- mode transitions ---------------------------------------------------
1210
+ def _mode_command(self, text: str) -> None:
1211
+ """Handle a mode-switch command from the composer — *never* inline.
1212
+
1213
+ Entering Agent Mode does real work (the ``.seedcode`` workspace scan,
1214
+ the desktop capability probe, and a permission prompt that waits for the
1215
+ user). The permission prompt is answered by the prompt_toolkit event
1216
+ loop, so running any of it on the event-loop thread would wait forever
1217
+ for a key press that thread had already made impossible — the freeze.
1218
+ Every transition therefore runs on the switcher's owned worker.
1219
+ """
1220
+ self._mode_transition(text)
1221
+
1222
+ def _mode_transition(self, text: str) -> int:
1223
+ """Start a mode transition; return immediately, never blocking the loop."""
1224
+ if not self._enters_agent_mode(text):
1225
+ # Leaving Agent Mode — and reporting mode state — is cheap and has
1226
+ # no permission prompt, so it applies here and now, and it cancels
1227
+ # any preparation still in flight. Nothing in this path can wait
1228
+ # for the event loop, so running it on the loop thread is safe.
1229
+ self.switcher.cancel()
1230
+ self.tui.end_initialization()
1231
+ self._inplace_command(text)
1232
+ return self.switcher.generation
1233
+ # Entering Agent Mode: the immediate, cheap half now; everything that
1234
+ # scans, probes or prompts happens on the switcher's owned worker.
1235
+ self._activate_agent_mode_now()
1236
+ return self.switcher.run(
1237
+ "agent", lambda cancel: self._run_mode_command(text, cancel)
1238
+ )
1239
+
1240
+ def _enters_agent_mode(self, text: str) -> bool:
1241
+ """Whether a mode command is turning Agent Mode **on**.
1242
+
1243
+ Mirrors the command layer's own aliases (``/agent``, ``/assist``,
1244
+ ``/desktop``, ``/mode``, ``/codemode``) so the immediate UI state and
1245
+ the background preparation agree. A bare form toggles, which is why the
1246
+ live config is consulted. Getting this wrong is not fatal — the command
1247
+ layer still performs the real switch — it only decides whether the
1248
+ user sees "Initializing..." instantly.
1249
+ """
1250
+ parts = text.strip().lstrip("/").split(maxsplit=1)
1251
+ if not parts:
1252
+ return False
1253
+ head = parts[0].lower()
1254
+ arg = parts[1].strip().lower() if len(parts) > 1 else ""
1255
+ if head in ("agent", "assist", "desktop"):
1256
+ if arg == "on":
1257
+ return True
1258
+ if arg == "off":
1259
+ return False
1260
+ return not self.config.agent_mode # the bare form toggles
1261
+ if head == "mode":
1262
+ if not arg:
1263
+ return False
1264
+ from .core.modes import Mode, parse_mode
1265
+
1266
+ return parse_mode(arg, default=Mode.CHAT) is Mode.AGENT
1267
+ if head == "codemode":
1268
+ return arg == "on"
1269
+ return False # /chat, /status and the rest never enter Agent Mode
1270
+
1271
+ def _activate_agent_mode_now(self) -> None:
1272
+ """Apply the cheap half of ``/agent on`` immediately.
1273
+
1274
+ Only the mode change itself (plus its config write): no workspace scan,
1275
+ no capability probe, no permission prompt. Those are what
1276
+ :func:`~seedcode.commands.assist.prepare_agent_mode` does on the worker.
1277
+ """
1278
+ from .commands.assist import activate_agent_mode
1279
+
1280
+ try:
1281
+ activate_agent_mode(self.config)
1282
+ except Exception:
1283
+ _log.exception("could not switch to Agent Mode")
1284
+ with self._agent_lock:
1285
+ # The engine built for Chat Mode must not serve an agent turn.
1286
+ self.agent = None
1287
+ self.tui.begin_initialization("Agent Mode")
1288
+ self.tui.sync_from_config(self.config)
1289
+
1290
+ def _run_mode_command(self, text: str, cancel) -> CommandResult:
1291
+ """The worker body of an Agent Mode entry (runs off the event loop).
1292
+
1293
+ ``cancel`` is the switcher's probe for this transition: it is handed to
1294
+ the command through the context so the workspace scan and the permission
1295
+ prompt stop as soon as this transition is superseded, and everything
1296
+ this worker might report is suppressed once it is stale.
1297
+ """
1298
+ ctx = CommandContext(
1299
+ ui=self.ui,
1300
+ config=self.config,
1301
+ engine=self.engine,
1302
+ cancel=cancel,
1303
+ )
1304
+ backend_before = self.config.provider
1305
+ try:
1306
+ result = dispatch(ctx, text)
1307
+ except (KeyboardInterrupt, EOFError):
1308
+ if not cancel():
1309
+ self.ui.dim("Cancelled.")
1310
+ result = CommandResult()
1311
+ except Exception as exc: # a broken command must not kill the session
1312
+ if not cancel():
1313
+ _log.exception("mode command failed: %s", text)
1314
+ _report_command_error(self.ui, exc)
1315
+ result = CommandResult()
1316
+ if cancel():
1317
+ return result # superseded: a newer transition owns the screen
1318
+ if self.config.provider != backend_before:
1319
+ self._rebuild_backend()
1320
+ self.tui.sync_from_config(self.config)
1321
+ return result
1322
+
1323
+ def _cancel_initialization(self) -> None:
1324
+ """Cancel a background Agent Mode preparation (Ctrl+C, Esc, shutdown)."""
1325
+ self.switcher.cancel()
1326
+ self.tui.end_initialization()
1327
+ self.tui.state.set_status(Status.READY, "")
1328
+ self.ui.dim(
1329
+ "(initialization cancelled — still in Agent Mode, preparing when "
1330
+ "needed; /chat on returns to Chat Mode)"
1331
+ )
1332
+
1333
+ def _switch_finished(self, _generation: int, _key: str, _result) -> None:
1334
+ """The background preparation completed (called on the worker)."""
1335
+ with self._agent_lock:
1336
+ self.agent = None # rebuilt lazily from the now-final config
1337
+ self.tui.end_initialization()
1338
+ try:
1339
+ self.tui.sync_from_config(self.config)
1340
+ except Exception:
1341
+ pass
1342
+
1343
+ def _switch_failed(self, _generation: int, _key: str, error: str) -> None:
1344
+ """A failed preparation is *reported*, never turned into a freeze.
1345
+
1346
+ The terminal keeps running, the header keeps working, and Chat Mode is
1347
+ one command away — exactly the contract for a failure in a mode switch.
1348
+ """
1349
+ with self._agent_lock:
1350
+ self.agent = None
1351
+ self.tui.end_initialization()
1352
+ self.tui.state.set_status(Status.ERROR, "initialization failed")
1353
+ self.ui.error(f"[Agent Mode] Initialization failed: {error}")
1354
+ self.ui.dim("Chat Mode is unaffected — /chat on returns to it.")
1355
+ self.tui.sync_from_config(self.config)
1356
+
1151
1357
  def _inplace_command(self, text: str) -> None:
1152
- """Dispatch a mode-switch command without leaving the TUI.
1358
+ """Dispatch a command that does not change the mode, in place.
1153
1359
 
1154
- The engine/agent are rebuilt only when the mode actually changed, and
1155
- the header is refreshed from live config — a pure state transition.
1360
+ The engine/agent are rebuilt only when the provider actually changed,
1361
+ and the header is refreshed from live config — a pure state transition
1362
+ with no application exit and no rebuild.
1156
1363
  """
1157
1364
  backend_before = self.config.provider
1158
- mode_before = self.config.mode
1159
1365
  try:
1160
1366
  dispatch(self.ctx, text)
1161
1367
  except (KeyboardInterrupt, EOFError):
@@ -1164,28 +1370,46 @@ class _TuiController:
1164
1370
  _report_command_error(self.ui, exc)
1165
1371
  if self.config.provider != backend_before:
1166
1372
  self._rebuild_backend()
1167
- elif self.config.mode != mode_before:
1168
- # A mode change invalidates the agent built for the old mode.
1169
- self.agent = None
1170
1373
  self.tui.sync_from_config(self.config)
1171
1374
 
1172
1375
  def _ensure_agent(self) -> None:
1376
+ """Build the Agent engine for the live config (on the turn worker).
1377
+
1378
+ If a mode switch is still preparing Agent Mode, wait for it here: this
1379
+ runs on the turn's own worker thread, so waiting costs the interface
1380
+ nothing, and it keeps one engine from being built underneath another.
1381
+ The wait is bounded, and Ctrl+C still aborts it.
1382
+ """
1383
+ deadline = time.monotonic() + _AGENT_INIT_WAIT_S
1384
+ while self.tui.state.agent_initializing and time.monotonic() < deadline:
1385
+ self.tui.check_cancel() # Ctrl+C still aborts the waiting turn
1386
+ if self.switcher.wait(timeout=0.1):
1387
+ break
1173
1388
  codemode_now = _codemode_enabled()
1174
- if (
1175
- self.agent is None
1176
- or self.agent_perm != self.config.permission_mode
1177
- or self.agent_codemode != codemode_now
1178
- ):
1179
- self.agent = _make_agent(self.ui, self.config, self.presenter)
1180
- self.agent_perm = self.config.permission_mode
1181
- self.agent_codemode = codemode_now
1389
+ workspace_now = str(Path.cwd())
1390
+ with self._agent_lock:
1391
+ if (
1392
+ self.agent is None
1393
+ or self.agent_perm != self.config.permission_mode
1394
+ or self.agent_codemode != codemode_now
1395
+ or self.agent_workspace != workspace_now
1396
+ ):
1397
+ self.agent = _make_agent(self.ui, self.config, self.presenter)
1398
+ self.agent_perm = self.config.permission_mode
1399
+ self.agent_codemode = codemode_now
1400
+ self.agent_workspace = workspace_now
1182
1401
 
1183
1402
  # --- the session loop --------------------------------------------------
1184
1403
  def run(self) -> None:
1185
1404
  while True:
1186
1405
  kind, payload = self.tui.run_once()
1406
+ # A mode command that somehow reached the main loop (an unexpected
1407
+ # result) still must not be dispatched on the event-loop thread.
1408
+ if kind == "command" and payload and _is_mode_command(payload):
1409
+ self._mode_transition(payload)
1410
+ continue
1187
1411
  if kind == "exit":
1188
- self.tui.shutdown()
1412
+ self._shutdown()
1189
1413
  _exit_application(self.ui, "tui exit")
1190
1414
  return
1191
1415
  if kind == "key":
@@ -1215,7 +1439,7 @@ class _TuiController:
1215
1439
  continue
1216
1440
  if result.should_exit:
1217
1441
  if self._menu():
1218
- self.tui.shutdown()
1442
+ self._shutdown()
1219
1443
  _exit_application(self.ui, "menu exit")
1220
1444
  return
1221
1445
  continue
@@ -1223,6 +1447,18 @@ class _TuiController:
1223
1447
  self._rebuild_backend()
1224
1448
  self.tui.sync_from_config(self.config)
1225
1449
 
1450
+ def _shutdown(self) -> None:
1451
+ """Release the session: stop background work, then the application.
1452
+
1453
+ A background mode initialization is cancelled and given a moment to
1454
+ finish, so no worker outlives the interface it belongs to.
1455
+ """
1456
+ try:
1457
+ self.switcher.cancel(wait=1.0)
1458
+ except Exception:
1459
+ pass # a teardown hiccup must never stop the session from ending
1460
+ self.tui.shutdown()
1461
+
1226
1462
  def _toolbar_action(self, action: str) -> None:
1227
1463
  """Run a real action behind a header control (provider/model/mode).
1228
1464
 
@@ -1264,7 +1500,9 @@ class _TuiController:
1264
1500
  hint="\u2191\u2193 move Enter select Esc cancel",
1265
1501
  )
1266
1502
  if choice:
1267
- self._command(f"/mode {choice}")
1503
+ # A real mode switch — routed to the background worker, never
1504
+ # dispatched on the event-loop thread.
1505
+ self._mode_transition(f"/mode {choice}")
1268
1506
 
1269
1507
  def _menu(self) -> bool:
1270
1508
  """The interactive main menu; returns True when the user quits."""
@@ -1285,7 +1523,10 @@ class _TuiController:
1285
1523
  elif choice == "model":
1286
1524
  select_model(self.ui, self.config)
1287
1525
  elif choice == "agent":
1288
- self._command("/agent on")
1526
+ # Agent Mode preparation is background work: it must not run
1527
+ # on the menu's (main) thread, which would deadlock on the
1528
+ # permission prompt it raises.
1529
+ self._mode_transition("/agent on")
1289
1530
  elif choice == "memory":
1290
1531
  self._command("/codemode status")
1291
1532
  elif choice == "settings":
@@ -1331,12 +1572,13 @@ def run_persistent(*, input_device=None, output_device=None) -> None:
1331
1572
  return
1332
1573
 
1333
1574
  workspace = str(workspace_root())
1334
- columns, _rows = _terminal_size()
1575
+ columns, rows = _terminal_size()
1335
1576
  state = AppState.from_config(config, workspace=workspace)
1336
1577
  tui = ChatTUI(
1337
1578
  state,
1338
1579
  workspace=workspace,
1339
1580
  width=columns,
1581
+ height=rows,
1340
1582
  input_device=input_device,
1341
1583
  output_device=output_device,
1342
1584
  )
seedcode/cli.py CHANGED
@@ -90,8 +90,7 @@ def main() -> None:
90
90
  from .utils.terminal_env import detect_terminal, workspace_root
91
91
 
92
92
  # Detect the terminal host/shell once (VS Code, Windows Terminal, Git Bash
93
- # or unknown) so a support log says exactly where this session ran. The
94
- # workspace root is the launch directory and is never changed below.
93
+ # or unknown) so a support log says exactly where this session ran.
95
94
  terminal = detect_terminal()
96
95
  log.info(
97
96
  "Seed Code v%s starting (python %s on %s); terminal %s; cwd=%s",
@@ -102,13 +101,24 @@ def main() -> None:
102
101
  workspace_root(),
103
102
  )
104
103
 
104
+ # v9.1.0: pick the project workspace explicitly instead of silently
105
+ # treating the launch directory as the project. Non-interactive hosts
106
+ # (pipelines, CI, scripts) skip the prompt and keep the launch directory;
107
+ # `SEEDCODE_WORKSPACE` selects one directly. Everything below — Agent
108
+ # Mode, file I/O, .seedcode, indexing, terminal commands, verification —
109
+ # resolves against whatever this returns.
110
+ from .workspace import ensure_workspace
111
+
112
+ workspace = ensure_workspace(interactive=_interactive())
113
+ log.info("workspace selected: %s", workspace)
114
+
105
115
  from .app import run
106
116
  from .ui import UI
107
117
 
108
118
  ui = UI(plain=terminal.plain)
109
119
  try:
110
120
  if _use_persistent_tui(terminal.plain):
111
- # v8.2.5: a real console gets the persistent three-region TUI;
121
+ # v9.1.0: a real console gets the persistent three-region TUI;
112
122
  # every other host keeps the sequential console experience.
113
123
  from .app import run_persistent
114
124