diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 3328110b2be..2631dab3787 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1732,6 +1732,48 @@ def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch assert agent.verbose_logging is True + +def test_config_set_model_waits_for_lazy_agent_before_switch(monkeypatch): + """A model switch against a lazy-created live session must apply to the + real agent, not just process env, before the prompt is dispatched. + """ + + agent_ready = threading.Event() + agent = types.SimpleNamespace(model="old/model", provider="old-provider") + session = _session(agent=agent) + session["agent"] = None + session["agent_ready"] = agent_ready + server._sessions["sid"] = session + calls = [] + + def fake_start(sid, target): + calls.append(("start", sid)) + target["agent"] = agent + agent_ready.set() + + def fake_apply(sid, target, raw): + calls.append(("apply", sid, target.get("agent"), raw)) + if target.get("agent") is not agent: + raise AssertionError("model switch ran before lazy agent was ready") + return {"value": "new/model", "warning": ""} + + monkeypatch.setattr(server, "_start_agent_build", fake_start) + monkeypatch.setattr(server, "_apply_model_switch", fake_apply) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "model", "value": "new/model"}, + } + ) + + assert resp["result"]["value"] == "new/model" + assert calls == [("start", "sid"), ("apply", "sid", agent, "new/model")] + finally: + server._sessions.pop("sid", None) + def test_config_set_model_uses_live_switch_path(monkeypatch): server._sessions["sid"] = _session() seen = {} @@ -3843,6 +3885,191 @@ def test_prompt_submit_preserves_empty_response_without_error(monkeypatch): assert text in {"", None}, f"expected empty text, got {text!r}" +# ── active live TUI sessions ───────────────────────────────────────── + + +def test_session_active_list_reports_live_sessions(monkeypatch): + class _DB: + def get_session_title(self, key): + return {"key-a": "Research", "key-b": "Implement"}.get(key, "") + + previous_sessions = dict(server._sessions) + server._sessions.clear() + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + server._sessions["sid-a"] = _session( + agent=types.SimpleNamespace(model="model-a"), + history=[{"role": "user", "content": "find docs"}], + session_key="key-a", + created_at=10.0, + last_active=20.0, + ) + server._sessions["sid-b"] = _session( + agent=types.SimpleNamespace(model="model-b"), + history=[{"role": "assistant", "content": "writing code"}], + running=True, + session_key="key-b", + created_at=11.0, + last_active=30.0, + ) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.active_list", + "params": {"current_session_id": "sid-b"}, + } + ) + finally: + server._sessions.clear() + server._sessions.update(previous_sessions) + + session_rows = resp["result"]["sessions"] + assert [row["id"] for row in session_rows] == ["sid-a", "sid-b"] + + rows = {row["id"]: row for row in session_rows} + assert rows["sid-a"] == { + "current": False, + "id": "sid-a", + "last_active": 20.0, + "message_count": 1, + "model": "model-a", + "preview": "find docs", + "session_key": "key-a", + "started_at": 10.0, + "status": "idle", + "title": "Research", + } + assert rows["sid-b"]["current"] is True + assert rows["sid-b"]["status"] == "working" + assert rows["sid-b"]["title"] == "Implement" + assert rows["sid-b"]["preview"] == "writing code" + + +def test_session_activate_returns_inflight_stream_before_completion(monkeypatch): + """Switching into a still-running live session must hydrate partial output. + + The committed session history is only updated after run_conversation returns, + so session.activate needs an explicit in-flight payload sourced from the + backend stream callback. + """ + started = threading.Event() + release = threading.Event() + done = threading.Event() + + class _Agent: + model = "model-live" + + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + assert prompt == "write a long answer" + assert conversation_history == [] + stream_callback("partial ") + stream_callback("answer") + started.set() + assert release.wait(2), "test timed out waiting to finish fake model turn" + return { + "final_response": "partial answer complete", + "messages": [ + {"role": "user", "content": "write a long answer"}, + {"role": "assistant", "content": "partial answer complete"}, + ], + } + + server._sessions["sid-live"] = _session(agent=_Agent()) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setattr(server, "_get_db", lambda: None) + monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model}) + + def _emit(event, sid, payload=None): + if event == "message.complete": + done.set() + + monkeypatch.setattr(server, "_emit", _emit) + + try: + submit = server.handle_request( + { + "id": "submit", + "method": "prompt.submit", + "params": {"session_id": "sid-live", "text": "write a long answer"}, + } + ) + assert submit["result"]["status"] == "streaming" + assert started.wait(2), "fake model did not stream before activation" + + resp = server.handle_request( + { + "id": "activate", + "method": "session.activate", + "params": {"session_id": "sid-live"}, + } + ) + + inflight = resp["result"].get("inflight") + assert inflight == { + "assistant": "partial answer", + "streaming": True, + "user": "write a long answer", + } + assert resp["result"]["messages"] == [] + + release.set() + assert done.wait(2), "fake model turn did not complete" + completed = server.handle_request( + { + "id": "activate-done", + "method": "session.activate", + "params": {"session_id": "sid-live"}, + } + ) + assert completed["result"].get("inflight") is None + assert completed["result"]["messages"] == [ + {"role": "user", "text": "write a long answer"}, + {"role": "assistant", "text": "partial answer complete"}, + ] + finally: + release.set() + done.wait(2) + server._sessions.pop("sid-live", None) + + +def test_session_activate_switches_live_session_without_closing_siblings(monkeypatch): + monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model}) + server._sessions["sid-a"] = _session( + agent=types.SimpleNamespace(model="model-a"), + history=[{"role": "user", "content": "old"}], + session_key="key-a", + ) + server._sessions["sid-b"] = _session( + agent=types.SimpleNamespace(model="model-b"), + history=[ + {"role": "user", "content": "new prompt"}, + {"role": "assistant", "content": "new answer"}, + ], + running=True, + session_key="key-b", + ) + try: + resp = server.handle_request( + {"id": "1", "method": "session.activate", "params": {"session_id": "sid-b"}} + ) + + assert "sid-a" in server._sessions + assert "sid-b" in server._sessions + assert resp["result"]["session_id"] == "sid-b" + assert resp["result"]["session_key"] == "key-b" + assert resp["result"]["running"] is True + assert resp["result"]["status"] == "working" + assert resp["result"]["info"] == {"model": "model-b"} + assert resp["result"]["messages"] == [ + {"role": "user", "text": "new prompt"}, + {"role": "assistant", "text": "new answer"}, + ] + finally: + server._sessions.pop("sid-a", None) + server._sessions.pop("sid-b", None) + + # ── session.most_recent ────────────────────────────────────────────── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index dc13969f1be..67e58644738 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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. diff --git a/ui-tui/packages/hermes-ink/src/ink/hit-test.test.ts b/ui-tui/packages/hermes-ink/src/ink/hit-test.test.ts new file mode 100644 index 00000000000..1bbf13f96cc --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hit-test.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { appendChildNode, createNode } from './dom.js' +import { dispatchClick, hitTest } from './hit-test.js' +import { nodeCache } from './node-cache.js' + +const rect = (node: ReturnType, x: number, y: number, width: number, height: number) => { + nodeCache.set(node, { x, y, width, height }) +} + +describe('hit-test', () => { + it('hits absolutely positioned children that paint outside their parent rect', () => { + const root = createNode('ink-root') + const parent = createNode('ink-box') + const wrapper = createNode('ink-box') + const overlay = createNode('ink-box') + const row = createNode('ink-box') + const seen: string[] = [] + + appendChildNode(root, parent) + appendChildNode(parent, wrapper) + appendChildNode(wrapper, overlay) + appendChildNode(overlay, row) + + overlay.style.position = 'absolute' + row._eventHandlers = { onClick: () => seen.push('row') } + + rect(root, 0, 0, 120, 40) + rect(parent, 0, 30, 120, 1) + rect(wrapper, 0, 30, 120, 1) + rect(overlay, 0, 20, 96, 6) + rect(row, 1, 22, 80, 1) + + expect(hitTest(root, 2, 22)).toBe(row) + expect(dispatchClick(root, 2, 22)).toBe(true) + expect(seen).toEqual(['row']) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts index c23ce34fe08..412a1659614 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts @@ -4,6 +4,36 @@ import type { EventHandlerProps } from './events/event-handlers.js' import { MouseEvent } from './events/mouse-event.js' import { nodeCache } from './node-cache.js' +function hitTestAbsoluteDescendants(node: DOMElement, col: number, row: number): DOMElement | null { + for (let i = node.childNodes.length - 1; i >= 0; i--) { + const child = node.childNodes[i]! + + if (child.nodeName === '#text') { + continue + } + + if (!nodeCache.get(child)) { + continue + } + + if (child.style.position === 'absolute') { + const hit = hitTest(child, col, row) + + if (hit) { + return hit + } + } + + const nestedHit = hitTestAbsoluteDescendants(child, col, row) + + if (nestedHit) { + return nestedHit + } + } + + return null +} + /** * Find the deepest DOM element whose rendered rect contains (col, row). * @@ -23,8 +53,10 @@ export function hitTest(node: DOMElement, col: number, row: number): DOMElement return null } - if (col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height) { - return null + const inside = col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height + + if (!inside) { + return hitTestAbsoluteDescendants(node, col, row) } // Later siblings paint on top; reversed traversal returns topmost hit. diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts new file mode 100644 index 00000000000..3e69449dc93 --- /dev/null +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_THEME } from '../theme.js' +import type { SessionActiveItem } from '../gatewayTypes.js' +import { + activeSessionCountLabel, + canTypeOrchestratorPrompt, + currentSessionSelectionIndex, + orchestratorContextHint, + orchestratorContextHintSegments, + orchestratorGlobalHotkeyHint, + orchestratorGlobalHotkeyHintSegments, + orchestratorHintSegmentColor, + clampOrchestratorSelection, + closeFallbackAfterClose, + draftModelArgFromPickerValue, + draftModelDisplayLabel, + fixedSessionColumnStyle, + draftTitleFromPrompt, + isNewSessionRow, + newSessionMarkerColor, + newSessionRowIndex, + orchestratorRowClickAction, + orchestratorVisibleRowIndexes, + selectedSessionRowStyle +} from '../components/activeSessionSwitcher.js' + +describe('session orchestrator helpers', () => { + it('labels live sessions compactly for tight overlays', () => { + expect(activeSessionCountLabel(0)).toBe('0 live sessions') + expect(activeSessionCountLabel(1)).toBe('1 live session') + expect(activeSessionCountLabel(3)).toBe('3 live sessions') + expect(activeSessionCountLabel(1)).not.toContain('in this TUI') + }) + + it('keeps session orchestrator hotkey hints short and contextual', () => { + expect(orchestratorContextHint(false)).toBe('Session row: Enter switch · Ctrl+D close') + expect(orchestratorContextHint(true)).toBe('New row: type prompt · Enter start · Tab model') + expect(orchestratorGlobalHotkeyHint).toBe('↑↓ move · Ctrl+N new · Ctrl+R refresh · Esc close') + expect(orchestratorGlobalHotkeyHint.length).toBeLessThanOrEqual(56) + }) + + it('assigns themed colors consistently to orchestrator labels and hotkeys', () => { + expect(orchestratorContextHintSegments(false)).toEqual([ + { role: 'label', text: 'Session row:' }, + { role: 'text', text: ' ' }, + { role: 'hotkey', text: 'Enter' }, + { role: 'text', text: ' switch · ' }, + { role: 'hotkey', text: 'Ctrl+D' }, + { role: 'text', text: ' close' } + ]) + expect(orchestratorContextHintSegments(true)).toEqual([ + { role: 'label', text: 'New row:' }, + { role: 'text', text: ' type prompt · ' }, + { role: 'hotkey', text: 'Enter' }, + { role: 'text', text: ' start · ' }, + { role: 'hotkey', text: 'Tab' }, + { role: 'text', text: ' model' } + ]) + expect(orchestratorGlobalHotkeyHintSegments.filter(s => s.role === 'hotkey').map(s => s.text)).toEqual([ + '↑↓', + 'Ctrl+N', + 'Ctrl+R', + 'Esc' + ]) + expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'hotkey')).toBe(DEFAULT_THEME.color.accent) + expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'label')).toBe(DEFAULT_THEME.color.label) + expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'text')).toBe(DEFAULT_THEME.color.muted) + expect(newSessionMarkerColor(DEFAULT_THEME, false)).toBe(DEFAULT_THEME.color.label) + expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text) + }) + + it('uses a readable selected row style instead of accent-on-accent inverse text', () => { + const style = selectedSessionRowStyle(DEFAULT_THEME) + + expect(style.backgroundColor).toBe(DEFAULT_THEME.color.selectionBg) + expect(style.color).toBe(DEFAULT_THEME.color.text) + expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent) + expect(style.color).not.toBe(DEFAULT_THEME.color.accent) + }) + + it('turns model picker values into session-scoped draft model args', () => { + expect(draftModelArgFromPickerValue('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe( + 'kimi-k2.6 --provider ollama-cloud' + ) + expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe( + 'openai/gpt-5.5 --provider openai-codex' + ) + }) + + it('highlights the current live session when the picker opens', () => { + const sessions = [ + { id: 'first', status: 'idle' }, + { id: 'second', status: 'working', current: true }, + { id: 'third', status: 'idle' } + ] satisfies SessionActiveItem[] + + expect(currentSessionSelectionIndex(sessions, 'second')).toBe(1) + expect( + currentSessionSelectionIndex([{ id: 'first', status: 'idle' }, { id: 'third', status: 'idle' }], 'third') + ).toBe(1) + expect(currentSessionSelectionIndex(sessions, 'missing')).toBe(1) + expect(currentSessionSelectionIndex([], 'missing')).toBe(0) + }) + + it('adds a selectable New row after the live sessions and gates prompt typing to it', () => { + expect(newSessionRowIndex(0)).toBe(0) + expect(newSessionRowIndex(3)).toBe(3) + expect(clampOrchestratorSelection(-5, 2)).toBe(0) + expect(clampOrchestratorSelection(99, 2)).toBe(2) + expect(isNewSessionRow(0, 0)).toBe(true) + expect(isNewSessionRow(1, 2)).toBe(false) + expect(isNewSessionRow(2, 2)).toBe(true) + expect(canTypeOrchestratorPrompt(1, 2)).toBe(false) + expect(canTypeOrchestratorPrompt(2, 2)).toBe(true) + expect(orchestratorVisibleRowIndexes(3, 3, 12)).toEqual([0, 1, 2, 3]) + expect(orchestratorVisibleRowIndexes(13, 13, 12)).toContain(13) + }) + + it('selects a safe fallback after closing the current live session', () => { + const remaining = [ + { id: 'next', status: 'idle' }, + { id: 'other', status: 'working' } + ] satisfies SessionActiveItem[] + + expect(closeFallbackAfterClose('other', 'current', remaining)).toEqual({ action: 'stay' }) + expect(closeFallbackAfterClose('current', 'current', remaining)).toEqual({ action: 'activate', sessionId: 'next' }) + expect(closeFallbackAfterClose('current', 'current', [])).toEqual({ action: 'new' }) + }) + + it('shows clean draft model labels without picker flags or provider params', () => { + expect(draftModelDisplayLabel('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe('kimi-k2.6') + expect(draftModelDisplayLabel('openai/gpt-5.5 --provider openai-codex --global')).toBe('gpt-5.5') + expect(draftModelDisplayLabel('')).toBe('current/default') + }) + + it('maps row clicks to existing-session activation or New-row focus', () => { + const sessions = [ + { id: 'a', status: 'idle' }, + { id: 'b', status: 'idle' } + ] satisfies SessionActiveItem[] + + expect(orchestratorRowClickAction(1, sessions)).toEqual({ action: 'activate', sessionId: 'b' }) + expect(orchestratorRowClickAction(2, sessions)).toEqual({ action: 'select-new' }) + expect(orchestratorRowClickAction(99, sessions)).toEqual({ action: 'select-new' }) + }) + + it('keeps fixed table columns from shrinking into adjacent columns', () => { + expect(fixedSessionColumnStyle().flexShrink).toBe(0) + }) + + it('builds a compact title from the orchestrator prompt', () => { + expect(draftTitleFromPrompt(' Build the websocket orchestrator panel and make it robust. ', 24)).toBe( + 'Build the websocket orc…' + ) + }) +}) diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx new file mode 100644 index 00000000000..4fb96385f4c --- /dev/null +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { StatusRule } from '../components/appChrome.js' +import { DEFAULT_THEME } from '../theme.js' + +type ReactNodeLike = React.ReactNode + +const textContent = (node: ReactNodeLike): string => { + if (node === null || node === undefined || typeof node === 'boolean') { + return '' + } + + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + + if (Array.isArray(node)) { + return node.map(textContent).join('') + } + + if (React.isValidElement(node)) { + return textContent(node.props.children) + } + + return '' +} + +const findClickableWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => { + if (node === null || node === undefined || typeof node === 'boolean') { + return null + } + + if (Array.isArray(node)) { + for (const child of node) { + const found = findClickableWithText(child, needle) + + if (found) { + return found + } + } + + return null + } + + if (!React.isValidElement(node)) { + return null + } + + if (typeof node.props.onClick === 'function' && textContent(node).includes(needle)) { + return node + } + + return findClickableWithText(node.props.children, needle) +} + +describe('StatusRule session count click target', () => { + it('makes the live session count itself clickable', () => { + const openSwitcher = vi.fn() + const element = StatusRule({ + bgCount: 0, + busy: false, + cols: 100, + cwdLabel: '~/repo', + liveSessionCount: 1, + model: 'kimi-k2.6', + onSessionCountClick: openSwitcher, + sessionStartedAt: null, + showCost: false, + status: 'ready', + statusColor: DEFAULT_THEME.color.ok, + t: DEFAULT_THEME, + turnStartedAt: null, + usage: { total: 0 }, + voiceLabel: '' + }) + + const clickableSessionCount = findClickableWithText(element, '1 session') + + expect(clickableSessionCount).not.toBeNull() + clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() }) + expect(openSwitcher).toHaveBeenCalledOnce() + }) +}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index e1251a4af9f..8e6348e5d4e 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -18,6 +18,16 @@ describe('createSlashHandler', () => { expect(getOverlayState().picker).toBe(true) }) + it('opens the live session switcher locally even when the current session is busy', () => { + patchUiState({ busy: true, sid: 'sid-abc' }) + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/sessions')).toBe(true) + expect(getOverlayState().sessions).toBe(true) + expect(ctx.session.guardBusySessionSwitch).not.toHaveBeenCalled() + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + it('handles /redraw locally without slash worker fallback', () => { const ctx = buildCtx() @@ -779,6 +789,7 @@ const buildSession = () => ({ die: vi.fn(), dieWithCode: vi.fn(), guardBusySessionSwitch: vi.fn(() => false), + newLiveSession: vi.fn(), newSession: vi.fn(), resetVisibleHistory: vi.fn(), resumeById: vi.fn(), @@ -796,7 +807,8 @@ const buildTranscript = () => ({ const buildVoice = () => ({ setVoiceEnabled: vi.fn(), - setVoiceRecordKey: vi.fn() + setVoiceRecordKey: vi.fn(), + setVoiceTts: vi.fn() }) interface Ctx { diff --git a/ui-tui/src/__tests__/orchestratorPromptSession.test.ts b/ui-tui/src/__tests__/orchestratorPromptSession.test.ts new file mode 100644 index 00000000000..f9ff16f34a5 --- /dev/null +++ b/ui-tui/src/__tests__/orchestratorPromptSession.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { startPromptLiveSession } from '../app/useMainApp.js' + +describe('startPromptLiveSession', () => { + it('starts a kept-live session with generated id/title, applies selected model, then dispatches the prompt', async () => { + const calls: Array<[string, unknown]> = [] + + const sid = await startPromptLiveSession({ + dispatchSubmission: prompt => calls.push(['dispatch', prompt]), + maybeWarn: value => calls.push(['warn', value]), + modelArg: 'kimi-k2.6 --provider ollama-cloud', + newLiveSession: async (message, title) => { + calls.push(['new', { message, title }]) + + return 'abc123' + }, + onModelSwitched: (value, result) => calls.push(['model-switched', { result, value }]), + prompt: ' Build the thing ', + rpc: async (method, params) => { + calls.push(['rpc', { method, params }]) + + return { value: 'kimi-k2.6', warning: '' } + }, + sys: text => calls.push(['sys', text]) + }) + + expect(sid).toBe('abc123') + expect(calls).toEqual([ + ['new', { message: 'new live session started', title: undefined }], + [ + 'rpc', + { + method: 'config.set', + params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud' } + } + ], + ['sys', 'model → kimi-k2.6'], + ['warn', { value: 'kimi-k2.6', warning: '' }], + ['model-switched', { result: { value: 'kimi-k2.6', warning: '' }, value: 'kimi-k2.6' }], + ['dispatch', 'Build the thing'] + ]) + }) + + it('does not start a session for an empty prompt', async () => { + const calls: string[] = [] + + const sid = await startPromptLiveSession({ + dispatchSubmission: () => calls.push('dispatch'), + maybeWarn: () => calls.push('warn'), + newLiveSession: async () => { + calls.push('new') + + return 'abc123' + }, + prompt: ' ', + rpc: async () => ({ value: 'unused' }), + sys: () => calls.push('sys') + }) + + expect(sid).toBeNull() + expect(calls).toEqual([]) + }) +}) diff --git a/ui-tui/src/__tests__/useSessionLifecycle.test.ts b/ui-tui/src/__tests__/useSessionLifecycle.test.ts index 8d797742f2d..7a7e11c8758 100644 --- a/ui-tui/src/__tests__/useSessionLifecycle.test.ts +++ b/ui-tui/src/__tests__/useSessionLifecycle.test.ts @@ -2,9 +2,12 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { writeActiveSessionFile } from '../app/useSessionLifecycle.js' +import { turnController } from '../app/turnController.js' +import { getTurnState, resetTurnState } from '../app/turnStore.js' +import { patchUiState, resetUiState } from '../app/uiStore.js' +import { hydrateLiveSessionInflight, liveSessionInflightMessages, writeActiveSessionFile } from '../app/useSessionLifecycle.js' describe('writeActiveSessionFile', () => { let dir = '' @@ -25,3 +28,33 @@ describe('writeActiveSessionFile', () => { expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ session_id: 'actual_session' }) }) }) + + +describe('live session activation in-flight state', () => { + beforeEach(() => { + resetUiState() + resetTurnState() + turnController.fullReset() + patchUiState({ streaming: true }) + }) + + it('keeps the in-flight user prompt in history and hydrates partial assistant text', () => { + const inflight = { assistant: 'partial answer', streaming: true, user: 'write a long answer' } + + expect(liveSessionInflightMessages(inflight)).toEqual([{ role: 'user', text: 'write a long answer' }]) + + hydrateLiveSessionInflight(inflight) + + expect(turnController.bufRef).toBe('partial answer') + expect(getTurnState().streaming).toBe('partial answer') + }) + + it('ignores empty in-flight payloads', () => { + expect(liveSessionInflightMessages({ assistant: '', streaming: false, user: ' ' })).toEqual([]) + + hydrateLiveSessionInflight({ assistant: '', streaming: false, user: '' }) + + expect(turnController.bufRef).toBe('') + expect(getTurnState().streaming).toBe('') + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 452f833fafd..991b69faba4 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea import type { PasteEvent } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' -import type { ImageAttachResponse } from '../gatewayTypes.js' +import type { ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js' import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -79,6 +79,7 @@ export interface OverlayState { pager: null | PagerState picker: boolean secret: null | SecretReq + sessions: boolean skillsHub: boolean sudo: null | SudoReq } @@ -103,6 +104,7 @@ export interface UiState { detailsMode: DetailsMode detailsModeCommandOverride: boolean info: null | SessionInfo + liveSessionCount: number inlineDiffs: boolean mouseTracking: MouseTrackingMode pasteCollapseLines: number @@ -284,6 +286,7 @@ export interface SlashHandlerContext { die: () => void dieWithCode: (code: number) => void guardBusySessionSwitch: (what?: string) => boolean + newLiveSession: (msg?: string, title?: string) => void newSession: (msg?: string, title?: string) => void resetVisibleHistory: (info?: null | SessionInfo) => void resumeById: (id: string) => void @@ -311,6 +314,10 @@ export interface AppLayoutActions { answerSecret: (value: string) => void answerSudo: (pw: string) => void clearSelection: () => void + activateLiveSession: (id: string) => void + closeLiveSession: (id: string) => Promise + newLiveSession: () => void + newPromptSession: (prompt: string, modelArg?: string) => void onModelSelect: (value: string) => void resumeById: (id: string) => void setStickyPrompt: (value: string) => void @@ -369,7 +376,11 @@ export interface AppOverlaysProps { completions: CompletionItem[] onApprovalChoice: (choice: string) => void onClarifyAnswer: (value: string) => void + onActiveSessionSelect: (sessionId: string) => void + onActiveSessionClose: (sessionId: string) => Promise onModelSelect: (value: string) => void + onNewLiveSession: () => void + onNewPromptSession: (prompt: string, modelArg?: string) => void onPickerSelect: (sessionId: string) => void onSecretSubmit: (value: string) => void onSudoSubmit: (pw: string) => void diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 60aa09c4469..72b7021f042 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -12,6 +12,7 @@ const buildOverlayState = (): OverlayState => ({ pager: null, picker: false, secret: null, + sessions: false, skillsHub: false, sudo: null }) @@ -20,8 +21,8 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) => - Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo) + ({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, sessions, skillsHub, sudo }) => + Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || sessions || skillsHub || sudo) ) export const getOverlayState = () => $overlayState.get() @@ -47,5 +48,6 @@ export const resetFlowOverlays = () => agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, modelPicker: $overlayState.get().modelPicker, picker: $overlayState.get().picker, + sessions: $overlayState.get().sessions, skillsHub: $overlayState.get().skillsHub }) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index fb990ef11be..e2fe6f8526b 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -93,15 +93,15 @@ export const sessionCommands: SlashCommand[] = [ }, { - help: 'browse and resume previous sessions', + aliases: ['switch'], + help: 'switch between live TUI sessions', name: 'sessions', run: (arg, ctx) => { - if (ctx.session.guardBusySessionSwitch('switch sessions')) { - return - } - if (!arg.trim()) { - return patchOverlayState({ picker: true }) + if (arg.trim().toLowerCase() === 'new') { + return ctx.session.newLiveSession() } + + patchOverlayState({ sessions: true }) } }, diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 4e22d3312cd..5f11145b010 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -757,6 +757,14 @@ class TurnController { }, this.streamDelay) } + hydrateStreamingText(text: string) { + this.streamTimer = clear(this.streamTimer) + this.bufRef = text + const raw = this.bufRef.trimStart() + const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw + patchTurnState({ streaming: boundedLiveRenderText(visible) }) + } + startMessage() { this.endReasoningPhase() this.clearReasoning() diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index a235bf08c61..b51001cb051 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -15,6 +15,7 @@ const buildUiState = (): UiState => ({ detailsModeCommandOverride: false, indicatorStyle: DEFAULT_INDICATOR_STYLE, info: null, + liveSessionCount: 0, inlineDiffs: true, mouseTracking: MOUSE_TRACKING, pasteCollapseLines: 5, diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 59de48a310d..2cbb745b8fe 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -479,6 +479,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return cActions.clearIn() } + if (isCtrl(key, ch, 'x')) { + return patchOverlayState({ sessions: true }) + } + if (key.ctrl && ch.toLowerCase() === 'c') { if (live.busy && live.sid) { return turnController.interruptTurn({ diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index ad2348d0dce..cfa45438399 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -11,7 +11,10 @@ import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, ClipboardPasteResponse, + ConfigSetResponse, GatewayEvent, + SessionActiveListResponse, + SessionCloseResponse, TerminalResizeResponse } from '../gatewayTypes.js' import { useGitBranch } from '../hooks/useGitBranch.js' @@ -70,6 +73,66 @@ const statusColorOf = (status: string, t: { error: string; muted: string; ok: st return t.muted } +export interface PromptLiveSessionOptions { + dispatchSubmission: (full: string) => void + maybeWarn: (value: unknown) => void + modelArg?: string + newLiveSession: (msg?: string, title?: string) => Promise | null | string | void + onModelSwitched?: (value: string, result: ConfigSetResponse) => void + prompt: string + rpc: GatewayRpc + sys: (text: string) => void +} + +export async function startPromptLiveSession({ + dispatchSubmission, + maybeWarn, + modelArg, + newLiveSession, + onModelSwitched, + prompt, + rpc, + sys +}: PromptLiveSessionOptions) { + const trimmed = prompt.trim() + + if (!trimmed) { + return null + } + + // Let the backend-created session key (YYYYMMDD_HHMMSS_xxxxxx) remain + // the initial title. Auto-title generation can rename it after the first + // response; pre-queuing prompt text here causes duplicate-title errors when + // users dispatch common prompts like "Hello, what model are you?". + const sid = (await newLiveSession('new live session started')) ?? null + + if (!sid) { + sys('error: failed to start new live session') + + return null + } + + const requestedModel = modelArg?.trim() + + if (requestedModel) { + const result = await rpc('config.set', { key: 'model', session_id: sid, value: requestedModel }) + + if (!result?.value) { + sys('error: invalid response: model switch') + + return sid + } + + sys(`model → ${result.value}`) + maybeWarn(result) + onModelSwitched?.(result.value, result) + } + + dispatchSubmission(trimmed) + + return sid +} + export function useMainApp(gw: GatewayClient) { const { exit } = useApp() const { stdout } = useStdout() @@ -429,6 +492,36 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) + useEffect(() => { + if (!ui.sid) { + patchUiState({ liveSessionCount: 0 }) + + return + } + + let stopped = false + + const refresh = () => { + gw.request('session.active_list', { current_session_id: getUiState().sid }) + .then(raw => { + const result = asRpcResult(raw) + + if (!stopped && result?.sessions) { + patchUiState({ liveSessionCount: result.sessions.length }) + } + }) + .catch(() => {}) + } + + refresh() + const timer = setInterval(refresh, 1500) + + return () => { + stopped = true + clearInterval(timer) + } + }, [gw, ui.sid]) + // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. const model = ui.info?.model?.replace(/^.*\//, '') ?? '' @@ -683,6 +776,7 @@ export function useMainApp(gw: GatewayClient) { die, dieWithCode, guardBusySessionSwitch: session.guardBusySessionSwitch, + newLiveSession: session.newLiveSession, newSession: session.newSession, resetVisibleHistory: session.resetVisibleHistory, resumeById: session.resumeById, @@ -690,7 +784,7 @@ export function useMainApp(gw: GatewayClient) { }, slashFlightRef, transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange }, - voice: { setVoiceEnabled, setVoiceRecordKey } + voice: { setVoiceEnabled, setVoiceRecordKey, setVoiceTts } }), [ catalog, @@ -760,6 +854,46 @@ export function useMainApp(gw: GatewayClient) { slashRef.current(`/model ${value}`) }, []) + const closeLiveSession = useCallback( + async (id: string) => { + patchUiState({ status: 'closing session…' }) + + try { + const result = (await session.closeSession(id)) as null | SessionCloseResponse + patchUiState({ status: 'ready' }) + + return result + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + sys(`error: ${message}`) + patchUiState({ status: 'ready' }) + + throw e + } + }, + [session, sys] + ) + + const newPromptSession = useCallback( + (prompt: string, modelArg?: string) => { + void startPromptLiveSession({ + dispatchSubmission, + maybeWarn, + modelArg, + newLiveSession: session.newLiveSession, + onModelSwitched: value => + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: value } : { model: value, skills: {}, tools: {} } + })), + prompt, + rpc, + sys + }) + }, + [dispatchSubmission, maybeWarn, rpc, session.newLiveSession, sys] + ) + const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim())) // Per-section overrides win over the global mode — when every section is @@ -813,16 +947,32 @@ export function useMainApp(gw: GatewayClient) { const appActions = useMemo( () => ({ + activateLiveSession: session.activateLiveSession, + closeLiveSession, answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, + newLiveSession: () => session.newLiveSession(), + newPromptSession, onModelSelect, resumeById: session.resumeById, setStickyPrompt }), - [answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, onModelSelect, session.resumeById] + [ + answerApproval, + answerClarify, + answerSecret, + answerSudo, + clearSelection, + closeLiveSession, + newPromptSession, + onModelSelect, + session.activateLiveSession, + session.newLiveSession, + session.resumeById + ] ) const appComposer = useMemo( diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index e73158b27bc..5857b44dd63 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -2,15 +2,17 @@ import { writeFileSync } from 'node:fs' import type { ScrollBoxHandle } from '@hermes/ink' import { evictInkCaches } from '@hermes/ink' -import { useCallback, type RefObject } from 'react' +import { type RefObject, useCallback } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' import { ZERO } from '../domain/usage.js' import { type GatewayClient } from '../gatewayClient.js' import type { + SessionActivateResponse, SessionCloseResponse, SessionCreateResponse, + SessionInflightTurn, SessionResumeResponse, SessionTitleResponse, SetupStatusResponse @@ -26,6 +28,18 @@ import { getUiState, patchUiState } from './uiStore.js' const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) +const statusFromLiveSession = (status?: string, running = false) => { + if (status === 'waiting') { + return 'waiting for input…' + } + + if (status === 'starting') { + return 'starting agent…' + } + + return running || status === 'working' ? 'running…' : 'ready' +} + export const writeActiveSessionFile = (sessionId: null | string, file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE) => { if (!file || !sessionId) { return @@ -38,6 +52,22 @@ export const writeActiveSessionFile = (sessionId: null | string, file = process. } } +export const liveSessionInflightMessages = (inflight?: null | SessionInflightTurn): Msg[] => { + const user = String(inflight?.user ?? '').trim() + + return user ? [{ role: 'user', text: user }] : [] +} + +export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn) => { + const assistant = String(inflight?.assistant ?? '') + + if (!assistant && !inflight?.streaming) { + return + } + + turnController.hydrateStreamingText(assistant) +} + const trimTail = (items: Msg[]) => { const q = [...items] @@ -122,23 +152,27 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] ) - const newSession = useCallback( - async (msg?: string, title?: string) => { + const startNewSession = useCallback( + async (msg?: string, title?: string, keepCurrent = false) => { const setup = await rpc('setup.status', {}) if (setup?.provider_configured === false) { panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) patchUiState({ status: 'setup required' }) - return + return null } - await closeSession(getUiState().sid) + if (!keepCurrent) { + await closeSession(getUiState().sid) + } const r = await rpc('session.create', { cols: colsRef.current }) if (!r) { - return patchUiState({ status: 'ready' }) + patchUiState({ status: 'ready' }) + + return null } const info = r.info ?? null @@ -194,10 +228,67 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { sys(`warning: failed to set session title: ${message}`) }) } + + return r.session_id }, [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) + const newSession = useCallback( + (msg?: string, title?: string) => startNewSession(msg, title, false), + [startNewSession] + ) + + const newLiveSession = useCallback( + (msg = 'new live session started', title?: string) => { + patchOverlayState({ sessions: false }) + + return startNewSession(msg, title, true) + }, + [startNewSession] + ) + + const activateLiveSession = useCallback( + (id: string) => { + patchOverlayState({ sessions: false }) + patchUiState({ status: 'switching session…' }) + + gw.request('session.activate', { session_id: id }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.activate') + + return patchUiState({ status: 'ready' }) + } + + const info = r.info ?? null + const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting') + + resetSession() + setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now()) + const transcript = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)] + setHistoryItems(info ? [introMsg(info), ...transcript] : transcript) + writeActiveSessionFile(r.session_key ?? r.session_id) + patchUiState({ + busy: running, + info, + sid: r.session_id, + status: statusFromLiveSession(r.status, running), + usage: usageFrom(info) + }) + hydrateLiveSessionInflight(r.inflight) + setTimeout(() => scrollRef.current?.scrollToBottom(), 0) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + }, + [gw, resetSession, scrollRef, setHistoryItems, setSessionStartedAt, sys] + ) + const resumeById = useCallback( (id: string) => { patchOverlayState({ picker: false }) @@ -262,8 +353,10 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { ) return { + activateLiveSession, closeSession, guardBusySessionSwitch, + newLiveSession, newSession, resetSession, resetVisibleHistory, diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx new file mode 100644 index 00000000000..f158b24a44d --- /dev/null +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -0,0 +1,635 @@ +import { Box, Text, useInput, useStdout } from '@hermes/ink' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' +import type { GatewayClient } from '../gatewayClient.js' +import type { SessionActiveItem, SessionActiveListResponse, SessionCloseResponse } from '../gatewayTypes.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { ModelPicker } from './modelPicker.js' +import { windowOffset } from './overlayControls.js' +import { TextInput } from './textInput.js' + +const VISIBLE = 12 +const MIN_WIDTH = 64 +const MAX_WIDTH = 128 +const TITLE_MAX = 64 + +const STATUS_GLYPH: Record = { + idle: '✓', + starting: '…', + waiting: '?', + working: '▶' +} + +const STATUS_LABEL: Record = { + idle: 'idle', + starting: 'starting', + waiting: 'waiting', + working: 'working' +} + +const CTRL_OFFSET = 96 + +const shortModel = (model = '') => model.replace(/^.*\//, '') || 'model?' +const ctrlChar = (letter: string) => String.fromCharCode(letter.charCodeAt(0) - CTRL_OFFSET) + +export const fixedSessionColumnStyle = () => ({ flexShrink: 0 }) + +export const activeSessionCountLabel = (count: number) => + `${count} live ${count === 1 ? 'session' : 'sessions'}` + +export type OrchestratorHintRole = 'hotkey' | 'label' | 'text' + +export interface OrchestratorHintSegment { + role: OrchestratorHintRole + text: string +} + +export const orchestratorContextHintSegments = (newSelected: boolean): OrchestratorHintSegment[] => + newSelected + ? [ + { role: 'label', text: 'New row:' }, + { role: 'text', text: ' type prompt · ' }, + { role: 'hotkey', text: 'Enter' }, + { role: 'text', text: ' start · ' }, + { role: 'hotkey', text: 'Tab' }, + { role: 'text', text: ' model' } + ] + : [ + { role: 'label', text: 'Session row:' }, + { role: 'text', text: ' ' }, + { role: 'hotkey', text: 'Enter' }, + { role: 'text', text: ' switch · ' }, + { role: 'hotkey', text: 'Ctrl+D' }, + { role: 'text', text: ' close' } + ] + +export const orchestratorGlobalHotkeyHintSegments: OrchestratorHintSegment[] = [ + { role: 'hotkey', text: '↑↓' }, + { role: 'text', text: ' move · ' }, + { role: 'hotkey', text: 'Ctrl+N' }, + { role: 'text', text: ' new · ' }, + { role: 'hotkey', text: 'Ctrl+R' }, + { role: 'text', text: ' refresh · ' }, + { role: 'hotkey', text: 'Esc' }, + { role: 'text', text: ' close' } +] + +const hintText = (segments: readonly OrchestratorHintSegment[]) => segments.map(segment => segment.text).join('') + +export const orchestratorContextHint = (newSelected: boolean) => hintText(orchestratorContextHintSegments(newSelected)) + +export const orchestratorGlobalHotkeyHint = hintText(orchestratorGlobalHotkeyHintSegments) + +export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRole) => { + if (role === 'hotkey') { + return t.color.accent + } + + if (role === 'label') { + return t.color.label + } + + return t.color.muted +} + +export const selectedSessionRowStyle = (t: Theme) => ({ + backgroundColor: t.color.selectionBg, + color: t.color.text +}) + +export const newSessionMarkerColor = (t: Theme, selected: boolean) => + selected ? selectedSessionRowStyle(t).color : t.color.label + +export const newSessionRowIndex = (sessionCount: number) => Math.max(0, sessionCount) + +export const isNewSessionRow = (index: number, sessionCount: number) => index >= newSessionRowIndex(sessionCount) + +export const canTypeOrchestratorPrompt = (index: number, sessionCount: number) => isNewSessionRow(index, sessionCount) + +export const clampOrchestratorSelection = (index: number, sessionCount: number) => + Math.max(0, Math.min(index, newSessionRowIndex(sessionCount))) + +export const currentSessionSelectionIndex = ( + sessions: readonly SessionActiveItem[], + currentSessionId: null | string +) => { + const index = sessions.findIndex(s => Boolean(s.current) || (!!currentSessionId && s.id === currentSessionId)) + + return index >= 0 ? index : 0 +} + +export const orchestratorVisibleRowIndexes = (sessionCount: number, selected: number, visible = VISIBLE) => { + const total = Math.max(0, sessionCount) + 1 + const clamped = clampOrchestratorSelection(selected, sessionCount) + const offset = windowOffset(total, clamped, visible) + const count = Math.min(visible, total - offset) + + return Array.from({ length: count }, (_, i) => offset + i) +} + +export type CloseFallback = { action: 'activate'; sessionId: string } | { action: 'new' } | { action: 'stay' } + +export const closeFallbackAfterClose = ( + closedId: string, + currentSessionId: null | string, + remaining: readonly SessionActiveItem[] +): CloseFallback => { + if (!currentSessionId || closedId !== currentSessionId) { + return { action: 'stay' } + } + + const next = remaining.find(s => s.id !== closedId) + + return next ? { action: 'activate', sessionId: next.id } : { action: 'new' } +} + +export const draftModelArgFromPickerValue = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean) + const kept: string[] = [] + + for (const part of parts) { + if (part === TUI_SESSION_MODEL_FLAG || part === '--global') { + continue + } + + kept.push(part) + } + + return kept.join(' ') +} + +export const draftModelNameFromArg = (value: string) => { + const parts = draftModelArgFromPickerValue(value).split(/\s+/).filter(Boolean) + const modelParts: string[] = [] + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]! + + if (part === '--provider') { + i++ + continue + } + + if (part.startsWith('--')) { + continue + } + + modelParts.push(part) + } + + return modelParts.join(' ').trim() +} + +export const draftModelDisplayLabel = (value: string) => { + const modelName = draftModelNameFromArg(value) + + return modelName ? shortModel(modelName) : 'current/default' +} + +export type OrchestratorRowClickAction = { action: 'activate'; sessionId: string } | { action: 'select-new' } + +export const orchestratorRowClickAction = ( + index: number, + sessions: readonly SessionActiveItem[] +): OrchestratorRowClickAction => { + const target = sessions[index] + + return target && !isNewSessionRow(index, sessions.length) + ? { action: 'activate', sessionId: target.id } + : { action: 'select-new' } +} + +export const draftTitleFromPrompt = (prompt: string, max = TITLE_MAX) => { + const compact = prompt.replace(/\s+/g, ' ').trim() + + if (compact.length <= max) { + return compact + } + + return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}…` +} + +function OrchestratorHintSegments({ segments, t }: OrchestratorHintTextProps) { + return ( + <> + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + ) +} + +function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) { + return ( + + + + ) +} + +export function ActiveSessionSwitcher({ + currentSessionId, + gw, + onCancel, + onClose, + onNew, + onNewPrompt, + onSelect, + t +}: ActiveSessionSwitcherProps) { + const [items, setItems] = useState([]) + const [err, setErr] = useState('') + const [sel, setSel] = useState(0) + const [loading, setLoading] = useState(true) + const [draft, setDraft] = useState('') + const [draftModel, setDraftModel] = useState('') + const [pickingModel, setPickingModel] = useState(false) + const [closingId, setClosingId] = useState('') + const initialSelectionAppliedRef = useRef(false) + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const promptColumns = Math.max(20, width - 11) + + const load = useCallback( + async (quiet = false) => { + if (!quiet) { + setLoading(true) + } + + try { + const raw = await gw.request('session.active_list', { + current_session_id: currentSessionId + }) + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: session.active_list') + setLoading(false) + + return [] + } + + const next = r.sessions ?? [] + const initializeSelection = !initialSelectionAppliedRef.current + initialSelectionAppliedRef.current = true + setItems(next) + setSel(s => + initializeSelection + ? clampOrchestratorSelection(currentSessionSelectionIndex(next, currentSessionId), next.length) + : clampOrchestratorSelection(s, next.length) + ) + setErr('') + setLoading(false) + + return next + } catch (e: unknown) { + setErr(rpcErrorMessage(e)) + setLoading(false) + + return [] + } + }, + [currentSessionId, gw] + ) + + useEffect(() => { + void load() + const timer = setInterval(() => void load(true), 1500) + + return () => clearInterval(timer) + }, [load]) + + const submitDraft = useCallback( + (value: string) => { + const prompt = value.trim() + + if (!prompt) { + return + } + + setDraft('') + onNewPrompt(prompt, draftModel || undefined) + }, + [draftModel, onNewPrompt] + ) + + const closeSelected = useCallback(async () => { + const target = items[sel] + + if (!target || isNewSessionRow(sel, items.length) || closingId) { + return + } + + setErr('') + setClosingId(target.id) + + try { + const result = await onClose(target.id) + const closed = Boolean(result?.closed ?? result?.ok) + + if (!closed) { + setErr('session was already closed') + + return + } + + const remaining = await load(true) + const fallback = closeFallbackAfterClose(target.id, currentSessionId, remaining) + + if (fallback.action === 'activate') { + onSelect(fallback.sessionId) + } else if (fallback.action === 'new') { + onNew() + } else { + setSel(s => clampOrchestratorSelection(s, remaining.length)) + } + } catch (e: unknown) { + setErr(rpcErrorMessage(e)) + } finally { + setClosingId('') + } + }, [closingId, currentSessionId, items, load, onClose, onNew, onSelect, sel]) + + const handleRowClick = useCallback( + (index: number) => (event: { stopImmediatePropagation?: () => void }) => { + event.stopImmediatePropagation?.() + const action = orchestratorRowClickAction(index, items) + + if (action.action === 'activate') { + setSel(clampOrchestratorSelection(index, items.length)) + onSelect(action.sessionId) + + return + } + + setSel(newSessionRowIndex(items.length)) + }, + [items, onSelect] + ) + + const newSelected = isNewSessionRow(sel, items.length) + const draftHasText = Boolean(draft.trim()) + + useInput((ch, key) => { + if (pickingModel) { + return + } + + const lower = ch?.toLowerCase() ?? '' + const isCtrl = (letter: string) => key.ctrl && (lower === letter || ch === ctrlChar(letter)) + + if (key.escape) { + return onCancel() + } + + if (isCtrl('n')) { + return onNew() + } + + if (isCtrl('r')) { + void load() + + return + } + + if (key.tab) { + if (newSelected) { + setPickingModel(true) + } + + return + } + + if (isCtrl('d')) { + if (!newSelected) { + void closeSelected() + } + + return + } + + if (newSelected && draftHasText) { + return + } + + if (key.upArrow && sel > 0) { + return setSel(s => clampOrchestratorSelection(s - 1, items.length)) + } + + if (key.downArrow && sel < newSessionRowIndex(items.length)) { + return setSel(s => clampOrchestratorSelection(s + 1, items.length)) + } + + if (key.return) { + if (newSelected) { + if (!draftHasText) { + return onNew() + } + + return + } + + if (items[sel]) { + return onSelect(items[sel]!.id) + } + } + }) + + if (pickingModel) { + return ( + setPickingModel(false)} + onSelect={value => { + setDraftModel(draftModelArgFromPickerValue(value)) + setPickingModel(false) + }} + sessionId={currentSessionId} + t={t} + /> + ) + } + + if (loading) { + return loading session orchestrator… + } + + const totalRows = items.length + 1 + const offset = windowOffset(totalRows, sel, VISIBLE) + const visibleRows = orchestratorVisibleRowIndexes(items.length, sel, VISIBLE) + + return ( + + + Session Orchestrator + + {activeSessionCountLabel(items.length)} + + {err && error: {err}} + {!items.length && ( + no live sessions — closed TUIs only leave resumable transcripts + )} + {offset > 0 && ↑ {offset} more} + + {visibleRows.map(i => { + const selected = sel === i + const selectedStyle = selected ? selectedSessionRowStyle(t) : null + const rowTextColor = selectedStyle?.color + + if (isNewSessionRow(i, items.length)) { + const promptTitle = draftTitleFromPrompt(draft) || 'Start a new live session' + const markerColor = newSessionMarkerColor(t, selected) + + return ( + + + {selected ? '▸ ' : ' '} + + + + + + + + + + + + new + + + + + + ✎ draft + + + + + + {draftModelDisplayLabel(draftModel)} + + + + + + {promptTitle} + + + + ) + } + + const s = items[i]! + const status = s.status ?? 'idle' + const current = s.current || s.id === currentSessionId + const title = closingId === s.id ? 'closing…' : s.title || s.preview || '(untitled)' + + return ( + + + {selected ? '▸ ' : ' '} + + + + + {String(i + 1).padStart(2)}. + + + + + + {current ? 'current' : s.id} + + + + + + {STATUS_GLYPH[status] ?? '·'} {STATUS_LABEL[status] ?? status} + + + + + + {shortModel(s.model)} + + + + + + {title} + + + + ) + })} + + {offset + VISIBLE < totalRows && ↓ {totalRows - offset - VISIBLE} more} + + {newSelected ? ( + <> + + prompt › + + + + + model: {draftModelDisplayLabel(draftModel)} + + + ) : ( + + + + Select +new to type a prompt + + + )} + + + + ) +} + +interface OrchestratorHintTextProps { + segments: readonly OrchestratorHintSegment[] + t: Theme +} + +interface ActiveSessionSwitcherProps { + currentSessionId: null | string + gw: GatewayClient + onCancel: () => void + onClose: (id: string) => Promise + onNew: () => void + onNewPrompt: (prompt: string, modelArg?: string) => void + onSelect: (id: string) => void + t: Theme +} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 771c917691f..0823b924e7a 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -143,6 +143,10 @@ function ctxBarColor(pct: number | undefined, t: Theme) { return t.color.statusGood } +function statusSessionCountLabel(count: number) { + return `${count} ${count === 1 ? 'session' : 'sessions'}` +} + function ctxBar(pct: number | undefined, w = 10) { const p = Math.max(0, Math.min(100, pct ?? 0)) const filled = Math.round((p / 100) * w) @@ -298,10 +302,12 @@ export function StatusRule({ modelReasoningEffort, usage, bgCount, + liveSessionCount, sessionStartedAt, showCost, turnStartedAt, voiceLabel, + onSessionCountClick, t }: StatusRuleProps) { const pct = usage.context_percent @@ -315,55 +321,92 @@ export function StatusRule({ const bar = usage.context_max ? ctxBar(pct) : '' const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel) + const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : '' + const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => { + event.stopImmediatePropagation?.() + onSessionCountClick?.() + } + + const sessionCountNode = sessionCountText ? ( + onSessionCountClick ? ( + + │ {sessionCountText} + + ) : ( + │ {sessionCountText} + ) + ) : null return ( - + {'─ '} - {busy ? ( - - ) : ( - {status} - )} - │ {modelLabel(model, modelReasoningEffort, modelFast)} - {ctxLabel ? │ {ctxLabel} : null} - {bar ? ( - - {' │ '} - [{bar}] {pct != null ? `${pct}%` : ''} - - ) : null} - {sessionStartedAt ? ( - - {' │ '} - - - ) : null} - {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( - - {' │ '} - = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}> - cmp {usage.compressions} - - - ) : null} - - {voiceLabel ? ( - - {' │ '} - {voiceLabel} - - ) : null} - {bgCount > 0 ? │ {bgCount} bg : null} - {showCost && typeof usage.cost_usd === 'number' ? ( - │ ${usage.cost_usd.toFixed(4)} - ) : null} + {busy ? ( + + ) : ( + + {status} + + )} + + {' │ '} + {modelLabel(model, modelReasoningEffort, modelFast)} + + {ctxLabel ? ( + + {' │ '} + {ctxLabel} + + ) : null} + {bar ? ( + + {' │ '} + [{bar}] {pct != null ? `${pct}%` : ''} + + ) : null} + {sessionStartedAt ? ( + + {' │ '} + + + ) : null} + {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( + + {' │ '} + = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted} + > + cmp {usage.compressions} + + + ) : null} + + {voiceLabel ? ( + + {' │ '} + {voiceLabel} + + ) : null} + {sessionCountNode} + {bgCount > 0 ? ( + + {' │ '} + {bgCount} bg + + ) : null} + {showCost && typeof usage.cost_usd === 'number' ? ( + + {' │ $'} + {usage.cost_usd.toFixed(4)} + + ) : null} {rightWidth > 0 ? ( @@ -480,6 +523,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) interface StatusRuleProps { bgCount: number + liveSessionCount: number busy: boolean cols: number cwdLabel: string @@ -494,6 +538,7 @@ interface StatusRuleProps { turnStartedAt?: null | number usage: Usage voiceLabel?: string + onSessionCountClick?: () => void } interface StickyPromptTrackerProps { diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 8b69b9e4425..7f43bc11772 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -252,7 +252,11 @@ const ComposerPane = memo(function ComposerPane({ cols={composer.cols} compIdx={composer.compIdx} completions={composer.completions} + onActiveSessionSelect={actions.activateLiveSession} + onActiveSessionClose={actions.closeLiveSession} onModelSelect={actions.onModelSelect} + onNewLiveSession={actions.newLiveSession} + onNewPromptSession={actions.newPromptSession} onPickerSelect={actions.resumeById} pagerPageSize={composer.pagerPageSize} /> @@ -354,9 +358,11 @@ const StatusRulePane = memo(function StatusRulePane({ busy={ui.busy} cols={composer.cols} cwdLabel={status.cwdLabel} + liveSessionCount={ui.liveSessionCount} model={ui.info?.model ?? ''} modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'} modelReasoningEffort={ui.info?.reasoning_effort} + onSessionCountClick={() => patchOverlayState({ sessions: true })} sessionStartedAt={status.sessionStartedAt} showCost={ui.showCost} status={ui.status} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 7fd14563a99..600a2ac19ec 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -6,6 +6,7 @@ import type { AppOverlaysProps } from '../app/interfaces.js' import { $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiSessionId, $uiTheme } from '../app/uiStore.js' +import { ActiveSessionSwitcher } from './activeSessionSwitcher.js' import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' @@ -95,16 +96,38 @@ export function FloatingOverlays({ cols, compIdx, completions, + onActiveSessionSelect, + onActiveSessionClose, onModelSelect, + onNewLiveSession, + onNewPromptSession, onPickerSelect, pagerPageSize -}: Pick) { +}: Pick< + AppOverlaysProps, + | 'cols' + | 'compIdx' + | 'completions' + | 'onActiveSessionSelect' + | 'onActiveSessionClose' + | 'onModelSelect' + | 'onNewLiveSession' + | 'onNewPromptSession' + | 'onPickerSelect' + | 'pagerPageSize' +>) { const { gw } = useGateway() const overlay = useStore($overlayState) const sid = useStore($uiSessionId) const theme = useStore($uiTheme) - const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length + const hasAny = + overlay.modelPicker || + overlay.pager || + overlay.picker || + overlay.sessions || + overlay.skillsHub || + completions.length if (!hasAny) { return null @@ -130,6 +153,21 @@ export function FloatingOverlays({ )} + {overlay.sessions && ( + + patchOverlayState({ sessions: false })} + onClose={onActiveSessionClose} + onNew={onNewLiveSession} + onNewPrompt={onNewPromptSession} + onSelect={onActiveSessionSelect} + t={theme} + /> + + )} + {overlay.modelPicker && ( ([]) const [currentModel, setCurrentModel] = useState('') const [err, setErr] = useState('') @@ -105,7 +105,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke gw.request<{ provider?: ModelOptionProvider }>('model.save_key', { slug: provider?.slug, api_key: keyInput.trim(), - ...(sessionId ? { session_id: sessionId } : {}), + ...(sessionId ? { session_id: sessionId } : {}) }) .then(raw => { const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw) @@ -118,9 +118,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke } // Update the provider in our list with fresh data - setProviders(prev => - prev.map(p => p.slug === r.provider!.slug ? r.provider! : p) - ) + setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p))) setKeyInput('') setKeySaving(false) setStage('model') @@ -166,7 +164,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke setKeySaving(true) gw.request<{ disconnected?: boolean }>('model.disconnect', { slug: provider.slug, - ...(sessionId ? { session_id: sessionId } : {}), + ...(sessionId ? { session_id: sessionId } : {}) }) .then(raw => { const r = asRpcResult<{ disconnected?: boolean }>(raw) @@ -174,9 +172,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (r?.disconnected) { // Mark provider as unauthenticated in local state setProviders(prev => - prev.map(p => p.slug === provider.slug - ? { ...p, authenticated: false, models: [], total_models: 0, warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' } - : p + prev.map(p => + p.slug === provider.slug + ? { + ...p, + authenticated: false, + models: [], + total_models: 0, + warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' + } + : p ) ) } @@ -244,7 +249,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const model = models[modelIdx] if (provider && model) { - onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`) + onSelect( + `${model} --provider ${provider.slug}${allowPersistGlobal && persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}` + ) } else { setStage('provider') } @@ -252,7 +259,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return } - if (ch.toLowerCase() === 'g') { + if (allowPersistGlobal && ch.toLowerCase() === 'g') { setPersistGlobal(v => !v) return @@ -302,17 +309,23 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Paste your API key below (saved to ~/.hermes/.env) - + + {' '} + {provider.key_env}: - {' '}{masked || '(empty)'}{keySaving ? '' : '▎'} + {' '} + {masked || '(empty)'} + {keySaving ? '' : '▎'} - + + {' '} + {keyError ? ( @@ -323,7 +336,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke saving… ) : ( - + + {' '} + )} Enter save · Ctrl+U clear · Esc back @@ -339,7 +354,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Disconnect {provider.name}? - + + {' '} + This removes saved credentials for {provider.name}. @@ -349,10 +366,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke You can re-authenticate later by selecting it again. - + + {' '} + {keySaving ? ( - disconnecting… + + disconnecting… + ) : ( y/Enter confirm · n/Esc cancel )} @@ -362,17 +383,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke // ── Provider selection stage ───────────────────────────────────────── if (stage === 'provider') { - const rows = providers.map( - (p, i) => { - const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●' - const modelCount = p.total_models ?? p.models?.length ?? 0 - const suffix = p.authenticated === false - ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') - : `${modelCount} models` + const rows = providers.map((p, i) => { + const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●' + const modelCount = p.total_models ?? p.models?.length ?? 0 + const suffix = + p.authenticated === false ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') : `${modelCount} models` - return `${authMark} ${names[i]} · ${suffix}` - } - ) + return `${authMark} ${names[i]} · ${suffix}` + }) const { items, offset } = windowItems(rows, providerIdx, VISIBLE) @@ -425,7 +443,8 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke - persist: {persistGlobal ? 'global' : 'session'} · g toggle + persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} + {allowPersistGlobal ? ' · g toggle' : ' only'} ↑/↓ select · Enter choose · d disconnect · Esc/q cancel @@ -488,7 +507,8 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke - persist: {persistGlobal ? 'global' : 'session'} · g toggle + persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} + {allowPersistGlobal ? ' · g toggle' : ' only'} {models.length ? '↑/↓ select · Enter switch · Esc back · q close' : 'Enter/Esc back · q close'} @@ -498,6 +518,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke } interface ModelPickerProps { + allowPersistGlobal?: boolean gw: GatewayClient onCancel: () => void onSelect: (value: string) => void diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index b79d08061bf..c1a4553a49d 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -23,7 +23,7 @@ export const HOTKEYS: [string, string][] = [ [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], - ['Ctrl+X', 'delete the queued message you’re editing (Esc cancels edit)'], + ['Ctrl+X', 'open live session switcher (deletes queued message while editing)'], [action + '+A/E', 'home / end of line'], [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], [action + '+W', 'delete word'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 4ff1483b236..ae1f38e9b38 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -122,6 +122,43 @@ export interface SessionResumeResponse { session_id: string } +export type LiveSessionStatus = 'idle' | 'starting' | 'waiting' | 'working' + +export interface SessionActiveItem { + current?: boolean + id: string + last_active?: number + message_count?: number + model?: string + preview?: string + session_key?: string + started_at?: number + status: LiveSessionStatus + title?: string +} + +export interface SessionActiveListResponse { + sessions?: SessionActiveItem[] +} + +export interface SessionInflightTurn { + assistant?: string + streaming?: boolean + user?: string +} + +export interface SessionActivateResponse { + inflight?: null | SessionInflightTurn + info?: SessionInfo + message_count?: number + messages: GatewayTranscriptMessage[] + running?: boolean + session_id: string + session_key?: string + started_at?: number + status?: LiveSessionStatus +} + export interface SessionListItem { id: string message_count: number @@ -203,6 +240,7 @@ export interface SessionBranchResponse { } export interface SessionCloseResponse { + closed?: boolean ok?: boolean } diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index 1ad0b13ef91..d21edbf85c3 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -41,7 +41,8 @@ hermes acp --bootstrap # print install snippet for an ACP-capable IDE ``` prompt.submit prompt.background session.steer -session.create session.list session.interrupt +session.create session.list session.active_list +session.activate session.close session.interrupt session.history session.compress session.branch session.title session.usage session.status clarify.respond sudo.respond secret.respond @@ -52,6 +53,8 @@ delegation.status subagent.interrupt spawn_tree.save / list / load terminal.resize clipboard.paste image.attach ``` +`session.active_list`, `session.activate`, and `session.close` are the process-local live-session controls used by the TUI session switcher. Use `session.list` / `/resume` for saved transcript discovery; use the active-session methods only for sessions that are currently open in the TUI gateway process. + ### Events streamed back `message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `gateway.ready`, plus session lifecycle and error events. diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index b1aaf259be5..7bb00442be5 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -52,7 +52,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/goal ` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. After each turn an auxiliary judge model decides whether the goal is done; if not, Hermes auto-continues. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Budget defaults to 20 turns (`goals.max_turns`); any real user message preempts the continuation loop, and state survives `/resume`. See [Persistent Goals](/user-guide/features/goals) for the full walkthrough. | | `/subgoal ` | Append a user-supplied criterion to the active goal mid-loop. The continuation prompt surfaces all subgoals to the agent verbatim, and the judge factors them into its DONE/CONTINUE verdict — so the goal isn't marked done until the original goal **and** every subgoal are met. Subcommands: `/subgoal` (list), `/subgoal remove `, `/subgoal clear`. Requires an active `/goal`. | | `/resume [name]` | Resume a previously-named session | -| `/sessions` | Browse and resume previous sessions in an interactive picker | +| `/sessions` (TUI alias: `/switch`) | Classic CLI: browse and resume previous sessions in an interactive picker. TUI: open the live session switcher for currently open TUI sessions. Use `/sessions new` in the TUI to start another live session immediately. | | `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) | | `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | @@ -238,6 +238,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands. - `/status`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. +- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts. ## Confirmation prompts for destructive commands diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 533a661258b..5be74faaae0 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -89,7 +89,7 @@ Keybindings match the [Classic CLI](cli.md#keybindings) exactly. The only behavi - **`Cmd+V` / `Ctrl+V`** first tries normal text paste, then falls back to OSC52/native clipboard reads, and finally image attach when the clipboard or pasted payload resolves to an image. - **`/terminal-setup`** installs local VS Code / Cursor / Windsurf terminal bindings for better `Cmd+Enter` and undo/redo parity on macOS. - **Slash autocompletion** opens as a floating panel with descriptions, not an inline dropdown. -- **`Ctrl+X`** — when a queued message is highlighted (sent while the agent was still running), delete it from the queue. **`Esc`** cancels editing and unhighlights without deleting. +- **`Ctrl+X`** opens the live session switcher. When a queued message is highlighted (sent while the agent was still running), it still deletes that queued message instead. **`Esc`** cancels editing and unhighlights without deleting. - **`Ctrl+G` / `Ctrl+X Ctrl+E`** — open the current input buffer in `$EDITOR` for multi-line / long-prompt composition; save-and-exit sends the contents back as the prompt. ## Slash commands @@ -99,7 +99,7 @@ All slash commands work unchanged. A few are TUI-owned — they produce richer o | Command | TUI behavior | |---------|--------------| | `/help` | Overlay with categorized commands, arrow-key navigable | -| `/sessions` | Modal session picker — preview, title, token totals, resume inline | +| `/sessions` (alias `/switch`) | Live session switcher — list open TUI sessions, switch between them, close them, or start another one | | `/model` | Modal model picker grouped by provider, with cost hints | | `/skin` | Live preview — theme change applies as you browse | | `/details` | Toggle verbose tool-call details (global or per-section) | @@ -110,6 +110,31 @@ All slash commands work unchanged. A few are TUI-owned — they produce richer o Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md). +## Live session switcher + +Use the live session switcher when you want one terminal to act as a dispatcher for several TUI sessions. It lists only sessions that are currently live in this TUI process; closed sessions remain saved transcripts and can still be reopened with `/resume` or `hermes --tui --resume `. + +Open it with any of these: + +- `Ctrl+X` from the TUI. +- `/sessions` or `/switch`. +- `/sessions new` to create a fresh live session immediately. +- Click the `N live sessions` count in the status line. + +Hermes TUI Session Orchestrator with one live session and a +new row + +