mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(tui): -c resume, ctrl z, pasting updates, exit summary, session fix
This commit is contained in:
parent
b66550ed08
commit
54bd25ff4a
11 changed files with 426 additions and 146 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
119
tests/hermes_cli/test_tui_resume_flow.py
Normal file
119
tests/hermes_cli/test_tui_resume_flow.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<number[]>([])
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
</Box>
|
||||
|
||||
{!!msg.tools?.length && (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={1} marginTop={1}>
|
||||
{msg.tools.map((tool, i) => (
|
||||
<Text
|
||||
color={tool.endsWith(' ✗') ? t.color.error : t.color.dim}
|
||||
|
|
|
|||
|
|
@ -48,11 +48,15 @@ interface Props {
|
|||
|
||||
export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) {
|
||||
const [cur, setCur] = useState(value.length)
|
||||
const curRef = useRef(cur)
|
||||
const vRef = useRef(value)
|
||||
const selfChange = useRef(false)
|
||||
const pasteBuf = useRef('')
|
||||
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pastePos = useRef(0)
|
||||
const undo = useRef<Array<{ cursor: number; value: string }>>([])
|
||||
const redo = useRef<Array<{ cursor: number; value: string }>>([])
|
||||
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')) {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue