Files
2026-07-13 13:04:19 +08:00

8599 lines
385 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 effortoff 清除;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/tempfile_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: # 绑 workspacebase + 父目录(短)
# 目录候选末尾带 '/',先剥掉再拆 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 cronsche_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:
# 受控 OptionListoption 序号 == 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())