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.
106 lines
3.6 KiB
Python
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]
|