feat: add TUI session orchestrator
Add a first-class active-session orchestrator for the Ink TUI: - list, activate, close, and launch live process-local TUI sessions - hydrate committed and in-flight output when switching sessions - dispatch a new prompt session from the +new row with session-scoped model picks - expose a clickable live-session count in the status chrome - preserve stable row order while initially focusing the current session - support mouse hit-testing for floating orchestrator overlays - add backend and frontend regression coverage for the lifecycle and UI helpers
This commit is contained in:
+218
-3
@@ -118,6 +118,7 @@ from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
||||
_sessions: dict[str, dict] = {}
|
||||
_methods: dict[str, callable] = {}
|
||||
_pending: dict[str, tuple[str, threading.Event]] = {}
|
||||
_pending_prompt_payloads: dict[str, tuple[str, dict]] = {}
|
||||
_answers: dict[str, str] = {}
|
||||
_db = None
|
||||
_db_error: str | None = None
|
||||
@@ -729,9 +730,13 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
||||
ev = threading.Event()
|
||||
_pending[rid] = (sid, ev)
|
||||
payload["request_id"] = rid
|
||||
_emit(event, sid, payload)
|
||||
ev.wait(timeout=timeout)
|
||||
_pending.pop(rid, None)
|
||||
_pending_prompt_payloads[rid] = (event, dict(payload))
|
||||
try:
|
||||
_emit(event, sid, payload)
|
||||
ev.wait(timeout=timeout)
|
||||
finally:
|
||||
_pending.pop(rid, None)
|
||||
_pending_prompt_payloads.pop(rid, None)
|
||||
return _answers.pop(rid, "")
|
||||
|
||||
|
||||
@@ -2054,12 +2059,16 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
||||
|
||||
|
||||
def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
||||
now = time.time()
|
||||
_sessions[sid] = {
|
||||
"agent": agent,
|
||||
"session_key": key,
|
||||
"history": history,
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"inflight_turn": None,
|
||||
"created_at": now,
|
||||
"last_active": now,
|
||||
"running": False,
|
||||
"attached_images": [],
|
||||
"image_counter": 0,
|
||||
@@ -2231,6 +2240,54 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
|
||||
return messages
|
||||
|
||||
|
||||
def _inflight_text(value: Any) -> str:
|
||||
return _content_display_text(value).strip()
|
||||
|
||||
|
||||
def _start_inflight_turn(session: dict, text: Any) -> None:
|
||||
now = time.time()
|
||||
session["inflight_turn"] = {
|
||||
"assistant": "",
|
||||
"started_at": now,
|
||||
"streaming": True,
|
||||
"updated_at": now,
|
||||
"user": _inflight_text(text),
|
||||
}
|
||||
|
||||
|
||||
def _append_inflight_delta(session: dict, delta: Any) -> None:
|
||||
text = "" if delta is None else str(delta)
|
||||
if not text:
|
||||
return
|
||||
turn = session.get("inflight_turn")
|
||||
if not isinstance(turn, dict):
|
||||
turn = {"assistant": "", "streaming": True, "user": ""}
|
||||
turn["assistant"] = f"{turn.get('assistant') or ''}{text}"
|
||||
turn["streaming"] = True
|
||||
turn["updated_at"] = time.time()
|
||||
session["inflight_turn"] = turn
|
||||
|
||||
|
||||
def _clear_inflight_turn(session: dict) -> None:
|
||||
session["inflight_turn"] = None
|
||||
|
||||
|
||||
def _inflight_snapshot(session: dict) -> dict | None:
|
||||
turn = session.get("inflight_turn")
|
||||
if not isinstance(turn, dict):
|
||||
return None
|
||||
user = str(turn.get("user") or "").strip()
|
||||
assistant = str(turn.get("assistant") or "")
|
||||
streaming = bool(turn.get("streaming"))
|
||||
if not user and not assistant and not streaming:
|
||||
return None
|
||||
return {
|
||||
"assistant": assistant,
|
||||
"streaming": streaming,
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
# ── Methods: session ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2242,6 +2299,7 @@ def _(rid, params: dict) -> dict:
|
||||
_enable_gateway_prompts()
|
||||
|
||||
ready = threading.Event()
|
||||
now = time.time()
|
||||
|
||||
_sessions[sid] = {
|
||||
"agent": None,
|
||||
@@ -2249,11 +2307,14 @@ def _(rid, params: dict) -> dict:
|
||||
"agent_ready": ready,
|
||||
"attached_images": [],
|
||||
"cols": cols,
|
||||
"created_at": now,
|
||||
"edit_snapshots": {},
|
||||
"history": [],
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"image_counter": 0,
|
||||
"inflight_turn": None,
|
||||
"last_active": now,
|
||||
"pending_title": None,
|
||||
"running": False,
|
||||
"session_key": key,
|
||||
@@ -2427,6 +2488,140 @@ def _(rid, params: dict) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def _session_pending_kind(sid: str) -> str:
|
||||
for rid, (owner_sid, _ev) in list(_pending.items()):
|
||||
if owner_sid != sid:
|
||||
continue
|
||||
event, _payload = _pending_prompt_payloads.get(rid, ("input.request", {}))
|
||||
return str(event).removesuffix(".request")
|
||||
return ""
|
||||
|
||||
|
||||
def _session_live_status(sid: str, session: dict) -> str:
|
||||
if _session_pending_kind(sid):
|
||||
return "waiting"
|
||||
ready = session.get("agent_ready")
|
||||
if ready is not None and not ready.is_set():
|
||||
return "starting"
|
||||
if session.get("running"):
|
||||
return "working"
|
||||
return "idle"
|
||||
|
||||
|
||||
def _message_preview(history: list) -> str:
|
||||
for msg in reversed(history or []):
|
||||
text = _content_display_text(msg.get("content", msg.get("text", ""))).strip()
|
||||
if text:
|
||||
return " ".join(text.split())[:160]
|
||||
return ""
|
||||
|
||||
|
||||
def _session_live_title(session: dict, key: str) -> str:
|
||||
title = str(session.get("pending_title") or "").strip()
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
try:
|
||||
title = str(db.get_session_title(key) or title or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return title
|
||||
|
||||
|
||||
def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
|
||||
key = str(session.get("session_key") or sid)
|
||||
agent = session.get("agent")
|
||||
history = list(session.get("history") or [])
|
||||
status = _session_live_status(sid, session)
|
||||
inflight = _inflight_snapshot(session)
|
||||
preview = _message_preview(history)
|
||||
if inflight:
|
||||
preview = inflight.get("assistant") or inflight.get("user") or preview
|
||||
preview = " ".join(str(preview).split())[:160]
|
||||
now = time.time()
|
||||
return {
|
||||
"current": sid == current_sid,
|
||||
"id": sid,
|
||||
"last_active": float(session.get("last_active") or session.get("created_at") or now),
|
||||
"message_count": len(history),
|
||||
"model": str(getattr(agent, "model", "") or _resolve_model()),
|
||||
"preview": preview,
|
||||
"session_key": key,
|
||||
"started_at": float(session.get("created_at") or now),
|
||||
"status": status,
|
||||
"title": _session_live_title(session, key),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_session_info(session: dict) -> dict:
|
||||
agent = session.get("agent")
|
||||
if agent is not None:
|
||||
return _session_info(agent)
|
||||
return {
|
||||
"cwd": os.getenv("TERMINAL_CWD", os.getcwd()),
|
||||
"lazy": True,
|
||||
"model": _resolve_model(),
|
||||
"skills": {},
|
||||
"tools": {},
|
||||
}
|
||||
|
||||
|
||||
@method("session.active_list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return live TUI sessions in this gateway process.
|
||||
|
||||
Unlike ``session.list`` this is not a historical DB browser: it reports only
|
||||
sessions with in-memory agents/workers that the current TUI can switch to
|
||||
without closing siblings.
|
||||
"""
|
||||
current = str(params.get("current_session_id") or "")
|
||||
try:
|
||||
snapshot = list(_sessions.items())
|
||||
except Exception as e:
|
||||
return _err(rid, 5036, f"could not enumerate active sessions: {e}")
|
||||
|
||||
# Keep the natural creation/insertion order from ``_sessions``. The
|
||||
# frontend marks the focused session with ``current``; it should not jump to
|
||||
# the top just because the user switched to it.
|
||||
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
|
||||
return _ok(rid, {"sessions": rows})
|
||||
|
||||
|
||||
@method("session.activate")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Attach the frontend to an already-live TUI session.
|
||||
|
||||
This intentionally does not close the previously focused session; it merely
|
||||
returns enough state for Ink to redraw around another live session id.
|
||||
"""
|
||||
sid = str(params.get("session_id") or "")
|
||||
session, err = _sess_nowait({"session_id": sid}, rid)
|
||||
if err:
|
||||
return err
|
||||
|
||||
with session["history_lock"]:
|
||||
session["last_active"] = time.time()
|
||||
history = list(session.get("display_history") or session.get("history") or [])
|
||||
inflight = _inflight_snapshot(session)
|
||||
running = bool(session.get("running"))
|
||||
status = _session_live_status(sid, session)
|
||||
payload = {
|
||||
"info": _fallback_session_info(session),
|
||||
"message_count": len(history),
|
||||
"messages": _history_to_messages(history),
|
||||
"running": running,
|
||||
"session_id": sid,
|
||||
"session_key": session.get("session_key") or sid,
|
||||
"started_at": float(session.get("created_at") or time.time()),
|
||||
"status": status,
|
||||
}
|
||||
if inflight:
|
||||
payload["inflight"] = inflight
|
||||
return _ok(
|
||||
rid,
|
||||
payload,
|
||||
)
|
||||
|
||||
|
||||
@method("session.delete")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Delete a stored session and its on-disk transcript files.
|
||||
@@ -3151,6 +3346,8 @@ def _(rid, params: dict) -> dict:
|
||||
if session.get("running"):
|
||||
return _err(rid, 4009, "session busy")
|
||||
session["running"] = True
|
||||
session["last_active"] = time.time()
|
||||
_start_inflight_turn(session, text)
|
||||
|
||||
_start_agent_build(sid, session)
|
||||
|
||||
@@ -3168,6 +3365,7 @@ def _(rid, params: dict) -> dict:
|
||||
)
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
_clear_inflight_turn(session)
|
||||
return
|
||||
_run_prompt_submit(rid, sid, session, text)
|
||||
|
||||
@@ -3280,6 +3478,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
||||
history_version = int(session.get("history_version", 0))
|
||||
images = list(session.get("attached_images", []))
|
||||
session["attached_images"] = []
|
||||
if not isinstance(session.get("inflight_turn"), dict):
|
||||
_start_inflight_turn(session, text)
|
||||
agent = session["agent"]
|
||||
_emit("message.start", sid)
|
||||
|
||||
@@ -3388,6 +3588,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
||||
run_message = _enrich_with_attached_images(prompt, images)
|
||||
|
||||
def _stream(delta):
|
||||
with session["history_lock"]:
|
||||
_append_inflight_delta(session, delta)
|
||||
payload = {"text": delta}
|
||||
if streamer and (r := streamer.feed(delta)) is not None:
|
||||
payload["rendered"] = r
|
||||
@@ -3471,6 +3673,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
||||
rendered = render_message(raw, cols)
|
||||
if rendered:
|
||||
payload["rendered"] = rendered
|
||||
with session["history_lock"]:
|
||||
_clear_inflight_turn(session)
|
||||
_emit("message.complete", sid, payload)
|
||||
|
||||
# ── /goal continuation (Ralph-style loop) ─────────────────
|
||||
@@ -3608,6 +3812,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
||||
_clear_session_context(session_tokens)
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
session["last_active"] = time.time()
|
||||
_clear_inflight_turn(session)
|
||||
|
||||
# Chain a goal-continuation turn if the judge said so. We do
|
||||
# this AFTER the finally releases session["running"], so the
|
||||
@@ -3921,6 +4127,14 @@ def _(rid, params: dict) -> dict:
|
||||
4009,
|
||||
"session busy — /interrupt the current turn before switching models",
|
||||
)
|
||||
if session.get("agent") is None:
|
||||
session_id = params.get("session_id", "")
|
||||
_start_agent_build(session_id, session)
|
||||
init_err = _wait_agent(session, rid)
|
||||
if init_err:
|
||||
return init_err
|
||||
if session.get("agent") is None:
|
||||
return _err(rid, 5032, "agent initialization failed")
|
||||
result = _apply_model_switch(
|
||||
params.get("session_id", ""), session, value
|
||||
)
|
||||
@@ -4534,6 +4748,7 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [
|
||||
"Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
|
||||
"TUI",
|
||||
),
|
||||
("/sessions", "Switch between live TUI sessions", "TUI"),
|
||||
]
|
||||
|
||||
# Commands that queue messages onto _pending_input in the CLI.
|
||||
|
||||
Reference in New Issue
Block a user