speiidev 1.0.1 → 1.2.0
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.
- package/PLAN.md +292 -0
- package/package.json +1 -1
- package/pyproject.toml +4 -1
- package/scripts/bump_version.py +104 -0
- package/src/autodev/__init__.py +18 -8
- package/src/autodev/_version.py +14 -0
- package/src/autodev/cli.py +712 -173
- package/tests/test_cli.py +128 -1
package/src/autodev/cli.py
CHANGED
|
@@ -14,6 +14,7 @@ to each role. The first run asks which CLI to use for each role and remembers it
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import argparse
|
|
17
|
+
import asyncio
|
|
17
18
|
import difflib
|
|
18
19
|
import json
|
|
19
20
|
import os
|
|
@@ -23,6 +24,7 @@ import shutil
|
|
|
23
24
|
import subprocess
|
|
24
25
|
import sys
|
|
25
26
|
import time
|
|
27
|
+
import uuid
|
|
26
28
|
from collections import deque
|
|
27
29
|
from dataclasses import dataclass, field
|
|
28
30
|
from datetime import datetime
|
|
@@ -44,19 +46,20 @@ from rich import box
|
|
|
44
46
|
from rich.console import Console, Group
|
|
45
47
|
from rich.live import Live
|
|
46
48
|
from rich.markdown import Markdown
|
|
49
|
+
from rich.markup import escape
|
|
47
50
|
from rich.panel import Panel
|
|
48
51
|
from rich.prompt import Prompt
|
|
49
52
|
from rich.rule import Rule
|
|
50
|
-
from rich.spinner import Spinner
|
|
51
53
|
from rich.syntax import Syntax
|
|
52
54
|
from rich.table import Table
|
|
53
55
|
from rich.text import Text
|
|
54
56
|
|
|
55
57
|
from autodev.font import render_block_text
|
|
58
|
+
from autodev._version import __version__
|
|
56
59
|
|
|
57
60
|
console = Console()
|
|
58
61
|
|
|
59
|
-
APP_VERSION =
|
|
62
|
+
APP_VERSION = __version__
|
|
60
63
|
VERIFIED_MARKER = "STATUS: VERIFIED"
|
|
61
64
|
FIXES_MARKER = "STATUS: NEEDS_FIXES"
|
|
62
65
|
|
|
@@ -92,6 +95,8 @@ CONFIG_DIR = Path(
|
|
|
92
95
|
)
|
|
93
96
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
94
97
|
HISTORY_FILE = CONFIG_DIR / "history"
|
|
98
|
+
REMBER_FILE = CONFIG_DIR / "speiidevrember.json"
|
|
99
|
+
REMBER_MAX = 5
|
|
95
100
|
|
|
96
101
|
PERSISTED_KEYS = (
|
|
97
102
|
"planner",
|
|
@@ -102,7 +107,6 @@ PERSISTED_KEYS = (
|
|
|
102
107
|
"builder_effort",
|
|
103
108
|
"permission_mode",
|
|
104
109
|
"approved_actions",
|
|
105
|
-
"conversation_id",
|
|
106
110
|
)
|
|
107
111
|
|
|
108
112
|
SLASH_COMMANDS = [
|
|
@@ -116,6 +120,7 @@ SLASH_COMMANDS = [
|
|
|
116
120
|
"/perms",
|
|
117
121
|
"/log",
|
|
118
122
|
"/conv",
|
|
123
|
+
"/rember",
|
|
119
124
|
"/diff",
|
|
120
125
|
"/show",
|
|
121
126
|
"/font",
|
|
@@ -141,6 +146,10 @@ class AgentState:
|
|
|
141
146
|
last_status: str = "IDLE"
|
|
142
147
|
git_branch: str = field(default_factory=lambda: get_git_branch())
|
|
143
148
|
conversation_id: str | None = None
|
|
149
|
+
# Locally generated id identifying this terminal session's chat (not the
|
|
150
|
+
# agent's own conversation uuid). Saved to speiidevrember.json on exit.
|
|
151
|
+
session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
152
|
+
last_task: str = ""
|
|
144
153
|
# Pluggable agent roles (keys into PROFILES)
|
|
145
154
|
planner: str = "agy"
|
|
146
155
|
builder: str = "opencode"
|
|
@@ -246,6 +255,58 @@ def _migrate_config(cfg: dict) -> dict:
|
|
|
246
255
|
return m
|
|
247
256
|
|
|
248
257
|
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# REMEMBERED CHATS (~/.config/speiidev/speiidevrember.json)
|
|
260
|
+
# Old conversations are never resumed automatically. Every chat id is written
|
|
261
|
+
# here with a timestamp so it can be recalled explicitly via /rember.
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
def load_rember() -> list[dict]:
|
|
264
|
+
try:
|
|
265
|
+
data = json.loads(REMBER_FILE.read_text(encoding="utf-8"))
|
|
266
|
+
if isinstance(data, list):
|
|
267
|
+
return [e for e in data if isinstance(e, dict) and e.get("id")]
|
|
268
|
+
if isinstance(data, dict) and data.get("id"):
|
|
269
|
+
return [data]
|
|
270
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
271
|
+
pass
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def save_rember(entries: list[dict]) -> None:
|
|
276
|
+
try:
|
|
277
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
tmp = REMBER_FILE.with_suffix(".tmp")
|
|
279
|
+
tmp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
|
280
|
+
tmp.replace(REMBER_FILE)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def remember_conversation(conversation_id: str, label: str = "") -> None:
|
|
286
|
+
"""Record a conversation id + timestamp, newest first, de-duplicated by id."""
|
|
287
|
+
if not conversation_id:
|
|
288
|
+
return
|
|
289
|
+
entry = {
|
|
290
|
+
"id": conversation_id,
|
|
291
|
+
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
292
|
+
"label": label or f"chat {conversation_id[:8]}",
|
|
293
|
+
}
|
|
294
|
+
entries = [e for e in load_rember() if e.get("id") != conversation_id]
|
|
295
|
+
entries.insert(0, entry)
|
|
296
|
+
save_rember(entries[:REMBER_MAX])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def persist_active_chat(state: "AgentState") -> None:
|
|
300
|
+
"""Save this session's chat id to speiidevrember.json on tool exit.
|
|
301
|
+
|
|
302
|
+
Uses the agent conversation uuid when one is set, otherwise the locally
|
|
303
|
+
generated session id, so every session is always remembered.
|
|
304
|
+
"""
|
|
305
|
+
chat_id = state.conversation_id or state.session_id
|
|
306
|
+
label = state.last_task or state.last_agent or ""
|
|
307
|
+
remember_conversation(chat_id, label)
|
|
308
|
+
|
|
309
|
+
|
|
249
310
|
# ============================================================================
|
|
250
311
|
# AGENT PROFILES (pluggable CLI adapters)
|
|
251
312
|
# ============================================================================
|
|
@@ -358,6 +419,14 @@ class AgentProfile:
|
|
|
358
419
|
def build(self, role, prompt, model, effort, allow_tools, plan_file, session_id):
|
|
359
420
|
builder = self.plan_builder if role == "plan" else self.build_builder
|
|
360
421
|
if builder is None:
|
|
422
|
+
# Auto-fallback to a profile that can serve this role (prefer installed ones)
|
|
423
|
+
candidates = installed_for_role(role)
|
|
424
|
+
if candidates:
|
|
425
|
+
fallback = candidates[0]
|
|
426
|
+
fb = PROFILES[fallback]
|
|
427
|
+
builder_fb = fb.plan_builder if role == "plan" else fb.build_builder
|
|
428
|
+
if builder_fb is not None:
|
|
429
|
+
return builder_fb(prompt, model, effort, allow_tools, plan_file, session_id)
|
|
361
430
|
raise ValueError(f"{self.label} cannot act as {role}")
|
|
362
431
|
return builder(prompt, model, effort, allow_tools, plan_file, session_id)
|
|
363
432
|
|
|
@@ -398,6 +467,18 @@ def installed_profiles() -> list[str]:
|
|
|
398
467
|
return [key for key, prof in PROFILES.items() if get_safe_executable(prof.binary)]
|
|
399
468
|
|
|
400
469
|
|
|
470
|
+
def installed_for_role(role: str) -> list[str]:
|
|
471
|
+
"""Installed agent CLIs on this PC that can serve a role ("plan"/"build")."""
|
|
472
|
+
return [k for k in installed_profiles() if role in PROFILES[k].roles]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def first_installed_for(role: str) -> str | None:
|
|
476
|
+
found = installed_profiles()
|
|
477
|
+
for key in found:
|
|
478
|
+
return key
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
|
|
401
482
|
def _preview_cmd(cmd: list[str]) -> str:
|
|
402
483
|
parts: list[str] = []
|
|
403
484
|
for item in cmd:
|
|
@@ -643,10 +724,15 @@ def render_colored_diff_panel(change: FileChange) -> Panel:
|
|
|
643
724
|
f"[bold bright_red]-{removed_count} old[/bold bright_red]"
|
|
644
725
|
)
|
|
645
726
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
727
|
+
w = console.width
|
|
728
|
+
pad_h = 2 if w >= 80 else 1
|
|
729
|
+
if w >= 65:
|
|
730
|
+
legend = Text.from_markup(
|
|
731
|
+
"[bold bright_green on #113311] + Green = New Code Added [/bold bright_green on #113311] "
|
|
732
|
+
"[bold bright_red on #3b1219] - Red = Old Code Removed [/bold bright_red on #3b1219]"
|
|
733
|
+
)
|
|
734
|
+
else:
|
|
735
|
+
legend = Text.from_markup("[green]+ New Code[/green] [red]- Old Code[/red]")
|
|
650
736
|
|
|
651
737
|
return Panel(
|
|
652
738
|
Group(legend, Rule(style="dim"), diff_text),
|
|
@@ -654,7 +740,7 @@ def render_colored_diff_panel(change: FileChange) -> Panel:
|
|
|
654
740
|
title_align="left",
|
|
655
741
|
border_style="bright_magenta",
|
|
656
742
|
box=box.ROUNDED,
|
|
657
|
-
padding=(0,
|
|
743
|
+
padding=(0, pad_h),
|
|
658
744
|
)
|
|
659
745
|
|
|
660
746
|
|
|
@@ -752,6 +838,11 @@ def searchable_select(
|
|
|
752
838
|
if current is not None and current in values:
|
|
753
839
|
st["sel"] = values.index(current)
|
|
754
840
|
|
|
841
|
+
def get_dynamic_height() -> int:
|
|
842
|
+
term_lines = shutil.get_terminal_size((80, 24)).lines
|
|
843
|
+
max_h = max(2, term_lines - 7)
|
|
844
|
+
return min(height, max_h, max(1, len(options)))
|
|
845
|
+
|
|
755
846
|
def recompute(reset: bool = True) -> None:
|
|
756
847
|
q = st["query"].strip().lower()
|
|
757
848
|
if not q:
|
|
@@ -782,11 +873,13 @@ def searchable_select(
|
|
|
782
873
|
res = st["results"]
|
|
783
874
|
if not res:
|
|
784
875
|
return [("class:dim", " (no matches)")]
|
|
785
|
-
win =
|
|
876
|
+
win = get_dynamic_height()
|
|
786
877
|
start = 0
|
|
787
878
|
if st["sel"] >= win:
|
|
788
879
|
start = st["sel"] - win + 1
|
|
789
880
|
end = min(len(res), start + win)
|
|
881
|
+
term_cols = shutil.get_terminal_size((80, 24)).columns
|
|
882
|
+
max_label_w = max(10, term_cols - 14)
|
|
790
883
|
lines = []
|
|
791
884
|
for pos in range(start, end):
|
|
792
885
|
value, label = options[res[pos]]
|
|
@@ -795,7 +888,10 @@ def searchable_select(
|
|
|
795
888
|
mark = " (current)" if is_cur else ""
|
|
796
889
|
cursor = "> " if is_sel else " "
|
|
797
890
|
cls = "sel" if is_sel else ("current" if is_cur else "item")
|
|
798
|
-
|
|
891
|
+
disp = label
|
|
892
|
+
if len(disp) + len(mark) > max_label_w:
|
|
893
|
+
disp = disp[: max(1, max_label_w - len(mark) - 1)] + "…"
|
|
894
|
+
lines.append((f"class:{cls}", f"{cursor}{disp}{mark}\n"))
|
|
799
895
|
return lines
|
|
800
896
|
|
|
801
897
|
def status_text():
|
|
@@ -804,7 +900,10 @@ def searchable_select(
|
|
|
804
900
|
return [("class:dim", f" {pos}/{n}")]
|
|
805
901
|
|
|
806
902
|
def footer_text():
|
|
807
|
-
|
|
903
|
+
term_cols = shutil.get_terminal_size((80, 24)).columns
|
|
904
|
+
if term_cols < 55:
|
|
905
|
+
return [("class:footer", " ↑/↓ Enter Esc")]
|
|
906
|
+
return [("class:footer", f" Keyboard: {footer}")]
|
|
808
907
|
|
|
809
908
|
style = Style.from_dict(
|
|
810
909
|
{
|
|
@@ -830,7 +929,7 @@ def searchable_select(
|
|
|
830
929
|
Window(char="─", height=1, style="class:sep"),
|
|
831
930
|
Window(
|
|
832
931
|
FormattedTextControl(list_text),
|
|
833
|
-
height=
|
|
932
|
+
height=get_dynamic_height,
|
|
834
933
|
),
|
|
835
934
|
Window(FormattedTextControl(status_text), height=1),
|
|
836
935
|
Window(FormattedTextControl(footer_text), height=1),
|
|
@@ -856,14 +955,16 @@ def searchable_select(
|
|
|
856
955
|
|
|
857
956
|
@kb.add("pageup")
|
|
858
957
|
def _(event):
|
|
958
|
+
win = get_dynamic_height()
|
|
859
959
|
if st["results"]:
|
|
860
|
-
st["sel"] = max(0, st["sel"] -
|
|
960
|
+
st["sel"] = max(0, st["sel"] - win)
|
|
861
961
|
event.app.invalidate()
|
|
862
962
|
|
|
863
963
|
@kb.add("pagedown")
|
|
864
964
|
def _(event):
|
|
965
|
+
win = get_dynamic_height()
|
|
865
966
|
if st["results"]:
|
|
866
|
-
st["sel"] = min(len(st["results"]) - 1, st["sel"] +
|
|
967
|
+
st["sel"] = min(len(st["results"]) - 1, st["sel"] + win)
|
|
867
968
|
event.app.invalidate()
|
|
868
969
|
|
|
869
970
|
@kb.add("enter")
|
|
@@ -905,6 +1006,7 @@ def searchable_select(
|
|
|
905
1006
|
style=style,
|
|
906
1007
|
full_screen=False,
|
|
907
1008
|
mouse_support=False,
|
|
1009
|
+
terminal_size_polling_interval=0.5,
|
|
908
1010
|
).run()
|
|
909
1011
|
except (EOFError, KeyboardInterrupt):
|
|
910
1012
|
return None
|
|
@@ -935,15 +1037,22 @@ def request_permission(
|
|
|
935
1037
|
f"[{THEME['dim']}]Use arrow keys to choose, Enter to confirm.[/]"
|
|
936
1038
|
),
|
|
937
1039
|
)
|
|
1040
|
+
w = console.width
|
|
1041
|
+
pad_h = 2 if w >= 80 else 1
|
|
1042
|
+
panel_title = (
|
|
1043
|
+
f"[bold {THEME['auditor']}]🔐 Permissions requested[/bold {THEME['auditor']}] "
|
|
1044
|
+
f"[{THEME['dim']}]{title}[/]"
|
|
1045
|
+
if w >= 70
|
|
1046
|
+
else f"[bold {THEME['auditor']}]🔐 Permissions[/]"
|
|
1047
|
+
)
|
|
938
1048
|
console.print(
|
|
939
1049
|
Panel(
|
|
940
1050
|
body,
|
|
941
|
-
title=
|
|
942
|
-
f"[{THEME['dim']}]{title}[/]",
|
|
1051
|
+
title=panel_title,
|
|
943
1052
|
title_align="left",
|
|
944
1053
|
border_style=THEME["auditor"],
|
|
945
1054
|
box=box.ROUNDED,
|
|
946
|
-
padding=(0,
|
|
1055
|
+
padding=(0, pad_h),
|
|
947
1056
|
)
|
|
948
1057
|
)
|
|
949
1058
|
|
|
@@ -1062,25 +1171,39 @@ def run_splash(state: AgentState) -> None:
|
|
|
1062
1171
|
if not console.is_terminal:
|
|
1063
1172
|
return
|
|
1064
1173
|
|
|
1065
|
-
cabbage = render_bok_choy_logo()
|
|
1066
1174
|
boot_steps = [
|
|
1067
1175
|
("booting orchestrator", "kernel"),
|
|
1068
1176
|
("linking architect", "agy · gemini"),
|
|
1069
1177
|
("linking builder", "opencode"),
|
|
1070
1178
|
("waking workspace", Path.cwd().name),
|
|
1071
1179
|
]
|
|
1072
|
-
width = 36
|
|
1073
1180
|
|
|
1074
1181
|
try:
|
|
1075
1182
|
with Live(
|
|
1076
1183
|
console=console, refresh_per_second=24, transient=True
|
|
1077
1184
|
) as live:
|
|
1078
1185
|
for idx, (label, tag) in enumerate(boot_steps, start=1):
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1186
|
+
w = console.width
|
|
1187
|
+
h = console.size.height or 24
|
|
1188
|
+
|
|
1189
|
+
# Responsive title typography
|
|
1190
|
+
if w >= 80:
|
|
1191
|
+
art = render_block_text("SPEIIDEV", style="claude", color="none")
|
|
1192
|
+
title_node = Text.from_markup(f"[bold {THEME['primary']}]{art}[/]")
|
|
1193
|
+
elif w >= 52:
|
|
1194
|
+
art = render_block_text("SPEIIDEV", style="mini", color="none")
|
|
1195
|
+
title_node = Text.from_markup(f"[bold {THEME['primary']}]{art}[/]")
|
|
1196
|
+
else:
|
|
1197
|
+
title_node = Text.from_markup(
|
|
1198
|
+
f"[bold {THEME['primary']}]SPEIIDEV[/bold {THEME['primary']}] "
|
|
1199
|
+
f"[{THEME['dim']}]v{APP_VERSION}[/]"
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
show_full_cabbage = (w >= 80 and h >= 22)
|
|
1203
|
+
bar_width = max(10, min(36, w - 46 if show_full_cabbage else w - 24))
|
|
1204
|
+
filled = int(bar_width * idx / len(boot_steps))
|
|
1205
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
1206
|
+
|
|
1084
1207
|
status_body = Group(
|
|
1085
1208
|
Text.from_markup(
|
|
1086
1209
|
f"[bold {THEME['primary']}]SPEIIDEV[/bold {THEME['primary']}] "
|
|
@@ -1095,24 +1218,39 @@ def run_splash(state: AgentState) -> None:
|
|
|
1095
1218
|
f" [{THEME['dim']}]{label}[/] "
|
|
1096
1219
|
f"[{THEME['architect']}]{tag}[/]"
|
|
1097
1220
|
),
|
|
1098
|
-
Text.from_markup(
|
|
1099
|
-
f"\n[{THEME['dim']}]Initializing neural coding environment…[/]"
|
|
1100
|
-
),
|
|
1101
1221
|
)
|
|
1102
|
-
|
|
1222
|
+
if h >= 20:
|
|
1223
|
+
status_body = Group(
|
|
1224
|
+
status_body,
|
|
1225
|
+
Text.from_markup(
|
|
1226
|
+
f"\n[{THEME['dim']}]Initializing neural coding environment…[/]"
|
|
1227
|
+
),
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
if show_full_cabbage:
|
|
1231
|
+
cabbage = render_bok_choy_logo()
|
|
1232
|
+
status_grid = Table.grid(padding=(0, 2 if w < 86 else 3))
|
|
1233
|
+
status_grid.add_column(justify="left")
|
|
1234
|
+
status_grid.add_column(justify="left", ratio=1)
|
|
1235
|
+
status_grid.add_row(cabbage, status_body)
|
|
1236
|
+
splash_content = status_grid
|
|
1237
|
+
else:
|
|
1238
|
+
splash_content = status_body
|
|
1103
1239
|
|
|
1104
1240
|
body = Group(
|
|
1105
|
-
|
|
1241
|
+
title_node,
|
|
1106
1242
|
Rule(style=THEME["dim"]),
|
|
1107
|
-
|
|
1243
|
+
splash_content,
|
|
1108
1244
|
)
|
|
1109
1245
|
|
|
1246
|
+
pad_h = 2 if w >= 86 else 1
|
|
1247
|
+
pad_v = 1 if (w >= 80 and h >= 22) else 0
|
|
1110
1248
|
live.update(
|
|
1111
1249
|
Panel(
|
|
1112
1250
|
body,
|
|
1113
1251
|
box=box.ROUNDED,
|
|
1114
1252
|
border_style=THEME["primary"],
|
|
1115
|
-
padding=(
|
|
1253
|
+
padding=(pad_v, pad_h),
|
|
1116
1254
|
)
|
|
1117
1255
|
)
|
|
1118
1256
|
time.sleep(0.24)
|
|
@@ -1122,68 +1260,311 @@ def run_splash(state: AgentState) -> None:
|
|
|
1122
1260
|
|
|
1123
1261
|
|
|
1124
1262
|
def print_banner(state: AgentState) -> None:
|
|
1125
|
-
|
|
1263
|
+
w = console.width
|
|
1264
|
+
h = console.size.height or 24
|
|
1265
|
+
|
|
1266
|
+
# 1. Responsive Title Art
|
|
1267
|
+
if w >= 80:
|
|
1268
|
+
art = render_block_text("SPEIIDEV", style="claude", color="none")
|
|
1269
|
+
title_renderable = Text.from_markup(f"[bold {THEME['primary']}]{art}[/]")
|
|
1270
|
+
elif w >= 52:
|
|
1271
|
+
art = render_block_text("SPEIIDEV", style="mini", color="none")
|
|
1272
|
+
title_renderable = Text.from_markup(f"[bold {THEME['primary']}]{art}[/]")
|
|
1273
|
+
else:
|
|
1274
|
+
title_renderable = Text.from_markup(
|
|
1275
|
+
f"[bold {THEME['primary']}]SPEIIDEV[/bold {THEME['primary']}] "
|
|
1276
|
+
f"[{THEME['dim']}]v{APP_VERSION}[/]"
|
|
1277
|
+
)
|
|
1126
1278
|
|
|
1279
|
+
# 2. Subtitle
|
|
1280
|
+
if w >= 60:
|
|
1281
|
+
sub = Text.from_markup(
|
|
1282
|
+
f"\n[bold {THEME['primary']}]SPEIIDEV[/bold {THEME['primary']}] "
|
|
1283
|
+
f"[{THEME['dim']}]v{APP_VERSION}[/{THEME['dim']}] · "
|
|
1284
|
+
f"[bold {THEME['text']}]Autonomous Multi-Agent Orchestrator[/bold {THEME['text']}]"
|
|
1285
|
+
)
|
|
1286
|
+
else:
|
|
1287
|
+
sub = Text.from_markup(
|
|
1288
|
+
f"[bold {THEME['text']}]Autonomous Multi-Agent Orchestrator[/bold {THEME['text']}]"
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
banner_items: list = [title_renderable, sub, Rule(style=THEME["dim"])]
|
|
1292
|
+
|
|
1293
|
+
# 3. Active conversation string
|
|
1294
|
+
active_chat = state.conversation_id or state.session_id
|
|
1127
1295
|
conv_display = (
|
|
1128
|
-
f"[{THEME['ok']}]{
|
|
1296
|
+
f"[{THEME['ok']}]{active_chat[:8]}…[/{THEME['ok']}]"
|
|
1129
1297
|
if state.conversation_id
|
|
1130
|
-
else f"[{THEME['dim']}]new
|
|
1298
|
+
else f"[{THEME['dim']}]{active_chat[:8]}… (new)[/{THEME['dim']}]"
|
|
1131
1299
|
)
|
|
1132
1300
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1301
|
+
# 4. Mascot and Workspace Info
|
|
1302
|
+
show_full_cabbage = (w >= 80 and h >= 22)
|
|
1303
|
+
if show_full_cabbage:
|
|
1304
|
+
cabbage = render_bok_choy_logo()
|
|
1305
|
+
info_grid = Table.grid(expand=True)
|
|
1306
|
+
info_grid.add_column(justify="left", ratio=1)
|
|
1307
|
+
info_grid.add_column(justify="right")
|
|
1308
|
+
|
|
1309
|
+
repo_name = Path.cwd().name
|
|
1310
|
+
branch_name = state.git_branch
|
|
1311
|
+
max_repo = 16 if w < 95 else 24
|
|
1312
|
+
if len(repo_name) > max_repo:
|
|
1313
|
+
repo_name = repo_name[: max_repo - 1] + "…"
|
|
1314
|
+
max_branch = 12 if w < 95 else 20
|
|
1315
|
+
if len(branch_name) > max_branch:
|
|
1316
|
+
branch_name = branch_name[: max_branch - 1] + "…"
|
|
1317
|
+
|
|
1318
|
+
if w >= 95:
|
|
1319
|
+
plan_line = (
|
|
1320
|
+
f"[{THEME['architect']}]◆ plan [/] [{THEME['text']}]{state.planner}[/] "
|
|
1321
|
+
f"[{THEME['dim']}]{state.planner_model} · {state.planner_effort}[/]"
|
|
1322
|
+
)
|
|
1323
|
+
build_line = (
|
|
1324
|
+
f"[{THEME['builder']}]◆ build[/] [{THEME['text']}]{state.builder}[/] "
|
|
1325
|
+
f"[{THEME['dim']}]{state.builder_model} · {state.builder_effort}[/]"
|
|
1326
|
+
)
|
|
1327
|
+
else:
|
|
1328
|
+
plan_line = (
|
|
1329
|
+
f"[{THEME['architect']}]◆ plan [/] [{THEME['text']}]{state.planner}[/] "
|
|
1330
|
+
f"[{THEME['dim']}]{state.planner_model}[/]"
|
|
1331
|
+
)
|
|
1332
|
+
build_line = (
|
|
1333
|
+
f"[{THEME['builder']}]◆ build[/] [{THEME['text']}]{state.builder}[/] "
|
|
1334
|
+
f"[{THEME['dim']}]{state.builder_model}[/]"
|
|
1335
|
+
)
|
|
1152
1336
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1337
|
+
left_info = (
|
|
1338
|
+
f"[{THEME['dim']}]repo [/][bold {THEME['text']}]{repo_name}[/bold {THEME['text']}] "
|
|
1339
|
+
f"[{THEME['dim']}]on[/] [{THEME['ok']}]{branch_name}[/{THEME['ok']}]\n"
|
|
1340
|
+
f"[{THEME['dim']}]chat [/]{conv_display}\n"
|
|
1341
|
+
f"{plan_line}\n"
|
|
1342
|
+
f"{build_line}"
|
|
1343
|
+
)
|
|
1344
|
+
right_info = (
|
|
1345
|
+
f"[{THEME['dim']}]gate [/]{_mode_badge(state.permission_mode)}\n"
|
|
1346
|
+
f"[{THEME['dim']}]agents[/] [{THEME['text']}]{state.planner} · {state.builder}[/]\n\n"
|
|
1347
|
+
f"[yellow]F2[/] [{THEME['dim']}]menu ·[/] [yellow]F3[/] [{THEME['dim']}]activity[/]"
|
|
1348
|
+
)
|
|
1349
|
+
info_grid.add_row(Text.from_markup(left_info), Text.from_markup(right_info))
|
|
1350
|
+
|
|
1351
|
+
meta_grid = Table.grid(padding=(0, 2 if w < 86 else 3))
|
|
1352
|
+
meta_grid.add_column(justify="left")
|
|
1353
|
+
meta_grid.add_column(justify="left", ratio=1)
|
|
1354
|
+
meta_grid.add_row(cabbage, info_grid)
|
|
1355
|
+
banner_items.append(meta_grid)
|
|
1356
|
+
elif w >= 65 and h >= 20:
|
|
1357
|
+
cabbage = render_bok_choy_logo()
|
|
1358
|
+
repo_name = Path.cwd().name
|
|
1359
|
+
branch_name = state.git_branch
|
|
1360
|
+
if len(repo_name) > 16:
|
|
1361
|
+
repo_name = repo_name[:15] + "…"
|
|
1362
|
+
if len(branch_name) > 12:
|
|
1363
|
+
branch_name = branch_name[:11] + "…"
|
|
1364
|
+
info_lines = [
|
|
1365
|
+
f"[{THEME['dim']}]repo [/][bold {THEME['text']}]{repo_name}[/] [{THEME['dim']}]on[/] [{THEME['ok']}]{branch_name}[/]",
|
|
1366
|
+
f"[{THEME['dim']}]chat [/]{conv_display}",
|
|
1367
|
+
f"[{THEME['architect']}]plan [/] [{THEME['text']}]{state.planner}[/] [{THEME['dim']}]{state.planner_model}[/]",
|
|
1368
|
+
f"[{THEME['builder']}]build[/] [{THEME['text']}]{state.builder}[/] [{THEME['dim']}]{state.builder_model}[/]",
|
|
1369
|
+
f"[{THEME['dim']}]gate [/]{_mode_badge(state.permission_mode)}",
|
|
1370
|
+
f"[{THEME['dim']}]agents[/] [{THEME['text']}]{state.planner} · {state.builder}[/]",
|
|
1371
|
+
"",
|
|
1372
|
+
f"[yellow]F2[/] [{THEME['dim']}]menu ·[/] [yellow]F3[/] [{THEME['dim']}]activity[/]",
|
|
1373
|
+
]
|
|
1374
|
+
meta_grid = Table.grid(padding=(0, 2))
|
|
1375
|
+
meta_grid.add_column(justify="left")
|
|
1376
|
+
meta_grid.add_column(justify="left", ratio=1)
|
|
1377
|
+
meta_grid.add_row(cabbage, Text.from_markup("\n".join(info_lines)))
|
|
1378
|
+
banner_items.append(meta_grid)
|
|
1379
|
+
else:
|
|
1380
|
+
compact_info = (
|
|
1381
|
+
f"[{THEME['dim']}]repo: [/][bold {THEME['text']}]{Path.cwd().name}[/] [{THEME['dim']}]({state.git_branch})[/] "
|
|
1382
|
+
f"[{THEME['dim']}]chat: [/]{conv_display}\n"
|
|
1383
|
+
f"[{THEME['architect']}]plan: [/][{THEME['text']}]{state.planner}[/] "
|
|
1384
|
+
f"[{THEME['builder']}]build: [/][{THEME['text']}]{state.builder}[/] "
|
|
1385
|
+
f"[{THEME['dim']}]gate: [/]{_mode_badge(state.permission_mode)}\n"
|
|
1386
|
+
f"[yellow]F2[/] [{THEME['dim']}]menu ·[/] [yellow]F3[/] [{THEME['dim']}]activity[/]"
|
|
1387
|
+
)
|
|
1388
|
+
banner_items.append(Text.from_markup(compact_info))
|
|
1157
1389
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
Rule(style=THEME["dim"]),
|
|
1166
|
-
meta_grid,
|
|
1167
|
-
)
|
|
1390
|
+
if w >= 75 and h >= 32:
|
|
1391
|
+
scan_table = build_agent_scan_table(state)
|
|
1392
|
+
if scan_table is not None:
|
|
1393
|
+
banner_items.extend([Rule(style=THEME["dim"]), scan_table])
|
|
1394
|
+
|
|
1395
|
+
pad_h = 2 if w >= 86 else 1
|
|
1396
|
+
pad_v = 1 if (w >= 80 and h >= 26) else 0
|
|
1168
1397
|
|
|
1169
1398
|
console.print(
|
|
1170
1399
|
Panel(
|
|
1171
|
-
|
|
1400
|
+
Group(*banner_items),
|
|
1172
1401
|
box=box.ROUNDED,
|
|
1173
1402
|
border_style=THEME["primary"],
|
|
1174
|
-
padding=(
|
|
1403
|
+
padding=(pad_v, pad_h),
|
|
1175
1404
|
title=f"[{THEME['dim']}]◆ speiidev workspace ◆[/{THEME['dim']}]",
|
|
1176
1405
|
title_align="left",
|
|
1177
1406
|
)
|
|
1178
1407
|
)
|
|
1179
1408
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1409
|
+
if w >= 60:
|
|
1410
|
+
console.print(
|
|
1411
|
+
f" [{THEME['dim']}]Type a task, or use[/] "
|
|
1412
|
+
f"[{THEME['primary']}]/agents[/] [{THEME['dim']}]·[/] "
|
|
1413
|
+
f"[{THEME['primary']}]/model[/] [{THEME['dim']}]·[/] [{THEME['primary']}]/effort[/] "
|
|
1414
|
+
f"[{THEME['dim']}]·[/] [{THEME['primary']}]/perms[/] [{THEME['dim']}]·[/] "
|
|
1415
|
+
f"[{THEME['primary']}]/help[/{THEME['primary']}]\n"
|
|
1416
|
+
)
|
|
1417
|
+
else:
|
|
1418
|
+
console.print(
|
|
1419
|
+
f" [{THEME['dim']}]Type a task, or[/] [{THEME['primary']}]/help[/{THEME['primary']}]\n"
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
_STREAM_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
1424
|
+
_STREAM_PHRASES = (
|
|
1425
|
+
"Booting agent runtime",
|
|
1426
|
+
"Reading workspace context",
|
|
1427
|
+
"Reasoning about the task",
|
|
1428
|
+
"Planning the next step",
|
|
1429
|
+
"Writing files",
|
|
1430
|
+
"Running tools",
|
|
1431
|
+
"Reviewing output",
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _stream_bar(elapsed: float, width: int = 22) -> str:
|
|
1436
|
+
"""Indeterminate scrolling bar so the terminal never looks frozen."""
|
|
1437
|
+
pos = int(elapsed * 7) % (width + 4)
|
|
1438
|
+
cells = []
|
|
1439
|
+
for i in range(width):
|
|
1440
|
+
d = abs(i - pos)
|
|
1441
|
+
cells.append("█" if d == 0 else "▓" if d == 1 else "▒" if d == 2 else "░")
|
|
1442
|
+
return "".join(cells)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
class _AgentStreamView:
|
|
1446
|
+
"""Self-animating Rich renderable for a running agent subprocess.
|
|
1447
|
+
|
|
1448
|
+
Rich's Live auto-refresh re-invokes ``__rich_console__`` on every frame, so
|
|
1449
|
+
the spinner, status phrase, scrolling bar and blinking cursor animate without
|
|
1450
|
+
any manual ``live.update`` calls (which keeps it smooth and cheap).
|
|
1451
|
+
"""
|
|
1452
|
+
|
|
1453
|
+
def __init__(self, cmd, title, border_color, badge, attempt, state, lines, activity):
|
|
1454
|
+
self.cmd = cmd
|
|
1455
|
+
self.cmd_preview = _preview_cmd(cmd) if cmd else ""
|
|
1456
|
+
self.title = title
|
|
1457
|
+
self.border_color = border_color
|
|
1458
|
+
self.badge = badge
|
|
1459
|
+
self.attempt = attempt
|
|
1460
|
+
self.state = state
|
|
1461
|
+
self.lines = lines
|
|
1462
|
+
self.activity = activity
|
|
1463
|
+
self.start_time = time.monotonic()
|
|
1464
|
+
self.done = False
|
|
1465
|
+
self.exit_code = 0
|
|
1466
|
+
|
|
1467
|
+
def __rich_console__(self, console, options):
|
|
1468
|
+
yield self._panel(time.monotonic() - self.start_time, console, options)
|
|
1469
|
+
|
|
1470
|
+
def _panel(self, elapsed: float, console: Console, options=None) -> Panel:
|
|
1471
|
+
color = self.border_color
|
|
1472
|
+
frame = _STREAM_SPINNER[int(elapsed * 12) % len(_STREAM_SPINNER)]
|
|
1473
|
+
blink = "▋" if int(elapsed * 2) % 2 == 0 else " "
|
|
1474
|
+
|
|
1475
|
+
w = (options.max_width if options else None) or console.width or 80
|
|
1476
|
+
h = console.size.height or 24
|
|
1477
|
+
pad_h = 2 if w >= 80 else 1
|
|
1478
|
+
inner_w = max(16, w - (pad_h * 2 + 4))
|
|
1479
|
+
|
|
1480
|
+
if not self.done:
|
|
1481
|
+
phrase = _STREAM_PHRASES[int(elapsed / 1.6) % len(_STREAM_PHRASES)]
|
|
1482
|
+
if w >= 70:
|
|
1483
|
+
header = Table.grid(expand=True)
|
|
1484
|
+
header.add_column(ratio=1)
|
|
1485
|
+
header.add_column(justify="right")
|
|
1486
|
+
header.add_row(
|
|
1487
|
+
Text.from_markup(
|
|
1488
|
+
f"[bold {color}]{frame} {self.badge}[/bold {color}] "
|
|
1489
|
+
f"{self.title} (Attempt {self.attempt})"
|
|
1490
|
+
),
|
|
1491
|
+
Text.from_markup(f"[dim]{elapsed:.1f}s[/dim]"),
|
|
1492
|
+
)
|
|
1493
|
+
else:
|
|
1494
|
+
short_title = self.title if len(self.title) <= 22 else self.title[:21] + "…"
|
|
1495
|
+
header = Text.from_markup(
|
|
1496
|
+
f"[bold {color}]{frame} {self.badge}[/bold {color}] {short_title} [dim]{elapsed:.0f}s[/dim]"
|
|
1497
|
+
)
|
|
1498
|
+
else:
|
|
1499
|
+
icon = "✅" if self.exit_code == 0 else "❌"
|
|
1500
|
+
if w >= 60:
|
|
1501
|
+
header = Text.from_markup(
|
|
1502
|
+
f"{icon} [bold {color}]{self.badge}[/bold {color}] {self.title} "
|
|
1503
|
+
f"[dim](completed in {elapsed:.1f}s)[/dim]"
|
|
1504
|
+
)
|
|
1505
|
+
else:
|
|
1506
|
+
header = Text.from_markup(
|
|
1507
|
+
f"{icon} [bold {color}]{self.badge}[/bold {color}] [dim]{elapsed:.0f}s[/dim]"
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
# Single-line command preview truncated with ellipsis
|
|
1511
|
+
cmd_shown = self.cmd_preview
|
|
1512
|
+
if len(cmd_shown) > inner_w - 4:
|
|
1513
|
+
cmd_shown = cmd_shown[: max(1, inner_w - 5)] + "…"
|
|
1514
|
+
proc_line = Text(f"$ {cmd_shown}", style=THEME["dim"], no_wrap=True)
|
|
1515
|
+
|
|
1516
|
+
# Clamping lines vertically to prevent terminal overflow and Live scroll desync
|
|
1517
|
+
overhead = 8 if (self.activity and h >= 16) else 7
|
|
1518
|
+
max_body_lines = max(2, min(14, h - overhead - 2))
|
|
1519
|
+
|
|
1520
|
+
if self.lines:
|
|
1521
|
+
visible = list(self.lines)[-max_body_lines:]
|
|
1522
|
+
body = Text()
|
|
1523
|
+
for idx, raw_line in enumerate(visible):
|
|
1524
|
+
clean = _strip_ansi(raw_line)
|
|
1525
|
+
if len(clean) > inner_w:
|
|
1526
|
+
clean = clean[: max(1, inner_w - 1)] + "…"
|
|
1527
|
+
body.append(Text.from_ansi(clean))
|
|
1528
|
+
if idx < len(visible) - 1:
|
|
1529
|
+
body.append("\n")
|
|
1530
|
+
if not self.done:
|
|
1531
|
+
body.append(f" {blink}", style=color)
|
|
1532
|
+
elif self.done:
|
|
1533
|
+
body = Text.from_markup(f"[{THEME['dim']}]No output.[/]")
|
|
1534
|
+
else:
|
|
1535
|
+
bar_len = max(8, min(22, inner_w - 32))
|
|
1536
|
+
bar = _stream_bar(elapsed, width=bar_len)
|
|
1537
|
+
if w >= 65:
|
|
1538
|
+
body = Text.from_markup(
|
|
1539
|
+
f"[{color}]{frame}[/] [{THEME['text']}]{phrase}[/]… "
|
|
1540
|
+
f"[{THEME['dim']}]{bar}[/] {blink}"
|
|
1541
|
+
)
|
|
1542
|
+
else:
|
|
1543
|
+
body = Text.from_markup(
|
|
1544
|
+
f"[{color}]{frame}[/] [{THEME['text']}]{phrase}[/]… {blink}"
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
rows: list = [header, Rule(style="dim"), proc_line, Rule(style="dim"), body]
|
|
1548
|
+
|
|
1549
|
+
if self.activity and h >= 16:
|
|
1550
|
+
if not self.done:
|
|
1551
|
+
rows.append(
|
|
1552
|
+
Text.from_markup(
|
|
1553
|
+
f"[{THEME['dim']}]● {len(self.activity)} file(s) touched[/]"
|
|
1554
|
+
)
|
|
1555
|
+
)
|
|
1556
|
+
if w >= 60 and h >= 18:
|
|
1557
|
+
rows.append(
|
|
1558
|
+
Text.from_markup(f"[{THEME['dim']}]full activity → /log[/] ")
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
border = color if (not self.done or self.exit_code == 0) else "red"
|
|
1562
|
+
return Panel(
|
|
1563
|
+
Group(*rows),
|
|
1564
|
+
box=box.ROUNDED,
|
|
1565
|
+
border_style=border,
|
|
1566
|
+
padding=(0, pad_h),
|
|
1567
|
+
)
|
|
1187
1568
|
|
|
1188
1569
|
|
|
1189
1570
|
def stream_agent_box(
|
|
@@ -1219,7 +1600,6 @@ def stream_agent_box(
|
|
|
1219
1600
|
|
|
1220
1601
|
for attempt in range(1, max_retries + 1):
|
|
1221
1602
|
lines: deque[str] = deque(maxlen=18)
|
|
1222
|
-
start_time = time.monotonic()
|
|
1223
1603
|
|
|
1224
1604
|
env = os.environ.copy()
|
|
1225
1605
|
env["PYTHONUNBUFFERED"] = "1"
|
|
@@ -1238,64 +1618,30 @@ def stream_agent_box(
|
|
|
1238
1618
|
shell=is_windows,
|
|
1239
1619
|
)
|
|
1240
1620
|
|
|
1241
|
-
def render_panel(done: bool = False, exit_code: int = 0) -> Panel:
|
|
1242
|
-
elapsed = time.monotonic() - start_time
|
|
1243
|
-
body_text = "\n".join(lines) if lines else "Waiting for agent stream..."
|
|
1244
|
-
body = Text.from_ansi(body_text)
|
|
1245
|
-
|
|
1246
|
-
files_badge = (
|
|
1247
|
-
Text.from_markup(f"[dim]● {len(activity)} file(s)[/dim] ")
|
|
1248
|
-
if activity
|
|
1249
|
-
else Text("")
|
|
1250
|
-
)
|
|
1251
|
-
|
|
1252
|
-
if not done:
|
|
1253
|
-
header = Table.grid(expand=True)
|
|
1254
|
-
header.add_column(ratio=1)
|
|
1255
|
-
header.add_column(justify="right")
|
|
1256
|
-
header.add_row(
|
|
1257
|
-
Spinner(
|
|
1258
|
-
"dots",
|
|
1259
|
-
text=Text.from_markup(f"[bold {border_color}]{badge}[/bold {border_color}] {title} (Attempt {attempt})"),
|
|
1260
|
-
),
|
|
1261
|
-
Text.from_markup(f"[dim]{elapsed:.1f}s[/dim]"),
|
|
1262
|
-
)
|
|
1263
|
-
content = Group(header, Rule(style="dim"), body)
|
|
1264
|
-
else:
|
|
1265
|
-
icon = "✅" if exit_code == 0 else "❌"
|
|
1266
|
-
status_line = Text.from_markup(
|
|
1267
|
-
f"{icon} [bold {border_color}]{badge}[/bold {border_color}] {title} "
|
|
1268
|
-
f"[dim](completed in {elapsed:.1f}s)[/dim]"
|
|
1269
|
-
)
|
|
1270
|
-
content = Group(status_line, Rule(style="dim"), body)
|
|
1271
|
-
|
|
1272
|
-
footer = (
|
|
1273
|
-
Text.from_markup(
|
|
1274
|
-
f"[{THEME['dim']}]full activity → /log[/]"
|
|
1275
|
-
)
|
|
1276
|
-
if activity
|
|
1277
|
-
else Text("")
|
|
1278
|
-
)
|
|
1279
|
-
parts = [content, files_badge, footer] if activity else [content]
|
|
1280
|
-
return Panel(
|
|
1281
|
-
Group(*parts),
|
|
1282
|
-
box=box.ROUNDED,
|
|
1283
|
-
border_style=border_color if not done or exit_code == 0 else "red",
|
|
1284
|
-
padding=(0, 2),
|
|
1285
|
-
)
|
|
1286
|
-
|
|
1287
1621
|
try:
|
|
1288
|
-
|
|
1622
|
+
view = _AgentStreamView(
|
|
1623
|
+
cmd=exec_cmd,
|
|
1624
|
+
title=title,
|
|
1625
|
+
border_color=border_color,
|
|
1626
|
+
badge=badge,
|
|
1627
|
+
attempt=attempt,
|
|
1628
|
+
state=state,
|
|
1629
|
+
lines=lines,
|
|
1630
|
+
activity=activity,
|
|
1631
|
+
)
|
|
1632
|
+
with Live(view, console=console, refresh_per_second=20) as live:
|
|
1289
1633
|
assert proc.stdout is not None
|
|
1290
1634
|
for raw_line in proc.stdout:
|
|
1291
1635
|
clean = raw_line.rstrip("\r\n")
|
|
1292
1636
|
if clean:
|
|
1293
1637
|
ingest(clean)
|
|
1294
1638
|
lines.append(_fmt_activity(clean))
|
|
1295
|
-
|
|
1296
|
-
|
|
1639
|
+
# Auto-refresh via Live thread is enough; optional tiny refresh
|
|
1640
|
+
live.refresh()
|
|
1297
1641
|
proc.wait(timeout=timeout)
|
|
1298
|
-
|
|
1642
|
+
view.done = True
|
|
1643
|
+
view.exit_code = proc.returncode or 0
|
|
1644
|
+
live.refresh()
|
|
1299
1645
|
finalize()
|
|
1300
1646
|
|
|
1301
1647
|
if proc.returncode == 0:
|
|
@@ -1327,13 +1673,19 @@ def show_plan_card(plan_file: Path) -> None:
|
|
|
1327
1673
|
console.print(f"[yellow]No {plan_file} found in current directory.[/yellow]")
|
|
1328
1674
|
return
|
|
1329
1675
|
content = plan_file.read_text(encoding="utf-8")
|
|
1676
|
+
pad_h = 2 if console.width >= 80 else 1
|
|
1677
|
+
card_title = (
|
|
1678
|
+
f"[bold yellow]📋 {plan_file.name} (Shared Agent Spec)[/bold yellow]"
|
|
1679
|
+
if console.width >= 60
|
|
1680
|
+
else f"[bold yellow]📋 {plan_file.name}[/]"
|
|
1681
|
+
)
|
|
1330
1682
|
console.print(
|
|
1331
1683
|
Panel(
|
|
1332
1684
|
Markdown(content),
|
|
1333
|
-
title=
|
|
1685
|
+
title=card_title,
|
|
1334
1686
|
border_style="yellow",
|
|
1335
1687
|
box=box.ROUNDED,
|
|
1336
|
-
padding=(1,
|
|
1688
|
+
padding=(1, pad_h),
|
|
1337
1689
|
)
|
|
1338
1690
|
)
|
|
1339
1691
|
|
|
@@ -1344,6 +1696,8 @@ def show_plan_card(plan_file: Path) -> None:
|
|
|
1344
1696
|
|
|
1345
1697
|
def step_plan(state: AgentState, task: str) -> bool:
|
|
1346
1698
|
profile = state.profile("plan")
|
|
1699
|
+
state.last_task = task
|
|
1700
|
+
persist_active_chat(state)
|
|
1347
1701
|
prompt = (
|
|
1348
1702
|
f"Create a phased implementation plan in {state.plan_file} for: {task}.\n"
|
|
1349
1703
|
"Requirements:\n"
|
|
@@ -1706,7 +2060,7 @@ def _ask_role(state: AgentState, role: str, first_run: bool = False) -> None:
|
|
|
1706
2060
|
model_attr = "planner_model" if role == "plan" else "builder_model"
|
|
1707
2061
|
effort_attr = "planner_effort" if role == "plan" else "builder_effort"
|
|
1708
2062
|
current = getattr(state, attr)
|
|
1709
|
-
candidates =
|
|
2063
|
+
candidates = installed_profiles()
|
|
1710
2064
|
role_label = "Planner / Auditor" if role == "plan" else "Builder"
|
|
1711
2065
|
|
|
1712
2066
|
table = Table(
|
|
@@ -1718,7 +2072,7 @@ def _ask_role(state: AgentState, role: str, first_run: bool = False) -> None:
|
|
|
1718
2072
|
table.add_column("Status", justify="center")
|
|
1719
2073
|
table.add_column("About", style=THEME["dim"])
|
|
1720
2074
|
for key, prof in PROFILES.items():
|
|
1721
|
-
can =
|
|
2075
|
+
can = key in candidates
|
|
1722
2076
|
exists = get_safe_executable(prof.binary) is not None
|
|
1723
2077
|
if can and exists:
|
|
1724
2078
|
status = f"[{THEME['ok']}]✓ ready[/]"
|
|
@@ -1727,7 +2081,7 @@ def _ask_role(state: AgentState, role: str, first_run: bool = False) -> None:
|
|
|
1727
2081
|
else:
|
|
1728
2082
|
status = f"[{THEME['dim']}]n/a[/]"
|
|
1729
2083
|
name = f"{key}" + (
|
|
1730
|
-
f" [{THEME['primary']}](current)[/]" if key == current
|
|
2084
|
+
f" [{THEME['primary']}](current)[/]" if key == current else ""
|
|
1731
2085
|
)
|
|
1732
2086
|
table.add_row(name, status, prof.blurb)
|
|
1733
2087
|
console.print(table)
|
|
@@ -1753,12 +2107,55 @@ def _ask_role(state: AgentState, role: str, first_run: bool = False) -> None:
|
|
|
1753
2107
|
console.print(f"[{THEME['ok']}]✓ {role_label} → {pick}[/]\n")
|
|
1754
2108
|
|
|
1755
2109
|
|
|
2110
|
+
def build_agent_scan_table(state: AgentState) -> Table | None:
|
|
2111
|
+
"""Build a table of agent CLIs installed on this PC + current selection."""
|
|
2112
|
+
found = installed_profiles()
|
|
2113
|
+
if not found:
|
|
2114
|
+
return None
|
|
2115
|
+
|
|
2116
|
+
table = Table(
|
|
2117
|
+
title="🔎 Installed agents on this PC",
|
|
2118
|
+
box=box.ROUNDED,
|
|
2119
|
+
border_style=THEME["primary"],
|
|
2120
|
+
title_style=f"bold {THEME['primary']}",
|
|
2121
|
+
padding=(0, 1),
|
|
2122
|
+
)
|
|
2123
|
+
table.add_column("Agent", style=f"bold {THEME['accent']}", no_wrap=True)
|
|
2124
|
+
table.add_column("Plan / Audit", justify="center")
|
|
2125
|
+
table.add_column("Build", justify="center")
|
|
2126
|
+
table.add_column("Selected for", style=THEME["text"])
|
|
2127
|
+
for key in found:
|
|
2128
|
+
prof = PROFILES[key]
|
|
2129
|
+
roles = []
|
|
2130
|
+
if key == state.planner:
|
|
2131
|
+
roles.append(f"[{THEME['architect']}]planner[/]")
|
|
2132
|
+
if key == state.builder:
|
|
2133
|
+
roles.append(f"[{THEME['builder']}]builder[/]")
|
|
2134
|
+
table.add_row(
|
|
2135
|
+
key,
|
|
2136
|
+
f"[{THEME['ok']}]◆[/]" if "plan" in prof.roles else f"[{THEME['dim']}]—[/]",
|
|
2137
|
+
f"[{THEME['ok']}]◆[/]" if "build" in prof.roles else f"[{THEME['dim']}]—[/]",
|
|
2138
|
+
" · ".join(roles) if roles else f"[{THEME['dim']}]—[/]",
|
|
2139
|
+
)
|
|
2140
|
+
return table
|
|
2141
|
+
|
|
2142
|
+
|
|
1756
2143
|
def run_setup_wizard(state: AgentState, first_run: bool = False) -> None:
|
|
1757
|
-
"""
|
|
2144
|
+
"""Scan the system for agent CLIs and assign one to each role."""
|
|
1758
2145
|
heading = "First-run setup" if first_run else "Agent setup"
|
|
2146
|
+
found = installed_profiles()
|
|
2147
|
+
if found:
|
|
2148
|
+
found_line = (
|
|
2149
|
+
f"[{THEME['ok']}]Found {len(found)} agent CLI(s):[/] "
|
|
2150
|
+
+ ", ".join(f"[bold {THEME['text']}]{k}[/]" for k in found)
|
|
2151
|
+
)
|
|
2152
|
+
else:
|
|
2153
|
+
found_line = f"[{THEME['err']}]No supported agent CLIs found on your PATH.[/]"
|
|
1759
2154
|
console.print(
|
|
1760
2155
|
Panel(
|
|
1761
2156
|
f"[bold {THEME['primary']}]{heading}[/]\n"
|
|
2157
|
+
f"[{THEME['dim']}]Scanning your PATH for installed agent CLIs…[/]\n"
|
|
2158
|
+
f"{found_line}\n"
|
|
1762
2159
|
f"[{THEME['text']}]Choose which agent CLI plays each role.[/] "
|
|
1763
2160
|
f"[{THEME['dim']}]Change this anytime with[/] "
|
|
1764
2161
|
f"[{THEME['primary']}]/agents[/][{THEME['dim']}].[/]",
|
|
@@ -1801,21 +2198,24 @@ def run_full_pipeline(state: AgentState, task: str) -> int:
|
|
|
1801
2198
|
if not step_plan(state, task):
|
|
1802
2199
|
return 1
|
|
1803
2200
|
|
|
1804
|
-
if
|
|
2201
|
+
if console.is_terminal:
|
|
1805
2202
|
choice = arrow_menu(
|
|
1806
|
-
"📋 Plan ready",
|
|
2203
|
+
"📋 Plan ready — review before building",
|
|
1807
2204
|
[
|
|
1808
2205
|
("y", "▶ Proceed to build"),
|
|
1809
2206
|
("n", "⏸ Pause — I'll review / edit the plan first"),
|
|
1810
2207
|
],
|
|
1811
|
-
text=f"{state.plan_file} is ready",
|
|
2208
|
+
text=f"{state.plan_file} is ready. Approve before the builder runs.",
|
|
1812
2209
|
default="y",
|
|
1813
2210
|
)
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2211
|
+
else:
|
|
2212
|
+
choice = "y" if state.permission_mode == "auto" else None
|
|
2213
|
+
|
|
2214
|
+
if choice != "y":
|
|
2215
|
+
console.print(
|
|
2216
|
+
f"[{THEME['warn']}]Paused. Edit {state.plan_file} and press /build or use the menu when ready.[/]\n"
|
|
2217
|
+
)
|
|
2218
|
+
return 0
|
|
1819
2219
|
|
|
1820
2220
|
if not step_build(state, is_fix_pass=False):
|
|
1821
2221
|
if state.last_status.startswith("BUILD_DENIED"):
|
|
@@ -2010,16 +2410,80 @@ def show_help() -> None:
|
|
|
2010
2410
|
table.add_row("/plan <task>", "Run ONLY the planner — active model & effort")
|
|
2011
2411
|
table.add_row("/build", "Run ONLY the builder + color-coded diff approval")
|
|
2012
2412
|
table.add_row("/audit", "Run ONLY the planner to audit git diff & tests")
|
|
2013
|
-
table.add_row("/conv <id>", "Set/clear the
|
|
2413
|
+
table.add_row("/conv <id>", "Set/clear the conversation UUID (current chat only)")
|
|
2414
|
+
table.add_row("/rember [n|id]", "List remembered chats, or resume one by number/id")
|
|
2014
2415
|
table.add_row("", "")
|
|
2015
2416
|
table.add_row("/diff", "Show color-coded git diff (+ green new / - red old)")
|
|
2016
2417
|
table.add_row("/show", "Render PLAN.md with formatted Markdown")
|
|
2017
2418
|
table.add_row("/font <text>", "Render text in 3D wireframe shadow or mini block font")
|
|
2018
2419
|
table.add_row("/clear", "Clear the screen and redraw the banner")
|
|
2420
|
+
table.add_row("Ctrl+L", "Clear the screen and redraw responsive banner")
|
|
2019
2421
|
table.add_row("/exit", "Quit SpeiiDev")
|
|
2020
2422
|
console.print(table)
|
|
2021
2423
|
|
|
2022
2424
|
|
|
2425
|
+
def handle_rember(state: AgentState, arg: str = "") -> None:
|
|
2426
|
+
"""List remembered chats or resume one by index/id."""
|
|
2427
|
+
entries = load_rember()
|
|
2428
|
+
arg = arg.strip()
|
|
2429
|
+
|
|
2430
|
+
if not entries:
|
|
2431
|
+
console.print(
|
|
2432
|
+
f"[{THEME['warn']}]No remembered chats yet.[/] "
|
|
2433
|
+
f"Set one with [bold]{escape('/conv <id>')}[/bold] and it will be stored in "
|
|
2434
|
+
f"{REMBER_FILE.name}."
|
|
2435
|
+
)
|
|
2436
|
+
return
|
|
2437
|
+
|
|
2438
|
+
if not arg:
|
|
2439
|
+
w = console.width
|
|
2440
|
+
table = Table(
|
|
2441
|
+
title="⌘ Remembered Chats",
|
|
2442
|
+
box=box.ROUNDED,
|
|
2443
|
+
border_style=THEME["primary"],
|
|
2444
|
+
title_style=f"bold {THEME['primary']}",
|
|
2445
|
+
padding=(0, 1),
|
|
2446
|
+
)
|
|
2447
|
+
table.add_column("#", style=f"bold {THEME['accent']}", justify="right", no_wrap=True)
|
|
2448
|
+
table.add_column("Chat ID", style=THEME["text"], no_wrap=True)
|
|
2449
|
+
if w >= 65:
|
|
2450
|
+
table.add_column("Time", style=THEME["dim"], no_wrap=True)
|
|
2451
|
+
task_max_w = max(15, min(40, w - 30))
|
|
2452
|
+
table.add_column("Task", style=THEME["text"], overflow="ellipsis", max_width=task_max_w)
|
|
2453
|
+
for i, entry in enumerate(entries, 1):
|
|
2454
|
+
label = str(entry.get("label", ""))
|
|
2455
|
+
if label.startswith("chat ") or label.startswith("agent"):
|
|
2456
|
+
label = ""
|
|
2457
|
+
row_items = [str(i), str(entry.get("id", ""))]
|
|
2458
|
+
if w >= 65:
|
|
2459
|
+
row_items.append(str(entry.get("time", "")))
|
|
2460
|
+
row_items.append(label)
|
|
2461
|
+
table.add_row(*row_items)
|
|
2462
|
+
console.print(table)
|
|
2463
|
+
console.print(
|
|
2464
|
+
f"[{THEME['dim']}]Resume with[/] "
|
|
2465
|
+
f"[bold {THEME['accent']}]{escape('/rember <#|id>')}[/]\n"
|
|
2466
|
+
)
|
|
2467
|
+
return
|
|
2468
|
+
|
|
2469
|
+
selected: dict | None = None
|
|
2470
|
+
if arg.isdigit() and 1 <= int(arg) <= len(entries):
|
|
2471
|
+
selected = entries[int(arg) - 1]
|
|
2472
|
+
else:
|
|
2473
|
+
selected = next((e for e in entries if e.get("id") == arg), None)
|
|
2474
|
+
|
|
2475
|
+
if selected is None:
|
|
2476
|
+
console.print(f"[{THEME['err']}]No remembered chat matches '{arg}'.[/]")
|
|
2477
|
+
return
|
|
2478
|
+
|
|
2479
|
+
state.conversation_id = str(selected["id"])
|
|
2480
|
+
remember_conversation(state.conversation_id, selected.get("label", ""))
|
|
2481
|
+
console.print(
|
|
2482
|
+
f"[{THEME['ok']}]✓ Resumed chat {state.conversation_id}[/] "
|
|
2483
|
+
f"[{THEME['dim']}]({selected.get('time', '')})[/]"
|
|
2484
|
+
)
|
|
2485
|
+
|
|
2486
|
+
|
|
2023
2487
|
def interactive_repl(state: AgentState) -> int:
|
|
2024
2488
|
console.clear()
|
|
2025
2489
|
print_banner(state)
|
|
@@ -2038,18 +2502,42 @@ def interactive_repl(state: AgentState) -> int:
|
|
|
2038
2502
|
)
|
|
2039
2503
|
|
|
2040
2504
|
def bottom_toolbar() -> HTML:
|
|
2041
|
-
|
|
2505
|
+
term_cols = shutil.get_terminal_size((80, 24)).columns
|
|
2506
|
+
conv_label = (
|
|
2507
|
+
state.conversation_id[:8] + "…"
|
|
2508
|
+
if state.conversation_id
|
|
2509
|
+
else state.session_id[:8] + "…"
|
|
2510
|
+
)
|
|
2042
2511
|
gate = {"auto": "AUTO", "deny": "DENY", "ask": "ASK"}.get(
|
|
2043
2512
|
state.permission_mode, "ASK"
|
|
2044
2513
|
)
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2514
|
+
if term_cols >= 95:
|
|
2515
|
+
return HTML(
|
|
2516
|
+
f" <b>⌘ SPEIIDEV</b> 🥬 "
|
|
2517
|
+
f"<ansicyan>{state.planner}</ansicyan> <b>{state.planner_model[:16]}</b> "
|
|
2518
|
+
f"<ansimagenta>{state.builder}</ansimagenta> <b>{state.builder_model[:16]}</b> "
|
|
2519
|
+
f"gate <b>{gate}</b> chat <b>{conv_label}</b> "
|
|
2520
|
+
f"<ansigreen>{state.last_status}</ansigreen> "
|
|
2521
|
+
f"<ansiyellow>F2 menu · F3 activity</ansiyellow> "
|
|
2522
|
+
)
|
|
2523
|
+
elif term_cols >= 75:
|
|
2524
|
+
return HTML(
|
|
2525
|
+
f" <b>⌘ SPEIIDEV</b> "
|
|
2526
|
+
f"<ansicyan>{state.planner}</ansicyan> "
|
|
2527
|
+
f"<ansimagenta>{state.builder}</ansimagenta> "
|
|
2528
|
+
f"gate <b>{gate}</b> chat <b>{conv_label}</b> "
|
|
2529
|
+
f"<ansigreen>{state.last_status}</ansigreen> "
|
|
2530
|
+
f"<ansiyellow>F2 menu</ansiyellow> "
|
|
2531
|
+
)
|
|
2532
|
+
elif term_cols >= 50:
|
|
2533
|
+
return HTML(
|
|
2534
|
+
f" <b>⌘</b> <ansicyan>{state.planner}</ansicyan>·<ansimagenta>{state.builder}</ansimagenta> "
|
|
2535
|
+
f"gate <b>{gate}</b> chat <b>{conv_label}</b> <ansiyellow>F2</ansiyellow> "
|
|
2536
|
+
)
|
|
2537
|
+
else:
|
|
2538
|
+
return HTML(
|
|
2539
|
+
f" <b>⌘</b> <b>{gate}</b> <ansiyellow>F2</ansiyellow> "
|
|
2540
|
+
)
|
|
2053
2541
|
|
|
2054
2542
|
try:
|
|
2055
2543
|
history = FileHistory(str(HISTORY_FILE))
|
|
@@ -2069,18 +2557,51 @@ def interactive_repl(state: AgentState) -> int:
|
|
|
2069
2557
|
def _open_activity(event):
|
|
2070
2558
|
event.app.exit(result=ACTIVITY_SENTINEL)
|
|
2071
2559
|
|
|
2560
|
+
@kb.add("c-l")
|
|
2561
|
+
def _clear_and_redraw(event):
|
|
2562
|
+
console.clear()
|
|
2563
|
+
print_banner(state)
|
|
2564
|
+
event.app.renderer.erase()
|
|
2565
|
+
|
|
2072
2566
|
session: PromptSession[str] = PromptSession(
|
|
2073
2567
|
history=history,
|
|
2074
2568
|
completer=completer,
|
|
2075
2569
|
bottom_toolbar=bottom_toolbar,
|
|
2076
2570
|
style=style,
|
|
2077
2571
|
key_bindings=kb,
|
|
2572
|
+
refresh_interval=0.5,
|
|
2078
2573
|
)
|
|
2079
2574
|
|
|
2575
|
+
orig_on_resize = session.app._on_resize
|
|
2576
|
+
resize_timer: list[asyncio.TimerHandle | None] = [None]
|
|
2577
|
+
|
|
2578
|
+
def _do_redraw() -> None:
|
|
2579
|
+
if getattr(session.app, "is_running", False):
|
|
2580
|
+
console.clear()
|
|
2581
|
+
print_banner(state)
|
|
2582
|
+
try:
|
|
2583
|
+
session.app.renderer.erase(leave_alternate_screen=False)
|
|
2584
|
+
session.app._request_absolute_cursor_position()
|
|
2585
|
+
session.app._redraw()
|
|
2586
|
+
except Exception:
|
|
2587
|
+
orig_on_resize()
|
|
2588
|
+
|
|
2589
|
+
def _handle_resize() -> None:
|
|
2590
|
+
try:
|
|
2591
|
+
loop = asyncio.get_running_loop()
|
|
2592
|
+
if resize_timer[0] is not None:
|
|
2593
|
+
resize_timer[0].cancel()
|
|
2594
|
+
resize_timer[0] = loop.call_later(0.08, _do_redraw)
|
|
2595
|
+
except Exception:
|
|
2596
|
+
_do_redraw()
|
|
2597
|
+
|
|
2598
|
+
session.app._on_resize = _handle_resize
|
|
2599
|
+
|
|
2080
2600
|
while True:
|
|
2081
2601
|
try:
|
|
2082
2602
|
user_input = session.prompt([("class:prompt", "❯ ")]).strip()
|
|
2083
2603
|
except (EOFError, KeyboardInterrupt):
|
|
2604
|
+
persist_active_chat(state)
|
|
2084
2605
|
save_config(state)
|
|
2085
2606
|
console.print("\n[dim]Goodbye![/dim]")
|
|
2086
2607
|
return 0
|
|
@@ -2098,6 +2619,7 @@ def interactive_repl(state: AgentState) -> int:
|
|
|
2098
2619
|
continue
|
|
2099
2620
|
|
|
2100
2621
|
if user_input in ("/exit", "/quit", "exit", "quit"):
|
|
2622
|
+
persist_active_chat(state)
|
|
2101
2623
|
save_config(state)
|
|
2102
2624
|
console.print("[dim]Goodbye![/dim]")
|
|
2103
2625
|
return 0
|
|
@@ -2143,10 +2665,16 @@ def interactive_repl(state: AgentState) -> int:
|
|
|
2143
2665
|
parts = user_input.split(maxsplit=1)
|
|
2144
2666
|
if len(parts) > 1:
|
|
2145
2667
|
state.conversation_id = parts[1].strip()
|
|
2146
|
-
|
|
2668
|
+
remember_conversation(state.conversation_id)
|
|
2669
|
+
console.print(
|
|
2670
|
+
f"[green]✓ Active conversation ID set to: {state.conversation_id}[/green]"
|
|
2671
|
+
)
|
|
2147
2672
|
else:
|
|
2148
2673
|
state.conversation_id = None
|
|
2149
2674
|
console.print("[yellow]Conversation ID cleared (using new chat).[/yellow]")
|
|
2675
|
+
elif user_input.startswith("/rember"):
|
|
2676
|
+
parts = user_input.split(maxsplit=1)
|
|
2677
|
+
handle_rember(state, parts[1] if len(parts) > 1 else "")
|
|
2150
2678
|
elif user_input.startswith("/plan"):
|
|
2151
2679
|
task = user_input[5:].strip()
|
|
2152
2680
|
if not task:
|
|
@@ -2207,22 +2735,20 @@ def main() -> int:
|
|
|
2207
2735
|
if permission_mode not in ("ask", "auto", "deny"):
|
|
2208
2736
|
permission_mode = "ask"
|
|
2209
2737
|
|
|
2210
|
-
planner = args.planner or cfg.get("planner"
|
|
2211
|
-
builder = args.builder or cfg.get("builder"
|
|
2212
|
-
if planner not in PROFILES or
|
|
2213
|
-
|
|
2214
|
-
planner = "agy"
|
|
2215
|
-
if builder not in PROFILES or
|
|
2216
|
-
|
|
2217
|
-
builder = "opencode"
|
|
2738
|
+
planner = args.planner or cfg.get("planner") or first_installed_for("plan") or installed_profiles()[0] if installed_profiles() else "agy"
|
|
2739
|
+
builder = args.builder or cfg.get("builder") or first_installed_for("build") or installed_profiles()[0] if installed_profiles() else "opencode"
|
|
2740
|
+
if planner not in PROFILES or not get_safe_executable(PROFILES[planner].binary):
|
|
2741
|
+
candidates = installed_profiles()
|
|
2742
|
+
planner = candidates[0] if candidates else "agy"
|
|
2743
|
+
if builder not in PROFILES or not get_safe_executable(PROFILES[builder].binary):
|
|
2744
|
+
candidates = installed_profiles()
|
|
2745
|
+
builder = candidates[0] if candidates else "opencode"
|
|
2218
2746
|
|
|
2219
2747
|
state = AgentState(
|
|
2220
2748
|
plan_file=args.plan_file,
|
|
2221
2749
|
max_retries=args.max_retries,
|
|
2222
2750
|
auto_approve=permission_mode == "auto",
|
|
2223
|
-
conversation_id=
|
|
2224
|
-
args.conversation if args.conversation is not None else cfg.get("conversation_id")
|
|
2225
|
-
),
|
|
2751
|
+
conversation_id=args.conversation,
|
|
2226
2752
|
planner=planner,
|
|
2227
2753
|
builder=builder,
|
|
2228
2754
|
planner_model=args.planner_model or cfg.get("planner_model", "default"),
|
|
@@ -2236,6 +2762,24 @@ def main() -> int:
|
|
|
2236
2762
|
if isinstance(saved, list):
|
|
2237
2763
|
state.approved_actions = {str(a) for a in saved}
|
|
2238
2764
|
|
|
2765
|
+
if state.conversation_id:
|
|
2766
|
+
remember_conversation(state.conversation_id)
|
|
2767
|
+
|
|
2768
|
+
if not args.no_splash:
|
|
2769
|
+
run_splash(state)
|
|
2770
|
+
|
|
2771
|
+
explicit_agents = bool(args.planner or args.builder)
|
|
2772
|
+
interactive = console.is_terminal and not args.task
|
|
2773
|
+
|
|
2774
|
+
planner_missing = get_safe_executable(PROFILES[state.planner].binary) is None
|
|
2775
|
+
builder_missing = get_safe_executable(PROFILES[state.builder].binary) is None
|
|
2776
|
+
needs_pick = first_run or planner_missing or builder_missing
|
|
2777
|
+
|
|
2778
|
+
if interactive and not args.no_save:
|
|
2779
|
+
if needs_pick and not explicit_agents:
|
|
2780
|
+
run_setup_wizard(state, first_run=first_run)
|
|
2781
|
+
save_config(state)
|
|
2782
|
+
|
|
2239
2783
|
missing = [
|
|
2240
2784
|
f"{role}:{key} ({PROFILES[key].binary})"
|
|
2241
2785
|
for role, key in (("planner", state.planner), ("builder", state.builder))
|
|
@@ -2243,19 +2787,14 @@ def main() -> int:
|
|
|
2243
2787
|
]
|
|
2244
2788
|
if missing:
|
|
2245
2789
|
console.print(
|
|
2246
|
-
f"[bold red]❌
|
|
2247
|
-
f"{'
|
|
2790
|
+
f"[bold red]❌ No agent CLI found for:[/bold red] {', '.join(missing)}\n"
|
|
2791
|
+
f"[{THEME['text']}]Install at least one supported agent on your PATH:[/] "
|
|
2792
|
+
f"{', '.join(PROFILES)}.\n"
|
|
2793
|
+
f"[{THEME['dim']}]Tip: run [/][{THEME['primary']}]/agents[/]"
|
|
2794
|
+
f"[{THEME['dim']}] inside the tool to scan and pick again.[/]"
|
|
2248
2795
|
)
|
|
2249
2796
|
return 1
|
|
2250
2797
|
|
|
2251
|
-
if not args.no_splash:
|
|
2252
|
-
run_splash(state)
|
|
2253
|
-
|
|
2254
|
-
explicit_agents = bool(args.planner or args.builder)
|
|
2255
|
-
if first_run and not args.task and not explicit_agents and not args.no_save:
|
|
2256
|
-
run_setup_wizard(state, first_run=True)
|
|
2257
|
-
save_config(state)
|
|
2258
|
-
|
|
2259
2798
|
if args.task:
|
|
2260
2799
|
print_banner(state)
|
|
2261
2800
|
return run_full_pipeline(state, " ".join(args.task))
|