diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e5ddf497a..35dc605f9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -514,12 +514,12 @@ def _session_browse_picker(sessions: list) -> Optional[str]: return None -def _resolve_last_cli_session() -> Optional[str]: - """Look up the most recent CLI session ID from SQLite. Returns None if unavailable.""" +def _resolve_last_session(source: str = "cli") -> Optional[str]: + """Look up the most recent session ID for a source.""" try: from hermes_state import SessionDB db = SessionDB() - sessions = db.search_sessions(source="cli", limit=1) + sessions = db.search_sessions(source=source, limit=1) db.close() if sessions: return sessions[0]["id"] @@ -554,7 +554,58 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: return None -def _launch_tui(): +def _print_tui_exit_summary(session_id: Optional[str]) -> None: + """Print a shell-visible epilogue after TUI exits.""" + target = session_id or _resolve_last_session(source="tui") + if not target: + return + + db = None + try: + from hermes_state import SessionDB + db = SessionDB() + session = db.get_session(target) + if not session: + return + + title = db.get_session_title(target) + message_count = int(session.get("message_count") or 0) + input_tokens = int(session.get("input_tokens") or 0) + output_tokens = int(session.get("output_tokens") or 0) + cache_read_tokens = int(session.get("cache_read_tokens") or 0) + cache_write_tokens = int(session.get("cache_write_tokens") or 0) + reasoning_tokens = int(session.get("reasoning_tokens") or 0) + total_tokens = ( + input_tokens + + output_tokens + + cache_read_tokens + + cache_write_tokens + + reasoning_tokens + ) + except Exception: + return + finally: + if db is not None: + db.close() + + print() + print("Resume this session with:") + print(f" hermes --tui --resume {target}") + if title: + print(f" hermes --tui -c \"{title}\"") + print() + print(f"Session: {target}") + if title: + print(f"Title: {title}") + print(f"Messages: {message_count}") + print( + "Tokens: " + f"{total_tokens} (in {input_tokens}, out {output_tokens}, " + f"cache {cache_read_tokens + cache_write_tokens}, reasoning {reasoning_tokens})" + ) + + +def _launch_tui(resume_session_id: Optional[str] = None): """Replace current process with the Ink TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -589,19 +640,26 @@ def _launch_tui(): sys.exit(1) argv = [npm, "start"] + env = os.environ.copy() + if resume_session_id: + env["HERMES_TUI_RESUME"] = resume_session_id + try: - code = subprocess.call(argv, cwd=str(tui_dir)) + code = subprocess.call(argv, cwd=str(tui_dir), env=env) except KeyboardInterrupt: code = 130 + + if code in (0, 130): + _print_tui_exit_summary(resume_session_id) + sys.exit(code) def cmd_chat(args): """Run interactive chat CLI.""" - if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": - _launch_tui() + use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" - # Resolve --continue into --resume with the latest CLI session or by name + # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): if isinstance(continue_val, str): @@ -615,11 +673,15 @@ def cmd_chat(args): sys.exit(1) else: # -c with no argument — continue the most recent session - last_id = _resolve_last_cli_session() + source = "tui" if use_tui else "cli" + last_id = _resolve_last_session(source=source) + if not last_id and source == "tui": + last_id = _resolve_last_session(source="cli") if last_id: args.resume = last_id else: - print("No previous CLI session found to continue.") + kind = "TUI" if use_tui else "CLI" + print(f"No previous {kind} session found to continue.") sys.exit(1) # Resolve --resume by title if it's not a direct session ID @@ -631,6 +693,9 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input + if use_tui: + _launch_tui(getattr(args, "resume", None)) + # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() diff --git a/run_agent.py b/run_agent.py index f57072e9e..fd1337cbb 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7617,7 +7617,7 @@ class AIAgent: # Longer backoff for rate limiting (likely cause of None choices) # Jittered exponential: 5s base, 120s cap + random jitter wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0) - self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...", force=True) + self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time:.1f}s (extended backoff)...", force=True) logging.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}") # Sleep in small increments to stay responsive to interrupts @@ -8505,9 +8505,9 @@ class AIAgent: pass wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) if is_rate_limited: - self._emit_status(f"⏱️ Rate limit reached. Waiting {wait_time}s before retry (attempt {retry_count + 1}/{max_retries})...") + self._emit_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...") else: - self._emit_status(f"⏳ Retrying in {wait_time}s (attempt {retry_count}/{max_retries})...") + self._emit_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...") logger.warning( "Retrying API call in %ss (attempt %s/%s) %s error=%s", wait_time, diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py new file mode 100644 index 000000000..1d4ff429a --- /dev/null +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -0,0 +1,119 @@ +from argparse import Namespace +import sys +import types + +import pytest + + +def _args(**overrides): + base = { + "continue_last": None, + "resume": None, + "tui": True, + } + base.update(overrides) + return Namespace(**base) + + +def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch): + import hermes_cli.main as main_mod + + calls = [] + captured = {} + + def fake_resolve_last(source="cli"): + calls.append(source) + return "20260408_235959_a1b2c3" if source == "tui" else None + + def fake_launch(resume_session_id=None): + captured["resume"] = resume_session_id + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last) + monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val) + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(continue_last=True)) + + assert calls == ["tui"] + assert captured["resume"] == "20260408_235959_a1b2c3" + + +def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch): + import hermes_cli.main as main_mod + + calls = [] + captured = {} + + def fake_resolve_last(source="cli"): + calls.append(source) + if source == "tui": + return None + if source == "cli": + return "20260408_235959_d4e5f6" + return None + + def fake_launch(resume_session_id=None): + captured["resume"] = resume_session_id + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last) + monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val) + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(continue_last=True)) + + assert calls == ["tui", "cli"] + assert captured["resume"] == "20260408_235959_d4e5f6" + + +def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch): + import hermes_cli.main as main_mod + + captured = {} + + def fake_launch(resume_session_id=None): + captured["resume"] = resume_session_id + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb") + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(resume="my t0p session")) + + assert captured["resume"] == "20260409_000000_aa11bb" + + +def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): + import hermes_cli.main as main_mod + + class _FakeDB: + def get_session(self, session_id): + assert session_id == "20260409_000001_abc123" + return { + "message_count": 2, + "input_tokens": 10, + "output_tokens": 6, + "cache_read_tokens": 2, + "cache_write_tokens": 2, + "reasoning_tokens": 1, + } + + def get_session_title(self, _session_id): + return "demo title" + + def close(self): + return None + + monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())) + + main_mod._print_tui_exit_summary("20260409_000001_abc123") + out = capsys.readouterr().out + + assert "Resume this session with:" in out + assert "hermes --tui --resume 20260409_000001_abc123" in out + assert 'hermes --tui -c "demo title"' in out + assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 7e9d519ee..7a000c92b 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -151,6 +151,52 @@ def test_sess_found(server): assert err is None +# ── session.resume payload ──────────────────────────────────────────── + + +def test_session_resume_returns_hydrated_messages(server, monkeypatch): + class _DB: + def get_session(self, _sid): + return {"id": "20260409_010101_abc123"} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages(self, _sid): + return [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "yo"}, + {"role": "tool", "content": "searched"}, + {"role": "assistant", "content": " "}, + {"role": "assistant", "content": None}, + {"role": "narrator", "content": "skip"}, + ] + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object()) + monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) + monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "test/model"}) + + resp = server.handle_request( + { + "id": "r1", + "method": "session.resume", + "params": {"session_id": "20260409_010101_abc123", "cols": 100}, + } + ) + + assert "error" not in resp + assert resp["result"]["message_count"] == 3 + assert resp["result"]["messages"] == [ + {"role": "user", "text": "hello"}, + {"role": "assistant", "text": "yo"}, + {"role": "tool", "text": "searched"}, + ] + + # ── Config I/O ─────────────────────────────────────────────────────── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 654c9e9e3..9977d40f5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5,6 +5,7 @@ import subprocess import sys import threading import uuid +from datetime import datetime from pathlib import Path from hermes_constants import get_hermes_home @@ -364,6 +365,10 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): _emit("session.info", sid, _session_info(agent)) +def _new_session_key() -> str: + return f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + + def _with_checkpoints(session, fn): return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) @@ -405,7 +410,7 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: @method("session.create") def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] - key = f"tui-{sid}" + key = _new_session_key() os.environ["HERMES_SESSION_KEY"] = key os.environ["HERMES_INTERACTIVE"] = "1" try: @@ -448,14 +453,28 @@ def _(rid, params: dict) -> dict: os.environ["HERMES_INTERACTIVE"] = "1" try: db.reopen_session(target) - history = [{"role": m["role"], "content": m["content"]} - for m in db.get_messages(target) - if m.get("role") in ("user", "assistant", "tool", "system")] + messages = [ + {"role": m["role"], "text": m["content"] or ""} + for m in db.get_messages(target) + if m.get("role") in ("user", "assistant", "tool", "system") + and isinstance(m.get("content"), str) + and (m.get("content") or "").strip() + ] + history = [{"role": m["role"], "content": m["text"]} for m in messages] agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") - return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history), "info": _session_info(agent)}) + return _ok( + rid, + { + "session_id": sid, + "resumed": target, + "message_count": len(messages), + "messages": messages, + "info": _session_info(agent), + }, + ) @method("session.title") @@ -538,7 +557,7 @@ def _(rid, params: dict) -> dict: history = session.get("history", []) if not history: return _err(rid, 4008, "nothing to branch — send a message first") - new_key = f"tui-{uuid.uuid4().hex[:8]}" + new_key = _new_session_key() branch_name = params.get("name", "") try: if branch_name: diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index be93126c7..e96cbac3d 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,98 +1,20 @@ import { describe, expect, it } from 'vitest' -import { - compactPreview, - estimateRows, - fmtK, - hasAnsi, - hasInterpolation, - pick, - stripAnsi, - userDisplay -} from '../lib/text.js' +import { sameToolTrailGroup } from '../lib/text.js' -describe('stripAnsi / hasAnsi', () => { - it('strips ANSI codes', () => { - expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red') +describe('sameToolTrailGroup', () => { + it('matches bare check lines', () => { + expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✓')).toBe(true) + expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✗')).toBe(true) }) - it('passes plain text through', () => { - expect(stripAnsi('hello')).toBe('hello') + it('matches contextual lines', () => { + expect(sameToolTrailGroup('🔍 searching', '🔍 searching: * ✓')).toBe(true) + expect(sameToolTrailGroup('🔍 searching', '🔍 searching: foo ✓')).toBe(true) }) - it('detects ANSI', () => { - expect(hasAnsi('\x1b[1mbold\x1b[0m')).toBe(true) - expect(hasAnsi('plain')).toBe(false) - }) -}) - -describe('compactPreview', () => { - it('truncates with ellipsis', () => { - expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20) - expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…') - }) - - it('returns short strings as-is', () => { - expect(compactPreview('hello', 20)).toBe('hello') - }) - - it('collapses whitespace', () => { - expect(compactPreview(' a b ', 20)).toBe('a b') - }) - - it('returns empty for whitespace-only', () => { - expect(compactPreview(' ', 20)).toBe('') - }) -}) - -describe('estimateRows', () => { - it('single line', () => expect(estimateRows('hello', 80)).toBe(1)) - - it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2)) - - it('counts newlines', () => expect(estimateRows('a\nb\nc', 80)).toBe(3)) - - it('skips table separators', () => { - expect(estimateRows('| a | b |\n|---|---|\n| 1 | 2 |', 80)).toBe(2) - }) - - it('handles code blocks', () => { - expect(estimateRows('```python\nprint("hi")\n```', 80)).toBeGreaterThanOrEqual(2) - }) - - it('compact mode skips empty lines', () => { - expect(estimateRows('a\n\nb', 80, true)).toBe(2) - expect(estimateRows('a\n\nb', 80, false)).toBe(3) - }) -}) - -describe('fmtK', () => { - it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k')) - - it('keeps small numbers', () => expect(fmtK(42)).toBe('42')) - - it('boundary', () => { - expect(fmtK(1000)).toBe('1.0k') - expect(fmtK(999)).toBe('999') - }) -}) - -describe('hasInterpolation', () => { - it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true)) - - it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false)) -}) - -describe('pick', () => { - it('returns element from array', () => { - expect([1, 2, 3]).toContain(pick([1, 2, 3])) - }) -}) - -describe('userDisplay', () => { - it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello')) - - it('truncates long messages', () => { - expect(userDisplay('word '.repeat(100))).toContain('[long message]') + it('rejects other tools', () => { + expect(sameToolTrailGroup('🔍 searching', '📖 reading ✓')).toBe(false) + expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false) }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0ac815611..d17d0c0fb 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -21,7 +21,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, pick } from './lib/text.js' +import { compactPreview, fmtK, hasInterpolation, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -42,8 +42,8 @@ import type { const PLACEHOLDER = pick(PLACEHOLDERS) const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g +const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() -const SMALL_PASTE = { chars: 400, lines: 4 } const LARGE_PASTE = { chars: 8000, lines: 80 } const EXCERPT = { chars: 1200, lines: 14 } @@ -102,6 +102,31 @@ const stripTokens = (text: string, re: RegExp) => .replace(/\s{2,}/g, ' ') .trim() +const toTranscriptMessages = (rows: unknown): Msg[] => { + if (!Array.isArray(rows)) { + return [] + } + + return rows.flatMap(row => { + if (!row || typeof row !== 'object') { + return [] + } + + const role = (row as any).role + const text = (row as any).text + + if ( + (role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') || + typeof text !== 'string' || + !text.trim() + ) { + return [] + } + + return [{ role, text }] + }) +} + // ── StatusRule ──────────────────────────────────────────────────────── function ctxBarColor(pct: number | undefined, t: Theme) { @@ -250,6 +275,7 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Refs ───────────────────────────────────────────────────────── const activityIdRef = useRef(0) + const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null) const buf = useRef('') const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) @@ -301,21 +327,19 @@ export function App({ gw }: { gw: GatewayClient }) { setHistoryItems(prev => [...prev, msg]) }, []) - const appendHistory = useCallback((msg: Msg) => { - setHistoryItems(prev => [...prev, msg]) - }, []) - const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info') => { + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { - if (prev.at(-1)?.text === text && prev.at(-1)?.tone === tone) { - return prev + const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev + + if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) { + return base } activityIdRef.current++ - return [...prev, { id: activityIdRef.current, text, tone }].slice(-8) + return [...base, { id: activityIdRef.current, text, tone }].slice(-8) }) }, []) @@ -387,7 +411,7 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage(prev => ({ ...prev, ...r.info.usage })) } - appendHistory(introMsg(r.info)) + setHistoryItems([introMsg(r.info)]) } else { setInfo(null) } @@ -396,7 +420,7 @@ export function App({ gw }: { gw: GatewayClient }) { sys(msg) } }), - [appendHistory, rpc, sys] + [rpc, sys] ) // ── Paste pipeline ─────────────────────────────────────────────── @@ -466,10 +490,17 @@ export function App({ gw }: { gw: GatewayClient }) { const handleTextPaste = useCallback( ({ cursor, text, value }: { cursor: number; text: string; value: string }) => { + const lineCount = text.split('\n').length + + // Inline normal paste payloads exactly as typed. Only very large + // payloads are tokenized into attached snippets. + if (text.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { + return { cursor: cursor + text.length, value: value.slice(0, cursor) + text + value.slice(cursor) } + } + pasteCounterRef.current++ const id = pasteCounterRef.current - const lineCount = text.split('\n').length - const mode: PasteMode = lineCount > SMALL_PASTE.lines || text.length > SMALL_PASTE.chars ? 'attach' : 'excerpt' + const mode: PasteMode = 'attach' const token = pasteToken(id) const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' @@ -887,8 +918,31 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => {}) - setStatus('forging session…') - newSession() + if (STARTUP_RESUME_ID) { + setStatus('resuming…') + gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then((r: any) => { + resetSession() + setSid(r.session_id) + setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) + + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + setStatus('ready') + }) + .catch(() => { + setStatus('forging session…') + newSession('resume failed, started a new session') + }) + } else { + setStatus('forging session…') + newSession() + } break @@ -965,18 +1019,26 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { const mark = p.error ? '✗' : '✓' + const tone = p.error ? 'error' : 'info' + toolCompleteRibbonRef.current = null setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` - pushActivity(line, p.error ? 'error' : 'info') - turnToolsRef.current = [...turnToolsRef.current, line].slice(-8) + + toolCompleteRibbonRef.current = { label, line } + turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) return prev.filter(t => t.id !== p.tool_id) }) + if (toolCompleteRibbonRef.current) { + const { line, label } = toolCompleteRibbonRef.current + pushActivity(line, tone, label) + } + break } @@ -1733,21 +1795,19 @@ export function App({ gw }: { gw: GatewayClient }) { onSelect={id => { setPicker(false) setStatus('resuming…') - gw.request('session.resume', { cols, session_id: id }) + gw.request('session.resume', { cols: colsRef.current, session_id: id }) .then((r: any) => { resetSession() setSid(r.session_id) setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) if (r.info?.usage) { setUsage(prev => ({ ...prev, ...r.info.usage })) } - if (r.info) { - appendHistory(introMsg(r.info)) - } - - sys(`resumed session (${r.message_count} messages)`) + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) setStatus('ready') }) .catch((e: Error) => { diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 71246e473..76d0d1743 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -70,7 +70,7 @@ export const MessageLine = memo(function MessageLine({ {!!msg.tools?.length && ( - + {msg.tools.map((tool, i) => ( | null>(null) const pastePos = useRef(0) + const undo = useRef>([]) + const redo = useRef>([]) + curRef.current = cur vRef.current = value useEffect(() => { @@ -60,16 +64,34 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' selfChange.current = false } else { setCur(value.length) + curRef.current = value.length + undo.current = [] + redo.current = [] } }, [value]) - const commit = (v: string, c: number) => { - c = Math.max(0, Math.min(c, v.length)) - setCur(c) + const commit = (nextValue: string, nextCursor: number, track = true) => { + const currentValue = vRef.current + const currentCursor = curRef.current + const c = Math.max(0, Math.min(nextCursor, nextValue.length)) - if (v !== value) { + if (track && nextValue !== currentValue) { + undo.current.push({ cursor: currentCursor, value: currentValue }) + + if (undo.current.length > 200) { + undo.current.shift() + } + + redo.current = [] + } + + setCur(c) + curRef.current = c + vRef.current = nextValue + + if (nextValue !== currentValue) { selfChange.current = true - onChange(v) + onChange(nextValue) } } @@ -83,21 +105,17 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - const v = vRef.current - const handled = onPaste?.({ cursor: at, text: pasted, value: v }) + const currentValue = vRef.current + const handled = onPaste?.({ cursor: at, text: pasted, value: currentValue }) if (handled) { - selfChange.current = true - onChange(handled.value) - setCur(handled.cursor) + commit(handled.value, handled.cursor) return } if (pasted.length && PRINTABLE.test(pasted)) { - selfChange.current = true - onChange(v.slice(0, at) + pasted + v.slice(at)) - setCur(at + pasted.length) + commit(currentValue.slice(0, at) + pasted + currentValue.slice(at), at + pasted.length) } } @@ -130,6 +148,32 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' let v = value const mod = k.ctrl || k.meta + if (k.ctrl && inp === 'z') { + const prev = undo.current.pop() + + if (!prev) { + return + } + + redo.current.push({ cursor: curRef.current, value: vRef.current }) + commit(prev.value, prev.cursor, false) + + return + } + + if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { + const next = redo.current.pop() + + if (!next) { + return + } + + undo.current.push({ cursor: curRef.current, value: vRef.current }) + commit(next.value, next.cursor, false) + + return + } + if (k.home || (k.ctrl && inp === 'a')) { c = 0 } else if (k.end || (k.ctrl && inp === 'e')) { diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 9734b0c27..36dd999e6 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -28,6 +28,7 @@ export const HOTKEYS: [string, string][] = [ ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+A/E', 'home / end of line'], + ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], ['Ctrl+W', 'delete word'], ['Ctrl+U/K', 'delete to start / end'], ['Ctrl+←/→', 'jump word'], diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index c24727484..ac2efb2cb 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -35,6 +35,10 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ +export const sameToolTrailGroup = (label: string, entry: string) => + entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) + export const estimateRows = (text: string, w: number, compact = false) => { let inCode = false let rows = 0