""" Interactive stderr/stdin picker for ``--resume``. A keyboard selector for picking a saved conversation: arrow keys move the highlighted row, Enter resumes it, and q/Esc cancels. Conversations come from Omnigent' conversation store. Two entry points share one rendering loop: - :func:`pick_conversation` — pure picker. Caller already has the conversation list (e.g. fetched via SDK or store). - :func:`pick_conversation_from_sdk` — convenience that drives the SDK fetch + filter for the chat REPL path. The picker writes to ``stderr`` rather than ``stdout`` so a parent process piping the agent's stdout stream still gets clean output — matching the legacy picker's stream choice. Reads come from ``stdin``. Interactive terminals use prompt-toolkit so key decoding, resize handling, and redraw diffing are owned by the same TUI stack as the REPL; non-TTY callers keep a simple line-buffered fallback for scripted compatibility. """ from __future__ import annotations import asyncio import sys import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import IO, Any, Protocol from omnigent._terminal_picker_theme import ( PICKER_ACCENT as _ACCENT, ) from omnigent._terminal_picker_theme import ( PICKER_MUTED as _MUTED, ) # Wrapper label sentinel — single source of truth in # ``omnigent._wrapper_labels``. Imported here so the picker module # stays decoupled from the heavy ``claude_native`` import graph (tmux # / websocket code) while still rendering the right badge. from omnigent._wrapper_labels import ( CLAUDE_NATIVE_WRAPPER_VALUE as _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE, ) from omnigent._wrapper_labels import ( WRAPPER_LABEL_KEY as _CLAUDE_NATIVE_WRAPPER_LABEL_KEY, ) # Client-side persistent launch state. The picker reads this per row # to render workspace metadata for native wrapper sessions. Decoupled # from the heavy wrapper import graphs; these state modules have no # tmux / websocket dependencies. from omnigent.claude_native_state import read_launch_state as _read_claude_launch_state from omnigent.codex_native_state import read_launch_state as _read_codex_launch_state from omnigent.harness_plugins import CODEX_NATIVE_CODING_AGENT from omnigent.native_coding_agents import native_coding_agent_for_wrapper_label # Page size for the paginated picker. # Small enough that a 24-line terminal shows the whole page; big enough # that a typical user finds their conversation in the first page. _PAGE_SIZE = 10 _CANCEL_TOKEN = "cancel" # Input tokens. Lowercased before comparison. Enter maps to # :data:`_SELECT_TOKEN` in both TTY and line-buffered fallback paths # so the highlighted row is the only resume action. _QUIT_TOKENS: frozenset[str] = frozenset({"q", "quit", _CANCEL_TOKEN}) _NEXT_TOKENS: frozenset[str] = frozenset({"n", "next", ">"}) _PREV_TOKENS: frozenset[str] = frozenset({"p", "prev", "previous", "<"}) _UP_TOKENS: frozenset[str] = frozenset({"up"}) _DOWN_TOKENS: frozenset[str] = frozenset({"down"}) _SELECT_TOKEN = "select" # Maximum characters of preview text shown per row. Wider would # crowd out titles on standard 80-col terminals; narrower # would truncate so aggressively the preview stops being useful. _PREVIEW_DISPLAY_CHARS = 60 # Cap on how many conversations we pre-fetch previews for. Most # users have far fewer than this; users with thousands fall back to # blank previews on rows past the cap (still selectable, just no # preview hint). Lazy per-page fetch would be # stricter but adds an async-from-sync bridge complication; pre- # fetching all is the simpler choice while limits stay sane. _PREVIEW_PREFETCH_CAP = 100 _METADATA_SEPARATOR = " · " # Role symbols shared with the rest of the REPL surface so the # preview row reads as the same visual language as the chat. # ``❯`` mirrors :meth:`omnigent_ui_sdk.RichBlockFormatter.\ # user_message`'s prefix; ``◆`` is the SDK formatter's default # assistant glyph. _USER_GLYPH = "❯" _ASSISTANT_GLYPH = "◆" @dataclass(frozen=True) class _Preview: """ The latest message preview from one conversation. :param role: ``"user"`` or ``"assistant"`` — drives the glyph the picker renders before the text. Other roles (``"system"``, ``"tool"``) shouldn't surface here in practice; treat them like the assistant for rendering. :param text: The latest message's plain-text content, already collapsed to a single line and truncated to :data:`_PREVIEW_DISPLAY_CHARS` with a trailing ``…`` when the original was longer. The picker does not re-truncate; this is the final display string. """ role: str text: str @dataclass(frozen=True) class _PageRowRender: """ Rendered line data for one resume-picker list item. :param lines: Rich-renderable lines printed for one conversation. """ lines: list[Any] class _ConversationRow(Protocol): """ Minimal shape the picker reads off each conversation row. Both the SDK's :class:`omnigent_client.types.Conversation` and the store's :class:`omnigent.entities.conversation.Conversation` satisfy this without inheriting from it — the Protocol exists purely to document what the picker depends on, so a future refactor that drops one of those fields breaks here loudly instead of confusing the renderer. :param id: Conversation identifier, e.g. ``"conv_abc123"``. :param title: Optional human-set title; ``None`` when the user hasn't named it yet. :param created_at: Creation time as seconds since epoch. Both row sources expose this as :class:`int`. :param labels: Session-scoped labels (read for the Runtime metadata badge — the ``omnigent.wrapper`` key identifies wrapper-style sessions like claude-native). Empty dict when the row has no labels; ``None`` is tolerated for callers (legacy fakes in tests) that do not surface labels on the row. :func:`_runtime_badge` treats both as "no wrapper" and falls through to ``[chat]``. """ id: str title: str | None created_at: int labels: dict[str, str] | None def _runtime_badge(row: _ConversationRow) -> str: """ Compute the runtime metadata label string for one picker row. The badge surfaces which Omnigent wrapper owns the session so a cross-agent picker can be skimmed at a glance. For now we distinguish terminal-native wrappers; other agents render as ``[chat]`` because the picker drives the chat REPL when the row is selected. Returned as a literal string with brackets. Callers that hand it to Rich for rendering must use a ``Text`` node rather than Rich markup, otherwise Rich treats ``[claude]`` / ``[chat]`` as style tags and renders nothing. :param row: One conversation row from the SDK / store list. :returns: Native runtime badge, or ``"[chat]"`` otherwise. Reads ``row.labels`` defensively so legacy fakes (test rows without a ``labels`` attribute) fall through to the default without raising. """ labels = getattr(row, "labels", None) if isinstance(labels, dict): native_agent = native_coding_agent_for_wrapper_label( labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY) ) if native_agent is not None: return f"[{native_agent.key}]" return "[chat]" def pick_conversation( conversations: list[_ConversationRow], *, agent_name: str, previews: dict[str, _Preview | None] | None = None, show_runtime: bool = False, show_workspace: bool = False, out: IO[str] | None = None, in_: IO[str] | None = None, ) -> str | None: """ Run the interactive picker over a pre-fetched conversation list. :param conversations: Conversations to choose from, already ordered (typically newest-first). Each item must expose ``id`` (str), ``title`` (str | None), and ``created_at`` (int seconds since epoch). Both SDK ``Conversation`` and the store's ``Conversation`` entity satisfy this. :param agent_name: Agent label shown in the header, e.g. ``"resume_test"``. Pure cosmetic; doesn't filter. :param previews: Optional ``{conversation_id: _Preview | None}`` map showing the latest message in each conversation — rendered as a preview line inside each list item. ``None`` for a conversation (or a missing key) means "no preview to show" and a muted placeholder is rendered for that row. The helpers :func:`pick_conversation_from_sdk` / :func:`pick_conversation_from_store` build the dict via :func:`_collect_previews_async` / :func:`_collect_previews_sync` before invoking this function; callers driving the pure picker can pass ``None`` and preview lines are omitted entirely. :param show_runtime: When ``True``, render runtime metadata showing ``[claude]`` for claude-native conversations and ``[chat]`` for everything else. Used by the cross-agent picker (``omnigent resume``) where a user is choosing across multiple wrappers and needs to see which runtime each row belongs to. Per-agent pickers leave this off because the runtime is implicit from the agent. :param show_workspace: When ``True``, render workspace metadata between the timestamp and conversation id showing the cwd each session was launched from (read per row from the wrapper's client-side persistent state, not from the conversation row). Rows with no recorded workspace omit that metadata segment. Rows whose recorded workspace differs from the current cwd are flagged with a ``↪ cd`` suffix so the user sees at a glance which picks will trigger the resume-time chdir prompt. Used by the claude-native wrapper picker because Claude Code's ``--resume`` requires the cwd to match the original; other pickers leave this off. :param out: Output stream for the rendered picker. Default ``sys.stderr``. Override in tests with a ``StringIO``. :param in_: Input stream for user keystrokes. Default ``sys.stdin``. Override in tests with a ``StringIO``. :returns: The selected ``conversation_id``, or ``None`` when the user cancels (q / Esc / EOF). """ out_stream: IO[str] = out if out is not None else sys.stderr in_stream: IO[str] = in_ if in_ is not None else sys.stdin if not conversations: _print_empty(agent_name, out_stream) return None if _is_tty(in_stream): return _pick_conversation_prompt_toolkit( conversations, agent_name=agent_name, previews=previews, show_runtime=show_runtime, show_workspace=show_workspace, out=out_stream, in_=in_stream, ) return _pick_conversation_line_buffered( conversations, agent_name=agent_name, previews=previews, show_runtime=show_runtime, show_workspace=show_workspace, out=out_stream, in_=in_stream, ) def _pick_conversation_line_buffered( conversations: list[_ConversationRow], *, agent_name: str, previews: dict[str, _Preview | None] | None, show_runtime: bool, show_workspace: bool, out: IO[str], in_: IO[str], ) -> str | None: """ Run the resume picker with line-buffered input. This path is for scripted / redirected stdin where prompt-toolkit cannot enter terminal mode. It keeps legacy numeric selection compatibility while sharing the same list rendering as the interactive path. :param conversations: Conversations to choose from. :param agent_name: Agent label shown in the header, e.g. ``"resume_test"``. :param previews: Optional preview map used for preview lines. :param show_runtime: Whether runtime metadata is present. :param show_workspace: Whether workspace metadata is present. :param out: Output stream for rendered pages. :param in_: Line-buffered input stream. :returns: Selected conversation id, or ``None`` on cancel / EOF. """ selected_index = 0 page_start = _page_start_for_selection(selected_index) while True: page = conversations[page_start : page_start + _PAGE_SIZE] _print_page( page, page_start, selected_index, len(conversations), agent_name, out, previews, show_runtime=show_runtime, show_workspace=show_workspace, ) choice = _read_line_choice(in_) if choice is None or choice in _QUIT_TOKENS: return None if choice == _SELECT_TOKEN: return str(conversations[selected_index].id) if choice in _UP_TOKENS: next_selected_index = max(0, selected_index - 1) next_page_start = _page_start_for_selection(next_selected_index) if next_selected_index == selected_index: continue selected_index = next_selected_index page_start = next_page_start continue if choice in _DOWN_TOKENS: next_selected_index = min(len(conversations) - 1, selected_index + 1) next_page_start = _page_start_for_selection(next_selected_index) if next_selected_index == selected_index: continue selected_index = next_selected_index page_start = next_page_start continue if choice in _NEXT_TOKENS: last_page_start = ((len(conversations) - 1) // _PAGE_SIZE) * _PAGE_SIZE page_start = min(page_start + _PAGE_SIZE, last_page_start) selected_index = page_start continue if choice in _PREV_TOKENS: page_start = max(0, page_start - _PAGE_SIZE) selected_index = page_start continue try: absolute_index = int(choice) - 1 except ValueError: _print_invalid(out) continue # Numeric selection is retained only for scripted / non-TTY # callers that still feed row numbers. The prompt-toolkit TTY # path does not use digit choices; interactive users get one # visible path: highlight a row and press Enter. page_end = page_start + len(page) if page_start <= absolute_index < page_end: return str(conversations[absolute_index].id) _print_invalid(out) @dataclass class _PromptToolkitPickerState: """ Mutable state owned by the prompt-toolkit resume picker. :param conversations: Full ordered conversation list. :param agent_name: Agent label shown in the header, e.g. ``"resume_test"``. :param previews: Optional preview map used for preview lines. :param show_runtime: Whether runtime metadata is present. :param show_workspace: Whether workspace metadata is present. :param selected_index: Zero-based selected conversation index. """ conversations: list[_ConversationRow] agent_name: str previews: dict[str, _Preview | None] | None show_runtime: bool show_workspace: bool selected_index: int = 0 @property def page_start(self) -> int: """ Current page offset for :attr:`selected_index`. :returns: Zero-based page start, always a multiple of :data:`_PAGE_SIZE`. """ return _page_start_for_selection(self.selected_index) @property def page(self) -> list[_ConversationRow]: """ Visible conversation slice for the current selection. :returns: Conversations on the current page. """ start = self.page_start return self.conversations[start : start + _PAGE_SIZE] def move_selection(self, delta: int) -> None: """ Move the highlighted row by *delta*. :param delta: Signed row delta, e.g. ``1`` for Down and ``-1`` for Up. :returns: None. """ last_index = len(self.conversations) - 1 self.selected_index = min(last_index, max(0, self.selected_index + delta)) def next_page(self) -> None: """ Move selection to the first row on the next page. :returns: None. """ last_page_start = ((len(self.conversations) - 1) // _PAGE_SIZE) * _PAGE_SIZE self.selected_index = min(self.page_start + _PAGE_SIZE, last_page_start) def previous_page(self) -> None: """ Move selection to the first row on the previous page. :returns: None. """ self.selected_index = max(0, self.page_start - _PAGE_SIZE) def selected_id(self) -> str: """ Return the currently highlighted conversation id. :returns: Conversation id, e.g. ``"conv_abc123"``. """ return str(self.conversations[self.selected_index].id) def _pick_conversation_prompt_toolkit( conversations: list[_ConversationRow], *, agent_name: str, previews: dict[str, _Preview | None] | None, show_runtime: bool, show_workspace: bool, out: IO[str], in_: IO[str], ) -> str | None: """ Run the interactive TTY picker using prompt-toolkit. prompt-toolkit owns key decoding and rendering diffing here; this module only supplies state and formatted fragments. That avoids hand-written terminal escape sequences while keeping arrow-key movement responsive. :param conversations: Conversations to choose from. :param agent_name: Agent label shown in the header, e.g. ``"resume_test"``. :param previews: Optional preview map used for preview lines. :param show_runtime: Whether runtime metadata is present. :param show_workspace: Whether workspace metadata is present. :param out: Output stream for the prompt-toolkit renderer. :param in_: Input stream for keypresses. :returns: Selected conversation id, or ``None`` when cancelled. :raises KeyboardInterrupt: Propagated when the user presses Ctrl+C. """ from prompt_toolkit.application import Application from prompt_toolkit.input.defaults import create_input from prompt_toolkit.layout import Layout, Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.output.defaults import create_output state = _PromptToolkitPickerState( conversations=conversations, agent_name=agent_name, previews=previews, show_runtime=show_runtime, show_workspace=show_workspace, ) control = FormattedTextControl(lambda: _prompt_toolkit_fragments(state), focusable=True) key_bindings = _prompt_toolkit_key_bindings(state) app: Application[str | None] = Application( layout=Layout(Window(content=control, wrap_lines=True, always_hide_cursor=True)), key_bindings=key_bindings, style=_prompt_toolkit_style(), include_default_pygments_style=False, full_screen=False, erase_when_done=False, input=create_input(stdin=in_), output=create_output(stdout=out), ) return app.run( handle_sigint=False, set_exception_handler=False, in_thread=_has_running_event_loop(), ) def _has_running_event_loop() -> bool: """ Return whether the current thread is already running asyncio. prompt-toolkit's synchronous :meth:`Application.run` calls :func:`asyncio.run` by default, which raises inside the async SDK resume path after session rows have been fetched. In that case we ask prompt-toolkit to host its application loop in a worker thread while this synchronous picker waits for the selected result. :returns: ``True`` when :func:`asyncio.get_running_loop` finds an active loop in this thread, otherwise ``False``. """ try: asyncio.get_running_loop() except RuntimeError: return False return True def _prompt_toolkit_key_bindings(state: _PromptToolkitPickerState) -> Any: """ Build keybindings for the prompt-toolkit picker. :param state: Mutable picker state. :returns: A :class:`prompt_toolkit.key_binding.KeyBindings` instance. """ from prompt_toolkit.key_binding import KeyBindings key_bindings = KeyBindings() @key_bindings.add("up") def _move_up(event: Any) -> None: """ Move the picker selection one row upward. :param event: prompt-toolkit key event. :returns: None. """ state.move_selection(-1) event.app.invalidate() @key_bindings.add("down") def _move_down(event: Any) -> None: """ Move the picker selection one row downward. :param event: prompt-toolkit key event. :returns: None. """ state.move_selection(1) event.app.invalidate() @key_bindings.add("n") @key_bindings.add("right") def _next_page(event: Any) -> None: """ Move the picker selection to the next page. :param event: prompt-toolkit key event. :returns: None. """ state.next_page() event.app.invalidate() @key_bindings.add("p") @key_bindings.add("left") def _previous_page(event: Any) -> None: """ Move the picker selection to the previous page. :param event: prompt-toolkit key event. :returns: None. """ state.previous_page() event.app.invalidate() @key_bindings.add("enter") def _select(event: Any) -> None: """ Resume the currently highlighted conversation. :param event: prompt-toolkit key event. :returns: None. """ event.app.exit(result=state.selected_id()) @key_bindings.add("q") @key_bindings.add("escape") @key_bindings.add("c-d") def _cancel(event: Any) -> None: """ Cancel the picker without selecting a conversation. :param event: prompt-toolkit key event. :returns: None. """ event.app.exit(result=None) @key_bindings.add("c-c") def _interrupt(event: Any) -> None: """ Propagate Ctrl+C as :class:`KeyboardInterrupt`. :param event: prompt-toolkit key event. :returns: None. """ event.app.exit(exception=KeyboardInterrupt) return key_bindings def _prompt_toolkit_style() -> Any: """ Build prompt-toolkit style classes for the picker. :returns: A :class:`prompt_toolkit.styles.Style` instance. """ from prompt_toolkit.styles import Style return Style.from_dict( { "accent": _ACCENT, "accent-bold": f"{_ACCENT} bold", "muted": _MUTED, "title": "bold", "selected-title": f"{_ACCENT} bold", "untitled": _MUTED, } ) def _prompt_toolkit_fragments(state: _PromptToolkitPickerState) -> list[tuple[str, str]]: """ Render picker state as prompt-toolkit formatted text fragments. :param state: Mutable picker state. :returns: ``(style, text)`` fragments for :class:`prompt_toolkit.layout.controls.FormattedTextControl`. """ fragments: list[tuple[str, str]] = [] _append_prompt_toolkit_header(fragments, state) current_cwd = Path.cwd().resolve() if state.show_workspace else None page_start = state.page_start page = state.page for offset, conv in enumerate(page): _append_prompt_toolkit_item( fragments, conv, absolute_index=page_start + offset, selected_index=state.selected_index, previews=state.previews, show_runtime=state.show_runtime, show_workspace=state.show_workspace, current_cwd=current_cwd, is_last=offset == len(page) - 1, ) _append_prompt_toolkit_footer(fragments) return fragments def _append_prompt_toolkit_header( fragments: list[tuple[str, str]], state: _PromptToolkitPickerState, ) -> None: """ Append the picker header to prompt-toolkit fragments. :param fragments: Fragment list being built. :param state: Mutable picker state. :returns: None. """ page_start = state.page_start page_len = len(state.page) total = len(state.conversations) page_no = page_start // _PAGE_SIZE + 1 page_count = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE) last_index = page_start + page_len fragments.extend( [ ("class:accent", "Resume"), ("class:muted", " · "), ("class:title", state.agent_name), ("class:muted", f" · page {page_no}/{page_count}, "), ("class:muted", f"showing {page_start + 1}-{last_index} of {total}\n"), ] ) def _append_prompt_toolkit_item( fragments: list[tuple[str, str]], conv: _ConversationRow, *, absolute_index: int, selected_index: int, previews: dict[str, _Preview | None] | None, show_runtime: bool, show_workspace: bool, current_cwd: Path | None, is_last: bool, ) -> None: """ Append one list item to prompt-toolkit fragments. :param fragments: Fragment list being built. :param conv: Conversation row being rendered. :param absolute_index: Zero-based index in the full conversation list. :param selected_index: Zero-based selected conversation index. :param previews: Optional preview map used for preview lines. :param show_runtime: Whether runtime metadata is present. :param show_workspace: Whether workspace metadata is present. :param current_cwd: Resolved current cwd when ``show_workspace=True``; otherwise ``None``. :param is_last: Whether this is the last visible item. :returns: None. """ selected = absolute_index == selected_index if selected: title_style = "class:selected-title" if conv.title else "class:untitled" fragments.extend( [ ("class:accent-bold", "> "), ("class:accent-bold", f"{absolute_index + 1}. "), (title_style, conv.title or "(untitled)"), ("", "\n"), ] ) else: title_style = "" if conv.title else "class:untitled" fragments.extend( [ ("", " "), ("class:muted", f"{absolute_index + 1}. "), (title_style, conv.title or "(untitled)"), ("", "\n"), ] ) _append_prompt_toolkit_metadata( fragments, conv, show_runtime=show_runtime, show_workspace=show_workspace, current_cwd=current_cwd, ) if previews is not None: _append_prompt_toolkit_preview(fragments, previews.get(conv.id)) if not is_last: fragments.append(("", "\n")) def _append_prompt_toolkit_metadata( fragments: list[tuple[str, str]], conv: _ConversationRow, *, show_runtime: bool, show_workspace: bool, current_cwd: Path | None, ) -> None: """ Append one prompt-toolkit metadata line. :param fragments: Fragment list being built. :param conv: Conversation row being rendered. :param show_runtime: Whether runtime metadata is present. :param show_workspace: Whether workspace metadata is present. :param current_cwd: Resolved current cwd when ``show_workspace=True``; otherwise ``None``. :returns: None. """ fragments.append(("class:muted", f" {_format_when(conv.created_at)}")) if show_workspace: assert current_cwd is not None workspace = _workspace_metadata(conv, current_cwd=current_cwd) if workspace is not None: working_directory, requires_cd = workspace fragments.extend( [ ("class:muted", _METADATA_SEPARATOR), ("class:muted", working_directory), ] ) if requires_cd: fragments.append(("class:accent", " ↪\xa0cd")) fragments.extend( [ ("class:muted", _METADATA_SEPARATOR), ("class:muted", str(conv.id)), ] ) if show_runtime: fragments.extend( [ ("class:muted", _METADATA_SEPARATOR), ("class:muted", _runtime_badge(conv)), ] ) fragments.append(("class:muted", "\n")) def _append_prompt_toolkit_preview( fragments: list[tuple[str, str]], preview: _Preview | None, ) -> None: """ Append one prompt-toolkit preview line. :param fragments: Fragment list being built. :param preview: Latest-message preview, or ``None``. :returns: None. """ if preview is None: fragments.append(("class:muted", " …\n")) return glyph = _USER_GLYPH if preview.role == "user" else _ASSISTANT_GLYPH fragments.extend( [ ("", " "), ("class:accent", glyph), ("class:muted", f" {preview.text}\n"), ] ) def _append_prompt_toolkit_footer(fragments: list[tuple[str, str]]) -> None: """ Append the prompt-toolkit picker footer. :param fragments: Fragment list being built. :returns: None. """ fragments.extend( [ ("class:muted", " Keys: "), ("class:accent-bold", "↑"), ("class:muted", "/"), ("class:accent-bold", "↓"), ("class:muted", " move · "), ("class:accent-bold", "Enter"), ("class:muted", " resume · "), ("class:accent-bold", "n"), ("class:muted", "/"), ("class:accent-bold", "p"), ("class:muted", " page · "), ("class:accent-bold", "q"), ("class:muted", "/"), ("class:accent-bold", "Esc"), ("class:muted", " cancel\n"), ] ) def _page_start_for_selection(selected_index: int) -> int: """ Return the page offset that contains *selected_index*. The picker stores selection as an absolute index so moving across a page boundary is just ``selected_index += 1``. This helper converts that absolute selection into the current page's first row before each render. :param selected_index: Zero-based index into the full conversation list, e.g. ``10`` for the first row on page 2. :returns: Zero-based page offset, always a multiple of :data:`_PAGE_SIZE`. """ return (selected_index // _PAGE_SIZE) * _PAGE_SIZE async def pick_conversation_from_sdk( # ``Any`` to avoid coupling the repl package to the client's load order. client: Any, *, agent_name: str, agent_id: str | None = None, agent_name_filter: str | None = None, out: IO[str] | None = None, in_: IO[str] | None = None, ) -> str | None: """Fetch sessions via ``/v1/sessions`` and run the picker. Sessions API filters by direct ``conversation.agent_id`` (not via ``task.agent_id``) so wrapper sessions without task rows still appear; also enforces ``has_agent_id`` and ``accessible_by`` server-side. :param agent_id: Scope to this agent; ``None`` lists across agents. :param agent_name_filter: Scope to sessions whose bound agent row has this name. Used for session-scoped agents that share a YAML name but intentionally do not share ``agent_id``. """ convos = await client.sessions.list( limit=200, agent_id=agent_id, agent_name=agent_name_filter, order="desc", ) previews = await _collect_previews_async(client, convos) return pick_conversation(convos, agent_name=agent_name, previews=previews, out=out, in_=in_) async def pick_conversation_by_wrapper_label_from_sdk( client: Any, *, wrapper_value: str, agent_name: str, out: IO[str] | None = None, in_: IO[str] | None = None, ) -> str | None: """Picker scoped to one wrapper kind (``omnigent.wrapper=``). Wrapper invocations (claude-native today) upload a fresh agent bundle per session, so ``agents.get_by_name`` returns no canonical record — agent-id filtering can't be used. List every session the caller can see and filter by the wrapper label client-side. Renders workspace metadata so the user can see which cwd each session was launched from -- claude --resume requires cwd parity with the original session, and the row-level hint prepares the user for the chdir prompt the wrapper raises after they pick. The cwd comes from the wrapper's client-side persistent state (``~/.omnigent/claude-native/``); sessions created on a different machine will show as having no recorded cwd.""" all_convos = await client.sessions.list(limit=200, agent_id=None, order="desc") convos = [ c for c in all_convos if getattr(c, "labels", None) and c.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY) == wrapper_value ] previews = await _collect_previews_async(client, convos) return pick_conversation( convos, agent_name=agent_name, previews=previews, show_workspace=True, out=out, in_=in_, ) async def pick_conversation_cross_agent_from_sdk( client: Any, *, out: IO[str] | None = None, in_: IO[str] | None = None, ) -> str | None: """Cross-agent variant: lists every session the caller can see via ``/v1/sessions`` and renders runtime metadata for ``omnigent resume``'s runtime-dispatch UX.""" convos = await client.sessions.list(limit=200, agent_id=None, order="desc") previews = await _collect_previews_async(client, convos) # Header label is intentionally generic — "resume" describes the # action, not a single agent. Without overriding the legacy # ``agent_name`` argument the header would print as "None" or an # empty string. return pick_conversation( convos, agent_name="all runtimes", previews=previews, show_runtime=True, out=out, in_=in_, ) def pick_conversation_from_store( # ``Any`` rather than the concrete store types so the picker # stays decoupled from the SqlAlchemy implementations — only # the ``ConversationStore`` abstract methods used here # (``list_conversations``) actually matter, and importing the # abstract base here would still add a non-trivial dependency # surface for a typing-only win. conv_store: Any, *, agent_name: str, out: IO[str] | None = None, in_: IO[str] | None = None, ) -> str | None: """ Sync sibling for the one-shot ``-p`` path. Lists conversations directly from the store, scoped by the bound agent row's name. The one-shot path doesn't have an SDK client connected (the in-process ASGI app is reached via raw httpx + ASGITransport), so we read through the stores directly. Name scoping is important for session-scoped agents: every multipart upload gets a distinct ``agent_id`` while preserving the user-authored YAML name for resume lookup. :param conv_store: Conversation store for the list query. :param agent_name: Agent's registered name from the YAML. :param out: Output stream override (tests). :param in_: Input stream override (tests). :returns: Selected conversation_id, or ``None`` on cancel / empty list / unknown agent. """ out_stream: IO[str] = out if out is not None else sys.stderr page = conv_store.list_conversations( agent_name=agent_name, has_agent_id=True, limit=200, sort_by="updated_at", order="desc", ) previews = _collect_previews_sync(conv_store, page.data) return pick_conversation( page.data, agent_name=agent_name, previews=previews, out=out_stream, in_=in_, ) def _print_page( page: list[_ConversationRow], page_start: int, selected_index: int, total: int, agent_name: str, out: IO[str], previews: dict[str, _Preview | None] | None = None, *, show_runtime: bool = False, show_workspace: bool = False, ) -> None: """ Render one page of the picker as a keyboard-selectable list. :param page: Slice of conversations to display. :param page_start: Zero-based offset of the first conversation on this page (used to compute ``"showing X-Y of Z"``). :param selected_index: Zero-based index into the full conversation list. The corresponding row is highlighted and marked with ``">"``. :param total: Total number of conversations across all pages. :param agent_name: Agent label for the header, e.g. ``"resume_test"``. :param out: Output stream. ``sys.stderr`` in production, ``StringIO`` in tests. :param previews: Optional preview map. When present, the page renders one preview under each list item. :param show_runtime: When ``True``, include runtime metadata. :param show_workspace: When ``True``, include launch-workspace metadata and compare recorded cwd against current cwd. :returns: None. """ console = _make_console(out) console.print(_page_header_text(page_start, len(page), total, agent_name)) current_cwd = Path.cwd().resolve() if show_workspace else None for offset, conv in enumerate(page): rendered = _list_item_lines( conv, absolute_index=page_start + offset, selected_index=selected_index, previews=previews, show_runtime=show_runtime, show_workspace=show_workspace, current_cwd=current_cwd, ) _print_list_item(console, rendered, is_last=offset == len(page) - 1) _print_page_footer(console) def _page_header_text( page_start: int, page_len: int, total: int, agent_name: str, ) -> Any: """ Build the list header for one resume-picker page. :param page_start: Zero-based offset of the first row on the page, e.g. ``10`` for page 2. :param page_len: Number of rows on the page. :param total: Total number of conversations across all pages. :param agent_name: Agent label shown in the header, e.g. ``"resume_test"``. :returns: A :class:`rich.text.Text` header. """ from rich.text import Text page_no = page_start // _PAGE_SIZE + 1 page_count = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE) last_index = page_start + page_len return Text.from_markup( f"[{_ACCENT}]Resume[/] [{_MUTED}]·[/] [bold]{_escape_for_markup(agent_name)}[/]" f" [{_MUTED}]· page {page_no}/{page_count}, " f"showing {page_start + 1}-{last_index} of {total}[/]" ) def _list_item_lines( conv: _ConversationRow, *, absolute_index: int, selected_index: int, previews: dict[str, _Preview | None] | None, show_runtime: bool, show_workspace: bool, current_cwd: Path | None, ) -> _PageRowRender: """ Build the rendered lines for one resume-picker list item. :param conv: Conversation row being rendered. :param absolute_index: Zero-based index in the full conversation list. :param selected_index: Zero-based selected conversation index. :param previews: Optional preview map used for preview lines. :param show_runtime: Whether runtime metadata is present. :param show_workspace: Whether workspace metadata is present. :param current_cwd: Resolved current cwd when ``show_workspace=True``; otherwise ``None``. :returns: Rendered item lines and selected state. """ selected = absolute_index == selected_index lines = [ _list_item_title(conv, absolute_index=absolute_index, selected=selected), _list_item_metadata( conv, show_runtime=show_runtime, show_workspace=show_workspace, current_cwd=current_cwd, ), ] if previews is not None: lines.append(_list_item_preview(previews.get(conv.id))) return _PageRowRender(lines=lines) def _list_item_title(conv: _ConversationRow, *, absolute_index: int, selected: bool) -> Any: """ Build the primary title line for a resume-picker list item. :param conv: Conversation row being rendered. :param absolute_index: Zero-based index in the full conversation list. :param selected: Whether the row is the highlighted selection. :returns: A :class:`rich.text.Text` title line. """ from rich.text import Text text = Text() if selected: text.append("> ", style=f"bold {_ACCENT}") text.append(f"{absolute_index + 1}. ", style=f"bold {_ACCENT}") title_style = f"bold {_ACCENT}" if conv.title else f"bold {_MUTED}" text.append(conv.title or "(untitled)", style=title_style) return text text.append(" ") text.append(f"{absolute_index + 1}. ", style=_MUTED) text.append(conv.title or "(untitled)", style=None if conv.title else _MUTED) return text def _list_item_metadata( conv: _ConversationRow, *, show_runtime: bool, show_workspace: bool, current_cwd: Path | None, ) -> Any: """ Build the metadata line for a resume-picker list item. :param conv: Conversation row being rendered. :param show_runtime: Whether to include runtime metadata. :param show_workspace: Whether to include recorded workspace metadata when available. :param current_cwd: Resolved current cwd when ``show_workspace=True``; otherwise ``None``. :returns: A :class:`rich.text.Text` metadata line. """ from rich.text import Text text = Text(" ", style=_MUTED) text.append(_format_when(conv.created_at), style=_MUTED) if show_workspace: assert current_cwd is not None workspace = _render_workspace_cell(conv, current_cwd=current_cwd) if workspace is not None: text.append(_METADATA_SEPARATOR, style=_MUTED) text.append_text(workspace) text.append(_METADATA_SEPARATOR, style=_MUTED) text.append(str(conv.id), style=_MUTED) if show_runtime: text.append(_METADATA_SEPARATOR, style=_MUTED) text.append(_runtime_badge(conv), style=_MUTED) return text def _list_item_preview(preview: _Preview | None) -> Any: """ Build the preview line for a resume-picker list item. :param preview: Latest-message preview, or ``None``. :returns: A :class:`rich.text.Text` preview line. """ from rich.text import Text text = _render_preview_cell(preview) return Text(" ") + text def _print_list_item(console: Any, rendered: _PageRowRender, *, is_last: bool) -> None: """ Print one rendered resume-picker list item. :param console: Rich console returned by :func:`_make_console`. :param rendered: Rendered item lines from :func:`_list_item_lines`. :param is_last: Whether this is the last item on the page. """ for line in rendered.lines: console.print(line) if not is_last: console.print() def _print_page_footer(console: Any) -> None: """ Print the resume-picker keybinding footer and trailing spacer. :param console: Rich console returned by :func:`_make_console`. """ from rich.text import Text console.print( Text.from_markup( f" [{_MUTED}]Keys:[/] " f"[bold {_ACCENT}]↑[/]/[bold {_ACCENT}]↓[/] move " f"[{_MUTED}]·[/] " f"[bold {_ACCENT}]Enter[/] resume " f"[{_MUTED}]·[/] " f"[bold {_ACCENT}]n[/]/[bold {_ACCENT}]p[/] page " f"[{_MUTED}]·[/] " f"[bold {_ACCENT}]q[/]/[bold {_ACCENT}]Esc[/] cancel" ) ) console.print() def _print_empty(agent_name: str, out: IO[str]) -> None: """ Render the "no prior conversations" notice. Replaces the bare ``print(...)`` call so the empty-list case matches the page render's visual language. Tests assert on the substring ``"No prior conversations"`` — preserve it exactly. :param agent_name: Agent label for the message, e.g. ``"resume_test"``. :param out: Output stream override. """ from rich.text import Text console = _make_console(out) console.print( Text.from_markup( f" [{_MUTED}]No prior conversations for agent[/] [bold]{agent_name}[/][{_MUTED}].[/]" ) ) def _print_invalid(out: IO[str]) -> None: """ Render the "invalid selection" notice between picker re-prompts. Tests assert on the substring ``"Invalid selection."`` — preserve it exactly. :param out: Output stream override. """ from rich.text import Text console = _make_console(out) console.print(Text.from_markup(f" [{_ACCENT}]Invalid selection.[/] [{_MUTED}]Try again.[/]")) def _make_console(out: IO[str]) -> Any: """ Build a :class:`rich.console.Console` aimed at *out*. Centralized so the page render, empty-list message, and invalid-selection notice all create their consoles the same way. Rich's defaults handle the tty-vs-StringIO branching: real stderr gets ANSI styling, in-memory streams get plain text. ``soft_wrap=True`` prevents Rich from injecting hard newlines into redirected transcripts; terminals still wrap long lines visually. :param out: Output stream override. :returns: A :class:`rich.console.Console` writing to *out*. """ from rich.console import Console return Console(file=out, highlight=False, soft_wrap=True) def _render_preview_cell(preview: _Preview | None) -> Any: """ Render a :class:`_Preview` as a list-item preview line. Renders as ``" "`` where the glyph reflects the role: ``❯`` for user, ``◆`` for assistant. Glyphs are styled in accent color; the text body is muted so the list item's title stays the eye's first stop. ``None`` (no preview available — fetch failed, conversation is empty, or it fell past the prefetch cap) renders as a single muted ``…`` so the line doesn't collapse to blank-and-look-broken. :param preview: The preview to render, or ``None``. :returns: A :class:`rich.text.Text` ready to print in a list item. """ from rich.text import Text if preview is None: return Text("…", style=_MUTED) glyph = _USER_GLYPH if preview.role == "user" else _ASSISTANT_GLYPH return Text.from_markup( f"[{_ACCENT}]{glyph}[/] [{_MUTED}]{_escape_for_markup(preview.text)}[/]" ) def _render_workspace_cell(row: _ConversationRow, *, current_cwd: Path) -> Any | None: """ Render the workspace metadata value for one picker row. Looks up the wrapper's client-side persistent launch state for *row.id* via the matching native state module. When state is present, renders the recorded path in muted gray and appends a ``↪ cd`` flag in accent color if it differs from *current_cwd* (resolved). When state is absent -- legacy session pre-dating this tracking, session created on a different machine, or a non-wrapper session that never set it -- returns ``None`` so the picker omits the workspace metadata segment. The flag uses ``↪`` ("return / leftwards arrow with hook") + a no-break space + ``cd`` so the badge stays on one visual unit when the line wraps. Picked deliberately over ``[cd]`` so Rich's markup parser doesn't try to interpret it as a style tag (we'd otherwise need :func:`_escape_for_markup`). Reads happen once per row at render time. With the default :data:`_PREVIEW_PREFETCH_CAP` of 100 conversations the picker issues at most 100 small JSON reads from ``~/.omnigent/claude-native//launch.json`` -- fast on local disk. We deliberately do not async / parallelize these because the picker is single-threaded and a stat+read of a sub-200-byte file is microseconds on any reasonable storage. :param row: One conversation row from the SDK / store list. :param current_cwd: The wrapper's current working directory, already resolved, captured once per page render. :returns: A :class:`rich.text.Text` ready to print in a list item, or ``None`` when no workspace was recorded. """ from rich.text import Text workspace = _workspace_metadata(row, current_cwd=current_cwd) if workspace is None: return None working_directory, requires_cd = workspace if not requires_cd: return Text(working_directory, style=_MUTED) return Text.from_markup( f"[{_MUTED}]{_escape_for_markup(working_directory)}[/] [{_ACCENT}]↪\xa0cd[/]" ) def _workspace_metadata(row: _ConversationRow, *, current_cwd: Path) -> tuple[str, bool] | None: """ Read the recorded workspace for one conversation. :param row: One conversation row from the SDK / store list. :param current_cwd: The wrapper's current working directory, already resolved. :returns: ``(working_directory, requires_cd)`` when recorded, or ``None`` when no workspace state exists. """ state = _launch_state_for_row(row) if state is None: return None working_directory = state.working_directory # Resolve so a recorded ``/repo`` and a current ``/home/me/repo`` # (symlink) don't falsely flag as different. ``Path.resolve()`` # doesn't raise on non-existent paths, so this is safe even when # the recorded directory has since been deleted -- the # downstream chdir-prompt path handles that case loudly. recorded = Path(working_directory).resolve() return working_directory, recorded != current_cwd def _launch_state_for_row(row: _ConversationRow) -> Any | None: """ Read native launch state using the row's wrapper label. :param row: One conversation row from the SDK / store list. :returns: Native launch state object with ``working_directory``, or ``None`` when the row has no supported wrapper state. """ labels = getattr(row, "labels", None) if not isinstance(labels, dict): return None wrapper = labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY) if wrapper == _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE: return _read_claude_launch_state(row.id) if wrapper == CODEX_NATIVE_CODING_AGENT.wrapper_label: return _read_codex_launch_state(row.id) return None def _escape_for_markup(text: str) -> str: """ Escape ``[`` so :func:`rich.text.Text.from_markup` doesn't interpret user-text bracket sequences as style tags. Without this, a preview text containing ``"[bold]"`` would re-enter Rich's parser and either throw or apply unintended styling. ``\\[`` is Rich's escape for ``[``; the rest of the text passes through untouched. :param text: User-visible text from the preview. :returns: The text with ``[`` characters escaped. """ return text.replace("[", r"\[") async def _collect_previews_async( client: Any, conversations: list[_ConversationRow], ) -> dict[str, _Preview | None]: """ Fetch the latest message for each conversation in parallel. Concurrent ``list_items(limit=10, order="desc")`` calls fanned out via :func:`asyncio.gather` so the picker's initial render isn't gated on N round-trips serially. The fetch is bounded by :data:`_PREVIEW_PREFETCH_CAP` because a user with thousands of conversations doesn't want the picker to stall while it warms up — the cap covers typical use while keeping the worst case bounded. Per-conversation failures (HTTP errors, missing items) surface as ``None`` in the returned dict instead of bubbling up; a preview is a UX nicety, not a correctness requirement, and one bad row shouldn't kill the picker. :param client: The :class:`omnigent_client.OmnigentClient`. :param conversations: Full conversation list (will be capped internally by :data:`_PREVIEW_PREFETCH_CAP`). :returns: A ``{conversation_id: _Preview | None}`` map. Keys beyond the cap simply don't appear; the picker treats a missing key the same as ``None``. """ async def fetch_one(conv: _ConversationRow) -> tuple[str, _Preview | None]: try: items = await client.sessions.list_items(conv.id, limit=10, order="desc") except Exception: # noqa: BLE001 — preview is best-effort, swallow per-conv errors return conv.id, None return conv.id, _last_message_preview_from_dicts(items) capped = conversations[:_PREVIEW_PREFETCH_CAP] pairs = await asyncio.gather(*(fetch_one(c) for c in capped)) return dict(pairs) def _collect_previews_sync( conv_store: Any, conversations: list[_ConversationRow], ) -> dict[str, _Preview | None]: """ Sync sibling for the one-shot ``-p`` path. Sequential rather than parallel — same ``conv_store`` instance can't safely be hit from multiple threads here without a deeper guarantee about the SqlAlchemy session policy, and the in-process store is fast enough that a serial loop over :data:`_PREVIEW_PREFETCH_CAP` rows finishes well under a second on local SQLite. If users ever hit the cap and notice latency, we move to a thread-pool fan-out — but only with measurements showing it's needed. :param conv_store: The conversation store. :param conversations: Full conversation list (will be capped internally by :data:`_PREVIEW_PREFETCH_CAP`). :returns: A ``{conversation_id: _Preview | None}`` map; same contract as :func:`_collect_previews_async`. """ out: dict[str, _Preview | None] = {} for conv in conversations[:_PREVIEW_PREFETCH_CAP]: try: page = conv_store.list_items(conversation_id=conv.id, limit=10, order="desc") except Exception: # noqa: BLE001 — preview is best-effort, swallow per-conv errors out[conv.id] = None continue out[conv.id] = _last_message_preview_from_entities(page.data) return out def _last_message_preview_from_dicts( items: list[dict[str, Any]], ) -> _Preview | None: """ Extract the latest message preview from API-shape item dicts. The SDK's ``client.sessions.list_items`` returns dicts in the API-flattened shape (``{"type": "message", "role": "...", "content": [...]}``). Walks the list newest-to-oldest (the caller fetched with ``order="desc"``) looking for the first non-meta ``message`` item with extractable text. :param items: Items from the SDK call (already in ``order="desc"`` so ``items[0]`` is the most recent). :returns: A :class:`_Preview`, or ``None`` when no message item with text is found in the slice (e.g. the conversation has only tool calls, or is empty). """ for item in items: if not isinstance(item, dict): continue if item.get("type") != "message": continue if item.get("is_meta") is True: continue role = item.get("role") if not isinstance(role, str) or not role: continue text = _extract_text_from_content_blocks(item.get("content")) if text: return _Preview(role=role, text=text) return None def _last_message_preview_from_entities(items: list[Any]) -> _Preview | None: """ Extract the latest message preview from store entity items. The store's ``list_items`` returns :class:`omnigent.entities.ConversationItem` objects whose ``data`` carries the typed ``MessageData`` for message rows. Same walk as :func:`_last_message_preview_from_dicts` but on the entity attribute path. :param items: Items from the store call (already in ``order="desc"``). :returns: A :class:`_Preview`, or ``None`` (same contract). """ for item in items: if getattr(item, "type", None) != "message": continue data = getattr(item, "data", None) if getattr(data, "is_meta", False): continue role = getattr(data, "role", None) content = getattr(data, "content", None) if not isinstance(role, str) or not role: continue text = _extract_text_from_content_blocks(content) if text: return _Preview(role=role, text=text) return None def _extract_text_from_content_blocks(content: Any) -> str: """ Reduce a Responses-API message ``content`` to one preview line. Pulls the ``text`` field off ``input_text`` / ``output_text`` blocks (and any other block carrying a string ``text``), collapses whitespace to single spaces, and truncates to :data:`_PREVIEW_DISPLAY_CHARS` with a trailing ``…`` when the original was longer. Image / file blocks are dropped — the preview line is text-only. :param content: A list of typed content blocks, a string, or ``None``. Anything else returns ``""``. :returns: The collapsed + truncated preview text. Empty string means "no extractable text" (caller treats as no preview for this item). """ if content is None: return "" if isinstance(content, str): text = content elif isinstance(content, list): parts: list[str] = [] for block in content: if isinstance(block, dict): value = block.get("text") else: value = getattr(block, "text", None) if isinstance(value, str): parts.append(value) text = " ".join(parts) else: return "" # Collapse whitespace so a multi-line message renders as a # single tidy preview line — newlines and tabs become single # spaces, runs of spaces become one space. compact = " ".join(text.split()) if not compact: return "" if len(compact) <= _PREVIEW_DISPLAY_CHARS: return compact return compact[: _PREVIEW_DISPLAY_CHARS - 1] + "…" def _format_when(created_at: int) -> str: """ Format *created_at* as a compact human-readable label. Picker rows benefit from the relative form ("5m ago", "3h ago", "2d ago") for recent items because that's how users actually think about which conversation they want; older items fall back to ``"Mon DD HH:MM"`` (local time) which is unambiguous without the visual noise of a full ISO timestamp. The legacy picker showed UTC ``"YYYY-MM-DD HH:MM"`` for everything, which is technically more precise but harder to scan. :param created_at: Unix epoch seconds (server-side ``Conversation.created_at``). :returns: Compact label, e.g. ``"5m ago"``, ``"3h ago"``, ``"2d ago"``, or ``"Apr 27 14:30"``. """ now = int(time.time()) delta = now - created_at if delta < 60: return "just now" if delta < 3600: return f"{delta // 60}m ago" if delta < 86400: return f"{delta // 3600}h ago" if delta < 7 * 86400: return f"{delta // 86400}d ago" return datetime.fromtimestamp(created_at).strftime("%b %d %H:%M") def _read_line_choice(in_: IO[str]) -> str | None: """ Read one line-buffered picker choice. Blank lines map to Enter/select so scripted fallback input follows the same action semantics as the prompt-toolkit TTY path. :param in_: Input stream. :returns: Lowercased + stripped input, :data:`_SELECT_TOKEN` for a blank line, or ``None`` on EOF. """ line = in_.readline() if not line: return None choice = line.strip().lower() return _SELECT_TOKEN if choice == "" else choice def _is_tty(in_: IO[str]) -> bool: """ Predicate: is *in_* an interactive terminal? The prompt-toolkit path needs both ``isatty()`` truth and a real file descriptor (``fileno()``). ``StringIO`` has neither; a pipe has ``fileno()`` but not ``isatty()``. :param in_: Input stream. :returns: ``True`` only when both checks pass. """ isatty = getattr(in_, "isatty", None) fileno = getattr(in_, "fileno", None) if isatty is None or fileno is None: return False try: return bool(isatty()) and isinstance(fileno(), int) except (OSError, ValueError): return False