Skip to content

Commit 3d76931

Browse files
committed
feat: add /session command to list and switch between conversation sessions
- Add session management functions in metadata.py with proper type annotations - list_sessions(): List all sessions with metadata - load_session(): Load and switch to a specific session - _get_session_info(): Extract session preview information - Add /session command (alias: /resume) for interactive session switching - Display sessions with preview (first 50 chars), relative time, and message count - Support full conversation history restoration when switching sessions - Handle edge cases: empty sessions, current session, user cancellation - Fix import issues after upstream refactoring (load_agents_md moved to soul.globals)
1 parent 579d323 commit 3d76931

File tree

2 files changed

+253
-0
lines changed

2 files changed

+253
-0
lines changed

src/kimi_cli/metadata.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from hashlib import md5
33
from pathlib import Path
4+
from typing import Any, cast
45

56
from pydantic import BaseModel, Field
67

@@ -52,3 +53,129 @@ def save_metadata(metadata: Metadata):
5253
logger.debug("Saving metadata to file: {file}", file=metadata_file)
5354
with open(metadata_file, "w", encoding="utf-8") as f:
5455
json.dump(metadata.model_dump(), f, indent=2, ensure_ascii=False)
56+
57+
58+
def load_session(work_dir: Path, session_id: str):
59+
"""Load a session by its id and set it to the current session."""
60+
from kimi_cli.session import Session
61+
62+
logger.debug(
63+
"Loading session: {session_id} for work directory: {work_dir}",
64+
session_id=session_id,
65+
work_dir=work_dir,
66+
)
67+
68+
metadata = load_metadata()
69+
work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
70+
if work_dir_meta is None:
71+
logger.debug("Work directory never been used")
72+
return None
73+
74+
history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
75+
if not history_file.exists():
76+
logger.warning("Session history file not found: {history_file}", history_file=history_file)
77+
return None
78+
79+
work_dir_meta.last_session_id = session_id
80+
save_metadata(metadata)
81+
82+
logger.info("Loaded session {session_id} and set as current", session_id=session_id)
83+
84+
return Session(id=session_id, work_dir=work_dir, history_file=history_file)
85+
86+
87+
def _get_session_info(session_file: Path) -> dict[str, Any]:
88+
"""Get session's meta info from the session file."""
89+
from datetime import datetime
90+
91+
info: dict[str, Any] = {
92+
"timestamp": datetime.fromtimestamp(session_file.stat().st_mtime),
93+
"num_messages": 0,
94+
"num_checkpoints": 0,
95+
"first_message": None,
96+
}
97+
98+
try:
99+
with open(session_file, encoding="utf-8") as f:
100+
for line in f:
101+
line = line.strip()
102+
if not line:
103+
continue
104+
105+
data = json.loads(line)
106+
role = data.get("role")
107+
108+
if role == "_checkpoint":
109+
info["num_checkpoints"] += 1
110+
elif role == "user" or role == "assistant":
111+
info["num_messages"] += 1
112+
113+
# Use the first message as preview for the session
114+
if info["first_message"] is None and role == "user":
115+
content = data.get("content")
116+
if not content:
117+
continue
118+
119+
text: str | None = None
120+
if isinstance(content, str):
121+
text = content
122+
elif isinstance(content, list):
123+
content_list = cast(list[Any], content)
124+
if len(content_list) > 0:
125+
first_part = content_list[0]
126+
if isinstance(first_part, str):
127+
text = first_part
128+
elif isinstance(first_part, dict):
129+
part_dict = cast(dict[str, Any], first_part)
130+
if "text" in part_dict:
131+
text = str(part_dict["text"])
132+
133+
if text is None:
134+
continue
135+
136+
# Skip injected system messages
137+
if text.startswith("<system>") and text.endswith("</system>"):
138+
continue
139+
info["first_message"] = text[:50].strip()
140+
except Exception as e:
141+
logger.warning(
142+
"Failed to get session info from {session_file}: {e}", session_file=session_file, e=e
143+
)
144+
return info
145+
146+
147+
def list_sessions(work_dir: Path) -> list[tuple[str, Path, dict[str, Any]]]:
148+
"""List all the latest sessions for a work directory.
149+
150+
Returns:
151+
List of tuples (session_id, session_file, info) sorted by timestamp descending.
152+
"""
153+
logger.debug("Listing sessions for work directory: {work_dir}", work_dir=work_dir)
154+
155+
metadata = load_metadata()
156+
work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
157+
if work_dir_meta is None:
158+
logger.debug("Work directory never been used")
159+
return []
160+
161+
sessions: list[tuple[str, Path, dict[str, Any]]] = []
162+
sessions_dir = work_dir_meta.sessions_dir
163+
164+
# Since the revert to checkpoint will create a new session file,
165+
# we need to list the most up-to-date files and sort them by timestamp.
166+
for session_file in sessions_dir.glob("*.jsonl"):
167+
if "_" in session_file.stem:
168+
logger.debug("Skipping backup file: {session_file}", session_file=session_file)
169+
continue
170+
171+
session_id = session_file.stem
172+
info = _get_session_info(session_file)
173+
info["is_current"] = session_id == work_dir_meta.last_session_id
174+
175+
sessions.append((session_id, session_file, info))
176+
177+
sessions.sort(key=lambda x: x[2]["timestamp"], reverse=True)
178+
179+
logger.debug("Found {num_sessions} sessions", num_sessions=len(sessions))
180+
181+
return sessions

src/kimi_cli/ui/shell/metacmd.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import getpass
12
import tempfile
23
import webbrowser
34
from collections.abc import Awaitable, Callable, Sequence
5+
from datetime import datetime, timedelta
46
from pathlib import Path
57
from typing import TYPE_CHECKING, NamedTuple, overload
68

79
from kosong.base.message import Message
10+
from prompt_toolkit.shortcuts.choice_input import ChoiceInput
811
from rich.panel import Panel
912

1013
import kimi_cli.prompts as prompts
@@ -13,8 +16,10 @@
1316
from kimi_cli.soul.message import system
1417
from kimi_cli.soul.runtime import load_agents_md
1518
from kimi_cli.ui.shell.console import console
19+
from kimi_cli.ui.shell.liveview import _LeftAlignedMarkdown
1620
from kimi_cli.utils.changelog import CHANGELOG, format_release_notes
1721
from kimi_cli.utils.logging import logger
22+
from kimi_cli.utils.message import message_extract_text
1823

1924
if TYPE_CHECKING:
2025
from kimi_cli.ui.shell import ShellApp
@@ -255,6 +260,127 @@ async def compact(app: "ShellApp", args: list[str]):
255260
console.print("[green]✓[/green] Context has been compacted.")
256261

257262

263+
@meta_command(aliases=["resume"], kimi_soul_only=True)
264+
async def session(app: "ShellApp", args: list[str]):
265+
"""List all conversation sessions and switch to a previous one to continue"""
266+
from kimi_cli.metadata import list_sessions
267+
268+
assert isinstance(app.soul, KimiSoul)
269+
work_dir = app.soul._runtime.builtin_args.KIMI_WORK_DIR
270+
271+
session_list = list_sessions(work_dir)
272+
273+
if not session_list:
274+
console.print("[yellow]No sessions found.[/yellow]")
275+
return
276+
277+
# Build a list of choices for the user to select from
278+
choices = []
279+
session_map = {} # Map display string to session_id
280+
preview_width = 50 # Fix the width of the preview column
281+
282+
for _i, (session_id, _path, info) in enumerate(session_list, start=1):
283+
# Build a relative time calculation
284+
now = datetime.now()
285+
time_diff = now - info["timestamp"]
286+
if time_diff < timedelta(minutes=5):
287+
time_str = "just now"
288+
elif time_diff < timedelta(hours=1):
289+
minutes = int(time_diff.total_seconds() / 60)
290+
time_str = f"{minutes}m ago"
291+
elif time_diff < timedelta(days=1):
292+
hours = int(time_diff.total_seconds() / 3600)
293+
time_str = f"{hours}h ago"
294+
elif time_diff < timedelta(days=7):
295+
days = time_diff.days
296+
time_str = f"{days}d ago"
297+
else:
298+
time_str = info["timestamp"].strftime("%m-%d")
299+
300+
preview = info["first_message"] or "empty"
301+
if len(preview) > preview_width - 3:
302+
preview_display = preview[: preview_width - 3] + "..."
303+
else:
304+
preview_display = preview.ljust(preview_width)
305+
306+
marker = "→" if info["is_current"] else " "
307+
label = f"{marker} {preview_display} {time_str} · {info['num_messages']} msgs"
308+
choices.append((label, label))
309+
session_map[label] = (session_id, info)
310+
311+
try:
312+
result = await ChoiceInput(
313+
message="Select a session to switch to(↑↓ navigate, Enter select, Ctrl+C cancel):",
314+
options=choices,
315+
).prompt_async()
316+
except (EOFError, KeyboardInterrupt):
317+
return
318+
319+
if result is None:
320+
return
321+
322+
# Get session_id and info from the selected label
323+
if result not in session_map:
324+
console.print("[red]Invalid selection.[/red]")
325+
return
326+
327+
session_id, session_info = session_map[result]
328+
329+
if session_info["is_current"]:
330+
console.print("[yellow]You are already in this session.[/yellow]")
331+
return
332+
333+
from kimi_cli.metadata import load_session
334+
from kimi_cli.soul.context import Context
335+
336+
new_session = load_session(work_dir, session_id)
337+
if not new_session:
338+
console.print("[red]Failed to load session.[/red]")
339+
return
340+
341+
new_context = Context(file_backend=new_session.history_file)
342+
restored = await new_context.restore()
343+
344+
if not restored or not new_context.history:
345+
console.print("[yellow]The session is empty.[/yellow]")
346+
return
347+
348+
# Switch to the new session completely
349+
app.soul._context = new_context
350+
351+
# Update runtime to point to the new session
352+
new_runtime = app.soul._runtime._replace(session=new_session)
353+
app.soul._runtime = new_runtime
354+
355+
console.clear()
356+
_render_history(list(new_context.history))
357+
358+
# Confirm successful switch
359+
console.print("[green]✓ Session switched successfully[/green]")
360+
console.print()
361+
362+
363+
def _render_history(history: list[Message]):
364+
"""Render the history as a table to display"""
365+
username = getpass.getuser()
366+
367+
for msg in history:
368+
if msg.role == "user":
369+
text = message_extract_text(msg)
370+
371+
# dmail will add some system messages, skip them
372+
if text and not (text.startswith("<system>") and text.endswith("</system>")):
373+
console.print(f"[bold]{username}✨[/bold] {text}")
374+
console.print()
375+
376+
elif msg.role == "assistant":
377+
text = message_extract_text(msg)
378+
if text:
379+
md = _LeftAlignedMarkdown(text, justify="left")
380+
console.print(md)
381+
console.print()
382+
383+
258384
from . import ( # noqa: E402
259385
debug, # noqa: F401
260386
setup, # noqa: F401

0 commit comments

Comments
 (0)