seedcode-cli 8.2.5__py3-none-any.whl → 9.1.1__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 (45) hide show
  1. seedcode/__init__.py +1 -1
  2. seedcode/_default_key_template.py +1 -1
  3. seedcode/app.py +394 -40
  4. seedcode/cli.py +13 -3
  5. seedcode/codemode.py +118 -50
  6. seedcode/codemode_state.py +12 -4
  7. seedcode/commands/__init__.py +14 -0
  8. seedcode/commands/agent.py +5 -3
  9. seedcode/commands/assist.py +154 -43
  10. seedcode/commands/codemode.py +85 -9
  11. seedcode/commands/desktop.py +2 -2
  12. seedcode/commands/doctor.py +10 -0
  13. seedcode/commands/mode.py +3 -3
  14. seedcode/commands/youtube_music.py +68 -0
  15. seedcode/core/agent.py +99 -10
  16. seedcode/core/chat.py +33 -9
  17. seedcode/core/intent.py +321 -0
  18. seedcode/core/internet.py +255 -0
  19. seedcode/core/models.py +1 -1
  20. seedcode/core/modes.py +2 -2
  21. seedcode/core/providers/__init__.py +20 -9
  22. seedcode/core/providers/default.py +1 -1
  23. seedcode/core/session.py +10 -2
  24. seedcode/core/tasks.py +22 -0
  25. seedcode/default_api.py +77 -0
  26. seedcode/tools/__init__.py +1 -1
  27. seedcode/tools/permissions.py +18 -2
  28. seedcode/tools/web.py +81 -0
  29. seedcode/ui/__init__.py +20 -5
  30. seedcode/ui/codemode_header.py +2 -2
  31. seedcode/ui/content.py +1 -1
  32. seedcode/ui/dashboard.py +5 -5
  33. seedcode/ui/dialog.py +12 -4
  34. seedcode/ui/header.py +1 -1
  35. seedcode/ui/mode_switch.py +276 -0
  36. seedcode/ui/state.py +11 -1
  37. seedcode/ui/statusbar.py +1 -1
  38. seedcode/ui/tasks.py +1 -1
  39. seedcode/ui/tui.py +304 -36
  40. seedcode/workspace.py +336 -0
  41. {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/METADATA +153 -26
  42. {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/RECORD +45 -39
  43. {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/WHEEL +0 -0
  44. {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/entry_points.txt +0 -0
  45. {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/licenses/LICENSE +0 -0
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.1"
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.1 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
@@ -30,6 +33,7 @@ from .commands.theme import pick_theme
30
33
  from .config import load_config
31
34
  from .core.agent import AgentEngine, strip_tool_blocks
32
35
  from .core.chat import ChatEngine, ChatError
36
+ from .core.intent import Intent, classify
33
37
  from .core.lifecycle import LifecycleError, lifecycle
34
38
  from .core.models import AppConfig
35
39
  from .core.providers import (
@@ -44,6 +48,7 @@ from .tools import PermissionManager, PermissionMode
44
48
  from .ui import UI
45
49
  from .ui.badges import badge_for_status
46
50
  from .ui.menu import MenuItem, run_menu
51
+ from .ui.mode_switch import ModeSwitcher
47
52
  from .ui.reference import CONTROLS_HINT, INPUT_HINT
48
53
  from .ui.state import AppState, Status
49
54
  from .ui.tasks import TaskFlow, TaskState, activity_for
@@ -168,7 +173,7 @@ def _workspace_status(config: AppConfig) -> str:
168
173
  def _main_menu(config: AppConfig):
169
174
  """The interactive main menu; returns an action id or None (exit).
170
175
 
171
- v8.2.5 layout: the mode/setup actions carry Ctrl+1..Ctrl+4 shortcuts that
176
+ v9.1.0 layout: the mode/setup actions carry Ctrl+1..Ctrl+4 shortcuts that
172
177
  execute the real handlers (no decorative items), followed by the
173
178
  chat/setup actions kept from earlier releases. There are exactly two
174
179
  modes — Chat and Agent — and Code Mode's workspace capability lives inside
@@ -241,9 +246,52 @@ def _guided_setup(ui: UI, config: AppConfig) -> bool:
241
246
  return config.is_configured()
242
247
 
243
248
 
244
- def _handle_chat(ui: UI, engine: ChatEngine, history: HistoryStore, text: str) -> None:
245
- """Send a user turn to the model and stream the reply to screen."""
246
- engine.add_user(text)
249
+ def _retrieve_context(ui: UI, intent: Intent, text: str) -> str:
250
+ """Fetch fresh external information for a research request (v9.1.1).
251
+
252
+ Returns a bounded context block, or '' when retrieval is disabled, fails,
253
+ or finds nothing. The caller shows a lightweight status and continues — a
254
+ failed lookup must never stop the answer. Nothing here is fatal.
255
+ """
256
+ try:
257
+ from .core import internet
258
+
259
+ if internet.internet_disabled():
260
+ return ""
261
+ ui.dim("[Accessing internet...]")
262
+ result = internet.research(intent.target or text)
263
+ if not result.ok:
264
+ ui.dim("[No online sources available]")
265
+ return ""
266
+ if result.excerpts:
267
+ ui.dim("[Reading relevant sources...]")
268
+ return internet.context_block(result)
269
+ except Exception: # retrieval must never break a turn
270
+ _log.exception("internet retrieval failed")
271
+ return ""
272
+
273
+
274
+ def _handle_chat(
275
+ ui: UI,
276
+ engine: ChatEngine,
277
+ history: HistoryStore,
278
+ text: str,
279
+ intent: Intent | None = None,
280
+ ) -> None:
281
+ """Send a user turn to the model and stream the reply to screen.
282
+
283
+ When the request needs current information, only a lightweight status is
284
+ shown (``[Accessing internet...]`` / ``[Reading relevant sources...]``)
285
+ and the retrieved sources are prepended to the turn; the browsing itself
286
+ is never exposed line by line.
287
+ """
288
+ resolved = intent or classify(text)
289
+ outgoing = text
290
+ if resolved.needs_internet:
291
+ block = _retrieve_context(ui, resolved, text)
292
+ if block:
293
+ outgoing = block + "\n\n" + text
294
+ engine.add_user(outgoing)
247
295
  renderer = None
248
296
  try:
249
297
  chunks = engine.stream_reply()
@@ -487,6 +535,24 @@ def _advance(lc, method: str) -> None:
487
535
  _log.debug("lifecycle transition %s skipped", method)
488
536
 
489
537
 
538
+ #: How long an Agent Mode turn waits for a background mode initialization that
539
+ #: is still running before it gets on with building its own engine. The wait
540
+ #: happens on the turn's worker thread, so nothing on screen is ever held up.
541
+ _AGENT_INIT_WAIT_S = 20.0
542
+
543
+
544
+ def _is_mode_command(text: str) -> bool:
545
+ """Whether ``text`` is an in-place mode transition (single source: the TUI).
546
+
547
+ Imported lazily so the controller can route a mode command to the
548
+ background worker without pulling prompt_toolkit into every entry point
549
+ that merely imports this module.
550
+ """
551
+ from .ui.tui import _is_mode_command as _is
552
+
553
+ return _is(text)
554
+
555
+
490
556
  def _codemode_enabled() -> bool:
491
557
  """Whether Code Mode is active (the persistent-session path)."""
492
558
  try:
@@ -698,15 +764,23 @@ def _report_session_summary(ui: UI, session) -> None:
698
764
  # A skipped task is resolved, never counted as verified work.
699
765
  ui.dim(f" • Skipped: {skipped} task(s) not needed")
700
766
  evidence = session.session_evidence
767
+ changed = list(session.state.changed_files)
701
768
  if evidence.tests:
702
769
  # What matters is the final state: the last test run passed.
703
770
  passed = evidence.tests[-1].ok
704
771
  ui.dim(f" ✓ Verification: {'accepted' if passed else 'tests NOT passing'}")
705
772
  ui.dim(f" ✓ Tests: {'passed' if passed else 'FAILED'}")
773
+ elif changed:
774
+ # Completion is evidence-gated (a task never verifies with zero
775
+ # observed work), so "accepted" here means files were really written.
776
+ ui.dim(
777
+ f" ✓ Verification: accepted ({len(changed)} file(s) created/modified)"
778
+ )
706
779
  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")
780
+ ui.dim(" ✓ Verification: accepted (commands ran and succeeded)")
781
+ if changed:
782
+ shown = ", ".join(changed[-5:])
783
+ ui.dim(f" ✓ Files: {len(changed)} affected — {shown}")
710
784
  commands = evidence.commands
711
785
  if commands:
712
786
  ok = sum(1 for record in commands if record.ok)
@@ -807,12 +881,22 @@ def _handle_agent(
807
881
  def _make_agent(
808
882
  ui: UI, config: AppConfig, presenter: "_TaskPresenter | None" = None
809
883
  ) -> AgentEngine:
810
- """Build an Agent Mode engine bound to the CWD and configured permissions."""
884
+ """Build an Agent Mode engine bound to the ACTIVE WORKSPACE and permissions.
885
+
886
+ The workspace is the folder the user selected at startup (or via
887
+ ``/workspace``), so every file operation the engine performs resolves
888
+ against it and stays inside it unless the permission system explicitly
889
+ authorizes an outside write.
890
+ """
811
891
  # Lazy import (matching _make_desktop_session): the optional, platform-
812
892
  # specific computer package stays out of app.py's top-level import graph.
813
893
  from .computer import is_available
894
+ from .workspace import active_workspace
814
895
 
815
- permissions = PermissionManager(level=PermissionMode.parse(config.permission_mode))
896
+ permissions = PermissionManager(
897
+ level=PermissionMode.parse(config.permission_mode),
898
+ workspace=active_workspace(),
899
+ )
816
900
  # Desktop capability is a property of the permission level now: attach the
817
901
  # Computer Engine gate whenever the level is Desktop or higher and the
818
902
  # engine is actually available on this machine.
@@ -835,12 +919,24 @@ def _make_agent(
835
919
  return engine
836
920
 
837
921
 
922
+ # The process-wide dangerous-action gate (v9.1.1). It is created once, not
923
+ # once per engine: the agent engine is rebuilt whenever the permission level or
924
+ # workspace changes, and re-creating the gate there was resetting the user's
925
+ # "Allow" / "Allow All" answers mid-task — the repeated-permission-prompt bug.
926
+ # A grant now survives every tool call, every multi-step task and every engine
927
+ # rebuild, exactly as the session-scope answer promises.
928
+ _ACTION_GATE = None
929
+ _ACTION_GATE_LOCK = threading.Lock()
930
+
931
+
838
932
  def _make_action_gate(ui: UI):
839
- """Dangerous-action gate wired to the interactive permission dialog.
933
+ """The session-scoped dangerous-action gate (created once per process).
840
934
 
841
- Note: the Assist engine (and thus this gate) is rebuilt on permission-mode
842
- changes, so session "Always" grants reset then — conservative on purpose.
935
+ Answers stick for the rest of the session: ``Always Allow`` per category,
936
+ and ``Allow All`` across every category. Nothing is persisted to disk, so
937
+ a new process starts clean — the scope is deliberately session-only.
843
938
  """
939
+ global _ACTION_GATE
844
940
  from .tools.permissions import ACTION_LABELS, ActionGate, ActionGrant
845
941
 
846
942
  def confirm(category: str, description: str) -> ActionGrant:
@@ -849,9 +945,20 @@ def _make_action_gate(ui: UI):
849
945
  return {
850
946
  "y": ActionGrant.ONCE,
851
947
  "a": ActionGrant.ALWAYS,
948
+ "A": ActionGrant.ALL,
852
949
  }.get(answer, ActionGrant.DENY)
853
950
 
854
- return ActionGate(confirm=confirm)
951
+ with _ACTION_GATE_LOCK:
952
+ if _ACTION_GATE is None:
953
+ _ACTION_GATE = ActionGate(confirm=confirm)
954
+ return _ACTION_GATE
955
+
956
+
957
+ def reset_action_gate() -> None:
958
+ """Forget all session grants (test isolation; also an explicit reset)."""
959
+ global _ACTION_GATE
960
+ with _ACTION_GATE_LOCK:
961
+ _ACTION_GATE = None
855
962
 
856
963
 
857
964
  def _make_desktop_session(ui: UI):
@@ -980,7 +1087,11 @@ def _chat_loop(
980
1087
  # span's ``finally`` returns the state machine to IDLE and the REPL
981
1088
  # prompts again. A task can never end the app.
982
1089
  with lifecycle().task_span():
983
- if config.agent_mode:
1090
+ intent = classify(text)
1091
+ if config.agent_mode or intent.executes:
1092
+ # Build (or rebuild) the Agent engine for the live config. A
1093
+ # temporary escalation builds exactly the same engine an
1094
+ # explicit Agent Mode turn would, so behaviour cannot diverge.
984
1095
  try:
985
1096
  from . import codemode_state as _cms
986
1097
 
@@ -995,11 +1106,21 @@ def _chat_loop(
995
1106
  agent = _make_agent(ui, config, presenter)
996
1107
  agent_perm = config.permission_mode
997
1108
  agent_codemode = codemode_now
1109
+ if not config.agent_mode:
1110
+ # Chat Mode escalation: one agent turn, then back to chat.
1111
+ # The persistent mode is untouched.
1112
+ ui.dim(f"[Agent assist - {intent.reason}]")
1113
+ try:
1114
+ agent.set_intent(intent) # type: ignore[union-attr]
1115
+ except Exception:
1116
+ pass
998
1117
  # One task flow per turn: the engine reports real activity into
999
1118
  # it, and the REPL keeps prompting when the turn ends.
1000
1119
  _handle_agent(ui, agent, history, text, presenter)
1120
+ if not config.agent_mode:
1121
+ ui.dim("[Temporary agent task finished - back to Chat Mode]")
1001
1122
  else:
1002
- _handle_chat(ui, engine, history, text)
1123
+ _handle_chat(ui, engine, history, text, intent)
1003
1124
 
1004
1125
 
1005
1126
  def _interactive() -> bool:
@@ -1077,7 +1198,7 @@ def _build_chat_session(ui: UI) -> PromptSession:
1077
1198
 
1078
1199
 
1079
1200
  # ---------------------------------------------------------------------------
1080
- # Persistent TUI session (v8.2.5)
1201
+ # Persistent TUI session (v9.1.0)
1081
1202
  # ---------------------------------------------------------------------------
1082
1203
 
1083
1204
 
@@ -1112,10 +1233,26 @@ class _TuiController:
1112
1233
  self.agent: AgentEngine | None = None
1113
1234
  self.agent_perm = config.permission_mode
1114
1235
  self.agent_codemode = _codemode_enabled()
1236
+ #: The workspace the live engine was built for. A `/workspace` change
1237
+ #: moves the process working directory, so the engine (whose permission
1238
+ #: manager scopes every file operation to one root) must be rebuilt —
1239
+ #: otherwise writes would keep resolving against the old folder.
1240
+ self.agent_workspace = str(Path.cwd())
1115
1241
  self._backend = config.provider
1242
+ #: Guards the agent engine pointer against a mode switch rebuilding it
1243
+ #: while a turn is reading it (both run on worker threads).
1244
+ self._agent_lock = threading.RLock()
1245
+ #: The single owner of every background mode transition. A switch is a
1246
+ #: state change plus a worker; it is never a bare thread started from a
1247
+ #: UI callback (which is how a switch could freeze the terminal).
1248
+ self.switcher = ModeSwitcher(
1249
+ on_finished=self._switch_finished,
1250
+ on_failed=self._switch_failed,
1251
+ )
1116
1252
  tui.on_submit = self.submit
1117
1253
  # Mode switches run in place: no application exit, no rebuild.
1118
- tui.on_command = self._inplace_command
1254
+ tui.on_command = self._mode_command
1255
+ tui.on_cancel_init = self._cancel_initialization
1119
1256
 
1120
1257
  # --- provider/backend --------------------------------------------------
1121
1258
  def _on_provider_event(self, kind: str, detail: str) -> None:
@@ -1129,7 +1266,9 @@ class _TuiController:
1129
1266
  self.engine = ChatEngine(self.config, on_event=self._on_provider_event)
1130
1267
  self.history = HistoryStore(provider_id=self.config.provider)
1131
1268
  self.ctx = CommandContext(ui=self.ui, config=self.config, engine=self.engine)
1132
- self.agent = None
1269
+ with self._agent_lock:
1270
+ # A new backend means a new engine; drop the old one.
1271
+ self.agent = None
1133
1272
  self._backend = self.config.provider
1134
1273
 
1135
1274
  # --- one turn (worker thread) ------------------------------------------
@@ -1142,20 +1281,199 @@ class _TuiController:
1142
1281
  self._ensure_agent()
1143
1282
  _handle_agent(self.ui, self.agent, self.history, text, self.presenter)
1144
1283
  else:
1145
- _handle_chat(self.ui, self.engine, self.history, text)
1284
+ intent = classify(text)
1285
+ if intent.executes:
1286
+ # Chat Mode detected a request needing real execution:
1287
+ # escalate to Agent for THIS turn only, then return to
1288
+ # Chat. The persistent mode is never changed.
1289
+ self._run_escalated_turn(text, intent)
1290
+ else:
1291
+ _handle_chat(self.ui, self.engine, self.history, text, intent)
1146
1292
  try:
1147
1293
  self.tui.sync_from_config(self.config)
1148
1294
  except Exception:
1149
1295
  pass
1150
1296
 
1297
+ def _run_escalated_turn(self, text: str, intent: Intent) -> None:
1298
+ """Run one Agent turn from Chat Mode, then hand control back (v9.1.1).
1299
+
1300
+ This is the *temporary* escalation path: the user stays in Chat Mode,
1301
+ one request is executed with the full agent loop, and the session
1302
+ returns to conversation. Explicit Agent Mode is unaffected and stays
1303
+ active until the user changes it.
1304
+ """
1305
+ self.ui.dim(f"[Agent assist - {intent.reason}]")
1306
+ try:
1307
+ self._ensure_agent()
1308
+ agent = self.agent
1309
+ if agent is None:
1310
+ _handle_chat(self.ui, self.engine, self.history, text, intent)
1311
+ return
1312
+ agent.set_intent(intent)
1313
+ _handle_agent(self.ui, agent, self.history, text, self.presenter)
1314
+ except Exception: # escalation must never break the session
1315
+ _log.exception("temporary agent escalation failed")
1316
+ self.ui.error("The agent step could not run; answering in Chat Mode.")
1317
+ _handle_chat(self.ui, self.engine, self.history, text, intent)
1318
+ finally:
1319
+ self.ui.dim("[Temporary agent task finished - back to Chat Mode]")
1320
+
1321
+ # --- mode transitions ---------------------------------------------------
1322
+ def _mode_command(self, text: str) -> None:
1323
+ """Handle a mode-switch command from the composer — *never* inline.
1324
+
1325
+ Entering Agent Mode does real work (the ``.seedcode`` workspace scan,
1326
+ the desktop capability probe, and a permission prompt that waits for the
1327
+ user). The permission prompt is answered by the prompt_toolkit event
1328
+ loop, so running any of it on the event-loop thread would wait forever
1329
+ for a key press that thread had already made impossible — the freeze.
1330
+ Every transition therefore runs on the switcher's owned worker.
1331
+ """
1332
+ self._mode_transition(text)
1333
+
1334
+ def _mode_transition(self, text: str) -> int:
1335
+ """Start a mode transition; return immediately, never blocking the loop."""
1336
+ if not self._enters_agent_mode(text):
1337
+ # Leaving Agent Mode — and reporting mode state — is cheap and has
1338
+ # no permission prompt, so it applies here and now, and it cancels
1339
+ # any preparation still in flight. Nothing in this path can wait
1340
+ # for the event loop, so running it on the loop thread is safe.
1341
+ self.switcher.cancel()
1342
+ self.tui.end_initialization()
1343
+ self._inplace_command(text)
1344
+ return self.switcher.generation
1345
+ # Entering Agent Mode: the immediate, cheap half now; everything that
1346
+ # scans, probes or prompts happens on the switcher's owned worker.
1347
+ self._activate_agent_mode_now()
1348
+ return self.switcher.run(
1349
+ "agent", lambda cancel: self._run_mode_command(text, cancel)
1350
+ )
1351
+
1352
+ def _enters_agent_mode(self, text: str) -> bool:
1353
+ """Whether a mode command is turning Agent Mode **on**.
1354
+
1355
+ Mirrors the command layer's own aliases (``/agent``, ``/assist``,
1356
+ ``/desktop``, ``/mode``, ``/codemode``) so the immediate UI state and
1357
+ the background preparation agree. A bare form toggles, which is why the
1358
+ live config is consulted. Getting this wrong is not fatal — the command
1359
+ layer still performs the real switch — it only decides whether the
1360
+ user sees "Initializing..." instantly.
1361
+ """
1362
+ parts = text.strip().lstrip("/").split(maxsplit=1)
1363
+ if not parts:
1364
+ return False
1365
+ head = parts[0].lower()
1366
+ arg = parts[1].strip().lower() if len(parts) > 1 else ""
1367
+ if head in ("agent", "assist", "desktop"):
1368
+ if arg == "on":
1369
+ return True
1370
+ if arg == "off":
1371
+ return False
1372
+ return not self.config.agent_mode # the bare form toggles
1373
+ if head == "mode":
1374
+ if not arg:
1375
+ return False
1376
+ from .core.modes import Mode, parse_mode
1377
+
1378
+ return parse_mode(arg, default=Mode.CHAT) is Mode.AGENT
1379
+ if head == "codemode":
1380
+ return arg == "on"
1381
+ return False # /chat, /status and the rest never enter Agent Mode
1382
+
1383
+ def _activate_agent_mode_now(self) -> None:
1384
+ """Apply the cheap half of ``/agent on`` immediately.
1385
+
1386
+ Only the mode change itself (plus its config write): no workspace scan,
1387
+ no capability probe, no permission prompt. Those are what
1388
+ :func:`~seedcode.commands.assist.prepare_agent_mode` does on the worker.
1389
+ """
1390
+ from .commands.assist import activate_agent_mode
1391
+
1392
+ try:
1393
+ activate_agent_mode(self.config)
1394
+ except Exception:
1395
+ _log.exception("could not switch to Agent Mode")
1396
+ with self._agent_lock:
1397
+ # The engine built for Chat Mode must not serve an agent turn.
1398
+ self.agent = None
1399
+ self.tui.begin_initialization("Agent Mode")
1400
+ self.tui.sync_from_config(self.config)
1401
+
1402
+ def _run_mode_command(self, text: str, cancel) -> CommandResult:
1403
+ """The worker body of an Agent Mode entry (runs off the event loop).
1404
+
1405
+ ``cancel`` is the switcher's probe for this transition: it is handed to
1406
+ the command through the context so the workspace scan and the permission
1407
+ prompt stop as soon as this transition is superseded, and everything
1408
+ this worker might report is suppressed once it is stale.
1409
+ """
1410
+ ctx = CommandContext(
1411
+ ui=self.ui,
1412
+ config=self.config,
1413
+ engine=self.engine,
1414
+ cancel=cancel,
1415
+ )
1416
+ backend_before = self.config.provider
1417
+ try:
1418
+ result = dispatch(ctx, text)
1419
+ except (KeyboardInterrupt, EOFError):
1420
+ if not cancel():
1421
+ self.ui.dim("Cancelled.")
1422
+ result = CommandResult()
1423
+ except Exception as exc: # a broken command must not kill the session
1424
+ if not cancel():
1425
+ _log.exception("mode command failed: %s", text)
1426
+ _report_command_error(self.ui, exc)
1427
+ result = CommandResult()
1428
+ if cancel():
1429
+ return result # superseded: a newer transition owns the screen
1430
+ if self.config.provider != backend_before:
1431
+ self._rebuild_backend()
1432
+ self.tui.sync_from_config(self.config)
1433
+ return result
1434
+
1435
+ def _cancel_initialization(self) -> None:
1436
+ """Cancel a background Agent Mode preparation (Ctrl+C, Esc, shutdown)."""
1437
+ self.switcher.cancel()
1438
+ self.tui.end_initialization()
1439
+ self.tui.state.set_status(Status.READY, "")
1440
+ self.ui.dim(
1441
+ "(initialization cancelled — still in Agent Mode, preparing when "
1442
+ "needed; /chat on returns to Chat Mode)"
1443
+ )
1444
+
1445
+ def _switch_finished(self, _generation: int, _key: str, _result) -> None:
1446
+ """The background preparation completed (called on the worker)."""
1447
+ with self._agent_lock:
1448
+ self.agent = None # rebuilt lazily from the now-final config
1449
+ self.tui.end_initialization()
1450
+ try:
1451
+ self.tui.sync_from_config(self.config)
1452
+ except Exception:
1453
+ pass
1454
+
1455
+ def _switch_failed(self, _generation: int, _key: str, error: str) -> None:
1456
+ """A failed preparation is *reported*, never turned into a freeze.
1457
+
1458
+ The terminal keeps running, the header keeps working, and Chat Mode is
1459
+ one command away — exactly the contract for a failure in a mode switch.
1460
+ """
1461
+ with self._agent_lock:
1462
+ self.agent = None
1463
+ self.tui.end_initialization()
1464
+ self.tui.state.set_status(Status.ERROR, "initialization failed")
1465
+ self.ui.error(f"[Agent Mode] Initialization failed: {error}")
1466
+ self.ui.dim("Chat Mode is unaffected — /chat on returns to it.")
1467
+ self.tui.sync_from_config(self.config)
1468
+
1151
1469
  def _inplace_command(self, text: str) -> None:
1152
- """Dispatch a mode-switch command without leaving the TUI.
1470
+ """Dispatch a command that does not change the mode, in place.
1153
1471
 
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.
1472
+ The engine/agent are rebuilt only when the provider actually changed,
1473
+ and the header is refreshed from live config — a pure state transition
1474
+ with no application exit and no rebuild.
1156
1475
  """
1157
1476
  backend_before = self.config.provider
1158
- mode_before = self.config.mode
1159
1477
  try:
1160
1478
  dispatch(self.ctx, text)
1161
1479
  except (KeyboardInterrupt, EOFError):
@@ -1164,28 +1482,46 @@ class _TuiController:
1164
1482
  _report_command_error(self.ui, exc)
1165
1483
  if self.config.provider != backend_before:
1166
1484
  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
1485
  self.tui.sync_from_config(self.config)
1171
1486
 
1172
1487
  def _ensure_agent(self) -> None:
1488
+ """Build the Agent engine for the live config (on the turn worker).
1489
+
1490
+ If a mode switch is still preparing Agent Mode, wait for it here: this
1491
+ runs on the turn's own worker thread, so waiting costs the interface
1492
+ nothing, and it keeps one engine from being built underneath another.
1493
+ The wait is bounded, and Ctrl+C still aborts it.
1494
+ """
1495
+ deadline = time.monotonic() + _AGENT_INIT_WAIT_S
1496
+ while self.tui.state.agent_initializing and time.monotonic() < deadline:
1497
+ self.tui.check_cancel() # Ctrl+C still aborts the waiting turn
1498
+ if self.switcher.wait(timeout=0.1):
1499
+ break
1173
1500
  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
1501
+ workspace_now = str(Path.cwd())
1502
+ with self._agent_lock:
1503
+ if (
1504
+ self.agent is None
1505
+ or self.agent_perm != self.config.permission_mode
1506
+ or self.agent_codemode != codemode_now
1507
+ or self.agent_workspace != workspace_now
1508
+ ):
1509
+ self.agent = _make_agent(self.ui, self.config, self.presenter)
1510
+ self.agent_perm = self.config.permission_mode
1511
+ self.agent_codemode = codemode_now
1512
+ self.agent_workspace = workspace_now
1182
1513
 
1183
1514
  # --- the session loop --------------------------------------------------
1184
1515
  def run(self) -> None:
1185
1516
  while True:
1186
1517
  kind, payload = self.tui.run_once()
1518
+ # A mode command that somehow reached the main loop (an unexpected
1519
+ # result) still must not be dispatched on the event-loop thread.
1520
+ if kind == "command" and payload and _is_mode_command(payload):
1521
+ self._mode_transition(payload)
1522
+ continue
1187
1523
  if kind == "exit":
1188
- self.tui.shutdown()
1524
+ self._shutdown()
1189
1525
  _exit_application(self.ui, "tui exit")
1190
1526
  return
1191
1527
  if kind == "key":
@@ -1215,7 +1551,7 @@ class _TuiController:
1215
1551
  continue
1216
1552
  if result.should_exit:
1217
1553
  if self._menu():
1218
- self.tui.shutdown()
1554
+ self._shutdown()
1219
1555
  _exit_application(self.ui, "menu exit")
1220
1556
  return
1221
1557
  continue
@@ -1223,6 +1559,18 @@ class _TuiController:
1223
1559
  self._rebuild_backend()
1224
1560
  self.tui.sync_from_config(self.config)
1225
1561
 
1562
+ def _shutdown(self) -> None:
1563
+ """Release the session: stop background work, then the application.
1564
+
1565
+ A background mode initialization is cancelled and given a moment to
1566
+ finish, so no worker outlives the interface it belongs to.
1567
+ """
1568
+ try:
1569
+ self.switcher.cancel(wait=1.0)
1570
+ except Exception:
1571
+ pass # a teardown hiccup must never stop the session from ending
1572
+ self.tui.shutdown()
1573
+
1226
1574
  def _toolbar_action(self, action: str) -> None:
1227
1575
  """Run a real action behind a header control (provider/model/mode).
1228
1576
 
@@ -1264,7 +1612,9 @@ class _TuiController:
1264
1612
  hint="\u2191\u2193 move Enter select Esc cancel",
1265
1613
  )
1266
1614
  if choice:
1267
- self._command(f"/mode {choice}")
1615
+ # A real mode switch — routed to the background worker, never
1616
+ # dispatched on the event-loop thread.
1617
+ self._mode_transition(f"/mode {choice}")
1268
1618
 
1269
1619
  def _menu(self) -> bool:
1270
1620
  """The interactive main menu; returns True when the user quits."""
@@ -1285,7 +1635,10 @@ class _TuiController:
1285
1635
  elif choice == "model":
1286
1636
  select_model(self.ui, self.config)
1287
1637
  elif choice == "agent":
1288
- self._command("/agent on")
1638
+ # Agent Mode preparation is background work: it must not run
1639
+ # on the menu's (main) thread, which would deadlock on the
1640
+ # permission prompt it raises.
1641
+ self._mode_transition("/agent on")
1289
1642
  elif choice == "memory":
1290
1643
  self._command("/codemode status")
1291
1644
  elif choice == "settings":
@@ -1331,12 +1684,13 @@ def run_persistent(*, input_device=None, output_device=None) -> None:
1331
1684
  return
1332
1685
 
1333
1686
  workspace = str(workspace_root())
1334
- columns, _rows = _terminal_size()
1687
+ columns, rows = _terminal_size()
1335
1688
  state = AppState.from_config(config, workspace=workspace)
1336
1689
  tui = ChatTUI(
1337
1690
  state,
1338
1691
  workspace=workspace,
1339
1692
  width=columns,
1693
+ height=rows,
1340
1694
  input_device=input_device,
1341
1695
  output_device=output_device,
1342
1696
  )