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.
@@ -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 = "4.0"
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
- legend = Text.from_markup(
647
- "[bold bright_green on #113311] + Green = New Code Added [/bold bright_green on #113311] "
648
- "[bold bright_red on #3b1219] - Red = Old Code Removed [/bold bright_red on #3b1219]"
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, 2),
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 = height
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
- lines.append((f"class:{cls}", f"{cursor}{label}{mark}\n"))
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
- return [("class:footer", f"Keyboard: {footer}")]
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=min(height, max(1, len(options))),
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"] - height)
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"] + height)
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=f"[bold {THEME['auditor']}]🔐 Permissions requested[/bold {THEME['auditor']}] "
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, 2),
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
- filled = int(width * idx / len(boot_steps))
1080
- bar = "█" * filled + "░" * (width - filled)
1081
- status_grid = Table.grid(padding=(0, 3))
1082
- status_grid.add_column(justify="left")
1083
- status_grid.add_column(justify="left", ratio=1)
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
- status_grid.add_row(cabbage, status_body)
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
- Text.from_markup(f"[bold {THEME['primary']}]{SPEIIDEV_ASCII}[/]"),
1241
+ title_node,
1106
1242
  Rule(style=THEME["dim"]),
1107
- status_grid,
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=(1, 2),
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
- cabbage = render_bok_choy_logo()
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']}]{state.conversation_id[:8]}…[/{THEME['ok']}]"
1296
+ f"[{THEME['ok']}]{active_chat[:8]}…[/{THEME['ok']}]"
1129
1297
  if state.conversation_id
1130
- else f"[{THEME['dim']}]new session[/{THEME['dim']}]"
1298
+ else f"[{THEME['dim']}]{active_chat[:8]}… (new)[/{THEME['dim']}]"
1131
1299
  )
1132
1300
 
1133
- info_grid = Table.grid(expand=True)
1134
- info_grid.add_column(justify="left", ratio=1)
1135
- info_grid.add_column(justify="right")
1136
-
1137
- left_info = (
1138
- f"[{THEME['dim']}]repo [/][bold {THEME['text']}]{Path.cwd().name}[/bold {THEME['text']}] "
1139
- f"[{THEME['dim']}]on[/] [{THEME['ok']}]{state.git_branch}[/{THEME['ok']}]\n"
1140
- f"[{THEME['dim']}]chat [/]{conv_display}\n"
1141
- f"[{THEME['architect']}]◆ plan [/] [{THEME['text']}]{state.planner}[/] "
1142
- f"[{THEME['dim']}]{state.planner_model} · {state.planner_effort}[/]\n"
1143
- f"[{THEME['builder']}]◆ build[/] [{THEME['text']}]{state.builder}[/] "
1144
- f"[{THEME['dim']}]{state.builder_model} · {state.builder_effort}[/]"
1145
- )
1146
- right_info = (
1147
- f"[{THEME['dim']}]gate [/]{_mode_badge(state.permission_mode)}\n"
1148
- f"[{THEME['dim']}]agents[/] [{THEME['text']}]{state.planner} · {state.builder}[/]\n\n"
1149
- f"[yellow]F2[/] [{THEME['dim']}]menu ·[/] [yellow]F3[/] [{THEME['dim']}]activity[/]"
1150
- )
1151
- info_grid.add_row(Text.from_markup(left_info), Text.from_markup(right_info))
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
- meta_grid = Table.grid(padding=(0, 3))
1154
- meta_grid.add_column(justify="left")
1155
- meta_grid.add_column(justify="left", ratio=1)
1156
- meta_grid.add_row(cabbage, info_grid)
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
- banner_content = Group(
1159
- Text.from_markup(f"[bold {THEME['primary']}]{SPEIIDEV_ASCII}[/]"),
1160
- Text.from_markup(
1161
- f"\n[bold {THEME['primary']}]SPEIIDEV[/bold {THEME['primary']}] "
1162
- f"[{THEME['dim']}]v{APP_VERSION}[/{THEME['dim']}] · "
1163
- f"[bold {THEME['text']}]Autonomous Multi-Agent Orchestrator[/bold {THEME['text']}]"
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
- banner_content,
1400
+ Group(*banner_items),
1172
1401
  box=box.ROUNDED,
1173
1402
  border_style=THEME["primary"],
1174
- padding=(1, 2),
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
- console.print(
1181
- f" [{THEME['dim']}]Type a task, or use[/] "
1182
- f"[{THEME['primary']}]/agents[/] [{THEME['dim']}]·[/] "
1183
- f"[{THEME['primary']}]/model[/] [{THEME['dim']}]·[/] [{THEME['primary']}]/effort[/] "
1184
- f"[{THEME['dim']}]·[/] [{THEME['primary']}]/perms[/] [{THEME['dim']}]·[/] "
1185
- f"[{THEME['primary']}]/help[/{THEME['primary']}]\n"
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
- with Live(render_panel(), console=console, refresh_per_second=12) as live:
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
- live.update(render_panel(done=False))
1296
-
1639
+ # Auto-refresh via Live thread is enough; optional tiny refresh
1640
+ live.refresh()
1297
1641
  proc.wait(timeout=timeout)
1298
- live.update(render_panel(done=True, exit_code=proc.returncode))
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=f"[bold yellow]📋 {plan_file.name} (Shared Agent Spec)[/bold yellow]",
1685
+ title=card_title,
1334
1686
  border_style="yellow",
1335
1687
  box=box.ROUNDED,
1336
- padding=(1, 2),
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 = [k for k in installed_profiles() if role in PROFILES[k].roles]
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 = role in prof.roles
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 and can else ""
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
- """Assign an agent CLI to the planner and builder roles."""
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 state.permission_mode != "auto":
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
- if choice != "y":
1815
- console.print(
1816
- f"[{THEME['warn']}]Paused. Edit {state.plan_file} and press /build or use the menu when ready.[/]\n"
1817
- )
1818
- return 0
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 agy conversation UUID to resume old chat context")
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
- conv_label = state.conversation_id[:8] + "…" if state.conversation_id else "new"
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
- return HTML(
2046
- f" <b>⌘ SPEIIDEV</b> 🥬 "
2047
- f"<ansicyan>{state.planner}</ansicyan> <b>{state.planner_model[:16]}</b> "
2048
- f"<ansimagenta>{state.builder}</ansimagenta> <b>{state.builder_model[:16]}</b> "
2049
- f"gate <b>{gate}</b> chat <b>{conv_label}</b> "
2050
- f"<ansigreen>{state.last_status}</ansigreen> "
2051
- f"<ansiyellow>F2 menu · F3 activity</ansiyellow> "
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
- console.print(f"[green]✓ Active conversation ID set to: {state.conversation_id}[/green]")
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", "agy")
2211
- builder = args.builder or cfg.get("builder", "opencode")
2212
- if planner not in PROFILES or "plan" not in PROFILES[planner].roles:
2213
- console.print(f"[{THEME['warn']}]'{planner}' cannot plan — falling back to agy.[/]")
2214
- planner = "agy"
2215
- if builder not in PROFILES or "build" not in PROFILES[builder].roles:
2216
- console.print(f"[{THEME['warn']}]'{builder}' cannot build — falling back to opencode.[/]")
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]❌ Missing required CLI binaries on PATH:[/bold red] "
2247
- f"{', '.join(missing)}"
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))