Files
GenericAgent/frontends/session_names.py
T
Shen Hao 78021999cd feat(tui): /rename, /cost, /continue <name>, ask_user picker, runtime tips
Adds to frontends/tuiapp_v2.py:
- /rename <name>: rename current session; collision-checked across in-memory
  sessions and the persistent registry. Topbar and sidebar refresh in place.
- /continue <name>: resume by stored name in addition to the existing index
  form (/continue 1). The picker (/continue with no arg) labels each entry
  with its stored name when available.
- /cost [all]: per-session token usage (input / output / cache_create /
  cache_read), elapsed time, request count, and context-window %-left. The
  "all" form lists every tracked thread, including ended sessions.
- ask_user picker upgrades: free-text escape hatch on every list, Esc-back
  navigation, single/multi-select switch from a [多选]/[multi] question hint,
  candidate sanitizer that handles JSON-envelope debris.
- Runtime tip footer (`└ Tip: ...`) that rotates short usage hints.
- Topbar status chip showing the active model + idle/busy state.
- Cross-platform launcher hardening: auto-install of rich/textual on first
  run, and a hint when running under git-bash/mintty.

New modules:
- frontends/session_names.py: JSON sidecar at temp/model_responses/
  session_names.json keyed by log-file basename; touched only by /rename and
  /continue <name>. continue_cmd is not modified.
- frontends/cost_tracker.py: per-thread TokenStats, captured via
  monkey-patches on llmcore._record_usage and llmcore.print (Anthropic SSE's
  final output count only arrives via the latter). Idempotent install().

No core files touched.
2026-05-16 19:24:35 +08:00

106 lines
3.6 KiB
Python

"""Persistent display names for `/continue`-able sessions.
JSON sidecar at `temp/model_responses/session_names.json` maps log-file
basename → user name. Touched only by `/rename` and `/continue <name>`.
"""
import glob, json, os, re, threading
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'temp', 'model_responses')
_REG_PATH = os.path.join(_LOG_DIR, 'session_names.json')
_LOG_RE = re.compile(r'^model_responses_(\d+)\.txt$')
_lock = threading.Lock()
def _load() -> dict:
try:
with open(_REG_PATH, encoding='utf-8') as f:
d = json.load(f)
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _save(d: dict) -> None:
os.makedirs(_LOG_DIR, exist_ok=True)
tmp = _REG_PATH + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(d, f, ensure_ascii=False, indent=2)
os.replace(tmp, _REG_PATH)
def _resolve_basename(basename: str):
# Registered file may be cleared by `continue_cmd._snapshot_current_log`
# on /new or /continue; fall back to the newest non-empty snapshot of the
# same PID so a mid-session rename survives the rotation.
p = os.path.join(_LOG_DIR, basename)
if os.path.isfile(p) and os.path.getsize(p) > 0:
return p
m = _LOG_RE.match(basename)
if m:
snaps = glob.glob(os.path.join(_LOG_DIR, f'model_responses_snapshot_{m.group(1)}_*.txt'))
snaps.sort(key=os.path.getmtime, reverse=True)
for s in snaps:
if os.path.getsize(s) > 0:
return s
return None
def set_name(log_path: str, name: str) -> None:
"""Persist `name` for `log_path`. Empty name removes the entry."""
key = os.path.basename(log_path)
with _lock:
d = _load()
if name: d[key] = name
else: d.pop(key, None)
_save(d)
def migrate(old_path: str, new_path: str) -> None:
"""Move the entry from old basename to new basename after /continue."""
if old_path == new_path: return
old_key, new_key = os.path.basename(old_path), os.path.basename(new_path)
with _lock:
d = _load()
if old_key in d:
d[new_key] = d.pop(old_key)
_save(d)
def name_for(log_path: str) -> str:
return _load().get(os.path.basename(log_path), '')
def has_name(name: str, exclude_basename: str = None) -> bool:
"""True when any other entry already owns `name` (case-insensitive)."""
target = (name or '').strip().lower()
if not target: return False
return any(v.lower() == target for k, v in _load().items() if k != exclude_basename)
def gc() -> int:
"""Drop entries whose log file is gone or empty. Returns count removed."""
with _lock:
d = _load()
bad = [k for k in d if _resolve_basename(k) is None]
for k in bad: d.pop(k)
if bad: _save(d)
return len(bad)
def path_for(name: str, exclude_basename: str = None):
"""Resolve `name` → newest resolvable log path. Exact-match then unique-prefix."""
target = (name or '').strip().lower()
if not target: return None
d = _load()
matches = [(k, v) for k, v in d.items() if v.lower() == target]
if not matches:
matches = [(k, v) for k, v in d.items() if v.lower().startswith(target)]
if len(matches) > 1: matches = []
if exclude_basename is not None:
matches = [m for m in matches if m[0] != exclude_basename]
resolved = [(p, k) for p, k in ((_resolve_basename(k), k) for k, _ in matches) if p]
if not resolved: return None
resolved.sort(key=lambda pk: os.path.getmtime(pk[0]), reverse=True)
return resolved[0][0]