Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/command_system/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .memory_command import MEMORY_COMMAND
from .stickers_command import STICKERS_COMMAND
from .rename_command import RENAME_COMMAND
from .resume_command import RESUME_COMMAND
from .types import Command, CommandType, CompactionResult, LocalCommand, PromptCommand


Expand Down Expand Up @@ -1260,6 +1261,7 @@ def get_builtin_commands() -> list[Command]:
MEMORY_COMMAND,
STICKERS_COMMAND,
RENAME_COMMAND,
RESUME_COMMAND,
]
if is_buddy_command_enabled():
cmds.append(BUDDY_COMMAND)
Expand Down
101 changes: 101 additions & 0 deletions src/command_system/resume_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""resume — ``/resume`` session picker (port of TS local-jsx, components C2).

TS ``/resume`` (``commands/resume/index.ts``: description "Resume a previous
conversation", argumentHint "[conversation id or search term]") mounts the
``LogSelector`` picker and swaps the live session. Python's interactive swap
lives in the TUI (``tui/commands.py`` → ``open_dialog="resume"`` →
``ResumeConversation`` → ``AgentBridge.resume_session``), because only the
TUI owns a live conversation it can replace.

This registry command serves the NON-TUI surfaces (REPL/SDK/help/aggregator)
in the **output-style precedent**: ``run()`` returns text without touching
``ctx.ui`` — a degraded-but-honest LIST of resumable sessions plus the
pointer to the TUI for the actual swap. Filtering matches the TUI picker:
metadata-only sessions (``message_count == 0``) are hidden and counted
(gap-doc §5 Q2 decision — headless ``/rename`` can mint such entries).

Coexistence: **inversion** (the ``/theme`` pattern) — the TUI intercept
stays authoritative; this command never runs there.
"""

from __future__ import annotations

from dataclasses import dataclass

from .types import (
CommandContext,
InteractiveCommand,
InteractiveOutcome,
)


def _list_resumable(term: str = "") -> tuple[list[str], int]:
"""``(lines, hidden_count)`` for the degraded session list.

UI-neutral by construction: imports only ``services`` modules (no
Textual — the dependency-direction rule from the C1/C2 reviews).
"""

from src.bootstrap.state import get_session_id
from src.services.session_listing import build_resume_entries, filter_entries

try:
from src.services.session_storage import SessionStorage

metas = SessionStorage.list_sessions()
except Exception:
metas = []
entries, hidden = build_resume_entries(
metas, exclude_session_id=str(get_session_id())
)
if term:
entries = filter_entries(entries, term)
return [f"• {entry.label()} [{entry.session_id}]" for entry in entries], hidden


@dataclass(frozen=True)
class ResumeCommand(InteractiveCommand):
"""List resumable sessions; the interactive swap is TUI-only."""

async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
term = (args or "").strip()
lines, hidden = _list_resumable(term)
if not lines:
message = (
f"No resumable conversations match {term!r}."
if term
else "No resumable conversations yet."
)
if hidden:
message += (
f" ({hidden} metadata-only session(s) hidden — "
"no stored messages.)"
)
return InteractiveOutcome(message=message, display="system")
header = (
f"Resumable conversations matching {term!r}:"
if term
else "Resumable conversations:"
)
parts = [header]
parts.extend(lines)
if hidden:
parts.append(
f"({hidden} unresumable metadata-only session(s) hidden)"
)
parts.append(
"Resuming replaces the live conversation — run /resume inside "
"the TUI to pick and load one."
)
return InteractiveOutcome(message="\n".join(parts), display="system")


RESUME_COMMAND = ResumeCommand(
name="resume",
description="Resume a previous conversation", # verbatim TS index.ts:6
argument_hint="[conversation id or search term]", # verbatim TS index.ts:8
aliases=["continue"], # verbatim TS index.ts:7
)


__all__ = ["RESUME_COMMAND", "ResumeCommand"]
114 changes: 114 additions & 0 deletions src/services/session_listing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""UI-neutral resumable-session listing (components C2).

Lives in services — NOT in ``src/tui`` — so the headless ``/resume``
registry command can list sessions without importing Textual (the C2
review measured 135 textual modules loaded through the screen-module
import; same dependency-direction rule the C1 review set for
``suggestions_label``). The Textual picker re-exports these names.

Standing critic condition (gap doc §5 Q2): entries with
``message_count == 0`` are FILTERED out — a headless ``/rename`` can
mint metadata-only sessions that would resume into an empty
conversation — and the hidden count is reported so the list stays
honest.
"""

from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Any, Iterable


@dataclass(frozen=True)
class ResumeEntry:
"""One selectable row: a resumable persisted session."""

session_id: str
title: str
message_count: int = 0
last_updated: float = 0.0
model: str = ""

def label(self) -> str:
when = ""
if self.last_updated:
try:
when = time.strftime(
"%Y-%m-%d %H:%M", time.localtime(self.last_updated)
)
except Exception:
when = ""
parts = [self.title or self.session_id]
meta: list[str] = []
if when:
meta.append(when)
if self.message_count:
meta.append(f"{self.message_count} msgs")
if self.model:
meta.append(self.model)
if meta:
parts.append(f"({' · '.join(meta)})")
return " ".join(parts)


def build_resume_entries(
metas: Iterable[Any],
*,
exclude_session_id: str | None = None,
) -> tuple[list[ResumeEntry], int]:
"""Filter raw ``SessionMetadata`` rows into resumable entries.

Returns ``(entries, hidden_count)`` where ``hidden_count`` is the
number of metadata-only sessions suppressed (``message_count == 0``
— the §5 Q2 decision). The active session is excluded silently (it
is not "resumable", it is current). Duplicate session ids keep the
first occurrence only — ``list_sessions`` orders by ``last_updated``
descending, and a duplicated id would crash Textual's OptionList
(DuplicateID).
"""

entries: list[ResumeEntry] = []
hidden = 0
seen: set[str] = set()
for meta in metas:
session_id = getattr(meta, "session_id", None) or getattr(meta, "id", None)
if not session_id:
continue
sid = str(session_id)
if exclude_session_id and sid == exclude_session_id:
continue
if sid in seen:
continue
seen.add(sid)
count = int(getattr(meta, "message_count", 0) or 0)
if count <= 0:
hidden += 1
continue
entries.append(
ResumeEntry(
session_id=sid,
title=str(getattr(meta, "title", "") or ""),
message_count=count,
last_updated=float(getattr(meta, "last_updated", 0.0) or 0.0),
model=str(getattr(meta, "model", "") or ""),
)
)
return entries, hidden


def filter_entries(entries: list[ResumeEntry], term: str) -> list[ResumeEntry]:
"""Case-insensitive substring filter over id + title (the TS
argumentHint's "search term")."""

needle = term.strip().lower()
if not needle:
return entries
return [
e
for e in entries
if needle in e.session_id.lower() or needle in e.title.lower()
]


__all__ = ["ResumeEntry", "build_resume_entries", "filter_entries"]
75 changes: 75 additions & 0 deletions src/tui/agent_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,81 @@ def reset_advisor_dedup(self) -> None:
def busy(self) -> bool:
return self._busy

def resume_session(self, session_id: str) -> list[Any] | None:
"""Swap the live conversation to a persisted session (C2 /resume).

Mirrors TS resume semantics: the picked session BECOMES the active
session — its id is installed via ``bootstrap.state.switch_session``
(the designated resume path, fires ``session_switched``), the
persister re-targets it so subsequent turns append to the SAME
store, and the in-memory conversation is replaced by the stored
transcript. Returns the loaded typed messages for the UI to
re-render, or ``None`` when refused (worker busy) / nothing
stored. Must be called from the UI thread while idle — the guard
is the same ``_busy_lock`` gate ``submit`` uses.
"""

with self._busy_lock:
if self._busy:
return None

from src.services.session_persistence import SessionPersister
from src.services.session_resume import resume_session

# The full TS-parity reader: malformed-line recovery, orphaned
# tool_use repair, snip boundaries, cross-project path
# adjustment (session/resume.ts). Synchronous on the UI thread
# — acceptable for the degraded C2 scope (TS reads async);
# revisit if multi-MB transcripts make the freeze noticeable.
try:
result = resume_session(session_id, current_cwd=os.getcwd())
except Exception:
# Unreadable transcript (permissions, dir-shaped file…)
# must refuse, not crash the Textual callback chain.
return None
if not result.success or not result.messages:
return None
messages = result.messages

from src.bootstrap.state import switch_session
from src.services.cost_restore import restore_cost_state_for_session

# TS ResumeConversation.tsx:224-227: switchSession then
# restoreCostStateForSession, in lockstep. (TS also passes the
# session's project dir to switchSession; nothing consumes
# get_session_project_dir() in Python yet, so it is omitted.)
switch_session(session_id)
# Best-effort: TUI-born sessions don't write the flat cost
# snapshot yet, so this is usually a no-op today; it exists so
# resume of snapshot-bearing sessions restores accumulators.
restore_cost_state_for_session(session_id)

conversation = self._session.conversation
conversation.messages.clear()
conversation.messages.extend(messages)
self._session.session_id = session_id

# Advisor dedup: clear the emitted-ID set (old IDs are gone),
# but point the scan cursor at the END of the repopulated list
# — index 0 (the /clear semantics of reset_advisor_dedup)
# would make the first post-resume scan re-emit every
# HISTORICAL advisor event as fresh UI rows.
self._emitted_advisor_ids.clear()
self._last_scanned_msg_index = len(conversation.messages)

# Re-target persistence; start() only initializes metadata when
# absent, so the resumed session's existing metadata (title,
# counts) is preserved. NOTE: AppState.usage token counters are
# deliberately NOT reset/hydrated here — the status line keeps
# counting from the live process (decision recorded in the C2
# review; revisit with the C3 context/status work).
self._persister = SessionPersister(session_id=session_id)
self._persister.start(
model=getattr(self._provider, "model", "") or "",
cwd=os.getcwd(),
)
return list(messages)

def submit(self, prompt: str) -> bool:
"""Queue ``prompt`` for the agent. Returns False if busy."""

Expand Down
Loading