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 +1 -1
- seedcode/_default_key_template.py +1 -1
- seedcode/app.py +272 -30
- seedcode/cli.py +13 -3
- seedcode/codemode.py +118 -50
- seedcode/codemode_state.py +12 -4
- seedcode/commands/__init__.py +14 -0
- seedcode/commands/agent.py +5 -3
- seedcode/commands/assist.py +154 -43
- seedcode/commands/codemode.py +85 -9
- seedcode/commands/desktop.py +2 -2
- seedcode/commands/mode.py +3 -3
- seedcode/commands/youtube_music.py +68 -0
- seedcode/core/models.py +1 -1
- seedcode/core/modes.py +2 -2
- seedcode/core/providers/__init__.py +20 -9
- seedcode/core/session.py +10 -2
- seedcode/core/tasks.py +22 -0
- seedcode/ui/__init__.py +20 -5
- seedcode/ui/codemode_header.py +2 -2
- seedcode/ui/content.py +1 -1
- seedcode/ui/dashboard.py +5 -5
- seedcode/ui/header.py +1 -1
- seedcode/ui/mode_switch.py +276 -0
- seedcode/ui/state.py +11 -1
- seedcode/ui/statusbar.py +1 -1
- seedcode/ui/tasks.py +1 -1
- seedcode/ui/tui.py +292 -32
- seedcode/workspace.py +336 -0
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.0.dist-info}/METADATA +43 -19
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.0.dist-info}/RECORD +34 -31
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.0.dist-info}/WHEEL +0 -0
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.0.dist-info}/entry_points.txt +0 -0
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.0.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__ = "
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
708
|
-
if
|
|
709
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
|
1358
|
+
"""Dispatch a command that does not change the mode, in place.
|
|
1153
1359
|
|
|
1154
|
-
The engine/agent are rebuilt only when the
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
#
|
|
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
|
|