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.
- seedcode/__init__.py +1 -1
- seedcode/_default_key_template.py +1 -1
- seedcode/app.py +394 -40
- 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/doctor.py +10 -0
- seedcode/commands/mode.py +3 -3
- seedcode/commands/youtube_music.py +68 -0
- seedcode/core/agent.py +99 -10
- seedcode/core/chat.py +33 -9
- seedcode/core/intent.py +321 -0
- seedcode/core/internet.py +255 -0
- seedcode/core/models.py +1 -1
- seedcode/core/modes.py +2 -2
- seedcode/core/providers/__init__.py +20 -9
- seedcode/core/providers/default.py +1 -1
- seedcode/core/session.py +10 -2
- seedcode/core/tasks.py +22 -0
- seedcode/default_api.py +77 -0
- seedcode/tools/__init__.py +1 -1
- seedcode/tools/permissions.py +18 -2
- seedcode/tools/web.py +81 -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/dialog.py +12 -4
- 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 +304 -36
- seedcode/workspace.py +336 -0
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/METADATA +153 -26
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/RECORD +45 -39
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/WHEEL +0 -0
- {seedcode_cli-8.2.5.dist-info → seedcode_cli-9.1.1.dist-info}/entry_points.txt +0 -0
- {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__ = "
|
|
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 (
|
|
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
|
-
|
|
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
|
|
245
|
-
"""
|
|
246
|
-
|
|
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 (
|
|
708
|
-
if
|
|
709
|
-
|
|
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
|
|
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(
|
|
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
|
-
"""
|
|
933
|
+
"""The session-scoped dangerous-action gate (created once per process).
|
|
840
934
|
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
1470
|
+
"""Dispatch a command that does not change the mode, in place.
|
|
1153
1471
|
|
|
1154
|
-
The engine/agent are rebuilt only when the
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
)
|