diff --git a/backend/muse/autopilot/sessions.py b/backend/muse/autopilot/sessions.py index 5a7a38d..bec2b06 100644 --- a/backend/muse/autopilot/sessions.py +++ b/backend/muse/autopilot/sessions.py @@ -1,8 +1,11 @@ -"""Discover active Claude Code sessions and match them to tmux panes. +"""Discover live provider sessions and match them to tmux panes. -Source of truth is ~/.claude/sessions/{pid}.json (written by Claude Code), -which gives pid -> sessionId/cwd/status. We keep only live pids, dedupe by -sessionId, and map each to its tmux pane via process ancestry. +Claude exposes pid->session sidecars under ~/.claude/sessions/*.json, which is +the primary source of truth for live Claude panes. Codex does not write an +equivalent sidecar; instead, its live process logs thread_ids into +~/.codex/logs_*.sqlite and stores thread metadata in ~/.codex/state_*.sqlite. +We resolve a tmux pane to its descendant Codex process, then map that pid to +the active thread id through those SQLite logs. """ from __future__ import annotations @@ -10,6 +13,7 @@ import glob import json import os +import sqlite3 from datetime import datetime, timezone from typing import Optional @@ -43,13 +47,117 @@ def _pane_for(pid: int, pane_pids: dict[int, str]) -> Optional[str]: return None -def discover() -> list[LiveSession]: +def _proc_snapshot() -> dict[int, dict[str, object]]: + out: dict[int, dict[str, object]] = {} + for name in os.listdir("/proc"): + if not name.isdigit(): + continue + pid = int(name) + try: + with open(f"/proc/{pid}/stat", encoding="utf-8") as f: + data = f.read() + start = data.find("(") + end = data.rfind(")") + comm = data[start + 1 : end] + after = data[end + 2 :].split() + ppid = int(after[1]) + with open(f"/proc/{pid}/cmdline", "rb") as f: + raw = f.read().replace(b"\0", b" ").decode("utf-8", errors="replace").strip() + except (OSError, ValueError, IndexError): + continue + out[pid] = {"ppid": ppid, "comm": comm, "cmdline": raw} + return out + + +def _children(procs: dict[int, dict[str, object]]) -> dict[int, list[int]]: + out: dict[int, list[int]] = {} + for pid, info in procs.items(): + ppid = int(info["ppid"]) + out.setdefault(ppid, []).append(pid) + return out + + +def _is_codex_proc(info: dict[str, object]) -> bool: + comm = os.path.basename(str(info.get("comm") or "")).lower() + if comm == "codex": + return True + cmd = str(info.get("cmdline") or "").lower() + argv0 = os.path.basename(cmd.split(" ", 1)[0]) if cmd else "" + return argv0 == "codex" + + +def _find_descendant( + root_pid: int, + procs: dict[int, dict[str, object]], + kids: dict[int, list[int]], + pred, +) -> Optional[int]: + if root_pid in procs and pred(procs[root_pid]): + return root_pid + queue = list(kids.get(root_pid, ())) + seen = set(queue) + while queue: + pid = queue.pop(0) + info = procs.get(pid) + if info and pred(info): + return pid + for child in kids.get(pid, ()): + if child not in seen: + seen.add(child) + queue.append(child) + return None + + +def _codex_dbs() -> tuple[Optional[str], Optional[str]]: + root = get_settings().codex_dir + state = sorted(root.glob("state_*.sqlite")) + logs = sorted(root.glob("logs_*.sqlite")) + return (str(state[-1]) if state else None, str(logs[-1]) if logs else None) + + +def _codex_meta_for_pid(pid: int, state_db: str, logs_db: str) -> Optional[dict[str, object]]: + try: + lconn = sqlite3.connect(f"file:{logs_db}?mode=ro", uri=True) + try: + row = lconn.execute( + "SELECT thread_id FROM logs " + "WHERE process_uuid LIKE ? AND thread_id IS NOT NULL " + "ORDER BY id DESC LIMIT 1", + (f"pid:{pid}:%",), + ).fetchone() + finally: + lconn.close() + except sqlite3.Error: + return None + if row is None or not row[0]: + return None + thread_id = str(row[0]) + try: + sconn = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True) + try: + srow = sconn.execute( + "SELECT id, cwd, cli_version, updated_at_ms FROM threads WHERE id=?", + (thread_id,), + ).fetchone() + finally: + sconn.close() + except sqlite3.Error: + return None + if srow is None: + return {"thread_id": thread_id} + return { + "thread_id": str(srow[0]), + "cwd": srow[1], + "version": srow[2], + "updated_at_ms": srow[3], + } + + +def _discover_claude(pane_pids: dict[int, str]) -> list[LiveSession]: sessions_dir = get_settings().claude_dir / "sessions" if not sessions_dir.is_dir(): return [] - pane_pids = {p["pane_pid"]: p["pane_id"] for p in tmux.list_panes()} - # Latest record per sessionId among live pids. best: dict[str, dict] = {} for path in glob.glob(str(sessions_dir / "*.json")): @@ -83,5 +191,50 @@ def discover() -> list[LiveSession]: ), ) ) + return out + + +def _discover_codex(panes: list[dict]) -> list[LiveSession]: + state_db, logs_db = _codex_dbs() + if not state_db or not logs_db: + return [] + procs = _proc_snapshot() + kids = _children(procs) + best: dict[str, LiveSession] = {} + for pane in panes: + runtime_pid = _find_descendant(int(pane["pane_pid"]), procs, kids, _is_codex_proc) + if runtime_pid is None: + continue + meta = _codex_meta_for_pid(runtime_pid, state_db, logs_db) + if not meta or not meta.get("thread_id"): + continue + sid = f"codex:{meta['thread_id']}" + updated_ms = meta.get("updated_at_ms") + live = LiveSession( + session_id=sid, + pid=runtime_pid, + cwd=(meta.get("cwd") or pane.get("cwd")), + # Non-Claude live mapping gives us identity, not turn-state. + status="shell", + pane_id=str(pane["pane_id"]), + version=meta.get("version"), + updated_at=( + datetime.fromtimestamp(float(updated_ms) / 1000, tz=timezone.utc) + if updated_ms + else None + ), + ) + prev = best.get(sid) + if prev is None or (live.updated_at or datetime.min.replace(tzinfo=timezone.utc)) > ( + prev.updated_at or datetime.min.replace(tzinfo=timezone.utc) + ): + best[sid] = live + return list(best.values()) + + +def discover() -> list[LiveSession]: + panes = tmux.list_panes() + pane_pids = {p["pane_pid"]: p["pane_id"] for p in panes} + out = _discover_claude(pane_pids) + _discover_codex(panes) out.sort(key=lambda s: s.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True) return out diff --git a/backend/muse/autopilot/tmux.py b/backend/muse/autopilot/tmux.py index d99093b..53030ad 100644 --- a/backend/muse/autopilot/tmux.py +++ b/backend/muse/autopilot/tmux.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +import time def _run(args: list[str], timeout: float = 5.0, input: str | None = None) -> tuple[int, str, str]: @@ -58,6 +59,7 @@ def list_layout() -> list[dict]: "#{pane_index}", "#{pane_active}", "#{pane_current_command}", + "#{pane_start_command}", "#{pane_current_path}", "#{pane_title}", "#{session_attached}", @@ -71,7 +73,7 @@ def list_layout() -> list[dict]: panes: list[dict] = [] for line in out.splitlines(): parts = line.split("\t") - if len(parts) != 13: + if len(parts) != 14: continue try: panes.append( @@ -84,14 +86,15 @@ def list_layout() -> list[dict]: "pane_index": int(parts[5]), "pane_active": parts[6] == "1", "command": parts[7], - "cwd": parts[8], - "title": parts[9], - "session_attached": parts[10] != "0", + "start_command": parts[8], + "cwd": parts[9], + "title": parts[10], + "session_attached": parts[11] != "0", # Epoch secs of last activity in this window — for most-recent sort. - "last_activity": int(parts[11]) if parts[11].isdigit() else 0, + "last_activity": int(parts[12]) if parts[12].isdigit() else 0, # Stable window handle (@) — survives index shifts, so it's the # safe target for move-window. - "window_id": parts[12], + "window_id": parts[13], } ) except ValueError: @@ -217,6 +220,10 @@ def send_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str]: if code != 0: return False, err or "send-keys failed" if submit: + # Some TUIs (notably Codex) can drop an immediate synthetic Enter sent in + # the same instant as a literal paste. Give the app one frame to ingest + # the text before submitting it. + time.sleep(0.05) code, _, err = _run(["send-keys", "-t", pane_id, "Enter"]) if code != 0: return False, err or "enter failed" @@ -237,6 +244,7 @@ def paste_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str] if code != 0: return False, err or "paste-buffer failed" if submit: + time.sleep(0.05) code, _, err = _run(["send-keys", "-t", pane_id, "Enter"]) if code != 0: return False, err or "enter failed" diff --git a/backend/muse/models.py b/backend/muse/models.py index 05b06aa..fb17a89 100644 --- a/backend/muse/models.py +++ b/backend/muse/models.py @@ -623,6 +623,7 @@ class PendingOptions(BaseModel): source: Literal["permission", "tool_question", "none"] = "none" available: bool = False # False when nothing is pending / actionable prompt: str = "" + detail: str = "" # long-form context to review before answering (e.g. a plan body) options: list[PendingOption] = Field(default_factory=list) current_index: Optional[int] = None # highlighted row in the live buffer fingerprint: str = "" # client echoes this back on select for stale-protection @@ -639,6 +640,7 @@ class SuggestReply(BaseModel): class TmuxPane(BaseModel): """One pane in the live tmux topology, for the swipeable mobile panes view.""" + provider: Optional[str] = None # claude | gemini | codex | opencode | None pane_id: str session_name: str window_index: int @@ -661,6 +663,7 @@ class TmuxPane(BaseModel): attention: str = "" # short reason, e.g. "permission prompt", "working" # Claude Code's permission mode (Shift+Tab cycles it); None for non-Claude panes. mode: Optional[Literal["default", "acceptEdits", "plan", "bypass"]] = None + capabilities: dict[str, bool] = Field(default_factory=dict) # Full screen text is heavy (hundreds of KB across a fleet) — it ships only # when the client asks (?previews=1); the one-line tail always ships for # task-list subtitles. The deck fetches live screens per-pane instead. diff --git a/backend/muse/options.py b/backend/muse/options.py index 9a9ce61..969795d 100644 --- a/backend/muse/options.py +++ b/backend/muse/options.py @@ -79,16 +79,25 @@ class ParsedMenu: options: list[MenuOption] = field(default_factory=list) current_index: Optional[int] = None # highlighted row, 0-based among options remaining_questions: int = 0 # AskUserQuestion with >1 question still pending + # Long-form context to review before answering — the full ExitPlanMode plan + # (markdown), so it can be read where it's answered instead of only in the + # terminal. Empty for permission dialogs and plain questions (prompt suffices). + detail: str = "" -def fingerprint(prompt: str, options: list[MenuOption]) -> str: +def fingerprint(prompt: str, options: list[MenuOption], detail: str = "") -> str: """Stable hash of what the user is being shown. The client echoes it back on - select so the server can refuse (409) if the buffer changed underneath.""" + select so the server can refuse (409) if the buffer changed underneath. The + detail (e.g. a plan's body) is folded in so a re-issued plan invalidates a + stale selection even when its one-line prompt is unchanged.""" h = hashlib.sha256() h.update(prompt.strip().encode("utf-8", "replace")) for opt in options: h.update(b"\x00") h.update(opt.label.strip().encode("utf-8", "replace")) + if detail: + h.update(b"\x01") + h.update(detail.strip().encode("utf-8", "replace")) return h.hexdigest()[:16] @@ -215,8 +224,7 @@ def _ask_user_question_menu(tool_input: dict) -> ParsedMenu: def _exit_plan_menu(tool_input: dict) -> ParsedMenu: plan = (tool_input.get("plan") or "").strip() - first = plan.splitlines()[0].strip() if plan else "" - prompt = "Ready to code? " + (f"Plan: {first}" if first else "(plan presented)") + prompt = "Review the plan, then choose:" if plan else "Ready to code?" return ParsedMenu( source="tool_question", prompt=prompt, @@ -224,4 +232,5 @@ def _exit_plan_menu(tool_input: dict) -> ParsedMenu: MenuOption(id="1", label="Yes, proceed", kind="menu"), MenuOption(id="2", label="No, keep planning", kind="menu"), ], + detail=plan, # full plan markdown — reviewable at the point of answering ) diff --git a/backend/muse/profiles.py b/backend/muse/profiles.py index 36dfe0a..38624c8 100644 --- a/backend/muse/profiles.py +++ b/backend/muse/profiles.py @@ -41,6 +41,7 @@ class ProfileParam(BaseModel): class Profile(BaseModel): model_config = ConfigDict(extra="forbid") name: str + provider: str = "claude" # claude | gemini | codex | opencode | shell-ish custom cwd: str = "~" command: str = "claude" params: list[ProfileParam] = [] @@ -52,9 +53,14 @@ class Profile(BaseModel): builtin: bool = False -# Always available so "New window" works with no config file. A file profile named -# "Claude" (case-insensitive) overrides this. -DEFAULT_PROFILE = Profile(name="Claude", cwd="~", command="claude", builtin=True) +# Always available so "New window" works with no config file. A file profile with the +# same name (case-insensitive) overrides the matching built-in. +BUILTIN_PROFILES = [ + Profile(name="Claude", provider="claude", cwd="~", command="claude", builtin=True), + Profile(name="Gemini", provider="gemini", cwd="~", command="antigravity", builtin=True), + Profile(name="Codex", provider="codex", cwd="~", command="codex", builtin=True), + Profile(name="OpenCode", provider="opencode", cwd="~", command="opencode", builtin=True), +] def profiles_path() -> Path: @@ -75,7 +81,7 @@ def load_profiles() -> list[Profile]: an actionable message instead of silently doing nothing.""" path = profiles_path() if not path.exists(): - return [DEFAULT_PROFILE] + return BUILTIN_PROFILES[:] try: data = tomllib.loads(path.read_text()) @@ -101,11 +107,16 @@ def load_profiles() -> list[Profile]: seen.add(low) file_profiles.append(p) - # File wins: only prepend the built-in default when the file doesn't define "Claude". out: list[Profile] = [] - if DEFAULT_PROFILE.name.lower() not in seen: - out.append(DEFAULT_PROFILE) - out.extend(file_profiles) + by_name = {p.name.lower(): p for p in file_profiles} + consumed: set[str] = set() + for builtin in BUILTIN_PROFILES: + low = builtin.name.lower() + chosen = by_name.get(low, builtin) + out.append(chosen) + if low in by_name: + consumed.add(low) + out.extend(p for p in file_profiles if p.name.lower() not in consumed) return out diff --git a/backend/muse/providers/codex.py b/backend/muse/providers/codex.py index 8018915..ff4fb04 100644 --- a/backend/muse/providers/codex.py +++ b/backend/muse/providers/codex.py @@ -44,6 +44,8 @@ _TOOL_INPUT_KEYS = ("command", "file_path", "path", "query", "url", "cmd") # per-file summary cache keyed by path -> (mtime, SessionSummary) _summary_cache: dict[str, tuple[float, SessionSummary]] = {} +# per-file context cache keyed by path -> (mtime, context_pct) +_context_cache: dict[str, tuple[float, Optional[float]]] = {} def _ts(value: Any) -> Optional[datetime]: @@ -204,6 +206,49 @@ def _meta(raw: list[dict]) -> dict[str, Any]: return {"cwd": cwd, "model": model, "version": version, "context_window": context_window} +def context_pct(session_id: str) -> Optional[float]: + """Live Codex context occupancy for tmux panes. + + Codex emits `token_count` event_msgs where `last_token_usage` describes the + latest prompt footprint and `model_context_window` gives the active model's + window. We read the newest such event and cache by rollout mtime.""" + loaded = _load_raw(session_id) + if loaded is None: + return None + path, raw = loaded + try: + mtime = path.stat().st_mtime + except OSError: + return None + cached = _context_cache.get(str(path)) + if cached and cached[0] == mtime: + return cached[1] + + pct: Optional[float] = None + fallback_window = _meta(raw).get("context_window") + for obj in reversed(raw): + if obj.get("type") != "event_msg": + continue + p = obj.get("payload") or {} + if p.get("type") != "token_count": + continue + info = p.get("info") or {} + if not isinstance(info, dict): + continue + last = info.get("last_token_usage") or {} + if not isinstance(last, dict): + continue + input_tokens = last.get("input_tokens") + window = info.get("model_context_window") or p.get("model_context_window") or fallback_window + if not isinstance(input_tokens, (int, float)) or not isinstance(window, (int, float)) or window <= 0: + continue + pct = max(0.0, min(100.0, float(input_tokens) * 100.0 / float(window))) + break + + _context_cache[str(path)] = (mtime, pct) + return pct + + def _build_items(raw: list[dict]) -> tuple[list[ThreadItem], dict[str, ToolResult], dict[str, str]]: """Return (items, results-by-call_id, call_id->tool_call_uuid).""" items: list[ThreadItem] = [] diff --git a/backend/muse/routers/interact.py b/backend/muse/routers/interact.py index 4eaafa6..7ce31aa 100644 --- a/backend/muse/routers/interact.py +++ b/backend/muse/routers/interact.py @@ -38,7 +38,7 @@ def _find_pane(session_id: str) -> str: if ls is None: raise HTTPException( status_code=400, - detail="session has no live process (not running, or not a Claude Code session)", + detail="session has no live process (not running, or not matched to a live pane)", ) if not ls.pane_id: raise HTTPException( diff --git a/backend/muse/routers/options.py b/backend/muse/routers/options.py index 54564ac..1f24302 100644 --- a/backend/muse/routers/options.py +++ b/backend/muse/routers/options.py @@ -36,9 +36,10 @@ def _to_api(menu: opt.ParsedMenu, session_id: str, pane_id: str) -> PendingOptio source=menu.source, available=True, prompt=menu.prompt, + detail=menu.detail, options=options, current_index=menu.current_index, - fingerprint=opt.fingerprint(menu.prompt, menu.options), + fingerprint=opt.fingerprint(menu.prompt, menu.options, menu.detail), remaining_questions=menu.remaining_questions, pane_id=pane_id, ) diff --git a/backend/muse/routers/tmux.py b/backend/muse/routers/tmux.py index 06a5489..e3813e7 100644 --- a/backend/muse/routers/tmux.py +++ b/backend/muse/routers/tmux.py @@ -12,6 +12,7 @@ import json import os import re +import shlex import time from concurrent.futures import ThreadPoolExecutor @@ -26,6 +27,8 @@ from ..autopilot import snapshot as layout_snapshot from ..autopilot import tmux from ..models import PendingOption, SlashCommand, TmuxLayout, TmuxPane +from ..providers import codex as codex_provider +from ..providers import provider_for router = APIRouter(prefix="/api/tmux", tags=["tmux"]) @@ -54,6 +57,18 @@ # Modifier prefixes on a key string: "c-c" → Ctrl-C, "m-f" → Alt-F, "c-left". _MOD_RE = re.compile(r"^((?:[cms]-)+)(.+)$") _FN_RE = re.compile(r"^f([1-9]|1[0-2])$") +_COMMAND_PROVIDER = { + "claude": "claude", + "antigravity": "gemini", + "codex": "codex", + "opencode": "opencode", +} +_WINDOW_PROVIDER = { + "claude": "claude", + "gemini": "gemini", + "codex": "codex", + "opencode": "opencode", +} def _resolve_key(raw: str) -> Optional[tuple[str, bool]]: @@ -78,6 +93,39 @@ def _resolve_key(raw: str) -> Optional[tuple[str, bool]]: if len(k) == 1 and k.isprintable(): return k, True # literal char (/, |, -, …) return None + + +def _infer_provider( + command: str, start_command: str = "", window_name: str = "" +) -> Optional[str]: + """Best-effort provider tag for a tmux pane. Tracked sessions use their real + provider id; untracked panes fall back to the executable name.""" + for raw in (start_command, command): + try: + argv = shlex.split(raw or "") + except ValueError: + continue + if not argv: + continue + provider = _COMMAND_PROVIDER.get(os.path.basename(argv[0]).lower()) + if provider: + return provider + return _WINDOW_PROVIDER.get((window_name or "").strip().lower()) + + +def _pane_capabilities(provider: Optional[str], tracked: bool) -> dict[str, bool]: + is_claude = provider == "claude" + return { + "mode_switch": is_claude, + "session_reply": tracked and provider is not None, + "rich_reply": tracked and is_claude, + "queue_replies": tracked and is_claude, + "reader": tracked and provider is not None, + "drive": tracked and provider is not None, + "slash_commands": is_claude, + } + + _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") # A full-width run of the same rule/border char (Claude's input box, separators) # wraps into many junk lines on a phone — collapse long runs to a short rule. @@ -132,6 +180,14 @@ class LaunchProfile(BaseModel): session: str | None = None # target group (tmux session); default = _default_session() +class LaunchCodexFromSession(BaseModel): + source_session_id: str + cwd: str + session: str | None = None # target group (tmux session); default = source/default + window_name: str = "" + prompt: str = "" + + class GroupBody(BaseModel): name: str # tmux session name — create target, or rename destination @@ -188,6 +244,11 @@ def _default_session() -> str | None: return max(pool, key=lambda s: len(windows.get(s, ())), default=None) +def _codex_window_label(window_name: str, cwd: str) -> str: + base = " ".join((window_name or os.path.basename(cwd.rstrip("/")) or "session").split()) + return f"{base} codex"[:40] + + @router.get("/layout", response_model=TmuxLayout) def layout(request: Request, previews: int = 1) -> TmuxLayout: """The whole tmux topology. `previews=0` omits the (heavy) per-pane screen @@ -228,14 +289,24 @@ def layout(request: Request, previews: int = 1) -> TmuxLayout: else [] ) ls = live_by_pane.get(p["pane_id"]) + provider = ( + provider_for(ls.session_id).id + if ls + else _infer_provider(p["command"], p.get("start_command", ""), p["window_name"]) + ) + context_pct = ctx_pcts.get(ls.session_id) if ls else None + if context_pct is None and ls and provider == "codex": + context_pct = codex_provider.context_pct(ls.session_id) + caps = _pane_capabilities(provider, ls is not None) status, attention = _attention(ls, bool(options)) # Every Claude pane has a mode; its "default" footer drops the status-line # marker, so fall back to default rather than hiding the (still tappable) chip. mode = opt.parse_mode(screen) - if mode is None and p["command"] == "claude": + if mode is None and provider == "claude": mode = "default" panes.append( TmuxPane( + provider=provider, pane_id=p["pane_id"], session_name=p["session_name"], window_index=p["window_index"], @@ -250,11 +321,12 @@ def layout(request: Request, previews: int = 1) -> TmuxLayout: session_attached=p["session_attached"], last_activity=p["last_activity"], muse_session_id=ls.session_id if ls else None, - context_pct=ctx_pcts.get(ls.session_id) if ls else None, + context_pct=context_pct, queued=queued.get(ls.session_id, 0) if ls else 0, status=status, attention=attention, mode=mode, + capabilities=caps, preview=preview if previews else "", preview_tail=_tail_line(preview), options=options, @@ -321,8 +393,11 @@ def capture(pane_id: str, lines: int = 40) -> dict: @router.post("/panes/{pane_id}/send") def send(pane_id: str, body: PaneSend, request: Request) -> dict: _require_pane(pane_id) - text = body.text.strip() - if not text: + text = body.text + # Terminal passthrough can legitimately send a literal space chunk with + # submit=false. Preserve raw text; only reject a truly empty payload, and + # keep blank submitted prompts out of the normal composer flow. + if text == "" or (body.submit and not text.strip()): raise HTTPException(status_code=400, detail="text is empty") ok, err = tmux.send_text(pane_id, text, submit=body.submit) if not ok: @@ -424,12 +499,77 @@ def launch_profile(name: str, body: LaunchProfile, request: Request) -> dict: ok, result = tmux.new_window(cwd, command, session=session, name=label or None) if not ok: raise HTTPException(status_code=400, detail=f"tmux: {result}") + service = getattr(request.app.state, "service", None) + if service is not None: + try: + service.refresh_sessions_soon() + except Exception: + pass request.app.state.autopilot.store.log( "tmux", "profile_launch", f"{profile.name}: {result} in {cwd}" ) return {"ok": True, "pane_id": result} +@router.post("/codex/launch") +def launch_codex_from_session(body: LaunchCodexFromSession, request: Request) -> dict: + """Open a Codex window in the source session's cwd/group, seeded from another + muse session. Codex sources use native `codex fork`; everything else becomes + a muse-owned context pack that the new Codex session reads on startup.""" + if not tmux.available(): + raise HTTPException(status_code=400, detail="tmux is not running") + source_session_id = body.source_session_id.strip() + cwd = body.cwd.strip() + if not source_session_id: + raise HTTPException(status_code=400, detail="source_session_id is required") + if not cwd: + raise HTTPException(status_code=400, detail="cwd is required") + if not os.path.isdir(cwd): + raise HTTPException(status_code=400, detail=f"not a directory: {cwd}") + try: + profile = profiles_mod.find_profile("Codex") + except profiles_mod.ProfileError as e: + raise HTTPException(status_code=400, detail=str(e)) + if profile is None: + raise HTTPException(status_code=404, detail="no such profile: 'Codex'") + _profile_cwd, base_command = profiles_mod.render(profile, {}) + session = _require_session_name(body.session) if body.session else _default_session() + prompt = body.prompt.strip() + pack_id = None + if provider_for(source_session_id).id == "codex": + source_id = source_session_id.removeprefix("codex:") + command = f"{base_command} fork {shlex.quote(source_id)}" + if prompt: + command += f" {shlex.quote(prompt)}" + else: + service = getattr(request.app.state, "service", None) + if service is None: + raise HTTPException(status_code=500, detail="session service unavailable") + pack = service.create_pack(source_session_id, True, None, True, "", "") + pack_id = pack.id + preamble = f"Read {pack.path} for context from my previous session" + full_prompt = f"{preamble}, then: {prompt}" if prompt else preamble + command = f"{base_command} {shlex.quote(full_prompt)}" + ok, result = tmux.new_window( + cwd, + command, + session=session, + name=_codex_window_label(body.window_name, cwd), + ) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {result}") + service = getattr(request.app.state, "service", None) + if service is not None: + try: + service.refresh_sessions_soon() + except Exception: + pass + request.app.state.autopilot.store.log( + "tmux", "codex_launch", f"{source_session_id}: {result} in {cwd}" + ) + return {"ok": True, "pane_id": result, "pack_id": pack_id} + + # --- Session restore ------------------------------------------------------------- # muse snapshots the tmux topology (see autopilot/snapshot.py) so it can be rebuilt after # a reboot, resuming each Claude session where it left off (`claude --resume `). diff --git a/backend/muse/services/session_service.py b/backend/muse/services/session_service.py index fa4d0e5..fea994e 100644 --- a/backend/muse/services/session_service.py +++ b/backend/muse/services/session_service.py @@ -247,6 +247,10 @@ def _kick_sessions_refresh(self) -> None: self._sessions_refreshing = True threading.Thread(target=self._bg_rebuild_sessions, daemon=True).start() + def refresh_sessions_soon(self) -> None: + """Public nudge for routes that know session artifacts may have changed.""" + self._kick_sessions_refresh() + def _bg_rebuild_sessions(self) -> None: try: self._rebuild_sessions() diff --git a/backend/tests/test_codex_adapter.py b/backend/tests/test_codex_adapter.py index 29f14b4..7a6bae7 100644 --- a/backend/tests/test_codex_adapter.py +++ b/backend/tests/test_codex_adapter.py @@ -37,13 +37,23 @@ def _write_session(codex_dir): _line("response_item", {"type": "custom_tool_call_output", "call_id": "c2", "output": "ok"}), _line("response_item", {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}), - _line("event_msg", {"type": "token_count", "info": {"total_token_usage": { - "input_tokens": 2000, "cached_input_tokens": 1000, "output_tokens": 100, - "reasoning_output_tokens": 0}}}), + _line("event_msg", {"type": "token_count", "info": { + "model_context_window": 258400, + "last_token_usage": {"input_tokens": 32000, "output_tokens": 100}, + "total_token_usage": { + "input_tokens": 2000, "cached_input_tokens": 1000, "output_tokens": 100, + "reasoning_output_tokens": 0, + }, + }}), # last (cumulative) wins; real = (6000-1000) + 500 + 178 = 5678 - _line("event_msg", {"type": "token_count", "info": {"total_token_usage": { - "input_tokens": 6000, "cached_input_tokens": 1000, "output_tokens": 500, - "reasoning_output_tokens": 178}}}), + _line("event_msg", {"type": "token_count", "info": { + "model_context_window": 258400, + "last_token_usage": {"input_tokens": 103597, "output_tokens": 500}, + "total_token_usage": { + "input_tokens": 6000, "cached_input_tokens": 1000, "output_tokens": 500, + "reasoning_output_tokens": 178, + }, + }}), _line("compacted", {"message": "summary", "replacement_history": []}), ] f = d / f"rollout-2026-06-08T00-00-00-{UUID}.jsonl" @@ -56,6 +66,7 @@ def codex_env(tmp_path, monkeypatch): codex.get_settings # noqa: B018 monkeypatch.setattr(codex, "get_settings", lambda: SimpleNamespace(codex_dir=tmp_path)) codex._summary_cache.clear() + codex._context_cache.clear() _write_session(tmp_path) return tmp_path @@ -73,6 +84,12 @@ def test_discovery_and_metadata(codex_env): assert s.total_tokens == 5678 # last cumulative token_count wins +def test_context_pct_uses_latest_prompt_tokens(codex_env): + pct = codex.context_pct(f"codex:{UUID}") + assert pct is not None + assert round(pct) == 40 # 103,597 / 258,400 + + def test_thread_parse_and_tool_pairing(codex_env): p = codex.CodexProvider() t = p.load_thread(f"codex:{UUID}") diff --git a/backend/tests/test_live_sessions.py b/backend/tests/test_live_sessions.py new file mode 100644 index 0000000..de02359 --- /dev/null +++ b/backend/tests/test_live_sessions.py @@ -0,0 +1,127 @@ +from pathlib import Path +from types import SimpleNamespace + +from muse.autopilot import sessions as live_sessions + + +def _mkdb(path: Path, sql: str) -> None: + import sqlite3 + + conn = sqlite3.connect(path) + try: + conn.executescript(sql) + conn.commit() + finally: + conn.close() + + +def test_discover_codex_matches_pane_to_thread_id(monkeypatch, tmp_path): + codex_dir = tmp_path / "codex" + codex_dir.mkdir() + state = codex_dir / "state_5.sqlite" + logs = codex_dir / "logs_2.sqlite" + _mkdb( + state, + """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + cli_version TEXT NOT NULL DEFAULT '', + first_user_message TEXT NOT NULL DEFAULT '', + agent_nickname TEXT, + agent_role TEXT, + memory_mode TEXT NOT NULL DEFAULT 'enabled', + model TEXT, + reasoning_effort TEXT, + agent_path TEXT, + created_at_ms INTEGER, + updated_at_ms INTEGER, + thread_source TEXT, + preview TEXT NOT NULL DEFAULT '', + recency_at INTEGER NOT NULL DEFAULT 0, + recency_at_ms INTEGER NOT NULL DEFAULT 0, + history_mode TEXT NOT NULL DEFAULT 'legacy' + ); + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, created_at_ms, updated_at_ms, cli_version + ) VALUES ( + '019f44ea-d2c3-7df1-9d1c-b240b4e748d8', + '/tmp/rollout.jsonl', 0, 0, 'cli', 'openai', '/home/luke/workspace/muse', + 'muse session', 'workspace-write', 'never', 1783567667908, 1783570008098, '0.143.0' + ); + """, + ) + _mkdb( + logs, + """ + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO logs (ts, ts_nanos, level, target, thread_id, process_uuid) + VALUES (1783570008, 0, 'INFO', 'codex_core::turn', '019f44ea-d2c3-7df1-9d1c-b240b4e748d8', + 'pid:2525803:ab823cb6-2407-4060-9358-6636ba8568fd'); + """, + ) + + monkeypatch.setattr( + live_sessions, + "get_settings", + lambda: SimpleNamespace(claude_dir=tmp_path / "claude", codex_dir=codex_dir), + ) + monkeypatch.setattr( + live_sessions.tmux, + "list_panes", + lambda: [ + { + "pane_id": "%163", + "pane_pid": 2525610, + "cmd": "node", + "cwd": "/home/luke/workspace/muse", + } + ], + ) + monkeypatch.setattr( + live_sessions, + "_proc_snapshot", + lambda: { + 2525610: {"ppid": 1, "comm": "node", "cmdline": "node /home/luke/.npm-global/bin/codex"}, + 2525792: {"ppid": 2525610, "comm": "node", "cmdline": "node /home/luke/.npm-global/bin/codex"}, + 2525803: {"ppid": 2525792, "comm": "codex", "cmdline": "/vendor/bin/codex"}, + }, + ) + + got = live_sessions.discover() + codex = next((s for s in got if s.session_id.startswith("codex:")), None) + assert codex is not None + assert codex.session_id == "codex:019f44ea-d2c3-7df1-9d1c-b240b4e748d8" + assert codex.pane_id == "%163" + assert codex.cwd == "/home/luke/workspace/muse" + assert codex.version == "0.143.0" + assert codex.status == "shell" diff --git a/backend/tests/test_options.py b/backend/tests/test_options.py index 2bb1da0..262c871 100644 --- a/backend/tests/test_options.py +++ b/backend/tests/test_options.py @@ -119,3 +119,22 @@ def test_exit_plan_mode_detected(): assert menu is not None assert len(menu.options) == 2 assert menu.options[0].label == "Yes, proceed" + # The full plan body rides along so it's reviewable at the point of answering. + assert menu.detail == "Step one\nStep two" + + +def test_exit_plan_fingerprint_tracks_plan_body(): + """Two plans with the same one-line prompt but different bodies must not share a + fingerprint — otherwise a stale selection could act on a re-issued plan.""" + def menu_for(plan: str): + tu = ToolUse(id="p1", name="ExitPlanMode", input={"plan": plan}) + item = ThreadItem( + uuid="u1", role="assistant", type="assistant", + blocks=[ContentBlock(kind="tool_use", tool_use=tu)], + ) + return find_pending_tool_question(_thread(item)) + + a = menu_for("Do X\nthen Y") + b = menu_for("Do X\nthen Z") + assert a.prompt == b.prompt # same one-liner + assert fingerprint(a.prompt, a.options, a.detail) != fingerprint(b.prompt, b.options, b.detail) diff --git a/backend/tests/test_profiles.py b/backend/tests/test_profiles.py index f885ebc..0d71df9 100644 --- a/backend/tests/test_profiles.py +++ b/backend/tests/test_profiles.py @@ -1,5 +1,5 @@ """Launch profiles: hand-authored templates (~/.muse/profiles.toml) for opening a new -tmux window. profiles.load_profiles parses + validates the TOML (built-in default first); +tmux window. profiles.load_profiles parses + validates the TOML (built-ins first); render() substitutes {key} params (shell-quoted into the command, raw into the cwd); the router launches via tmux.new_window into the current group (or the default session).""" @@ -27,10 +27,16 @@ def write(text: str) -> None: # --- load_profiles ---------------------------------------------------------------- -def test_no_file_yields_only_builtin_default(profiles_file): +def test_no_file_yields_builtin_provider_profiles(profiles_file): got = profiles.load_profiles() - assert [p.name for p in got] == ["Claude"] - assert got[0].builtin and got[0].command == "claude" + assert [p.name for p in got] == ["Claude", "Gemini", "Codex", "OpenCode"] + assert [(p.provider, p.command) for p in got] == [ + ("claude", "claude"), + ("gemini", "antigravity"), + ("codex", "codex"), + ("opencode", "opencode"), + ] + assert all(p.builtin for p in got) def test_parses_profiles_default_first_in_file_order(profiles_file): @@ -49,8 +55,8 @@ def test_parses_profiles_default_first_in_file_order(profiles_file): """ ) got = profiles.load_profiles() - assert [p.name for p in got] == ["Claude", "igloo dev", "web"] - igloo = got[1] + assert [p.name for p in got] == ["Claude", "Gemini", "Codex", "OpenCode", "igloo dev", "web"] + igloo = got[4] assert igloo.params[0].key == "issue" and igloo.params[0].default == "" assert not igloo.builtin @@ -65,10 +71,25 @@ def test_file_profile_named_claude_overrides_builtin(profiles_file): """ ) got = profiles.load_profiles() - assert [p.name for p in got] == ["Claude"] # not duplicated + assert [p.name for p in got][:4] == ["Claude", "Gemini", "Codex", "OpenCode"] assert got[0].command == "claude --resume" and not got[0].builtin +def test_file_profile_named_gemini_overrides_builtin_in_place(profiles_file): + profiles_file( + """ + [[profile]] + name = "Gemini" + provider = "gemini" + cwd = "~/work" + command = "antigravity --resume" + """ + ) + got = profiles.load_profiles() + assert [p.name for p in got][:4] == ["Claude", "Gemini", "Codex", "OpenCode"] + assert got[1].command == "antigravity --resume" and not got[1].builtin + + @pytest.mark.parametrize( "text", [ @@ -202,11 +223,20 @@ def log(self, sid, action, detail=""): self.entries.append((sid, action, detail)) +class FakeService: + def __init__(self): + self.refreshes = 0 + + def refresh_sessions_soon(self): + self.refreshes += 1 + + @pytest.fixture def client(monkeypatch, tmp_path): app = FastAPI() app.include_router(tmux_router.router) app.state.autopilot = type("AP", (), {"store": FakeStore()})() + app.state.service = FakeService() calls: list[tuple] = [] monkeypatch.setattr(tmux_router.tmux, "available", lambda: True) @@ -219,13 +249,14 @@ def client(monkeypatch, tmp_path): ) c = TestClient(app) c.calls = calls + c.service = app.state.service return c def test_list_profiles_includes_builtin(client, profiles_file): r = client.get("/api/tmux/profiles") assert r.status_code == 200 - assert [p["name"] for p in r.json()] == ["Claude"] + assert [p["name"] for p in r.json()] == ["Claude", "Gemini", "Codex", "OpenCode"] def test_list_profiles_surfaces_broken_config(client, profiles_file): @@ -240,6 +271,7 @@ def test_launch_default_profile_uses_default_session(client, profiles_file): home = __import__("os").path.expanduser("~") # No params → the window is labelled with the profile name. assert client.calls == [(home, "claude", "main", "Claude")] + assert client.service.refreshes == 1 def test_launch_renders_params_and_targets_group(client, profiles_file, tmp_path): diff --git a/backend/tests/test_tmux_groups.py b/backend/tests/test_tmux_groups.py index 8b36add..87da1fe 100644 --- a/backend/tests/test_tmux_groups.py +++ b/backend/tests/test_tmux_groups.py @@ -5,6 +5,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from types import SimpleNamespace from muse.autopilot import tmux as tmux_mod from muse.routers import tmux as tmux_router @@ -56,6 +57,12 @@ def test_rename_window_disables_auto_rename_then_renames(monkeypatch): ] +def test_infer_provider_prefers_start_command_then_window_name(): + assert tmux_router._infer_provider("node", "codex", "scratch") == "codex" + assert tmux_router._infer_provider("python", "antigravity --resume", "scratch") == "gemini" + assert tmux_router._infer_provider("bash", "", "OpenCode") == "opencode" + + # --- router endpoints ------------------------------------------------------------- @@ -67,11 +74,41 @@ def log(self, sid, action, detail=""): self.entries.append((sid, action, detail)) +class FakeService: + def __init__(self): + self.created_packs = [] + + def create_pack( + self, + source_session_id, + include_brief=True, + note_ids=None, + include_files=True, + extra_md="", + title="", + ): + self.created_packs.append( + { + "source_session_id": source_session_id, + "include_brief": include_brief, + "note_ids": note_ids, + "include_files": include_files, + "extra_md": extra_md, + "title": title, + } + ) + return SimpleNamespace(id="pk_test", path="/tmp/pk_test.md") + + def refresh_sessions_soon(self): + return None + + @pytest.fixture def client(monkeypatch): app = FastAPI() app.include_router(tmux_router.router) app.state.autopilot = type("AP", (), {"store": FakeStore()})() + app.state.service = FakeService() calls: list[tuple] = [] monkeypatch.setattr(tmux_router.tmux, "available", lambda: True) @@ -93,6 +130,16 @@ def client(monkeypatch): monkeypatch.setattr( tmux_router.tmux, "kill_window", lambda w: (calls.append(("killwin", w)) or (True, "")) ) + monkeypatch.setattr( + tmux_router.tmux, "send_text", lambda p, t, submit=True: (calls.append(("send", p, t, submit)) or (True, "")) + ) + monkeypatch.setattr( + tmux_router.tmux, + "new_window", + lambda cwd, command, session=None, name=None: ( + calls.append(("new-window", cwd, command, session, name)) or (True, "%99") + ), + ) # A window @7 whose active pane sits in a cwd claimed by a cleanup profile. monkeypatch.setattr( tmux_router.tmux, @@ -119,6 +166,17 @@ def test_close_window_kills_only_without_cleanup(client, monkeypatch): assert ("killwin", "@7") in client.calls and ran == [] # no cleanup when not opted in +def test_pane_send_preserves_literal_spaces_when_not_submitting(client): + r = client.post("/api/tmux/panes/%252512/send", json={"text": " ", "submit": False}) + assert r.status_code == 200 and r.json()["ok"] is True + assert ("send", "%12", " ", False) in client.calls + + +def test_pane_send_rejects_blank_submitted_prompt(client): + r = client.post("/api/tmux/panes/%252512/send", json={"text": " ", "submit": True}) + assert r.status_code == 400 + + def test_close_window_runs_cleanup_after_kill(client, monkeypatch): monkeypatch.setattr( tmux_router.profiles_mod, @@ -207,3 +265,66 @@ def test_rename_window_rejects_bad_names(client, bad): def test_rename_window_rejects_bad_window_id(client): r = client.post("/api/tmux/windows/nope/rename", json={"name": "x"}) assert r.status_code == 400 and client.calls == [] + + +def test_launch_codex_from_codex_session_forks_in_place(client, monkeypatch): + monkeypatch.setattr(tmux_router.os.path, "isdir", lambda path: path == "/proj/stuff") + monkeypatch.setattr( + tmux_router.profiles_mod, + "find_profile", + lambda name: SimpleNamespace(name="Codex", cwd="~", command="codex", params=[]), + ) + r = client.post( + "/api/tmux/codex/launch", + json={ + "source_session_id": "codex:019f44ea-d2c3-7df1-9d1c-b240b4e748d8", + "cwd": "/proj/stuff", + "session": "work", + "window_name": "muse", + }, + ) + assert r.status_code == 200 + assert client.app.state.service.created_packs == [] + assert client.calls[-1] == ( + "new-window", + "/proj/stuff", + "codex fork 019f44ea-d2c3-7df1-9d1c-b240b4e748d8", + "work", + "muse codex", + ) + + +def test_launch_codex_from_other_session_builds_reference_pack(client, monkeypatch): + monkeypatch.setattr(tmux_router.os.path, "isdir", lambda path: path == "/proj/stuff") + monkeypatch.setattr( + tmux_router.profiles_mod, + "find_profile", + lambda name: SimpleNamespace(name="Codex", cwd="~", command="codex --search", params=[]), + ) + r = client.post( + "/api/tmux/codex/launch", + json={ + "source_session_id": "sess-1", + "cwd": "/proj/stuff", + "session": "work", + "window_name": "muse", + }, + ) + assert r.status_code == 200 + assert client.app.state.service.created_packs == [ + { + "source_session_id": "sess-1", + "include_brief": True, + "note_ids": None, + "include_files": True, + "extra_md": "", + "title": "", + } + ] + assert client.calls[-1] == ( + "new-window", + "/proj/stuff", + "codex --search 'Read /tmp/pk_test.md for context from my previous session'", + "work", + "muse codex", + ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f27a1af..c57ad7e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -419,6 +419,19 @@ export const api = { { values, session: session ?? null }, ), + launchCodexFromSession: (body: { + source_session_id: string; + cwd: string; + session?: string | null; + window_name?: string; + prompt?: string; + }) => + sendJSON<{ ok: boolean; pane_id: string; pack_id: string | null }>( + "POST", + "/api/tmux/codex/launch", + body, + ), + // --- session restore (rebuild the tmux layout after a reboot) --- getTmuxSnapshot: () => getJSON("/api/tmux/snapshot"), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 09a3d5a..047bc2e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -771,6 +771,7 @@ export interface PendingOption { } export interface TmuxPane { + provider: "claude" | "gemini" | "codex" | "opencode" | null; pane_id: string; session_name: string; window_index: number; @@ -790,6 +791,15 @@ export interface TmuxPane { status: "needs_you" | "responded" | "working" | "idle"; attention: string; mode: "default" | "acceptEdits" | "plan" | "bypass" | null; + capabilities: { + mode_switch: boolean; + session_reply: boolean; + rich_reply: boolean; + queue_replies: boolean; + reader: boolean; + drive: boolean; + slash_commands: boolean; + }; preview: string; // full visible screen (ANSI); empty when polled with previews=0 preview_tail: string; // last visible line — task-list subtitle options: PendingOption[]; @@ -825,10 +835,11 @@ export interface ProfileParam { /** A launch profile from ~/.muse/profiles.toml: a template for opening a new window. */ export interface Profile { name: string; + provider: string; cwd: string; command: string; params: ProfileParam[]; - builtin: boolean; // the always-present "Claude" default + builtin: boolean; // one of muse's always-present built-ins } /** One window in a saved layout snapshot (for session-restore after a reboot). */ @@ -922,6 +933,7 @@ export interface PendingOptions { source: "permission" | "tool_question" | "none"; available: boolean; prompt: string; + detail?: string; // long-form context to review before answering (e.g. a plan body) options: PendingOption[]; current_index: number | null; fingerprint: string; diff --git a/frontend/src/components/ConversationView.plan.test.tsx b/frontend/src/components/ConversationView.plan.test.tsx new file mode 100644 index 0000000..640599e --- /dev/null +++ b/frontend/src/components/ConversationView.plan.test.tsx @@ -0,0 +1,82 @@ +/** Plans and questions render inline in the conversation (PlanLine/QuestionLine) + * instead of as a collapsed tool row — so a plan is reviewable in the reader and + * a pending question is visible there, without switching to the raw terminal. */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ConversationView from "./ConversationView"; +import { makeToolOnlyItem } from "../util/readerFixtures"; +import type { ThreadItem } from "../api/types"; + +// Markdown is exercised elsewhere; here render its text so we can assert content. +vi.mock("./Markdown", () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})); + +const noop = () => {}; + +function renderView(items: ThreadItem[]) { + return render( + , + ); +} + +function planItem(plan: string): ThreadItem { + const it = makeToolOnlyItem("plan", 1, 0, { name: "ExitPlanMode" }); + it.blocks[0].tool_use!.input = { plan }; + return it; +} + +function questionItem(): ThreadItem { + const it = makeToolOnlyItem("q", 1, 0, { name: "AskUserQuestion" }); + it.blocks[0].tool_use!.input = { + questions: [ + { + question: "Which database?", + options: [ + { label: "Postgres", description: "relational" }, + { label: "SQLite", description: "embedded" }, + ], + }, + ], + }; + return it; +} + +describe("reader inline plan / question", () => { + it("renders the plan body inline, expanded by default in the reader", () => { + renderView([planItem("## Plan\n\nFirst step, then second step.")]); + expect(screen.getByText("Plan")).toBeTruthy(); + expect(screen.getByText(/First step, then second step/)).toBeTruthy(); + // Focus (reader) mode opens the plan so it's readable without a tap; the + // toggle then offers to collapse it. + const toggle = screen.getByText("▴ collapse plan"); + fireEvent.click(toggle); + expect(screen.getByText("▾ read full plan")).toBeTruthy(); + }); + + it("does not collapse the plan into a '… steps' tool run", () => { + renderView([planItem("Do the thing.")]); + expect(screen.queryByText(/steps/)).toBeNull(); + expect(screen.queryByText("ExitPlanMode")).toBeNull(); // rendered as "Plan", not the raw name + }); + + it("renders a question with its options inline", () => { + renderView([questionItem()]); + expect(screen.getByText("Question")).toBeTruthy(); + expect(screen.getByText("Which database?")).toBeTruthy(); + expect(screen.getByText("Postgres")).toBeTruthy(); + expect(screen.getByText("SQLite")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ConversationView.tsx b/frontend/src/components/ConversationView.tsx index a0fe67f..9f5d507 100644 --- a/frontend/src/components/ConversationView.tsx +++ b/frontend/src/components/ConversationView.tsx @@ -785,6 +785,15 @@ function ToolLine({ const status = toolStatus(tool); const arg = toolArg(tool); + // A plan or a question is content to read/answer, not a step to skim past — so + // render it inline (visible in the reader) instead of a collapsed tool row. + if (tool.name === "ExitPlanMode") { + return ; + } + if (tool.name === "AskUserQuestion") { + return ; + } + return (
registerRef(tool.id, el)} @@ -807,6 +816,101 @@ function ToolLine({ ); } +/** ExitPlanMode rendered as the plan itself: the markdown body inline (capped + * with its own scroll, expandable), so a plan is reviewable in the reader + * instead of only in the raw terminal. `answered` (has a result) dims it. */ +function PlanLine({ + tool, + status, + registerRef, + defaultOpen = false, +}: { + tool: ToolUse; + status: string; + registerRef: (id: string, el: HTMLElement | null) => void; + defaultOpen?: boolean; +}) { + const [full, setFull] = useState(defaultOpen); + const plan = String(tool.input.plan ?? "").trim(); + const answered = tool.result != null; + return ( +
registerRef(tool.id, el)} + className={`cc-line cc-plan-line${answered ? " answered" : ""}`} + > +
+ + Plan + {answered && answered} +
+ {plan ? ( + <> +
+ {plan} +
+ + + ) : ( +
+ (empty plan) +
+ )} +
+ ); +} + +/** AskUserQuestion rendered read-only in the stream: the question and its + * choices, so it's visible in the reader. Answering happens through the + * OptionPicker (which addresses the live pane); this is the record of the ask. */ +function QuestionLine({ + tool, + status, + registerRef, +}: { + tool: ToolUse; + status: string; + registerRef: (id: string, el: HTMLElement | null) => void; +}) { + const questions = Array.isArray(tool.input.questions) + ? (tool.input.questions as Record[]) + : []; + const answered = tool.result != null; + return ( +
registerRef(tool.id, el)} + className={`cc-line cc-question-line${answered ? " answered" : ""}`} + > +
+ + Question + {answered && answered} +
+ {questions.map((q, qi) => { + const opts = Array.isArray(q.options) ? (q.options as Record[]) : []; + return ( +
+
+ {String(q.question ?? q.header ?? "Select an option")} +
+
    + {opts.map((o, oi) => ( +
  • + {String(o.label ?? "")} + {o.description ? ( + — {String(o.description)} + ) : null} +
  • + ))} +
+
+ ); + })} +
+ ); +} + function ResultConnector({ tool, expanded, diff --git a/frontend/src/components/OptionPicker.test.tsx b/frontend/src/components/OptionPicker.test.tsx new file mode 100644 index 0000000..932d64b --- /dev/null +++ b/frontend/src/components/OptionPicker.test.tsx @@ -0,0 +1,66 @@ +/** OptionPicker: renders the choices a session is presenting, plus (new) the + * long-form `detail` (a plan body) so it's reviewable right where it's answered. */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import OptionPicker from "./OptionPicker"; +import type { PendingOptions } from "../api/types"; + +function pending(over: Partial): PendingOptions { + return { + session_id: "s", + source: "tool_question", + available: true, + prompt: "Review the plan, then choose:", + detail: "", + options: [ + { id: "1", label: "Yes, proceed", description: null, kind: "menu" }, + { id: "2", label: "No, keep planning", description: null, kind: "menu" }, + ], + current_index: null, + fingerprint: "fp", + remaining_questions: 0, + pane_id: "%1", + in_tmux: true, + reason: null, + ...over, + }; +} + +describe("OptionPicker", () => { + it("renders the prompt and fires onSelect with the option id", () => { + const onSelect = vi.fn(); + render(); + expect(screen.getByText("Review the plan, then choose:")).toBeTruthy(); + fireEvent.click(screen.getByText("Yes, proceed")); + expect(onSelect).toHaveBeenCalledWith("1"); + }); + + it("renders the plan detail as reviewable content when present", () => { + render( + {}} + />, + ); + expect(screen.getByText(/First do the thing/)).toBeTruthy(); + }); + + it("toggles the detail between capped and full height", () => { + render( + {}} + />, + ); + const toggle = screen.getByText("▾ read full plan"); + fireEvent.click(toggle); + expect(screen.getByText("▴ collapse")).toBeTruthy(); + }); + + it("omits the detail block when there's no detail", () => { + render( {}} />); + expect(screen.queryByText(/read full plan/)).toBeNull(); + }); +}); diff --git a/frontend/src/components/OptionPicker.tsx b/frontend/src/components/OptionPicker.tsx index 4bf33bd..a833363 100644 --- a/frontend/src/components/OptionPicker.tsx +++ b/frontend/src/components/OptionPicker.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { PendingOptions } from "../api/types"; +import Markdown from "./Markdown"; const SOURCE_BADGE: Record = { permission: "permission", @@ -24,6 +25,9 @@ export default function OptionPicker({ }) { const [typing, setTyping] = useState(null); // option id in free-text mode const [freeText, setFreeText] = useState(""); + // Long-form context (a plan) starts capped-with-scroll; the user expands it to + // read the whole thing in place rather than switching to the raw terminal. + const [showFullDetail, setShowFullDetail] = useState(false); return (
@@ -36,6 +40,23 @@ export default function OptionPicker({ +{pending.remaining_questions} more after this )}
+ {pending.detail && ( +
+
+ {pending.detail} +
+ +
+ )}
{pending.options.map((o) => o.kind === "free_text" ? ( diff --git a/frontend/src/pages/DrivePage.tsx b/frontend/src/pages/DrivePage.tsx index 6de01d7..4caea26 100644 --- a/frontend/src/pages/DrivePage.tsx +++ b/frontend/src/pages/DrivePage.tsx @@ -34,6 +34,17 @@ export default function DrivePage() { const [draft, setDraft] = useState(""); // prefills the composer (top suggestion) const [queueBump, setQueueBump] = useState(0); // refresh QueueChips right after queueing const suggested = useRef(false); + const providerLabel = + thread?.provider === "claude" + ? "Claude" + : thread?.provider === "codex" + ? "Codex" + : thread?.provider === "gemini" + ? "Gemini" + : thread?.provider === "opencode" + ? "OpenCode" + : "Agent"; + const canQueue = thread?.provider === "claude"; // Enqueue suggest_replies, poll the job, prefill the composer with the top one and // expose the rest as tap-to-edit chips. Reused by auto-prefill and the ✦ button. @@ -57,7 +68,12 @@ export default function DrivePage() { const status = pending ? { cls: "wait", text: "Needs your input — choose below" } : live - ? { cls: "busy", text: "Claude is working — you can queue a reply or interrupt" } + ? { + cls: "busy", + text: canQueue + ? `${providerLabel} is working — you can queue a reply or interrupt` + : `${providerLabel} is working — send a reply or interrupt`, + } : { cls: "idle", text: "Your move — edit the suggested reply or write your own" }; useEffect(() => { @@ -157,7 +173,7 @@ export default function DrivePage() { {pending && } - + {canQueue && } {suggestions.length > 1 && (
@@ -178,7 +194,7 @@ export default function DrivePage() { setQueueBump((n) => n + 1)} diff --git a/frontend/src/pages/PanesPage.groups.test.tsx b/frontend/src/pages/PanesPage.groups.test.tsx index 64f5d74..c9a9314 100644 --- a/frontend/src/pages/PanesPage.groups.test.tsx +++ b/frontend/src/pages/PanesPage.groups.test.tsx @@ -7,6 +7,7 @@ import type { TmuxPane } from "../api/types"; function pane(over: Partial): TmuxPane { return { + provider: "claude", pane_id: "%1", session_name: "a", window_index: 0, @@ -26,6 +27,15 @@ function pane(over: Partial): TmuxPane { status: "idle", attention: "", mode: null, + capabilities: { + mode_switch: true, + session_reply: true, + rich_reply: true, + queue_replies: true, + reader: true, + drive: true, + slash_commands: true, + }, preview: "", preview_tail: "", options: [], @@ -53,7 +63,22 @@ describe("buildGroups", () => { it("marks a session of only idle non-Claude shells as a deletable placeholder", () => { const groups = buildGroups( buildWindows([ - pane({ session_name: "empty", command: "bash", muse_session_id: null, status: "idle" }), + pane({ + provider: null, + session_name: "empty", + command: "bash", + muse_session_id: null, + status: "idle", + capabilities: { + mode_switch: false, + session_reply: false, + rich_reply: false, + queue_replies: false, + reader: false, + drive: false, + slash_commands: false, + }, + }), pane({ pane_id: "%9", session_name: "live", command: "claude", status: "working" }), ]), ); @@ -100,6 +125,31 @@ describe("TaskRow move menu", () => { }); }); +describe("TaskRow close (✕)", () => { + const win = buildWindows([pane({ session_name: "a", window_id: "@7", window_name: "job" })])[0]; + + it("shows an always-visible close button that fires onRemove (opens the confirm)", () => { + const onRemove = vi.fn(); + const onOpen = vi.fn(); + render(); + const btn = screen.getByLabelText("Close session"); + fireEvent.click(btn); + expect(onRemove).toHaveBeenCalledTimes(1); + expect(onOpen).not.toHaveBeenCalled(); // click doesn't also open the pane + }); + + it("omits the close button without onRemove", () => { + render( {}} />); + expect(screen.queryByLabelText("Close session")).toBeNull(); + }); + + it("omits the close button when the window has no id (can't target it)", () => { + const w = buildWindows([pane({ session_name: "a", window_id: "" })])[0]; + render( {}} onRemove={() => {}} />); + expect(screen.queryByLabelText("Close session")).toBeNull(); + }); +}); + describe("TaskRow rename (desktop ✎)", () => { const win = buildWindows([pane({ session_name: "a", window_id: "@7", window_name: "old-name" })])[0]; diff --git a/frontend/src/pages/PanesPage.profiles.test.tsx b/frontend/src/pages/PanesPage.profiles.test.tsx index 889d681..e9ba74c 100644 --- a/frontend/src/pages/PanesPage.profiles.test.tsx +++ b/frontend/src/pages/PanesPage.profiles.test.tsx @@ -8,23 +8,82 @@ vi.mock("../api/client", () => ({ api: { listProfiles: vi.fn(), launchProfile: vi.fn() }, })); import { api } from "../api/client"; -import { NewWindowLauncher } from "./PanesPage"; -import type { Profile } from "../api/types"; +import { NewWindowLauncher, waitForPane } from "./PanesPage"; +import type { Profile, TmuxLayout } from "../api/types"; function profile(over: Partial): Profile { - return { name: "p", cwd: "~", command: "claude", params: [], builtin: false, ...over }; + return { + name: "p", + provider: "claude", + cwd: "~", + command: "claude", + params: [], + builtin: false, + ...over, + }; } beforeEach(() => { + localStorage.clear(); vi.mocked(api.listProfiles).mockReset(); vi.mocked(api.launchProfile).mockReset(); }); describe("NewWindowLauncher", () => { - it("launches the built-in Claude profile from the primary button", () => { + it("waits briefly for a launched pane to appear in tmux layout", async () => { + const layouts: TmuxLayout[] = [ + { available: true, panes: [], reason: null }, + { + available: true, + panes: [ + { + provider: "codex", + pane_id: "%9", + session_name: "main", + window_index: 0, + window_id: "@9", + window_name: "Codex", + window_active: true, + pane_index: 0, + pane_active: true, + command: "node", + cwd: "/tmp", + title: "", + session_attached: true, + last_activity: 0, + muse_session_id: null, + context_pct: null, + queued: 0, + status: "idle", + attention: "", + mode: null, + capabilities: { + mode_switch: false, + session_reply: false, + rich_reply: false, + queue_replies: false, + reader: false, + drive: false, + slash_commands: false, + }, + preview: "", + preview_tail: "", + options: [], + }, + ], + reason: null, + }, + ]; + let calls = 0; + const fresh = await waitForPane(async () => layouts[calls++] ?? layouts[layouts.length - 1], "%9", 3, 0); + expect(calls).toBe(2); + expect(fresh.panes[0].pane_id).toBe("%9"); + }); + + it("launches the default built-in provider from the primary button", () => { const onLaunch = vi.fn(); render(); - fireEvent.click(screen.getByText("New window")); + fireEvent.click(screen.getByText("New Claude")); expect(onLaunch).toHaveBeenCalledWith("Claude", {}); expect(api.listProfiles).not.toHaveBeenCalled(); // no menu fetch for the fast path }); @@ -32,11 +91,14 @@ describe("NewWindowLauncher", () => { it("opens the caret menu, lists profiles, and launches a params-less one immediately", async () => { vi.mocked(api.listProfiles).mockResolvedValue([ profile({ name: "Claude", builtin: true }), + profile({ name: "Gemini", provider: "gemini", command: "antigravity", builtin: true }), profile({ name: "web", command: "npm run dev & claude" }), ]); const onLaunch = vi.fn(); render(); fireEvent.click(screen.getByTitle("Launch a profile")); + expect(await screen.findByText("Providers")).toBeTruthy(); + expect(screen.getByText("Profiles")).toBeTruthy(); fireEvent.click(await screen.findByText("web")); expect(onLaunch).toHaveBeenCalledWith("web", {}); }); diff --git a/frontend/src/pages/PanesPage.remove.test.tsx b/frontend/src/pages/PanesPage.remove.test.tsx index ba63998..2af09bc 100644 --- a/frontend/src/pages/PanesPage.remove.test.tsx +++ b/frontend/src/pages/PanesPage.remove.test.tsx @@ -26,6 +26,22 @@ describe("MoveMenu Remove row", () => { fireEvent.click(screen.getByTitle("Move to a group")); expect(screen.queryByText("Remove session…")).toBeNull(); }); + + it("renders a New Codex row only when onLaunchCodex is provided, and fires it", () => { + const onLaunchCodex = vi.fn(); + render( + {}} + onMoveNew={() => {}} + onLaunchCodex={onLaunchCodex} + />, + ); + fireEvent.click(screen.getByTitle("Move to a group")); + fireEvent.click(screen.getByText("New Codex here")); + expect(onLaunchCodex).toHaveBeenCalledTimes(1); + }); }); describe("RemoveDialog", () => { diff --git a/frontend/src/pages/PanesPage.tsx b/frontend/src/pages/PanesPage.tsx index 2f3cb62..8696151 100644 --- a/frontend/src/pages/PanesPage.tsx +++ b/frontend/src/pages/PanesPage.tsx @@ -19,6 +19,12 @@ import { useTypeToFocus } from "../util/typeToFocus"; type Status = "needs_you" | "responded" | "working" | "idle"; const RANK: Record = { needs_you: 0, responded: 1, working: 2, idle: 3 }; const SECTION_ORDER: Status[] = ["needs_you", "responded", "working", "idle"]; +const PROVIDER_LABEL: Record = { + claude: "Claude", + gemini: "Gemini", + codex: "Codex", + opencode: "OpenCode", +}; export interface Win { key: string; @@ -38,6 +44,22 @@ const SORTS: { mode: SortMode; label: string }[] = [ { mode: "alpha", label: "A–Z" }, ]; +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +export async function waitForPane( + getLayout: () => Promise, + paneId: string, + attempts = 20, + delayMs = 250, +): Promise { + let layout = await getLayout(); + for (let i = 1; i < attempts && !layout.panes.some((p) => p.pane_id === paneId); i += 1) { + await sleep(delayMs); + layout = await getLayout(); + } + return layout; +} + // Order windows for the current sort. "attention" keeps the urgent-first grouping // (ties broken by recency); "recent" and "alpha" are flat reorderings. function sortWins(wins: Win[], mode: SortMode): Win[] { @@ -81,7 +103,7 @@ export interface Group { windows: number; // window count in the session status: Status; // most-urgent status across its windows last_activity: number; - placeholder: boolean; // every pane is a non-Claude idle shell → safe to delete + placeholder: boolean; // every pane is an idle non-agent shell → safe to delete } export function buildGroups(wins: Win[]): Group[] { @@ -102,12 +124,12 @@ export function buildGroups(wins: Win[]): Group[] { return groups; } -// Scratch/background: a window with no Claude agent that's just sitting idle. +// Scratch/background: a window with no tracked or known agent provider that's just sitting idle. // (We deliberately ignore tmux's session_attached here — when you're driving from a // phone no terminal client is attached, so *every* session reads as detached; that // flag says nothing about whether the session is worth your attention.) function isNoise(w: Win): boolean { - const hasAgent = w.panes.some((p) => p.muse_session_id || p.command === "claude"); + const hasAgent = w.panes.some((p) => p.muse_session_id || p.provider); return !hasAgent && w.status === "idle"; } @@ -181,7 +203,7 @@ export default function PanesPage() { setCreating(true); try { const { pane_id } = await api.launchProfile(name, values, group); - const fresh = await api.getTmuxLayout(false); // pick up the new window + const fresh = await waitForPane(() => api.getTmuxLayout(false), pane_id); setLayout(fresh); // Follow the new window into view before selecting it. The deck is scoped to the // active name/group filter, so if either would exclude the new pane, selecting it @@ -189,10 +211,10 @@ export default function PanesPage() { // filter to whatever session the window actually landed in (same idea as deckMove). const landed = fresh.panes.find((p) => p.pane_id === pane_id); setFilter(""); - if (landed && group !== null && landed.session_name !== group) { - setGroup(landed.session_name); + if (landed) { + if (group !== null && landed.session_name !== group) setGroup(landed.session_name); + select(pane_id); // jump straight into it } - select(pane_id); // jump straight into it } catch (e) { window.alert(e instanceof Error ? e.message : "Could not launch window"); } finally { @@ -201,6 +223,33 @@ export default function PanesPage() { }, [creating, group, select, setGroup], ); + const launchCodexFromPane = useCallback( + async (pane: TmuxPane) => { + if (creating || !pane.muse_session_id) return; + setCreating(true); + try { + const { pane_id } = await api.launchCodexFromSession({ + source_session_id: pane.muse_session_id, + cwd: pane.cwd, + session: pane.session_name, + window_name: pane.window_name, + }); + const fresh = await waitForPane(() => api.getTmuxLayout(false), pane_id); + setLayout(fresh); + const landed = fresh.panes.find((p) => p.pane_id === pane_id); + setFilter(""); + if (landed) { + if (group !== null && landed.session_name !== group) setGroup(landed.session_name); + select(pane_id); + } + } catch (e) { + window.alert(e instanceof Error ? e.message : "Could not launch Codex session"); + } finally { + setCreating(false); + } + }, + [creating, group, select, setGroup], + ); const wins = useMemo(() => buildWindows(layout?.panes ?? []), [layout]); const groups = useMemo(() => buildGroups(wins), [wins]); @@ -436,6 +485,7 @@ export default function PanesPage() { groups={groups.map((g) => g.name)} onMoveWindow={deckMove} onMoveToNewGroup={deckMoveToNewGroup} + onLaunchCodex={launchCodexFromPane} onRenameWindow={renameWindow} onRemoveWindow={removeWindow} /> @@ -445,7 +495,9 @@ export default function PanesPage() { } const groupNames = groups.map((g) => g.name); - const row = (w: Win, muted = false) => ( + const row = (w: Win, muted = false) => { + const sourcePane = w.panes.find((p) => !!p.muse_session_id) ?? null; + return ( moveWindow(w.rep.window_id, session)} onMoveNew={() => moveToNewGroup(w.rep.window_id)} + onLaunchCodex={sourcePane ? () => launchCodexFromPane(sourcePane) : undefined} onRenameWindow={() => renameWindow(w.rep.window_id, w.window_name)} onRemove={() => removeWindow(w.rep.window_id, w.window_name)} /> - ); + ); + }; return (
@@ -511,7 +565,10 @@ export default function PanesPage() { {sortMode === "attention" ? ( SECTIONS.map(({ status, label }) => { - const items = visible.filter((w) => w.status === status); + const items = sortWins( + visible.filter((w) => w.status === status), + "recent", + ); if (!items.length) return null; return (
@@ -833,10 +890,10 @@ export function RestoreBanner({ onRestored }: { onRestored: () => void }) { ); } -// The "New window" launcher: a split control. The primary button opens a plain claude -// window (the built-in "Claude" profile); the ▾ caret opens a menu of profiles read from -// ~/.muse/profiles.toml. Picking a profile with declared params opens a tiny form to -// collect them; params-less profiles launch immediately. Launches land in the current +// The "New window" launcher: a split control. The primary button launches the last-used +// built-in provider/profile (default: Claude); the ▾ caret opens built-in providers plus +// ~/.muse/profiles.toml entries. Picking a profile with declared params opens a tiny form +// to collect them; params-less profiles launch immediately. Launches land in the current // group (handled by the caller). Works on both desktop and mobile. export function NewWindowLauncher({ group, @@ -852,6 +909,12 @@ export function NewWindowLauncher({ const [error, setError] = useState(null); const [form, setForm] = useState(null); // profile awaiting param input const [values, setValues] = useState>({}); + const [primary, setPrimary] = useState(() => localStorage.getItem("panesPrimaryProfile") || "Claude"); + + const rememberPrimary = (name: string) => { + setPrimary(name); + localStorage.setItem("panesPrimaryProfile", name); + }; const close = () => { setOpen(false); @@ -872,6 +935,7 @@ export function NewWindowLauncher({ const pick = (p: Profile) => { if (p.params.length === 0) { close(); + rememberPrimary(p.name); onLaunch(p.name, {}); return; } @@ -885,14 +949,26 @@ export function NewWindowLauncher({ if (!form) return; const name = form.name; close(); + rememberPrimary(name); onLaunch(name, values); }; + const builtins = profiles?.filter((p) => p.builtin) ?? []; + const customs = profiles?.filter((p) => !p.builtin) ?? []; + const primaryLabel = PROVIDER_LABEL[(builtins.find((p) => p.name === primary)?.provider ?? "").toLowerCase()] || primary; + return (
-
{error &&
{error}
} {!error && profiles === null &&
Loading…
} - {profiles?.map((p) => ( - - ))} + {builtins.length > 0 && ( + <> +
Providers
+ {builtins.map((p) => ( + + ))} + + )} + {customs.length > 0 && ( + <> +
Profiles
+ {customs.map((p) => ( + + ))} + + )}
)} @@ -974,6 +1073,7 @@ export function MoveMenu({ currentSession, onMove, onMoveNew, + onLaunchCodex, onRemove, triggerClass = "task-move", triggerLabel = "⋯", @@ -983,6 +1083,7 @@ export function MoveMenu({ currentSession: string; onMove: (session: string) => void; onMoveNew: () => void; + onLaunchCodex?: () => void; onRemove?: () => void; // remove (close) this window — a danger row under the move options triggerClass?: string; triggerLabel?: string; @@ -1015,7 +1116,7 @@ export function MoveMenu({ }} />
-
Move to group
+
Window actions
{others.map((g) => ( + {onLaunchCodex && ( + + )} {onRemove && ( )} + {onRemove && !!win.rep.window_id && ( + // Always-visible close, not buried in the ⋯ menu — one tap opens the + // confirm dialog (RemoveDialog), so it's reachable but never destructive + // by accident. + + )} {canMove && ( onMove?.(s)} onMoveNew={() => onMoveNew?.()} + onLaunchCodex={onLaunchCodex} onRemove={onRemove} /> )} @@ -1300,6 +1433,7 @@ function PaneDeck({ groups, onMoveWindow, onMoveToNewGroup, + onLaunchCodex, onRenameWindow, onRemoveWindow, }: { @@ -1313,6 +1447,7 @@ function PaneDeck({ groups: string[]; // all group (session) names — move targets for the header menu onMoveWindow: (windowId: string, session: string) => void; // recategorize + follow onMoveToNewGroup: (windowId: string) => void; // create a group, move, focus it + onLaunchCodex: (pane: TmuxPane) => void; // branch a pane into a new Codex session onRenameWindow: (windowId: string, currentName: string) => void; // rename a window onRemoveWindow: (windowId: string, name: string) => void; // remove (close) a window }) { @@ -1564,6 +1699,7 @@ function PaneDeck({ groups={groups} onMove={(session) => onMoveWindow(p.window_id, session)} onMoveNew={() => onMoveToNewGroup(p.window_id)} + onLaunchCodex={p.muse_session_id ? () => onLaunchCodex(p) : undefined} onRemove={() => onRemoveWindow(p.window_id, p.window_name)} onStepPane={stepPane} onExit={onBack} @@ -1623,6 +1759,7 @@ function PaneCard({ groups = [], onMove, onMoveNew, + onLaunchCodex, onRemove, onStepPane, onExit, @@ -1635,6 +1772,7 @@ function PaneCard({ groups?: string[]; // all group (session) names — move targets (desktop header menu) onMove?: (session: string) => void; // recategorize this window into an existing group onMoveNew?: () => void; // recategorize into a freshly-created group + onLaunchCodex?: () => void; // start a Codex session here, seeded from this one onRemove?: () => void; // remove (close) this window from the header menu onStepPane?: (dir: -1 | 1) => void; // terminal passthrough: Alt+←/→ step panes onExit?: () => void; // terminal passthrough: Alt+Esc back to the task list @@ -1657,6 +1795,13 @@ function PaneCard({ const screenText = screen?.text || pane.preview || ""; const options = screen ? screen.options : pane.options; const mode = screen?.mode ?? pane.mode; + const caps = pane.capabilities; + const providerLabel = pane.provider ? (PROVIDER_LABEL[pane.provider] ?? pane.provider) : null; + const showRightRail = + (mode != null && caps.mode_switch) || + pane.context_pct != null || + pane.queued > 0 || + (!!pane.muse_session_id && caps.drive); // Parse ANSI once per screen update (not on every keystroke in the composer). const segments = useMemo(() => parseAnsi(screenText), [screenText]); @@ -1666,13 +1811,16 @@ function PaneCard({ // scroll up, so the whole session is reachable without leaving the deck. // Data layer (poll/merge/prepend) lives in useReaderThread — unit-tested. const sid = pane.muse_session_id; - const showReader = view === "reader" && !!sid; + const showReader = view === "reader" && !!sid && caps.reader; const { reader, loadEarlier, pullingEarlier } = useReaderThread(sid, showReader && active); // Rich pending options (full prompt + descriptions + free-text) from the // thread-aware endpoint — the screen-parsed chips stay as the fallback for // untracked panes and parse gaps. Active card only: one 1.8s poll, not ×8. - const { pending, sending, select } = usePendingOptions(sid ?? "", !!sid && active); + const { pending, sending, select } = usePendingOptions( + sid ?? "", + !!sid && active && caps.rich_reply, + ); // Bumped when ReplyBox queues so the chips below refresh instantly. const [queueBump, setQueueBump] = useState(0); // One-line status banner — only when it says something actionable (a pending @@ -1682,7 +1830,7 @@ function PaneCard({ const statusCls = pending ? "wait" : "busy"; const statusText = pending ? "Needs your input — choose below" - : "Claude is working — queue a reply or interrupt"; + : `${providerLabel ?? "Agent"} is working — queue a reply or interrupt`; // Distance-from-bottom captured before a prepend, to restore the exact reading // position after the earlier items land above the viewport. const fromBottom = useRef(null); @@ -1809,7 +1957,7 @@ function PaneCard({ paneId: pane.pane_id, text, setText, - enabled: !pane.muse_session_id, + enabled: !caps.rich_reply && caps.slash_commands, }); // Desktop keyboard niceties for the untracked composer (tracked panes get these @@ -2001,12 +2149,13 @@ function PaneCard({ {pane.session_name}:{pane.window_index}.{pane.pane_index} {pane.command} + {providerLabel && {providerLabel}} {cwdShort} - {(mode || pane.muse_session_id || pane.context_pct != null) && ( + {showRightRail && ( - {mode && ( + {mode && caps.mode_switch && (