8599 lines
385 KiB
Python
8599 lines
385 KiB
Python
"""GenericAgent TUI v2 — Textual app with refined visual style.
|
||
|
||
Run from project root:
|
||
python frontends/tuiapp_v2.py
|
||
|
||
Visual design carried from temp/GA_tui 设计/tui_demo.py;
|
||
functionality migrated from frontends/tuiapp.py plus new commands:
|
||
- /btw — side question (subagent, doesn't interrupt main)
|
||
- /continue — list / restore historical sessions
|
||
- /export — export last reply (clip / file / all)
|
||
- /restore — restore last model_responses log
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import queue
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import subprocess
|
||
import shutil
|
||
|
||
# Local: cross-platform shortcut-label formatter (Win/Linux "Ctrl+B" vs mac "⌃B").
|
||
# Imported early because _TIPS at module load time uses fmt_key().
|
||
from keysym import fmt_key, fmt_keys # noqa: E402
|
||
from dataclasses import dataclass, field
|
||
from itertools import count
|
||
from typing import Any, Callable, Optional
|
||
|
||
|
||
def _ensure_tui_deps() -> None:
|
||
"""Try the imports; on first miss, pip-install the wheel and retry once.
|
||
Keeps `ga-cli` working on a fresh Python (Windows / macOS / Linux) where
|
||
Textual or Rich hasn't been installed yet. Bails with a clear message if
|
||
pip itself is unavailable or the install fails — never silently."""
|
||
import importlib.util, subprocess
|
||
needed = ("rich", "textual")
|
||
missing = [m for m in needed if importlib.util.find_spec(m) is None]
|
||
if not missing: return
|
||
print(f"[ga-tui] installing {' '.join(missing)} into {sys.executable} ...", file=sys.stderr)
|
||
try:
|
||
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *missing])
|
||
except Exception as e:
|
||
print(f"[ga-tui] auto-install failed: {e}\n fix: {sys.executable} -m pip install {' '.join(missing)}",
|
||
file=sys.stderr)
|
||
raise SystemExit(2)
|
||
for m in missing: importlib.invalidate_caches()
|
||
|
||
|
||
_ensure_tui_deps()
|
||
try:
|
||
from rich.markdown import Markdown
|
||
from rich.table import Table
|
||
from rich.text import Text
|
||
from textual import events
|
||
from textual.app import App, ComposeResult
|
||
from textual.binding import Binding
|
||
from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
|
||
from textual.geometry import Region
|
||
from textual.message import Message
|
||
from textual.screen import ModalScreen
|
||
from textual.widget import Widget
|
||
from textual.widgets import Input, OptionList, SelectionList, Static, TextArea
|
||
from textual.widgets.option_list import Option
|
||
from textual.widgets.selection_list import Selection
|
||
except ModuleNotFoundError as exc:
|
||
print(f"[ga-tui] still missing: {exc.name}. Run: {sys.executable} -m pip install rich textual",
|
||
file=sys.stderr)
|
||
raise SystemExit(2) from exc
|
||
|
||
|
||
def _hint_terminal_capabilities() -> None:
|
||
"""Warn once at startup if we detect a terminal known to render Textual
|
||
poorly (e.g. bare mintty/git-bash). The UI still works, but visuals like
|
||
truecolor chips and unicode glyphs may degrade. Heuristic-only — never
|
||
blocks startup, just prints a hint to stderr.
|
||
"""
|
||
if os.name != "nt": return
|
||
if os.environ.get("WT_SESSION") or os.environ.get("TERM_PROGRAM"):
|
||
return # Windows Terminal / iTerm2 / VSCode / Hyper — all fine
|
||
if os.environ.get("TERM", "").startswith("xterm"):
|
||
# mintty exports TERM=xterm-256color. Textual still renders, but
|
||
# mouse + truecolor handling is patchy. Point at the better option.
|
||
print("[ga-tui] hint: best rendering on Windows Terminal (`wt`) — "
|
||
"the mintty/git-bash console may clip colors or mouse events.",
|
||
file=sys.stderr)
|
||
|
||
|
||
_hint_terminal_capabilities()
|
||
|
||
|
||
# Strip terminal control sequences from subprocess stdout but keep SGR color codes,
|
||
# otherwise Text.from_ansi loses color downstream.
|
||
_ANSI_CONTROL_RE = re.compile(
|
||
r"\x1b\[\?[\d;]*[hl]"
|
||
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
|
||
r"|\x1b[=>]"
|
||
)
|
||
|
||
# Strip SGR-only codes — used when we need plain text for downstream parsing
|
||
# (e.g. mapping narrow rendered output to source positions for selection).
|
||
_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||
|
||
# Strip the leading turn marker that agent_loop yields per turn — covers
|
||
# both the default `**LLM Running (Turn N) ...**` and the task-mode short
|
||
# `**Turn N ...**` (agent_loop.py:52 switches when handler.parent.task_dir
|
||
# is set; v2 sets task_dir for the `_stop` / `_keyinfo` consume paths).
|
||
# fold_turns still needs the marker in source content to split turns, so we only strip at
|
||
# render time. Applies to the live (last) text segment, since folded turns don't include it.
|
||
_TURN_MARKER_RE = re.compile(r"^\s*\**(?:LLM Running \()?Turn \d+\)?[^\n]*\**\s*", re.MULTILINE)
|
||
|
||
# Commonmark task-list patterns: `- [ ] foo` / `* [x] foo` / `+ [X] foo`.
|
||
# Group 1 keeps the bullet + leading space so we can substitute the [ ] / [x]
|
||
# portion only and let the Markdown renderer still treat the line as a list item.
|
||
_TASKLIST_OPEN_RE = re.compile(r"^(\s*[-*+] )\[ \] ", re.MULTILINE)
|
||
_TASKLIST_DONE_RE = re.compile(r"^(\s*[-*+] )\[[xX]\] ", re.MULTILINE)
|
||
|
||
# `<tool_use>{...}</tool_use>` envelope emitted by the streaming layer in
|
||
# llmcore. Agents emit one per tool call; the wrapped object always has
|
||
# {"name": ..., "arguments": ...}. We replace the whole envelope so the raw
|
||
# JSON braces/quotes never leak into the markdown render.
|
||
_TOOL_USE_RE = re.compile(r"<tool_use>\s*(\{.*?\})\s*</tool_use>", re.DOTALL)
|
||
|
||
# Agent-internal metadata tags. The sidebar's `S:` and the fold title already
|
||
# surface the summary; the chat body should not show the raw tag. Stripping is
|
||
# also required because `<summary>X</summary>\n<body>` (no blank line) is parsed
|
||
# as a CommonMark HTML block that swallows the following body line, so the
|
||
# model's actual reply disappears from the rendered output.
|
||
# Only the start-of-line form triggers the CommonMark HTML-block swallow; mid-line
|
||
# occurrences are inline HTML that Rich renders as text, and tags inside backticks
|
||
# / fenced / indented code must stay verbatim. Anchoring sidesteps all of those.
|
||
_META_TAG_RE = re.compile(
|
||
r"^[ ]{0,3}<(summary|thinking)>.*?</\1>\s*",
|
||
re.DOTALL | re.IGNORECASE | re.MULTILINE,
|
||
)
|
||
|
||
|
||
# Rotating usage tips, picked once per launch.
|
||
_TIPS = (
|
||
"Tip: 按 / 唤起命令面板;任何命令都能用方向键选择。",
|
||
"Tip: /rename <name> 持久化会话名;/continue <name> 跨次重开同名会话。",
|
||
"Tip: /cost 查看 token 用量;/cost all 列出所有会话的累计。",
|
||
"Tip: /continue 列出最近 20 个历史会话,按 Enter 进入。",
|
||
"Tip: /btw <问题> 让 side-agent 回答而不打断主任务。",
|
||
f"Tip: {fmt_key('ctrl+b')} 折叠侧栏;{fmt_key('ctrl+o')} 切换长输出折叠;{fmt_key('ctrl+/')} 查看快捷键。",
|
||
f"Tip: {fmt_key('ctrl+n')} 新建会话;{fmt_keys('ctrl+up','ctrl+down')} 在多个会话间切换。",
|
||
"Tip: 粘贴图片 / 文件后会自动折叠成 [Image #N] / [File #N] 占位符。",
|
||
f"Tip: 多行输入用 {fmt_key('ctrl+j')} 换行;Enter 直接发送。",
|
||
"Tip: /rewind <n> 回退最近 n 轮对话;/stop 中止当前任务。",
|
||
"Tip: /export clip 把上一条回复复制到剪贴板;/export all 给出完整日志路径。",
|
||
"Tip: /branch [name] 从当前历史分裂出新会话,互不污染。",
|
||
"Tip: ask_user 题目里写 [多选] 自动切到 SelectionList;任何 picker 都有 \"Type something\" 走自由输入。",
|
||
"Tip: plan 模式下的 todo 会渲染在消息区与输入框之间的 📋 Plan 卡片,完成后自动消失。",
|
||
"Tip: /update 让主 agent 自动 git pull 并核查影响面;/autorun 进入 autonomous 自主模式。",
|
||
"Tip: /morphling <目标> 启用蒸馏吞噬外部技能。",
|
||
"Tip: /goal <目标> 进入 Goal 模式(缺 condition 时会回头问你预算 / worker 上限)。",
|
||
"Tip: /hive <目标> 进入 Hive 多 worker 协作;/scheduler 调出 reflect 任务多选启动器。",
|
||
"Tip: /conductor <任务> 直接交给 frontends/conductor.py 做多 subagent 编排。",
|
||
"Tip: /update 是双分支 upstream 同步 —— 先 diff 预演,再分别快进。",
|
||
"Tip: /scheduler 里再点一下已勾选的任务可以 stop —— 取消勾选 = 停止。",
|
||
f"Tip: {fmt_key('ctrl+s')} 把当前输入 stash 起来,下次 / 打开 picker 时还在。",
|
||
)
|
||
|
||
|
||
def _random_tip(exclude: str = "") -> str:
|
||
"""Pick a tip distinct from `exclude` so rotation doesn't repeat."""
|
||
import random
|
||
pool = [t for t in _TIPS if t != exclude] or list(_TIPS)
|
||
return random.choice(pool)
|
||
|
||
|
||
def _tip_line(text: str = ""):
|
||
"""`└ Tip: …` as styled Rich Text; empty `text` → blank pulse line."""
|
||
from rich.text import Text as _T
|
||
t = _T()
|
||
if not text:
|
||
return t
|
||
t.append("└ ", style="#6e7681")
|
||
t.append("Tip: ", style="bold #6e7681")
|
||
t.append(text.removeprefix("Tip: "), style="#6e7681")
|
||
return t
|
||
|
||
# Defensive cleaners for ask_user candidates. The model occasionally smuggles
|
||
# JSON envelope debris (`"}`, `]`, `\`) in or out of a candidate string, or
|
||
# mashes several options together with `\n`. Both arrive as opaque strings
|
||
# from `_install_ask_user_hook` — we sanitize at the boundary so the picker
|
||
# never has to render broken text.
|
||
_CAND_LEFT_TRIM = re.compile(r'^[",\[\]{}\\\s]+')
|
||
_CAND_RIGHT_TRIM = re.compile(r'[",\[\]{}\\\s]+$')
|
||
_CAND_NUMBER_PFX = re.compile(r'^\d+\s*[.)、::).]\s*')
|
||
|
||
|
||
def _sanitize_candidates(raw) -> list[str]:
|
||
"""Normalize whatever the agent passes as `candidates` into a clean,
|
||
deduped list of human-facing strings. Handles a `list[str]` of clean
|
||
options (no-op), as well as the failure modes we've seen in the wild:
|
||
JSON debris glued to one entry, a single string with embedded `\\n` that
|
||
really meant N entries, numbered prefixes (`3. foo`) the picker would
|
||
re-number, and pathologically long entries.
|
||
"""
|
||
out: list[str] = []
|
||
items = raw if isinstance(raw, list) else [raw] if raw else []
|
||
for item in items:
|
||
s = str(item) if item is not None else ""
|
||
# An entry with literal `\n` or real newlines is N entries mashed together.
|
||
for line in s.replace("\\n", "\n").splitlines() or [s]:
|
||
line = _CAND_LEFT_TRIM.sub("", line)
|
||
line = _CAND_RIGHT_TRIM.sub("", line)
|
||
line = _CAND_NUMBER_PFX.sub("", line)
|
||
line = line.strip()
|
||
if not line: continue
|
||
if len(line) > 200: line = line[:200] + "…"
|
||
if line not in out: out.append(line)
|
||
return out
|
||
|
||
|
||
def _render_tool_use_block(match) -> str:
|
||
"""Render a `<tool_use>{...}</tool_use>` envelope as readable markdown.
|
||
|
||
For `ask_user` with candidates we deliberately render only the question —
|
||
the interactive picker (drained in `_drain_ask_user_events`) shows the
|
||
actual choices and owns the user input. Rendering candidates here too
|
||
would double up the visible card.
|
||
|
||
For `ask_user` without candidates (pure free-text prompt) the markdown
|
||
stays the source of truth, so we still emit `> 💬 question`.
|
||
|
||
All other tools collapse to a single `tool: <name>` line — the full fold
|
||
machinery still hides the raw turn body when fold-mode is on.
|
||
"""
|
||
try:
|
||
obj = json.loads(match.group(1))
|
||
except Exception:
|
||
return match.group(0)
|
||
name = obj.get("name", "")
|
||
args = obj.get("arguments") or {}
|
||
if name == "ask_user":
|
||
question = (args.get("question") or "").strip()
|
||
if not question:
|
||
return ""
|
||
return f"\n> 💬 **{question}**\n"
|
||
return f"\n*tool: {name}*\n"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Write-tool diff rendering.
|
||
#
|
||
# `file_patch` / `file_write` are the only tools whose *content* matters at a
|
||
# glance, so we render them as a real, themed diff instead of the generic
|
||
# `tool: <name>` line. The render produces two parallel strings:
|
||
# - an ANSI string (line-number gutter + ±/space sign + low-saturation row
|
||
# background) injected into the colored "narrow" markdown stream, and
|
||
# - a plain string (identical visible text, no SGR) injected into the
|
||
# "wide" stream so `_align_md_renders` can pair them line-for-line and
|
||
# mouse-selection / copy still works.
|
||
# Both strings have the SAME line count and the SAME post-rstrip text, which is
|
||
# the invariant `_align_md_renders` relies on (K==W branch).
|
||
#
|
||
# The verbose display stream (agent_loop.py:78) shows tool args as pretty JSON
|
||
# inside a ```` ```text ```` fence — but `get_pretty_json` turns escaped `\n`
|
||
# into *real* newlines, so multi-line content no longer parses as JSON. Instead
|
||
# of parsing the display text, `_install_write_snapshot_hook` captures the real
|
||
# structured `args` dict (plus the file's pre-write content for `overwrite`)
|
||
# from a `tool_before` hook, keyed by `hash(get_pretty_json(args))`. At render
|
||
# time we hash the fence body and look the capture back up — content-addressed,
|
||
# so it survives re-renders and pairs each block with its exact call.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# hash(get_pretty_json(args)) -> {"name", "args", "existed", "old"}
|
||
_WRITE_CAP: dict[int, dict] = {}
|
||
|
||
# Cap the rendered diff body so a 2000-line file_write can't flood the chat.
|
||
_DIFF_MAX_ROWS = 80
|
||
_DIFF_CONTEXT = 3
|
||
|
||
# Visual-only left margin for the rendered card: prefixed to the ANSI (narrow)
|
||
# stream but NOT the plain (wide) copy source, so mouse-copy stays margin-free.
|
||
# `_align_md_renders` maps the offset via line_indents (single-line groups), and
|
||
# `_md_line_has_box_drawing` exempts the card's `└─ ` line from the table
|
||
# passthrough that would otherwise copy the visible (margined) text.
|
||
_DIFF_MARGIN = 2
|
||
|
||
# Matches the verbose tool display agent_loop.py emits:
|
||
# 🛠️ Tool: `file_patch` 📥 args:
|
||
# ````text
|
||
# { ...pretty json... }
|
||
# ````
|
||
# Group 1 = tool name, group 2 = the (newline-mangled) pretty-JSON fence body,
|
||
# which we hash to recover the real args captured by the snapshot hook.
|
||
_VERBOSE_WRITE_RE = re.compile(
|
||
r"🛠️ Tool: `(file_write|file_patch|file_read|code_run)` 📥 args:\n`{4}\w*\n(.*?)\n`{4}"
|
||
# Also swallow the dispatch output fence that immediately follows
|
||
# (`[Action]…`/`{status}` lines) — its info moves into the diff header.
|
||
r"(?:\s*`{5}\n(.*?)\n`{5})?",
|
||
re.DOTALL,
|
||
)
|
||
|
||
|
||
# Any other tool's verbose block — same shape, any tool name. Substituted AFTER
|
||
# `_VERBOSE_WRITE_RE` (write blocks are already sentinels by then), purely to
|
||
# add the left margin while keeping the Rich-markdown look unchanged.
|
||
_VERBOSE_TOOL_RE = re.compile(
|
||
r"🛠️ Tool: `(\w+)` 📥 args:\n`{4}\w*\n.*?\n`{4}"
|
||
r"(?:\s*`{5}\n.*?\n`{5})?",
|
||
re.DOTALL,
|
||
)
|
||
|
||
|
||
def _fence_status(fence_body):
|
||
"""Per-block outcome from the dispatch-output fence trailing a write call
|
||
(a `{"status": ..., "msg": ...}` line). This is the only *per-occurrence*
|
||
signal: `_WRITE_CAP` is keyed by args, so two identical calls (fail → mkdir
|
||
→ retry) share one capture and its stored status is last-write-wins."""
|
||
for line in (fence_body or "").splitlines():
|
||
try:
|
||
d = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
if isinstance(d, dict) and "status" in d:
|
||
return d.get("status"), str(d.get("msg") or "")
|
||
return None
|
||
|
||
|
||
def _sgr_fg(hex_color: str) -> str:
|
||
r, g, b = _hex_rgb(hex_color)
|
||
return f"\x1b[38;2;{r};{g};{b}m"
|
||
|
||
|
||
def _sgr_bg(hex_color: str) -> str:
|
||
r, g, b = _hex_rgb(hex_color)
|
||
return f"\x1b[48;2;{r};{g};{b}m"
|
||
|
||
|
||
_SGR_RESET = "\x1b[0m"
|
||
|
||
|
||
def _chop_cells(s: str, w: int) -> list[str]:
|
||
"""Split into chunks each ≤ w display cells (CJK counts as 2)."""
|
||
from rich.cells import cell_len
|
||
out, cur, cw = [], [], 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if cur and cw + c > w:
|
||
out.append("".join(cur)); cur, cw = [ch], c
|
||
else:
|
||
cur.append(ch); cw += c
|
||
if cur:
|
||
out.append("".join(cur))
|
||
return out or [""]
|
||
|
||
|
||
def _cell_tail(s: str, n: int) -> str:
|
||
"""Keep the tail of `s` within n cells, prefixing … if truncated."""
|
||
from rich.cells import cell_len
|
||
if cell_len(s) <= n:
|
||
return s
|
||
if n <= 1:
|
||
return "…"
|
||
out, w = [], 0
|
||
for ch in reversed(s):
|
||
c = cell_len(ch)
|
||
if w + c > n - 1:
|
||
break
|
||
out.append(ch); w += c
|
||
return "…" + "".join(reversed(out))
|
||
|
||
|
||
def _cell_head(s: str, n: int) -> str:
|
||
"""Keep the head of `s` within n cells, suffixing … if truncated.
|
||
(省略末尾 — names: keep the start, drop the tail.)"""
|
||
from rich.cells import cell_len
|
||
if cell_len(s) <= n:
|
||
return s
|
||
if n <= 1:
|
||
return "…"
|
||
out, w = [], 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if w + c > n - 1:
|
||
break
|
||
out.append(ch); w += c
|
||
return "".join(out) + "…"
|
||
|
||
|
||
def _cell_mid(s: str, n: int) -> str:
|
||
"""Keep head+tail of `s` within n cells, eliding the middle with ….
|
||
(省略中间 — paths: the project root and the leaf both stay visible.)"""
|
||
from rich.cells import cell_len
|
||
if cell_len(s) <= n:
|
||
return s
|
||
if n <= 1:
|
||
return "…"
|
||
avail = n - 1 # reserve one cell for …
|
||
head_budget = avail - avail // 2 # head gets the rounding bias
|
||
tail_budget = avail // 2
|
||
head, w = [], 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if w + c > head_budget:
|
||
break
|
||
head.append(ch); w += c
|
||
tail, w = [], 0
|
||
for ch in reversed(s):
|
||
c = cell_len(ch)
|
||
if w + c > tail_budget:
|
||
break
|
||
tail.append(ch); w += c
|
||
return "".join(head) + "…" + "".join(reversed(tail))
|
||
|
||
|
||
class _CardWriter:
|
||
"""Accumulates a tool card's two parallel streams: `ansi` (narrow, colored,
|
||
later margined) and `plain` (wide, the copy source — no margin). `row()`
|
||
takes (text, color) segments for simple uniform lines; `raw()` takes
|
||
pre-built ansi/plain for rows with gutters/backgrounds. `finish()` applies
|
||
the visual-only left margin to the ANSI stream ONLY (keeping it out of the
|
||
copy source) and joins both. Shared by all three tool cards so the _hrow /
|
||
margin boilerplate lives in one place."""
|
||
__slots__ = ("ansi", "plain")
|
||
|
||
def __init__(self):
|
||
self.ansi, self.plain = [], []
|
||
|
||
def row(self, *segs):
|
||
self.plain.append("".join(s for s, _ in segs))
|
||
self.ansi.append("".join(f"{_sgr_fg(c)}{s}" for s, c in segs) + _SGR_RESET)
|
||
|
||
def raw(self, ansi_line, plain_line):
|
||
self.ansi.append(ansi_line)
|
||
self.plain.append(plain_line)
|
||
|
||
def finish(self):
|
||
m = " " * _DIFF_MARGIN
|
||
return "\n".join(m + l for l in self.ansi), "\n".join(self.plain)
|
||
|
||
|
||
def _card_status_row(cw, base_segs, err, detail, width):
|
||
"""Emit a card's status-bearing line: `base_segs` followed by an inline red
|
||
`✗ detail` if it fits, else a bare `✗` with `detail` wrapped onto indented
|
||
red lines. `detail=''` with `err=True` yields a bare ✗. The single place
|
||
the three cards' failure-header layout lives."""
|
||
from rich.cells import cell_len
|
||
col = _diff_colors()
|
||
base_plain = "".join(s for s, _ in base_segs)
|
||
mark = (f" ✗ {detail}" if detail else " ✗") if err else ""
|
||
inline = (not detail) or cell_len(base_plain + mark) <= width
|
||
cw.row(*base_segs,
|
||
*([(mark if inline else " ✗", col["del_sign"])] if err else []))
|
||
if err and not inline and detail:
|
||
for chunk in _chop_cells(detail, max(8, width - 3)):
|
||
cw.row((" " + chunk, col["del_sign"]))
|
||
|
||
|
||
def _card_note(cw, text):
|
||
"""A card annotation line (truncation / omission notes), head color. One
|
||
style so every card's `… +N 行…` note reads alike."""
|
||
cw.row((text, _diff_colors()["head"]))
|
||
|
||
|
||
def _emit_gutter(cw, content_w, segs, glyph_first, glyph_rest):
|
||
"""Emit (text, color) segments behind a 2-col gutter: `glyph_first` on the
|
||
block's first physical row, `glyph_rest` after; soft-wraps each line, gutter
|
||
always dim. The code card's command (`│ `) and output (`└ `/` `) blocks
|
||
share it."""
|
||
col = _diff_colors()
|
||
first = True
|
||
for text, color in segs:
|
||
for chunk in _chop_cells(text, content_w):
|
||
g = glyph_first if first else glyph_rest
|
||
cw.raw(f"{_sgr_fg(col['gutter'])}{g}{_sgr_fg(color)}{chunk}{_SGR_RESET}",
|
||
f"{g}{chunk}".rstrip())
|
||
first = False
|
||
|
||
|
||
def _diff_colors() -> dict:
|
||
"""Derive diff colors from the live `_palette`. Backgrounds/foregrounds are
|
||
blended *from the theme's own bg/fg*, so the same code yields dark tints on
|
||
a dark theme and light tints on a light one — no explicit dark/light fork."""
|
||
p = _palette
|
||
bg = p.get("bg") or "#0d1117"
|
||
fg = p.get("fg") or "#c9d1d9"
|
||
# Fixed add/del hues so the diff semantics stay green/red on any theme
|
||
# (palette["green"] falls back to a theme's primary, which isn't always
|
||
# green). Backgrounds are still blended *from the theme bg*, so the tint
|
||
# auto-darkens on dark themes and lightens on light ones.
|
||
green = "#3fb950"
|
||
red = "#e5534b"
|
||
return {
|
||
"bg": bg,
|
||
"add_bg": _mix(bg, green, 0.18),
|
||
"del_bg": _mix(bg, red, 0.18),
|
||
"add_fg": _mix(fg, green, 0.50),
|
||
"del_fg": _mix(fg, red, 0.50),
|
||
"add_sign": green,
|
||
"del_sign": red,
|
||
"ctx_fg": _mix(bg, fg, 0.78),
|
||
"gutter": p.get("dim") or _mix(bg, fg, 0.35),
|
||
"head": p.get("muted") or _mix(bg, fg, 0.55),
|
||
}
|
||
|
||
|
||
def _diff_hunks(old: str, new: str, context: int = _DIFF_CONTEXT) -> list[list[tuple]]:
|
||
"""Group a diff into hunks of `(sign, line_no, text)` rows. `sign` is one of
|
||
'+'/'-'/' '; `line_no` is the new-file number for '+'/' ' and the old-file
|
||
number for '-'. Uses difflib's grouped opcodes so each hunk carries `context`
|
||
unchanged lines around the change, and gaps collapse into separate hunks."""
|
||
from difflib import SequenceMatcher
|
||
a, b = old.splitlines(), new.splitlines()
|
||
sm = SequenceMatcher(None, a, b, autojunk=False)
|
||
hunks: list[list[tuple]] = []
|
||
for group in sm.get_grouped_opcodes(context):
|
||
rows: list[tuple] = []
|
||
for tag, i1, i2, j1, j2 in group:
|
||
if tag == "equal":
|
||
for k in range(i2 - i1):
|
||
rows.append((" ", j1 + k + 1, b[j1 + k]))
|
||
else:
|
||
for k in range(i1, i2):
|
||
rows.append(("-", k + 1, a[k]))
|
||
for k in range(j1, j2):
|
||
rows.append(("+", k + 1, b[k]))
|
||
if rows:
|
||
hunks.append(rows)
|
||
return hunks
|
||
|
||
|
||
def _insert_hunk(text: str, start_no: int = 1) -> list[list[tuple]]:
|
||
"""A single all-'+' hunk for a brand-new / appended / prepended file."""
|
||
return [[("+", start_no + k, line) for k, line in enumerate(text.splitlines())]]
|
||
|
||
|
||
def _render_write_diff(name: str, args: dict, existed: bool, old: str, width: int,
|
||
status: str = None, msg: str = ""):
|
||
"""Render a file_write/file_patch call as a themed diff.
|
||
|
||
`existed`/`old` describe the target before the write (captured by the hook).
|
||
For file_patch, `old` (the pre-write full file) lets us render a whole-file
|
||
diff with real line numbers + surrounding context; without it we fall back to
|
||
diffing just the old/new fragment. `status` is the tool outcome
|
||
('success'/'error'); success stays silent, error tags the header with a red ✗
|
||
(+msg) so the noisy `[Action]`/`{status}` lines can be dropped. Returns
|
||
`(ansi, plain)` parallel strings, or `None` to fall back."""
|
||
args = args or {}
|
||
old = old or ""
|
||
width = max(20, width - _DIFF_MARGIN) # body math sees the post-margin width
|
||
path = str(args.get("path") or "").strip()
|
||
if not path:
|
||
return None
|
||
|
||
display_mode = None
|
||
if name == "file_patch":
|
||
old_c = str(args.get("old_content") or "")
|
||
new_c = str(args.get("new_content") or "")
|
||
if not old_c and not new_c:
|
||
return None
|
||
# Whole-file diff (real line numbers + context around the change) when we
|
||
# have the pre-write full file and old_content matches exactly once;
|
||
# otherwise diff just the fragment (no file context available, e.g.
|
||
# /continue without a tracked full file).
|
||
if existed and old and old_c and old.count(old_c) == 1:
|
||
hunks = _diff_hunks(old, old.replace(old_c, new_c, 1))
|
||
else:
|
||
hunks = _diff_hunks(old_c, new_c)
|
||
elif name == "file_write":
|
||
new = args.get("content")
|
||
if new is None:
|
||
return None # content lives in the response body, not args — can't diff
|
||
new = str(new)
|
||
mode = str(args.get("mode") or "overwrite")
|
||
display_mode = mode
|
||
if mode in ("append", "prepend"):
|
||
if existed:
|
||
# Show append/prepend with the same surrounding unchanged context
|
||
# as file_patch by diffing the whole pre/post file, instead of an
|
||
# isolated all-green insertion block.
|
||
hunks = _diff_hunks(old, old + new if mode == "append" else new + old)
|
||
else:
|
||
hunks = _insert_hunk(new)
|
||
elif existed and old != new:
|
||
hunks = _diff_hunks(old, new)
|
||
elif existed:
|
||
return None # overwrite with identical content — nothing to diff
|
||
else:
|
||
hunks = _insert_hunk(new)
|
||
else:
|
||
return None
|
||
|
||
if not any(hunks):
|
||
return None
|
||
|
||
added = sum(1 for h in hunks for r in h if r[0] == "+")
|
||
removed = sum(1 for h in hunks for r in h if r[0] == "-")
|
||
max_no = max((r[1] for h in hunks for r in h), default=1)
|
||
lw = max(2, len(str(max_no)))
|
||
content_w = max(8, width - (lw + 3)) # "<no> <sign> " prefix
|
||
col = _diff_colors()
|
||
|
||
from rich.cells import cell_len, set_cell_size
|
||
|
||
cw = _CardWriter()
|
||
|
||
# Header: compact tool-call line + tree child path/counts. Keep file_patch
|
||
# bare, but show the file_write mode as file_write(overwrite|append|prepend).
|
||
# Path is tail-truncated by display width so the header never wraps.
|
||
err = (status == "error")
|
||
msg = " ".join(str(msg or "").split())
|
||
tool_label = name if name == "file_patch" else f"{name}({display_mode})"
|
||
add_seg, del_seg = f" +{added}", f" -{removed}"
|
||
child_prefix = "└─ "
|
||
avail = width - cell_len(child_prefix + add_seg + del_seg) - (cell_len(" ✗") if err else 0)
|
||
shown = _cell_tail(path, max(4, avail))
|
||
cw.row((tool_label, col["head"]))
|
||
_card_status_row(cw, [(child_prefix + shown, col["head"]),
|
||
(add_seg, col["add_sign"]), (del_seg, col["del_sign"])],
|
||
err, msg, width)
|
||
|
||
def emit(sign: str, no, text: str):
|
||
text = (text or "").replace("\t", " ").replace("\x1b", "")
|
||
no_str = (str(no) if no is not None else "").rjust(lw)
|
||
if sign == "+":
|
||
row_bg, sign_c, text_c = col["add_bg"], col["add_sign"], col["add_fg"]
|
||
elif sign == "-":
|
||
row_bg, sign_c, text_c = col["del_bg"], col["del_sign"], col["del_fg"]
|
||
else:
|
||
row_bg, sign_c, text_c = None, col["gutter"], col["ctx_fg"]
|
||
bg = _sgr_bg(row_bg) if row_bg else ""
|
||
# Active soft-wrap: split long lines into width-sized physical rows so
|
||
# each row carries its own full-width background (Textual's own wrap
|
||
# leaves continuation rows unpainted). Continuation rows get a blank
|
||
# gutter so content stays aligned under the first row.
|
||
for ci, chunk in enumerate(_chop_cells(text, content_w)):
|
||
body = set_cell_size(chunk, content_w) # pad to exactly content_w cells
|
||
if ci == 0:
|
||
gutter = f"{_sgr_fg(col['gutter'])}{no_str} {_sgr_fg(sign_c)}{sign} "
|
||
cw.raw(f"{bg}{gutter}{_sgr_fg(text_c)}{body}{_SGR_RESET}",
|
||
f"{no_str} {sign} {chunk}")
|
||
else:
|
||
gutter = " " * (lw + 3)
|
||
cw.raw(f"{bg}{gutter}{_sgr_fg(text_c)}{body}{_SGR_RESET}",
|
||
f"{' ' * (lw + 3)}{chunk}")
|
||
|
||
rows_emitted = 0
|
||
truncated = 0
|
||
for hi, hunk in enumerate(hunks):
|
||
if hi > 0:
|
||
cw.raw(f"{_sgr_fg(col['gutter'])}{'⋯'.rjust(lw + 2)}{_SGR_RESET}",
|
||
"⋯".rjust(lw + 2))
|
||
for sign, no, text in hunk:
|
||
if rows_emitted >= _DIFF_MAX_ROWS:
|
||
truncated += 1
|
||
continue
|
||
emit(sign, no, text)
|
||
rows_emitted += 1
|
||
if truncated:
|
||
_card_note(cw, f"… +{truncated} 行未显示")
|
||
|
||
return cw.finish()
|
||
|
||
|
||
_READ_MAX_ROWS = 10
|
||
|
||
# Lines of a file_read result that exist for the LLM, not the user: the
|
||
# show_linenos preamble, the `[FILE] N lines | PARTIAL …` header, truncation
|
||
# tips, and the keyword-fallback explanation. All stripped from the card.
|
||
_READ_NOISE_RE = re.compile(
|
||
r"^(?:由于设置了show_linenos|\[FILE[\] ]|\[FILE PARTIAL|(某些行被截断"
|
||
r"|Keyword '.*' not found after line )"
|
||
)
|
||
|
||
|
||
def _render_read_card(args, content, width):
|
||
"""Render a file_read call like the write cards: header + the lines the
|
||
tool actually returned, LLM-facing chrome stripped (see _READ_NOISE_RE).
|
||
The gutter always shows line numbers when they're knowable: parsed from the
|
||
`N|` prefixes (show_linenos), else synthesized from `start` for sequential
|
||
reads — numbers and content are decoupled; the only number the renderer
|
||
can't derive is a keyword window's position without `N|`. smart_format's
|
||
`[omitted long content]` hole renders as a `⋯` row and ends the gutter for
|
||
the rest of the card (parsed and synthesized alike — the cut is mid-line,
|
||
so post-hole numbers mislead).
|
||
Body capped at _READ_MAX_ROWS with a tail count. `Error:` results render
|
||
header-✗ only — the Did-you-mean suggestions are for the agent's
|
||
self-correction, not the user. Returns (ansi, plain) or None."""
|
||
args = args or {}
|
||
width = max(20, width - _DIFF_MARGIN)
|
||
path = str(args.get("path") or "").strip()
|
||
if content is None or not path:
|
||
return None
|
||
from rich.cells import cell_len
|
||
col = _diff_colors()
|
||
text = str(content)
|
||
err = text.startswith("Error:")
|
||
|
||
rows: list[tuple] = [] # (lineno_or_None, text)
|
||
linenos = bool(args.get("show_linenos", True))
|
||
if not err:
|
||
# Strip LLM-facing chrome, then trim blank runs at both ends
|
||
# (separators around the stripped noise) BEFORE numbering; interior
|
||
# blanks are real file content and stay.
|
||
lines = [ln for ln in text.split("\n") if not _READ_NOISE_RE.match(ln.strip())]
|
||
while lines and not lines[0].strip():
|
||
lines.pop(0)
|
||
while lines and not lines[-1].strip():
|
||
lines.pop()
|
||
# Without `N|` prefixes, synthesize the gutter: a sequential read's
|
||
# lines are start, start+1, … Stay blank where numbers would be
|
||
# guesses: a keyword window's position is unknown (unless the keyword
|
||
# fell back to a sequential read).
|
||
fell_back = "not found after line" in text.split("\n", 1)[0]
|
||
synth = (not linenos) and (not args.get("keyword") or fell_back)
|
||
no = int(args.get("start") or 1) if synth else None
|
||
holed = False
|
||
for ln in lines:
|
||
if ln.strip() == "[omitted long content]":
|
||
rows.append((None, "⋯"))
|
||
no, holed = None, True # gutter ends at the hole: the cut is
|
||
continue # mid-line, post-hole numbers mislead
|
||
m = re.match(r"^(\d+)\|(.*)$", ln) if linenos else None
|
||
if m:
|
||
rows.append((None if holed else int(m.group(1)), m.group(2)))
|
||
else:
|
||
rows.append((no, ln))
|
||
if no is not None:
|
||
no += 1
|
||
if not rows:
|
||
return None
|
||
|
||
msg = " ".join(text.split("\n")[0][len("Error:"):].split()) if err else ""
|
||
child_prefix = "└─ "
|
||
avail = width - cell_len(child_prefix) - (cell_len(" ✗") if err else 0)
|
||
shown = _cell_tail(path, max(4, avail))
|
||
|
||
cw = _CardWriter()
|
||
cw.row(("file_read", col["head"]))
|
||
_card_status_row(cw, [(child_prefix + shown, col["head"])], err, msg, width)
|
||
|
||
shown_rows = rows[:_READ_MAX_ROWS]
|
||
hidden = len(rows) - len(shown_rows)
|
||
max_no = max((no for no, _ in shown_rows if no), default=0)
|
||
lw = max(2, len(str(max_no or 1)))
|
||
content_w = max(8, width - (lw + 3))
|
||
for no, body in shown_rows:
|
||
body = (body or "").replace("\t", " ").replace("\x1b", "")
|
||
no_str = (str(no) if no is not None else "").rjust(lw)
|
||
for ci, chunk in enumerate(_chop_cells(body, content_w)):
|
||
gutter = f"{no_str} " if ci == 0 else " " * (lw + 3)
|
||
cw.raw(f"{_sgr_fg(col['gutter'])}{gutter}"
|
||
f"{_sgr_fg(col['ctx_fg'])}{chunk}{_SGR_RESET}",
|
||
f"{gutter}{chunk}".rstrip())
|
||
if hidden > 0:
|
||
_card_note(cw, f"… +{hidden} 行未显示")
|
||
|
||
return cw.finish()
|
||
|
||
|
||
_CODE_MAX_CODE_ROWS = 20 # 代码段从头展示
|
||
_CODE_MAX_OUT_ROWS = 15 # 输出段保尾展示(报错 traceback 关键在末尾)
|
||
_CODE_DEFAULT_CWD = {"", ".", "./", ".\\"}
|
||
|
||
|
||
def _code_parse_data(data):
|
||
"""把 code_run 的 StepOutcome.data 归一成 (out_text, exit_code, err_msg, is_err)。
|
||
兼容三种返回形态:
|
||
- 正常/进程异常 dict: {"status","stdout","exit_code"} 或 {"status":"error","msg"}
|
||
- inline_eval 字符串: eval 的 repr,或 'Error: ...'
|
||
- 代码缺失字符串: '[Error] Code missing ...'
|
||
out_text 只取真正的输出(dict 的 stdout / 字符串结果),绝不回退 msg——msg
|
||
是 dict 进程异常的摘要,单独走 err_msg 进头部,避免和输出段重复显示。
|
||
is_err 标记失败但无独立 detail 的情形(字符串错误本身就是输出),让头部至少
|
||
打个裸 ✗。out_text 已规整 CRLF→LF。"""
|
||
exit_code, err_msg, is_err = None, "", False
|
||
if isinstance(data, dict):
|
||
out_text = data.get("stdout") or ""
|
||
exit_code = data.get("exit_code")
|
||
if data.get("status") == "error":
|
||
is_err = True
|
||
err_msg = str(data.get("msg") or "")
|
||
elif isinstance(data, str):
|
||
out_text = data
|
||
if data.lstrip().startswith(("Error:", "[Error]")):
|
||
is_err = True # 内容即错误,留给输出段;头部裸 ✗
|
||
else:
|
||
out_text = "" if data is None else str(data)
|
||
out_text = str(out_text).replace("\r\n", "\n").replace("\r", "\n")
|
||
return out_text, exit_code, err_msg, is_err
|
||
|
||
|
||
def _render_code_card(args, data, width):
|
||
"""CC/Codex-style gutter card for code_run — no borders, structure comes
|
||
from dim gutters (Codex's exec cell look, adapted to our card family):
|
||
|
||
code_run(python) ← header; red `✗ Exit 1 …` on failure
|
||
│ import os ← command: `│ ` gutter (plain text)
|
||
│ print(run())
|
||
└ epoch 1 loss 0.3 ← output: `└ ` + dim text, continuation
|
||
… +12 行已省略 indented 2; TAIL-kept (traceback ends
|
||
ValueError: boom matter most — differs from CC/Codex)
|
||
|
||
The command segment renders only when the code rode in via `script`/`code`
|
||
args; a body ```block``` source is already rendered above by markdown.
|
||
Returns (ansi, plain); tolerant of every data shape (_code_parse_data)."""
|
||
args = args or {}
|
||
width = max(20, width - _DIFF_MARGIN)
|
||
col = _diff_colors()
|
||
ctype = str(args.get("type") or "python").strip() or "python"
|
||
code = str(args.get("script") or args.get("code") or "")
|
||
out_text, exit_code, err_msg, is_err = _code_parse_data(data)
|
||
|
||
# Timeout / manual-stop markers live inside stdout (ga.py:72-73).
|
||
note_extra = ("超时" if "[Timeout Error]" in out_text
|
||
else "已停止" if "[Stopped]" in out_text else "")
|
||
# Header failure detail. exit_code may be None (inline_eval has none, a
|
||
# killed process polls None) — only a non-zero *integer* counts. err_msg is
|
||
# the dict-error summary; a string-error's text stays in the output body, so
|
||
# it sets is_err (bare ✗) but contributes no detail (avoids duplication).
|
||
parts = []
|
||
if isinstance(exit_code, int) and exit_code != 0:
|
||
parts.append(f"Exit {exit_code}")
|
||
if note_extra:
|
||
parts.append(note_extra)
|
||
if err_msg:
|
||
m = err_msg
|
||
for p in ("Error:", "[Error]"):
|
||
if m.startswith(p):
|
||
m = m[len(p):].strip()
|
||
if m:
|
||
parts.append(m)
|
||
err = is_err or bool(parts)
|
||
detail = " ".join(" · ".join(parts).split())
|
||
|
||
cw = _CardWriter()
|
||
_card_status_row(cw, [(f"code_run({ctype})", col["head"])], err, detail, width)
|
||
cwd = str(args.get("cwd") or "").strip()
|
||
if cwd and cwd not in _CODE_DEFAULT_CWD:
|
||
for chunk in _chop_cells(f"cwd: {cwd}", max(8, width)):
|
||
cw.row((chunk, col["gutter"]))
|
||
|
||
content_w = max(8, width - 2) # behind the 2-col gutter
|
||
|
||
# Command segment: `│ ` gutter on every physical line (plain text). Only
|
||
# when the code rode in via script/code args (a body ```block``` source is
|
||
# already rendered above by markdown).
|
||
code_lines = code.split("\n") if code.strip() else []
|
||
if code_lines:
|
||
shown = [l.replace("\t", " ").replace("\x1b", "")
|
||
for l in code_lines[:_CODE_MAX_CODE_ROWS]]
|
||
segs = [(l, col["ctx_fg"]) for l in shown]
|
||
extra = len(code_lines) - len(shown)
|
||
if extra > 0:
|
||
segs.append((f"… +{extra} 行未显示", col["head"]))
|
||
_emit_gutter(cw, content_w, segs, "│ ", "│ ")
|
||
|
||
# Output segment: `└ ` first row, ` ` after, dim. Tail-kept — the omission
|
||
# note leads, the tail lines follow (a traceback's last lines matter most).
|
||
out_lines = [l.replace("\t", " ").replace("\x1b", "")
|
||
for l in out_text.split("\n")]
|
||
while out_lines and not out_lines[-1].strip():
|
||
out_lines.pop()
|
||
segs = []
|
||
omitted = len(out_lines) - _CODE_MAX_OUT_ROWS
|
||
if omitted > 0:
|
||
segs.append((f"… +{omitted} 行已省略", col["head"]))
|
||
out_lines = out_lines[-_CODE_MAX_OUT_ROWS:]
|
||
if not out_lines and not segs:
|
||
segs.append(("(无输出)", col["gutter"]))
|
||
segs += [(l, col["ctx_fg"]) for l in out_lines]
|
||
_emit_gutter(cw, content_w, segs, "└ ", " ")
|
||
|
||
return cw.finish()
|
||
|
||
|
||
def _extract_user_text(entry: dict) -> str:
|
||
c = entry.get("content") if isinstance(entry, dict) else None
|
||
if isinstance(c, str):
|
||
return c
|
||
if isinstance(c, list):
|
||
parts = [b.get("text", "") for b in c
|
||
if isinstance(b, dict) and b.get("type") == "text"]
|
||
return "\n".join(p for p in parts if p)
|
||
return ""
|
||
|
||
|
||
def fold_turns(text: str) -> list[dict]:
|
||
placeholders: list[str] = []
|
||
def stash(m):
|
||
placeholders.append(m.group(0))
|
||
return f"\x00PH{len(placeholders) - 1}\x00"
|
||
# Line-anchored so backticks embedded in tool output (e.g. `N|\`\`\`\``
|
||
# gutter from file_read) don't pair with later real fences.
|
||
safe = re.sub(r"^`{4,}.*?^`{4,}\n?", stash, text, flags=re.DOTALL | re.MULTILINE)
|
||
parts = re.split(r"(\**(?:LLM Running \()?Turn \d+\)? \.\.\.\**)", safe)
|
||
parts = [re.sub(r"\x00PH(\d+)\x00", lambda m: placeholders[int(m.group(1))], p) for p in parts]
|
||
if len(parts) < 4:
|
||
return [{"type": "text", "content": text}]
|
||
segs: list[dict] = []
|
||
if parts[0].strip():
|
||
segs.append({"type": "text", "content": parts[0]})
|
||
turns = [(parts[i], parts[i + 1] if i + 1 < len(parts) else "")
|
||
for i in range(1, len(parts), 2)]
|
||
for idx, (marker, content) in enumerate(turns):
|
||
if idx == len(turns) - 1:
|
||
segs.append({"type": "text", "content": marker + content})
|
||
continue
|
||
cleaned = re.sub(r"`{3,}.*?`{3,}|<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
|
||
ms = re.findall(r"<summary>\s*((?:(?!<summary>).)*?)\s*</summary>", cleaned, re.DOTALL)
|
||
title = (ms[0].strip().split("\n", 1)[0] if ms
|
||
else re.sub(r",?\s*args:.*$", "", cleaned.strip().split("\n", 1)[0] or marker.strip("*")))
|
||
if len(title) > 72: title = title[:72] + "..."
|
||
segs.append({"type": "fold", "title": title, "content": content})
|
||
return segs
|
||
|
||
|
||
def render_folded_text(text: str) -> str:
|
||
out = []
|
||
for seg in fold_turns(text):
|
||
out.append(f"\n▸ {seg.get('title') or 'completed turn'}\n\n"
|
||
if seg["type"] == "fold" else seg.get("content", ""))
|
||
return "".join(out)
|
||
|
||
|
||
class HardBreakMarkdown(Markdown):
|
||
# softbreak → hardbreak so multi-line agent logs aren't collapsed into one line.
|
||
def __init__(self, markup, **kwargs):
|
||
super().__init__(markup, **kwargs)
|
||
self._soft_to_hard(self.parsed)
|
||
|
||
@staticmethod
|
||
def _soft_to_hard(tokens):
|
||
for tok in tokens:
|
||
if tok.type == "softbreak":
|
||
tok.type = "hardbreak"
|
||
if tok.children:
|
||
HardBreakMarkdown._soft_to_hard(tok.children)
|
||
|
||
|
||
# Rich's Markdown.TableElement adds columns without specifying `overflow`,
|
||
# so Rich Table falls back to "ellipsis" — long cell contents get truncated
|
||
# with `…` in narrow terminals. Patch to use "fold" instead so cells wrap
|
||
# across multiple lines and full content stays visible.
|
||
def _patch_markdown_table_overflow():
|
||
import rich.markdown as _rmd
|
||
from rich.table import Table as _RichTable
|
||
from rich import box as _rich_box
|
||
|
||
def _table_render(self, console, options):
|
||
# `markdown.table.border` / `markdown.table.header` were Rich default
|
||
# styles in older releases but have been dropped from DEFAULT_STYLES;
|
||
# resolving the bare names now raises MissingStyle. Resolve with a
|
||
# fallback so a table never aborts the whole Markdown render — which
|
||
# would drop the entire message to raw, unrendered text.
|
||
table = _RichTable(
|
||
box=_rich_box.SIMPLE,
|
||
pad_edge=False,
|
||
style=console.get_style("markdown.table.border", default="none"),
|
||
show_edge=True,
|
||
collapse_padding=True,
|
||
)
|
||
if self.header is not None and self.header.row is not None:
|
||
header_style = console.get_style("markdown.table.header", default="bold")
|
||
for column in self.header.row.cells:
|
||
heading = column.content.copy()
|
||
heading.stylize(header_style)
|
||
table.add_column(heading, overflow="fold")
|
||
if self.body is not None:
|
||
for row in self.body.rows:
|
||
row_content = [element.content for element in row.cells]
|
||
table.add_row(*row_content)
|
||
yield table
|
||
|
||
_rmd.TableElement.__rich_console__ = _table_render
|
||
|
||
|
||
_patch_markdown_table_overflow()
|
||
|
||
|
||
# Rich/Textual wrap treats a continuous CJK run as one indivisible word and
|
||
# bumps it whole to the next line when it doesn't fit the remaining space,
|
||
# leaving the line tail padded and producing wraps like "AI ↩ 助手...". We patch
|
||
# every binding of divide_line/compute_wrap_offsets so CJK-bearing chunks pack
|
||
# leading chars into the remainder then fold the rest at full width.
|
||
# Covers CJK Unified Ideographs, Hangul Syllables, fullwidth/halfwidth forms.
|
||
_CJK_WRAP_RE = re.compile(
|
||
r"[ -鿿" # CJK punctuation through Unified Ideographs
|
||
r"가-" # Hangul Syllables
|
||
r"-]" # Halfwidth / Fullwidth Forms
|
||
)
|
||
|
||
|
||
def _fold_chunk_cells(chunk, width, char_width_fn, line_offset=0):
|
||
"""Walk chunk char-by-char; return (breaks_relative_to_chunk, final_offset).
|
||
|
||
A break at index i means a newline lands before chunk[i]. line_offset is the
|
||
column where chunk[0] starts. char_width_fn must be called in order — it may
|
||
carry state (e.g. tab section index).
|
||
"""
|
||
breaks: list[int] = []
|
||
for i, ch in enumerate(chunk):
|
||
cw = char_width_fn(ch)
|
||
if line_offset > 0 and line_offset + cw > width:
|
||
breaks.append(i)
|
||
line_offset = cw
|
||
else:
|
||
line_offset += cw
|
||
return breaks, line_offset
|
||
|
||
|
||
def _cjk_divide_line(text: str, width: int, fold: bool = True) -> list[int]:
|
||
from rich._wrap import words as _words
|
||
from rich.cells import cell_len as _clen
|
||
|
||
breaks: list[int] = []
|
||
cell_offset = 0
|
||
for start, _end, word in _words(text):
|
||
word_length = _clen(word.rstrip())
|
||
if width - cell_offset >= word_length:
|
||
cell_offset += _clen(word)
|
||
continue
|
||
if not fold:
|
||
if cell_offset:
|
||
breaks.append(start)
|
||
cell_offset = _clen(word)
|
||
continue
|
||
|
||
has_cjk = bool(_CJK_WRAP_RE.search(word))
|
||
if not has_cjk and word_length <= width:
|
||
if cell_offset:
|
||
breaks.append(start)
|
||
cell_offset = _clen(word)
|
||
continue
|
||
|
||
if has_cjk:
|
||
line_offset = cell_offset
|
||
else:
|
||
if cell_offset:
|
||
breaks.append(start)
|
||
line_offset = 0
|
||
sub_breaks, cell_offset = _fold_chunk_cells(
|
||
word, width, _clen, line_offset
|
||
)
|
||
breaks.extend(start + b for b in sub_breaks)
|
||
return breaks
|
||
|
||
|
||
def _cjk_compute_wrap_offsets(text, width, tab_size, fold=True,
|
||
precomputed_tab_sections=None):
|
||
from rich.cells import get_character_cell_size
|
||
from textual._cells import cell_len as _clen
|
||
from textual._loop import loop_last
|
||
from textual.expand_tabs import get_tab_widths
|
||
|
||
tab_size = min(tab_size, width)
|
||
tab_sections = precomputed_tab_sections or get_tab_widths(text, tab_size)
|
||
|
||
cumulative_widths: list[int] = []
|
||
cumulative_width = 0
|
||
for last, (tab_section, tab_width) in loop_last(tab_sections):
|
||
cumulative_widths.extend([cumulative_width] * (len(tab_section) + int(bool(tab_width))))
|
||
cumulative_width += tab_width
|
||
if last:
|
||
cumulative_widths.append(cumulative_width)
|
||
|
||
tab_idx = [0]
|
||
def char_width(ch):
|
||
if ch == "\t":
|
||
cw = tab_sections[tab_idx[0]][1]
|
||
tab_idx[0] += 1
|
||
return cw
|
||
return get_character_cell_size(ch)
|
||
|
||
breaks: list[int] = []
|
||
cell_offset = 0
|
||
pos = 0
|
||
chunk_re = re.compile(r"\S+\s*|\s+")
|
||
while pos < len(text):
|
||
m = chunk_re.match(text, pos)
|
||
if m is None:
|
||
break
|
||
start, end = m.span()
|
||
chunk = m.group(0)
|
||
pos = end
|
||
chunk_width = _clen(chunk) + (cumulative_widths[end] - cumulative_widths[start])
|
||
|
||
if width - cell_offset >= chunk_width:
|
||
cell_offset += chunk_width
|
||
continue
|
||
if not fold:
|
||
if cell_offset:
|
||
breaks.append(start)
|
||
cell_offset = chunk_width
|
||
continue
|
||
|
||
has_cjk = bool(_CJK_WRAP_RE.search(chunk))
|
||
if not has_cjk and chunk_width <= width:
|
||
if cell_offset:
|
||
breaks.append(start)
|
||
cell_offset = chunk_width
|
||
continue
|
||
|
||
if has_cjk:
|
||
line_offset = cell_offset
|
||
else:
|
||
if cell_offset:
|
||
breaks.append(start)
|
||
line_offset = 0
|
||
sub_breaks, cell_offset = _fold_chunk_cells(chunk, width, char_width, line_offset)
|
||
breaks.extend(start + b for b in sub_breaks)
|
||
return breaks
|
||
|
||
|
||
def _install_cjk_wrap() -> None:
|
||
# `from X import fn` copies the binding into the importer's namespace, so a
|
||
# rebind on the source module misses every holder. Patch each one explicitly.
|
||
import rich._wrap as _rw
|
||
import rich.text as _rt
|
||
import textual.content as _tc
|
||
import textual._wrap as _tw
|
||
import textual.document._wrapped_document as _twd
|
||
if getattr(_cjk_divide_line, "_cjk_patched", False):
|
||
return
|
||
_cjk_divide_line._cjk_patched = True
|
||
_rw.divide_line = _cjk_divide_line
|
||
_rt.divide_line = _cjk_divide_line
|
||
_tc.divide_line = _cjk_divide_line
|
||
_tw.compute_wrap_offsets = _cjk_compute_wrap_offsets
|
||
_twd.compute_wrap_offsets = _cjk_compute_wrap_offsets
|
||
|
||
|
||
_install_cjk_wrap()
|
||
|
||
|
||
# Markdown render result that supports clean copy. We render twice: once at the
|
||
# display width (wraps to ANSI for selectability) and once at a wide width (one
|
||
# logical line per block, no wrap newlines). The narrow render goes into the
|
||
# Text widget for display; the wide render becomes the "source" string that
|
||
# get_selection extracts from, with per-visual-line offsets mapping cursor
|
||
# positions back into source — wrap continuations skip the wide-side whitespace
|
||
# eaten at the break, and hanging indent on wrap lines maps to the same source
|
||
# position as the start of the wrapped content.
|
||
@dataclass
|
||
class _MdRender:
|
||
text: Text
|
||
source: str
|
||
line_starts: list # source offset for the content start of each visual line
|
||
line_indents: list # leading whitespace count to skip when mapping x
|
||
line_lengths: list # total length of each visual line (incl. indent)
|
||
|
||
|
||
_CENTER_LEAD_MIN = 4
|
||
|
||
|
||
def _strip_quote_deco(s: str) -> tuple:
|
||
"""Rich Markdown re-emits the `▌ ` blockquote marker on every wrapped visual
|
||
line in narrow, but the wide single-line render contains it only once at the
|
||
block start. Treat the re-prefix on continuation lines as visual indent that
|
||
doesn't consume wide chars. Returns (content_without_deco, deco_width)."""
|
||
if not s.startswith("▌"): # `▌`
|
||
return s, 0
|
||
rest = s[1:]
|
||
if rest.startswith(" "):
|
||
return rest[1:], 2
|
||
return rest, 1
|
||
|
||
|
||
def _md_line_has_box_drawing(line: str) -> bool:
|
||
"""Return True for Rich table / box-art glyphs, not for normal dashes.
|
||
|
||
The previous table workaround keyed on the literal `─` at the whole-widget
|
||
level. That was too broad: one table anywhere in a message made ordinary
|
||
paragraphs copy from the wrapped/narrow render, reintroducing visual
|
||
newlines. Use the Unicode Box Drawing block so SIMPLE/ROUNDED/HEAVY/etc.
|
||
table styles are covered while em-dashes (`—`) and ASCII/Unicode hyphens are
|
||
not mistaken for tables.
|
||
|
||
A leading `\u2514\u2500 ` (with the space) is the write-diff card's child-path line,
|
||
not a table border \u2014 borders run the glyphs together (`\u2514\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2518`). Same
|
||
for the code card's `\u2502 ` / `\u2514 ` gutters, and a gutter-only row (`\u2502` / `\u2514`
|
||
left after rstrip trims the trailing space of an empty command/output line).
|
||
Exempt all of them so the cards keep their exact narrow\u2194wide line mapping
|
||
instead of the visible-text passthrough (which would copy the card's
|
||
visual-only margin).
|
||
|
||
A pure horizontal run (only `─` + spaces) is NOT exempt: a SIMPLE-box
|
||
Markdown table's only box glyph is its header rule, so exempting bare
|
||
`─` rows drops the whole table out of passthrough and misaligns CJK
|
||
copy. The cosmetic cost is that a real markdown hr copies as a dash run
|
||
— the pre-card behavior, never reported as a problem.
|
||
"""
|
||
s = line.lstrip()
|
||
for pfx in ("\u2514\u2500 ", "\u2502 ", "\u2514 "):
|
||
if s.startswith(pfx):
|
||
s = s[len(pfx):]
|
||
break
|
||
else:
|
||
if s in ("\u2502", "\u2514"): # gutter-only row (empty command/output line)
|
||
s = ""
|
||
return any("\u2500" <= ch <= "\u257f" for ch in s)
|
||
|
||
|
||
def _md_run_has_box_drawing(lines: list[str]) -> bool:
|
||
return any(_md_line_has_box_drawing(line) for line in lines)
|
||
|
||
|
||
def _build_passthrough_source(narrow_plain: str):
|
||
"""Fallback aligner: treat narrow render as the copy source verbatim.
|
||
|
||
Used when the wide/narrow line-by-line correspondence assumed by
|
||
`_align_md_renders` breaks down — most notably for Rich tables, where
|
||
the wide render keeps each logical row on one line with `│` separators
|
||
while the narrow render lays cells vertically. In that case we can't
|
||
map (y, x) selection coordinates back to the wide source, so we just
|
||
copy whatever is visually on screen and accept the cosmetic cost of
|
||
leaving the table's `─`/`│` box characters in the clipboard output.
|
||
|
||
Returns the same 4-tuple shape as `_align_md_renders`:
|
||
(source, line_starts, line_indents, line_lengths)
|
||
"""
|
||
lines = narrow_plain.split("\n")
|
||
line_starts = [0] * len(lines)
|
||
line_indents = [0] * len(lines)
|
||
line_lengths = [0] * len(lines)
|
||
parts = []
|
||
pos = 0
|
||
for i, raw in enumerate(lines):
|
||
# Strip the `▌` user-message side bar the same way the aligner does,
|
||
# so selections inside user echoes still copy clean text.
|
||
body, deco = _strip_quote_deco(raw)
|
||
line_starts[i] = pos
|
||
line_indents[i] = deco
|
||
line_lengths[i] = len(body)
|
||
parts.append(body)
|
||
pos += len(body)
|
||
if i != len(lines) - 1:
|
||
parts.append("\n")
|
||
pos += 1
|
||
return "".join(parts), line_starts, line_indents, line_lengths
|
||
|
||
|
||
def _align_md_renders(narrow_raw: str, wide_raw: str):
|
||
"""Walk narrow + wide line-by-line; return (source, line_starts, line_indents, line_lengths)."""
|
||
narrow = [l.rstrip() for l in narrow_raw.split("\n")]
|
||
wide = [l.rstrip() for l in wide_raw.split("\n")]
|
||
|
||
wrap_groups: list = []
|
||
ni = 0
|
||
wi = 0
|
||
while ni < len(narrow):
|
||
if narrow[ni] == "":
|
||
ni += 1
|
||
while wi < len(wide) and wide[wi] == "":
|
||
wi += 1
|
||
continue
|
||
run_start = ni
|
||
while ni < len(narrow) and narrow[ni] != "":
|
||
ni += 1
|
||
run_lines = narrow[run_start:ni]
|
||
|
||
wide_start = wi
|
||
while wi < len(wide) and wide[wi] != "":
|
||
wi += 1
|
||
wide_lines = wide[wide_start:wi]
|
||
|
||
K, W = len(run_lines), len(wide_lines)
|
||
if _md_run_has_box_drawing(run_lines):
|
||
# Rich tables are inherently two-dimensional: a single logical row in
|
||
# the wide render may become several visual rows in the narrow render.
|
||
# Treat only this *run* as visual/passthrough. Do not poison the
|
||
# rest of the widget, otherwise paragraphs before/after the table
|
||
# start copying their wrapped visual newlines again.
|
||
for k in range(K):
|
||
wrap_groups.append(((run_start + k, run_start + k + 1), run_lines[k], True))
|
||
elif W == 0:
|
||
for k in range(K):
|
||
wrap_groups.append(((run_start + k, run_start + k + 1), run_lines[k], False))
|
||
elif K == W:
|
||
for k in range(K):
|
||
wrap_groups.append(((run_start + k, run_start + k + 1), wide_lines[k], False))
|
||
else:
|
||
j = 0
|
||
for w_idx, w_line in enumerate(wide_lines):
|
||
g_start = run_start + j
|
||
accumulated = 0
|
||
target = len(w_line)
|
||
is_last = (w_idx == W - 1)
|
||
while j < K and (accumulated < target or is_last):
|
||
nt = run_lines[j]
|
||
if j > g_start - run_start:
|
||
content, _ = _strip_quote_deco(nt.lstrip())
|
||
else:
|
||
# First line of the group: drop any visual-only left
|
||
# margin (narrow lead beyond the wide line's own lead —
|
||
# margined tool blocks) so the accumulation against the
|
||
# wide target stays balanced.
|
||
n_lead = len(nt) - len(nt.lstrip())
|
||
w_lead = len(w_line) - len(w_line.lstrip())
|
||
content = nt[max(0, n_lead - w_lead):]
|
||
accumulated += len(content)
|
||
j += 1
|
||
# Each wrap boundary eats one space from the wide line, so
|
||
# the narrow side's accumulated content runs (consumed - 1)
|
||
# chars short of target at the natural wrap point.
|
||
consumed = j - (g_start - run_start)
|
||
if not is_last and accumulated + max(0, consumed - 1) >= target:
|
||
break
|
||
wrap_groups.append(((g_start, run_start + j), w_line, False))
|
||
|
||
source_parts: list = []
|
||
line_starts = [0] * len(narrow)
|
||
line_indents = [0] * len(narrow)
|
||
line_lengths = [len(nt) for nt in narrow]
|
||
src_pos = 0
|
||
last_was_content = False
|
||
group_idx = 0
|
||
|
||
ni = 0
|
||
while ni < len(narrow):
|
||
if narrow[ni] == "":
|
||
line_starts[ni] = src_pos
|
||
if last_was_content:
|
||
source_parts.append("\n")
|
||
src_pos += 1
|
||
source_parts.append("\n")
|
||
src_pos += 1
|
||
last_was_content = False
|
||
ni += 1
|
||
continue
|
||
|
||
while group_idx < len(wrap_groups) and ni >= wrap_groups[group_idx][0][1]:
|
||
group_idx += 1
|
||
if group_idx >= len(wrap_groups):
|
||
line_starts[ni] = src_pos
|
||
source_parts.append(narrow[ni])
|
||
src_pos += len(narrow[ni])
|
||
ni += 1
|
||
last_was_content = True
|
||
continue
|
||
|
||
(g_start, g_end), wide_line, passthrough = wrap_groups[group_idx]
|
||
single_line = (g_end - g_start == 1)
|
||
|
||
nt0 = narrow[g_start]
|
||
nt0_lead = len(nt0) - len(nt0.lstrip())
|
||
wide_lead = len(wide_line) - len(wide_line.lstrip())
|
||
# Rich centers H1 against the available width, so wide_lead grows with the
|
||
# console width (≈ 5000 at width=10000) while nt0_lead reflects narrow's
|
||
# half-padding. Code lines, list/blockquote markers, etc. have wide_lead
|
||
# ≈ nt0_lead — without the >=2× guard the heuristic would strip indent
|
||
# from any code line with ≥5 leading spaces (e.g. ` print("hi")`),
|
||
# causing the visible selection and the copied text to disagree.
|
||
is_centered = (single_line and wide_lead > _CENTER_LEAD_MIN and nt0_lead > 0
|
||
and wide_lead >= 2 * nt0_lead)
|
||
|
||
if last_was_content:
|
||
source_parts.append("\n")
|
||
src_pos += 1
|
||
|
||
if passthrough:
|
||
# Visual/source mapping for table rows: keep exactly what the user
|
||
# sees on this line (minus quote decoration) so x offsets remain
|
||
# valid. Each table visual line is its own group, so no wrapped
|
||
# paragraph outside the table inherits this behavior.
|
||
body, deco = _strip_quote_deco(narrow[g_start])
|
||
source_parts.append(body)
|
||
line_starts[g_start] = src_pos
|
||
line_indents[g_start] = deco
|
||
src_pos += len(body)
|
||
elif is_centered:
|
||
content = wide_line.lstrip()
|
||
source_parts.append(content)
|
||
line_starts[g_start] = src_pos
|
||
line_indents[g_start] = nt0_lead
|
||
src_pos += len(content)
|
||
else:
|
||
block_start = src_pos
|
||
source_parts.append(wide_line)
|
||
src_pos += len(wide_line)
|
||
pointer = 0
|
||
for k in range(g_start, g_end):
|
||
nt = narrow[k]
|
||
if k == g_start:
|
||
# A narrow line with MORE lead than its wide twin is showing
|
||
# a visual-only left margin (write-diff cards / margined tool
|
||
# blocks). Map it as indent so selection x-coords shift and
|
||
# copies exclude it; drop it from `content` so the pointer
|
||
# accounting over the wide line stays balanced.
|
||
nt_lead = len(nt) - len(nt.lstrip())
|
||
indent = max(0, nt_lead - wide_lead)
|
||
content = nt[indent:]
|
||
else:
|
||
indent = len(nt) - len(nt.lstrip())
|
||
content = nt.lstrip()
|
||
content, deco = _strip_quote_deco(content)
|
||
indent += deco
|
||
while pointer < len(wide_line) and wide_line[pointer].isspace():
|
||
pointer += 1
|
||
line_starts[k] = block_start + pointer
|
||
line_indents[k] = indent
|
||
pointer += len(content)
|
||
ni = g_end
|
||
last_was_content = True
|
||
|
||
return "".join(source_parts).rstrip("\n"), line_starts, line_indents, line_lengths
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# @ 文件引用(at-mention)— 补全版(completion-only)
|
||
# 编辑期:光标处 @token → 后台文件索引 + 模糊匹配 → 复用 #palette 下拉,
|
||
# 选中把 @路径 补进输入框(索引根 = 会话 workspace,未绑定退化为 CWD)。
|
||
# 提交期:不处理,@路径 作为普通文本发给 agent,由其自行决定是否 file_read。
|
||
# 纯逻辑(索引/模糊/token)抽到 frontends/at_complete.py,与 tui_v3 共用;
|
||
# 自动预读那一版见 temp/plan_v2_at_mention/autoread_version.py。
|
||
from at_complete import get_index, fuzzy_rank, find_at_token, format_pick, candidates_for, absolutize_mentions
|
||
|
||
|
||
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
if ROOT_DIR not in sys.path:
|
||
sys.path.insert(0, ROOT_DIR)
|
||
FRONTENDS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
if FRONTENDS_DIR not in sys.path:
|
||
sys.path.insert(0, FRONTENDS_DIR)
|
||
|
||
_TASK_DIR_GLOB = os.path.join(FRONTENDS_DIR, '..', 'temp', '_tui_v2_*')
|
||
|
||
|
||
def _rmdir_if_empty(path: Optional[str]) -> None:
|
||
"""Best-effort remove a signal task_dir once it holds no in-flight files.
|
||
`os.rmdir` only succeeds on an empty dir, so a stray `_intervene` still
|
||
pending consumption is never clobbered."""
|
||
if not path:
|
||
return
|
||
try: os.rmdir(path)
|
||
except OSError: pass
|
||
|
||
|
||
def _sweep_stale_task_dirs() -> None:
|
||
"""Delete empty `temp/_tui_v2_*` signal dirs left by prior runs (incl.
|
||
crashes). Empty == no pending signal, so removal is safe even while
|
||
another live instance owns one — its writer re-creates lazily on the
|
||
next inject."""
|
||
import glob as _glob
|
||
for d in _glob.glob(_TASK_DIR_GLOB):
|
||
if os.path.isdir(d):
|
||
_rmdir_if_empty(d)
|
||
|
||
# Side-effect imports activate /btw + /continue monkey-patches.
|
||
import chatapp_common # noqa: F401
|
||
from chatapp_common import format_restore
|
||
from btw_cmd import handle_frontend_command as btw_handle
|
||
from review_cmd import handle as review_handle
|
||
from continue_cmd import list_sessions as continue_list, extract_ui_messages as continue_extract
|
||
import workspace_cmd
|
||
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
|
||
from worldline import (
|
||
RewindStore, restore_plan,
|
||
ellipsize, rel_time, files_summary, kind_glyph, kind_label,
|
||
tree_from_store, CompressedTree,
|
||
_order_depths, nearest_depth_node,
|
||
)
|
||
# RewindTreeScreen 等三栏树 UI 已内联到本文件末尾(原 rewind_tree_view.py),跟随 v2 主题配色。
|
||
|
||
# Cross-platform clipboard copy for /export clip. Mirrors tui_v3's native-tool
|
||
# strategy but stays local to v2 so the Textual frontend has no dependency on
|
||
# the raw terminal frontend module.
|
||
_HAS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY"))
|
||
|
||
|
||
def _clipboard_run(cmd: list[str], input: bytes | None = None, timeout: float = 3.0) -> bytes | None:
|
||
try:
|
||
stdin = subprocess.DEVNULL if input is None else None
|
||
r = subprocess.run(cmd, input=input, stdin=stdin, capture_output=True, timeout=timeout)
|
||
return r.stdout if r.returncode == 0 else None
|
||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||
return None
|
||
|
||
|
||
def _copy_to_clipboard_win32(text: str) -> bool:
|
||
"""Copy Unicode text on Windows without going through console code pages."""
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
|
||
user32 = ctypes.windll.user32
|
||
kernel32 = ctypes.windll.kernel32
|
||
GMEM_MOVEABLE = 0x0002
|
||
CF_UNICODETEXT = 13
|
||
|
||
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
|
||
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
|
||
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
|
||
kernel32.GlobalLock.restype = wintypes.LPVOID
|
||
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
|
||
kernel32.GlobalUnlock.restype = wintypes.BOOL
|
||
kernel32.GlobalFree.argtypes = [wintypes.HGLOBAL]
|
||
kernel32.GlobalFree.restype = wintypes.HGLOBAL
|
||
user32.OpenClipboard.argtypes = [wintypes.HWND]
|
||
user32.OpenClipboard.restype = wintypes.BOOL
|
||
user32.EmptyClipboard.restype = wintypes.BOOL
|
||
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
|
||
user32.SetClipboardData.restype = wintypes.HANDLE
|
||
user32.CloseClipboard.restype = wintypes.BOOL
|
||
|
||
data = text.encode("utf-16-le") + b"\x00\x00"
|
||
handle = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data))
|
||
if not handle:
|
||
return False
|
||
locked = kernel32.GlobalLock(handle)
|
||
if not locked:
|
||
kernel32.GlobalFree(handle)
|
||
return False
|
||
ctypes.memmove(locked, data, len(data))
|
||
kernel32.GlobalUnlock(handle)
|
||
|
||
if not user32.OpenClipboard(None):
|
||
kernel32.GlobalFree(handle)
|
||
return False
|
||
try:
|
||
user32.EmptyClipboard()
|
||
if not user32.SetClipboardData(CF_UNICODETEXT, handle):
|
||
kernel32.GlobalFree(handle)
|
||
return False
|
||
# Ownership transferred to the clipboard; do not free `handle`.
|
||
return True
|
||
finally:
|
||
user32.CloseClipboard()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _copy_to_clipboard(text: str) -> bool:
|
||
data = text.encode("utf-8")
|
||
if sys.platform == "darwin":
|
||
return _clipboard_run(["pbcopy"], input=data) is not None
|
||
if sys.platform == "win32":
|
||
return _copy_to_clipboard_win32(text)
|
||
if _HAS_WAYLAND and shutil.which("wl-copy"):
|
||
return _clipboard_run(["wl-copy"], input=data) is not None
|
||
if shutil.which("xclip"):
|
||
return _clipboard_run(["xclip", "-selection", "clipboard"], input=data) is not None
|
||
if shutil.which("xsel"):
|
||
return _clipboard_run(["xsel", "--clipboard", "--input"], input=data) is not None
|
||
return False
|
||
|
||
AgentFactory = Callable[[], Any]
|
||
|
||
# ---------- themes ----------
|
||
# Our `ga-default` palette is registered as a Textual Theme; the other themes in
|
||
# `_THEME_CYCLE` are Textual built-ins, whose ga-* slots are derived in
|
||
# get_css_variables. C_* globals are kept in sync via watch_theme so Rich Text
|
||
# styles (which take plain hex strings) update on theme switch.
|
||
_DEFAULT_PALETTE: dict[str, str] = {
|
||
"fg": "#c9d1d9", "muted": "#8b949e", "dim": "#6e7681",
|
||
"bg": "#0d1117", "alt_bg": "#21262d", "sel_bg": "#161b22",
|
||
"border": "#30363d", "border_hi": "#484f58",
|
||
"green": "#7ec27e", "blue": "#82adcf", "purple": "#b596d8",
|
||
# Topbar info-segment chips — distinct hues for at-a-glance scanability.
|
||
# Values are from the github-dark palette; built-in Textual themes derive
|
||
# these from primary/secondary/warning/accent/success in get_css_variables.
|
||
"chip_name": "#79c0ff", # session name — cyan-blue
|
||
"chip_model": "#a5d6ff", # model id — pale blue
|
||
"chip_effort": "#f0883e", # effort — amber (heat)
|
||
"chip_tasks": "#d2a8ff", # task count — lavender
|
||
"chip_time": "#7ec27e", # clock — same muted green as the sidebar's active-session marker
|
||
}
|
||
|
||
_THEME_CYCLE = ["ga-default", "nord", "gruvbox", "dracula", "tokyo-night", "textual-light"]
|
||
|
||
|
||
# ---------- persisted settings ----------
|
||
# Lightweight JSON dropbox for cross-run UI state (theme, future toggles).
|
||
# Lives under temp/ alongside model logs so it tracks the workspace.
|
||
_SETTINGS_PATH = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "..", "temp", "tui_settings.json"
|
||
)
|
||
|
||
def _load_settings() -> dict:
|
||
try:
|
||
with open(_SETTINGS_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
def _save_settings(patch: dict) -> None:
|
||
cur = _load_settings()
|
||
cur.update(patch)
|
||
try:
|
||
os.makedirs(os.path.dirname(_SETTINGS_PATH), exist_ok=True)
|
||
with open(_SETTINGS_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(cur, f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
_palette: dict[str, str] = dict(_DEFAULT_PALETTE)
|
||
C_FG = _palette["fg"]
|
||
C_MUTED = _palette["muted"]
|
||
C_DIM = _palette["dim"]
|
||
C_SEL_BG = _palette["sel_bg"]
|
||
C_GREEN = _palette["green"]
|
||
C_BLUE = _palette["blue"]
|
||
C_PURPLE = _palette["purple"]
|
||
C_CHIP_NAME = _palette["chip_name"]
|
||
C_CHIP_MODEL = _palette["chip_model"]
|
||
C_CHIP_EFFORT = _palette["chip_effort"]
|
||
C_CHIP_TASKS = _palette["chip_tasks"]
|
||
C_CHIP_TIME = _palette["chip_time"]
|
||
|
||
|
||
def _hex_rgb(h: str) -> tuple[int, int, int]:
|
||
h = (h or "#000000").lstrip("#")
|
||
if len(h) == 3: h = "".join(c * 2 for c in h)
|
||
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||
|
||
|
||
def _rgb_hex(rgb) -> str:
|
||
return "#{:02x}{:02x}{:02x}".format(*(max(0, min(255, int(c))) for c in rgb))
|
||
|
||
|
||
def _mix(a: str, b: str, t: float) -> str:
|
||
ra, rb = _hex_rgb(a), _hex_rgb(b)
|
||
return _rgb_hex(tuple(ra[i] * (1 - t) + rb[i] * t for i in range(3)))
|
||
|
||
|
||
def _markdown_rich_theme(p: dict[str, str], minimal: bool = False):
|
||
"""Map our palette to Rich Markdown's named styles so code/links/headings
|
||
follow the active theme instead of Rich's frozen defaults.
|
||
|
||
`minimal=True` collapses everything to fg/muted so non-default themes don't
|
||
fight Rich's frozen accent colors — each theme can be re-colorised case by
|
||
case later."""
|
||
from rich.theme import Theme as _RichTheme
|
||
if minimal:
|
||
fg, muted, dim, border = p["fg"], p["muted"], p["dim"], p["border"]
|
||
return _RichTheme({
|
||
"markdown.h1": f"bold {fg}",
|
||
"markdown.h2": f"bold {fg}",
|
||
"markdown.h3": f"bold {fg}",
|
||
"markdown.h4": f"bold {fg}",
|
||
"markdown.h5": f"bold {fg}",
|
||
"markdown.h6": f"bold {fg}",
|
||
"markdown.code": f"bold {fg}",
|
||
"markdown.code_block": fg,
|
||
"markdown.link": f"underline {fg}",
|
||
"markdown.link_url": f"underline {dim}",
|
||
"markdown.block_quote": muted,
|
||
"markdown.item": fg,
|
||
"markdown.list": fg,
|
||
"markdown.item.bullet": f"bold {fg}",
|
||
"markdown.item.number": fg,
|
||
"markdown.hr": border,
|
||
"markdown.strong": f"bold {fg}",
|
||
"markdown.em": f"italic {fg}",
|
||
"markdown.s": f"strike {dim}",
|
||
"markdown.table.border": border,
|
||
"markdown.table.header": f"bold {fg}",
|
||
})
|
||
return _RichTheme({
|
||
"markdown.h1": f"bold {p['green']}",
|
||
"markdown.h2": f"bold {p['blue']}",
|
||
"markdown.h3": f"bold {p['purple']}",
|
||
"markdown.h4": f"bold {p['fg']}",
|
||
"markdown.h5": f"bold {p['fg']}",
|
||
"markdown.h6": f"bold {p['fg']}",
|
||
"markdown.code": f"bold {p['fg']}",
|
||
"markdown.code_block": f"{p['fg']} on {p['sel_bg']}",
|
||
"markdown.link": p["blue"],
|
||
"markdown.link_url": f"underline {p['dim']}",
|
||
"markdown.block_quote": p["muted"],
|
||
"markdown.item": p["fg"],
|
||
"markdown.list": p["blue"],
|
||
"markdown.item.bullet": f"bold {p['blue']}",
|
||
"markdown.item.number": p["blue"],
|
||
"markdown.hr": p["border"],
|
||
"markdown.strong": f"bold {p['fg']}",
|
||
"markdown.em": f"italic {p['fg']}",
|
||
"markdown.s": f"strike {p['dim']}",
|
||
"markdown.table.border": p["border"],
|
||
"markdown.table.header": f"bold {p['fg']}",
|
||
})
|
||
|
||
|
||
def _palette_from_resolved_vars(v: dict[str, str], dark: bool) -> dict[str, str]:
|
||
"""Derive our 11-slot palette from Textual's *resolved* CSS variables (i.e.
|
||
after super().get_css_variables()). Textual auto-fills foreground / surface /
|
||
panel when the Theme leaves them None, so we read those rather than raw
|
||
Theme attributes."""
|
||
bg = v.get("background") or ("#1a1a1a" if dark else "#ffffff")
|
||
fg = v.get("foreground") or ("#e6e6e6" if dark else "#1a1a1a")
|
||
surface = v.get("surface") or _mix(bg, fg, 0.08)
|
||
panel = v.get("panel") or _mix(bg, fg, 0.14)
|
||
primary = v.get("primary") or fg
|
||
return {
|
||
"fg": fg, "bg": bg,
|
||
"alt_bg": surface, "sel_bg": panel,
|
||
# text-muted / text-disabled in Textual resolve to "auto NN%" — a Textual-only
|
||
# syntax Rich can't parse. Always derive from bg/fg blend so the strings we
|
||
# hand to Rich Text are plain hex.
|
||
"muted": _mix(bg, fg, 0.55),
|
||
"dim": _mix(bg, fg, 0.35),
|
||
"border": _mix(bg, fg, 0.20),
|
||
"border_hi": _mix(bg, fg, 0.35),
|
||
"green": v.get("success") or primary,
|
||
"blue": v.get("secondary") or primary,
|
||
"purple": v.get("accent") or primary,
|
||
# Topbar chips — five distinguishable Textual roles so each segment keeps
|
||
# its own hue across themes. Fall back to primary if a slot is missing.
|
||
"chip_name": v.get("primary") or primary,
|
||
"chip_model": v.get("secondary") or primary,
|
||
"chip_effort": v.get("warning") or v.get("accent") or primary,
|
||
"chip_tasks": v.get("accent") or primary,
|
||
"chip_time": v.get("success") or primary,
|
||
}
|
||
|
||
|
||
_MAIN_CSS = """
|
||
Screen { background: $ga-bg; color: $ga-fg; }
|
||
|
||
#topbar, #bottombar {
|
||
height: 1;
|
||
background: $ga-bg;
|
||
padding: 0 2;
|
||
}
|
||
|
||
#body { height: 1fr; }
|
||
|
||
/* Outer scroll container owns the geometry (width/height/border) and the
|
||
scrolling; the inner #sidebar Static keeps the padding so the click
|
||
hit-test math in on_click (event.y - 3) is unchanged. */
|
||
#sidebar-scroll {
|
||
width: 34;
|
||
height: 100%;
|
||
background: $ga-bg;
|
||
border-right: solid $ga-alt-bg;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
scrollbar-size: 0 1;
|
||
/* Reserve the 1-col scrollbar gutter up front so overflowing the window
|
||
doesn't suddenly squeeze the session rows narrower. */
|
||
scrollbar-gutter: stable;
|
||
}
|
||
#sidebar-scroll.-hidden, #sidebar-scroll.-narrow { display: none; }
|
||
|
||
#sidebar {
|
||
width: 1fr;
|
||
height: auto;
|
||
padding: 1 2;
|
||
}
|
||
|
||
#main {
|
||
height: 100%;
|
||
padding: 1 6;
|
||
background: $ga-bg;
|
||
}
|
||
|
||
#messages {
|
||
height: 1fr;
|
||
background: $ga-bg;
|
||
/* horizontal hidden, 1-col vertical bar on right. */
|
||
scrollbar-size: 0 1;
|
||
scrollbar-background: $ga-bg;
|
||
scrollbar-background-hover: $ga-bg;
|
||
scrollbar-background-active: $ga-bg;
|
||
scrollbar-color: $ga-border;
|
||
scrollbar-color-hover: $ga-border-hi;
|
||
scrollbar-color-active: $ga-dim;
|
||
}
|
||
|
||
/* Plan/todo panel — fixed 5-row card between messages and composer.
|
||
`display: none` default so the empty post-compose frame doesn't flash;
|
||
renderer flips it on once items materialize. Fixed height (no scroll)
|
||
keeps layout stable; body truncates to 4 items + "+N more" footer. */
|
||
/* Plan card. Outer #planbar-scroll owns the frame (border/padding) + show-hide.
|
||
#planbar-head pins the header + current-step line. #planbar-tasks is the only
|
||
scrolling region: capped at 4 rows so at most 4 TODO items show at once, the
|
||
rest reachable by wheel/PageUp. */
|
||
#planbar-scroll {
|
||
display: none;
|
||
height: auto;
|
||
background: $ga-sel-bg;
|
||
padding: 0 1;
|
||
margin: 0 0 1 0;
|
||
border-left: thick $ga-green;
|
||
}
|
||
#planbar-scroll.-visible { display: block; }
|
||
#planbar-head {
|
||
height: auto;
|
||
background: $ga-sel-bg;
|
||
}
|
||
#planbar-tasks {
|
||
height: auto;
|
||
max-height: 4;
|
||
background: $ga-sel-bg;
|
||
scrollbar-size: 0 1;
|
||
scrollbar-background: $ga-sel-bg;
|
||
scrollbar-background-hover: $ga-sel-bg;
|
||
scrollbar-background-active: $ga-sel-bg;
|
||
scrollbar-color: $ga-border;
|
||
}
|
||
#planbar {
|
||
height: auto;
|
||
background: $ga-sel-bg;
|
||
}
|
||
|
||
/* `└ Tip:` footer — one dim row, never grows. */
|
||
#tipbar {
|
||
height: 1;
|
||
background: $ga-bg;
|
||
padding: 0;
|
||
color: $ga-dim;
|
||
}
|
||
|
||
/* Pickers — used by both ChoiceList (OptionList) and MultiChoiceList
|
||
(SelectionList). Same flat single-column look as the rest of the chat,
|
||
with a thin green left edge so the picker reads as an actionable card. */
|
||
OptionList.picker, SelectionList.picker {
|
||
height: auto;
|
||
max-height: 12;
|
||
margin: 0 0 1 0;
|
||
padding: 0 1;
|
||
background: $ga-bg;
|
||
border: none;
|
||
border-left: thick $ga-green;
|
||
scrollbar-size: 0 1;
|
||
}
|
||
OptionList.picker > .option-list--option-hover,
|
||
SelectionList.picker > .option-list--option-hover { background: $ga-sel-bg; }
|
||
OptionList.picker > .option-list--option-highlighted,
|
||
SelectionList.picker > .option-list--option-highlighted {
|
||
background: $ga-blue 20%;
|
||
color: $ga-fg;
|
||
text-style: none;
|
||
}
|
||
SelectionList.picker > .selection-list--button { color: $ga-dim; }
|
||
SelectionList.picker > .selection-list--button-selected { color: $ga-green; }
|
||
SelectionList.picker > .selection-list--button-highlighted { background: transparent; }
|
||
|
||
/* Searchable `/continue` picker wrapper. Textual's Vertical container defaults
|
||
to a flex-like height in this scroll layout; if left implicit, scroll_end can
|
||
align only the wrapper's tail and leave the search box / options hidden under
|
||
the composer. Keep the wrapper content-sized; the inner OptionList.picker
|
||
remains the only scrollable/clamped part (12 rows). */
|
||
SearchableChoiceList.picker {
|
||
height: auto;
|
||
margin: 0 0 1 0;
|
||
}
|
||
|
||
/* `/continue` search box: one-row gap above (to separate the input from the
|
||
"选择要恢复的会话 …" prompt header) and one-row gap below (to separate it
|
||
from the result list), so the input is visually distinct on both sides
|
||
(user feedback 2026-05-27). */
|
||
#continue-search { margin: 1 0 1 0; }
|
||
|
||
.role {
|
||
height: 1;
|
||
margin-top: 1;
|
||
margin-bottom: 0;
|
||
}
|
||
.msg {
|
||
height: auto;
|
||
margin-bottom: 0;
|
||
}
|
||
.fold-header:hover { background: $ga-sel-bg; }
|
||
.spinner {
|
||
height: 1;
|
||
margin-top: 1;
|
||
}
|
||
|
||
#palette {
|
||
height: auto;
|
||
max-height: 8;
|
||
background: $ga-bg;
|
||
border: none;
|
||
padding: 0;
|
||
display: none;
|
||
margin-bottom: 1;
|
||
scrollbar-size: 0 0;
|
||
}
|
||
#palette.-visible { display: block; }
|
||
OptionList {
|
||
background: $ga-bg;
|
||
border: none;
|
||
padding: 0;
|
||
}
|
||
OptionList > .option-list--option {
|
||
padding: 0 2;
|
||
background: $ga-bg;
|
||
color: $ga-fg;
|
||
}
|
||
OptionList > .option-list--option-highlighted {
|
||
background: $ga-fg;
|
||
color: $ga-bg;
|
||
text-style: bold;
|
||
}
|
||
|
||
ChoiceList {
|
||
height: auto;
|
||
max-height: 12;
|
||
background: $ga-bg;
|
||
border: none;
|
||
padding: 0;
|
||
margin-bottom: 1;
|
||
scrollbar-size: 0 0;
|
||
}
|
||
|
||
#input {
|
||
height: 3;
|
||
min-height: 3;
|
||
max-height: 5;
|
||
/* min-width guards TextArea.render_lines against `range() arg 3 must not be zero`
|
||
when the content region collapses to <= 0 cols (narrow window + sidebar shown). */
|
||
min-width: 10;
|
||
background: $ga-sel-bg;
|
||
border: none;
|
||
margin-bottom: 1;
|
||
padding: 1 2;
|
||
color: $ga-fg;
|
||
scrollbar-size: 0 0;
|
||
}
|
||
#input:focus { border: none; }
|
||
"""
|
||
|
||
|
||
@dataclass
|
||
class ChatMessage:
|
||
role: str # 'user' | 'assistant' | 'system'
|
||
content: str
|
||
task_id: Optional[int] = None
|
||
done: bool = True
|
||
# Interactive choice support
|
||
kind: str = "text" # "text" | "choice"
|
||
choices: list = field(default_factory=list) # [(label, value), ...]
|
||
on_select: Optional[Callable] = field(default=None, repr=False)
|
||
# Optional Esc/cancel hook for choice cards. When set, _cancel_choice
|
||
# invokes this *after* removing the card (used by /scheduler's submit-
|
||
# confirm card to re-show the picker, mirroring ask_user's free-text
|
||
# "Esc rolls back to the previous picker" UX).
|
||
on_cancel: Optional[Callable] = field(default=None, repr=False)
|
||
selected_label: Optional[str] = None
|
||
# Indices into `choices` that should render pre-ticked when the card first
|
||
# mounts (multi_choice only). Used by /scheduler so already-running
|
||
# services show up checked, making "untick = stop" discoverable (bug#4).
|
||
preselected_indices: list[int] = field(default_factory=list)
|
||
# Non-searchable choice cards only: highlight (and scroll to) this option
|
||
# index on first mount instead of the default row 0. `/rewind` uses it to
|
||
# open focused on the newest entry at the bottom. None → default (top).
|
||
initial_highlight: Optional[int] = None
|
||
# When True, selecting an option fires on_select but does NOT auto-collapse
|
||
# the card into a "✓ …" breadcrumb — on_select owns the card's lifecycle.
|
||
# `/rewind` uses it so picking an entry opens the mode picker with the list
|
||
# still mounted: confirm → on_select clears the card + rewinds; Esc → the
|
||
# list stays put (no misleading "✓ selected" when nothing was rewound).
|
||
defer_collapse: bool = False
|
||
# Optional lazy-render hints for choice pickers with huge option counts
|
||
# (e.g. /continue across thousands of sessions). Default is empty / 0,
|
||
# so every existing call site keeps the eager-mount behavior bit-for-bit.
|
||
lazy_choice_items: Optional[list] = field(default=None, repr=False)
|
||
lazy_choice_batch: int = 0
|
||
# `/continue` picker opt-in: when True, _mount_message wraps the picker
|
||
# with an Input filter; `all_choices` is the unfiltered baseline so empty
|
||
# queries restore the full list. Other call sites keep searchable=False
|
||
# (default) and the existing eager/lazy paths run untouched.
|
||
searchable: bool = False
|
||
search_query: str = ""
|
||
all_choices: Optional[list] = field(default=None, repr=False)
|
||
# Free-input opt-in for searchable pickers (/model): when True, Enter in
|
||
# the search box with NO selectable match commits the raw query text as
|
||
# the value — the filter box doubles as a custom-name input. /continue
|
||
# keeps the default False, so a no-match Enter stays a no-op there.
|
||
free_input: bool = False
|
||
# Optional placeholder for the search Input ("" → the /continue default).
|
||
search_placeholder: str = ""
|
||
# Hint row shown (disabled) under the search Input when choices is empty —
|
||
# doubles as a "loading…" indicator for pickers filled asynchronously (/model).
|
||
empty_hint: str = "(no matches)"
|
||
image_paths: list[str] = field(default_factory=list)
|
||
_role_widget: Any = field(default=None, repr=False)
|
||
_hint_widget: Any = field(default=None, repr=False)
|
||
_body_widget: Any = field(default=None, repr=False)
|
||
_cached_body: Any = field(default=None, repr=False)
|
||
_cache_key: tuple = field(default=(), repr=False)
|
||
# Fold indices the user has manually toggled away from the global default.
|
||
# Effective expansion = (default ⊕ in this set), where default = not fold_mode.
|
||
_toggled_folds: set = field(default_factory=set, repr=False)
|
||
_segment_widgets: list = field(default_factory=list, repr=False)
|
||
_segment_sig: tuple = field(default=(), repr=False)
|
||
_spinner_widget: Any = field(default=None, repr=False)
|
||
# Stream start + token baselines so the spinner shows *this turn's* deltas.
|
||
_stream_started_at: Optional[float] = field(default=None, repr=False)
|
||
_stream_baseline_input: int = field(default=0, repr=False)
|
||
_stream_baseline_output: int = field(default=0, repr=False)
|
||
# Frozen `(elapsed, last_in, last_out)` at done→True; keeps the post-turn
|
||
# card from ticking when the next turn shifts cost_tracker deltas.
|
||
_done_summary: Optional[tuple] = field(default=None, repr=False)
|
||
# Frozen `(elapsed, last_in, last_out)` stamped the instant the user aborts
|
||
# (Ctrl+C / `/stop`). Flips the live spinner to a settled "Stopping…" line so
|
||
# elapsed stops climbing while the LLM stream unwinds in the background.
|
||
_stop_summary: Optional[tuple] = field(default=None, repr=False)
|
||
# Per-(seg_hash, width) Text cache; survives fold-toggle re-mounts.
|
||
_seg_render_cache: dict = field(default_factory=dict, repr=False)
|
||
|
||
|
||
@dataclass
|
||
class AgentSession:
|
||
agent_id: int
|
||
name: str
|
||
agent: Any
|
||
thread: Optional[threading.Thread] = None
|
||
status: str = "idle"
|
||
messages: list[ChatMessage] = field(default_factory=list)
|
||
task_seq: int = 0
|
||
current_task_id: Optional[int] = None
|
||
current_display_queue: Optional[queue.Queue] = None
|
||
# Per-session input box state. Restored into the shared InputArea on session switch.
|
||
input_text: str = ""
|
||
input_history: list[str] = field(default_factory=list)
|
||
input_pastes: dict[int, str] = field(default_factory=dict)
|
||
input_paste_counter: int = 0
|
||
buffer: str = ""
|
||
# Per-session workspace/project-mode binding. Empty means ordinary mode.
|
||
workspace_name: str = ""
|
||
workspace_path: str = ""
|
||
workspace_link: str = ""
|
||
# Drives topbar heat-color ramp + elapsed label; set on first running tick.
|
||
_busy_since: Optional[float] = None
|
||
# Stamps running→idle; topbar dot flashes green for ~5s after.
|
||
_done_at: Optional[float] = None
|
||
# ask_user INTERRUPT events; drained by display thread on turn done.
|
||
ask_user_events: Any = field(default_factory=lambda: queue.Queue())
|
||
# Pending `{question:str}` after the user picks free-text in an ask_user
|
||
# picker; next submission becomes a 2-step "Ready to submit?" confirm.
|
||
free_text_pending: Optional[dict] = None
|
||
# Plan state: items + grace-period timers (3s farewell, 1.5s lost-grace).
|
||
plan_items: list = field(default_factory=list)
|
||
plan_complete_since: Optional[float] = None
|
||
plan_lost_since: Optional[float] = None
|
||
# Boundary between restored history (≤ idx) and this run (> idx); only
|
||
# `current_step`'s 📌-line scan uses it now — card activation no longer
|
||
# reads messages.
|
||
plan_scan_baseline: int = 0
|
||
# plan.md recovered from the transcript's structured `enter_plan_mode`
|
||
# tool_use by /continue (continue_cmd.find_plan_entry). Drives card
|
||
# activation alongside the live `working['in_plan_mode']` stash.
|
||
restored_plan_path: str = ""
|
||
# `pending`: raw user text for UI display ([queued #N] chip).
|
||
# `pending_wrapped`: same entries wrapped with the "complete current
|
||
# task first" supplementary phrasing, in the form actually appended
|
||
# to `_intervene`. Replay uses these so the exit-turn put_task
|
||
# carries the wrap context.
|
||
pending: list[str] = field(default_factory=list)
|
||
pending_wrapped: list[str] = field(default_factory=list)
|
||
pending_lk: threading.Lock = field(default_factory=threading.Lock)
|
||
|
||
|
||
def default_agent_factory() -> Any:
|
||
from agentmain import GenericAgent
|
||
from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT
|
||
agent = GenericAgent()
|
||
agent.inc_out = True
|
||
agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT)
|
||
return agent
|
||
|
||
|
||
# ---------- commands ----------
|
||
COMMANDS = [
|
||
("/help", "", "显示帮助"),
|
||
("/status", "", "查看会话状态"),
|
||
("/sessions", "", "列出所有会话"),
|
||
("/new", "[name]", "新建并切换到新会话"),
|
||
("/switch", "<id|name>", "切换到指定会话"),
|
||
("/close", "", "关闭当前会话"),
|
||
("/rename", "<name>", "重命名当前会话(持久化)"),
|
||
("/branch", "[name]", "从当前会话分支"),
|
||
("/rewind", "[n]", "回退面板(时间线)/ 直接回退 n 轮"),
|
||
("/worldline", "", "世界线 — checkpoint 树状回退(打开即选中当前节点)"),
|
||
("/clear", "", "清空显示(不动 LLM 历史)"),
|
||
("/stop", "", "中止当前任务"),
|
||
("/llm", "[n]", "查看 / 切换模型"),
|
||
("/model", "[name]", "查看 / 设置当前渠道的 model(列表在线拉取)"),
|
||
("/effort", "[level]", "查看 / 设置 reasoning effort(off 清除;Claude xhigh→max)"),
|
||
("/btw", "<question>", "side question — 不打断主 agent"),
|
||
("/review", "[request]", "in-session 代码审查(直接输出报告)"),
|
||
# ── slash_cmds bundle (prompt-injection + /scheduler picker). Kept in
|
||
# the same table so /-completion + the palette pick them up for free.
|
||
("/update", "[note]", "git pull 更新 GA 仓库并报告影响面"),
|
||
("/autorun", "[seed]", "进入 autonomous_operation 自主模式"),
|
||
("/morphling", "[target]", "启用 Morphling 蒸馏 / 吞噬外部技能"),
|
||
("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"),
|
||
("/hive", "[target]", "进入 Hive 多 worker 协作模式"),
|
||
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
|
||
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
|
||
("/continue", "[n|name]", "列出 / 恢复历史会话"),
|
||
("/workspace","[path|off]", "设定工作目录(绝对路径)并进入项目模式"),
|
||
("/resume", "", "列出最近会话并恢复其中一个"),
|
||
("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"),
|
||
("/export", "clip|<file>|all", "导出最后回复"),
|
||
("/restore", "", "恢复上次模型响应日志"),
|
||
("/reload-keys", "", "重新加载mykey.py(不重启)"),
|
||
("/quit", "", "退出"),
|
||
]
|
||
|
||
|
||
# ---------- widgets ----------
|
||
# Picker sentinels — opaque values routed through `_collapse_choice` so any
|
||
# kind of picker can hand off to the same handlers.
|
||
# FREE_TEXT — user wants to type a free-form answer instead of picking
|
||
# EDIT_ANSWER — back from the submit-confirmation, go re-edit the draft
|
||
FREE_TEXT_CHOICE = "\x00__free_text__"
|
||
FREE_TEXT_LABEL = "Type something"
|
||
EDIT_ANSWER_CHOICE = "\x00__edit_answer__"
|
||
|
||
|
||
class ChoiceList(OptionList):
|
||
BINDINGS = [*OptionList.BINDINGS,
|
||
Binding("right", "select", "Select", show=False),
|
||
# `left` mirrors Esc — pickers spawned with an on_cancel
|
||
# (e.g. /scheduler's submit-confirm card → rollback to
|
||
# picker) get a directional way to back out without
|
||
# reaching for Esc. Choices without an on_cancel just
|
||
# dismiss, same as Esc.
|
||
Binding("left", "cancel", "Back", show=False),
|
||
Binding("escape", "cancel", "Cancel", show=False)]
|
||
|
||
def __init__(self, msg: "ChatMessage", *options, **kwargs):
|
||
super().__init__(*[self._single_line(o) for o in options], **kwargs)
|
||
self.msg = msg
|
||
|
||
@staticmethod
|
||
def _single_line(item):
|
||
# A str prompt → a no-wrap, ellipsis-on-overflow Option so a long
|
||
# candidate (e.g. a workspace `name · /very/long/path · …`) stays on
|
||
# exactly one row instead of soft-wrapping into several. Already-built
|
||
# Option / None pass through untouched.
|
||
if isinstance(item, str):
|
||
return Option(Text(item, no_wrap=True, overflow="ellipsis"))
|
||
return item
|
||
|
||
def add_option(self, option=None):
|
||
return super().add_option(self._single_line(option))
|
||
|
||
def add_options(self, items):
|
||
return super().add_options([self._single_line(i) for i in items])
|
||
|
||
def action_cancel(self) -> None:
|
||
try:
|
||
self.app._cancel_choice(self.msg)
|
||
except Exception:
|
||
pass
|
||
|
||
def on_key(self, event) -> None:
|
||
# Inside `/continue`'s SearchablePicker, Up on the first row returns
|
||
# focus to the search box (mirrors Down going search → list), closing
|
||
# the navigation loop. No-op for ChoiceLists mounted outside a
|
||
# SearchablePicker (other pickers have no `_search_input` parent), so
|
||
# this stays scoped to `/continue`.
|
||
if event.key != "up":
|
||
return
|
||
search = getattr(self.parent, "_search_input", None)
|
||
if search is None:
|
||
return
|
||
if self.highlighted not in (None, 0):
|
||
return
|
||
try:
|
||
# Clear the highlight on the way out so the search box doesn't show
|
||
# row 0 as still-selected, and the next Down re-enters at the first
|
||
# row (cursor_down from None → 0) instead of skipping to the second.
|
||
self.highlighted = None
|
||
search.focus()
|
||
except Exception:
|
||
pass
|
||
event.stop(); event.prevent_default()
|
||
|
||
|
||
class LazyChoiceList(ChoiceList):
|
||
"""ChoiceList that materializes options in bounded batches.
|
||
|
||
Why: `/continue` can list thousands of historical sessions; mounting every
|
||
`Option` up-front stalls Textual's render pipeline for ~hundreds of ms and
|
||
inflates the row cache. We mount the first `batch` rows immediately so the
|
||
picker is interactive on first paint, then extend the mounted set as the
|
||
cursor approaches the loaded tail (Down/PageDown/End) or as the user asks
|
||
for the last row from the top via Up — see `action_cursor_up`.
|
||
|
||
Back-end contract: ChoiceList already accepts whatever the picker's
|
||
`highlighted` Option's prompt is — the consumer code uses the index via
|
||
`msg.choices`. Lazy only changes *when* rows enter the DOM, not the value
|
||
contract. Falls back to eager super() behaviour for empty / tiny lists.
|
||
"""
|
||
|
||
def __init__(self, msg: "ChatMessage", labels: list, batch: int = 50, **kwargs):
|
||
self._lazy_labels = list(labels or [])
|
||
self._lazy_loaded = 0
|
||
self._lazy_batch = max(1, int(batch or 50))
|
||
super().__init__(msg, **kwargs)
|
||
# Mount the first batch synchronously so the picker is usable on the
|
||
# very first frame; remaining rows stream in on demand.
|
||
self._load_more(self._lazy_batch)
|
||
|
||
@property
|
||
def _has_more(self) -> bool:
|
||
return self._lazy_loaded < len(self._lazy_labels)
|
||
|
||
def _load_more(self, count: Optional[int] = None) -> bool:
|
||
if not self._has_more:
|
||
return False
|
||
take = (len(self._lazy_labels) - self._lazy_loaded) if count is None else max(1, int(count))
|
||
end = min(len(self._lazy_labels), self._lazy_loaded + take)
|
||
try:
|
||
self.add_options([self._lazy_labels[i] for i in range(self._lazy_loaded, end)])
|
||
except Exception:
|
||
# If the list isn't mounted yet (very early call), fall back to
|
||
# buffering via _options if available; otherwise silently bail so
|
||
# the eager half still works.
|
||
return False
|
||
self._lazy_loaded = end
|
||
return True
|
||
|
||
def _ensure_window(self) -> None:
|
||
"""Extend the loaded window when the cursor nears the tail."""
|
||
hi = self.highlighted
|
||
if hi is None or not self._has_more:
|
||
return
|
||
if hi >= max(0, self._lazy_loaded - 5):
|
||
self._load_more(self._lazy_batch)
|
||
|
||
def action_cursor_down(self) -> None:
|
||
before = self.highlighted
|
||
super().action_cursor_down()
|
||
# If Down had no effect (cursor was at the last loaded row), extend.
|
||
if self.highlighted == before and self._has_more:
|
||
if self._load_more(self._lazy_batch):
|
||
super().action_cursor_down()
|
||
self._ensure_window()
|
||
|
||
def action_page_down(self) -> None:
|
||
# PageDown can leap ~10 rows at once; pre-extend by a full batch so the
|
||
# visible window doesn't get capped by the load horizon.
|
||
if self._has_more:
|
||
self._load_more(self._lazy_batch)
|
||
super().action_page_down()
|
||
self._ensure_window()
|
||
|
||
def action_last(self) -> None:
|
||
# End/Last must reveal the genuine last session, not the last *loaded*
|
||
# row. Load everything (one-shot, no batching loop) then defer to super.
|
||
if self._has_more:
|
||
self._load_more(None)
|
||
super().action_last()
|
||
|
||
def action_cursor_up(self) -> None:
|
||
# OptionList wraps Up-at-row-0 to the last *mounted* row. With lazy
|
||
# loading that would land on row 99, not on the actual most-recent
|
||
# session. Detect the wrap intent and redirect to the real tail.
|
||
cur = self.highlighted
|
||
if (cur in (None, 0)) and self._has_more:
|
||
self._load_more(None)
|
||
try:
|
||
self.highlighted = len(self._lazy_labels) - 1
|
||
return
|
||
except Exception:
|
||
pass
|
||
super().action_cursor_up()
|
||
|
||
|
||
def _filter_choices(all_choices: list, query: str) -> list:
|
||
"""Case-insensitive multi-term filter for `/continue` style pickers.
|
||
|
||
`all_choices` is `[(label, value), ...]`. Each whitespace-separated token
|
||
in `query` must hit somewhere in either:
|
||
* the label text (cheap, always tried first), or
|
||
* the **full** `value` when it's a string (e.g. a workspace's complete
|
||
real path — so a mid-path directory still matches even though the
|
||
displayed label elides the middle; display-layer truncation must not
|
||
shrink the searchable data), or
|
||
* the **content** of the session file at `value` (first ~1MB), so users
|
||
who remember a phrase from inside a session ("Conductor", "subB diff",
|
||
a file path they pasted) can find it back.
|
||
|
||
Empty/whitespace query short-circuits to the full list. Lives at module
|
||
scope so the smoke test can exercise it without booting the TUI.
|
||
"""
|
||
q = (query or "").strip().lower()
|
||
if not q:
|
||
return list(all_choices or [])
|
||
terms = [t for t in q.split() if t]
|
||
if not terms:
|
||
return list(all_choices or [])
|
||
|
||
# Lazy import: continue_cmd already lives next to this module and provides
|
||
# the bounded-window file grep. We keep the import inside the function so
|
||
# other (non-/continue) pickers don't pay for it on app startup.
|
||
try:
|
||
from . import continue_cmd as _cc
|
||
except Exception:
|
||
try:
|
||
import continue_cmd as _cc # type: ignore
|
||
except Exception:
|
||
_cc = None
|
||
|
||
out = []
|
||
for item in (all_choices or []):
|
||
try:
|
||
label, value = item[0], item[1]
|
||
except (TypeError, IndexError):
|
||
continue
|
||
meta = str(label).lower()
|
||
if isinstance(value, str) and value:
|
||
# Full value, not just basename: workspace pickers put the complete
|
||
# real path here, and the displayed label elides the middle — search
|
||
# must see the whole path so a mid-path term still matches.
|
||
meta = meta + "\n" + value.lower()
|
||
if all(t in meta for t in terms):
|
||
out.append(item)
|
||
continue
|
||
# Fall back to session-file content grep so phrases that only appear
|
||
# inside the conversation (not in the one-line preview label) still
|
||
# surface. Path-shaped string values only — non-path values skip.
|
||
if (
|
||
_cc is not None
|
||
and isinstance(value, str)
|
||
and value
|
||
and os.path.isfile(value)
|
||
and _cc.file_contains_all(value, terms)
|
||
):
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
class SearchableChoiceList(Vertical):
|
||
"""Picker wrapper: an Input filter on top of an inner ChoiceList.
|
||
|
||
Only used when `ChatMessage.searchable=True` (today: `/continue`). Other
|
||
pickers keep mounting `ChoiceList` / `LazyChoiceList` / `MultiChoiceList`
|
||
directly so this code path has zero blast radius outside `/continue`.
|
||
|
||
The inner picker is rebuilt on every query change because OptionList
|
||
doesn't expose a stable "replace all options" primitive that plays nice
|
||
with the lazy-loading subclass. Rebuilds are cheap relative to the user's
|
||
typing cadence and use the same eager/lazy threshold as the original
|
||
`_mount_message` (≤50 eager, >50 lazy).
|
||
"""
|
||
|
||
LAZY_THRESHOLD = 50
|
||
|
||
def __init__(self, msg: "ChatMessage", initial_picker: Optional[OptionList] = None, **kwargs):
|
||
super().__init__(**kwargs)
|
||
self.msg = msg
|
||
self._search_input: Optional[Input] = None
|
||
# `initial_picker` is the eager/lazy widget that `_mount_message`
|
||
# already built from the unfiltered choices. We reuse it on first
|
||
# mount so the eager/lazy decision stays in one place.
|
||
self.picker: Optional[OptionList] = initial_picker
|
||
|
||
def compose(self):
|
||
self._search_input = Input(
|
||
value=self.msg.search_query or "",
|
||
placeholder=(self.msg.search_placeholder
|
||
or "Search sessions: type to filter, Esc to cancel"),
|
||
id="continue-search",
|
||
)
|
||
yield self._search_input
|
||
if self.picker is None:
|
||
self.picker = self._build_picker(self.msg.choices)
|
||
yield self.picker
|
||
|
||
def on_mount(self) -> None:
|
||
# First paint: the inner picker was just yielded from compose, but a
|
||
# LazyChoiceList populates its rows across later refresh passes. Defer
|
||
# a scroll so we pin the *settled* wrapper height into view rather than
|
||
# racing the lazy fill (see _rescroll_into_view).
|
||
self._rescroll_into_view()
|
||
|
||
def _rescroll_into_view(self) -> None:
|
||
"""Pin this picker into the viewport after its inner list (re)mounts.
|
||
|
||
The inner LazyChoiceList fills its option rows across refresh passes,
|
||
so the wrapper's final height isn't known until after the next layout.
|
||
Scrolling synchronously here — or relying solely on the single
|
||
deferred scroll_end in `_mount_message` — can fire before those rows
|
||
land, leaving the options below the fold (the `/continue` bug seen
|
||
with a populated history). Deferring our own `scroll_visible()` to
|
||
after the next refresh guarantees we scroll against the settled
|
||
height. Covers both first mount and every query rebuild. Guarded: a
|
||
harmless no-op if the widget is already detached.
|
||
"""
|
||
def _do():
|
||
try:
|
||
self.scroll_visible(animate=False)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.call_after_refresh(_do)
|
||
except Exception:
|
||
_do()
|
||
|
||
def _build_picker(self, choices: list) -> ChoiceList:
|
||
labels = [lbl for lbl, _ in choices]
|
||
# `classes="picker"` is what lets the OptionList.picker CSS rule
|
||
# (`max-height: 12`) clamp the inner list's physical height. Without
|
||
# it the inner ChoiceList falls back to OptionList's default
|
||
# `max-height: 100%`, which — combined with this wrapper being a
|
||
# plain Vertical (height: 1fr inside a VerticalScroll → content-sized)
|
||
# — lets the picker grow to ≈50 rows and push the head / role / search
|
||
# input above the viewport fold on `/continue`. The outer wrapper
|
||
# already carries `classes="picker"` from `_mount_message`, but that
|
||
# selector is type-qualified (`OptionList.picker, SelectionList.picker`)
|
||
# so it does NOT match the Vertical wrapper — only the inner list it
|
||
# builds can claim the height cap. (Root-cause fix 2026-05-27.)
|
||
if len(choices) > self.LAZY_THRESHOLD:
|
||
return LazyChoiceList(self.msg, labels, batch=self.LAZY_THRESHOLD, classes="picker")
|
||
return ChoiceList(self.msg, *labels, classes="picker")
|
||
|
||
# Debounce window for incremental filtering. Content-grep across ~270
|
||
# session files costs ~0.2s; running it per keystroke makes the Input
|
||
# feel laggy. Wait until the user pauses for this many seconds before
|
||
# rebuilding the picker. Empty query still applies immediately so a
|
||
# Ctrl+U / backspace-to-empty restores the full list with no perceptible
|
||
# delay. Tuned 2026-05-27 on user feedback ("每输入一个 char 都会立马搜索").
|
||
DEBOUNCE_SEC = 0.22
|
||
|
||
def on_input_changed(self, event) -> None:
|
||
if event.input is not self._search_input:
|
||
return
|
||
query = event.value or ""
|
||
self.msg.search_query = query
|
||
# Cancel any pending rebuild from a previous keystroke — last input
|
||
# wins, so we never grep for an intermediate prefix the user has
|
||
# already moved past.
|
||
prev = getattr(self, "_debounce_timer", None)
|
||
if prev is not None:
|
||
try:
|
||
prev.stop()
|
||
except Exception:
|
||
pass
|
||
self._debounce_timer = None
|
||
# Empty query: clearing the box should feel instant, no debounce.
|
||
if not query.strip():
|
||
self._apply_filter(query)
|
||
return
|
||
# Otherwise schedule a single deferred rebuild.
|
||
try:
|
||
self._debounce_timer = self.set_timer(
|
||
self.DEBOUNCE_SEC,
|
||
lambda q=query: self._apply_filter(q),
|
||
)
|
||
except Exception:
|
||
# Fallback: if set_timer is unavailable for any reason, apply
|
||
# synchronously so search at least still works.
|
||
self._apply_filter(query)
|
||
|
||
def _apply_filter(self, query: str) -> None:
|
||
"""Rebuild the picker for `query`. Called from the debounce timer or
|
||
directly for the empty-query fast path. Safe to call after the widget
|
||
has been unmounted (guards every DOM op)."""
|
||
self._debounce_timer = None
|
||
# If the input value has moved on while we were waiting, skip this
|
||
# stale rebuild — a fresher timer will land shortly with the latest
|
||
# text. This keeps fast typing snappy without queueing grep work.
|
||
try:
|
||
current = self._search_input.value if self._search_input else query
|
||
except Exception:
|
||
current = query
|
||
if (current or "") != (query or ""):
|
||
return
|
||
filtered = _filter_choices(self.msg.all_choices or [], query)
|
||
self.msg.choices = filtered
|
||
# Remove the old picker before mounting a new one. `remove()` is sync
|
||
# enough for our needs — Textual flushes the DOM before the next paint.
|
||
if self.picker is not None:
|
||
try:
|
||
self.picker.remove()
|
||
except Exception:
|
||
pass
|
||
self.picker = None
|
||
if not filtered:
|
||
# Show a disabled hint row so Enter on an empty result set is a
|
||
# no-op rather than a crash inside _collapse_choice.
|
||
empty = ChoiceList(self.msg, self.msg.empty_hint or "(no matches)", classes="picker")
|
||
try:
|
||
empty.disabled = True
|
||
except Exception:
|
||
pass
|
||
self.picker = empty
|
||
else:
|
||
self.picker = self._build_picker(filtered)
|
||
try:
|
||
self.mount(self.picker)
|
||
except Exception:
|
||
# Widget likely unmounted between the timer firing and now (e.g.
|
||
# user pressed Esc). Drop silently — nothing to render into.
|
||
return
|
||
# A rebuilt result set changes the wrapper height; re-pin it into view
|
||
# so a query that shrinks/grows the list never leaves the picker (or
|
||
# the search Input) stranded below the fold. Same deferred-scroll
|
||
# rationale as first mount.
|
||
self._rescroll_into_view()
|
||
|
||
def on_key(self, event) -> None:
|
||
# While the Input has focus, redirect navigation keys to the picker so
|
||
# the user can keep typing yet still drive selection. Enter/Right on
|
||
# the Input commits the current highlight.
|
||
if self._search_input is None or self.picker is None:
|
||
return
|
||
if not self._search_input.has_focus:
|
||
return
|
||
key = event.key
|
||
if key == "up":
|
||
# Up from the search box wraps around to the BOTTOM of the list, so
|
||
# the loop is search ↓→ list top ... list top ↑→ search ↑→ list
|
||
# bottom. Land on the last row directly.
|
||
try:
|
||
self.picker.focus()
|
||
last = getattr(self.picker, "action_last", None)
|
||
if last is not None:
|
||
last()
|
||
else:
|
||
n = getattr(self.picker, "option_count", 0)
|
||
if n:
|
||
self.picker.highlighted = n - 1
|
||
except Exception:
|
||
pass
|
||
event.stop(); event.prevent_default()
|
||
return
|
||
if key in ("down", "pageup", "pagedown", "home", "end"):
|
||
try:
|
||
self.picker.focus()
|
||
# Replay one step so the very first arrow doesn't get swallowed
|
||
# by the focus change. Subsequent arrows go straight to the picker.
|
||
action = {
|
||
"down": self.picker.action_cursor_down,
|
||
"pagedown": getattr(self.picker, "action_page_down", None),
|
||
"pageup": getattr(self.picker, "action_page_up", None),
|
||
"home": getattr(self.picker, "action_first", None),
|
||
"end": getattr(self.picker, "action_last", None),
|
||
}.get(key)
|
||
if action is not None:
|
||
action()
|
||
except Exception:
|
||
pass
|
||
event.stop(); event.prevent_default()
|
||
return
|
||
if key == "right":
|
||
# Right commits the highlight ONLY when the caret is already at the
|
||
# end of the query — otherwise let the Input consume it so Right
|
||
# still moves the caret within the search text (the box must stay
|
||
# editable). Without this guard Right was always swallowed and the
|
||
# cursor could never move right inside `/continue`'s search box.
|
||
try:
|
||
at_end = self._search_input.cursor_position >= len(self._search_input.value or "")
|
||
except Exception:
|
||
at_end = True
|
||
if not at_end:
|
||
return
|
||
if key in ("enter", "right"):
|
||
# Don't go through picker.action_select() here: the OptionSelected
|
||
# it posts is constructed while *this* widget's pump is active, so
|
||
# its _sender == picker's parent and Textual auto-stops the bubble
|
||
# one hop up — the App handler never sees it. Collapse directly.
|
||
committed = False
|
||
try:
|
||
hi = self.picker.highlighted
|
||
opts = getattr(self.picker, "_options", [])
|
||
# Eligibility must be checked here, not inferred from
|
||
# _collapse_choice "not raising": it silently no-ops on an
|
||
# out-of-range idx (the disabled empty-hint picker highlights
|
||
# its placeholder row at 0 while msg.choices is []).
|
||
if (hi is not None and 0 <= hi < len(self.picker.msg.choices)
|
||
and hi < len(opts) and not opts[hi].disabled):
|
||
self.app._collapse_choice(self.picker.msg, hi)
|
||
committed = True
|
||
except Exception:
|
||
pass
|
||
# Free-input pickers (/model): Enter with no selectable match
|
||
# commits the raw query as the value — the search box doubles as
|
||
# a custom-name input (list still loading / fetch failed / name
|
||
# not in the list).
|
||
if (not committed and key == "enter"
|
||
and getattr(self.msg, "free_input", False)):
|
||
q = ""
|
||
try:
|
||
q = (self._search_input.value or "").strip()
|
||
except Exception:
|
||
pass
|
||
if q:
|
||
try:
|
||
self.msg.choices = list(self.msg.choices or []) + [(q, q)]
|
||
self.app._collapse_choice(self.msg, len(self.msg.choices) - 1)
|
||
except Exception:
|
||
pass
|
||
event.stop(); event.prevent_default()
|
||
return
|
||
|
||
|
||
class MultiChoiceList(SelectionList):
|
||
"""Multi-select variant of ChoiceList. Space toggles, Enter submits all
|
||
checked items joined by `; `. Esc cancels back to free-text input.
|
||
|
||
SelectionList expects `Selection` objects as positional args, so we
|
||
forward `*selections` through. The `msg` kwarg is ours.
|
||
"""
|
||
BINDINGS = [*SelectionList.BINDINGS,
|
||
Binding("enter", "submit", "Submit", show=True),
|
||
Binding("escape", "cancel", "Cancel", show=False)]
|
||
|
||
def __init__(self, msg: "ChatMessage", *selections, **kwargs):
|
||
super().__init__(*selections, **kwargs)
|
||
self.msg = msg
|
||
|
||
def action_submit(self) -> None:
|
||
try:
|
||
self.app._finalize_multi_choice(self.msg, list(self.selected))
|
||
except Exception:
|
||
pass
|
||
|
||
def action_cancel(self) -> None:
|
||
try:
|
||
self.app._cancel_choice(self.msg)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class SelectableStatic(Static):
|
||
# PR #461: a SelectableStatic that gets removed from the DOM but whose
|
||
# reference still lingers (e.g. cached in a closure) was firing mouse
|
||
# selection on stale screen coordinates. has_valid_selection_parent
|
||
# is the cheap "am I still in the tree?" probe used by the screen-
|
||
# level mouse-event filter (`_is_stale_selectable_mouse_event`).
|
||
def has_valid_selection_parent(self) -> bool:
|
||
return isinstance(self.parent, Widget)
|
||
|
||
# Widget.get_selection returns None for non-Text/Content visuals; fall back to render_line.
|
||
def get_selection(self, selection):
|
||
render = getattr(self, "_ga_render", None)
|
||
if render is not None:
|
||
return _extract_md_render(render, selection), "\n"
|
||
result = super().get_selection(selection)
|
||
if result is not None:
|
||
return result
|
||
height = self.size.height
|
||
if height <= 0:
|
||
return None
|
||
lines = []
|
||
for y in range(height):
|
||
try:
|
||
strip = self.render_line(y)
|
||
except Exception:
|
||
lines.append("")
|
||
continue
|
||
lines.append("".join(seg.text for seg in strip))
|
||
if not lines:
|
||
return None
|
||
return selection.extract("\n".join(lines)), "\n"
|
||
|
||
|
||
def _extract_md_render(render, selection) -> str:
|
||
starts = render.line_starts
|
||
indents = render.line_indents
|
||
lens = render.line_lengths
|
||
n = len(starts)
|
||
if n == 0:
|
||
return ""
|
||
|
||
if selection.start is None:
|
||
s_y, s_x = 0, 0
|
||
else:
|
||
s_y, s_x = selection.start.y, selection.start.x
|
||
if selection.end is None:
|
||
e_y, e_x = n - 1, lens[n - 1]
|
||
else:
|
||
e_y, e_x = selection.end.y, selection.end.x
|
||
|
||
s_y = max(0, min(s_y, n - 1))
|
||
e_y = max(0, min(e_y, n - 1))
|
||
|
||
def col(y, x):
|
||
ind = indents[y]
|
||
total = lens[y]
|
||
content_len = max(0, total - ind)
|
||
if x <= ind:
|
||
return 0
|
||
return min(x - ind, content_len)
|
||
|
||
return render.source[starts[s_y] + col(s_y, s_x): starts[e_y] + col(e_y, e_x)]
|
||
|
||
|
||
class FoldHeader(SelectableStatic):
|
||
# Clickable collapsed/expanded turn header. App.on_click reads .msg/.fold_idx
|
||
# to toggle msg._toggled_folds and remount the segments around this widget.
|
||
def __init__(self, body, msg, fold_idx, **kwargs):
|
||
super().__init__(body, **kwargs)
|
||
self.msg = msg
|
||
self.fold_idx = fold_idx
|
||
|
||
|
||
# User-message display elision: pastes get expanded to full content before send
|
||
# (agent needs the whole thing) but the user-visible message echo collapses the
|
||
# middle so the chat log doesn't get buried under a 1000-line dump.
|
||
_USER_DISPLAY_HEAD_LINES = 10
|
||
_USER_DISPLAY_TAIL_LINES = 5
|
||
_USER_DISPLAY_MAX_LINES = _USER_DISPLAY_HEAD_LINES + _USER_DISPLAY_TAIL_LINES + 4
|
||
|
||
|
||
def _elide_user_display(text: str) -> str:
|
||
"""Collapse middle of long user messages: keep head + tail, summarize gap."""
|
||
lines = text.split("\n")
|
||
n = len(lines)
|
||
if n <= _USER_DISPLAY_MAX_LINES:
|
||
return text
|
||
omitted = n - _USER_DISPLAY_HEAD_LINES - _USER_DISPLAY_TAIL_LINES
|
||
head = lines[:_USER_DISPLAY_HEAD_LINES]
|
||
tail = lines[-_USER_DISPLAY_TAIL_LINES:]
|
||
return "\n".join(head + [f"⋯ 省略 {omitted} 行 ⋯"] + tail)
|
||
|
||
|
||
def _read_clipboard_text() -> str:
|
||
try:
|
||
import tkinter as tk
|
||
r = tk.Tk(); r.withdraw()
|
||
try:
|
||
return r.clipboard_get() or ""
|
||
finally:
|
||
r.destroy()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico"}
|
||
|
||
|
||
def _grab_clipboard_file() -> Optional[tuple[str, bool]]:
|
||
"""Return (path, is_image) from clipboard. is_image distinguishes image files
|
||
(rendered inline as `[Image #N]`) from any other file (folded as `[File #N]`)."""
|
||
try:
|
||
from PIL import ImageGrab, Image
|
||
data = ImageGrab.grabclipboard()
|
||
except Exception:
|
||
return None
|
||
if isinstance(data, list):
|
||
for item in data:
|
||
if isinstance(item, str) and os.path.isfile(item):
|
||
is_img = os.path.splitext(item)[1].lower() in _IMAGE_EXTS
|
||
return (item, is_img)
|
||
return None
|
||
if isinstance(data, Image.Image):
|
||
try:
|
||
out_dir = os.path.join(tempfile.gettempdir(), "genericagent_tui_clipboard")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
path = os.path.join(out_dir, f"clipboard_{int(time.time() * 1000)}.png")
|
||
data.save(path, "PNG")
|
||
return (path, True)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
class InputArea(TextArea):
|
||
_PASTE_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]')
|
||
# `[Image #N]` is the folded form; expand_placeholders restores the raw path at submit time.
|
||
# The longer `[Image #N: ...]` form is tolerated for backward compatibility only.
|
||
_IMAGE_RE = re.compile(r'\[Image #(\d+)(?::[^\]]*)?\]')
|
||
_FILE_RE = re.compile(r'\[File #(\d+)\]')
|
||
_PLACEHOLDER_RES = (_PASTE_RE, _IMAGE_RE, _FILE_RE)
|
||
|
||
BINDINGS = [
|
||
Binding("ctrl+j", "newline", "Newline", show=False),
|
||
Binding("ctrl+enter", "newline", "Newline", show=False),
|
||
Binding("shift+enter", "newline", "Newline", show=False),
|
||
Binding("ctrl+v", "paste", "Paste", show=False),
|
||
# macOS muscle-memory alias: most terminals swallow Cmd+V (forward via bracketed
|
||
# paste → _on_paste); this only hits if the terminal forwards Cmd as a key.
|
||
Binding("cmd+v", "paste", "Paste", show=False),
|
||
# Ctrl+U: readline-style kill-line, repurposed here to clear the whole input.
|
||
Binding("ctrl+u", "clear_input", "ClearInput", show=False),
|
||
# Ctrl+S: toggle-stash the current draft. First press → stash
|
||
# text + clear input; second press on empty input → restore the
|
||
# stashed draft. Independent of Up/Down history so a queued
|
||
# draft survives sending the previous one. reset() uses
|
||
# TextArea.clear() to avoid the document-rebuild path that
|
||
# blocked the UI for seconds on long sessions.
|
||
Binding("ctrl+s", "stash", "Stash", show=False),
|
||
Binding("cmd+s", "stash", "Stash", show=False),
|
||
]
|
||
|
||
def action_noop(self) -> None:
|
||
pass
|
||
|
||
def action_stash(self) -> None:
|
||
"""Stash/restore the input draft. reset()/text restore both defer
|
||
to `call_after_refresh` so the layout cascade runs off the
|
||
keystroke event, leaving Ctrl+S itself snappy on long sessions."""
|
||
current = self.text
|
||
if current:
|
||
self._draft_stash = current
|
||
self._history_index = -1
|
||
self._history_stash = ""
|
||
try:
|
||
self.app.call_after_refresh(self._stash_cleanup_clear)
|
||
except Exception:
|
||
# Last-resort synchronous fallback (re-introduces the freeze
|
||
# window but at least keeps the function correct).
|
||
self._stash_cleanup_clear()
|
||
elif self._draft_stash:
|
||
stashed = self._draft_stash
|
||
self._draft_stash = ""
|
||
self._history_index = -1
|
||
self._history_stash = ""
|
||
try:
|
||
self.app.call_after_refresh(self._stash_cleanup_restore, stashed)
|
||
except Exception:
|
||
self._stash_cleanup_restore(stashed)
|
||
|
||
def _stash_cleanup_clear(self) -> None:
|
||
"""Deferred companion to action_stash (clear path). The Changed
|
||
event posted by `clear()` is async-queued — set the flag and let
|
||
`on_text_area_changed` self-clear it when the event lands. A
|
||
try/finally here clears the flag too early and lets the handler
|
||
re-run the heavy resize + palette path."""
|
||
self._skip_change_next = True
|
||
self.reset()
|
||
try: self.app._hide_palette()
|
||
except Exception: pass
|
||
try: self.app._resize_input(self)
|
||
except Exception: pass
|
||
|
||
def _stash_cleanup_restore(self, stashed: str) -> None:
|
||
"""Deferred companion to action_stash (restore path). Mirrors the
|
||
clear path: `self.text = stashed` rebuilds Document + WrappedDocument
|
||
and triggers a full re-wrap + screen-wide relayout, which freezes the
|
||
UI for seconds on long sessions. Inject through the edit pipeline
|
||
instead so only the affected range re-wraps; `_insert_via_keyboard`
|
||
also moves the caret to the end, re-focuses, and resizes."""
|
||
try: self._suppress_palette_next_change()
|
||
except Exception: pass
|
||
if self.document.text:
|
||
self.clear()
|
||
self._insert_via_keyboard(stashed)
|
||
|
||
def action_clear_input(self) -> None:
|
||
self.reset()
|
||
self._history_index = -1
|
||
self._history_stash = ""
|
||
try:
|
||
self.app._hide_palette()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.app._resize_input(self)
|
||
except Exception:
|
||
pass
|
||
|
||
def _insert_via_keyboard(self, text: str) -> None:
|
||
result = self._replace_via_keyboard(text, *self.selection)
|
||
if result:
|
||
self.move_cursor(result.end_location)
|
||
self.focus()
|
||
try:
|
||
self.app._resize_input(self)
|
||
except Exception:
|
||
pass
|
||
|
||
def _paste_file_from_clipboard(self) -> bool:
|
||
result = _grab_clipboard_file()
|
||
if not result:
|
||
return False
|
||
path, is_image = result
|
||
self._paste_counter += 1
|
||
sid = self._paste_counter
|
||
self._pastes[sid] = path
|
||
marker = f"[Image #{sid}]" if is_image else f"[File #{sid}]"
|
||
self._insert_via_keyboard(marker)
|
||
return True
|
||
|
||
def _insert_paste_text(self, text: str) -> None:
|
||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||
line_count = len(text.splitlines()) or 1
|
||
if line_count > 2:
|
||
self._paste_counter += 1
|
||
sid = self._paste_counter
|
||
self._pastes[sid] = text
|
||
text = f"[Pasted text #{sid} +{line_count} lines]"
|
||
self._insert_via_keyboard(text)
|
||
|
||
def _paste_gesture_echo(self, source: str) -> bool:
|
||
"""One VSCode right-click can emit BOTH a forwarded mouse-click
|
||
(→ action_paste, source='manual') and a native bracketed paste
|
||
(→ _on_paste, source='bracketed'), pasting the clipboard twice. Treat the
|
||
second arrival from the *other* mechanism within a short window as an echo
|
||
and report it so the caller can skip. Same-mechanism repeats (a deliberate
|
||
double Ctrl+V) and lone gestures are never suppressed."""
|
||
now = time.monotonic()
|
||
prev = self._last_paste_gesture
|
||
if prev and prev[0] != source and now - prev[1] < 0.5:
|
||
self._last_paste_gesture = None # pair consumed; next gesture starts clean
|
||
return True
|
||
self._last_paste_gesture = (source, now)
|
||
return False
|
||
|
||
def action_paste(self) -> None:
|
||
if self.read_only or self._paste_gesture_echo("manual"):
|
||
return
|
||
if self._paste_file_from_clipboard():
|
||
return
|
||
text = _read_clipboard_text() or getattr(self.app, "clipboard", "")
|
||
if text:
|
||
self._insert_paste_text(text)
|
||
|
||
def action_paste_file(self) -> None:
|
||
self._paste_file_from_clipboard()
|
||
|
||
def _placeholder_adjacent(self, side: str) -> Optional[tuple[int, int, int, int]]:
|
||
"""Return (row, start_col, end_col, sid) if a placeholder is flush against
|
||
the caret on the given side ('left' = backspace target, 'right' = delete target)."""
|
||
if self.selection.start != self.selection.end:
|
||
return None
|
||
row, col = self.cursor_location
|
||
try:
|
||
line = self.text.split("\n")[row]
|
||
except IndexError:
|
||
return None
|
||
for pat in self._PLACEHOLDER_RES:
|
||
for m in pat.finditer(line):
|
||
edge = m.end() if side == "left" else m.start()
|
||
if edge == col:
|
||
return (row, m.start(), m.end(), int(m.group(1)))
|
||
return None
|
||
|
||
def _delete_placeholder(self, side: str) -> bool:
|
||
hit = self._placeholder_adjacent(side)
|
||
if not hit:
|
||
return False
|
||
row, start, end, sid = hit
|
||
self.delete((row, start), (row, end))
|
||
self._pastes.pop(sid, None)
|
||
try:
|
||
self.app._resize_input(self)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
def action_delete_left(self) -> None:
|
||
if not self._delete_placeholder("left"):
|
||
super().action_delete_left()
|
||
|
||
def action_delete_right(self) -> None:
|
||
if not self._delete_placeholder("right"):
|
||
super().action_delete_right()
|
||
|
||
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
||
# Right-button: short-circuit TextArea's default cursor-move so
|
||
# paste lands at the user's existing caret, not where their mouse
|
||
# happened to be — matches every native text-box right-click.
|
||
if getattr(event, "button", 0) == 3:
|
||
event.stop(); event.prevent_default()
|
||
return
|
||
await super()._on_mouse_down(event)
|
||
|
||
async def _on_click(self, event: events.Click) -> None:
|
||
if getattr(event, "button", 0) == 3 and not self.read_only:
|
||
self.action_paste()
|
||
event.stop(); event.prevent_default()
|
||
|
||
class Submitted(Message):
|
||
def __init__(self, input_area: "InputArea", value: str) -> None:
|
||
super().__init__()
|
||
self.input_area = input_area
|
||
self.value = value
|
||
|
||
def __init__(self, *args, **kwargs) -> None:
|
||
super().__init__(*args, **kwargs)
|
||
self._pastes: dict[int, str] = {}
|
||
self._paste_counter = 0
|
||
self._last_paste_gesture: Optional[tuple[str, float]] = None # (source, monotonic) — VSCode right-click double-paste guard
|
||
self._input_history: list[str] = []
|
||
self._history_index: int = -1 # -1 means not browsing
|
||
self._history_stash: str = ""
|
||
# Ctrl+S scratch draft (PR#479 semantics). Distinct from
|
||
# `_history_stash`, which is the Up/Down-arrow working buffer.
|
||
self._draft_stash: str = ""
|
||
# Set by `action_stash` to make on_input_area_changed bail out on
|
||
# the synchronous Changed event from `reset()` — the layout work
|
||
# is rescheduled via `call_after_refresh` so the keystroke handler
|
||
# returns immediately even when streaming has the reactive queue
|
||
# saturated. Cleared by `_stash_cleanup_clear`.
|
||
self._skip_change_next: bool = False
|
||
self._HISTORY_MAX = 200
|
||
|
||
def expand_placeholders(self, text: str) -> str:
|
||
def repl(m):
|
||
sid = int(m.group(1))
|
||
return self._pastes.get(sid, m.group(0))
|
||
for pat in self._PLACEHOLDER_RES:
|
||
text = pat.sub(repl, text)
|
||
return text
|
||
|
||
# ---- history public API ----
|
||
def record_history(self, raw_text: str) -> None:
|
||
stripped = raw_text.strip()
|
||
if not stripped:
|
||
return
|
||
if not (self._input_history and self._input_history[-1] == stripped):
|
||
self._input_history.append(stripped)
|
||
if len(self._input_history) > self._HISTORY_MAX:
|
||
self._input_history = self._input_history[-self._HISTORY_MAX:]
|
||
self._history_index = -1
|
||
self._history_stash = ""
|
||
|
||
def _suppress_palette_next_change(self) -> None:
|
||
# Single-shot guard against re-opening the palette during programmatic text changes.
|
||
self.app._suppress_palette_open = True
|
||
|
||
def _history_up(self) -> bool:
|
||
if not self._input_history:
|
||
return False
|
||
if self._history_index == -1:
|
||
self._history_stash = self.text
|
||
self._history_index = len(self._input_history) - 1
|
||
elif self._history_index > 0:
|
||
self._history_index -= 1
|
||
else:
|
||
return True # already at oldest — absorb the key
|
||
self._suppress_palette_next_change()
|
||
self.text = self._input_history[self._history_index]
|
||
return True
|
||
|
||
def _history_down(self) -> bool:
|
||
if self._history_index == -1:
|
||
return False
|
||
if self._history_index < len(self._input_history) - 1:
|
||
self._history_index += 1
|
||
new_text = self._input_history[self._history_index]
|
||
else:
|
||
self._history_index = -1
|
||
new_text = self._history_stash
|
||
self._suppress_palette_next_change()
|
||
self.text = new_text
|
||
return True
|
||
|
||
def reset(self) -> None:
|
||
# `self.text = ""` rebuilds Document + WrappedDocument and triggers
|
||
# a full re-wrap + `_refresh_size` layout cascade. On long
|
||
# sessions (100+ message widgets in the scroll), that cascade
|
||
# blocks the UI for seconds — perceived as freeze on Ctrl+S.
|
||
# `clear()` deletes in place via the edit pipeline and only
|
||
# re-wraps the affected range, so empty-out is O(content-len)
|
||
# without rebuilding the document object.
|
||
if self.document.text:
|
||
self.clear()
|
||
self._pastes.clear()
|
||
self._paste_counter = 0
|
||
self._history_index = -1
|
||
self._history_stash = ""
|
||
|
||
def action_newline(self) -> None:
|
||
self._insert_via_keyboard("\n")
|
||
|
||
def _shift_is_physically_down(self) -> bool:
|
||
"""Best-effort fallback for terminals/Textual builds that report Shift+Enter as plain Enter."""
|
||
if os.name != "nt":
|
||
return False
|
||
try:
|
||
import ctypes
|
||
# VK_SHIFT = 0x10. High bit means the key is currently down.
|
||
return bool(ctypes.windll.user32.GetAsyncKeyState(0x10) & 0x8000)
|
||
except Exception:
|
||
return False
|
||
|
||
async def _on_paste(self, event: events.Paste) -> None:
|
||
# Terminal Ctrl+V in bracketed-paste mode lands here, bypassing action_paste.
|
||
if self.read_only:
|
||
return
|
||
event.stop(); event.prevent_default()
|
||
# VSCode right-click fires this Paste AND a forwarded mouse-click
|
||
# (→ _on_click → action_paste); collapse the duplicate. See _paste_gesture_echo.
|
||
if self._paste_gesture_echo("bracketed"):
|
||
return
|
||
if self._paste_file_from_clipboard():
|
||
return
|
||
# Git-bash / mintty fallback: PIL.ImageGrab can't return Image objects
|
||
# in that TTY env, but the OS clipboard does hold the file path the
|
||
# screenshot tool wrote. Treat a single-line, on-disk path as if the
|
||
# file grab had succeeded — same placeholder + `_pastes` entry.
|
||
if self._paste_file_from_text(event.text):
|
||
return
|
||
self._insert_paste_text(event.text)
|
||
|
||
def _paste_file_from_text(self, raw: str) -> bool:
|
||
if not raw: return False
|
||
path = raw.strip().strip('"').strip("'")
|
||
if "\n" in path or "\r" in path: return False
|
||
if len(path) > 1024: return False
|
||
if not os.path.isfile(path): return False
|
||
is_image = os.path.splitext(path)[1].lower() in _IMAGE_EXTS
|
||
self._paste_counter += 1
|
||
sid = self._paste_counter
|
||
self._pastes[sid] = path
|
||
marker = f"[Image #{sid}]" if is_image else f"[File #{sid}]"
|
||
self._insert_via_keyboard(marker)
|
||
return True
|
||
|
||
async def _on_key(self, event: events.Key) -> None:
|
||
# 1) command palette routing
|
||
try:
|
||
palette = self.app.query_one("#palette", OptionList)
|
||
except Exception:
|
||
palette = None
|
||
if palette is not None and palette.has_class("-visible"):
|
||
routes = {"up": palette.action_cursor_up, "down": palette.action_cursor_down}
|
||
if event.key in {"enter", "right"} and palette.highlighted is not None:
|
||
routes[event.key] = palette.action_select
|
||
elif event.key == "left":
|
||
routes["left"] = self.app._hide_palette
|
||
fn = routes.get(event.key)
|
||
if fn:
|
||
fn(); event.stop(); event.prevent_default(); return
|
||
# 2) inline ChoiceList routing — borrow arrow keys without moving focus.
|
||
choice = getattr(self.app, "_active_choice", lambda: None)()
|
||
if choice is not None:
|
||
if event.key == "up":
|
||
choice.action_cursor_up(); event.stop(); event.prevent_default(); return
|
||
if event.key == "down":
|
||
choice.action_cursor_down(); event.stop(); event.prevent_default(); return
|
||
if event.key in ("enter", "right") and choice.highlighted is not None:
|
||
choice.action_select(); event.stop(); event.prevent_default(); return
|
||
if event.key == "escape":
|
||
self.app._cancel_choice(choice.msg); event.stop(); event.prevent_default(); return
|
||
# 3) history browse: only at (0,0) for up / end-of-text for down, so in-line
|
||
# cursor movement is preserved.
|
||
if event.key == "up" and self.cursor_location == (0, 0):
|
||
# Pending-queue recall removed: each Enter while running writes
|
||
# to `_intervene` immediately; popping back would leave a stale
|
||
# entry in the file. Up just walks input history; Esc clears.
|
||
if self._history_up():
|
||
event.stop(); event.prevent_default(); return
|
||
if event.key == "down":
|
||
row, col = self.cursor_location
|
||
lines = self.text.split("\n")
|
||
if row == len(lines) - 1 and col == len(lines[-1]):
|
||
if self._history_down():
|
||
event.stop(); event.prevent_default(); return
|
||
if event.key == "enter": # plain Enter submits; physical Shift+Enter inserts newline
|
||
if self._shift_is_physically_down():
|
||
event.stop(); event.prevent_default()
|
||
self.action_newline()
|
||
return
|
||
event.stop(); event.prevent_default()
|
||
self.post_message(self.Submitted(self, self.text))
|
||
return
|
||
if self._history_index != -1 and event.key not in ("up", "down", "left", "right"):
|
||
self._history_index = -1
|
||
await super()._on_key(event)
|
||
|
||
|
||
# ---------- top bar ----------
|
||
def _fmt_elapsed(secs: int) -> str:
|
||
if secs < 60: return f"{secs}s"
|
||
if secs < 3600: return f"{secs // 60}m {secs % 60:02d}s"
|
||
h, rem = divmod(secs, 3600); m, s = divmod(rem, 60)
|
||
return f"{h}h {m:02d}m {s:02d}s"
|
||
|
||
|
||
_TITLE_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||
|
||
# Done-flash window: dot stays green this many seconds after a run finishes.
|
||
_DONE_FLASH_SECS = 5
|
||
|
||
# Heat ramp for the running dot. Pale green → amber → deep orange → vivid red.
|
||
# The thresholds are deliberately non-linear: short runs stay cool, only past
|
||
# ~3min do we paint it red to signal "this is taking unusually long".
|
||
_HEAT_RAMP = (
|
||
(20, "#aae8aa"), # <20s pale mint
|
||
(60, "#d4a72c"), # <60s amber
|
||
(180, "#dc6b1f"), # <3min deep orange
|
||
(None, "bold #ff2c2c"), # ≥3min vivid red — "stuck?" warning
|
||
)
|
||
|
||
|
||
def _heat_color(elapsed: int) -> str:
|
||
"""Map a busy-elapsed in seconds to a Rich style for the running dot."""
|
||
for threshold, color in _HEAT_RAMP:
|
||
if threshold is None or elapsed < threshold:
|
||
return color
|
||
return _HEAT_RAMP[-1][1]
|
||
|
||
|
||
# Gerund (`Reticulating…`) easter-egg color ramp. Drives a two-axis heat:
|
||
# elapsed seconds + accumulated tokens. Cool blue → cyan → mint → amber → red.
|
||
# Each tier returns a Rich style string. Keep bands wide so the color rarely
|
||
# strobes between adjacent ticks.
|
||
_GERUND_RAMP = (
|
||
"#5e9fd6", # cool blue — fresh, < ~10s and < ~1k tokens
|
||
"#56d4d4", # cyan — warming up
|
||
"#7ec27e", # mint — settled cruise
|
||
"#d4a72c", # amber — taking a while
|
||
"#dc6b1f", # deep orange — long wait
|
||
"bold #ff2c2c", # vivid red — really stuck
|
||
)
|
||
|
||
|
||
def _gerund_color(elapsed: int, tokens: int) -> str:
|
||
"""Compose a tier index from elapsed (sec) + tokens, then index the ramp.
|
||
|
||
The two axes contribute additively so a tokenless 3-minute hang and a
|
||
fast-but-token-heavy run both walk up the ramp. Tiers are integer-clamped
|
||
to len(ramp)-1 so the worst case caps at the red band.
|
||
"""
|
||
t_tier = 0 if elapsed < 10 else 1 if elapsed < 30 else 2 if elapsed < 90 else 3 if elapsed < 180 else 4
|
||
k_tier = 0 if tokens < 1_000 else 1 if tokens < 10_000 else 2 if tokens < 50_000 else 3
|
||
tier = min(len(_GERUND_RAMP) - 1, t_tier + k_tier)
|
||
return _GERUND_RAMP[tier]
|
||
|
||
|
||
def render_status_chip(busy: bool, elapsed: int = 0) -> Text:
|
||
"""`✦ GenericAgent` identity chip. Brightens green when any session is busy.
|
||
|
||
The `elapsed` kwarg is kept for API stability but intentionally unrendered:
|
||
the per-session dot now carries the elapsed counter, which is more accurate
|
||
than an app-wide tally when multiple sessions run concurrently.
|
||
"""
|
||
chip = Text()
|
||
chip.append("✦ ", style=C_GREEN if busy else C_DIM)
|
||
chip.append("GenericAgent", style=f"bold {C_GREEN}" if busy else f"bold {C_FG}")
|
||
return chip
|
||
|
||
|
||
def render_topbar(session_name: str, status: str, model: str, tasks_running: int,
|
||
fold_mode: bool = True, busy_elapsed: int = 0,
|
||
effort: str = "", sess_elapsed: int = 0,
|
||
just_done: bool = False, term_width: int = 0,
|
||
workspace: str = "") -> Table:
|
||
# Layout: identity-chip + session + status + fold packed LEFT; model + effort
|
||
# + tasks CENTERED; clock RIGHT. The 2:2:1 ratio keeps the centered model
|
||
# chip visually anchored even when the left column has the long status pill.
|
||
# The OS terminal tab title carries the session name separately — see
|
||
# GenericAgentTUI._update_terminal_title.
|
||
t = Table.grid(expand=True)
|
||
# Equal column widths so the middle column's geometric center sits at the
|
||
# window center. Uneven ratios shift the centered band off-axis.
|
||
t.add_column(ratio=1, justify="left", no_wrap=True, overflow="ellipsis")
|
||
t.add_column(ratio=1, justify="center", no_wrap=True, overflow="ellipsis")
|
||
t.add_column(ratio=1, justify="right", no_wrap=True)
|
||
|
||
short_name = session_name if len(session_name) <= 20 else session_name[:19] + "…"
|
||
|
||
# LEFT: identity chip · session · status
|
||
left = Text()
|
||
left.append_text(render_status_chip(busy=tasks_running > 0, elapsed=busy_elapsed))
|
||
left.append(" · ", style=C_DIM)
|
||
left.append("session: ", style=C_MUTED); left.append(short_name, style=f"bold {C_CHIP_NAME}")
|
||
left.append(" · ", style=C_DIM)
|
||
if status == "running":
|
||
dot_color = _heat_color(sess_elapsed)
|
||
left.append("● ", style=dot_color)
|
||
left.append(f"running {_fmt_elapsed(sess_elapsed)}", style=f"bold {dot_color}")
|
||
elif just_done:
|
||
left.append("● ", style=f"bold {C_GREEN}")
|
||
left.append("done", style=f"bold {C_GREEN}")
|
||
else:
|
||
left.append("● ", style=C_DIM); left.append(status, style=C_MUTED)
|
||
|
||
# CENTER: model · effort · tasks — dropped right-to-left on narrow terminals
|
||
# so the chip column never wraps under the left half.
|
||
budget = max(20, (term_width * 2 // 5) - 6) if term_width else 999
|
||
def chip_w(label: str, value: str) -> int:
|
||
return len(label) + len(value) + 5
|
||
used = chip_w("model: ", model or "?")
|
||
show_effort = bool(effort) and used + chip_w("effort: ", effort) <= budget
|
||
if show_effort: used += chip_w("effort: ", effort)
|
||
show_tasks = used + chip_w("tasks: ", str(tasks_running)) <= budget
|
||
mid = Text()
|
||
mid.append("model: ", style=C_MUTED); mid.append(model or "?", style=C_CHIP_MODEL)
|
||
if show_effort:
|
||
mid.append(" · ", style=C_DIM)
|
||
mid.append("effort: ", style=C_MUTED); mid.append(effort, style=f"bold {C_CHIP_EFFORT}")
|
||
if show_tasks:
|
||
mid.append(" · ", style=C_DIM)
|
||
mid.append("tasks: ", style=C_MUTED); mid.append(str(tasks_running), style=C_CHIP_TASKS)
|
||
|
||
# RIGHT: fold indicator + clock. Moved here from the LEFT column to keep the
|
||
# narrow `▾ fold` glyph from being eaten by the left's ellipsis when the
|
||
# running status pill fills the column budget.
|
||
right = Text()
|
||
# workspace chip (top-right) — only when active. Clean real-dir basename,
|
||
# never the hashed junction name, so the hash never reaches the user.
|
||
if workspace:
|
||
short_ws = workspace if len(workspace) <= 18 else workspace[:17] + "…"
|
||
right.append("workspace: ", style=C_MUTED)
|
||
right.append(short_ws, style=f"bold {C_GREEN}")
|
||
right.append(" · ", style=C_DIM)
|
||
if fold_mode:
|
||
right.append("▾ fold", style=C_DIM)
|
||
right.append(" · ", style=C_DIM)
|
||
right.append(time.strftime("%H:%M:%S"), style=C_CHIP_TIME)
|
||
|
||
t.add_row(left, mid, right)
|
||
return t
|
||
|
||
|
||
def render_bottombar(quit_armed: bool = False, rewind_armed: bool = False) -> Table:
|
||
t = Table.grid(expand=True)
|
||
t.add_column(justify="left")
|
||
left = Text()
|
||
if quit_armed:
|
||
left.append(f"再按 {fmt_key('ctrl+c')} 退出", style=f"bold {C_GREEN}")
|
||
elif rewind_armed:
|
||
left.append("再按 Esc 回退", style=f"bold {C_GREEN}")
|
||
else:
|
||
pairs = [("enter", "发送"), ("ctrl+n", "新会话"),
|
||
("ctrl+b", "侧栏"), ("ctrl+c", "停止/退出"),
|
||
("/", "命令面板"), ("ctrl+/", "快捷键帮助")]
|
||
for i, (combo, d) in enumerate(pairs):
|
||
if i: left.append(" ")
|
||
k = "/" if combo == "/" else fmt_key(combo)
|
||
left.append(k, style=C_GREEN if combo in ("/", "ctrl+/") else C_FG)
|
||
left.append(" ")
|
||
left.append(d, style=C_MUTED)
|
||
t.add_row(left)
|
||
return t
|
||
|
||
|
||
# ---------- sidebar ----------
|
||
def _truncate(text: str, max_w: int) -> str:
|
||
import unicodedata
|
||
w, out = 0, []
|
||
for ch in text:
|
||
wch = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
||
if w + wch > max_w:
|
||
out.append("…"); break
|
||
out.append(ch); w += wch
|
||
return "".join(out)
|
||
|
||
|
||
def _short_age(mtime: float) -> str:
|
||
d = int(time.time() - mtime)
|
||
if d < 60: return f"{d}s"
|
||
if d < 3600: return f"{d // 60}m"
|
||
if d < 86400: return f"{d // 3600}h"
|
||
return f"{d // 86400}d"
|
||
|
||
|
||
def _history_text(content) -> str:
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
return "\n".join(b.get("text", "") for b in content
|
||
if isinstance(b, dict) and b.get("type") == "text")
|
||
return ""
|
||
|
||
|
||
def _sidebar_last_user(sess: AgentSession) -> str:
|
||
# Read from LLM-side history so /clear (display-only) doesn't wipe sidebar preview.
|
||
try:
|
||
history = sess.agent.llmclient.backend.history
|
||
except Exception:
|
||
return ""
|
||
for m in reversed(history):
|
||
if m.get("role") != "user":
|
||
continue
|
||
c = m.get("content")
|
||
if isinstance(c, list) and any(
|
||
isinstance(b, dict) and b.get("type") == "tool_result" for b in c):
|
||
continue
|
||
text = _history_text(c)
|
||
if text.strip():
|
||
return re.sub(r"\s+", " ", text).strip()
|
||
return ""
|
||
|
||
|
||
def _sidebar_last_summary(sess: AgentSession) -> str:
|
||
try:
|
||
history = sess.agent.llmclient.backend.history
|
||
except Exception:
|
||
return ""
|
||
for m in reversed(history):
|
||
if m.get("role") != "assistant":
|
||
continue
|
||
text = _history_text(m.get("content"))
|
||
if not text:
|
||
continue
|
||
matches = re.findall(r"<summary>\s*(.*?)\s*</summary>", text, re.DOTALL)
|
||
if matches:
|
||
return re.sub(r"\s+", " ", matches[-1]).strip()
|
||
return ""
|
||
|
||
|
||
def render_sidebar(sessions: dict[int, AgentSession], current_id: Optional[int]) -> Table:
|
||
outer = Table.grid(expand=True)
|
||
outer.add_column()
|
||
|
||
SEL = f"on {C_SEL_BG}"
|
||
sess_tbl = Table.grid(expand=True)
|
||
sess_tbl.add_column(width=2)
|
||
sess_tbl.add_column(width=2)
|
||
sess_tbl.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
|
||
sess_tbl.add_column(justify="right")
|
||
sess_tbl.add_column(width=2)
|
||
blank = Text("")
|
||
def spacer(style):
|
||
sess_tbl.add_row(blank, blank, blank, blank, blank, style=style)
|
||
def preview(label, txt, style):
|
||
# C_DIM blends bg/fg at 0.35 — under SEL_BG on the active row the contrast
|
||
# collapses (e.g. tokyo-night). C_MUTED (0.55 blend) stays readable in both.
|
||
sess_tbl.add_row(blank, blank,
|
||
Text(f"{label}: {txt}", style=C_MUTED, no_wrap=True, overflow="ellipsis"),
|
||
blank, blank, style=style)
|
||
for sid, sess in sessions.items():
|
||
active = sid == current_id
|
||
style = SEL if active else None
|
||
spacer(style)
|
||
sess_tbl.add_row(
|
||
blank,
|
||
Text("●" if active else "›", style=C_GREEN if active else C_DIM),
|
||
Text(_truncate(f"#{sid} {sess.name}", 16), style=C_GREEN if active else C_MUTED),
|
||
Text(sess.status, style=C_DIM),
|
||
blank, style=style,
|
||
)
|
||
if (q := _sidebar_last_user(sess)): preview("Q", q, style)
|
||
if (s := _sidebar_last_summary(sess)): preview("S", s, style)
|
||
spacer(style)
|
||
outer.add_row(Text("SESSIONS", style=f"bold {C_DIM}"))
|
||
outer.add_row(Text(""))
|
||
outer.add_row(sess_tbl)
|
||
return outer
|
||
|
||
|
||
# ---------- App ----------
|
||
|
||
|
||
class HelpScreen(ModalScreen):
|
||
CSS = """
|
||
HelpScreen { align: center middle; }
|
||
HelpScreen > Static {
|
||
width: auto;
|
||
max-width: 80;
|
||
height: auto;
|
||
max-height: 80%;
|
||
background: $ga-alt-bg;
|
||
border: solid $ga-border;
|
||
padding: 1 2;
|
||
color: $ga-fg;
|
||
}
|
||
"""
|
||
BINDINGS = [
|
||
Binding("escape", "dismiss", "Close", show=False),
|
||
Binding("ctrl+slash", "dismiss", "Close", show=False),
|
||
Binding("ctrl+/", "dismiss", "Close", show=False),
|
||
Binding("ctrl+underscore", "dismiss", "Close", show=False),
|
||
Binding("cmd+slash", "dismiss", "Close", show=False),
|
||
Binding("cmd+/", "dismiss", "Close", show=False),
|
||
]
|
||
|
||
def __init__(self, content) -> None:
|
||
super().__init__()
|
||
self._content = content
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Static(self._content)
|
||
|
||
|
||
class ThemePicker(ModalScreen):
|
||
# Live-preview theme picker: highlight applies the theme so the rest of the
|
||
# UI repaints behind the modal; Enter commits + persists, Esc reverts.
|
||
CSS = """
|
||
ThemePicker { align: center middle; }
|
||
ThemePicker > OptionList {
|
||
width: 36;
|
||
max-height: 80%;
|
||
background: $ga-alt-bg;
|
||
border: solid $ga-border;
|
||
padding: 0 1;
|
||
color: $ga-fg;
|
||
}
|
||
ThemePicker > OptionList > .option-list--option-highlighted {
|
||
background: $ga-sel-bg;
|
||
color: $ga-fg;
|
||
}
|
||
"""
|
||
BINDINGS = [
|
||
Binding("escape", "cancel", "Cancel", show=False),
|
||
Binding("enter", "commit", "Apply", show=False),
|
||
]
|
||
|
||
def __init__(self, themes: list[str], current: str) -> None:
|
||
super().__init__()
|
||
self._themes = themes
|
||
self._initial = current
|
||
|
||
def compose(self) -> ComposeResult:
|
||
ol = OptionList(*self._themes, id="theme-picker")
|
||
yield ol
|
||
|
||
def on_mount(self) -> None:
|
||
ol = self.query_one(OptionList)
|
||
try:
|
||
ol.highlighted = self._themes.index(self._initial)
|
||
except ValueError:
|
||
ol.highlighted = 0
|
||
ol.focus()
|
||
|
||
def on_option_list_option_highlighted(self, ev) -> None:
|
||
name = self._themes[ev.option_index]
|
||
if self.app.theme != name:
|
||
self.app.theme = name
|
||
|
||
def on_option_list_option_selected(self, ev) -> None:
|
||
self.action_commit()
|
||
|
||
def action_commit(self) -> None:
|
||
_save_settings({"theme": self.app.theme})
|
||
self.dismiss()
|
||
|
||
def action_cancel(self) -> None:
|
||
if self.app.theme != self._initial:
|
||
self.app.theme = self._initial
|
||
self.dismiss()
|
||
|
||
|
||
# ===========================================================================
|
||
# /rewind 面板 — 会话 checkpoint 的时间线 / 树状可视化 + 对话恢复
|
||
#
|
||
# GA 没有真正的 rewind 后端(checkpoint 树是从 LLM history 现算的),所以这里的
|
||
# 「节点」= 用户提问边界(与既有 /rewind 的 _rewindable_turns 同源),每个节点的
|
||
# `n` 直接喂给 _do_rewind(n)。「恢复对话」= 把 backend.history 截断回该提问之前
|
||
# (复用既有 _do_rewind,仅在 agent 空闲时允许);restore code / fork / diff /
|
||
# delete 需要文件快照后端,本版为占位(详见需求文档 §9 的「暂缓」决策)。
|
||
# ===========================================================================
|
||
|
||
# tui_v2 主题色(C_FG/C_DIM/C_GREEN/C_BLUE 随主题刷新,这里直接复用模块全局);
|
||
# 补几个 v2 未定义的固定色。
|
||
C_ALT_BG = "#21262d"
|
||
C_AMBER = "#f0883e"
|
||
# 世界线树(/worldline)配色:除 diff 删除红是固定语义色外,其余跟随 v2 主题(_palette)。
|
||
C_BG = _palette["bg"]
|
||
C_BORDER = _palette["border"]
|
||
C_CYAN = C_BLUE # 聚焦高亮 → 跟主题蓝
|
||
C_LAVENDER = C_PURPLE # lane 配色 → 跟主题紫
|
||
C_RED = "#e5534b" # diff 删除行(固定语义色)
|
||
|
||
|
||
class GenericAgentTUI(App[None]):
|
||
|
||
CSS = _MAIN_CSS
|
||
|
||
BINDINGS = [
|
||
Binding("ctrl+c", "handle_ctrl_c", "Stop/Quit", show=False, priority=True),
|
||
# macOS muscle-memory aliases — only fire if the terminal forwards Cmd as a key
|
||
# (Terminal.app / default iTerm2 swallow them; Ghostty / WezTerm / kitty can forward).
|
||
Binding("cmd+c", "handle_ctrl_c", "Stop/Quit", show=False, priority=True),
|
||
Binding("ctrl+n", "new_session", "New", show=False),
|
||
Binding("cmd+n", "new_session", "New", show=False),
|
||
Binding("ctrl+b", "toggle_sidebar","Sidebar", show=False),
|
||
Binding("ctrl+o", "toggle_fold", "Fold", show=False),
|
||
Binding("ctrl+up", "prev_session", "Prev", show=False, priority=True),
|
||
Binding("ctrl+down", "next_session", "Next", show=False, priority=True),
|
||
Binding("ctrl+d", "drop_session", "Drop", show=False, priority=True),
|
||
Binding("cmd+d", "drop_session", "Drop", show=False, priority=True),
|
||
# Terminals report Ctrl+/ as ctrl+slash or legacy ctrl+_ (ASCII 0x1F); bind both.
|
||
Binding("ctrl+slash", "show_help", "Help", show=False),
|
||
Binding("ctrl+/", "show_help", "Help", show=False),
|
||
Binding("ctrl+underscore", "show_help", "Help", show=False),
|
||
Binding("cmd+slash", "show_help", "Help", show=False),
|
||
Binding("cmd+/", "show_help", "Help", show=False),
|
||
Binding("escape", "escape", "Close", show=False),
|
||
Binding("tab", "complete_command", "Complete", show=False, priority=True),
|
||
Binding("ctrl+t", "pick_theme", "Theme", show=False),
|
||
]
|
||
|
||
def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None:
|
||
super().__init__()
|
||
self.agent_factory: AgentFactory = agent_factory or default_agent_factory
|
||
self.sessions: dict[int, AgentSession] = {}
|
||
self.current_id: Optional[int] = None
|
||
# Wall-clock marker used by `/cost` to scope subagent log scans to
|
||
# logs touched after the TUI started — pre-launch leftovers shouldn't
|
||
# bleed into "this run's" total.
|
||
self._started_at: float = time.time()
|
||
self._ids = count(1)
|
||
self._suppress_palette_open = False
|
||
self.fold_mode: bool = True
|
||
self._last_size: tuple[int, int] = (-1, -1)
|
||
self._resize_timer = None
|
||
self._quit_armed: bool = False
|
||
self._quit_timer = None
|
||
self._rewind_armed: bool = False
|
||
self._rewind_timer = None
|
||
self._busy_since: Optional[float] = None
|
||
self._chip_timer = None
|
||
self._title_frame: int = 0
|
||
self._title_timer = None
|
||
self._last_title: str = ""
|
||
# Register our github-dark palette as a first-class Textual theme; the other
|
||
# cycle entries are Textual built-ins (nord, gruvbox, dracula, tokyo-night,
|
||
# textual-light), whose ga-* CSS slots are derived in get_css_variables.
|
||
from textual.theme import Theme as _TxTheme
|
||
p = _DEFAULT_PALETTE
|
||
self.register_theme(_TxTheme(
|
||
name="ga-default", dark=True,
|
||
background=p["bg"], surface=p["alt_bg"], panel=p["sel_bg"],
|
||
foreground=p["fg"],
|
||
primary=p["green"], secondary=p["blue"], accent=p["purple"],
|
||
))
|
||
saved = _load_settings().get("theme")
|
||
self.theme = saved if saved in _THEME_CYCLE else "ga-default"
|
||
self._spinner_frame: int = 0
|
||
self._spinner_timer = None
|
||
self._handlers: dict = {
|
||
"help": self._cmd_help, "status": self._cmd_status, "sessions": self._cmd_status,
|
||
"new": self._cmd_new, "switch": self._cmd_switch, "close": self._cmd_close,
|
||
"rename": self._cmd_rename,
|
||
"branch": self._cmd_branch, "rewind": self._cmd_rewind, "clear": self._cmd_clear,
|
||
"worldline": self._cmd_rewind_tree, # 世界线(树状回退)
|
||
"rewind-tree": self._cmd_rewind_tree, # 旧名,保留为静默别名
|
||
"stop": self._cmd_stop, "llm": self._cmd_llm, "model": self._cmd_model,
|
||
"effort": self._cmd_effort,
|
||
"export": self._cmd_export,
|
||
"restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review,
|
||
"continue": self._cmd_continue, "cost": self._cmd_cost,
|
||
"workspace": self._cmd_workspace,
|
||
"reload-keys": self._cmd_reload_keys,
|
||
# slash_cmds bundle — see frontends/slash_cmds.py for the prompt
|
||
# bodies + reflect/scheduler discovery. All but /scheduler are
|
||
# thin shims that build a prompt and re-enter submit_user_message,
|
||
# so the agent processes them as ordinary turns.
|
||
"update": self._cmd_slash_inject, "autorun": self._cmd_slash_inject,
|
||
"morphling": self._cmd_slash_inject, "goal": self._cmd_slash_inject,
|
||
"hive": self._cmd_slash_inject, "conductor": self._cmd_slash_inject,
|
||
"scheduler": self._cmd_scheduler,
|
||
"quit": self._cmd_quit, "exit": self._cmd_quit,
|
||
}
|
||
try:
|
||
import cost_tracker; cost_tracker.install()
|
||
except Exception:
|
||
pass
|
||
# Patch GenericAgent for /review in case chatapp_common didn't wire it.
|
||
try:
|
||
from agentmain import GenericAgent as _GA
|
||
import review_cmd; review_cmd.install(_GA)
|
||
except Exception:
|
||
pass
|
||
# Drop session_names entries pointing at rotated-away logs.
|
||
try:
|
||
import session_names; session_names.gc()
|
||
except Exception:
|
||
pass
|
||
|
||
# PR #461 (upstream 08f21e8): suppress mouse events that target a
|
||
# SelectableStatic whose parent is no longer in the widget tree.
|
||
# Such widgets persist as cached references (e.g. the ChatMessage
|
||
# picker collapse path stashes the previous body widget) but they're
|
||
# no longer mounted, so any MouseDown/MouseMove on their old screen
|
||
# rect would fire selection callbacks on detached objects → crash.
|
||
def _is_stale_selectable_mouse_event(self, event) -> bool:
|
||
if not isinstance(event, (events.MouseDown, events.MouseMove)):
|
||
return False
|
||
try:
|
||
select_widget, select_offset = self.screen.get_widget_and_offset_at(
|
||
event.x, event.y
|
||
)
|
||
except Exception:
|
||
return False
|
||
return (
|
||
select_offset is not None
|
||
and isinstance(select_widget, SelectableStatic)
|
||
and not select_widget.has_valid_selection_parent()
|
||
)
|
||
|
||
async def on_event(self, event) -> None:
|
||
if self._is_stale_selectable_mouse_event(event):
|
||
if isinstance(event, events.MouseDown):
|
||
try:
|
||
self.screen.clear_selection()
|
||
except Exception:
|
||
pass
|
||
event.stop()
|
||
return
|
||
await super().on_event(event)
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Static("", id="topbar")
|
||
with Horizontal(id="body"):
|
||
_sidebar = VerticalScroll(Static("", id="sidebar"), id="sidebar-scroll")
|
||
_sidebar.can_focus = False
|
||
yield _sidebar
|
||
with Vertical(id="main"):
|
||
yield VerticalScroll(id="messages")
|
||
# Plan card: pinned header/step (#planbar-head) above a task list
|
||
# (#planbar) that scrolls inside a 4-row window (#planbar-tasks).
|
||
with Vertical(id="planbar-scroll"):
|
||
yield Static("", id="planbar-head")
|
||
_tasks = VerticalScroll(Static("", id="planbar"), id="planbar-tasks")
|
||
_tasks.can_focus = False # don't steal Tab focus from input
|
||
yield _tasks
|
||
yield OptionList(id="palette")
|
||
yield InputArea(
|
||
"",
|
||
id="input",
|
||
soft_wrap=True,
|
||
show_line_numbers=False,
|
||
compact=True,
|
||
highlight_cursor_line=False,
|
||
placeholder=f"输入指令或问题... (Enter 发送, {fmt_key('ctrl+j')} 换行, / 唤起命令面板)",
|
||
)
|
||
# Tip line sits inside #main so it doesn't compete for height
|
||
# with #body's 1fr. Content set at compose so the first frame
|
||
# already shows it.
|
||
yield Static(_tip_line(_random_tip()), id="tipbar")
|
||
yield Static(render_bottombar(), id="bottombar")
|
||
|
||
def on_mount(self) -> None:
|
||
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
|
||
try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
|
||
except Exception: pass
|
||
try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
|
||
except Exception: pass
|
||
get_index(os.path.join(ROOT_DIR, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
|
||
self.add_session("main")
|
||
self._system(f"Welcome to GenericAgent TUI. 按 / 唤起命令面板,{fmt_key('ctrl+n')} 新建会话。")
|
||
|
||
# CSS `#planbar-scroll { display: none }` keeps it hidden by default —
|
||
# the renderer adds `-visible` once plan items materialize.
|
||
self.query_one("#input", InputArea).focus()
|
||
self.set_interval(0.5, self._tick)
|
||
self._patch_auto_scroll_for_selection()
|
||
self._start_plan_watcher()
|
||
self._start_tip_rotator()
|
||
self._apply_responsive_layout()
|
||
# Disable alternate scroll mode (?1007). Textual enables ?1006 SGR mouse but doesn't
|
||
# turn off ?1007, which on macOS Terminal / iTerm2 makes the wheel emit both mouse
|
||
# events and ↑/↓ keys — triggering InputArea history nav.
|
||
self._term_write("\x1b[?1007l")
|
||
|
||
def _tick(self) -> None:
|
||
# 0.5s poll: refresh clock + detect resizes Windows misses (snap, fullscreen).
|
||
self._refresh_topbar()
|
||
size = (self.size.width, self.size.height)
|
||
if size != self._last_size:
|
||
self._last_size = size
|
||
self._apply_responsive_layout()
|
||
|
||
def _patch_auto_scroll_for_selection(self) -> None:
|
||
# Make selection-drag into #input still scroll #messages: include _select_start as a
|
||
# candidate source, and trigger when the mouse leaves the scrollable above or below.
|
||
from textual._auto_scroll import get_auto_scroll_regions
|
||
from textual.geometry import Offset
|
||
from textual.widget import Widget as _W
|
||
|
||
screen = self.screen
|
||
app = self
|
||
|
||
def patched(select_widget, mouse_coord, delta_y):
|
||
if not app.ENABLE_SELECT_AUTO_SCROLL:
|
||
return
|
||
if screen._auto_select_scroll_timer is None and abs(delta_y) < 1:
|
||
return
|
||
mouse_x, mouse_y = mouse_coord
|
||
mouse_offset = Offset(int(mouse_x), int(mouse_y))
|
||
scroll_lines = app.SELECT_AUTO_SCROLL_LINES
|
||
|
||
candidates = [select_widget]
|
||
# Textual 8.2.6 renamed _select_start to _select_state (SelectState.start.container).
|
||
select_state = getattr(screen, "_select_state", None)
|
||
if select_state is not None:
|
||
sw = select_state.start.container
|
||
else:
|
||
ss = getattr(screen, "_select_start", None)
|
||
sw = ss[0] if ss is not None else None
|
||
if sw is not None and sw is not select_widget:
|
||
candidates.append(sw)
|
||
|
||
for source in candidates:
|
||
for ancestor in source.ancestors_with_self:
|
||
if not isinstance(ancestor, _W):
|
||
break
|
||
if not ancestor.allow_vertical_scroll:
|
||
continue
|
||
ar = ancestor.content_region
|
||
up_r, down_r = get_auto_scroll_regions(ar, auto_scroll_lines=scroll_lines)
|
||
if mouse_offset in up_r:
|
||
if ancestor.scroll_y > 0:
|
||
speed = (scroll_lines - (mouse_y - up_r.y)) / scroll_lines
|
||
if speed:
|
||
screen._start_auto_scroll(ancestor, -1, speed)
|
||
return
|
||
elif mouse_offset in down_r:
|
||
if ancestor.scroll_y < ancestor.max_scroll_y:
|
||
speed = (mouse_y - down_r.y) / scroll_lines
|
||
if speed:
|
||
screen._start_auto_scroll(ancestor, +1, speed)
|
||
return
|
||
elif mouse_y >= ar.y + ar.height:
|
||
if ancestor.scroll_y < ancestor.max_scroll_y:
|
||
screen._start_auto_scroll(ancestor, +1, 1.0)
|
||
return
|
||
elif mouse_y < ar.y:
|
||
if ancestor.scroll_y > 0:
|
||
screen._start_auto_scroll(ancestor, -1, 1.0)
|
||
return
|
||
screen._stop_auto_scroll()
|
||
|
||
screen._check_auto_scroll = patched
|
||
|
||
# ---------------- session management ----------------
|
||
@property
|
||
def current(self) -> AgentSession:
|
||
if self.current_id is None:
|
||
raise RuntimeError("no active session")
|
||
return self.sessions[self.current_id]
|
||
|
||
def add_session(self, name: Optional[str] = None) -> AgentSession:
|
||
agent_id = next(self._ids)
|
||
agent = self.agent_factory()
|
||
try: agent.inc_out = True
|
||
except Exception: pass
|
||
# Per-session task_dir path enables ga's `_intervene` / `_keyinfo`
|
||
# consume paths (ga.py:575). PID+session scoped so concurrent
|
||
# sessions don't share signal files. We only set the *path* here —
|
||
# the dir is created lazily by the writer (`_session_intervene_path`)
|
||
# when a signal is actually injected. Eager makedirs left a stale
|
||
# empty `temp/_tui_v2_<pid>_<id>` behind for every session that never
|
||
# used intervene; `consume_file` tolerates a missing dir.
|
||
try:
|
||
agent.task_dir = os.path.join(FRONTENDS_DIR, '..', 'temp',
|
||
f'_tui_v2_{os.getpid()}_{agent_id}')
|
||
except Exception:
|
||
pass
|
||
try:
|
||
# Opt TUI v2 agents into per-agent project-mode selection. The
|
||
# plugin falls back to the legacy pid anchor only when this
|
||
# private attribute is absent, so None here means "ordinary mode"
|
||
# for this session rather than "use the process-global workspace".
|
||
agent._ga_project_mode_name = None
|
||
agent._ga_project_mode_workspace_path = ""
|
||
except Exception:
|
||
pass
|
||
sess = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
|
||
# Rewind 后端:每 session 一个 checkpoint 树 + blob 库,key 对齐 agent.log_path
|
||
# (GA 稳定会话身份)。cwd = temp(与 GA handler 解析相对路径的基准一致,
|
||
# 见 agentmain GenericAgentHandler(..., temp))。store 挂到 agent 上供全局
|
||
# tool_before 钩子按 agent 路由。
|
||
try:
|
||
temp_dir = os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp'))
|
||
log_path = getattr(agent, 'log_path', '') or f'sess_{os.getpid()}_{agent_id}'
|
||
sess.store = RewindStore.for_log(temp_dir, log_path, temp_dir)
|
||
agent._rw_store = sess.store
|
||
except Exception:
|
||
sess.store = None
|
||
self._install_rw_tool_hook()
|
||
try:
|
||
from continue_cmd import acquire_birth_lock
|
||
acquire_birth_lock(agent, agent_id) # 原地复原:出生持锁,占用检测可见
|
||
except Exception:
|
||
pass
|
||
thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True)
|
||
thread.start()
|
||
sess.thread = thread
|
||
self.sessions[agent_id] = sess
|
||
self.current_id = agent_id
|
||
self._install_ask_user_hook(sess)
|
||
self._install_intervene_replay_hook(sess)
|
||
self._install_write_snapshot_hook()
|
||
self._install_rw_time_hook(sess)
|
||
self._refresh_all()
|
||
return sess
|
||
|
||
def _bind_workspace(self, sess: AgentSession, info: Optional[dict],
|
||
persist: bool = True) -> None:
|
||
# persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的
|
||
# reset(_bind_workspace(sess, None)) 用它——原地续后 agent.log_path==被续
|
||
# 文件,若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表
|
||
# 只应由显式 /workspace、/workspace off 与成功的续接恢复来写。
|
||
if info:
|
||
sess.workspace_name = info.get("name") or ""
|
||
sess.workspace_path = info.get("target") or info.get("path") or ""
|
||
sess.workspace_link = info.get("link") or ""
|
||
project_name = sess.workspace_name or None
|
||
project_path = sess.workspace_path
|
||
else:
|
||
sess.workspace_name = ""
|
||
sess.workspace_path = ""
|
||
sess.workspace_link = ""
|
||
project_name = None
|
||
project_path = ""
|
||
try:
|
||
sess.agent._ga_project_mode_name = project_name
|
||
sess.agent._ga_project_mode_workspace_path = project_path
|
||
except Exception:
|
||
pass
|
||
# 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
|
||
if persist:
|
||
workspace_cmd.session_ws_set(getattr(sess.agent, "log_path", "") or "", project_path or "")
|
||
if project_path:
|
||
get_index(project_path).warm() # @ 候选跟随 workspace
|
||
|
||
def _at_root(self, sess: Optional["AgentSession"] = None) -> str:
|
||
# @ 索引根:绑了 workspace 用真实 target;否则用 agent 的实际工作目录
|
||
# ROOT_DIR/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
|
||
# 一律真实路径,绝不暴露哈希 junction 名。
|
||
s = sess or (self.sessions.get(self.current_id) if self.current_id is not None else None)
|
||
return (s.workspace_path if s and s.workspace_path else os.path.join(ROOT_DIR, "temp"))
|
||
|
||
_write_snapshot_hook_installed = False
|
||
|
||
def _install_write_snapshot_hook(self) -> None:
|
||
"""Register one global `tool_before` hook that captures the structured
|
||
args of every file_write/file_patch (plus a file_write target's pre-write
|
||
content) into `_WRITE_CAP`, keyed by `hash(get_pretty_json(args))`.
|
||
|
||
That hash is the bridge to the display layer: agent_loop renders the same
|
||
`get_pretty_json(args)` inside the verbose `📥 args:` fence, so at render
|
||
time `_render_md` hashes the fence body and looks the real args back up —
|
||
no need to parse the newline-mangled pretty JSON. Content-addressed, so it
|
||
survives re-renders and pairs each block with its exact call. The registry
|
||
is process-global, so install at most once."""
|
||
if GenericAgentTUI._write_snapshot_hook_installed:
|
||
return
|
||
try:
|
||
from plugins import hooks as _ph
|
||
from agent_loop import get_pretty_json
|
||
|
||
def _strip_dispatch_keys(raw):
|
||
# dispatch() injects `_index`/`_tool_num` into args *before* the
|
||
# hooks fire, but the verbose display (agent_loop.py:78) ran
|
||
# get_pretty_json on the args *before* dispatch — i.e. without
|
||
# those keys. Strip them so our hash matches the fence body the
|
||
# renderer will hash.
|
||
return {k: v for k, v in (raw or {}).items()
|
||
if k not in ("_index", "_tool_num")}
|
||
|
||
def _snap(ctx):
|
||
try:
|
||
name = (ctx or {}).get("tool_name")
|
||
if name not in ("file_write", "file_patch", "file_read", "code_run"):
|
||
return ctx
|
||
handler = (ctx or {}).get("self")
|
||
args = _strip_dispatch_keys((ctx or {}).get("args"))
|
||
if name == "code_run":
|
||
# No path; the card shows args (code) + the result
|
||
# stamped by _snap_after. Keyed the same way.
|
||
_WRITE_CAP[hash(get_pretty_json(args))] = {
|
||
"name": name, "args": args,
|
||
}
|
||
if len(_WRITE_CAP) > 256:
|
||
for k in list(_WRITE_CAP)[:128]:
|
||
_WRITE_CAP.pop(k, None)
|
||
return ctx
|
||
path = args.get("path")
|
||
if not path or handler is None:
|
||
return ctx
|
||
if name == "file_read":
|
||
# No disk snapshot needed — the read card shows the
|
||
# tool's own result, stamped by _snap_after from
|
||
# StepOutcome.data.
|
||
_WRITE_CAP[hash(get_pretty_json(args))] = {
|
||
"name": name, "args": args,
|
||
}
|
||
else:
|
||
# Snapshot the pre-write full file for BOTH write tools:
|
||
# file_write overwrite diffs against it, and file_patch
|
||
# uses it to render a whole-file diff (real line numbers
|
||
# + surrounding context) instead of just the fragment.
|
||
existed, old = False, ""
|
||
try:
|
||
abs_path = handler._get_abs_path(path)
|
||
existed = os.path.exists(abs_path)
|
||
if existed:
|
||
with open(abs_path, "r", encoding="utf-8") as f:
|
||
old = f.read()
|
||
except Exception:
|
||
existed, old = False, ""
|
||
_WRITE_CAP[hash(get_pretty_json(args))] = {
|
||
"name": name, "args": args, "existed": existed, "old": old,
|
||
}
|
||
# Bound memory: keep only the most recent captures.
|
||
if len(_WRITE_CAP) > 256:
|
||
for k in list(_WRITE_CAP)[:128]:
|
||
_WRITE_CAP.pop(k, None)
|
||
except Exception:
|
||
pass
|
||
return ctx
|
||
|
||
def _snap_after(ctx):
|
||
# Stamp the outcome onto the same capture: write tools get
|
||
# {status, msg} for the header ✗; file_read gets the result text
|
||
# itself (its StepOutcome.data IS the content the card shows).
|
||
try:
|
||
name = (ctx or {}).get("tool_name")
|
||
if name not in ("file_write", "file_patch", "file_read", "code_run"):
|
||
return ctx
|
||
args = _strip_dispatch_keys((ctx or {}).get("args"))
|
||
data = getattr((ctx or {}).get("ret"), "data", None)
|
||
h = hash(get_pretty_json(args))
|
||
if h not in _WRITE_CAP:
|
||
return ctx
|
||
if name == "file_read":
|
||
if isinstance(data, str):
|
||
_WRITE_CAP[h]["content"] = data
|
||
elif name == "code_run":
|
||
# data is the StepOutcome.data verbatim: dict
|
||
# {status,stdout,exit_code} | {status:error,msg} | str.
|
||
_WRITE_CAP[h]["data"] = data
|
||
elif isinstance(data, dict):
|
||
_WRITE_CAP[h]["status"] = data.get("status")
|
||
_WRITE_CAP[h]["msg"] = str(data.get("msg") or "")
|
||
except Exception:
|
||
pass
|
||
return ctx
|
||
|
||
_ph.register("tool_before")(_snap)
|
||
_ph.register("tool_after")(_snap_after)
|
||
GenericAgentTUI._write_snapshot_hook_installed = True
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
_rw_tool_hook_installed = False
|
||
|
||
def _install_rw_tool_hook(self) -> None:
|
||
"""全局注册一次 `tool_before` 钩子(plugins.hooks):在 file_write/file_patch
|
||
真写盘**之前**,把目标文件的「改前内容」存进对应 session 的 store(track_pre_edit)。
|
||
|
||
注册表是全局的(非 per-session),回调对所有 agent 触发 → 靠 ctx 的
|
||
`self.parent`(GenericAgent)上挂的 `_rw_store` 路由到正确 session。
|
||
回调跑在 agent 线程;每 agent 独占自己的 store,无跨线程竞争。"""
|
||
if GenericAgentTUI._rw_tool_hook_installed:
|
||
return
|
||
try:
|
||
from plugins import hooks as _ph
|
||
|
||
def _rw_tool_before(ctx):
|
||
try:
|
||
if (ctx or {}).get("tool_name") not in ("file_write", "file_patch"):
|
||
return ctx
|
||
handler = ctx.get("self")
|
||
args = ctx.get("args") or {}
|
||
path = args.get("path")
|
||
store = getattr(getattr(handler, "parent", None), "_rw_store", None)
|
||
if store is not None and path:
|
||
store.track_pre_edit(handler._get_abs_path(path))
|
||
except Exception:
|
||
pass
|
||
return ctx
|
||
|
||
_ph.register("tool_before")(_rw_tool_before)
|
||
GenericAgentTUI._rw_tool_hook_installed = True
|
||
except Exception:
|
||
pass
|
||
|
||
def _rw_log_history(self, sess: AgentSession):
|
||
"""按 continue 的 native-log 口径读取当前会话 history。
|
||
|
||
worldline 的持久对话层以 `model_responses_*.txt` 为真相源;live backend.history
|
||
只是异常 fallback。cache 仅按文件 stat 避免同一版本重复解析,不改变语义。"""
|
||
log_path = getattr(sess.agent, "log_path", "") or ""
|
||
if not log_path:
|
||
return None
|
||
try:
|
||
st = os.stat(log_path)
|
||
except OSError:
|
||
return None
|
||
key = (log_path, int(st.st_size), int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))))
|
||
cache = getattr(sess, "_rw_log_hist_cache", None)
|
||
if isinstance(cache, dict) and cache.get("key") == key:
|
||
return list(cache.get("history") or [])
|
||
try:
|
||
import continue_cmd as _cc
|
||
hist = _cc.parse_native_log(log_path, allow_empty=True)
|
||
except Exception:
|
||
hist = None
|
||
if hist is None:
|
||
return None
|
||
sess._rw_log_hist_cache = {"key": key, "history": list(hist)}
|
||
return list(hist)
|
||
|
||
def _rw_title_from_delta(self, store, history: list, fallback: str) -> str:
|
||
"""checkpoint 标题跟随**实际写入树的日志增量**;避免残缺/未配对 prompt 误成节点名。"""
|
||
try:
|
||
parent = store.head if store.head in store.nodes else store.root_id
|
||
parent_len = len(store.rebuild_history(parent)) if parent is not None else 0
|
||
for msg in list(history or [])[parent_len:]:
|
||
text = store._msg_user_text(msg).strip()
|
||
if text:
|
||
return text.replace("\n", " ").strip()[:80]
|
||
except Exception:
|
||
pass
|
||
return (fallback or "checkpoint").replace("\n", " ").strip()[:80]
|
||
|
||
def _rw_commit(self, sess: AgentSession) -> None:
|
||
"""用户提问段完成时落一个 checkpoint 节点(对话推进也算 checkpoint)。
|
||
|
||
对话来源正常取 native log(continue 同口径、完整 Prompt→Response pairs);只有日志读不到
|
||
/非 native 时才 fallback 到 live backend.history。store 故障一律静默(rewind 是旁路
|
||
安全网,绝不冒泡打断任务收尾)。"""
|
||
store = getattr(sess, "store", None)
|
||
if store is None:
|
||
return
|
||
try:
|
||
agent = sess.agent
|
||
history = self._rw_log_history(sess)
|
||
if history is None:
|
||
history = agent.llmclient.backend.history
|
||
parent = store.head if store.head in store.nodes else store.root_id
|
||
parent_len = len(store.rebuild_history(parent)) if parent is not None else 0
|
||
if len(history or []) <= parent_len:
|
||
store._touched.clear() # 残缺/未配对轮不进树,本段文件触碰也不能污染下一轮
|
||
store.save()
|
||
return
|
||
title = self._rw_title_from_delta(store, history, getattr(sess, "_rw_title", ""))
|
||
# 工作记忆不取 live handler.history_info(/continue 会清空它,且与树派生口径不一致);
|
||
# 交给 commit 从日志 history 统一派生 → 树的 WM 恒由日志重建、互相对齐。
|
||
store.commit(title or "checkpoint", history=history)
|
||
store._rw_cursor = None # 继续提问 → 新末端成为当前,清除 rewind 游标
|
||
except Exception:
|
||
pass
|
||
|
||
def _install_rw_time_hook(self, sess: AgentSession) -> None:
|
||
"""Stamp wall-clock per turn-end, keyed by LLM-history length, so the
|
||
/rewind panels can show coarse relative time. GA stores no timestamps;
|
||
nodes predating this session (e.g. restored via /continue) show none.
|
||
Hook runs in the agent thread — dict writes are GIL-safe."""
|
||
agent = sess.agent
|
||
sess._rw_times = {}
|
||
try:
|
||
hooks = getattr(agent, "_turn_end_hooks", None)
|
||
if hooks is None:
|
||
hooks = agent._turn_end_hooks = {}
|
||
def _hook(ctx, _s=sess, _a=agent):
|
||
try: _s._rw_times[len(_a.llmclient.backend.history)] = time.time()
|
||
except Exception: pass
|
||
hooks["_ga_tui_rw_time"] = _hook
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _install_ask_user_hook(self, sess: AgentSession) -> None:
|
||
"""Capture ask_user INTERRUPT payloads from agent_loop's turn_end hook.
|
||
|
||
The agent yields `{"status": "INTERRUPT", "intent": "HUMAN_INTERVENTION",
|
||
"data": {question, candidates}}` via `exit_reason.data`. We push events
|
||
onto the session queue; `_on_stream(done=True)` drains and posts an
|
||
interactive ChoiceList ChatMessage. Candidates pass through
|
||
`_sanitize_candidates` so envelope debris / numbered prefixes / mashed
|
||
multi-line strings don't leak into the picker.
|
||
|
||
ga.turn_end_callback reads hooks from `self.parent._turn_end_hooks`
|
||
where `parent` is the GenericAgent — so the dict lives on the agent.
|
||
"""
|
||
agent = sess.agent
|
||
try:
|
||
hooks = getattr(agent, "_turn_end_hooks", None)
|
||
if hooks is None:
|
||
hooks = agent._turn_end_hooks = {}
|
||
def _hook(ctx, _q=sess.ask_user_events):
|
||
er = (ctx or {}).get("exit_reason") or {}
|
||
if er.get("result") != "EXITED": return
|
||
payload = er.get("data")
|
||
if not isinstance(payload, dict): return
|
||
if payload.get("status") != "INTERRUPT" or payload.get("intent") != "HUMAN_INTERVENTION": return
|
||
data = payload.get("data") or {}
|
||
cands = _sanitize_candidates(data.get("candidates"))
|
||
if not cands: return
|
||
q = str(data.get("question") or "请选择:").strip() or "请选择:"
|
||
_q.put({"question": q, "candidates": cands})
|
||
hooks["_ga_tui_ask_user"] = _hook
|
||
except Exception:
|
||
pass
|
||
|
||
def action_new_session(self) -> None:
|
||
sess = self.add_session()
|
||
self._system(f"Created session #{sess.agent_id} — {sess.name}")
|
||
|
||
def action_prev_session(self) -> None:
|
||
# ctrl+up 是 app 级 priority 绑定,会盖过 ModalScreen;世界线屏激活时把它
|
||
# 转交给屏内「移动非聚焦窗↑」(否则在三栏里 ctrl+↑ 变成切换会话)。
|
||
if isinstance(self.screen, RewindTreeScreen):
|
||
self.screen.action_other_up(); return
|
||
ids = sorted(self.sessions.keys())
|
||
if len(ids) <= 1: return
|
||
i = ids.index(self.current_id)
|
||
self.current_id = ids[(i - 1) % len(ids)]
|
||
self._refresh_all()
|
||
|
||
def action_next_session(self) -> None:
|
||
if isinstance(self.screen, RewindTreeScreen):
|
||
self.screen.action_other_down(); return
|
||
ids = sorted(self.sessions.keys())
|
||
if len(ids) <= 1: return
|
||
i = ids.index(self.current_id)
|
||
self.current_id = ids[(i + 1) % len(ids)]
|
||
self._refresh_all()
|
||
|
||
def copy_to_clipboard(self, text: str) -> None:
|
||
self._clipboard = text
|
||
_ssh = os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY")
|
||
if not _ssh and _copy_to_clipboard(text):
|
||
return
|
||
super().copy_to_clipboard(text)
|
||
|
||
def action_handle_ctrl_c(self) -> None:
|
||
# Two-stage quit: when no task is running, first press clears input and arms;
|
||
# second press within 2s exits.
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
except Exception:
|
||
inp = None
|
||
# Copy precedence: focused InputArea selection first (screen-level selection
|
||
# doesn't cover TextArea internals), then screen drag selection.
|
||
if inp is not None and self.focused is inp and inp.selected_text:
|
||
try: self.copy_to_clipboard(inp.selected_text)
|
||
except Exception: pass
|
||
self._disarm_quit()
|
||
return
|
||
try:
|
||
selected_text = self.screen.get_selected_text()
|
||
except Exception:
|
||
selected_text = None
|
||
if selected_text:
|
||
try: self.copy_to_clipboard(selected_text)
|
||
except Exception: pass
|
||
self._disarm_quit()
|
||
return
|
||
sess = self.sessions.get(self.current_id)
|
||
if sess is not None and sess.status == "running":
|
||
self._cmd_stop([], "")
|
||
self._disarm_quit()
|
||
return
|
||
if self._quit_armed:
|
||
self.exit()
|
||
return
|
||
if inp is not None and inp.text:
|
||
inp.reset()
|
||
try: self._resize_input(inp)
|
||
except Exception: pass
|
||
self._quit_armed = True
|
||
self._refresh_bottombar()
|
||
if self._quit_timer is not None:
|
||
try: self._quit_timer.stop()
|
||
except Exception: pass
|
||
self._quit_timer = self.set_timer(2.0, self._disarm_quit)
|
||
|
||
def _disarm_quit(self) -> None:
|
||
if not self._quit_armed and self._quit_timer is None:
|
||
return
|
||
self._quit_armed = False
|
||
if self._quit_timer is not None:
|
||
try: self._quit_timer.stop()
|
||
except Exception: pass
|
||
self._quit_timer = None
|
||
try: self._refresh_bottombar()
|
||
except Exception: pass
|
||
|
||
def _disarm_rewind(self) -> None:
|
||
if not self._rewind_armed and self._rewind_timer is None:
|
||
return
|
||
self._rewind_armed = False
|
||
if self._rewind_timer is not None:
|
||
try: self._rewind_timer.stop()
|
||
except Exception: pass
|
||
self._rewind_timer = None
|
||
try: self._refresh_bottombar()
|
||
except Exception: pass
|
||
|
||
def on_key(self, event: events.Key) -> None:
|
||
if self._quit_armed and event.key not in ("ctrl+c", "cmd+c"):
|
||
self._disarm_quit()
|
||
if self._rewind_armed and event.key != "escape":
|
||
self._disarm_rewind()
|
||
|
||
def action_toggle_sidebar(self) -> None:
|
||
# display:none/block reflow doesn't always settle within one refresh, so
|
||
# mirror the resize debounce: invalidate width-keyed caches, then remount
|
||
# via a short timer (call_after_refresh alone races the layout and the
|
||
# remount can capture the old content_region.width — leaving messages
|
||
# wrapped at the previous width after Ctrl+B).
|
||
sidebar = self.query_one("#sidebar-scroll", VerticalScroll)
|
||
sidebar.toggle_class("-hidden")
|
||
for sess in self.sessions.values():
|
||
for m in sess.messages:
|
||
if m.role == "assistant":
|
||
m._cached_body = None
|
||
m._cache_key = ()
|
||
if self._resize_timer is not None:
|
||
self._resize_timer.stop()
|
||
self._resize_timer = self.set_timer(0.05, self._flush_resize)
|
||
|
||
def action_toggle_fold(self) -> None:
|
||
self.fold_mode = not self.fold_mode
|
||
# Global toggle is authoritative: clear per-fold overrides so the new state
|
||
# is uniformly all-collapsed or all-expanded.
|
||
for sess in self.sessions.values():
|
||
for m in sess.messages:
|
||
if m.role == "assistant":
|
||
m._toggled_folds.clear()
|
||
m._cached_body = None
|
||
m._cache_key = ()
|
||
self._remount_current_session()
|
||
self._refresh_topbar()
|
||
self.notify(f"Fold: {'on' if self.fold_mode else 'off'}", timeout=1)
|
||
|
||
def action_escape(self) -> None:
|
||
# Back out of free-text-input mode → restore the picker the user was
|
||
# answering. Takes priority over the normal Esc path so the InputArea
|
||
# doesn't eat the press.
|
||
if self._return_from_free_text():
|
||
self._disarm_rewind()
|
||
return
|
||
choice = self._active_choice()
|
||
if choice is not None:
|
||
self._cancel_choice(choice.msg)
|
||
self._disarm_rewind()
|
||
return
|
||
try:
|
||
palette = self.query_one("#palette", OptionList)
|
||
except Exception:
|
||
palette = None
|
||
if palette is not None and palette.has_class("-visible"):
|
||
self._hide_palette()
|
||
self.query_one("#input", InputArea).focus()
|
||
self._disarm_rewind()
|
||
return
|
||
# Pending-queue cancel: Esc with the input empty and entries queued
|
||
# drops the lot. Runs before quit-arm so a single Esc clears the
|
||
# queue (no second-press needed).
|
||
try:
|
||
sess = self.current
|
||
except Exception:
|
||
sess = None
|
||
if sess is not None and sess.pending:
|
||
try:
|
||
inp_empty = not (self.query_one("#input", InputArea).text or "")
|
||
except Exception:
|
||
inp_empty = True
|
||
if inp_empty:
|
||
with sess.pending_lk:
|
||
n = len(sess.pending)
|
||
sess.pending = []
|
||
sess.pending_wrapped = []
|
||
self._clear_intervene(sess)
|
||
self._system(f"已清空 {n} 条待发送消息")
|
||
self._disarm_rewind()
|
||
return
|
||
if self._quit_armed:
|
||
self._disarm_quit()
|
||
return
|
||
if self._rewind_armed:
|
||
self._disarm_rewind()
|
||
self._cmd_rewind([], "")
|
||
return
|
||
self._rewind_armed = True
|
||
self._refresh_bottombar()
|
||
if self._rewind_timer is not None:
|
||
try: self._rewind_timer.stop()
|
||
except Exception: pass
|
||
self._rewind_timer = self.set_timer(2.0, self._disarm_rewind)
|
||
|
||
def action_drop_session(self) -> None:
|
||
# Sidebar-only removal: drops the in-memory session so it stops appearing
|
||
# in the sidebar/switcher. The on-disk log + session_names entry are kept,
|
||
# so the session is still recoverable via `/continue <name>` later.
|
||
if len(self.sessions) <= 1:
|
||
self._system("⚠️ 至少保留一个会话")
|
||
return
|
||
sid = self.current_id
|
||
name = self.current.name
|
||
ids = list(self.sessions)
|
||
i = ids.index(sid)
|
||
next_id = ids[i + 1] if i + 1 < len(ids) else ids[i - 1]
|
||
# 释放被移除会话的日志锁:否则它仍留在心跳的 _held_locks 里被持续续活,
|
||
# 占用检测永远判其"活着",之后无法对该日志原地 /continue。先 abort 停掉
|
||
# 可能仍在写日志的 agent,避免"锁已释放但仍在写"的冲突窗口(对齐 begin_fresh_session)。
|
||
dropped = self.sessions.get(sid)
|
||
if dropped is not None:
|
||
try: dropped.agent.abort()
|
||
except Exception: pass
|
||
try:
|
||
from continue_cmd import release_current
|
||
release_current(dropped.agent)
|
||
except Exception: pass
|
||
del self.sessions[sid]
|
||
self.current_id = next_id
|
||
self._last_title = "" # force title refresh on next call
|
||
self._refresh_all()
|
||
self._system(f"✅ 已从侧栏移除 #{sid} {name!r}")
|
||
|
||
def action_show_help(self) -> None:
|
||
if isinstance(self.screen, HelpScreen):
|
||
self.pop_screen()
|
||
else:
|
||
self.push_screen(HelpScreen(self._render_help()))
|
||
|
||
def action_pick_theme(self) -> None:
|
||
if isinstance(self.screen, ThemePicker):
|
||
return
|
||
self.push_screen(ThemePicker(list(_THEME_CYCLE), self.theme or "ga-default"))
|
||
|
||
def _resolve_palette(self) -> dict[str, str]:
|
||
theme = self.current_theme
|
||
if theme is not None and theme.name == "ga-default":
|
||
return dict(_DEFAULT_PALETTE)
|
||
base = super().get_css_variables()
|
||
dark = bool(getattr(theme, "dark", True)) if theme is not None else True
|
||
return _palette_from_resolved_vars(base, dark)
|
||
|
||
def get_css_variables(self) -> dict[str, str]:
|
||
base = super().get_css_variables()
|
||
p = self._resolve_palette()
|
||
for k, v in p.items():
|
||
base[f"ga-{k.replace('_', '-')}"] = v
|
||
return base
|
||
|
||
def watch_theme(self, _old_theme, _new_theme) -> None:
|
||
# Triggered by `self.theme = name`. Sync Python-side state (palette dict,
|
||
# C_* globals, cached widgets) so Rich Text and Markdown also follow.
|
||
theme = self.current_theme
|
||
if theme is None: return
|
||
global _palette, C_FG, C_MUTED, C_DIM, C_SEL_BG, C_GREEN, C_BLUE, C_PURPLE
|
||
global C_CHIP_NAME, C_CHIP_MODEL, C_CHIP_EFFORT, C_CHIP_TASKS, C_CHIP_TIME
|
||
_palette = self._resolve_palette()
|
||
C_FG, C_MUTED, C_DIM = _palette["fg"], _palette["muted"], _palette["dim"]
|
||
C_SEL_BG = _palette["sel_bg"]
|
||
C_GREEN, C_BLUE, C_PURPLE = _palette["green"], _palette["blue"], _palette["purple"]
|
||
C_CHIP_NAME = _palette["chip_name"]
|
||
C_CHIP_MODEL = _palette["chip_model"]
|
||
C_CHIP_EFFORT = _palette["chip_effort"]
|
||
C_CHIP_TASKS = _palette["chip_tasks"]
|
||
C_CHIP_TIME = _palette["chip_time"]
|
||
# watch_theme fires once during __init__ when we set ga-default — at that
|
||
# point sessions is empty and the DOM isn't composed yet. Skip the rebuild.
|
||
if not self.is_mounted or self.current_id is None:
|
||
return
|
||
# Cached Rich Text / Markdown captured the old hex values; force a remount.
|
||
for s in self.sessions.values():
|
||
for m in s.messages:
|
||
m._cache_key = None
|
||
m._cached_body = None
|
||
m._seg_render_cache.clear()
|
||
m._segment_widgets = []
|
||
m._segment_sig = ()
|
||
m._role_widget = None
|
||
m._body_widget = None
|
||
m._hint_widget = None
|
||
m._spinner_widget = None
|
||
try:
|
||
self._remount_current_session()
|
||
self._refresh_topbar()
|
||
self._refresh_sidebar()
|
||
self._refresh_bottombar()
|
||
except Exception:
|
||
pass
|
||
|
||
def _render_help(self) -> Text:
|
||
rows = [
|
||
("Enter", "发送"),
|
||
(fmt_keys("ctrl+j", "ctrl+enter"), "换行(Shift+Enter 同义)"),
|
||
(fmt_key("ctrl+c"), "停止任务 / 空闲时连按两次退出"),
|
||
(fmt_key("ctrl+n"), "新建会话"),
|
||
(fmt_key("ctrl+b"), "切换侧栏"),
|
||
(fmt_keys("ctrl+up", "ctrl+down"), "切换会话"),
|
||
(fmt_key("ctrl+d"), "侧栏移除会话"),
|
||
(fmt_key("ctrl+o"), "折叠 / 展开已完成的轮次"),
|
||
(fmt_key("ctrl+u"), "清空输入框"),
|
||
(fmt_key("ctrl+v"), "粘贴(图片优先)"),
|
||
("↑ / ↓", "输入框:浏览发送历史 / 面板内:移动"),
|
||
("/", "唤起命令面板"),
|
||
("Tab", "命令面板可见时补全"),
|
||
("Esc", "取消选择 / 关闭面板 / 关闭帮助"),
|
||
("Esc Esc", "打开回退选择"),
|
||
(fmt_key("ctrl+t"), "切换主题"),
|
||
(fmt_key("ctrl+/"), "显示 / 隐藏本帮助"),
|
||
]
|
||
t = Text()
|
||
t.append("快捷键帮助\n\n", style=f"bold {C_GREEN}")
|
||
for k, d in rows:
|
||
t.append(f" {k:<22}", style=C_FG)
|
||
t.append(f"{d}\n", style=C_MUTED)
|
||
t.append(f"\n按 Esc 或 {fmt_key('ctrl+/')} 关闭", style=C_DIM)
|
||
return t
|
||
|
||
def action_complete_command(self) -> None:
|
||
# `tab` 是 app 级 priority 绑定(命令补全),会盖过 ModalScreen 的 on_key;
|
||
# 世界线屏激活时把 Tab 转交给它的「切聚焦」(否则 Tab 在三栏里被补全吞掉)。
|
||
if isinstance(self.screen, RewindTreeScreen):
|
||
self.screen.action_focus_toggle(); return
|
||
palette = self.query_one("#palette", OptionList)
|
||
if not palette.has_class("-visible"):
|
||
return
|
||
inp = self.query_one("#input", InputArea)
|
||
if not inp.has_focus:
|
||
return
|
||
if palette.highlighted is None:
|
||
palette.action_cursor_down()
|
||
if palette.highlighted is not None:
|
||
palette.action_select()
|
||
|
||
async def _on_paste(self, event: events.Paste) -> None:
|
||
# Windows Terminal yanks window focus when its large-paste-warning dialog
|
||
# pops, and the focus doesn't return to any specific widget after confirm.
|
||
# Without a focused widget Textual routes the Paste event to the App
|
||
# bubble — InputArea never sees it. Forward it back to the input box.
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
except Exception:
|
||
return
|
||
inp.focus()
|
||
await inp._on_paste(event)
|
||
event.stop(); event.prevent_default()
|
||
|
||
def on_click(self, event: events.Click) -> None:
|
||
w = event.widget
|
||
if isinstance(w, FoldHeader):
|
||
msg = w.msg
|
||
idx = w.fold_idx
|
||
if idx in msg._toggled_folds:
|
||
msg._toggled_folds.discard(idx)
|
||
else:
|
||
msg._toggled_folds.add(idx)
|
||
msg._cached_body = None
|
||
msg._cache_key = ()
|
||
self._remount_assistant_message(msg)
|
||
return
|
||
try:
|
||
sidebar = self.query_one("#sidebar", Static)
|
||
except Exception:
|
||
return
|
||
if event.widget is not sidebar:
|
||
return
|
||
# event.y is widget-local (includes padding-top=1). Layout: pad + "SESSIONS" + blank.
|
||
y = event.y - 3
|
||
if y < 0:
|
||
return
|
||
for sid, sess in self.sessions.items():
|
||
rows = 3
|
||
if _sidebar_last_user(sess): rows += 1
|
||
if _sidebar_last_summary(sess): rows += 1
|
||
if y < rows:
|
||
if sid != self.current_id:
|
||
self.current_id = sid
|
||
self._refresh_all()
|
||
return
|
||
y -= rows
|
||
|
||
# ---------------- input + palette ----------------
|
||
def on_resize(self, event) -> None:
|
||
# Terminals fire multiple resize events per drag; short-circuit on identical size.
|
||
size = (self.size.width, self.size.height)
|
||
if size == self._last_size:
|
||
return
|
||
self._last_size = size
|
||
# Input height auto-fit is latency-sensitive; full layout reflow is debounced 80ms.
|
||
try: self._resize_input(self.query_one("#input", InputArea))
|
||
except Exception: pass
|
||
if self._resize_timer is not None:
|
||
self._resize_timer.stop()
|
||
self._resize_timer = self.set_timer(0.08, self._flush_resize)
|
||
|
||
def _flush_resize(self) -> None:
|
||
self._resize_timer = None
|
||
self._apply_responsive_layout()
|
||
|
||
def _apply_responsive_layout(self) -> None:
|
||
try:
|
||
sidebar = self.query_one("#sidebar-scroll", VerticalScroll)
|
||
main = self.query_one("#main", Vertical)
|
||
except Exception:
|
||
return
|
||
w = self.size.width
|
||
self._last_size = (w, self.size.height)
|
||
# -narrow is auto-hide; -hidden is the Ctrl+B manual toggle. Keep them separate.
|
||
if w < 70:
|
||
sidebar.add_class("-narrow")
|
||
else:
|
||
sidebar.remove_class("-narrow")
|
||
sidebar.styles.width = max(30, min(50, w // 5))
|
||
main.styles.padding = (1, 2) if w < 90 else (1, 6)
|
||
# Padding changes recompute layout asynchronously — defer remount one frame.
|
||
self.call_after_refresh(self._remount_current_session)
|
||
|
||
def _remount_current_session(self) -> None:
|
||
if self.current_id is None or not self.is_mounted:
|
||
return
|
||
try:
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
except Exception:
|
||
return
|
||
# Preserve scroll position across remount. "Near the bottom" snaps to
|
||
# bottom afterwards so streaming output stays visible; mid-scroll keeps
|
||
# the same scroll_y so resize/sidebar-toggle don't yank the user away
|
||
# from what they're reading. 2-line tolerance covers rounding.
|
||
try:
|
||
was_at_bottom = (container.scroll_y + container.size.height
|
||
>= container.virtual_size.height - 2)
|
||
prev_scroll_y = container.scroll_y
|
||
except Exception:
|
||
was_at_bottom = True
|
||
prev_scroll_y = 0
|
||
container.remove_children()
|
||
for m in self.current.messages:
|
||
m._role_widget = None
|
||
m._body_widget = None
|
||
m._hint_widget = None
|
||
m._segment_widgets = []
|
||
m._segment_sig = ()
|
||
m._spinner_widget = None
|
||
for m in self.current.messages:
|
||
self._mount_message(container, m)
|
||
if was_at_bottom:
|
||
container.scroll_end(animate=False)
|
||
else:
|
||
container.scroll_to(y=prev_scroll_y, animate=False)
|
||
|
||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||
if event.text_area.id != "input":
|
||
return
|
||
inp = event.text_area
|
||
# action_stash flips this flag right before `reset()`/text assign
|
||
# so the synchronous Changed event won't trigger the heavy
|
||
# _resize_input + palette-query on the keystroke hot path. The
|
||
# deferred `_stash_cleanup_*` callbacks (call_after_refresh) own
|
||
# all the layout work for that path.
|
||
if getattr(inp, "_skip_change_next", False):
|
||
inp._skip_change_next = False
|
||
return
|
||
self._resize_input(inp)
|
||
val = (inp.text or "").lstrip()
|
||
if self._suppress_palette_open:
|
||
self._suppress_palette_open = False
|
||
self._hide_palette()
|
||
return
|
||
# Only show palette while the first line still looks like a command name.
|
||
first_line = val.split("\n", 1)[0]
|
||
if first_line.startswith("/") and " " not in first_line and "\n" not in val:
|
||
self._populate_palette(first_line)
|
||
self._show_palette()
|
||
return
|
||
# @ file mention: reuse the same palette for path candidates when the
|
||
# cursor sits inside an `@token` (claude-code parity, workspace-rooted).
|
||
try:
|
||
row, col = inp.cursor_location
|
||
line = inp.document.get_line(row)[:col]
|
||
except Exception:
|
||
line = ""
|
||
tok = find_at_token(line)
|
||
if tok is not None:
|
||
self._populate_at_palette(tok[0])
|
||
else:
|
||
self._hide_palette()
|
||
|
||
def _populate_at_palette(self, query: str) -> None:
|
||
sess = self.sessions.get(self.current_id)
|
||
unbound = not (sess and sess.workspace_path) # 未绑 workspace → 根是 temp,显示完整路径
|
||
matches = candidates_for(query, self._at_root(), absolute=unbound)
|
||
palette = self.query_one("#palette", OptionList)
|
||
palette.clear_options()
|
||
if not matches:
|
||
self._hide_palette()
|
||
return
|
||
for path in matches:
|
||
t = Text()
|
||
if unbound: # 未绑:整条完整路径(根不直观)
|
||
t.append(path)
|
||
else: # 绑 workspace:base + 父目录(短)
|
||
# 目录候选末尾带 '/',先剥掉再拆 base,否则 rsplit 得到空串 → 空白行。
|
||
is_dir = path.endswith("/")
|
||
core = path.rstrip("/")
|
||
parent, name = core.rsplit("/", 1) if "/" in core else ("", core)
|
||
base = name + ("/" if is_dir else "")
|
||
t.append(base, style="bold")
|
||
if parent:
|
||
t.append(f" {parent}", style=C_MUTED)
|
||
palette.add_option(Option(t, id=f"at:{path}"))
|
||
self._show_palette()
|
||
|
||
def _resize_input(self, inp: TextArea) -> None:
|
||
# wrapped_document.height counts soft-wrapped lines; document.line_count only logical.
|
||
try:
|
||
lines = inp.wrapped_document.height or inp.document.line_count
|
||
except Exception:
|
||
lines = inp.document.line_count
|
||
target = min(max(lines, 1), 3) + 2 # +2 for padding 1 2 top/bottom
|
||
# No-op guard: assigning `styles.height` re-triggers a screen-wide,
|
||
# O(mounted-widgets) relayout in Textual even when the value is
|
||
# unchanged. With 100+ messages on screen each call costs hundreds of
|
||
# ms, which makes typing laggy and makes Ctrl+S/stash feel frozen
|
||
# (this method is called on every keystroke and twice per stash/submit).
|
||
# `_resize_input` is the only writer of the input height, so caching the
|
||
# last value we applied is an authoritative, API-stable way to skip the
|
||
# redundant relayouts. A genuine height change still relayouts once.
|
||
if getattr(inp, "_ga_last_height", None) == target:
|
||
return
|
||
inp._ga_last_height = target
|
||
inp.styles.height = target
|
||
|
||
def on_input_area_submitted(self, event: "InputArea.Submitted") -> None:
|
||
inp = event.input_area
|
||
if inp.id != "input":
|
||
return
|
||
text = inp.expand_placeholders(event.value).rstrip()
|
||
images = re.findall(r"\[Image #\d+: (.*?)\]", text)
|
||
inp.record_history(event.value)
|
||
inp.reset()
|
||
self._hide_palette()
|
||
self._resize_input(inp)
|
||
if not text:
|
||
return
|
||
# Pick up mykey.py edits without restart: load_llm_sessions() is a
|
||
# cheap mtime check when nothing changed; on change it rebuilds the
|
||
# llm clients in place, migrating history. Done per submit so a user
|
||
# who tweaks mykey then sends a message gets the new config.
|
||
try:
|
||
sess = self.sessions.get(self.current_id)
|
||
if sess is not None and hasattr(sess, "agent"):
|
||
sess.agent.load_llm_sessions()
|
||
except Exception:
|
||
pass
|
||
if text.startswith("!"):
|
||
# Shell-mode magic: run the rest as a host shell command, echo
|
||
# the command + output into scrollback, and append the pair to
|
||
# the agent's LLM history so a follow-up question can reference
|
||
# it (parity with v3 _run_shell).
|
||
self._run_shell(text[1:].strip())
|
||
try:
|
||
self.query_one("#messages", VerticalScroll).scroll_end(animate=False)
|
||
except Exception:
|
||
pass
|
||
return
|
||
if text.startswith("/"):
|
||
parts = text.split(maxsplit=1)
|
||
cmd = parts[0][1:].lower()
|
||
args = parts[1].split() if len(parts) > 1 else []
|
||
if cmd in self._handlers:
|
||
self._dispatch_command(cmd, args, raw=text)
|
||
try:
|
||
self.query_one("#messages", VerticalScroll).scroll_end(animate=False)
|
||
except Exception:
|
||
pass
|
||
return
|
||
if cmd == "resume":
|
||
# GA's _handle_slash_cmd expands `/resume` at agent side —
|
||
# forward the literal so the agent recovers context.
|
||
self.submit_user_message(text)
|
||
return
|
||
# @ mentions (completion-only): rewrite @relative → @absolute so the
|
||
# agent's file_read can locate it; scrollback keeps the short form via
|
||
# display_text. No content is read here. (Content-injecting auto-read
|
||
# variant: temp/plan_v2_at_mention/autoread_version.py.)
|
||
if "@" in text:
|
||
abs_text = absolutize_mentions(text, self._at_root())
|
||
if abs_text != text:
|
||
self.submit_user_message(abs_text, images=images, display_text=text)
|
||
return
|
||
self.submit_user_message(text, images=images)
|
||
|
||
def _show_palette(self) -> None:
|
||
self.query_one("#palette", OptionList).add_class("-visible")
|
||
|
||
def _hide_palette(self) -> None:
|
||
self.query_one("#palette", OptionList).remove_class("-visible")
|
||
|
||
def _populate_palette(self, value: str) -> None:
|
||
palette = self.query_one("#palette", OptionList)
|
||
prefix = value.strip().lower()
|
||
matches = [c for c in COMMANDS if c[0].startswith(prefix)]
|
||
palette.clear_options()
|
||
if not matches:
|
||
self._hide_palette()
|
||
return
|
||
for cmd, args, desc in matches:
|
||
# No color: reverse-video highlight pairs badly with colored text.
|
||
t = Text()
|
||
t.append(f"{cmd:<11}", style="bold")
|
||
t.append(f"{args:<18}")
|
||
t.append(f" {desc}")
|
||
palette.add_option(Option(t, id=cmd))
|
||
|
||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||
ol = event.option_list
|
||
if ol.id == "palette":
|
||
cmd_id = event.option.id
|
||
if cmd_id and cmd_id.startswith("at:"):
|
||
# @ candidate accepted: replace the in-progress @token with the
|
||
# picked path (quoted when it contains spaces), cursor to end.
|
||
inp = self.query_one("#input", InputArea)
|
||
try:
|
||
row, col = inp.cursor_location
|
||
line = inp.document.get_line(row)[:col]
|
||
tok = find_at_token(line)
|
||
if tok is not None:
|
||
rep = format_pick(cmd_id[3:])
|
||
self._suppress_palette_open = True
|
||
inp.replace(rep, (row, tok[1]), (row, col))
|
||
inp.move_cursor((row, tok[1] + len(rep)))
|
||
except Exception:
|
||
pass
|
||
self._hide_palette()
|
||
inp.focus()
|
||
return
|
||
if cmd_id:
|
||
inp = self.query_one("#input", InputArea)
|
||
needs_args = any(c[1] for c in COMMANDS if c[0] == cmd_id)
|
||
self._suppress_palette_open = True
|
||
new_text = cmd_id + (" " if needs_args else "")
|
||
inp.text = new_text
|
||
inp.move_cursor((0, len(new_text)))
|
||
self._hide_palette()
|
||
self.query_one("#input", InputArea).focus()
|
||
return
|
||
if isinstance(ol, ChoiceList):
|
||
self._collapse_choice(ol.msg, event.option_index)
|
||
return
|
||
|
||
def _active_choice(self) -> Optional["ChoiceList"]:
|
||
if self.current_id is None:
|
||
return None
|
||
for m in reversed(self.current.messages):
|
||
if m.kind == "choice" and m.selected_label is None:
|
||
w = m._body_widget
|
||
# SearchableChoiceList wraps a ChoiceList; expose the inner
|
||
# picker so all the existing keyboard / selected_label code
|
||
# (action_select, page_up/down, etc.) works untouched.
|
||
if isinstance(w, SearchableChoiceList):
|
||
if isinstance(w.picker, ChoiceList):
|
||
return w.picker
|
||
return None
|
||
if isinstance(w, ChoiceList):
|
||
return w
|
||
return None
|
||
|
||
def _cancel_choice(self, msg: ChatMessage) -> None:
|
||
for w in (msg._role_widget, msg._hint_widget, msg._body_widget):
|
||
if w is not None:
|
||
try: w.remove()
|
||
except Exception: pass
|
||
msg._role_widget = None
|
||
msg._hint_widget = None
|
||
msg._body_widget = None
|
||
sess = self.sessions.get(self.current_id)
|
||
if sess and msg in sess.messages:
|
||
sess.messages.remove(msg)
|
||
try:
|
||
self.query_one("#input", InputArea).focus()
|
||
except Exception:
|
||
pass
|
||
# ask_user-style rollback hook: if the card declared an on_cancel
|
||
# callable (e.g. /scheduler submit-confirm wants Esc to re-show the
|
||
# picker), fire it after the card is gone.
|
||
cb = getattr(msg, "on_cancel", None)
|
||
if cb is not None:
|
||
try: cb()
|
||
except Exception as e:
|
||
self._system(f"on_cancel 异常: {type(e).__name__}: {e}")
|
||
|
||
def _finalize_multi_choice(self, msg: ChatMessage, indices: list[int]) -> None:
|
||
"""User pressed Enter on a MultiChoiceList.
|
||
|
||
- If any picked entry is the free-text sentinel, switch the whole
|
||
message into free-text mode (the user wants to type instead).
|
||
- If `msg.on_select` is set, *the picker owner* consumes the picked
|
||
values directly (used by `/scheduler`'s multi-pick reflect launcher).
|
||
We still collapse the picker into a "Selected: ..." breadcrumb so the
|
||
scrollback shows what happened, but skip the `Submit answers?`
|
||
confirmation card — the caller handles follow-up actions itself.
|
||
- Otherwise (default `ask_user` multi-mode) post a `Ready to submit
|
||
your answers?` confirmation card (Submit / Edit answer) before the
|
||
agent sees it.
|
||
|
||
Indices are SelectionList values (set = list index in _mount_message)."""
|
||
picked = [msg.choices[i] for i in indices if 0 <= i < len(msg.choices)]
|
||
if any(v == FREE_TEXT_CHOICE for _, v in picked):
|
||
self._enter_free_text_mode(msg)
|
||
return
|
||
labels = [lbl for lbl, _ in picked]
|
||
values = [val for _, val in picked]
|
||
# Empty selection: for default ask_user multi-mode this means "nothing
|
||
# picked yet" → keep the picker up. But a picker *owner* (on_select set,
|
||
# e.g. /scheduler) treats an empty set as a deliberate action — namely
|
||
# "stop everything that was preselected" — so we must let it through.
|
||
if not labels and msg.on_select is None:
|
||
return # nothing selected → keep the picker up
|
||
joined = "; ".join(labels) if labels else "(无 / none)"
|
||
question = msg.content.split(" ")[0].rstrip() if " " in msg.content else msg.content
|
||
msg.selected_label = f"{question} → {joined}"
|
||
msg.content = msg.selected_label
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
body = Text()
|
||
body.append("✓ ", style=C_GREEN); body.append("Selected: ", style=C_MUTED)
|
||
body.append(joined, style=C_FG)
|
||
new_widget = SelectableStatic(body, classes="msg")
|
||
anchor = msg._hint_widget or msg._body_widget
|
||
if anchor is not None: container.mount(new_widget, after=anchor)
|
||
else: container.mount(new_widget)
|
||
if msg._hint_widget is not None: msg._hint_widget.remove(); msg._hint_widget = None
|
||
if msg._body_widget is not None: msg._body_widget.remove()
|
||
msg._body_widget = new_widget
|
||
# Picker-owner short-circuit: caller wants the raw list of picked
|
||
# values, no confirm card. Try labels too so callers can pick either.
|
||
if msg.on_select is not None:
|
||
try:
|
||
msg.on_select(values)
|
||
except Exception as e:
|
||
self._system(f"multi_choice on_select 异常: {type(e).__name__}: {e}")
|
||
return
|
||
sess = self.sessions.get(self.current_id)
|
||
if sess is None: return
|
||
confirm = ChatMessage(
|
||
role="system",
|
||
content="Ready to submit your answers? ←/→ 选择 · Enter 确认 · Esc 取消",
|
||
kind="choice",
|
||
choices=[("Submit answers", joined), ("Edit answer", EDIT_ANSWER_CHOICE)],
|
||
on_select=lambda v, aid=sess.agent_id: self._finalize_free_text(aid, v),
|
||
)
|
||
sess.messages.append(confirm)
|
||
self._refresh_messages()
|
||
|
||
def _collapse_choice(self, msg: ChatMessage, idx: int) -> None:
|
||
if not (0 <= idx < len(msg.choices)):
|
||
return
|
||
label, value = msg.choices[idx]
|
||
# Free-text sentinel: collapse the picker into a "type your answer"
|
||
# prompt (keeping the question visible), focus the input, and arm
|
||
# `sess.free_text_pending` so the next submit goes through a
|
||
# `Ready to submit?` confirmation step before reaching the agent.
|
||
if value == FREE_TEXT_CHOICE:
|
||
self._enter_free_text_mode(msg)
|
||
return
|
||
# Edit sentinel: emitted from the submit-confirmation card to mean
|
||
# "go back to typing". Just collapse this card and refocus input —
|
||
# the user's pending answer is already in `sess.free_text_pending`.
|
||
if value == EDIT_ANSWER_CHOICE:
|
||
self._return_to_free_text_edit(msg)
|
||
return
|
||
result_text = None
|
||
if msg.on_select:
|
||
try:
|
||
result_text = msg.on_select(value)
|
||
except Exception as e:
|
||
result_text = f"❌ 失败: {type(e).__name__}: {e}"
|
||
# 延迟折叠卡片(如 /rewind):on_select 已接管卡片生命周期(弹模式窗;确认才清卡片+
|
||
# 回退,Esc 则原样保留列表)。不在此折叠成「✓」,避免"选了却没回退"的误导。
|
||
if getattr(msg, "defer_collapse", False):
|
||
if result_text: # on_select 异常时仍反馈,不静默吞掉
|
||
self._system(result_text)
|
||
return
|
||
# PR #466 (upstream 8ae3645): if on_select rebuilt the message
|
||
# container (e.g. /rewind picker → _do_rewind → _remount_current_session
|
||
# detaches every widget under #messages), the captured anchors are
|
||
# now stale. Bail out early so we don't try to mount(after=...)
|
||
# against a detached widget — that'd raise NoWidget and crash.
|
||
anchor_guard = msg._hint_widget or msg._body_widget
|
||
if (anchor_guard is not None
|
||
and hasattr(anchor_guard, 'is_mounted')
|
||
and not anchor_guard.is_mounted):
|
||
return
|
||
# label 可能是 rich Text(如 /rewind 的带色三列)——取其纯文本再 strip,
|
||
# 折叠后的「✓ …」行用纯文本即可(颜色只需在实时列表里)。
|
||
lbl = label if isinstance(label, str) else getattr(label, "plain", str(label))
|
||
display = (result_text or lbl).strip() or lbl
|
||
msg.selected_label = display
|
||
msg.content = display
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
was_at_bottom = self._at_bottom(container)
|
||
body = Text()
|
||
body.append("✓ ", style=C_GREEN)
|
||
body.append(display, style=C_FG)
|
||
new_widget = SelectableStatic(body, classes="msg")
|
||
# Prefer hint anchor; fall back to body; finally append at the bottom.
|
||
# `getattr(..., "is_mounted", False)` guards against a hint that was
|
||
# already removed by a concurrent re-render path (Textual raises
|
||
# NoWidget when mounting after a detached widget).
|
||
anchor = msg._hint_widget if getattr(msg._hint_widget, "is_mounted", False) else None
|
||
if anchor is None and getattr(msg._body_widget, "is_mounted", False):
|
||
anchor = msg._body_widget
|
||
try:
|
||
if anchor is not None:
|
||
container.mount(new_widget, after=anchor)
|
||
else:
|
||
container.mount(new_widget)
|
||
except Exception:
|
||
# Last-resort: append at the end so the user still sees the choice
|
||
# they made. The selectable widget itself is correct; only its
|
||
# placement degrades.
|
||
container.mount(new_widget)
|
||
if msg._hint_widget is not None:
|
||
msg._hint_widget.remove()
|
||
msg._hint_widget = None
|
||
if msg._body_widget is not None:
|
||
msg._body_widget.remove()
|
||
msg._body_widget = new_widget
|
||
if was_at_bottom:
|
||
container.scroll_end(animate=False)
|
||
self.query_one("#input", InputArea).focus()
|
||
|
||
def _dispatch_command(self, cmd: str, args: list[str], raw: str = "") -> None:
|
||
h = self._handlers.get(cmd)
|
||
if h: h(args, raw)
|
||
|
||
# ---------------- legacy commands ----------------
|
||
def _cmd_help(self, args, raw):
|
||
lines = [f"{c:<11} {a:<18} {d}" for c, a, d in COMMANDS]
|
||
self._system("命令列表:\n" + "\n".join(lines))
|
||
|
||
def _cmd_status(self, args, raw):
|
||
lines = []
|
||
for sid, s in self.sessions.items():
|
||
mark = "*" if sid == self.current_id else " "
|
||
lines.append(f"{mark} #{sid} {s.name} [{s.status}] msgs={len(s.messages)} task={s.current_task_id}")
|
||
self._system("Sessions:\n" + "\n".join(lines))
|
||
|
||
def _cmd_new(self, args, raw):
|
||
name = " ".join(args).strip() or None
|
||
sess = self.add_session(name)
|
||
self._system(f"Created session #{sess.agent_id} ({sess.name}).")
|
||
|
||
def _cmd_switch(self, args, raw):
|
||
if not args:
|
||
self._system("Usage: /switch <id|name>"); return
|
||
key = " ".join(args)
|
||
target = int(key) if key.isdigit() and int(key) in self.sessions else None
|
||
if target is None:
|
||
for sid, s in self.sessions.items():
|
||
if s.name == key: target = sid; break
|
||
if target is None:
|
||
self._system(f"No session: {key!r}"); return
|
||
self.current_id = target
|
||
self._refresh_all()
|
||
self._system(f"Switched to #{target}.")
|
||
|
||
def _cmd_close(self, args, raw):
|
||
if len(self.sessions) <= 1:
|
||
self._system("Cannot close the last session."); return
|
||
closed = self.sessions.pop(self.current_id)
|
||
_rmdir_if_empty(getattr(closed.agent, 'task_dir', None))
|
||
self.current_id = next(iter(self.sessions))
|
||
self._refresh_all()
|
||
|
||
def _cmd_rename(self, args, raw):
|
||
if not args:
|
||
self._system("Usage: /rename <name>"); return
|
||
name = " ".join(args).strip()
|
||
if not name:
|
||
self._system("Usage: /rename <name>"); return
|
||
if name.lower() == (self.current.name or "").lower():
|
||
self._system(f"⚠️ 已经叫 {name!r}"); return
|
||
for sid, s in self.sessions.items():
|
||
if sid != self.current_id and s.name.lower() == name.lower():
|
||
self._system(f"❌ 名称已被会话 #{sid} 占用,请换一个"); return
|
||
# Registry collision: another log already owns this name on disk.
|
||
# `agent.log_path` is the microsecond-stamped file the agent actually
|
||
# writes to (see agentmain.GenericAgent.__init__); exclude its basename
|
||
# so renaming yourself isn't reported as a collision.
|
||
log_path = getattr(self.current.agent, "log_path", "") or ""
|
||
own_key = os.path.basename(log_path)
|
||
try:
|
||
import session_names
|
||
if session_names.has_name(name, exclude_basename=own_key):
|
||
self._system(f"❌ 名称已被另一会话注册,请换一个"); return
|
||
except Exception:
|
||
session_names = None
|
||
self.current.name = name
|
||
if log_path and session_names is not None:
|
||
try:
|
||
session_names.set_name(log_path, name)
|
||
except Exception as e:
|
||
self._system(f"⚠️ 名称未持久化: {type(e).__name__}: {e}")
|
||
self._refresh_topbar(); self._refresh_sidebar()
|
||
self._system(f"✅ 已重命名为 {name!r}")
|
||
|
||
def _cmd_branch(self, args, raw):
|
||
import copy
|
||
old = self.current
|
||
name = " ".join(args).strip() or f"{old.name}-branch"
|
||
new = self.add_session(name)
|
||
try:
|
||
new.agent.llmclient.backend.history = copy.deepcopy(old.agent.llmclient.backend.history)
|
||
except Exception as e:
|
||
self._system(f"Branch warning: {e}"); return
|
||
if old.workspace_name:
|
||
self._bind_workspace(new, {
|
||
"name": old.workspace_name,
|
||
"target": old.workspace_path,
|
||
"link": old.workspace_link,
|
||
})
|
||
# deepcopy(old.messages) trips on mounted Textual widget refs; shallow-copy each
|
||
# ChatMessage and null out widget/cache fields so the new session re-mounts cleanly.
|
||
new.messages = []
|
||
for m in old.messages:
|
||
nm = copy.copy(m)
|
||
nm._role_widget = None
|
||
nm._body_widget = None
|
||
nm._hint_widget = None
|
||
nm._cached_body = None
|
||
nm._cache_key = ()
|
||
nm._segment_widgets = []
|
||
nm._segment_sig = ()
|
||
nm._toggled_folds = set()
|
||
nm._spinner_widget = None
|
||
new.messages.append(nm)
|
||
new.task_seq = old.task_seq
|
||
n = len(new.agent.llmclient.backend.history)
|
||
self._system(f"Branched #{old.agent_id} → #{new.agent_id} ({n} msgs).")
|
||
|
||
def _cmd_rewind(self, args, raw):
|
||
# 外观:对话流内联选择卡片(旧版样式)。行为:有世界线树时走【持久】通道——
|
||
# 选中提问后弹 RestoreModeScreen(复用 /worldline 那套)选 对话/代码/两者,再
|
||
# _rw_restore_node→restore_plan(落盘 tree HEAD + 重写投影日志,continue 后不复活)。
|
||
# 无树才兜底内存级 _do_rewind(非持久,仅对话)。分支树视图 / diff 走 /worldline。
|
||
sess = self.current
|
||
if sess.status == "running":
|
||
self._system("Cannot rewind while running. /stop first."); return
|
||
store = getattr(sess, "store", None)
|
||
# 不在会话内对账:树由 _rw_commit 每轮提交,始终 ⊇ 当前对话;且此刻 backend.history
|
||
# 是压缩/删头后的 live,拿它对账会被误判「分歧」而弃树(见 worldline.reconcile)。
|
||
# 对账统一只在 /continue(全量日志刚载入、未压缩)时做。直接按树的线性路径建卡片,
|
||
# 每个选项 = 一个真实节点 → 选中即可持久回退。
|
||
durable = False
|
||
now = time.time()
|
||
items = [] # 最近→最旧,每项 {payload, prompt, ago, ins, dele}
|
||
if store is not None and store.nodes and store.head in store.nodes:
|
||
path = []
|
||
for nid in store.linear_path(): # 旧→新
|
||
nd = store.nodes[nid]
|
||
if nd.get("kind") == "origin":
|
||
continue
|
||
title = nd.get("title") or "(空)"
|
||
try:
|
||
title = store._strip_project_mode(title).replace("\n", " ").strip() or "(空)"
|
||
except Exception:
|
||
pass
|
||
try:
|
||
ins, dele = store.node_line_delta(nid)
|
||
except Exception:
|
||
ins = dele = 0
|
||
path.append({"payload": nid, "prompt": title,
|
||
"ago": int(max(0, now - nd.get("created", now))),
|
||
"ins": ins, "dele": dele})
|
||
items = list(reversed(path)) # → 最近→最旧
|
||
durable = bool(items)
|
||
if not items: # 兜底:无 store/无真实提问 → 内存级
|
||
turns = self._rewindable_turns()
|
||
if not turns:
|
||
self._system("No rewindable turns."); return
|
||
items = [{"payload": i + 1, "prompt": p, "ago": None, "ins": 0, "dele": 0}
|
||
for i, (_, p) in enumerate(reversed(turns))] # 最近→最旧;payload=回退轮数 n
|
||
durable = False
|
||
total = len(items)
|
||
if args: # /rewind n 直接回退,不弹卡片
|
||
try: n = int(args[0])
|
||
except ValueError: self._system("Usage: /rewind [n]"); return
|
||
if n < 1 or n > total:
|
||
self._system(f"Invalid: 1-{total}"); return
|
||
if durable:
|
||
self._rewind_pick_mode(sess, items[n - 1]["payload"]) # 弹模式窗后回退
|
||
else:
|
||
self._system(self._do_rewind(items[n - 1]["payload"]))
|
||
return
|
||
LIMIT = 20
|
||
# 卡片:取最近 LIMIT 条,倒序展示(越往下越新),打开即聚焦到底部(最新)。
|
||
card = list(reversed(items[:LIMIT])) # → 最旧→最新
|
||
choices = [(self._rewind_label(it, durable), it["payload"]) for it in card]
|
||
head = "选择回退到的提问(越往下越新;↑/↓ 移动,→/Enter 确认,Esc 取消)"
|
||
if total > LIMIT:
|
||
head += f" [仅显示最近 {LIMIT}/{total}]"
|
||
msg = ChatMessage(role="system", content=head, kind="choice",
|
||
choices=choices, on_select=None,
|
||
initial_highlight=len(choices) - 1)
|
||
if durable:
|
||
# 延迟折叠:选中只弹模式窗、列表卡片留着。确认 → _rewind_pick_mode 清卡片+回退;
|
||
# Esc → 卡片原样留在列表(不折叠成误导性的「✓ 已选中」)。
|
||
msg.defer_collapse = True
|
||
msg.on_select = lambda v, _m=msg: self._rewind_pick_mode(sess, v, _m)
|
||
else:
|
||
msg.on_select = lambda v: self._do_rewind(v)
|
||
sess.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def _rewind_label(self, it, durable) -> "Text":
|
||
"""一行:用户 prompt(定宽) · 上次会话时间 · 代码变动行数(+新增 -删除)。
|
||
定宽让时间/行数两列对齐(CJK 按显示格计);非持久(无 store)只显示 prompt。
|
||
返回 rich Text:+新增 绿、-删除 红、无改动显示暗色「(无改动)」。"""
|
||
from rich.cells import set_cell_size
|
||
# 先 ellipsize(超长截断并加「…」)再补齐到定宽——直接 set_cell_size 会硬切、无省略号。
|
||
p = set_cell_size(ellipsize(it.get("prompt") or "(空)", 40), 40)
|
||
t = Text(no_wrap=True, overflow="ellipsis")
|
||
if not durable or it.get("ago") is None:
|
||
t.append(p.rstrip())
|
||
return t
|
||
t.append(p)
|
||
t.append(" ")
|
||
t.append(set_cell_size(rel_time(it["ago"]), 8), style=C_DIM)
|
||
t.append(" ")
|
||
ins, dele = it["ins"], it["dele"]
|
||
if not (ins or dele):
|
||
t.append("(无改动)", style=C_DIM)
|
||
else:
|
||
t.append(f"+{ins}", style=C_GREEN)
|
||
t.append(" ")
|
||
t.append(f"-{dele}", style=C_RED)
|
||
return t
|
||
|
||
def _rewind_pick_mode(self, sess, node_id, card=None) -> None:
|
||
"""选中要回退到的提问后,弹 RestoreModeScreen(复用 /worldline 的模式选择窗)选
|
||
对话/代码/两者,再走持久回退 _rw_restore_node(to='before')。
|
||
card:发起本次选择的 /rewind 列表卡片(defer_collapse)。确认回退 → 先清掉它(不留
|
||
「✓」)再回退;Esc 取消(mode=None) → 什么都不做,列表卡片仍在 → 焦点自动回到列表。"""
|
||
store = getattr(sess, "store", None)
|
||
title = ""
|
||
if store is not None and node_id in getattr(store, "nodes", {}):
|
||
title = store.nodes[node_id].get("title") or ""
|
||
try:
|
||
title = store._strip_project_mode(title).replace("\n", " ").strip()
|
||
except Exception:
|
||
pass
|
||
def _after(mode):
|
||
if mode:
|
||
if card is not None:
|
||
self._cancel_choice(card) # 清掉列表卡片(不留误导性「✓」),再回退
|
||
self._system(self._rw_restore_node(sess, node_id, mode=mode, to="before"))
|
||
# mode is None(Esc 取消):不输出任何提示,列表卡片保留,焦点回到 /rewind 列表
|
||
self.push_screen(RestoreModeScreen(title, "before"), _after)
|
||
|
||
def _cmd_rewind_tree(self, args, raw):
|
||
sess = self.current
|
||
if sess.status == "running":
|
||
self._system("Cannot rewind while running. /stop first."); return
|
||
store = getattr(sess, "store", None)
|
||
if store is None:
|
||
self._system("No rewindable checkpoints."); return
|
||
# 不在会话内对账:树由 _rw_commit 每轮提交,打开世界线时已 ⊇ 当前对话;此刻
|
||
# backend.history 是压缩/删头后的 live,拿它对账会被误判分歧而弃树(见 reconcile)。
|
||
# 对账统一只在 /continue(全量日志刚载入、未压缩)时做;外部 UI 改写日志的灾难由
|
||
# 「每会话锁 + 心跳判活」拦截,不再依赖此处对账。空 store/老会话由 continue 路径
|
||
# 的对账建树,本处仅守卫:无节点则无可回退。
|
||
if not (store.nodes and store.root_id in store.nodes):
|
||
self._system("No rewindable checkpoints."); return # 连一轮真实提问都没有
|
||
# 三栏全屏可视化器(§3–§7):左压缩树 / 右上折叠段 / 右下详情+操作。
|
||
self.push_screen(RewindTreeScreen(store), self._on_rewind_tree_result)
|
||
|
||
def _on_rewind_tree_result(self, result) -> None:
|
||
# 三栏屏回调:None=取消/已内联处理(diff/delete);dict=恢复请求。
|
||
if not isinstance(result, dict) or result.get("action") != "restore":
|
||
return
|
||
self._system(self._rw_restore_node(self.current, result.get("node"),
|
||
mode=result.get("mode", "both"),
|
||
to=result.get("to", "before")))
|
||
|
||
def _rw_restore_node(self, sess, node_id, mode: str = "both", to: str = "before") -> str:
|
||
"""恢复到某节点(薄封装)。**编排在 `worldline.restore_plan`**(UI 无关:算出回退后
|
||
的对话/文件/prefill,并落地——改文件 + 移 HEAD + 重写投影);本方法只做 TUI 侧刷新:
|
||
把 history 赋回 backend、重组界面消息、prefill 输入框、重挂。
|
||
|
||
to: before(回到该提问之前 + prefill) / at(在该节点继续,无 prefill);
|
||
mode: both/conv/code。需 agent 空闲(调用方已保证)。"""
|
||
store = getattr(sess, "store", None)
|
||
log_path = getattr(sess.agent, "log_path", "") or ""
|
||
hist = sess.agent.llmclient.backend.history
|
||
old_len = len(hist)
|
||
res = restore_plan(store, node_id, mode=mode, to=to, log_path=log_path)
|
||
if res is None:
|
||
return "❌ 无效的 checkpoint"
|
||
# rewind 游标(世界线面板 ◉「当前位置」标记):
|
||
# - both:to=before 时标选中节点(用户心中的「当前」是它),to=at 标 HEAD;
|
||
# - conv/code:HEAD 落在桥接节点上,标桥接(res["target"])。
|
||
try:
|
||
if mode in ("conv", "code"):
|
||
store._rw_cursor = res["target"]
|
||
else:
|
||
store._rw_cursor = node_id if (to == "before") else None
|
||
except Exception:
|
||
pass
|
||
removed = 0
|
||
if res["history"] is not None: # 对话有变更(both/conv)
|
||
hist[:] = res["history"]
|
||
removed = max(0, old_len - len(res["history"]))
|
||
self._rw_sync_working_memory(sess, res) # 纪要/便签随对话硬同步回退点
|
||
self._rw_rebuild_display(sess) # 从重写后的投影重组界面历史消息
|
||
self._remount_current_session()
|
||
self._refresh_topbar()
|
||
self._refresh_sidebar()
|
||
if res["prefill"]:
|
||
self._rw_prefill_input(res["prefill"])
|
||
label = {"both": "对话+代码", "conv": "仅对话", "code": "仅代码"}.get(mode, mode)
|
||
at_origin, title = res["at_origin"], res["title"]
|
||
if to == "at":
|
||
where = "空起点" if at_origin else f"「{title}」之后(在此继续)"
|
||
else:
|
||
where = "空起点" if at_origin else f"「{title}」之前"
|
||
msg = (f"↩ 已回退到{where}({label}):清除 {removed} 条上下文,"
|
||
f"代码恢复 {len(res['changed'])} 个文件")
|
||
if res.get("code_error"): # apply_code 中途失败 → 工作区可能半回退,不静默
|
||
msg += f"\n⚠️ 代码回退未完成({res['code_error']}):工作区可能处于部分回退状态,请检查后重试。"
|
||
return msg
|
||
|
||
def _rw_sync_working_memory(self, sess, res) -> None:
|
||
"""rewind 时把 working memory(history_info 纪要 + key_info 便签)硬同步到回退点,
|
||
与 backend.history 一致 —— 消灭旧 rewind「历史回退了、纪要没回」的串味。
|
||
|
||
WM 注入(`_get_anchor_prompt`)读的是 handler.history_info,故就地 [:] 替换它;
|
||
agent.history 指向同一列表,一并对齐。老树无 WM 记录时 res 给 None → 跳过,
|
||
不动现场纪要(向后兼容)。纪要是旁路,故障静默,绝不打断恢复。"""
|
||
try:
|
||
agent = sess.agent
|
||
handler = getattr(agent, "handler", None)
|
||
hi = res.get("hist_info")
|
||
if hi is not None and handler is not None and isinstance(
|
||
getattr(handler, "history_info", None), list):
|
||
handler.history_info[:] = list(hi) # 就地替换:WM 注入读的就是它
|
||
try: agent.history = handler.history_info # 让 agent.history 指向同一列表
|
||
except Exception: pass
|
||
ki = res.get("key_info")
|
||
if ki is not None and handler is not None and isinstance(
|
||
getattr(handler, "working", None), dict):
|
||
handler.working["key_info"] = ki
|
||
except Exception:
|
||
pass
|
||
|
||
def _rw_rebuild_display(self, sess) -> None:
|
||
"""rewind 后重组**界面历史消息**:从已重写成新 HEAD 路径的投影日志重新解析
|
||
(复用 /continue 的 `extract_ui_messages`,与其它视角同一套渲染),替换
|
||
`sess.messages`。随后由 `_remount_current_session` 重挂显示。
|
||
|
||
没这步的话,backend.history 已回退但屏幕上还停留在旧对话。故障静默降级。"""
|
||
log_path = getattr(sess.agent, "log_path", "") or ""
|
||
if not log_path:
|
||
return
|
||
try:
|
||
rebuilt = [ChatMessage(role=h["role"], content=h["content"])
|
||
for h in continue_extract(log_path)]
|
||
sess.messages.clear()
|
||
sess.messages.extend(rebuilt)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rw_prefill_input(self, text: str) -> None:
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
inp.text = text
|
||
inp.move_cursor((inp.document.line_count - 1, len(text.split("\n")[-1])))
|
||
inp.focus()
|
||
self._resize_input(inp)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rewindable_turns(self) -> list[tuple[int, str]]:
|
||
history = self.current.agent.llmclient.backend.history
|
||
turns: list[tuple[int, str]] = []
|
||
for i, m in enumerate(history):
|
||
if m.get("role") != "user": continue
|
||
c = m.get("content")
|
||
if isinstance(c, str):
|
||
turns.append((i, c[:60])); continue
|
||
if isinstance(c, list):
|
||
if any(b.get("type") == "tool_result" for b in c if isinstance(b, dict)):
|
||
continue
|
||
texts = [b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text"]
|
||
if texts and any(t.strip() for t in texts):
|
||
turns.append((i, texts[0][:60]))
|
||
return turns
|
||
|
||
def _do_rewind(self, n: int) -> str:
|
||
"""【降级兜底·非持久】纯内存截断 backend.history。仅在【没有 checkpoint 树】时用
|
||
(store 创建失败 / 会话刚开还没 commit)。缺陷:砍的是压缩态内存——够不到已删头的轮、
|
||
留下的是被截断的残骸、不恢复文件、不回退 working memory。有树时一律走持久的
|
||
树重建路径(_rw_restore_node → restore_plan),不要用本方法。"""
|
||
sess = self.current
|
||
turns = self._rewindable_turns()
|
||
if not (1 <= n <= len(turns)):
|
||
return f"❌ 回退失败:n 应在 1-{len(turns)}"
|
||
history = sess.agent.llmclient.backend.history
|
||
cut = turns[-n][0]
|
||
prefill = _extract_user_text(history[cut]) if cut < len(history) else ""
|
||
removed = len(history) - cut
|
||
history[:] = history[:cut]
|
||
real_user = [i for i, msg in enumerate(sess.messages) if msg.role == "user"]
|
||
if n <= len(real_user):
|
||
sess.messages = sess.messages[:real_user[-n]]
|
||
try: sess.agent.history.append(f"[USER]: /rewind {n}")
|
||
except Exception: pass
|
||
self._remount_current_session()
|
||
self._refresh_topbar()
|
||
self._refresh_sidebar()
|
||
if prefill:
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
inp.text = prefill
|
||
inp.move_cursor((inp.document.line_count - 1, len(prefill.split("\n")[-1])))
|
||
inp.focus()
|
||
self._resize_input(inp)
|
||
except Exception: pass
|
||
return f"↩ 已回退 {n} 轮(内存级·不持久,移除 {removed} 条历史)"
|
||
|
||
def _cmd_clear(self, args, raw):
|
||
self.current.messages.clear()
|
||
self._remount_current_session()
|
||
self._refresh_topbar()
|
||
self._refresh_sidebar()
|
||
self._system("已清空显示(LLM 历史保留)")
|
||
|
||
def _cmd_stop(self, args, raw):
|
||
sess = self.current
|
||
# Locate the last user message AND whether the agent already produced a
|
||
# reply for that turn. Walking reversed, any non-empty assistant message
|
||
# seen *before* we reach the user message means this turn was consumed
|
||
# (the LLM emitted output → it's in history; a resend would duplicate).
|
||
# System "[queued #n]" steers are skipped (neither role). The current
|
||
# task's assistant placeholder starts empty, so an interrupt before any
|
||
# stream leaves `consumed` False.
|
||
last_user_text = None
|
||
consumed = False
|
||
for m in reversed(sess.messages):
|
||
if m.role == "assistant" and (m.content or "").strip():
|
||
consumed = True
|
||
elif m.role == "user":
|
||
last_user_text = m.content
|
||
break
|
||
try:
|
||
sess.agent.abort()
|
||
if sess.status == "running":
|
||
sess.status = "stopping"
|
||
self._mark_stopping(sess)
|
||
self._system(f"Stop sent to #{sess.agent_id}.")
|
||
except Exception as e:
|
||
self._system(f"Stop failed: {e}")
|
||
# Refill the input box with the interrupted user text so edit-and-
|
||
# resend is one keystroke away — but only for an *unconsumed* turn
|
||
# (aborted before the LLM replied). Once the agent has answered, the
|
||
# turn lives in history and a resend would duplicate it, so leave the
|
||
# box alone. Also only when the box is empty (don't clobber a
|
||
# half-typed follow-up).
|
||
if last_user_text and not consumed:
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
if not inp.text:
|
||
inp.text = last_user_text
|
||
inp.move_cursor((inp.document.line_count - 1,
|
||
len(last_user_text.split("\n")[-1])))
|
||
inp.focus()
|
||
self._resize_input(inp)
|
||
except Exception:
|
||
pass
|
||
self._refresh_all()
|
||
|
||
def _cmd_reload_keys(self, args, raw):
|
||
# Force rebuild of every session's llmclients from a fresh mykey.py.
|
||
# reload_mykeys() uses a module-level mtime cache, so the first agent
|
||
# to call it consumes the "changed" signal and subsequent agents see
|
||
# changed=False (and skip rebuild). Invalidate the cache before each
|
||
# agent so every session picks up the new config.
|
||
try:
|
||
import llmcore
|
||
except Exception as e:
|
||
self._system(f"❌ 无法 import llmcore: {e}"); return
|
||
n_ok = n_fail = 0
|
||
for sess in self.sessions.values():
|
||
agent = getattr(sess, "agent", None)
|
||
if agent is None:
|
||
continue
|
||
try:
|
||
llmcore._mykey_mtime = None
|
||
agent.load_llm_sessions()
|
||
n_ok += 1
|
||
except Exception:
|
||
n_fail += 1
|
||
msg = f"🔑 已重载 mykey.py({n_ok} 个会话)" + (f",{n_fail} 个失败" if n_fail else "")
|
||
self._system(msg)
|
||
|
||
def _cmd_llm(self, args, raw):
|
||
sess = self.current
|
||
if args:
|
||
try:
|
||
sess.agent.next_llm(int(args[0]))
|
||
self._system(f"Switched model to #{int(args[0])}.")
|
||
except Exception as e:
|
||
self._system(f"Switch failed: {e}")
|
||
return
|
||
try:
|
||
rows = sess.agent.list_llms()
|
||
except Exception as e:
|
||
self._system(f"List failed: {e}")
|
||
return
|
||
if not rows:
|
||
self._system("没有可用模型。")
|
||
return
|
||
choices = []
|
||
for i, name, cur in rows:
|
||
mark = "✓ " if cur else " "
|
||
choices.append((f"{mark}[{i}] {name}", i))
|
||
msg = ChatMessage(
|
||
role="system",
|
||
content="选择模型 (↑/↓ 移动,→/Enter 确认,Esc 取消)",
|
||
kind="choice",
|
||
choices=choices,
|
||
on_select=lambda v: self._do_switch_llm(v),
|
||
)
|
||
self.current.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def _do_switch_llm(self, idx: int) -> str:
|
||
try:
|
||
self.current.agent.next_llm(int(idx))
|
||
name = self.current.agent.get_llm_name()
|
||
return f"已切换到 [{idx}] {name}"
|
||
except Exception as e:
|
||
return f"❌ 切换失败: {e}"
|
||
|
||
# ---------------- /model: 渠道内 model 切换(逻辑在 model_cmd.py) ----------------
|
||
def _cmd_model(self, args, raw):
|
||
import model_cmd
|
||
agent = self.current.agent
|
||
if args: # /model <name> 直设, 不拉列表
|
||
self._system(model_cmd.set_model(agent, " ".join(args)))
|
||
return
|
||
self._open_model_picker()
|
||
|
||
def _open_model_picker(self) -> None:
|
||
"""立即挂一个空的 searchable picker(输入框先可用, 下方 hint 行显示加载中),
|
||
后台拉取完成后 _fill_model_picker 就地填充。mixin 不再选渠道, 直接作用于
|
||
当前子渠道 (model_cmd sub=None 即当前)。"""
|
||
import model_cmd
|
||
agent = self.current.agent
|
||
cur = model_cmd.current_model(agent, None)
|
||
msg = ChatMessage(
|
||
role="system",
|
||
content=f"选择模型 (当前: {cur} · 输入过滤或自定义名称 · ↑/↓ 移动,Enter 确认,Esc 取消)",
|
||
kind="choice", choices=[],
|
||
on_select=lambda v: model_cmd.set_model(self.current.agent, v),
|
||
)
|
||
msg.searchable = True
|
||
msg.free_input = True
|
||
msg.search_placeholder = "输入关键字过滤;无匹配时 Enter 设置自定义模型名"
|
||
msg.all_choices = []
|
||
msg.empty_hint = "⏳ 正在拉取模型列表… (或直接输入完整模型名 Enter 直设)"
|
||
self.current.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def worker():
|
||
try:
|
||
models = model_cmd.fetch_models(agent, None)
|
||
err = None if models else "渠道未返回模型列表"
|
||
except Exception as e:
|
||
models, err = [], f"{type(e).__name__}: {e}"
|
||
self.call_from_thread(self._fill_model_picker, msg, models, err, cur)
|
||
|
||
threading.Thread(target=worker, daemon=True, name="ga-tui-model").start()
|
||
|
||
# ---------------- /effort: reasoning effort 切换(逻辑在 model_cmd.py) ----------------
|
||
def _cmd_effort(self, args, raw):
|
||
import model_cmd
|
||
if args: # /effort <level> 直设
|
||
self._system(model_cmd.set_effort(self.current.agent, " ".join(args)))
|
||
return
|
||
agent = self.current.agent
|
||
cur = model_cmd.current_effort(agent)
|
||
protocols = model_cmd._protocols(agent)
|
||
# (显示名, value, 备注, 是否当前选中)。有备注的行名字补齐到等宽,
|
||
# 备注对齐成一列;无备注的行直接用显示名,不留尾随空格。
|
||
rows = [("默认", "off", "", not cur)]
|
||
for lv in model_cmd.EFFORT_LEVELS:
|
||
rows.append((lv, lv, model_cmd.effort_note(lv, protocols), cur == lv))
|
||
w = max(len(d) for d, _, _, _ in rows)
|
||
choices = [(("✓ " if tick else " ")
|
||
+ (disp.ljust(w) + f" {note}" if note else disp), val)
|
||
for disp, val, note, tick in rows]
|
||
msg = ChatMessage(
|
||
role="system",
|
||
content=(f"选择 reasoning effort (当前: {cur or '未设置'} · "
|
||
"↑/↓ 移动,Enter 确认,Esc 取消)"),
|
||
kind="choice", choices=choices,
|
||
on_select=lambda v: model_cmd.set_effort(self.current.agent, v),
|
||
)
|
||
self.current.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def _fill_model_picker(self, msg, models, err, cur) -> None:
|
||
"""拉取完成: 就地重建 picker 区, 保留 Input 焦点与已输入的过滤词。"""
|
||
w = msg._body_widget
|
||
if (msg.selected_label is not None or w is None
|
||
or not getattr(w, "is_mounted", False)):
|
||
return # 用户已 Esc/已选, 静默丢弃
|
||
if err:
|
||
msg.empty_hint = f"❌ 拉取失败: {err} · 直接输入完整模型名 Enter 设置"
|
||
else:
|
||
msg.all_choices = [(("✓ " if m == cur else " ") + m, m) for m in models]
|
||
msg.empty_hint = "(无匹配 · Enter 设置自定义模型名)"
|
||
msg.content = (f"选择模型 ({len(models)} 个 · 当前: {cur} · "
|
||
"输入过滤或自定义名称 · ↑/↓ 移动,Enter 确认,Esc 取消)")
|
||
try:
|
||
msg._hint_widget.update(Text(msg.content, style=C_MUTED))
|
||
except Exception:
|
||
pass
|
||
w._apply_filter(msg.search_query or "")
|
||
|
||
# ---------------- new commands ----------------
|
||
def _cmd_btw(self, args, raw):
|
||
question = " ".join(args).strip()
|
||
if not question:
|
||
self._system("Usage: /btw <question>"); return
|
||
sess = self.current
|
||
sess.messages.append(ChatMessage("user", f"/btw {question}"))
|
||
placeholder = ChatMessage("assistant", "(side question 处理中...)", done=False)
|
||
sess.messages.append(placeholder)
|
||
self._refresh_messages()
|
||
|
||
def worker():
|
||
try:
|
||
answer = btw_handle(sess.agent, raw)
|
||
except Exception as e:
|
||
answer = f"❌ /btw 失败: {type(e).__name__}: {e}"
|
||
self.call_from_thread(self._update_assistant, sess.agent_id, answer)
|
||
|
||
threading.Thread(target=worker, daemon=True, name="ga-tui-btw").start()
|
||
|
||
def _cmd_review(self, args, raw):
|
||
"""`/review` via TUI's streaming path; the TUI intercepts slash commands
|
||
before `review_cmd.install`'s patch, so we render the prompt via
|
||
`review_cmd.handle` and submit it as a normal task with `/review ...`
|
||
kept as the visible user message."""
|
||
body = (raw or "").strip()
|
||
if body == "/review":
|
||
body = ""
|
||
elif body.startswith("/review ") or body.startswith("/review\t"):
|
||
body = body[len("/review"):].strip()
|
||
else:
|
||
body = " ".join(args).strip()
|
||
sess = self.current
|
||
if body in ("help", "?", "-h", "--help"):
|
||
try:
|
||
dq = queue.Queue()
|
||
rendered = review_handle(sess.agent, body, dq)
|
||
try:
|
||
item = dq.get_nowait()
|
||
self._system(str(item.get("done") or ""))
|
||
except queue.Empty:
|
||
if rendered:
|
||
self._system(rendered)
|
||
except Exception as e:
|
||
self._system(f"❌ /review help 失败: {type(e).__name__}: {e}")
|
||
return
|
||
if sess.status == "running":
|
||
self._system(f"#{sess.agent_id} 正在跑,/stop 后再发。")
|
||
return
|
||
try:
|
||
prompt = review_handle(sess.agent, body, queue.Queue())
|
||
except Exception as e:
|
||
self._system(f"❌ /review 初始化失败: {type(e).__name__}: {e}")
|
||
return
|
||
if not prompt:
|
||
self._system("❌ /review 未生成审查提示。")
|
||
return
|
||
display_text = raw.strip() if (raw or "").strip() else "/review"
|
||
self.submit_user_message(prompt, display_text=display_text)
|
||
|
||
def _rw_rewind_root(self):
|
||
"""世界线树根目录(temp/.ga_rewind),供 continue_list 树感知发现"已回退至起点"的空会话。"""
|
||
return os.path.join(os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp')), '.ga_rewind')
|
||
|
||
def _cmd_continue(self, args, raw):
|
||
sess = self.current
|
||
m = re.match(r"/continue\s+(\S.*?)\s*$", (raw or "").strip())
|
||
if m:
|
||
token = m.group(1)
|
||
if token.isdigit():
|
||
sessions = continue_list(exclude_log=os.path.basename(getattr(sess.agent, "log_path", "") or ""),
|
||
rewind_root=self._rw_rewind_root())
|
||
idx = int(token) - 1
|
||
if not (0 <= idx < len(sessions)):
|
||
self._system(f"❌ 索引越界(有效范围 1-{len(sessions)})"); return
|
||
self._do_continue_restore(sessions[idx][0])
|
||
return
|
||
log_path = getattr(sess.agent, "log_path", "") or ""
|
||
own_key = os.path.basename(log_path)
|
||
try:
|
||
import session_names
|
||
path = session_names.path_for(token, exclude_basename=own_key)
|
||
if path is None and session_names.name_for(log_path).lower() == token.strip().lower():
|
||
self._system(f"✅ 当前已在 {token!r} 会话中"); return
|
||
except Exception:
|
||
path = None
|
||
if not path:
|
||
self._system(f"❌ 找不到名为 {token!r} 的会话"); return
|
||
self._do_continue_restore(path)
|
||
return
|
||
sessions = continue_list(exclude_log=os.path.basename(getattr(sess.agent, "log_path", "") or ""),
|
||
rewind_root=self._rw_rewind_root())
|
||
if not sessions:
|
||
self._system("❌ 没有可恢复的历史会话"); return
|
||
choices = []
|
||
try:
|
||
import session_names as _sn
|
||
except Exception:
|
||
_sn = None
|
||
for path, mtime, first, n in sessions:
|
||
preview = (first or "(无法预览)").replace("\n", " ").strip()[:50]
|
||
nm = _sn.name_for(path) if _sn else ""
|
||
tag = f"{nm} · " if nm else ""
|
||
choices.append((f"{_short_age(mtime)} · {tag}{n}轮 · {preview}", path))
|
||
head = f"选择要恢复的会话 ({len(sessions)} 条 · 输入关键字过滤 · ↑/↓ 移动,→/Enter 确认,Esc 取消)"
|
||
msg = ChatMessage(
|
||
role="system", content=head, kind="choice", choices=choices,
|
||
on_select=lambda v: self._do_continue_restore(v),
|
||
)
|
||
# `/continue` is the only place that opts into the searchable picker
|
||
# (per task B 2026-05-27). `all_choices` is the unfiltered baseline so
|
||
# clearing the query restores the full list; `searchable=True` flips
|
||
# the `_mount_message` branch to wrap the picker with an Input filter.
|
||
msg.searchable = True
|
||
msg.all_choices = list(choices)
|
||
# Threshold chosen empirically (user-preferred 2026-05-27): mounting 50
|
||
# rows fits typical viewport with no perceptible cost, so the eager
|
||
# path stays for ≤50 entries; larger lists stream via LazyChoiceList
|
||
# with the same 50-row first batch.
|
||
if len(choices) > 50:
|
||
msg.lazy_choice_items = [label for label, _ in choices]
|
||
msg.lazy_choice_batch = 50
|
||
sess.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def _do_continue_restore(self, path: str) -> str:
|
||
# 默认原地续(接管原日志,延续同一会话)。快照只能拷贝续;被活进程占用 →
|
||
# 弹窗问是否从原会话拷贝一份继续(复用内联 choice)。
|
||
import continue_cmd as _cc
|
||
sess = self.current
|
||
if not _cc.is_snapshot(path):
|
||
occ = _cc.session_occupant(path)
|
||
if occ is not None:
|
||
head = f"该会话正被占用(pid {occ.get('pid', '?')})—— 是否从原会话拷贝一份继续?"
|
||
msg = ChatMessage(
|
||
role="system", content=head, kind="choice",
|
||
choices=[("拷贝一份继续", path), ("取消", None)],
|
||
on_select=lambda v: (self._continue_restore_apply(v, copy=True) if v else None),
|
||
)
|
||
sess.messages.append(msg); self._refresh_messages()
|
||
return head
|
||
return self._continue_restore_apply(path, copy=_cc.is_snapshot(path))
|
||
|
||
def _continue_restore_apply(self, path: str, copy: bool) -> str:
|
||
sess = self.current
|
||
import continue_cmd as _cc
|
||
try:
|
||
if copy:
|
||
result, ok = _cc.continue_copy(sess.agent, path, sess.agent_id, allow_empty=True, restore_wm=True)
|
||
else:
|
||
result, ok = _cc.continue_inplace(sess.agent, path, sess.agent_id, allow_empty=True, restore_wm=True)
|
||
except Exception as e:
|
||
msg = f"❌ 恢复失败: {e}"
|
||
self._system(msg); return msg
|
||
if not ok:
|
||
self._system(result); return result
|
||
# 原地:new_log == path(接管原文件);拷贝:new_log 是内容相同的新副本。
|
||
new_log = getattr(sess.agent, "log_path", "") or ""
|
||
# 世界线树:重建 store 指向新 log_path;拷贝时再 resume_from 把源会话的树搬过来
|
||
# (原地直接指向 X 那棵树,无需搬)。
|
||
try:
|
||
temp_dir = os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp'))
|
||
sess.store = RewindStore.for_log(temp_dir, new_log, temp_dir)
|
||
sess.agent._rw_store = sess.store
|
||
if copy:
|
||
old_root = os.path.normpath(os.path.join(
|
||
temp_dir, '.ga_rewind', RewindStore.key_for_log(path)))
|
||
sess.store.resume_from(old_root)
|
||
# 接管后立即对账【日志 ↔ 树】。对账的基准是**日志**(全量真相源),不是压缩态
|
||
# backend.history —— 后者是有损派生物,拿它对账会让全量的树去迁就残缺内存(假
|
||
# 分歧弃树的根因)。这里显式从日志解析全量历史喂入,即便将来 continue 流程变化
|
||
# 或内存被压缩,对账恒以日志为准。用途:源日志若被不更新树的 UI 续写,树会滞后
|
||
# → 吸收尾部使树 ⊇ 日志,杜绝之后 rewind 用陈旧树回写、抹掉外部轮次的灾难。
|
||
try:
|
||
_log_hist = _cc._parse_native_history(
|
||
_cc._pairs(open(new_log, encoding='utf-8', errors='replace').read())) or []
|
||
except Exception:
|
||
_log_hist = []
|
||
sess.store.reconcile(_log_hist)
|
||
# 续接恢复工作记忆已由 continue_cmd 的 restore_wm=True 在加载日志时做掉
|
||
# (派生 history_info 写回 agent.history),此处不再重复。
|
||
except Exception:
|
||
pass
|
||
def _finish():
|
||
sess.messages.clear()
|
||
# Plan state belongs to the *previous* conversation. Clearing it
|
||
# along with messages stops the planbar from leaking stale items
|
||
# (`Plan (3/7)` from #4 qxs) into the freshly-restored session.
|
||
sess.plan_items = []
|
||
sess.plan_complete_since = None
|
||
sess.plan_lost_since = None
|
||
sess.restored_plan_path = ""
|
||
self._plan_mtime.pop(sess.agent_id, None)
|
||
# Live mode fills _WRITE_CAP from the tool_before hook; on restore that
|
||
# is gone, so seed it from the log's structured tool_use inputs (keyed
|
||
# the same way) — otherwise restored file_write/file_patch fall back to
|
||
# the raw verbose args block instead of a diff.
|
||
try:
|
||
from continue_cmd import iter_write_captures
|
||
from agent_loop import get_pretty_json
|
||
for cap in iter_write_captures(path):
|
||
_WRITE_CAP[hash(get_pretty_json(cap["args"]))] = cap
|
||
except Exception:
|
||
pass
|
||
for h in continue_extract(path):
|
||
sess.messages.append(ChatMessage(role=h["role"], content=h["content"]))
|
||
# Plan-card restore is keyed off the transcript's structured
|
||
# `enter_plan_mode` tool_use (find_plan_entry), NOT off plan.md
|
||
# paths mentioned in chat text — a typed filename can't fake it.
|
||
# Restore iff the entered plan still exists, parses to ≥1 task,
|
||
# and isn't all-done (an abandoned finished/headless plan stays
|
||
# buried). baseline stays 0: it only scopes current_step's 📌 scan.
|
||
sess.plan_scan_baseline = 0
|
||
import plan_state
|
||
from continue_cmd import find_plan_entry
|
||
pp = find_plan_entry(path)
|
||
rp = plan_state._resolve_stashed(pp) if pp else None
|
||
if rp:
|
||
try:
|
||
with open(rp, encoding="utf-8", errors="replace") as f:
|
||
items = plan_state.extract(f.read())
|
||
except OSError:
|
||
items = []
|
||
if items and not plan_state.is_complete(items):
|
||
sess.restored_plan_path = rp
|
||
sess.plan_items = items
|
||
try:
|
||
import session_names
|
||
nm = session_names.name_for(path)
|
||
if nm:
|
||
sess.name = nm
|
||
if new_log and new_log != path: # 仅拷贝续才迁移名字到新副本;原地无需迁移
|
||
session_names.migrate(path, new_log)
|
||
except Exception:
|
||
pass
|
||
# Auto-restore workspace: if the continued session worked in a
|
||
# registered workspace, bind it to this session (recreating the
|
||
# junction if needed) without touching the legacy process anchor.
|
||
# persist=False: the reset bind must NOT write the session→ws map —
|
||
# in-place continue points log_path at `path`, so persisting "" here
|
||
# would erase this session's own mapping before we read it back.
|
||
self._bind_workspace(self.current, None, persist=False)
|
||
try:
|
||
rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
|
||
if rec is not None:
|
||
ws_path = rec or None # "" → 该会话已 off,明确不恢复
|
||
else:
|
||
info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
|
||
ws_path = info["path"] if info else None
|
||
if ws_path:
|
||
r = workspace_cmd.prepare(ws_path)
|
||
if r.get("ok"):
|
||
self._bind_workspace(self.current, r)
|
||
self._system(f"⌂ 已恢复工作目录: {r['target']}")
|
||
else:
|
||
self._system(f"⚠ workspace 恢复失败: {r.get('error')}")
|
||
except Exception:
|
||
pass
|
||
self._remount_current_session()
|
||
self._refresh_all()
|
||
self.call_after_refresh(_finish)
|
||
return result.splitlines()[0] if result else "✅ 已恢复"
|
||
|
||
def _cmd_workspace(self, args, raw):
|
||
# /workspace <abs path> | /workspace off | /workspace (picker).
|
||
# Path may contain spaces (Windows) → capture the whole tail.
|
||
m = re.match(r"/workspace\s+(\S.*?)\s*$", (raw or "").strip())
|
||
if m:
|
||
token = m.group(1)
|
||
if token.lower() == "off":
|
||
sess = self.current
|
||
if sess.workspace_name:
|
||
self._bind_workspace(sess, None)
|
||
self._system("已退出 workspace(项目模式关闭;junction 与文件保留)")
|
||
else:
|
||
self._system("当前未处于 workspace 模式")
|
||
self._refresh_topbar()
|
||
return
|
||
# 直接路径无 picker 面包屑,自己显示一条。
|
||
self._system(self._do_workspace_activate(token))
|
||
return
|
||
# No arg → searchable picker: free-text input (type an abs path to
|
||
# create/enter) over a candidate list of registered workspaces.
|
||
sess = self.current
|
||
choices = []
|
||
for it in workspace_cmd.registry_list():
|
||
age = _short_age(it["last_used"]) if it["last_used"] else "—"
|
||
mem = f"{it['mem_lines']}行记忆" if it["mem_lines"] else "空"
|
||
flag = " ⚠失效" if it["dangling"] else ""
|
||
# 显示名取真实目录 basename(天然不含 junction 的 -hash8 后缀);
|
||
# dangling 无 path 时退回剥掉 name 尾部 hash。名称省略末尾、路径
|
||
# 省略中间,整行经 ChoiceList 单行渲染不会折行。
|
||
disp = os.path.basename((it["path"] or "").rstrip("/\\")) \
|
||
or re.sub(r"-[0-9a-f]{8}$", "", it["name"])
|
||
label = f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}"
|
||
choices.append((label, it["path"]))
|
||
head = ("指定工作目录(输入绝对路径回车新建/进入,或从下方选择已有 · "
|
||
"↑/↓ 移动,→/Enter 确认,Esc 取消)")
|
||
msg = ChatMessage(
|
||
role="system", content=head, kind="choice", choices=choices,
|
||
on_select=lambda v: self._do_workspace_activate(v),
|
||
)
|
||
msg.searchable = True
|
||
msg.free_input = True # Enter on a typed abs path commits it as a new workspace
|
||
msg.all_choices = list(choices)
|
||
sess.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def _do_workspace_activate(self, path: str) -> str:
|
||
# 唯一展示文本 = 返回值:picker 路径由 _collapse_choice 渲染成 `✓ …`
|
||
# 面包屑;直接 `/workspace <path>` 路径由 _cmd_workspace 用 _system 显示。
|
||
# 两条路径各出一条,故此处不再自行 _system(否则与面包屑重复)。
|
||
r = workspace_cmd.prepare(path)
|
||
if not r.get("ok"):
|
||
return f"❌ workspace 设定失败: {r.get('error')}"
|
||
self._bind_workspace(self.current, r)
|
||
self._refresh_topbar()
|
||
# 显示名去 hash(与 picker 一致):真实目录 basename,退回剥 name 尾 hash。
|
||
disp = os.path.basename((r.get("target") or "").rstrip("/\\")) \
|
||
or re.sub(r"-[0-9a-f]{8}$", "", r.get("name") or "")
|
||
out = f"✅ 已进入 workspace「{disp}」"
|
||
if r.get("warning"):
|
||
out += f" ⚠ {r['warning']}"
|
||
return out
|
||
|
||
def _cmd_cost(self, args, raw):
|
||
try:
|
||
import cost_tracker
|
||
except Exception as e:
|
||
self._system(f"❌ cost_tracker 不可用: {e}"); return
|
||
show_all = bool(args) and args[0].lower() == "all"
|
||
|
||
def _k(n: int) -> str:
|
||
# Human-readable number: 12.3K / 1.45M / 167 — keeps the column
|
||
# narrow so the layout doesn't shift between idle and 200K-deep sessions.
|
||
n = int(n)
|
||
if n < 1000: return f"{n}"
|
||
if n < 1_000_000:
|
||
v = n / 1000.0
|
||
return f"{v:.1f}K" if v < 100 else f"{int(v)}K"
|
||
v = n / 1_000_000.0
|
||
return f"{v:.2f}M" if v < 100 else f"{int(v)}M"
|
||
|
||
def _elapsed(secs: float) -> str:
|
||
s = int(secs)
|
||
if s < 60: return f"{s}s"
|
||
if s < 3600: return f"{s // 60}m {s % 60:02d}s"
|
||
h, rem = divmod(s, 3600); m, sec = divmod(rem, 60)
|
||
return f"{h}h {m:02d}m {sec:02d}s"
|
||
|
||
def _section(sid: int, sess, t) -> list[str]:
|
||
try: model = sess.agent.get_llm_name(model=True) or "?"
|
||
except Exception: model = "?"
|
||
total = t.total_tokens()
|
||
inp_side = t.total_input_side()
|
||
ls = []
|
||
ls.append(f"#{sid} {sess.name} · model: {model} · elapsed: {_elapsed(t.elapsed_seconds())}")
|
||
ls.append(
|
||
f" Token usage: {_k(total):>7} total "
|
||
f"({_k(inp_side)} input + {_k(t.output)} output)"
|
||
)
|
||
if t.cache_read or t.cache_create:
|
||
ls.append(
|
||
f" Cache: {_k(t.cache_read):>7} read · "
|
||
f"{_k(t.cache_create)} created · "
|
||
f"{t.cache_hit_rate():.1f}% hit"
|
||
)
|
||
try: backend = sess.agent.llmclient.backend
|
||
except Exception: backend = None
|
||
cap = cost_tracker.context_window_chars(backend) if backend else 0
|
||
used = cost_tracker.current_input_chars(backend) if backend else 0
|
||
if cap > 0:
|
||
pct_left = max(0.0, (cap - used) / cap * 100.0)
|
||
ls.append(
|
||
f" Context window: {pct_left:>5.0f}% left "
|
||
f"({_k(used)} chars used / {_k(cap)} cap)"
|
||
)
|
||
ls.append(f" Requests: {t.requests:>7}")
|
||
return ls
|
||
|
||
# Scope subagent logs to this TUI run so prior-session logs don't bleed in.
|
||
try: sub = cost_tracker.scan_subagent_logs(since=getattr(self, "_started_at", 0.0))
|
||
except Exception: sub = None
|
||
|
||
def _sub_section() -> list[str]:
|
||
if not sub or sub.total_tokens() == 0: return []
|
||
ls = ["", f"subagents (扫描 temp/*/stdout.log)"]
|
||
ls.append(
|
||
f" Token usage: {_k(sub.total_tokens()):>7} total "
|
||
f"({_k(sub.total_input_side())} input + {_k(sub.output)} output)"
|
||
)
|
||
if sub.cache_read or sub.cache_create:
|
||
ls.append(
|
||
f" Cache: {_k(sub.cache_read):>7} read · "
|
||
f"{_k(sub.cache_create)} created · "
|
||
f"{sub.cache_hit_rate():.1f}% hit"
|
||
)
|
||
ls.append(f" Requests: {sub.requests:>7}")
|
||
return ls
|
||
|
||
lines: list[str] = []
|
||
if show_all:
|
||
trackers = cost_tracker.all_trackers()
|
||
if not trackers and not (sub and sub.total_tokens()):
|
||
lines = ["✦ Token usage", " (尚无任何 LLM 调用记录)"]
|
||
else:
|
||
# Resolve each thread back to a session if we still know it; otherwise
|
||
# surface the bare thread name (the session may have been Ctrl+D'd).
|
||
by_name = {(s.thread.name if s.thread else f"ga-tui-agent-{sid}"): (sid, s)
|
||
for sid, s in self.sessions.items()}
|
||
lines.append("✦ Token usage (all sessions)")
|
||
first = True
|
||
for tname in sorted(trackers):
|
||
if not first: lines.append("")
|
||
first = False
|
||
if tname in by_name:
|
||
sid, s = by_name[tname]
|
||
lines += _section(sid, s, trackers[tname])
|
||
else:
|
||
t = trackers[tname]
|
||
lines.append(f"[{tname}] · elapsed: {_elapsed(t.elapsed_seconds())}")
|
||
total = t.total_tokens()
|
||
lines.append(
|
||
f" Token usage: {_k(total):>7} total "
|
||
f"({_k(t.total_input_side())} input + {_k(t.output)} output)"
|
||
)
|
||
lines.append(f" Requests: {t.requests:>7}")
|
||
lines += _sub_section()
|
||
else:
|
||
sess = self.current
|
||
tname = sess.thread.name if sess.thread else f"ga-tui-agent-{sess.agent_id}"
|
||
t = cost_tracker.get(tname)
|
||
lines.append("✦ Token usage")
|
||
lines += _section(sess.agent_id, sess, t)
|
||
lines += _sub_section()
|
||
self._system("\n".join(lines))
|
||
|
||
def _cmd_export(self, args, raw):
|
||
"""Forms:
|
||
/export → 3-choice picker (clip/all/file with timestamp)
|
||
/export clip|copy last reply wrapped in code block
|
||
/export all full log file path
|
||
/export file [name] export last reply to file
|
||
/export <name> legacy: equivalent to /export file <name>
|
||
"""
|
||
sub = args[0].lower() if args else ""
|
||
if not sub:
|
||
choices = [
|
||
("📋 clip — 复制最后一轮回复(代码块包裹,便于粘贴)", "clip"),
|
||
("📂 all — 显示完整日志文件路径", "all"),
|
||
("💾 file — 导出到文件(提交前可编辑文件名)", "file"),
|
||
]
|
||
msg = ChatMessage(
|
||
role="system",
|
||
content="选择导出方式 (↑/↓ 移动,→/Enter 确认,Esc 取消)",
|
||
kind="choice",
|
||
choices=choices,
|
||
on_select=lambda v: self._prompt_export_filename() if v == "file" else self._do_export(v),
|
||
)
|
||
self.current.messages.append(msg)
|
||
self._refresh_messages()
|
||
return
|
||
if sub == "file":
|
||
custom = " ".join(args[1:]).strip() or None
|
||
self._system(self._do_export("file", custom))
|
||
return
|
||
if sub == "all":
|
||
self._system(self._do_export("all"))
|
||
return
|
||
if sub in ("clip", "copy"):
|
||
self._system(self._do_export("clip"))
|
||
return
|
||
self._system(self._do_export("file", " ".join(args).strip()))
|
||
|
||
def _prompt_export_filename(self) -> str:
|
||
from datetime import datetime as _dt
|
||
default = "export-" + _dt.now().strftime("%Y%m%d-%H%M%S") + ".md"
|
||
text = "/export " + default
|
||
def _fill():
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
self._suppress_palette_open = True
|
||
inp.text = text
|
||
inp.move_cursor((0, len(text)))
|
||
inp.focus()
|
||
self._resize_input(inp)
|
||
except Exception:
|
||
pass
|
||
self.call_after_refresh(_fill)
|
||
return "✏️ 已填入默认文件名,按 Enter 确认或先编辑"
|
||
|
||
def _do_export(self, kind: str, filename: str | None = None) -> str:
|
||
sess = self.current
|
||
try:
|
||
if kind == "all":
|
||
log = getattr(sess.agent, "log_path", "")
|
||
if log and os.path.isfile(log):
|
||
return f"📂 完整日志:\n{log}"
|
||
return "❌ 尚无日志文件"
|
||
text = last_assistant_text(sess.agent)
|
||
if not text:
|
||
return "❌ 还没有可导出的回复"
|
||
if kind == "clip":
|
||
payload = wrap_for_clipboard(text)
|
||
if _copy_to_clipboard(payload):
|
||
return "✅ 已复制最后一轮回复到剪贴板"
|
||
return f"⚠️ 自动复制失败,请手动复制:\n\n{payload}"
|
||
if kind == "file":
|
||
if not filename:
|
||
from datetime import datetime as _dt
|
||
filename = "export-" + _dt.now().strftime("%Y%m%d-%H%M%S") + ".md"
|
||
path = export_to_temp(text, filename)
|
||
return f"✅ 已导出: {path}"
|
||
return f"❌ 未知选项: {kind}"
|
||
except Exception as e:
|
||
return f"❌ 导出失败: {type(e).__name__}: {e}"
|
||
|
||
def _cmd_restore(self, args, raw):
|
||
sess = self.current
|
||
try:
|
||
info, err = format_restore()
|
||
except Exception as e:
|
||
self._system(f"❌ 恢复失败: {e}"); return
|
||
if err:
|
||
self._system(err); return
|
||
restored, fname, count = info
|
||
try:
|
||
sess.agent.abort()
|
||
sess.agent.history.extend(restored)
|
||
self._system(f"✅ 已恢复 {count} 轮上下文,来源: {fname}")
|
||
except Exception as e:
|
||
self._system(f"❌ 注入失败: {e}")
|
||
|
||
def _cmd_quit(self, args, raw):
|
||
self._reset_terminal_title()
|
||
self.exit()
|
||
|
||
# ---------------- slash_cmds bundle ----------------
|
||
def _cmd_slash_inject(self, args, raw):
|
||
"""`/update /autorun /morphling /goal /hive /conductor` → prompt
|
||
injection. We strip the leading slash command from `raw`, hand the
|
||
tail to `slash_cmds.prompt_for`, and re-enter `submit_user_message`
|
||
so the agent sees it as a normal user turn (display bubble still
|
||
shows the original `/cmd ...` for clarity).
|
||
"""
|
||
from frontends import slash_cmds
|
||
text = (raw or "").strip()
|
||
# Pull just the leading token to look up the prompt builder.
|
||
head = text.split(None, 1)[0] if text else ""
|
||
if not head.startswith("/"):
|
||
self._system("❌ /slash 命令解析失败"); return
|
||
tail = text[len(head):].strip()
|
||
prompt = slash_cmds.prompt_for(head, tail)
|
||
if prompt is None:
|
||
self._system(f"❌ 未知命令 {head}"); return
|
||
sess = self.current
|
||
if sess.status == "running":
|
||
self._system(f"#{sess.agent_id} 正在跑,/stop 后再发。")
|
||
return
|
||
# Keep the user's original `/cmd ...` as the visible bubble so the
|
||
# transcript stays self-explanatory; the agent sees the long prompt.
|
||
self.submit_user_message(prompt, display_text=text or head)
|
||
|
||
def _cmd_scheduler(self, args, raw):
|
||
"""`/scheduler` lists reflect/*.py + sche_tasks/*.json and starts the
|
||
chosen reflect task(s). Usage:
|
||
/scheduler — interactive multi-select picker
|
||
(Space toggles, Enter launches every
|
||
checked task in one batch)
|
||
/scheduler start <name> — start one reflect task by stem (CLI)
|
||
/scheduler start a,b,c — start several at once (CSV, CLI)
|
||
Cron-style sche_tasks/*.json are read-only here; the launch.pyw
|
||
scheduler daemon already owns them.
|
||
"""
|
||
from frontends import slash_cmds
|
||
body = " ".join(args).strip()
|
||
parts = body.split(None, 1)
|
||
head = parts[0].lower() if parts else ""
|
||
if head in ("start", "run"):
|
||
names = (parts[1] if len(parts) > 1 else "").replace(",", " ").split()
|
||
if not names:
|
||
self._system("Usage: /scheduler start <reflect_name>[,<name2>...]"); return
|
||
self._launch_service_batch(names)
|
||
return
|
||
# Default: surface a MultiChoiceList picker for reflect/*.py so the
|
||
# user can tick several tasks at once (Space toggle, Enter submit).
|
||
# sche_tasks/*.json are read-only — shown below as a system advisory
|
||
# so the user still has visibility, but they can't be launched here.
|
||
services = slash_cmds.list_launchable_services()
|
||
if not services:
|
||
self._system("📋 没有可启动的服务(reflect/*.py 与 frontends/*app*.py 均为空)"); return
|
||
# bug#4: query what's actually alive *now* (psutil cmdline scan) so the
|
||
# picker can (a) pre-tick running services and (b) tag them visibly.
|
||
# Unticking a pre-ticked row therefore reads as "stop this service".
|
||
try:
|
||
running = slash_cmds.running_services() # {name: pid}
|
||
except Exception:
|
||
running = {}
|
||
# Mirror hub.pyw: reflect tasks + frontend apps, grouped by kind so the
|
||
# picker reads like the GUI launcher. Picker value = hub-style path.
|
||
choices = []
|
||
preselected = [] # indices into `choices` that are running right now
|
||
for kind in ("reflect", "frontend"):
|
||
for s in (svc for svc in services if svc["kind"] == kind):
|
||
doc = f" — {s['doc']}" if s["doc"] else ""
|
||
is_running = s["name"] in running
|
||
tag = " · running" if is_running else ""
|
||
if is_running:
|
||
preselected.append(len(choices))
|
||
label = f"{s['name']}{doc}{tag}"
|
||
# Functional green for already-running rows so they're
|
||
# distinguishable even when the [x] tick is small or the
|
||
# row scrolls off the visible window.
|
||
if is_running:
|
||
from rich.text import Text as _T
|
||
rich_label = _T(label, style="green")
|
||
choices.append((rich_label, s["name"]))
|
||
else:
|
||
choices.append((label, s["name"]))
|
||
sess = self.current
|
||
hint = ("选择要启动的服务(与 hub.pyw 一致:reflect 任务 + frontend 应用)"
|
||
" Space 勾选 · Enter 提交 · Esc 取消 — 提交后还需二次确认")
|
||
if running:
|
||
hint += f"\n 绿色 = 正在运行(已勾选);取消勾选即停止该服务(共 {len(running)} 个在运行)"
|
||
try:
|
||
cron_n = len(slash_cmds.list_scheduler_tasks())
|
||
except Exception:
|
||
cron_n = 0
|
||
if cron_n:
|
||
sch_running = 'reflect/scheduler.py' in running
|
||
cron_state = "已激活" if sch_running else "未激活(启动 reflect/scheduler.py 来调度)"
|
||
hint += f"\n cron:sche_tasks/*.json 共 {cron_n} 个任务 · {cron_state}"
|
||
msg = ChatMessage(
|
||
role="system",
|
||
content=hint,
|
||
kind="multi_choice",
|
||
choices=choices,
|
||
preselected_indices=preselected,
|
||
on_select=lambda names, base=dict(running): self._scheduler_confirm(names, base),
|
||
)
|
||
sess.messages.append(msg)
|
||
self._refresh_messages()
|
||
|
||
def _scheduler_diff(self, selected: list[str], running: dict) -> tuple[list[str], list[str]]:
|
||
"""Translate the picker's *final tick state* into actions relative to
|
||
the running baseline (bug#4):
|
||
starts = ticked but not currently running
|
||
stops = currently running but unticked
|
||
Order is preserved from `selected`/`running` for stable summaries."""
|
||
sel = list(dict.fromkeys(selected)) # dedupe, keep order
|
||
run_names = list(running.keys())
|
||
starts = [n for n in sel if n not in running]
|
||
stops = [n for n in run_names if n not in sel]
|
||
return starts, stops
|
||
|
||
def _scheduler_confirm(self, names: list[str], running: dict | None = None) -> None:
|
||
"""Picker submitted → ask one more ask_user-style submit-confirm card
|
||
before actually launching/stopping anything (user-requested safety).
|
||
|
||
UX mirrors `ask_user`'s `Ready to submit your answer?` confirmation:
|
||
- No ✅ glyph on the choice labels (style consistency).
|
||
- ←/→ Enter Esc hint in the title.
|
||
- Esc / "Edit selection" → re-open the picker (rollback to previous
|
||
screen) just like Esc rolls back free-text typing to the picker.
|
||
bug#4: the card now spells out the *diff* (start X / stop Y) so the
|
||
consequence of unticking a running service is explicit before commit.
|
||
"""
|
||
running = running or {}
|
||
starts, stops = self._scheduler_diff(names, running)
|
||
if not starts and not stops:
|
||
self._system("(选择无变化 — 没有要启动或停止的服务)"); return
|
||
sess = self.current
|
||
if sess is None: return
|
||
lines = []
|
||
if starts:
|
||
lines.append(f"▶ 启动 {len(starts)} 个: " + "、".join(starts))
|
||
if stops:
|
||
lines.append(f"■ 停止 {len(stops)} 个: " + "、".join(stops))
|
||
detail = "\n".join(" " + ln for ln in lines)
|
||
confirm = ChatMessage(
|
||
role="system",
|
||
content=(f"Ready to submit your selection?\n{detail}"
|
||
"\n ←/→ 选择 · Enter 确认 · Esc 回退"),
|
||
kind="choice",
|
||
choices=[("Submit", "__SCHED_GO__"), ("Edit selection", "__SCHED_EDIT__")],
|
||
on_select=lambda v, st=list(starts), sp=list(stops): self._scheduler_commit(v, st, sp),
|
||
# Esc on the confirm card → roll back to the picker (ask_user style).
|
||
on_cancel=lambda: self._cmd_scheduler([], ""),
|
||
)
|
||
sess.messages.append(confirm)
|
||
self._refresh_messages()
|
||
|
||
def _scheduler_commit(self, value: str, starts: list[str], stops: list[str]) -> str:
|
||
"""on_select for the submit-confirm card; returns the breadcrumb text
|
||
shown after the card collapses (see _collapse_choice).
|
||
|
||
- Submit (__SCHED_GO__) → apply the start/stop diff.
|
||
- Edit selection (__SCHED_EDIT__) → re-open the picker (rollback).
|
||
"""
|
||
if value == "__SCHED_EDIT__":
|
||
# Re-show the picker on the next tick so the breadcrumb settles
|
||
# first; using call_after_refresh keeps message order stable.
|
||
try:
|
||
self.call_after_refresh(self._cmd_scheduler, [], "")
|
||
except Exception:
|
||
self._cmd_scheduler([], "")
|
||
return "已回到选择界面"
|
||
if value != "__SCHED_GO__":
|
||
return "已取消,未改动任何服务"
|
||
self._apply_scheduler_diff(starts, stops)
|
||
bits = []
|
||
if starts: bits.append(f"启动 {len(starts)}")
|
||
if stops: bits.append(f"停止 {len(stops)}")
|
||
return "已确认 — " + "、".join(bits) if bits else "已确认(无改动)"
|
||
|
||
def _apply_scheduler_diff(self, starts: list[str], stops: list[str]) -> None:
|
||
"""Run the start/stop actions and print one ✅/❌ summary block.
|
||
Stops run first so a restart (stop+start of the same name) can't race
|
||
the cmdline scan — though the diff never produces such a pair."""
|
||
from frontends import slash_cmds
|
||
lines = []
|
||
for name in stops:
|
||
ok, detail = slash_cmds.stop_service(name)
|
||
lines.append(("■ " if ok else "❌ ") + detail)
|
||
for name in starts:
|
||
ok, detail = slash_cmds.start_service(name)
|
||
lines.append(("▶ " if ok else "❌ ") + detail)
|
||
if not lines:
|
||
lines = ["(无改动)"]
|
||
self._system("调度变更结果:\n" + "\n".join(lines))
|
||
|
||
def _launch_service_batch(self, names: list[str]) -> None:
|
||
"""Shared by `/scheduler start ...` (CLI) and the confirmed picker.
|
||
Launches every requested service via slash_cmds.start_service and
|
||
prints a single ✅/❌ summary block."""
|
||
from frontends import slash_cmds
|
||
if not names:
|
||
self._system("(未选择任何服务)"); return
|
||
lines = [f"批量启动 {len(names)} 个服务:"]
|
||
for n in names:
|
||
ok, msg = slash_cmds.start_service(n)
|
||
lines.append((" ✅ " if ok else " ❌ ") + msg)
|
||
self._system("\n".join(lines))
|
||
|
||
def _reset_terminal_title(self) -> None:
|
||
# Direct write on purpose: this runs at teardown when frames have stopped
|
||
# (so there's no writer-thread race to avoid) and the driver may already
|
||
# be stopped (enqueued writes would be silently dropped). See _term_write.
|
||
try:
|
||
out = sys.__stdout__
|
||
out.write("\x1b]0;\x07")
|
||
out.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def on_unmount(self) -> None:
|
||
self._reset_terminal_title()
|
||
# Drop this run's empty signal dirs on graceful exit; the startup
|
||
# sweep mops up anything a crash leaves behind.
|
||
for s in list(self.sessions.values()):
|
||
_rmdir_if_empty(getattr(s.agent, 'task_dir', None))
|
||
|
||
def _run_shell(self, cmd: str) -> None:
|
||
"""`!cmd` magic: run `cmd` in the user's shell (Git Bash / pwsh /
|
||
sh — see `detect_user_shell`), echo command + output into the
|
||
current session's scrollback, and append a `[!shell]` pair to
|
||
backend.history so the agent sees it on the next turn."""
|
||
if not cmd:
|
||
return
|
||
sess = self.current
|
||
sess.messages.append(ChatMessage("system",
|
||
f"! {cmd}",
|
||
kind="system"))
|
||
import subprocess
|
||
from frontends.slash_cmds import detect_user_shell
|
||
shell_argv, shell_name = detect_user_shell()
|
||
out = ''; rc = 0
|
||
try:
|
||
r = subprocess.run(
|
||
shell_argv + [cmd], stdin=subprocess.DEVNULL, capture_output=True,
|
||
timeout=30, encoding='utf-8', errors='replace',
|
||
)
|
||
out = (r.stdout or '') + (r.stderr or '')
|
||
rc = r.returncode
|
||
except subprocess.TimeoutExpired:
|
||
out = '[shell: timeout 30s]'; rc = -1
|
||
except Exception as e:
|
||
out = f'[shell error: {type(e).__name__}: {e}]'; rc = -1
|
||
body = (out.rstrip('\n') or '(no output)').split('\n')
|
||
formatted = '\n'.join((' └ ' + ln if i == 0 else ' ' + ln)
|
||
for i, ln in enumerate(body))
|
||
sess.messages.append(ChatMessage("system", formatted, kind="system"))
|
||
if sess.agent_id == self.current_id:
|
||
self._refresh_messages()
|
||
try:
|
||
be = getattr(sess.agent, 'llmclient', None)
|
||
be = getattr(be, 'backend', None) if be is not None else None
|
||
if be is not None and hasattr(be, 'history'):
|
||
txt = f"[!shell {shell_name}] {cmd}\n```\n{out.rstrip()}\n```\n(exit {rc})"
|
||
be.history.append({"role": "user",
|
||
"content": [{"type": "text", "text": txt}]})
|
||
except Exception:
|
||
pass
|
||
|
||
# ---------------- agent task + stream ----------------
|
||
# Pending-queue transport: submit while running → wrap text with the
|
||
# "complete current task first, then address this" supplementary
|
||
# phrasing and append to `_intervene` so ga.turn_end_callback prepends
|
||
# it to next_prompt as `[MASTER] ...` mid-turn. The wrap makes
|
||
# `[MASTER]` read as an envelope, not a directive override. On an
|
||
# exit-turn boundary consume_file ate the file but next_prompt was
|
||
# discarded — the replay hook re-routes via put_task.
|
||
|
||
# Soft-guidance wrap — frame the user's mid-task message as input to fold
|
||
# into ongoing reasoning, not a deferred queue item. This lets the model
|
||
# redirect mid-flight if the message warrants it.
|
||
_INTERVENE_WRAP_EN = (
|
||
"User sent a message while you were working:\n"
|
||
"{text}\n"
|
||
"Please take it into consideration and adjust direction if needed."
|
||
)
|
||
_INTERVENE_WRAP_ZH = (
|
||
"用户在你工作时发来了一条新消息:\n"
|
||
"{text}\n"
|
||
"请将其纳入考虑,必要时调整方向。"
|
||
)
|
||
|
||
def _wrap_user_steer(self, text: str) -> str:
|
||
lang = (os.environ.get("GA_LANG", "") or "").lower()
|
||
tmpl = self._INTERVENE_WRAP_EN if lang == "en" else self._INTERVENE_WRAP_ZH
|
||
return tmpl.format(text=text)
|
||
|
||
def _session_intervene_path(self, sess: AgentSession) -> Optional[str]:
|
||
td = getattr(sess.agent, 'task_dir', None)
|
||
if not td:
|
||
return None
|
||
try: os.makedirs(td, exist_ok=True)
|
||
except Exception: return None
|
||
return os.path.join(td, '_intervene')
|
||
|
||
def _inject_intervene(self, sess: AgentSession, text: str) -> bool:
|
||
"""Append `text` to `<task_dir>/_intervene`. Append-mode keeps us
|
||
idempotent under the consume_file race."""
|
||
if sess.status != "running":
|
||
return False
|
||
fp = self._session_intervene_path(sess)
|
||
if not fp:
|
||
return False
|
||
try:
|
||
sep = ''
|
||
try:
|
||
if os.path.getsize(fp) > 0: sep = '\n\n'
|
||
except OSError: pass
|
||
with open(fp, 'a', encoding='utf-8') as f:
|
||
f.write(sep + text)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _clear_intervene(self, sess: AgentSession) -> None:
|
||
fp = self._session_intervene_path(sess)
|
||
if fp:
|
||
try: os.remove(fp)
|
||
except OSError: pass
|
||
|
||
def _install_intervene_replay_hook(self, sess: AgentSession) -> None:
|
||
"""At each turn boundary: non-exit → consume_file already delivered
|
||
our wrapped text into next_prompt (clear UI mirror). Exit → file
|
||
was eaten but next_prompt was discarded; re-route the combined
|
||
wrapped text via put_task so the user's words aren't lost."""
|
||
agent = sess.agent
|
||
try:
|
||
hooks = getattr(agent, "_turn_end_hooks", None)
|
||
if hooks is None:
|
||
hooks = agent._turn_end_hooks = {}
|
||
def _hook(ctx, _s=sess):
|
||
with _s.pending_lk:
|
||
if not _s.pending_wrapped:
|
||
return
|
||
combined = "\n\n".join(_s.pending_wrapped)
|
||
_s.pending_wrapped = []
|
||
_s.pending = []
|
||
if (ctx or {}).get("exit_reason"):
|
||
try:
|
||
dq = _s.agent.put_task(combined, source="user")
|
||
except Exception:
|
||
dq = None
|
||
if dq is not None:
|
||
_s.task_seq += 1
|
||
tid = _s.task_seq
|
||
_s.current_task_id = tid
|
||
_s.current_display_queue = dq
|
||
_s.buffer = ""
|
||
_s.status = "running"
|
||
_s.messages.append(ChatMessage("assistant", "", task_id=tid, done=False))
|
||
threading.Thread(
|
||
target=self._consume_display_queue,
|
||
args=(_s.agent_id, tid, dq),
|
||
daemon=True,
|
||
name=f"ga-tui-consume-{_s.agent_id}-{tid}",
|
||
).start()
|
||
try: self.call_from_thread(self._refresh_messages)
|
||
except Exception: pass
|
||
try: self.call_from_thread(self._refresh_bottombar)
|
||
except Exception: pass
|
||
hooks[f"tui_v2_intervene_{sess.agent_id}"] = _hook
|
||
except Exception:
|
||
pass
|
||
|
||
def submit_user_message(self, text: str, images: Optional[list[str]] = None, display_text: Optional[str] = None) -> int:
|
||
sess = self.current
|
||
# Free-text ask_user answers go through a 2-step submit-confirm card.
|
||
if self._maybe_intercept_free_text(sess, text):
|
||
return -1
|
||
if sess.status == "running":
|
||
wrapped = self._wrap_user_steer(text)
|
||
if self._inject_intervene(sess, wrapped):
|
||
visible = text if display_text is None else display_text
|
||
with sess.pending_lk:
|
||
sess.pending.append(text)
|
||
sess.pending_wrapped.append(wrapped)
|
||
n = len(sess.pending)
|
||
sess.messages.append(ChatMessage(
|
||
"system",
|
||
f"[queued #{n}] {visible}",
|
||
kind="system",
|
||
))
|
||
if sess.agent_id == self.current_id:
|
||
self._refresh_messages()
|
||
self._refresh_bottombar()
|
||
return -1
|
||
# Status flipped in the race — fall through to idle put_task.
|
||
sess.task_seq += 1
|
||
tid = sess.task_seq
|
||
sess.current_task_id = tid
|
||
sess.buffer = ""
|
||
sess.status = "running"
|
||
sess._rw_title = text # checkpoint 标题 = 本次用户输入(段末 commit 用)
|
||
image_paths = list(images or [])
|
||
visible_text = text if display_text is None else display_text
|
||
sess.messages.append(ChatMessage("user", visible_text, image_paths=image_paths))
|
||
sess.messages.append(ChatMessage("assistant", "", task_id=tid, done=False))
|
||
self._refresh_all()
|
||
try:
|
||
self.query_one("#messages", VerticalScroll).scroll_end(animate=False)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
dq = sess.agent.put_task(text, source="user")
|
||
except Exception as e:
|
||
sess.status = "error"
|
||
self._update_assistant(sess.agent_id, f"[ERROR] put_task: {e}", task_id=tid, refresh_chrome=True)
|
||
return tid
|
||
sess.current_display_queue = dq
|
||
threading.Thread(
|
||
target=self._consume_display_queue,
|
||
args=(sess.agent_id, tid, dq),
|
||
daemon=True,
|
||
name=f"ga-tui-consume-{sess.agent_id}-{tid}",
|
||
).start()
|
||
return tid
|
||
|
||
def _consume_display_queue(self, agent_id, task_id, dq):
|
||
buf = ""
|
||
while True:
|
||
try: item = dq.get(timeout=0.25)
|
||
except queue.Empty: continue
|
||
if "next" in item:
|
||
piece = str(item.get("next") or "")
|
||
buf += piece
|
||
self.call_from_thread(self._on_stream, agent_id, task_id, buf, False)
|
||
if "done" in item:
|
||
done_text = str(item.get("done") or buf)
|
||
self.call_from_thread(self._on_stream, agent_id, task_id, done_text, True)
|
||
return
|
||
|
||
def _on_stream(self, agent_id, task_id, text, done):
|
||
s = self.sessions.get(agent_id)
|
||
if not s: return
|
||
if s.current_task_id != task_id:
|
||
# Exit-boundary replay can start a follow-up task before the original
|
||
# display queue emits its final `done`. The old done event must still
|
||
# settle that assistant message; otherwise a single-turn interrupted
|
||
# run keeps its spinner forever while the replay task owns
|
||
# current_task_id.
|
||
if done:
|
||
found = None
|
||
for m in reversed(s.messages):
|
||
if m.role == "assistant" and m.task_id == task_id:
|
||
m.content = text
|
||
m.done = True
|
||
found = m
|
||
break
|
||
if found and agent_id == self.current_id:
|
||
if found._segment_widgets:
|
||
try: self._stream_update_assistant(found)
|
||
except Exception: self._refresh_messages()
|
||
else:
|
||
self._refresh_messages()
|
||
if refresh_chrome:
|
||
self._refresh_sidebar()
|
||
self._refresh_topbar()
|
||
self._ensure_spinner()
|
||
return
|
||
s.buffer = text
|
||
if done:
|
||
s.status = "idle"
|
||
s.current_display_queue = None
|
||
self._update_assistant(agent_id, text, task_id=task_id, done=done, refresh_chrome=True)
|
||
if done:
|
||
self._rw_commit(s) # 落 checkpoint 节点(文件改动已由 tool_before 钩子追踪)
|
||
self._update_plan_state(s, text)
|
||
self._drain_ask_user_events(s)
|
||
|
||
# Phrasing-based opt-in for multi-select picker (no core schema change).
|
||
_MULTI_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE)
|
||
|
||
def _drain_ask_user_events(self, sess: AgentSession) -> None:
|
||
"""Pop any pending ask_user INTERRUPTs and surface them as an
|
||
interactive picker. The selected text is fed back via
|
||
`submit_user_message`, exactly like a typed reply.
|
||
|
||
- Single-select (default) → ChoiceList; ↑/↓ + Enter to pick.
|
||
- Multi-select (when question hints `[多选]`) → MultiChoiceList;
|
||
Space toggles, Enter submits joined by `; `.
|
||
- Always appends a free-text escape hatch as the last option.
|
||
"""
|
||
latest = None
|
||
while True:
|
||
try: latest = sess.ask_user_events.get_nowait()
|
||
except queue.Empty: break
|
||
if not latest: return
|
||
question = latest["question"]; candidates = latest["candidates"]
|
||
multi = bool(self._MULTI_RE.search(question))
|
||
kind = "multi_choice" if multi else "choice"
|
||
choices = [(c, c) for c in candidates] + [(FREE_TEXT_LABEL, FREE_TEXT_CHOICE)]
|
||
hint = "Space 切换 · Enter 提交 · Esc 取消" if multi else "↑/↓ 选择 · Enter 确认 · Esc 取消"
|
||
head = f"{question} {hint}"
|
||
# multi_choice hands `_finalize_multi_choice` a list of picked values;
|
||
# single choice hands a plain string. The agent answer must be a string,
|
||
# so collapse a list the same way the breadcrumb does ("; ".join).
|
||
msg = ChatMessage(
|
||
role="system", content=head, kind=kind, choices=choices,
|
||
on_select=lambda v: self._answer_ask_user(
|
||
sess.agent_id, "; ".join(v) if isinstance(v, list) else v),
|
||
)
|
||
sess.messages.append(msg)
|
||
if sess.agent_id == self.current_id:
|
||
self._refresh_messages()
|
||
|
||
def _enter_free_text_mode(self, msg: ChatMessage) -> None:
|
||
"""User picked the free-text option. Swap the picker for a one-line
|
||
prompt, keep the question hint visible, focus the input, and stash
|
||
the full picker state so Esc can restore it. The question text is
|
||
recovered from `msg.content`'s leading line (head was rendered as
|
||
`question ↑/↓...`)."""
|
||
sess = self.sessions.get(self.current_id)
|
||
if sess is None: return
|
||
question = msg.content.split(" ")[0].rstrip() if " " in msg.content else msg.content
|
||
# Stash everything needed to rebuild the picker on Esc.
|
||
sess.free_text_pending = {
|
||
"question": question,
|
||
"choices": list(msg.choices),
|
||
"on_select": msg.on_select,
|
||
"kind": msg.kind,
|
||
"head": msg.content,
|
||
"picker_msg": msg,
|
||
}
|
||
msg.selected_label = "Other (typing below — Esc to go back)"
|
||
if msg._body_widget is not None:
|
||
try: msg._body_widget.remove()
|
||
except Exception: pass
|
||
prompt = Text()
|
||
prompt.append("Type your answer below, then press Enter. ", style=C_MUTED)
|
||
prompt.append("Esc", style=C_GREEN)
|
||
prompt.append(" goes back to the choices.", style=C_MUTED)
|
||
try:
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
new_widget = SelectableStatic(prompt, classes="msg")
|
||
anchor = msg._hint_widget
|
||
if anchor is not None: container.mount(new_widget, after=anchor)
|
||
else: container.mount(new_widget)
|
||
msg._body_widget = new_widget
|
||
container.scroll_end(animate=False)
|
||
except Exception:
|
||
pass
|
||
try: self.query_one("#input", InputArea).focus()
|
||
except Exception: pass
|
||
|
||
def _return_from_free_text(self) -> bool:
|
||
"""Esc while in free-text mode → restore the original picker.
|
||
|
||
Tears down the `Type your answer below…` prompt and any draft input,
|
||
then reposts the picker as a fresh ChatMessage. Returns True iff a
|
||
restoration ran (so action_escape knows to swallow the key)."""
|
||
sess = self.sessions.get(self.current_id) if self.current_id is not None else None
|
||
pending = sess.free_text_pending if sess else None
|
||
if not pending or not sess: return False
|
||
old: ChatMessage = pending.get("picker_msg") # type: ignore
|
||
# Clear the input draft.
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
inp.text = ""
|
||
except Exception: pass
|
||
# Remove the consumed picker entirely so the rebuilt one is the only
|
||
# active picker — keeps `_active_choice` unambiguous.
|
||
if old is not None:
|
||
for w in (old._role_widget, old._hint_widget, old._body_widget):
|
||
if w is not None:
|
||
try: w.remove()
|
||
except Exception: pass
|
||
if old in sess.messages: sess.messages.remove(old)
|
||
# Repost a fresh picker using the stashed state. _refresh_messages
|
||
# mounts the widget; on_mount focuses it.
|
||
revived = ChatMessage(
|
||
role="system", content=pending["head"], kind=pending["kind"],
|
||
choices=pending["choices"], on_select=pending["on_select"],
|
||
)
|
||
sess.messages.append(revived)
|
||
sess.free_text_pending = None
|
||
self._refresh_messages()
|
||
return True
|
||
|
||
def _return_to_free_text_edit(self, confirm_msg: ChatMessage) -> None:
|
||
"""The submit-confirmation card sent us back to Edit. Tear down the
|
||
confirmation, restore the typed answer to the input, and refocus."""
|
||
sess = self.sessions.get(self.current_id)
|
||
if sess is None: return
|
||
prior = (sess.free_text_pending or {}).get("draft", "")
|
||
for w in (confirm_msg._role_widget, confirm_msg._hint_widget, confirm_msg._body_widget):
|
||
if w is not None:
|
||
try: w.remove()
|
||
except Exception: pass
|
||
if confirm_msg in sess.messages: sess.messages.remove(confirm_msg)
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
inp.text = prior
|
||
inp.focus()
|
||
except Exception: pass
|
||
|
||
def _maybe_intercept_free_text(self, sess: AgentSession, text: str) -> bool:
|
||
"""If a free-text answer is pending, show the `Ready to submit
|
||
your answer?` confirmation card and DON'T forward to the agent yet.
|
||
Returns True if the submit was intercepted."""
|
||
if not sess.free_text_pending or not text.strip(): return False
|
||
question = sess.free_text_pending.get("question", "")
|
||
sess.free_text_pending["draft"] = text
|
||
head = (f"Question: {question}\n"
|
||
f"Your answer: {text}\n\n"
|
||
f"Ready to submit your answer? ←/→ 选择 · Enter 确认 · Esc 取消")
|
||
confirm = ChatMessage(
|
||
role="system", content=head, kind="choice",
|
||
choices=[("Submit answer", text), ("Edit answer", EDIT_ANSWER_CHOICE)],
|
||
on_select=lambda v, aid=sess.agent_id: self._finalize_free_text(aid, v),
|
||
)
|
||
sess.messages.append(confirm)
|
||
self._refresh_messages()
|
||
return True
|
||
|
||
def _finalize_free_text(self, agent_id: int, value: str) -> str:
|
||
"""Submit-confirmation accepted: clear the pending state and route
|
||
through the normal user-message path so the agent gets the answer."""
|
||
s = self.sessions.get(agent_id)
|
||
if s is not None: s.free_text_pending = None
|
||
return self._answer_ask_user(agent_id, value)
|
||
|
||
def _answer_ask_user(self, agent_id: int, value: str) -> str:
|
||
s = self.sessions.get(agent_id)
|
||
if not s: return value
|
||
# submit_user_message must run on this agent's session — switch first
|
||
# so it routes to the right put_task. (Choice clicks always come from
|
||
# the foreground session anyway, but be defensive.)
|
||
prev = self.current_id
|
||
if agent_id != prev:
|
||
self.current_id = agent_id
|
||
try: self.submit_user_message(value)
|
||
finally:
|
||
if agent_id != prev: self.current_id = prev
|
||
return value
|
||
|
||
def _update_assistant(self, agent_id, text, *, task_id=None, done=True, refresh_chrome=False):
|
||
# task_id=None matches the last assistant message; otherwise matches by task_id.
|
||
s = self.sessions.get(agent_id)
|
||
if not s: return
|
||
found = None
|
||
for m in reversed(s.messages):
|
||
if m.role == "assistant" and (task_id is None or m.task_id == task_id):
|
||
m.content = text
|
||
m.done = done
|
||
found = m
|
||
break
|
||
if agent_id != self.current_id:
|
||
return
|
||
if found and found._segment_widgets:
|
||
try:
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
was_at_bottom = self._at_bottom(container)
|
||
self._stream_update_assistant(found)
|
||
if was_at_bottom:
|
||
container.scroll_end(animate=False)
|
||
except Exception:
|
||
self._refresh_messages()
|
||
else:
|
||
self._refresh_messages()
|
||
if refresh_chrome:
|
||
self._refresh_sidebar()
|
||
self._refresh_topbar()
|
||
self._ensure_spinner()
|
||
|
||
# ---------------- Plan/todo panel ----------------
|
||
# State machine (graces absorb mid-stream parse misses / let final tally read):
|
||
# hidden → active(n_done/n_total) → complete(n/n) → [3s grace] → hidden
|
||
# active/complete → empty → [1.5s grace] → hidden
|
||
_PLAN_GRACE_SEC = 3.0
|
||
_PLAN_LOST_GRACE_SEC = 1.5
|
||
|
||
def _update_plan_state(self, sess: AgentSession, _stream_text: str = "") -> None:
|
||
import plan_state
|
||
prev = sess.plan_items
|
||
# Detect plan mode: `working['in_plan_mode']` (live) first, then
|
||
# `restored_plan_path` (/continue, recovered from the structured
|
||
# enter_plan_mode tool_use). Chat text mentioning a plan path is
|
||
# deliberately NOT a signal — no messages passed.
|
||
new_items: list = []
|
||
if plan_state.is_active(sess.agent, restored_path=sess.restored_plan_path):
|
||
path = plan_state.resolve_path(sess.agent, restored_path=sess.restored_plan_path)
|
||
if path:
|
||
try:
|
||
with open(path, encoding="utf-8", errors="replace") as f:
|
||
new_items = plan_state.extract(f.read())
|
||
except OSError:
|
||
new_items = []
|
||
now_c = plan_state.is_complete(new_items) and new_items
|
||
was_c = plan_state.is_complete(prev) and prev
|
||
if now_c and not was_c: sess.plan_complete_since = time.time()
|
||
elif not now_c: sess.plan_complete_since = None
|
||
if not new_items and prev:
|
||
sess.plan_lost_since = time.time()
|
||
elif new_items:
|
||
sess.plan_lost_since = None
|
||
sess.plan_items = new_items
|
||
if sess.agent_id == self.current_id:
|
||
self._refresh_planbar()
|
||
|
||
def _refresh_planbar(self) -> None:
|
||
try: bar = self.query_one("#planbar", Static)
|
||
except Exception: return
|
||
sess = self.sessions.get(self.current_id) if self.current_id is not None else None
|
||
items = sess.plan_items if sess else []
|
||
if sess and sess.plan_lost_since is not None:
|
||
if time.time() - sess.plan_lost_since >= self._PLAN_LOST_GRACE_SEC:
|
||
sess.plan_items = []; sess.plan_lost_since = None; items = []
|
||
import plan_state
|
||
msgs = sess.messages if sess else None
|
||
base = sess.plan_scan_baseline if sess else 0
|
||
# Plan-mode armed but no items yet → placeholder (covers the
|
||
# enter_plan_mode → first plan.md write gap).
|
||
if not items:
|
||
if sess and plan_state.is_active(sess.agent,
|
||
restored_path=sess.restored_plan_path):
|
||
self._render_planbar_placeholder(bar, sess)
|
||
return
|
||
self._set_planbar_visible(bar, False); return
|
||
n_done, n_total = plan_state.summary(items)
|
||
complete = plan_state.is_complete(items)
|
||
if complete and sess and sess.plan_complete_since is not None:
|
||
if time.time() - sess.plan_complete_since >= self._PLAN_GRACE_SEC:
|
||
self._set_planbar_visible(bar, False); return
|
||
# Render all tasks — #planbar-tasks caps the visible window at 4 rows and
|
||
# scrolls the rest. Open tasks first, done last (open work stays on top).
|
||
step = plan_state.current_step(msgs, start_idx=base)
|
||
ordered = [(c, st) for c, st in items if st != "done"] + \
|
||
[(c, st) for c, st in items if st == "done"]
|
||
sig = (tuple(ordered), step, bool(complete and sess and sess.plan_complete_since))
|
||
if getattr(bar, "_plan_sig", None) == sig and self._planbar_shown(): return
|
||
bar._plan_sig = sig
|
||
head = Text()
|
||
head.append(f"✓ Plan complete ({n_total}/{n_total})" if complete
|
||
else f"📋 Plan ({n_done}/{n_total})", style=f"bold {C_GREEN}")
|
||
if step:
|
||
head.append("\n ▸ ", style=C_GREEN)
|
||
head.append(step[:120], style=C_MUTED)
|
||
body = Text()
|
||
for i, (c, st) in enumerate(ordered):
|
||
if i: body.append("\n")
|
||
if st == "done": body.append(" [x] ", style=C_GREEN); body.append(c, style=C_DIM)
|
||
else: body.append(" [ ] ", style=C_DIM); body.append(c, style=C_FG)
|
||
self._planbar_paint(head, body, bar)
|
||
self._set_planbar_visible(bar, True)
|
||
|
||
def _render_planbar_placeholder(self, bar: Static, sess: AgentSession) -> None:
|
||
# Placeholder for armed-but-empty plan mode (pre-first plan.md write).
|
||
import plan_state
|
||
base = sess.plan_scan_baseline
|
||
path = (plan_state._stashed_plan_path(sess.agent)
|
||
or sess.restored_plan_path
|
||
or "")
|
||
hint = "/".join(path.replace("\\", "/").rstrip("/").split("/")[-2:]) if path else "plan.md"
|
||
step = plan_state.current_step(sess.messages, start_idx=base)
|
||
sig = ("__placeholder__", hint, step)
|
||
if getattr(bar, "_plan_sig", None) == sig and self._planbar_shown(): return
|
||
bar._plan_sig = sig
|
||
head = Text()
|
||
head.append("📋 Plan 模式已激活", style=f"bold {C_GREEN}")
|
||
if step:
|
||
head.append("\n ▸ ", style=C_GREEN)
|
||
head.append(step[:120], style=C_MUTED)
|
||
body = Text()
|
||
body.append(f" 等待写入 {hint} …", style=C_DIM)
|
||
self._planbar_paint(head, body, bar)
|
||
self._set_planbar_visible(bar, True)
|
||
|
||
def _planbar_paint(self, head: Text, body: Text, bar: Static) -> None:
|
||
# Header/step go to the pinned #planbar-head; tasks to #planbar (the
|
||
# scrolling body). bar is #planbar, passed in by the callers.
|
||
try: self.query_one("#planbar-head", Static).update(head)
|
||
except Exception: pass
|
||
bar.update(body)
|
||
|
||
def _planbar_shown(self) -> bool:
|
||
try: return self.query_one("#planbar-scroll", Vertical).has_class("-visible")
|
||
except Exception: return False
|
||
|
||
def _set_planbar_visible(self, bar: Static, visible: bool) -> None:
|
||
# Visibility lives on the outer container (display:none ↔ -visible),
|
||
# mirroring #palette. Repaint only on show→hide transition; idle ticks no-op.
|
||
try: cont = self.query_one("#planbar-scroll", Vertical)
|
||
except Exception: return
|
||
if not visible:
|
||
if not cont.has_class("-visible"): return
|
||
cont.remove_class("-visible")
|
||
try: self.query_one("#planbar-head", Static).update(Text())
|
||
except Exception: pass
|
||
bar.update(Text())
|
||
bar._plan_sig = None
|
||
return
|
||
if not cont.has_class("-visible"):
|
||
cont.add_class("-visible")
|
||
|
||
def _start_plan_watcher(self) -> None:
|
||
if getattr(self, "_plan_timer", None) is not None: return
|
||
self._plan_mtime: dict = {}
|
||
try: self._plan_timer = self.set_interval(1.0, self._poll_plan_files)
|
||
except Exception: pass
|
||
|
||
def _poll_plan_files(self) -> None:
|
||
# Poll only the visible session — background sessions don't paint planbar.
|
||
import plan_state
|
||
sess = self.sessions.get(self.current_id) if self.current_id is not None else None
|
||
if sess is None: return
|
||
if not plan_state.is_active(sess.agent, restored_path=sess.restored_plan_path):
|
||
self._refresh_planbar(); return
|
||
path = plan_state.resolve_path(sess.agent, restored_path=sess.restored_plan_path)
|
||
if not path:
|
||
self._refresh_planbar(); return
|
||
try: mtime = os.path.getmtime(path)
|
||
except OSError:
|
||
self._refresh_planbar(); return
|
||
if self._plan_mtime.get(sess.agent_id) != mtime:
|
||
self._plan_mtime[sess.agent_id] = mtime
|
||
self._update_plan_state(sess); return
|
||
self._refresh_planbar() # tick grace timers
|
||
|
||
# ---------------- Tip rotation ----------------
|
||
# 12s show → 1s blank → next tip.
|
||
_TIP_SHOW_SEC = 12.0
|
||
_TIP_BLANK_SEC = 1.0
|
||
|
||
def _start_tip_rotator(self) -> None:
|
||
if getattr(self, "_tip_timer", None) is not None: return
|
||
self._tip_current: str = ""
|
||
try: self._tip_timer = self.set_interval(self._TIP_SHOW_SEC, self._rotate_tip)
|
||
except Exception: pass
|
||
|
||
def _rotate_tip(self) -> None:
|
||
try: bar = self.query_one("#tipbar", Static)
|
||
except Exception: return
|
||
bar.update(_tip_line("")) # blank pulse
|
||
nxt = _random_tip(exclude=self._tip_current)
|
||
self._tip_current = nxt
|
||
try: self.set_timer(self._TIP_BLANK_SEC, lambda: self._show_tip(nxt))
|
||
except Exception: self._show_tip(nxt)
|
||
|
||
def _show_tip(self, tip: str) -> None:
|
||
try: bar = self.query_one("#tipbar", Static)
|
||
except Exception: return
|
||
bar.update(_tip_line(tip))
|
||
|
||
# ---------------- UI refresh ----------------
|
||
def _system(self, text: str) -> None:
|
||
if self.current_id is None: return
|
||
self.current.messages.append(ChatMessage("system", text))
|
||
self._refresh_messages()
|
||
|
||
def _refresh_all(self):
|
||
if not self.is_mounted: return
|
||
self._swap_input_for_session()
|
||
self._refresh_topbar()
|
||
self._refresh_sidebar()
|
||
self._refresh_messages()
|
||
self._refresh_planbar()
|
||
self._ensure_spinner()
|
||
|
||
def _swap_input_for_session(self) -> None:
|
||
"""Persist the InputArea's text/history/pastes per-session so switching
|
||
agents doesn't bleed input state across them."""
|
||
if self.current_id is None:
|
||
return
|
||
try:
|
||
inp = self.query_one("#input", InputArea)
|
||
except Exception:
|
||
return
|
||
prev_id = getattr(self, "_input_owner_id", None)
|
||
if prev_id == self.current_id:
|
||
return
|
||
if prev_id is not None and prev_id in self.sessions:
|
||
prev = self.sessions[prev_id]
|
||
prev.input_text = inp.text
|
||
prev.input_history = inp._input_history
|
||
prev.input_pastes = inp._pastes
|
||
prev.input_paste_counter = inp._paste_counter
|
||
sess = self.current
|
||
inp._input_history = sess.input_history
|
||
inp._pastes = sess.input_pastes
|
||
inp._paste_counter = sess.input_paste_counter
|
||
inp._history_index = -1
|
||
inp._history_stash = ""
|
||
try: inp._suppress_palette_next_change()
|
||
except Exception: pass
|
||
inp.text = sess.input_text
|
||
self._input_owner_id = self.current_id
|
||
try: self._resize_input(inp)
|
||
except Exception: pass
|
||
|
||
def _refresh_topbar(self):
|
||
if not self.is_mounted or self.current_id is None: return
|
||
s = self.current
|
||
try: model = s.agent.get_llm_name(model=True)
|
||
except Exception: model = "?"
|
||
try: effort = getattr(s.agent.llmclient.backend, "reasoning_effort", "") or ""
|
||
except Exception: effort = ""
|
||
tasks_running = sum(1 for x in self.sessions.values() if x.status == "running")
|
||
# App-wide busy window for the ✦ identity chip.
|
||
if tasks_running > 0:
|
||
if self._busy_since is None: self._busy_since = time.time()
|
||
elapsed = int(time.time() - self._busy_since)
|
||
else:
|
||
self._busy_since = None
|
||
elapsed = 0
|
||
# Per-session busy window — drives the heat-color dot + done-flash.
|
||
now = time.time()
|
||
if s.status == "running":
|
||
if s._busy_since is None: s._busy_since = now
|
||
sess_elapsed = int(now - s._busy_since)
|
||
just_done = False
|
||
else:
|
||
if s._busy_since is not None:
|
||
s._done_at = now
|
||
s._busy_since = None
|
||
sess_elapsed = 0
|
||
just_done = bool(s._done_at and (now - s._done_at) < _DONE_FLASH_SECS)
|
||
# Chip ticker: keep running both for the elapsed counter AND so the
|
||
# done-flash decays back to dim after _DONE_FLASH_SECS without input.
|
||
need_ticker = (tasks_running > 0) or just_done
|
||
if need_ticker and self._chip_timer is None:
|
||
try: self._chip_timer = self.set_interval(1.0, self._refresh_topbar)
|
||
except Exception: pass
|
||
elif not need_ticker and self._chip_timer is not None:
|
||
try: self._chip_timer.stop()
|
||
except Exception: pass
|
||
self._chip_timer = None
|
||
try: term_w = self.size.width
|
||
except Exception: term_w = 0
|
||
# Workspace label is per-session, not the legacy process-global anchor.
|
||
p = (s.workspace_path or "").rstrip("/\\")
|
||
ws_name = os.path.basename(p) if p else s.workspace_name
|
||
self.query_one("#topbar", Static).update(
|
||
render_topbar(s.name, s.status, model, tasks_running,
|
||
fold_mode=self.fold_mode, busy_elapsed=elapsed, effort=effort,
|
||
sess_elapsed=sess_elapsed, just_done=just_done,
|
||
term_width=term_w, workspace=ws_name))
|
||
self._ensure_title_timer()
|
||
self._update_terminal_title()
|
||
|
||
def _term_write(self, data: str) -> None:
|
||
"""Emit a raw control sequence to the terminal THROUGH Textual's driver.
|
||
|
||
Direct sys.__stdout__ writes race Textual's background WriterThread at the
|
||
byte level: an OSC/control sequence injected mid-frame splits one of
|
||
Textual's own escape sequences, and the terminal renders the wreckage as
|
||
flashing ANSI garbage (cleared by the next frame). Reproduces reliably by
|
||
switching sessions while streaming, when the title ticker fires often.
|
||
Routing through self._driver.write enqueues the sequence on the same
|
||
serialized writer queue as the frames, so it lands atomically between
|
||
them. Falls back to __stdout__ before the driver exists / in headless.
|
||
"""
|
||
drv = getattr(self, "_driver", None)
|
||
if drv is not None:
|
||
try:
|
||
drv.write(data)
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
sys.__stdout__.write(data); sys.__stdout__.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def _update_terminal_title(self) -> None:
|
||
# OSC 0 (set window + icon title). Mainstream terminals consume it: Windows
|
||
# Terminal, mintty (MinGW64/MSYS), iTerm2, Terminal.app, kitty, alacritty,
|
||
# gnome-terminal, xterm. Others ignore the sequence silently.
|
||
# IMPORTANT: write to sys.__stdout__, NOT sys.stdout — Textual replaces
|
||
# sys.stdout with _capture_stdout during run, so writes to it never reach
|
||
# the terminal. (textual/app.py: `with redirect_stdout(self._capture_stdout)`)
|
||
if not self.is_mounted or self.current_id is None: return
|
||
sess = self.current
|
||
busy = any(x.status == "running" for x in self.sessions.values())
|
||
name = (sess.name or "session").strip() or "session"
|
||
if busy:
|
||
glyph = _TITLE_SPINNER_FRAMES[self._title_frame % len(_TITLE_SPINNER_FRAMES)]
|
||
title = f"{glyph} {name} · GenericAgent"
|
||
else:
|
||
title = f"{name} · GenericAgent"
|
||
if title == self._last_title: return
|
||
self._last_title = title
|
||
# Serialize through the driver — see _term_write for the race this avoids.
|
||
self._term_write(f"\x1b]0;{title}\x07")
|
||
|
||
def _ensure_title_timer(self) -> None:
|
||
busy = any(x.status == "running" for x in self.sessions.values())
|
||
if busy and self._title_timer is None:
|
||
try: self._title_timer = self.set_interval(0.2, self._tick_title)
|
||
except Exception: pass
|
||
elif not busy and self._title_timer is not None:
|
||
try: self._title_timer.stop()
|
||
except Exception: pass
|
||
self._title_timer = None
|
||
|
||
def _tick_title(self) -> None:
|
||
self._title_frame = (self._title_frame + 1) % len(_TITLE_SPINNER_FRAMES)
|
||
self._update_terminal_title()
|
||
if not any(x.status == "running" for x in self.sessions.values()):
|
||
self._ensure_title_timer()
|
||
|
||
def _refresh_bottombar(self):
|
||
if not self.is_mounted: return
|
||
try:
|
||
self.query_one("#bottombar", Static).update(render_bottombar(
|
||
quit_armed=self._quit_armed,
|
||
rewind_armed=self._rewind_armed,
|
||
))
|
||
except Exception:
|
||
pass
|
||
|
||
def _refresh_sidebar(self):
|
||
if not self.is_mounted: return
|
||
self.query_one("#sidebar", Static).update(render_sidebar(self.sessions, self.current_id))
|
||
self._scroll_active_session_into_view()
|
||
|
||
def _scroll_active_session_into_view(self) -> None:
|
||
# Keyboard session-switching (ctrl+up/down) can land on a session below
|
||
# the fold; mirror on_click's row math to bring its block into view.
|
||
if self.current_id is None:
|
||
return
|
||
try:
|
||
scroll = self.query_one("#sidebar-scroll", VerticalScroll)
|
||
except Exception:
|
||
return
|
||
y = 3 # pad-top(1) + "SESSIONS"(1) + blank(1), matches on_click
|
||
for sid, sess in self.sessions.items():
|
||
rows = 3
|
||
if _sidebar_last_user(sess): rows += 1
|
||
if _sidebar_last_summary(sess): rows += 1
|
||
if sid == self.current_id:
|
||
self.call_after_refresh(scroll.scroll_to_region,
|
||
Region(0, y, 1, rows), animate=False)
|
||
return
|
||
y += rows
|
||
|
||
def _at_bottom(self, container) -> bool:
|
||
try:
|
||
return container.scroll_y >= container.max_scroll_y - 1
|
||
except Exception:
|
||
return True
|
||
|
||
def _refresh_messages(self):
|
||
if not self.is_mounted or self.current_id is None: return
|
||
sess = self.current
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
switched = getattr(self, "_last_session_id", None) != sess.agent_id
|
||
was_at_bottom = True if switched else self._at_bottom(container)
|
||
if switched:
|
||
container.remove_children()
|
||
for m in sess.messages:
|
||
m._role_widget = None
|
||
m._body_widget = None
|
||
m._segment_widgets = []
|
||
m._segment_sig = ()
|
||
m._spinner_widget = None
|
||
self._last_session_id = sess.agent_id
|
||
for m in sess.messages:
|
||
if m._role_widget is None:
|
||
self._mount_message(container, m)
|
||
if was_at_bottom:
|
||
# Defer the scroll until AFTER Textual has laid out any freshly
|
||
# mounted widgets (e.g. a SearchableChoiceList picker). Calling
|
||
# scroll_end() synchronously here races the layout pass: the new
|
||
# widget still reports a stale/zero height, so we scroll to a
|
||
# too-short virtual size and land on the message head, then the
|
||
# picker expands below the fold (the "title visible, options
|
||
# hidden" bug). call_after_refresh runs post-layout, so the final
|
||
# height is known and scroll_end pins the true bottom.
|
||
self.call_after_refresh(lambda: container.scroll_end(animate=False))
|
||
|
||
def _messages_width(self) -> int:
|
||
try:
|
||
w = self.query_one("#messages", VerticalScroll).content_region.width
|
||
return max(40, w)
|
||
except Exception:
|
||
return 100
|
||
|
||
def _render_md(self, text: str, width: int):
|
||
# Markdown via RichVisual loses segment.style.meta["offset"] so mouse selection
|
||
# can't anchor; round-trip through ANSI → Text.from_ansi to restore selectability.
|
||
# A parallel wide render builds a wrap-free "source" string that
|
||
# SelectableStatic.get_selection uses, so copy never includes wrap newlines.
|
||
try:
|
||
from io import StringIO
|
||
from rich.console import Console
|
||
render_w = max(1, width - 1)
|
||
text = _TASKLIST_OPEN_RE.sub(r"\1☐ ", text)
|
||
text = _TASKLIST_DONE_RE.sub(r"\1✔ ", text)
|
||
# file_write/file_patch render as a themed diff that markdown can't
|
||
# express; swap them for a sentinel now and splice the prerendered
|
||
# ANSI (narrow) / plain (wide) blocks back in after markdown runs.
|
||
diff_blocks: dict[int, tuple[str, str]] = {}
|
||
def _stash(rendered, _b=diff_blocks):
|
||
n = len(_b)
|
||
_b[n] = rendered
|
||
return f"\n\nGADIFFSENTINEL{n}END\n\n"
|
||
|
||
# Render a standalone markdown block to (narrow-ANSI, wide-plain)
|
||
# streams with the card left margin: same theme/widths as the main
|
||
# render so the spliced block matches its surroundings, but only the
|
||
# narrow lines get the visual-only margin (kept out of the copy
|
||
# source). Shared by the code_run card and the generic tool margin.
|
||
def _md_to_streams(seg, _w=render_w):
|
||
nbuf, wbuf = StringIO(), StringIO()
|
||
Console(file=nbuf, width=max(8, _w - _DIFF_MARGIN),
|
||
force_terminal=True, color_system="truecolor",
|
||
legacy_windows=False,
|
||
theme=_markdown_rich_theme(_palette, minimal=(self.theme != "ga-default"))
|
||
).print(HardBreakMarkdown(seg), end="")
|
||
Console(file=wbuf, width=10000, force_terminal=False,
|
||
legacy_windows=False).print(HardBreakMarkdown(seg), end="")
|
||
margin = " " * _DIFF_MARGIN
|
||
nar = "\n".join(margin + l for l in nbuf.getvalue().rstrip("\n").split("\n"))
|
||
return nar, wbuf.getvalue().rstrip("\n")
|
||
|
||
# Primary path: the verbose `🛠️ Tool: … 📥 args:` block. Hash the
|
||
# fence body to recover the real args the snapshot hook captured.
|
||
def _sub_verbose(m, _w=render_w):
|
||
cap = _WRITE_CAP.get(hash(m.group(2)))
|
||
if cap:
|
||
if cap["name"] == "file_read":
|
||
r = _render_read_card(cap["args"], cap.get("content"), _w)
|
||
elif cap["name"] == "code_run":
|
||
r = _render_code_card(cap["args"], cap.get("data"), _w)
|
||
else:
|
||
status, msg = cap.get("status"), cap.get("msg", "")
|
||
st = _fence_status(m.group(3))
|
||
if st:
|
||
status, msg = st
|
||
r = _render_write_diff(cap["name"], cap["args"],
|
||
cap["existed"], cap["old"], _w,
|
||
status=status, msg=msg)
|
||
if r:
|
||
return _stash(r)
|
||
return m.group(0)
|
||
text = _VERBOSE_WRITE_RE.sub(_sub_verbose, text)
|
||
# Every other tool keeps its Rich-markdown look but gains the same
|
||
# left margin: render the block standalone with the same theme.
|
||
# file_read / code_run with a missing capture fall through here and
|
||
# at least get the margin (their bespoke cards already ran above).
|
||
def _sub_tool_margin(m, _w=render_w):
|
||
return _stash(_md_to_streams(m.group(0), _w))
|
||
text = _VERBOSE_TOOL_RE.sub(_sub_tool_margin, text)
|
||
# Legacy path: a raw `<tool_use>{…}</tool_use>` envelope (weak-tool
|
||
# models that inline tool calls as text rather than native calls).
|
||
def _sub_tool(m, _w=render_w):
|
||
try:
|
||
obj = json.loads(m.group(1))
|
||
except Exception:
|
||
return m.group(0)
|
||
if obj.get("name") in ("file_write", "file_patch"):
|
||
args = obj.get("arguments") or {}
|
||
r = _render_write_diff(obj.get("name"), args, False, "", _w)
|
||
if r:
|
||
return _stash(r)
|
||
return _render_tool_use_block(m)
|
||
text = _TOOL_USE_RE.sub(_sub_tool, text)
|
||
text = _META_TAG_RE.sub("", text)
|
||
buf = StringIO()
|
||
Console(file=buf, width=render_w, force_terminal=True,
|
||
color_system="truecolor", legacy_windows=False,
|
||
theme=_markdown_rich_theme(_palette, minimal=(self.theme != "ga-default"))
|
||
).print(HardBreakMarkdown(text), end="")
|
||
narrow_raw = buf.getvalue().rstrip("\n")
|
||
|
||
wide_buf = StringIO()
|
||
Console(file=wide_buf, width=10000, force_terminal=False,
|
||
legacy_windows=False).print(HardBreakMarkdown(text), end="")
|
||
wide_raw = wide_buf.getvalue().rstrip("\n")
|
||
|
||
# Splice diff blocks over their sentinel lines (ANSI → narrow,
|
||
# plain → wide). Identical post-rstrip text keeps line counts equal
|
||
# so `_align_md_renders` pairs them via its K==W branch.
|
||
for n, (ansi_block, plain_block) in diff_blocks.items():
|
||
pat = re.compile(rf"^.*GADIFFSENTINEL{n}END.*$", re.M)
|
||
narrow_raw = pat.sub(lambda _m, _a=ansi_block: _a, narrow_raw, count=1)
|
||
wide_raw = pat.sub(lambda _m, _p=plain_block: _p, wide_raw, count=1)
|
||
|
||
t = Text.from_ansi(narrow_raw)
|
||
t.highlight_regex(r"✔[^\n]*", style=C_DIM)
|
||
t.highlight_regex(r"☐", style=C_DIM)
|
||
t.highlight_regex(r"✔", style=C_GREEN)
|
||
|
||
narrow_plain = _ANSI_SGR_RE.sub("", narrow_raw)
|
||
# `_align_md_renders` handles Rich table/box-drawing runs at run
|
||
# granularity: only the table block is copied visually, while normal
|
||
# paragraphs in the same widget still use the wrap-stripping wide
|
||
# source. A whole-widget table bypass regressed mixed
|
||
# paragraph+table messages by copying visual wrap newlines.
|
||
source, starts, indents, lens = _align_md_renders(narrow_plain, wide_raw)
|
||
return _MdRender(text=t, source=source, line_starts=starts,
|
||
line_indents=indents, line_lengths=lens)
|
||
except Exception:
|
||
fallback = Text(text, style=C_FG)
|
||
return _MdRender(text=fallback, source=text,
|
||
line_starts=[0], line_indents=[0], line_lengths=[len(text)])
|
||
|
||
def _assistant_segments(self, m: ChatMessage, width: int) -> list[tuple]:
|
||
"""Return [(kind, body, fold_idx_or_None)]. kind ∈ {'text','fold-header','fold-body'}.
|
||
fold_idx is the position in fold_turns() output — stable across streaming since
|
||
new turns only append. Last segment carries the streaming suffix."""
|
||
raw = m.content or ""
|
||
# Cache final renders — Markdown re-parse on every resize is expensive over long history.
|
||
key = (len(raw), m.done, width, self.fold_mode, frozenset(m._toggled_folds))
|
||
if m.done and m._cache_key == key and m._cached_body is not None:
|
||
return m._cached_body
|
||
# No streaming suffix here — spinner lives in m._spinner_widget so Markdown
|
||
# rendering (unclosed code fences, paragraph whitespace stripping) can't eat it.
|
||
if not raw.strip():
|
||
return [("text", Text("(空)" if m.done else " ", style=C_DIM), None)]
|
||
cleaned = _ANSI_CONTROL_RE.sub("", raw)
|
||
raw_segs = fold_turns(cleaned)
|
||
# Drop cache entries whose width changed — content keys with stale width
|
||
# would never be hit again and would leak memory across resizes.
|
||
if m._seg_render_cache and any(k[1] != width for k in m._seg_render_cache):
|
||
m._seg_render_cache.clear()
|
||
|
||
def cached_render(content: str) -> "_MdRender":
|
||
k = (hash(content), width)
|
||
v = m._seg_render_cache.get(k)
|
||
if v is None:
|
||
v = self._render_md(content, width)
|
||
m._seg_render_cache[k] = v
|
||
return v
|
||
|
||
out: list[tuple] = []
|
||
last_i = len(raw_segs) - 1
|
||
for i, seg in enumerate(raw_segs):
|
||
if seg["type"] == "fold":
|
||
# fold_mode=True → default collapsed; False → default expanded. Per-fold
|
||
# clicks flip the default for that fold via the toggle set.
|
||
expanded = (not self.fold_mode) ^ (i in m._toggled_folds)
|
||
arrow = "▾" if expanded else "▸"
|
||
title = seg.get("title") or "completed turn"
|
||
header = Text(); header.append(f"{arrow} ", style=C_DIM); header.append(title, style=C_MUTED)
|
||
out.append(("fold-header", header, i))
|
||
if expanded:
|
||
out.append(("fold-body", cached_render(seg.get("content", "")), i))
|
||
else:
|
||
content = _TURN_MARKER_RE.sub("", seg.get("content", ""), count=1)
|
||
# While streaming, the tail text segment grows every chunk — Markdown
|
||
# parsing it per chunk is the streaming-lag root cause. Render via
|
||
# Text.from_ansi during streaming (O(n) scan, no reflow) so SGR codes
|
||
# in the chunk become styles instead of literal `[31m` glyphs;
|
||
# _stream_update_assistant swaps in the real Markdown render once
|
||
# m.done flips True.
|
||
if i == last_i and not m.done:
|
||
out.append(("text", Text.from_ansi(content, style=C_FG), None))
|
||
else:
|
||
out.append(("text", cached_render(content), None))
|
||
if m.done:
|
||
m._cached_body = out
|
||
m._cache_key = key
|
||
return out
|
||
|
||
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||
|
||
# Spinner gerund pool (stable per-message via id-hash; separate from _DONE_GERUNDS).
|
||
_SPINNER_GERUNDS = (
|
||
"Pondering", "Reticulating", "Sleuthing", "Hatching", "Pouncing",
|
||
"Brewing", "Sharpening", "Untangling", "Compiling", "Unraveling",
|
||
"Distilling", "Calibrating", "Marinating", "Conjuring", "Foraging",
|
||
"Spelunking", "Synthesizing", "Refactoring thoughts", "Tracing breadcrumbs",
|
||
"Following the rabbit hole",
|
||
"Routing", "Threading", "Polling", "Spinning", "Hooking",
|
||
"Patching", "Caching", "Yielding", "Hydrating", "Folding",
|
||
"Streaming", "Resolving", "Reaping", "Tuning",
|
||
)
|
||
|
||
def _spinner_glyph(self) -> str:
|
||
return self._SPINNER_FRAMES[self._spinner_frame % len(self._SPINNER_FRAMES)]
|
||
|
||
def _spinner_gerund(self, m) -> str:
|
||
# ID-hashed → stable per-message; survives content mutation.
|
||
idx = (id(m) // 16) % len(self._SPINNER_GERUNDS)
|
||
return self._SPINNER_GERUNDS[idx]
|
||
|
||
@staticmethod
|
||
def _humanize_tokens(n: int) -> str:
|
||
if n < 1000: return f"{n}"
|
||
if n < 1_000_000:
|
||
v = n / 1000.0
|
||
return f"{v:.1f}k" if v < 100 else f"{int(v)}k"
|
||
return f"{n / 1_000_000.0:.2f}M"
|
||
|
||
def _fmt_tokens(self, last_in: int, last_out: int) -> str:
|
||
"""`↑ N · ↓ M` for the latest call's sizes, or "" when both are zero."""
|
||
if last_in <= 0 and last_out <= 0:
|
||
return ""
|
||
return f"↑ {self._humanize_tokens(last_in)} · ↓ {self._humanize_tokens(last_out)}"
|
||
|
||
def _spinner_annotation(self, m) -> Text:
|
||
"""Render `⠋ Gerund… (Xm Ys · ↑ N · ↓ M)` for a streaming message.
|
||
↑/↓ are the latest LLM call's prompt / completion sizes, gated on
|
||
cumulative counters moving past the baselines captured at stream start
|
||
(otherwise the prior turn's tail values leak in on prompt submit).
|
||
"""
|
||
if m._stop_summary is not None:
|
||
return self._stopping_annotation(m)
|
||
out = Text()
|
||
elapsed = int(time.time() - m._stream_started_at) if m._stream_started_at else 0
|
||
last_in, last_out = self._live_call_tokens(m)
|
||
gerund_style = _gerund_color(elapsed, last_in)
|
||
out.append(self._spinner_glyph(), style=gerund_style)
|
||
out.append(f" {self._spinner_gerund(m)}…", style=gerund_style)
|
||
bits = []
|
||
if m._stream_started_at:
|
||
bits.append(_fmt_elapsed(elapsed))
|
||
tok = self._fmt_tokens(last_in, last_out)
|
||
if tok:
|
||
bits.append(tok)
|
||
if bits:
|
||
out.append(" (", style=C_DIM)
|
||
out.append(" · ".join(bits), style=C_DIM)
|
||
out.append(")", style=C_DIM)
|
||
return out
|
||
|
||
def _live_call_tokens(self, m) -> tuple:
|
||
"""`(last_in, last_out)` for this turn, gated on cumulative deltas past
|
||
the per-message baselines. Returns zeros until the new turn moves
|
||
the counters. Shared by spinner + done-card."""
|
||
last_in = last_out = 0
|
||
try:
|
||
import cost_tracker
|
||
sess = self.sessions.get(self.current_id)
|
||
tname = sess.thread.name if sess and sess.thread else f"ga-tui-agent-{self.current_id}"
|
||
t = cost_tracker.get(tname)
|
||
cum_in = t.input + t.cache_create + t.cache_read
|
||
cum_out = t.output
|
||
if cum_in > m._stream_baseline_input: last_in = t.last_input
|
||
if cum_out > m._stream_baseline_output: last_out = t.last_output
|
||
except Exception:
|
||
pass
|
||
return last_in, last_out
|
||
|
||
# Settled-state braille pairs with the spinner frames (⠋…⠏ → ⠿).
|
||
_DONE_GLYPH = "⠿"
|
||
|
||
# Past-tense pool for the post-turn card; reads "{Verb} for Xm Ys".
|
||
_DONE_GERUNDS = (
|
||
"Churned", "Ruminated", "Brewed", "Cooked", "Marinated", "Percolated",
|
||
"Distilled", "Crystallized", "Synthesized", "Sharpened", "Conjured",
|
||
"Pondered", "Spelunked", "Untangled", "Foraged", "Hatched", "Pounced",
|
||
"Sleuthed", "Unraveled", "Calibrated", "Mused", "Schemed", "Tinkered",
|
||
"Forged", "Simmered", "Steeped",
|
||
"Threaded", "Folded", "Patched", "Streamed", "Cached", "Hooked",
|
||
"Routed", "Resolved", "Yielded", "Hydrated", "Reaped", "Tuned",
|
||
"Plotted", "Reviewed", "Audited", "Verified", "Adjudicated",
|
||
"Conducted", "Orchestrated",
|
||
"Mapped", "Reduced", "Dispatched",
|
||
"Recalled", "Stashed", "Indexed",
|
||
)
|
||
|
||
def _done_gerund(self, m) -> str:
|
||
# Stable per-message — id-hash so re-mount (theme / resize / fold) keeps
|
||
# the verb; spinner uses a separate pool so live/settled never collide.
|
||
idx = (id(m) // 16) % len(self._DONE_GERUNDS)
|
||
return self._DONE_GERUNDS[idx]
|
||
|
||
def _done_annotation(self, m) -> Text:
|
||
"""Render `⠿ {Verb} for Xm Ys · ↑ N · ↓ M` after a turn finishes.
|
||
Numbers frozen via `_done_summary` so re-mounts / next turn don't
|
||
shift the line. A user-aborted turn reads `⠿ Stopped after Xm Ys`
|
||
off the abort-time `_stop_summary` instead."""
|
||
if m._stop_summary is not None:
|
||
elapsed, last_in, last_out = m._stop_summary
|
||
verb, glyph_style = "Stopped after", C_DIM
|
||
else:
|
||
elapsed, last_in, last_out = m._done_summary or (0, 0, 0)
|
||
verb, glyph_style = f"{self._done_gerund(m)} for", C_GREEN
|
||
out = Text()
|
||
out.append(self._DONE_GLYPH + " ", style=glyph_style)
|
||
out.append(f"{verb} {_fmt_elapsed(int(elapsed))}", style=C_DIM)
|
||
tok = self._fmt_tokens(last_in, last_out)
|
||
if tok:
|
||
out.append(" · " + tok, style=C_DIM)
|
||
return out
|
||
|
||
def _stopping_annotation(self, m) -> Text:
|
||
"""Settled `⠿ Stopping… (Xm Ys · ↑ N · ↓ M)` shown from the moment the
|
||
user aborts until the LLM stream actually unwinds. Numbers frozen via
|
||
`_stop_summary` so elapsed stops climbing while we wait — the live
|
||
spinner would otherwise keep ticking until `done` finally flips."""
|
||
elapsed, last_in, last_out = m._stop_summary or (0, 0, 0)
|
||
out = Text()
|
||
out.append(self._DONE_GLYPH + " ", style=C_DIM)
|
||
out.append(f"Stopping… ({_fmt_elapsed(int(elapsed))}", style=C_DIM)
|
||
tok = self._fmt_tokens(last_in, last_out)
|
||
if tok:
|
||
out.append(" · " + tok, style=C_DIM)
|
||
out.append(")", style=C_DIM)
|
||
return out
|
||
|
||
def _freeze_summary(self, m) -> tuple:
|
||
"""Snapshot `(elapsed, last_in, last_out)` at the current instant."""
|
||
elapsed = (time.time() - m._stream_started_at) if m._stream_started_at else 0.0
|
||
return (elapsed, *self._live_call_tokens(m))
|
||
|
||
def _capture_done_summary(self, m) -> None:
|
||
"""Freeze the turn's numbers once it flips done→True. Idempotent, so
|
||
re-mounts and stream-update passes never overwrite the snapshot."""
|
||
if m._done_summary is None and m.done:
|
||
m._done_summary = self._freeze_summary(m)
|
||
|
||
def _capture_stop_summary(self, m) -> None:
|
||
"""Freeze the turn's numbers the instant the user aborts. Idempotent —
|
||
the first Ctrl+C / `/stop` wins so a late real abort can't bump elapsed."""
|
||
if m._stop_summary is None:
|
||
m._stop_summary = self._freeze_summary(m)
|
||
|
||
def _mark_stopping(self, sess) -> None:
|
||
"""Freeze every in-flight assistant in `sess` to the settled "Stopping…"
|
||
line and push it now, so the spinner stops climbing the instant the user
|
||
aborts instead of after the LLM stream unwinds in the background."""
|
||
for m in sess.messages:
|
||
if m.role == "assistant" and not m.done:
|
||
self._capture_stop_summary(m)
|
||
if m._spinner_widget is not None:
|
||
try: m._spinner_widget.update(self._stopping_annotation(m))
|
||
except Exception: pass
|
||
|
||
def _has_streaming(self) -> bool:
|
||
if self.current_id is None:
|
||
return False
|
||
return any(m.role == "assistant" and not m.done for m in self.current.messages)
|
||
|
||
def _ensure_spinner(self) -> None:
|
||
# Independent timer keeps frames advancing between chunks (chunks may stall on the
|
||
# network). Self-stops once no assistant message in the current session is streaming.
|
||
running = self._has_streaming()
|
||
if running and self._spinner_timer is None:
|
||
self._spinner_timer = self.set_interval(0.1, self._spinner_tick)
|
||
elif not running and self._spinner_timer is not None:
|
||
self._spinner_timer.stop()
|
||
self._spinner_timer = None
|
||
self._spinner_frame = 0
|
||
|
||
def _spinner_tick(self) -> None:
|
||
self._spinner_frame = (self._spinner_frame + 1) % len(self._SPINNER_FRAMES)
|
||
if self.current_id is None:
|
||
self._ensure_spinner(); return
|
||
for m in self.current.messages:
|
||
if m.role == "assistant" and not m.done and m._spinner_widget is not None:
|
||
if m._stream_started_at is None:
|
||
self._mark_stream_start(m)
|
||
try: m._spinner_widget.update(self._spinner_annotation(m))
|
||
except Exception: pass
|
||
if not self._has_streaming():
|
||
self._ensure_spinner()
|
||
|
||
def _mark_stream_start(self, m) -> None:
|
||
"""Lazily timestamp a streaming message so the spinner can show elapsed/tokens.
|
||
Snapshots both input-side and output-side token totals as baselines so
|
||
the spinner's `↑ N · ↓ M` reflects *this* turn only."""
|
||
m._stream_started_at = time.time()
|
||
try:
|
||
import cost_tracker
|
||
sess = self.sessions.get(self.current_id)
|
||
tname = sess.thread.name if sess and sess.thread else f"ga-tui-agent-{self.current_id}"
|
||
t = cost_tracker.get(tname)
|
||
m._stream_baseline_input = t.input + t.cache_create + t.cache_read
|
||
m._stream_baseline_output = t.output
|
||
except Exception:
|
||
m._stream_baseline_input = 0
|
||
m._stream_baseline_output = 0
|
||
|
||
@staticmethod
|
||
def _segment_sig(segs: list[tuple]) -> tuple:
|
||
# Topology fingerprint: ignores body content so streaming chunks within the same
|
||
# last text segment don't invalidate the structure. Used to decide stream-update
|
||
# (in-place .update of last widget) vs. full remount (when folds appear/expand).
|
||
return tuple((kind, idx) for kind, _, idx in segs)
|
||
|
||
def _mount_message(self, container: VerticalScroll, m: ChatMessage) -> None:
|
||
# Looked up at call time (not class init) so theme switches propagate.
|
||
color = {"user": C_PURPLE, "system": C_BLUE, "assistant": C_GREEN}.get(m.role, C_GREEN)
|
||
label = m.role.upper() if m.role != "assistant" else "AGENT"
|
||
m._role_widget = SelectableStatic(f"[bold {color}]{label}[/]", classes="role")
|
||
container.mount(m._role_widget)
|
||
|
||
if m.kind in ("choice", "multi_choice") and m.selected_label is None:
|
||
m._hint_widget = SelectableStatic(Text(m.content, style=C_MUTED), classes="msg")
|
||
container.mount(m._hint_widget)
|
||
if m.kind == "multi_choice":
|
||
# Index into m.choices is preserved as the Selection value, so
|
||
# the submit handler can recover labels — including the free-
|
||
# text option, treated as a "drop everything and type" trigger.
|
||
# `initial_state=True` for indices already running (bug#4) so
|
||
# the card opens with them ticked → unticking == "stop this".
|
||
_pre = set(m.preselected_indices or [])
|
||
widget = MultiChoiceList(m, *(Selection(cl, idx, idx in _pre)
|
||
for idx, (cl, _) in enumerate(m.choices)),
|
||
classes="picker")
|
||
else:
|
||
if m.lazy_choice_items:
|
||
# Lazy path: mount only the first batch, stream the rest.
|
||
# `lazy_choice_items` holds the label list mirrored from
|
||
# m.choices so we never mutate the canonical choices array.
|
||
widget = LazyChoiceList(
|
||
m, m.lazy_choice_items,
|
||
batch=m.lazy_choice_batch or 50,
|
||
classes="picker",
|
||
)
|
||
elif m.searchable and not m.choices:
|
||
# Async-filled picker still loading (or empty): disabled
|
||
# hint row under the Input, same shape as the no-matches row.
|
||
widget = ChoiceList(m, m.empty_hint, classes="picker")
|
||
widget.disabled = True
|
||
else:
|
||
widget = ChoiceList(m, classes="picker")
|
||
for cl, _ in m.choices:
|
||
widget.add_option(cl)
|
||
# `searchable` wraps the freshly-built picker in a Vertical
|
||
# container with an Input filter on top. The original picker
|
||
# is preserved as `.picker` so `_active_choice`, key routing
|
||
# and `_collapse_choice` all keep working unchanged.
|
||
if m.searchable:
|
||
widget = SearchableChoiceList(m, widget, classes="picker")
|
||
m._body_widget = widget
|
||
container.mount(widget)
|
||
# For searchable pickers we focus the Input so the user can start
|
||
# typing immediately; for plain pickers we focus the OptionList as
|
||
# before so arrow keys work out of the box.
|
||
if isinstance(widget, SearchableChoiceList):
|
||
def _focus_input(w=widget):
|
||
inp = getattr(w, "_search_input", None)
|
||
try:
|
||
(inp or w).focus()
|
||
except Exception:
|
||
pass
|
||
self.call_after_refresh(_focus_input)
|
||
else:
|
||
def _focus_plain(w=widget, hi=m.initial_highlight):
|
||
try:
|
||
if hi is not None and getattr(w, "option_count", 0):
|
||
w.highlighted = max(0, min(hi, w.option_count - 1))
|
||
w.focus()
|
||
except Exception:
|
||
pass
|
||
self.call_after_refresh(_focus_plain)
|
||
return
|
||
|
||
if m.kind in ("choice", "multi_choice"): # selected_label is not None
|
||
body = Text(); body.append("✓ ", style=C_GREEN); body.append(m.selected_label, style=C_FG)
|
||
m._body_widget = SelectableStatic(body, classes="msg")
|
||
container.mount(m._body_widget)
|
||
return
|
||
if m.role == "user":
|
||
body = Text(); body.append("> ", style=C_DIM); body.append(_elide_user_display(m.content), style=C_FG)
|
||
for path in m.image_paths:
|
||
body.append(f"\n📎 {path}", style=C_MUTED)
|
||
m._body_widget = SelectableStatic(body, classes="msg")
|
||
container.mount(m._body_widget)
|
||
return
|
||
if m.role == "system":
|
||
m._body_widget = SelectableStatic(Text(m.content, style=C_MUTED), classes="msg")
|
||
container.mount(m._body_widget)
|
||
return
|
||
# assistant — multi-segment for per-fold click-to-expand
|
||
segs = self._assistant_segments(m, self._messages_width())
|
||
self._mount_assistant_segments(container, m, segs)
|
||
|
||
def _mount_assistant_segments(self, container, m: ChatMessage, segs: list[tuple],
|
||
after=None) -> None:
|
||
m._segment_widgets = []
|
||
last_text = None
|
||
anchor = after
|
||
for kind, body, fold_idx in segs:
|
||
if kind == "fold-header":
|
||
w = FoldHeader(body, m, fold_idx, classes="msg fold-header")
|
||
else:
|
||
if isinstance(body, _MdRender):
|
||
w = SelectableStatic(body.text, classes="msg")
|
||
w._ga_render = body
|
||
else:
|
||
w = SelectableStatic(body, classes="msg")
|
||
if anchor is None:
|
||
container.mount(w)
|
||
else:
|
||
container.mount(w, after=anchor)
|
||
anchor = w
|
||
m._segment_widgets.append(w)
|
||
if kind == "text":
|
||
last_text = w
|
||
m._body_widget = last_text # keeps existing streaming `.update()` paths working
|
||
m._segment_sig = self._segment_sig(segs)
|
||
self._sync_spinner_widget(container, m, anchor)
|
||
|
||
def _sync_spinner_widget(self, container, m: ChatMessage, anchor) -> None:
|
||
"""Tiny dedicated Static after segment widgets — outside Markdown so
|
||
unclosed code fences / paragraph trimming can't eat it. While streaming
|
||
shows the spinner annotation; once `m.done` flips True, the same widget
|
||
becomes the post-turn `⠿ Churned for Xm Ys` card (frozen via
|
||
`_capture_done_summary`)."""
|
||
if m.done:
|
||
# `_stream_started_at` is the marker that this message was actually
|
||
# streamed in this TUI session. Restored /continue history flips
|
||
# done=True without ever streaming, so skip the card there — a
|
||
# "⠿ Churned for 0s" badge under every archived turn is just noise.
|
||
if m._stream_started_at is None:
|
||
if m._spinner_widget is not None:
|
||
try: m._spinner_widget.remove()
|
||
except Exception: pass
|
||
m._spinner_widget = None
|
||
return
|
||
self._capture_done_summary(m)
|
||
if m._spinner_widget is None:
|
||
w = Static(self._done_annotation(m), classes="msg spinner")
|
||
if anchor is None: container.mount(w)
|
||
else: container.mount(w, after=anchor)
|
||
m._spinner_widget = w
|
||
else:
|
||
try: m._spinner_widget.update(self._done_annotation(m))
|
||
except Exception: pass
|
||
return
|
||
if m._spinner_widget is None:
|
||
if m._stream_started_at is None:
|
||
self._mark_stream_start(m)
|
||
w = Static(self._spinner_annotation(m), classes="msg spinner")
|
||
if anchor is None:
|
||
container.mount(w)
|
||
else:
|
||
container.mount(w, after=anchor)
|
||
m._spinner_widget = w
|
||
|
||
def _stream_update_assistant(self, m: ChatMessage) -> None:
|
||
"""Cheap path for per-chunk streaming: if the fold topology is unchanged, only
|
||
the last text segment got new content, so render and update that one widget.
|
||
Otherwise (a new Turn marker appeared), do a full remount."""
|
||
new_sig = self._assistant_sig_only(m)
|
||
if (new_sig == m._segment_sig and m._segment_widgets
|
||
and new_sig and new_sig[-1][0] == "text"):
|
||
width = self._messages_width()
|
||
raw = m.content or ""
|
||
cleaned = _ANSI_CONTROL_RE.sub("", raw)
|
||
last_seg = fold_turns(cleaned)[-1]
|
||
last_text = _TURN_MARKER_RE.sub("", last_seg.get("content", ""), count=1)
|
||
last_widget = m._segment_widgets[-1]
|
||
# During streaming use Text.from_ansi — Markdown parse per chunk is
|
||
# O(chunks × turn_len), but raw Text() would render upstream SGR codes
|
||
# as literal `[31m` glyphs (visible as ANSI garbage until done flips
|
||
# True or a resize forces remount). from_ansi is O(n) and resolves
|
||
# the codes into Rich styles. On the terminal `done` chunk we render
|
||
# Markdown once and swap, restoring code blocks / lists / inline
|
||
# styling and clean-copy.
|
||
if m.done:
|
||
rendered = self._render_md(last_text, width)
|
||
if isinstance(rendered, _MdRender):
|
||
last_widget._ga_render = rendered
|
||
last_widget.update(rendered.text)
|
||
else:
|
||
last_widget.update(rendered)
|
||
else:
|
||
last_widget._ga_render = None
|
||
# Normalise CRLF → LF before from_ansi. On Windows child stdout
|
||
# is `\r\n`; from_ansi treats `\r` as a carriage return, so each
|
||
# line's text gets overwritten/erased by its own trailing `\r`
|
||
# and the whole `[Stdout]` block renders as blank lines until the
|
||
# turn finishes (the done-state Markdown render strips `\r`). We
|
||
# show the output as-is otherwise — blank-line runs are left for
|
||
# Markdown to fold on completion. Lone `\r` (no `\n`) is kept so
|
||
# progress-bar overwrites still work.
|
||
display = last_text.replace("\r\n", "\n")
|
||
last_widget.update(Text.from_ansi(display, style=C_FG))
|
||
if m.done and m._spinner_widget is not None:
|
||
# Convert the live spinner into the post-turn ⠿ card in place.
|
||
self._capture_done_summary(m)
|
||
try: m._spinner_widget.update(self._done_annotation(m))
|
||
except Exception: pass
|
||
return
|
||
self._remount_assistant_message(m)
|
||
|
||
def _assistant_sig_only(self, m: ChatMessage) -> tuple:
|
||
# Topology signature without rendering bodies — used by the streaming fast path.
|
||
raw = m.content or ""
|
||
if not raw.strip():
|
||
return (("text", None),)
|
||
cleaned = _ANSI_CONTROL_RE.sub("", raw)
|
||
sig = []
|
||
for i, seg in enumerate(fold_turns(cleaned)):
|
||
if seg["type"] == "fold":
|
||
sig.append(("fold-header", i))
|
||
if (not self.fold_mode) ^ (i in m._toggled_folds):
|
||
sig.append(("fold-body", i))
|
||
else:
|
||
sig.append(("text", None))
|
||
return tuple(sig)
|
||
|
||
def _remount_assistant_message(self, m: ChatMessage) -> None:
|
||
"""Rebuild just this message's segments in-place. Used by click-to-expand and
|
||
by streaming when fold topology changes."""
|
||
try:
|
||
container = self.query_one("#messages", VerticalScroll)
|
||
except Exception:
|
||
return
|
||
anchor = m._role_widget
|
||
for w in m._segment_widgets:
|
||
try: w.remove()
|
||
except Exception: pass
|
||
m._segment_widgets = []
|
||
if m._spinner_widget is not None:
|
||
try: m._spinner_widget.remove()
|
||
except Exception: pass
|
||
m._spinner_widget = None
|
||
segs = self._assistant_segments(m, self._messages_width())
|
||
self._mount_assistant_segments(container, m, segs, after=anchor)
|
||
|
||
|
||
# ---------- CLI ----------
|
||
def build_arg_parser() -> argparse.ArgumentParser:
|
||
return argparse.ArgumentParser(description="GenericAgent TUI v2 (refined visual style)")
|
||
|
||
|
||
def _warn_mintty():
|
||
"""Warn only for direct Git Bash/mintty, not Git Bash inside Windows Terminal."""
|
||
if sys.platform != 'win32':
|
||
return
|
||
# Direct Git Bash uses mintty. Git Bash hosted by Windows Terminal still sets
|
||
# MSYSTEM, but has WT_SESSION and renders Textual correctly, so do not block it.
|
||
term_prog = os.environ.get('TERM_PROGRAM', '').lower()
|
||
wt_session = os.environ.get('WT_SESSION', '')
|
||
direct_mintty = term_prog == 'mintty' and not wt_session
|
||
if direct_mintty:
|
||
print(
|
||
"\033[33m[ga-tui] WARNING: direct Git Bash/mintty detected.\033[0m\n"
|
||
" Textual TUI requires a modern terminal with full VT/xterm support.\n"
|
||
" Direct mintty can cause rendering issues (blank screen, garbled output).\n"
|
||
"\n"
|
||
" Recommended alternatives:\n"
|
||
" - Windows Terminal Git Bash: wt -p \"Git Bash\" python frontends/tuiapp_v2.py\n"
|
||
" - Windows Terminal: wt python frontends/tuiapp_v2.py\n"
|
||
" - CMD: python frontends\\tuiapp_v2.py\n"
|
||
" - PowerShell: python frontends/tuiapp_v2.py\n"
|
||
"\n"
|
||
" To continue anyway, set GA_TUI_FORCE=1",
|
||
file=sys.stderr,
|
||
)
|
||
if not os.environ.get('GA_TUI_FORCE'):
|
||
raise SystemExit(1)
|
||
|
||
|
||
|
||
#============================================================================
|
||
# worldline three-pane tree UI (inlined from rewind_tree_view.py; colors follow v2 theme)
|
||
#============================================================================
|
||
LANE_COLORS = [C_BLUE, C_PURPLE, C_GREEN, C_CYAN, C_LAVENDER, C_AMBER]
|
||
|
||
|
||
def lane_color(depth: int) -> str:
|
||
return LANE_COLORS[depth % len(LANE_COLORS)]
|
||
|
||
|
||
KIND_COLOR = {"current": C_GREEN, "rewind": C_AMBER, "origin": C_FG}
|
||
|
||
|
||
def kind_style(kind: str):
|
||
"""(字形, 颜色, 标签):字形/标签取自后端语义,颜色由前端配色决定。
|
||
保留 3 元组形态,render_tree / 详情区调用点无需改动。"""
|
||
return kind_glyph(kind), KIND_COLOR.get(kind, C_FG), kind_label(kind)
|
||
|
||
|
||
@dataclass
|
||
class RenderRow:
|
||
key: int
|
||
depth: int
|
||
text: Text
|
||
node_start: int
|
||
|
||
|
||
def render_tree(ct: CompressedTree, selected_key: int) -> List[RenderRow]:
|
||
rows: List[RenderRow] = []
|
||
sel_depth = ct.disp[selected_key].depth if selected_key in ct.disp else 0
|
||
|
||
def node_style(key: int, depth: int) -> tuple[str, str]:
|
||
d = ct.disp[key]
|
||
base = C_DIM if d.kind == "fold" else kind_style(ct.tree.nodes[d.node_id].kind)[1]
|
||
if key == selected_key:
|
||
return base, f"bold {base} on {C_ALT_BG}"
|
||
if depth == sel_depth:
|
||
return base, base
|
||
return base, C_DIM
|
||
|
||
def dfs(key: int, is_last: bool, prefix_active: List[bool]) -> None:
|
||
d = ct.disp[key]
|
||
line = Text()
|
||
for col, active in enumerate(prefix_active):
|
||
if not active:
|
||
line.append(" ")
|
||
continue
|
||
style = lane_color(col) if col == sel_depth else C_DIM
|
||
line.append("│ ", style=style)
|
||
if d.parent_key is not None:
|
||
connector = "╰─ " if is_last else "├─ "
|
||
cstyle = lane_color(d.depth) if d.depth == sel_depth else C_DIM
|
||
line.append(connector, style=cstyle)
|
||
base, gstyle = node_style(key, d.depth)
|
||
node_start = line.cell_len
|
||
line.append(ct.glyph(key), style=gstyle)
|
||
line.append(" ")
|
||
lbl_style = f"bold {C_FG} on {C_ALT_BG}" if key == selected_key else (
|
||
base if d.depth == sel_depth else C_MUTED)
|
||
line.append(ct.label(key), style=lbl_style)
|
||
_en = ct.end_node(key)
|
||
if getattr(_en, "rw_tag", None):
|
||
line.append(f"({_en.rw_tag})",
|
||
style=C_AMBER if key == selected_key else C_DIM)
|
||
line.append(f" {rel_time(ct.end_node(key).ago)}", style=C_DIM)
|
||
rows.append(RenderRow(key=key, depth=d.depth, text=line, node_start=node_start))
|
||
kids = d.children
|
||
child_prefix = prefix_active + [not is_last]
|
||
for i, ck in enumerate(kids):
|
||
dfs(ck, i == len(kids) - 1, child_prefix)
|
||
|
||
for i, rk in enumerate(ct.roots):
|
||
dfs(rk, i == len(ct.roots) - 1, [])
|
||
return rows
|
||
|
||
# ------------------------------------------------------- Textual 部件
|
||
class ClickableTree(Static):
|
||
class NodeClicked(Message):
|
||
def __init__(self, key: int) -> None:
|
||
self.key = key
|
||
super().__init__()
|
||
|
||
def __init__(self, **kw) -> None:
|
||
super().__init__("", **kw)
|
||
self.rows: List[RenderRow] = []
|
||
|
||
def set_rows(self, rows: List[RenderRow]) -> None:
|
||
self.rows = rows
|
||
body = Text("\n").join(r.text for r in rows) if rows else Text("")
|
||
body.no_wrap = True # 禁折行:窄窗靠横向 viewport,绝不折行(否则连线错位)
|
||
self.update(body)
|
||
|
||
def on_click(self, event) -> None:
|
||
off = event.get_content_offset(self)
|
||
if off is None:
|
||
return
|
||
if 0 <= off.y < len(self.rows) and off.x >= self.rows[off.y].node_start:
|
||
event.stop()
|
||
self.post_message(self.NodeClicked(self.rows[off.y].key))
|
||
|
||
|
||
class RestoreModeScreen(ModalScreen):
|
||
"""CC 式回退模式选择弹窗:在世界线里 Enter 选中节点后弹出,选恢复模式。
|
||
|
||
dismiss 返回 `"both"|"conv"|"code"` 或 None(取消)。`to` 决定文案:
|
||
`at`=在此节点继续(HEAD→该节点);`before`=回到该提问之前。"""
|
||
|
||
CSS = f"""
|
||
RestoreModeScreen {{ align: center middle; }}
|
||
#rmode_box {{
|
||
width: 50; height: auto; padding: 1 2;
|
||
border: round {C_GREEN}; background: {C_SEL_BG};
|
||
}}
|
||
RestoreModeScreen Static {{ background: transparent; }}
|
||
"""
|
||
BINDINGS = [
|
||
Binding("up,k", "up", "↑", priority=True),
|
||
Binding("down,j", "down", "↓", priority=True),
|
||
Binding("enter", "confirm", "确认", priority=True),
|
||
Binding("c", "pick_conv", "仅对话", priority=True),
|
||
Binding("o", "pick_code", "仅代码", priority=True),
|
||
Binding("escape", "cancel", "取消", priority=True),
|
||
]
|
||
_OPTS = [("both", "恢复对话 + 代码"), ("conv", "仅对话"), ("code", "仅代码")]
|
||
|
||
def __init__(self, title: str, to: str) -> None:
|
||
super().__init__()
|
||
self._title = title
|
||
self._to = to
|
||
self.sel = 0
|
||
|
||
def compose(self) -> ComposeResult:
|
||
with Vertical(id="rmode_box"):
|
||
yield Static("", id="rmode_body")
|
||
|
||
def on_mount(self) -> None:
|
||
self._redraw()
|
||
|
||
def _redraw(self) -> None:
|
||
body = Text()
|
||
verb = "在此继续会话" if self._to == "at" else "回到此提问之前"
|
||
body.append(f"{verb}:", style=C_BLUE)
|
||
body.append(f"{ellipsize(self._title, 28)}\n\n", style=f"bold {C_FG}")
|
||
for i, (_, lbl) in enumerate(self._OPTS):
|
||
active = i == self.sel
|
||
body.append("▶ " if active else " ", style=C_GREEN if active else C_DIM)
|
||
body.append(lbl + "\n",
|
||
style=f"bold {C_FG} on {C_ALT_BG}" if active else C_MUTED)
|
||
body.append("\n↑↓ 选择 Enter 确认 c 仅对话 o 仅代码 Esc 取消", style=C_DIM)
|
||
self.query_one("#rmode_body", Static).update(body)
|
||
|
||
def action_up(self) -> None:
|
||
self.sel = (self.sel - 1) % len(self._OPTS); self._redraw()
|
||
|
||
def action_down(self) -> None:
|
||
self.sel = (self.sel + 1) % len(self._OPTS); self._redraw()
|
||
|
||
def action_confirm(self) -> None:
|
||
self.dismiss(self._OPTS[self.sel][0])
|
||
|
||
def action_pick_conv(self) -> None:
|
||
self.dismiss("conv")
|
||
|
||
def action_pick_code(self) -> None:
|
||
self.dismiss("code")
|
||
|
||
def action_cancel(self) -> None:
|
||
self.dismiss(None)
|
||
|
||
|
||
class RewindTreeScreen(ModalScreen):
|
||
"""三栏全屏 checkpoint 浏览器(世界线)。dismiss 结果:
|
||
`{"action":"restore","mode":"both"|"conv"|"code","node":nid,"to":"before"|"at"}`
|
||
或 None(取消)。`to=before`=回到该提问之前(prefill);`to=at`=在该节点继续
|
||
(HEAD→该节点,无 prefill,= tip「当前位置/新分支」)。
|
||
diff / delete 在屏内直接读/改 store(delete 后重建树)。"""
|
||
|
||
CSS = f"""
|
||
RewindTreeScreen {{ background: {C_BG}; }}
|
||
* {{
|
||
scrollbar-background: {C_BG};
|
||
scrollbar-color: {C_BORDER};
|
||
scrollbar-color-hover: {C_DIM};
|
||
scrollbar-color-active: {C_DIM};
|
||
scrollbar-corner-color: {C_BG};
|
||
}}
|
||
/* ModalScreen defaults to height:auto + a centered dialog box, which
|
||
collapses our %/fr-based three-pane layout into a small popup. Force the
|
||
screen to fill the terminal and give the top Horizontal the full height so
|
||
the panes' percentages resolve against a real size again. */
|
||
RewindTreeScreen {{ height: 100%; overflow: hidden; }}
|
||
RewindTreeScreen > Horizontal {{ height: 1fr; width: 100%; }}
|
||
#rw3_left {{
|
||
width: 60%; height: 1fr; border: round {C_BORDER}; border-title-color: {C_GREEN};
|
||
padding: 0 1; overflow-x: auto; overflow-y: auto;
|
||
}}
|
||
#rw3_lefttree {{ width: auto; height: auto; }}
|
||
#rw3_right {{ width: 40%; height: 1fr; }}
|
||
#rw3_rtop {{
|
||
height: 50%; border: round {C_BORDER}; border-title-color: {C_BLUE};
|
||
padding: 0 1;
|
||
}}
|
||
#rw3_rtop_hint {{ height: auto; color: {C_DIM}; }}
|
||
#rw3_rtop_body {{
|
||
height: 1fr; background: transparent; border: none; padding: 0;
|
||
scrollbar-size-vertical: 1; scrollbar-gutter: stable;
|
||
}}
|
||
#rw3_rtop_body > .option-list--option {{ padding: 0; background: transparent; color: {C_FG}; }}
|
||
#rw3_rtop_body > .option-list--option-highlighted {{
|
||
background: {C_SEL_BG}; color: {C_DIM}; text-style: none;
|
||
}}
|
||
#rw3_rtop_body.rt-active > .option-list--option-highlighted {{
|
||
background: {C_ALT_BG}; color: {C_FG}; text-style: bold;
|
||
}}
|
||
#rw3_rbot {{
|
||
height: 1fr; border: round {C_BORDER}; border-title-color: {C_PURPLE};
|
||
padding: 0 1;
|
||
}}
|
||
#rw3_rbot_body {{ height: auto; }}
|
||
#rw3_diff {{ height: 1fr; overflow-y: auto; overflow-x: auto; scrollbar-size-vertical: 1; }}
|
||
#rw3_diff_body {{ height: auto; width: auto; }}
|
||
RewindTreeScreen Static {{ background: transparent; }}
|
||
#rw3_status {{ height: 1; color: {C_DIM}; padding: 0 1; }}
|
||
"""
|
||
BINDINGS = [
|
||
# 上下 = 移动**聚焦**窗口(左树按整树视觉行序 / 右上段内含 tip)。
|
||
Binding("up,k", "up", "↑", priority=True),
|
||
Binding("down,j", "down", "↓", priority=True),
|
||
# Ctrl+↑↓ = 移动**非聚焦**窗口(ctrl 在部分终端不可靠 → PgUp/PgDn、[ ] 兜底)。
|
||
Binding("ctrl+up,pageup,left_square_bracket", "other_up", "非聚焦↑", priority=True),
|
||
Binding("ctrl+down,pagedown,right_square_bracket", "other_down", "非聚焦↓", priority=True),
|
||
# ←/→ = 左树切换层级(列);Tab = 翻转聚焦框(另由 on_key 兜底,见下)。
|
||
Binding("left,h", "level_up", "上层", priority=True),
|
||
Binding("right,l", "level_down", "下层", priority=True),
|
||
Binding("tab", "focus_toggle", "切聚焦", priority=True),
|
||
# Enter:左聚焦 → 进入右侧节点选择;右聚焦 → 弹回退模式窗。
|
||
Binding("enter", "enter", "进入/回退", priority=True),
|
||
Binding("x", "delete", "删除子树"),
|
||
Binding("escape", "cancel", "取消"),
|
||
]
|
||
|
||
def __init__(self, store) -> None:
|
||
super().__init__()
|
||
self.store = store
|
||
self.ct = CompressedTree(tree_from_store(store, __import__("time").time()))
|
||
self._mark_current()
|
||
flat = self.ct.flatten()
|
||
# 打开即聚焦到「当前位置」所在的显示节点(折叠组)。
|
||
self.sel_key = self._disp_key_for_node(self._current_node()) or (flat[0] if flat else 0)
|
||
self.focus_right = False
|
||
# 默认聚焦到「当前位置」(见 _default_seg_idx)。
|
||
self.seg_idx = self._default_seg_idx()
|
||
|
||
# ---- 当前位置:就一个游标。有游标=回退到的那个节点;没有=末端 tip ----
|
||
def _cursor(self):
|
||
"""rewind 游标 = 被截断回退到的那个节点(用户心中的「当前位置」)。集成层在
|
||
rewind 时记到 store._rw_cursor,继续提问时清除。无效/无 → None(当前=末端 tip)。
|
||
|
||
一致性:从「当前位置」再进去要重现当前视图。rewind 到 X→视图=parent(X)前;
|
||
从 X 进去=rewind 到 X=同样视图 ✓。所以当前位置就是 X 本身。"""
|
||
cur = getattr(self.store, "_rw_cursor", None)
|
||
return cur if (cur and cur in self.store.nodes) else None
|
||
|
||
def _current_node(self):
|
||
"""开屏定位用的「当前位置」节点:游标 or HEAD。"""
|
||
return self._cursor() or self.store.head
|
||
|
||
def _mark_current(self) -> None:
|
||
"""◉「当前」只两种落点:有游标→标在游标节点;无游标→不标节点(末端 tip 承载)。
|
||
先撤掉 tree_from_store 给 HEAD 的自动 current。"""
|
||
head, nodes = self.store.head, self.ct.tree.nodes
|
||
if head in nodes:
|
||
nodes[head].kind = self.store.nodes.get(head, {}).get("kind") or "edit"
|
||
cur = self._cursor()
|
||
if cur and cur in nodes:
|
||
nodes[cur].kind = "current"
|
||
|
||
def _default_seg_idx(self) -> int:
|
||
"""▶ 默认落「当前位置」:有游标→游标节点;无游标→末端 tip。"""
|
||
seg = self._current_seg()
|
||
if not seg:
|
||
return 0
|
||
cur = self._cursor()
|
||
if cur:
|
||
idx = seg.index(cur) if cur in seg else len(seg)
|
||
else:
|
||
idx = len(seg)
|
||
return min(idx, self._seg_max_idx(seg)) # 无 tip 的 origin 段:落到末项而非占位项
|
||
|
||
def _disp_key_for_node(self, node_id) -> Optional[int]:
|
||
"""找包含 node_id 的显示节点 key(真实节点本身 / 折叠段含它)。"""
|
||
if node_id is None:
|
||
return None
|
||
for k, d in self.ct.disp.items():
|
||
if d.kind == "real" and d.node_id == node_id:
|
||
return k
|
||
if d.kind == "fold" and node_id in d.seg:
|
||
return k
|
||
return None
|
||
|
||
def _seg_of(self, key) -> List[str]:
|
||
d = self.ct.disp.get(key)
|
||
if d is None:
|
||
return []
|
||
return list(d.seg) if d.kind == "fold" else [d.node_id]
|
||
|
||
def compose(self) -> ComposeResult:
|
||
with Horizontal():
|
||
with ScrollableContainer(id="rw3_left"):
|
||
yield ClickableTree(id="rw3_lefttree")
|
||
with Vertical(id="rw3_right"):
|
||
with Vertical(id="rw3_rtop"):
|
||
yield Static("", id="rw3_rtop_hint")
|
||
yield OptionList(id="rw3_rtop_body")
|
||
with Vertical(id="rw3_rbot"):
|
||
yield Static("", id="rw3_rbot_body")
|
||
with VerticalScroll(id="rw3_diff"):
|
||
yield Static("", id="rw3_diff_body")
|
||
yield Static("", id="rw3_status")
|
||
|
||
def on_mount(self) -> None:
|
||
self.query_one("#rw3_left").border_title = "世界线 · 全局压缩树"
|
||
self.query_one("#rw3_rtop").border_title = "被折叠的节点"
|
||
self.query_one("#rw3_rbot").border_title = "详情 / 操作"
|
||
self.query_one("#rw3_rtop").can_focus = False
|
||
self.query_one("#rw3_rtop_body").can_focus = False # 键盘焦点恒在左树;此列仅受控渲染+鼠标
|
||
self.query_one("#rw3_rtop_hint", Static).update(
|
||
Text("选中提问回退到其之前,末项从当前继续", style=C_DIM))
|
||
# 左栏设为可聚焦并**抓住焦点**:让本 modal 真正持有键盘。否则全部子组件
|
||
# can_focus=False → 焦点留在下层输入框,Tab 会被输入框的命令补全吃掉、屏的
|
||
# on_key 收不到(↑↓ 靠 priority binding 才照常生效)。聚焦框仍由
|
||
# _refresh_focus_frame 手动画(亮青边框),与 Textual 默认 focus 视觉无关。
|
||
left = self.query_one("#rw3_left")
|
||
left.can_focus = True
|
||
left.focus()
|
||
self.refresh_all()
|
||
|
||
def on_resize(self, event) -> None:
|
||
# 栏宽变化 → 重裁 rtop 行宽、重算左树横向跟踪,避免按旧宽度裁剪
|
||
self.refresh_all()
|
||
|
||
# ---- 联动 ----
|
||
def _set_left_selection(self, key: int, keep_focus: bool = False) -> None:
|
||
"""选中左树某显示节点。keep_focus=True 时不夺回聚焦(供 Ctrl 移动非聚焦左树)。
|
||
右上默认选中「当前(绿)记录」或最后一条真实记录(见 _default_seg_idx)。"""
|
||
self.sel_key = key
|
||
if not keep_focus:
|
||
self.focus_right = False
|
||
self.seg_idx = self._default_seg_idx()
|
||
self.refresh_all()
|
||
|
||
def refresh_all(self) -> None:
|
||
rows = render_tree(self.ct, self.sel_key)
|
||
self.query_one("#rw3_lefttree", ClickableTree).set_rows(rows)
|
||
self._refresh_rtop()
|
||
self._refresh_rbot()
|
||
self._refresh_status()
|
||
self._refresh_focus_frame()
|
||
sel_idx = next((i for i, r in enumerate(rows) if r.key == self.sel_key), 0)
|
||
self._follow_left(rows, sel_idx)
|
||
|
||
def _refresh_focus_frame(self) -> None:
|
||
"""聚焦框:聚焦窗口边框高亮(亮青),另一个用普通边框色。"""
|
||
try:
|
||
left = self.query_one("#rw3_left")
|
||
rtop = self.query_one("#rw3_rtop")
|
||
left.styles.border = ("round", C_BORDER if self.focus_right else C_CYAN)
|
||
rtop.styles.border = ("round", C_CYAN if self.focus_right else C_BORDER)
|
||
except Exception:
|
||
pass
|
||
|
||
def _follow_left(self, rows, sel_idx) -> None:
|
||
self._ensure_line_visible("#rw3_left", sel_idx)
|
||
try:
|
||
c = self.query_one("#rw3_left")
|
||
w = c.scrollable_size.width
|
||
if w <= 0 or sel_idx >= len(rows):
|
||
return
|
||
sx = int(c.scroll_offset.x)
|
||
x0 = rows[sel_idx].node_start
|
||
x1 = rows[sel_idx].text.cell_len
|
||
if x0 < sx or x0 >= sx + w:
|
||
c.scroll_to(x=max(0, x0 - 2), animate=False)
|
||
elif x1 > sx + w:
|
||
c.scroll_to(x=max(0, min(x0 - 2, x1 - w + 1)), animate=False)
|
||
except Exception:
|
||
pass
|
||
|
||
def _ensure_line_visible(self, selector, y) -> None:
|
||
try:
|
||
c = self.query_one(selector)
|
||
h = c.scrollable_size.height
|
||
if h <= 0:
|
||
return
|
||
sy = int(c.scroll_offset.y)
|
||
if y < sy:
|
||
c.scroll_to(y=y, animate=False)
|
||
elif y >= sy + h:
|
||
c.scroll_to(y=y - h + 1, animate=False)
|
||
except Exception:
|
||
pass
|
||
|
||
def _current_seg(self) -> List[str]:
|
||
return self._seg_of(self.sel_key)
|
||
|
||
def _tip_kind(self) -> str:
|
||
"""末尾 tip 的语义:HEAD 是叶 且就是本段末端 → current(当前位置·自此继续);
|
||
否则 → new(自此另起一条对话)。HEAD 有后代(rewind 未继续)时,当前位置标在那个
|
||
中间节点本身(◉),tip 一律是 new。"""
|
||
if self._cursor():
|
||
return "new" # 当前在游标节点(◉),末端一律 new
|
||
seg = self._current_seg()
|
||
# 无游标:本段末端就是 HEAD → 末端 tip 即「当前位置·自此继续」。
|
||
return "current" if (seg and seg[-1] == self.store.head) else "new"
|
||
|
||
def _seg_has_tip(self, seg=None) -> bool:
|
||
"""段末是否显示「自此继续」占位项。会话起点(origin)不显示:选中 origin 项本身
|
||
即回到空起点,与「自此继续」语义重复。"""
|
||
seg = self._current_seg() if seg is None else seg
|
||
return not (seg and self.ct.tree.nodes[seg[-1]].kind == "origin")
|
||
|
||
def _seg_max_idx(self, seg=None) -> int:
|
||
"""seg_idx 上界:有 tip → len(seg)(可停在占位项);无 tip → 最后一条真实项。"""
|
||
seg = self._current_seg() if seg is None else seg
|
||
return len(seg) if self._seg_has_tip(seg) else max(0, len(seg) - 1)
|
||
|
||
def _refresh_rtop(self) -> None:
|
||
# 受控 OptionList:option 序号 == seg 序号(tip = len(seg))。选中态由
|
||
# OptionList 的 highlighted 整项高亮 + 自动滚动到高亮负责——折行时整块选中、
|
||
# 滑窗跟踪与点击映射都由控件保证,不再靠「1 行 = 1 项」的脆弱手写逻辑。
|
||
seg = self._current_seg()
|
||
ol = self.query_one("#rw3_rtop_body", OptionList)
|
||
ol.set_class(self.focus_right, "rt-active") # 聚焦右栏 → 高亮更亮
|
||
opts = [Option(self._rtop_line(nid)) for nid in seg]
|
||
if self._seg_has_tip(seg):
|
||
opts.append(Option(self._rtop_tip_line()))
|
||
ol.clear_options()
|
||
if opts:
|
||
ol.add_options(opts)
|
||
ol.highlighted = max(0, min(self.seg_idx, len(opts) - 1))
|
||
|
||
def _rtop_line(self, nid) -> Text:
|
||
"""段内节点的一行(不含选中态;高亮由 OptionList 绘制)。"""
|
||
n = self.ct.tree.nodes[nid]
|
||
g, gs, _ = kind_style(n.kind)
|
||
line = Text()
|
||
line.append(f"{g} ", style=gs)
|
||
line.append(ellipsize(n.title, 40))
|
||
if n.rw_tag:
|
||
line.append(f"({n.rw_tag})", style=C_AMBER)
|
||
line.append(f" {rel_time(n.ago)}", style=C_DIM)
|
||
return line
|
||
|
||
def _rtop_tip_line(self) -> Text:
|
||
"""末尾占位项(当前位置 / 自此继续)。"""
|
||
tk = self._tip_kind()
|
||
label = "(当前位置 · 自此继续)" if tk == "current" else "(自此继续会话)"
|
||
glyph = "◉" if tk == "current" else "●"
|
||
t = Text()
|
||
t.append(f"{glyph} ", style=C_GREEN if tk == "current" else C_DIM)
|
||
t.append(label, style=C_GREEN if tk == "current" else C_FG)
|
||
return t
|
||
|
||
def _target_node(self) -> Optional[str]:
|
||
"""diff/delete 的目标节点:右上选中段内节点 > 当前显示节点的叶(tip 也归叶)。"""
|
||
seg = self._current_seg()
|
||
if self.focus_right and self.seg_idx < len(seg):
|
||
return seg[self.seg_idx]
|
||
return seg[-1] if seg else None
|
||
|
||
def _restore_spec(self) -> "tuple[Optional[str], str]":
|
||
"""恢复目标 (node_id, to):
|
||
- 段内节点(seg_idx<len) → ('before') 回到该提问之前 + prefill;
|
||
- tip(seg_idx==len) → ('at') 在叶继续(HEAD→叶,无 prefill)。"""
|
||
seg = self._current_seg()
|
||
if not seg:
|
||
return None, "at"
|
||
if self.focus_right and self.seg_idx < len(seg):
|
||
return seg[self.seg_idx], "before"
|
||
return seg[-1], "at"
|
||
|
||
def _refresh_rbot(self) -> None:
|
||
seg = self._current_seg()
|
||
on_tip = self.seg_idx >= len(seg)
|
||
body = Text()
|
||
if on_tip:
|
||
tk = self._tip_kind()
|
||
tail = self.ct.tree.nodes[seg[-1]] if seg else None
|
||
if tk == "current":
|
||
body.append("(当前位置)\n", style=f"bold {C_GREEN}")
|
||
body.append(" 已处于该对话最新处,发送消息即可继续。\n", style=C_MUTED)
|
||
else:
|
||
body.append("(自此继续会话)\n", style=f"bold {C_FG}")
|
||
body.append(" 从这条记录之后继续对话。\n", style=C_MUTED)
|
||
if tail is not None:
|
||
body.append(f" 续接于 {ellipsize(tail.title, 30)}\n", style=C_MUTED)
|
||
else:
|
||
nid = seg[self.seg_idx]
|
||
n = self.ct.tree.nodes[nid]
|
||
_, color, kind_label = kind_style(n.kind)
|
||
body.append(f"{n.title}\n", style=f"bold {color}")
|
||
if kind_label:
|
||
body.append(f" 类型 {kind_label}\n", style=C_MUTED)
|
||
body.append(f" 改动文件 {files_summary(n.files)}\n", style=C_MUTED)
|
||
|
||
body.append("\n(回退前自动保存还原点)", style=C_DIM)
|
||
self.query_one("#rw3_rbot_body", Static).update(body)
|
||
# 下方可滚动 diff 视窗:选中节点 vs 父节点(上一个状态)的逐行改动。
|
||
self._refresh_diff(None if on_tip else seg[self.seg_idx])
|
||
|
||
_DIFF_MAX_LINES = 500 # diff 视窗最多渲染这么多行,防超长文件卡渲染
|
||
|
||
def _render_node_diff(self, node_id):
|
||
"""选中节点 vs 父节点的逐行 diff。返回 (Text, 总增行, 总删行)。"""
|
||
from difflib import SequenceMatcher
|
||
out = Text()
|
||
ins = dele = 0
|
||
emitted = 0
|
||
capped = False
|
||
for fi, f in enumerate(self.store.node_diff(node_id)):
|
||
a, b = f["old"].splitlines(), f["new"].splitlines()
|
||
groups = SequenceMatcher(None, a, b, autojunk=False).get_grouped_opcodes(3)
|
||
if not groups:
|
||
continue
|
||
if out:
|
||
out.append("\n")
|
||
out.append(f"{f['rel']}\n", style=f"bold {C_BLUE}")
|
||
for gi, group in enumerate(groups):
|
||
if gi:
|
||
out.append(" ⋮\n", style=C_DIM)
|
||
for tag, i1, i2, j1, j2 in group:
|
||
if tag == "equal":
|
||
for k in range(i1, i2):
|
||
if emitted < self._DIFF_MAX_LINES:
|
||
out.append(f" {a[k]}\n", style=C_MUTED); emitted += 1
|
||
else:
|
||
capped = True
|
||
else:
|
||
for k in range(i1, i2):
|
||
dele += 1
|
||
if emitted < self._DIFF_MAX_LINES:
|
||
out.append(f" - {a[k]}\n", style=C_RED); emitted += 1
|
||
else:
|
||
capped = True
|
||
for k in range(j1, j2):
|
||
ins += 1
|
||
if emitted < self._DIFF_MAX_LINES:
|
||
out.append(f" + {b[k]}\n", style=C_GREEN); emitted += 1
|
||
else:
|
||
capped = True
|
||
if capped:
|
||
out.append(f" …(已截断,仅显示前 {self._DIFF_MAX_LINES} 行)\n", style=C_AMBER)
|
||
return out, ins, dele
|
||
|
||
def _refresh_diff(self, node_id) -> None:
|
||
dbody = Text()
|
||
if node_id is None: # 占位项(自此继续/当前位置):diff 视窗留空
|
||
pass
|
||
else:
|
||
diff, ins, dele = self._render_node_diff(node_id)
|
||
dbody.append(f"+{ins} ", style=f"bold {C_GREEN}")
|
||
dbody.append(f"-{dele}", style=f"bold {C_RED}")
|
||
dbody.append(" 本次改动(对比上一个状态)\n", style=C_DIM)
|
||
dbody.append(diff if (ins or dele) else Text(" 本次无文件改动(仅对话)", style=C_DIM))
|
||
self.query_one("#rw3_diff_body", Static).update(dbody)
|
||
try:
|
||
self.query_one("#rw3_diff").scroll_home(animate=False) # 切节点后回到 diff 顶部
|
||
except Exception:
|
||
pass
|
||
|
||
def _refresh_status(self) -> None:
|
||
enter = "Enter 回退到此" if self.focus_right else "Enter 进入右栏"
|
||
t = Text(style=C_DIM)
|
||
t.append(" ↑↓ 移动 · Ctrl+↑↓ 移动另一栏 · ←→ 切换层级 · Tab 切换焦点 · ")
|
||
t.append(enter, style=C_FG)
|
||
t.append(" · x 删除节点 · Esc 关闭")
|
||
self.query_one("#rw3_status", Static).update(t)
|
||
|
||
# ---- 导航:上下=聚焦窗 / Ctrl上下=非聚焦窗 / Tab·←→=切聚焦 ----
|
||
def action_up(self) -> None:
|
||
if self.focus_right: self._move_right(-1)
|
||
else: self._move_left(-1, keep_focus=False)
|
||
|
||
def action_down(self) -> None:
|
||
if self.focus_right: self._move_right(+1)
|
||
else: self._move_left(+1, keep_focus=False)
|
||
|
||
def action_other_up(self) -> None:
|
||
if self.focus_right: self._move_left(-1, keep_focus=True)
|
||
else: self._move_right(-1)
|
||
|
||
def action_other_down(self) -> None:
|
||
if self.focus_right: self._move_left(+1, keep_focus=True)
|
||
else: self._move_right(+1)
|
||
|
||
def _move_left(self, delta: int, keep_focus: bool) -> None:
|
||
order = self.ct.flatten()
|
||
if self.sel_key not in order:
|
||
return
|
||
i = order.index(self.sel_key)
|
||
j = max(0, min(i + delta, len(order) - 1))
|
||
if j != i:
|
||
self._set_left_selection(order[j], keep_focus=keep_focus)
|
||
|
||
def _move_right(self, delta: int) -> None:
|
||
n = self._seg_max_idx() # 索引 0..n,n=tip(无 tip 的 origin 段则到末项)
|
||
self.seg_idx = max(0, min(self.seg_idx + delta, n))
|
||
self._refresh_rtop(); self._refresh_rbot(); self._refresh_status()
|
||
|
||
def _set_focus(self, right: bool) -> None:
|
||
self.focus_right = right
|
||
if right:
|
||
self.seg_idx = min(self.seg_idx, self._seg_max_idx()) # 越界落到 tip(或末项)
|
||
self._refresh_rtop(); self._refresh_rbot(); self._refresh_status()
|
||
self._refresh_focus_frame()
|
||
|
||
def action_focus_toggle(self) -> None:
|
||
self._set_focus(not self.focus_right)
|
||
|
||
def on_key(self, event) -> None:
|
||
# Tab 兜底:真终端里 Tab 会被 Textual 的焦点遍历系统吃掉(headless 正常),
|
||
# 故在 on_key 直接拦截,prevent_default 阻止默认焦点移动。
|
||
if event.key in ("tab", "shift+tab"):
|
||
event.prevent_default()
|
||
event.stop()
|
||
self.action_focus_toggle()
|
||
|
||
def _order(self):
|
||
return _order_depths(self.ct)
|
||
|
||
def action_level_up(self) -> None:
|
||
# ←:左树切到上一层级(列);回到左聚焦。
|
||
self._set_left_selection(nearest_depth_node(self._order(), self.sel_key, -1))
|
||
|
||
def action_level_down(self) -> None:
|
||
self._set_left_selection(nearest_depth_node(self._order(), self.sel_key, +1))
|
||
|
||
def action_enter(self) -> None:
|
||
# 左聚焦:进入右侧节点选择;右聚焦:弹回退模式窗。
|
||
if not self.focus_right:
|
||
self._set_focus(True)
|
||
else:
|
||
self._open_restore_popup()
|
||
|
||
# ---- 操作 actions ----
|
||
def _open_restore_popup(self) -> None:
|
||
"""右聚焦时 Enter:弹 CC 式回退模式窗,选定后再 dismiss(带 mode + to)。"""
|
||
node_id, to = self._restore_spec()
|
||
if node_id is None:
|
||
return
|
||
self._pending = (node_id, to)
|
||
title = self.ct.tree.nodes[node_id].title if node_id in self.ct.tree.nodes else ""
|
||
self.app.push_screen(RestoreModeScreen(title, to), self._on_mode_picked)
|
||
|
||
def _on_mode_picked(self, mode) -> None:
|
||
if not mode:
|
||
return
|
||
node_id, to = getattr(self, "_pending", (None, "at"))
|
||
if node_id is None:
|
||
return
|
||
self.dismiss({"action": "restore", "mode": mode, "node": node_id, "to": to})
|
||
|
||
def action_delete(self) -> None:
|
||
tgt = self._target_node()
|
||
if tgt is None or tgt == self.store.root_id:
|
||
return # 不删根
|
||
try:
|
||
self.store.delete_subtree(tgt)
|
||
except Exception:
|
||
return
|
||
# 重建压缩树;选中落回当前位置所在显示节点
|
||
self.ct = CompressedTree(tree_from_store(self.store, __import__("time").time()))
|
||
self._mark_current()
|
||
flat = self.ct.flatten()
|
||
if not flat:
|
||
self.dismiss(None) # 树空了
|
||
return
|
||
self.sel_key = self._disp_key_for_node(self._current_node()) or flat[0]
|
||
self.focus_right = False
|
||
self.seg_idx = self._default_seg_idx()
|
||
self.refresh_all()
|
||
|
||
def action_cancel(self) -> None:
|
||
self.dismiss(None)
|
||
|
||
# ---- 点击 ----
|
||
def on_click(self, event) -> None:
|
||
# 点击不同区域切聚焦框:点到节点/条目时各自 handler 已 stop 并设好聚焦,
|
||
# 这里兜住「点到面板空白处」也能切聚焦。
|
||
try:
|
||
x, y = event.screen_x, event.screen_y
|
||
if self.query_one("#rw3_left").region.contains(x, y):
|
||
if self.focus_right:
|
||
self._set_focus(False)
|
||
elif self.query_one("#rw3_rtop").region.contains(x, y):
|
||
if not self.focus_right:
|
||
self._set_focus(True)
|
||
except Exception:
|
||
pass
|
||
|
||
def on_clickable_tree_node_clicked(self, msg) -> None:
|
||
self._set_left_selection(msg.key)
|
||
|
||
def on_option_list_option_selected(self, ev) -> None:
|
||
# 鼠标点击右栏某项 → 选中该 seg(不触发回退;回退走 Enter/action_enter)。
|
||
if ev.option_list.id != "rw3_rtop_body":
|
||
return
|
||
self.focus_right = True
|
||
self.seg_idx = max(0, min(ev.option_index, self._seg_max_idx()))
|
||
self._refresh_rtop(); self._refresh_rbot(); self._refresh_status()
|
||
self._refresh_focus_frame()
|
||
|
||
|
||
def main(argv: Optional[list[str]] = None) -> int:
|
||
build_arg_parser().parse_args(argv)
|
||
_warn_mintty()
|
||
GenericAgentTUI().run()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|