Files
GenericAgent/frontends/tuiapp_v2.py
T
nianyucatfish da6a4ba558 fix(tui-v2): copy via native clipboard, OSC 52 only over SSH (#581)
Ctrl+C / selection copy fell back to Textual's OSC 52 escape on Windows/Linux.
Some terminals (notably VSCode's integrated terminal) decode the OSC 52 base64
payload as latin-1 instead of UTF-8, so non-ASCII copied text lands in the
clipboard as mojibake (e.g. 需要 -> éè¦). Classic Windows conhost ignores
OSC 52 clipboard writes entirely, so copy was a silent no-op there (incl.
Windows Server).

Prefer the OS-native clipboard (_copy_to_clipboard: win32 CF_UNICODETEXT /
pbcopy / wl-copy / xclip), which writes real Unicode and bypasses the terminal.
Keep OSC 52 only as a fallback, and force it under SSH (SSH_CONNECTION /
SSH_TTY) where the native call would write the remote host's clipboard instead
of the user's local one.
2026-06-08 10:47:29 +08:00

6057 lines
262 KiB
Python
Raw 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, 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"
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.
"""
return any("\u2500" <= ch <= "\u257f" for ch in line)
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:
content = nt
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:
content = nt
indent = 0
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
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
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
# 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:
r = subprocess.run(cmd, input=input, 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. */
#planbar {
display: none;
height: 5;
max-height: 5;
background: $ga-sel-bg;
padding: 0 1;
margin: 0 0 1 0;
border-left: thick $ga-green;
}
/* `└ 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)
# 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)
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 = ""
# 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);
# `/continue` bumps to `len(messages)` so old plan cards don't resurrect.
plan_scan_baseline: int = 0
# `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
agent = GenericAgent()
agent.inc_out = True
return agent
# ---------- commands ----------
COMMANDS = [
("/help", "", "显示帮助"),
("/status", "", "查看会话状态"),
("/sessions", "", "列出所有会话"),
("/new", "[name]", "新建并切换到新会话"),
("/switch", "<id|name>", "切换到指定会话"),
("/close", "", "关闭当前会话"),
("/rename", "<name>", "重命名当前会话(持久化)"),
("/branch", "[name]", "从当前会话分支"),
("/rewind", "[n]", "回退最近 n 轮"),
("/clear", "", "清空显示(不动 LLM 历史)"),
("/stop", "", "中止当前任务"),
("/llm", "[n]", "查看 / 切换模型"),
("/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]", "列出 / 恢复历史会话"),
("/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__(*options, **kwargs)
self.msg = msg
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([Option(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 basename of `value` when it looks like a path, 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:
meta = meta + "\n" + os.path.basename(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="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, "(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"):
try:
self.picker.action_select()
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)."""
try: self._suppress_palette_next_change()
except Exception: pass
self.text = stashed
try:
self.cursor_location = self.document.end
except Exception:
pass
try: self.app._resize_input(self)
except Exception: pass
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 action_paste(self) -> None:
if self.read_only or 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._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
if self._paste_file_from_clipboard():
event.stop(); event.prevent_default(); 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):
event.stop(); event.prevent_default(); return
self._insert_paste_text(event.text)
event.stop(); event.prevent_default()
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) -> 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()
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()
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,
"stop": self._cmd_stop, "llm": self._cmd_llm, "export": self._cmd_export,
"restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review,
"continue": self._cmd_continue, "cost": self._cmd_cost,
"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")
yield Static("", id="planbar")
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
self.add_session("main")
self._system(f"Welcome to GenericAgent TUI. 按 / 唤起命令面板,{fmt_key('ctrl+n')} 新建会话。")
# CSS `#planbar { display: none }` keeps it hidden by default —
# the renderer flips it on once 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
sess = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
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._refresh_all()
return sess
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:
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:
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]
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:
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()
else:
self._hide_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
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:
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}"
# 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
display = (result_text or label).strip() or label
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
# 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):
sess = self.current
if sess.status == "running":
self._system("Cannot rewind while running. /stop first."); return
turns = self._rewindable_turns()
if not turns:
self._system("No rewindable turns."); return
if args:
try: n = int(args[0])
except ValueError: self._system("Usage: /rewind <n>"); return
if n < 1 or n > len(turns):
self._system(f"Invalid: 1-{len(turns)}"); return
self._system(self._do_rewind(n))
return
LIMIT = 20
recent = list(reversed(turns))[:LIMIT]
choices = []
for offset, (_, prev) in enumerate(recent, 1):
preview = (prev or "(空)").replace("\n", " ").strip()[:60]
choices.append((f"回退 {offset} 轮 · {preview}", offset))
head = "选择回退到的轮次 (↑/↓ 移动,→/Enter 确认,Esc 取消)"
if len(turns) > LIMIT:
head += f" [仅显示最近 {LIMIT}/{len(turns)}]"
msg = ChatMessage(
role="system", content=head, kind="choice", choices=choices,
on_select=lambda v: self._do_rewind(v),
)
sess.messages.append(msg)
self._refresh_messages()
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:
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
last_user_text = next((m.content for m in reversed(sess.messages)
if m.role == "user"), None)
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. Only when the box is empty (don't
# clobber a half-typed follow-up). Agent history is untouched — a
# resend duplicates the turn in LLM context; `/rewind 1` is the
# manual escape.
if last_user_text:
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}"
# ---------------- 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 _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_pid=os.getpid())
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_pid=os.getpid())
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:
sess = self.current
from continue_cmd import reset_conversation, restore
try:
reset_conversation(sess.agent, message=None)
result, ok = restore(sess.agent, path)
except Exception as e:
msg = f"❌ 恢复失败: {e}"
self._system(msg); return msg
if not ok:
self._system(result); return result
# Mirror the source transcript into this agent's own log file so a
# future /continue resolves the merged history under the migrated name.
current_log = getattr(sess.agent, "log_path", "") or ""
if current_log and path != current_log:
try:
import shutil
shutil.copyfile(path, current_log)
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
self._plan_mtime.pop(sess.agent_id, None)
for h in continue_extract(path):
sess.messages.append(ChatMessage(role=h["role"], content=h["content"]))
# baseline=0 lets the scanner see prior plan_X/plan.md refs so an
# unfinished plan resumes after /continue. Only when the restored
# plan.md is already all-done do we push baseline past history to
# suppress the stale ✓ card.
sess.plan_scan_baseline = 0
import plan_state
pp = plan_state.resolve_path(sess.agent, messages=sess.messages)
if pp and os.path.isfile(pp):
try:
with open(pp, encoding="utf-8", errors="replace") as f:
items = plan_state.extract(f.read())
if items and plan_state.is_complete(items):
sess.plan_scan_baseline = len(sess.messages)
except OSError:
pass
try:
import session_names
nm = session_names.name_for(path)
if nm:
sess.name = nm
if current_log:
session_names.migrate(path, current_log)
except Exception:
pass
self._remount_current_session()
self._refresh_all()
self.call_after_refresh(_finish)
return result.splitlines()[0] if result else "✅ 已恢复"
def _cmd_cost(self, args, raw):
try:
import cost_tracker
except Exception as e:
self._system(f"❌ cost_tracker 不可用: {e}"); return
show_all = bool(args) and args[0].lower() == "all"
def _k(n: int) -> str:
# Human-readable number: 12.3K / 1.45M / 167 — keeps the column
# narrow so the layout doesn't shift between idle and 200K-deep sessions.
n = int(n)
if n < 1000: return f"{n}"
if n < 1_000_000:
v = n / 1000.0
return f"{v:.1f}K" if v < 100 else f"{int(v)}K"
v = n / 1_000_000.0
return f"{v:.2f}M" if v < 100 else f"{int(v)}M"
def _elapsed(secs: float) -> str:
s = int(secs)
if s < 60: return f"{s}s"
if s < 3600: return f"{s // 60}m {s % 60:02d}s"
h, rem = divmod(s, 3600); m, sec = divmod(rem, 60)
return f"{h}h {m:02d}m {sec:02d}s"
def _section(sid: int, sess, t) -> list[str]:
try: model = sess.agent.get_llm_name(model=True) or "?"
except Exception: model = "?"
total = t.total_tokens()
inp_side = t.total_input_side()
ls = []
ls.append(f"#{sid} {sess.name} · model: {model} · elapsed: {_elapsed(t.elapsed_seconds())}")
ls.append(
f" Token usage: {_k(total):>7} total "
f"({_k(inp_side)} input + {_k(t.output)} output)"
)
if t.cache_read or t.cache_create:
ls.append(
f" Cache: {_k(t.cache_read):>7} read · "
f"{_k(t.cache_create)} created · "
f"{t.cache_hit_rate():.1f}% hit"
)
try: backend = sess.agent.llmclient.backend
except Exception: backend = None
cap = cost_tracker.context_window_chars(backend) if backend else 0
used = cost_tracker.current_input_chars(backend) if backend else 0
if cap > 0:
pct_left = max(0.0, (cap - used) / cap * 100.0)
ls.append(
f" Context window: {pct_left:>5.0f}% left "
f"({_k(used)} chars used / {_k(cap)} cap)"
)
ls.append(f" Requests: {t.requests:>7}")
return ls
# Scope subagent logs to this TUI run so prior-session logs don't bleed in.
try: sub = cost_tracker.scan_subagent_logs(since=getattr(self, "_started_at", 0.0))
except Exception: sub = None
def _sub_section() -> list[str]:
if not sub or sub.total_tokens() == 0: return []
ls = ["", f"subagents (扫描 temp/*/stdout.log)"]
ls.append(
f" Token usage: {_k(sub.total_tokens()):>7} total "
f"({_k(sub.total_input_side())} input + {_k(sub.output)} output)"
)
if sub.cache_read or sub.cache_create:
ls.append(
f" Cache: {_k(sub.cache_read):>7} read · "
f"{_k(sub.cache_create)} created · "
f"{sub.cache_hit_rate():.1f}% hit"
)
ls.append(f" Requests: {sub.requests:>7}")
return ls
lines: list[str] = []
if show_all:
trackers = cost_tracker.all_trackers()
if not trackers and not (sub and sub.total_tokens()):
lines = ["✦ Token usage", " (尚无任何 LLM 调用记录)"]
else:
# Resolve each thread back to a session if we still know it; otherwise
# surface the bare thread name (the session may have been Ctrl+D'd).
by_name = {(s.thread.name if s.thread else f"ga-tui-agent-{sid}"): (sid, s)
for sid, s in self.sessions.items()}
lines.append("✦ Token usage (all sessions)")
first = True
for tname in sorted(trackers):
if not first: lines.append("")
first = False
if tname in by_name:
sid, s = by_name[tname]
lines += _section(sid, s, trackers[tname])
else:
t = trackers[tname]
lines.append(f"[{tname}] · elapsed: {_elapsed(t.elapsed_seconds())}")
total = t.total_tokens()
lines.append(
f" Token usage: {_k(total):>7} total "
f"({_k(t.total_input_side())} input + {_k(t.output)} output)"
)
lines.append(f" Requests: {t.requests:>7}")
lines += _sub_section()
else:
sess = self.current
tname = sess.thread.name if sess.thread else f"ga-tui-agent-{sess.agent_id}"
t = cost_tracker.get(tname)
lines.append("✦ Token usage")
lines += _section(sess.agent_id, sess, t)
lines += _sub_section()
self._system("\n".join(lines))
def _cmd_export(self, args, raw):
"""Forms:
/export → 3-choice picker (clip/all/file with timestamp)
/export clip|copy last reply wrapped in code block
/export all full log file path
/export file [name] export last reply to file
/export <name> legacy: equivalent to /export file <name>
"""
sub = args[0].lower() if args else ""
if not sub:
choices = [
("📋 clip — 复制最后一轮回复(代码块包裹,便于粘贴)", "clip"),
("📂 all — 显示完整日志文件路径", "all"),
("💾 file — 导出到文件(提交前可编辑文件名)", "file"),
]
msg = ChatMessage(
role="system",
content="选择导出方式 (↑/↓ 移动,→/Enter 确认,Esc 取消)",
kind="choice",
choices=choices,
on_select=lambda v: self._prompt_export_filename() if v == "file" else self._do_export(v),
)
self.current.messages.append(msg)
self._refresh_messages()
return
if sub == "file":
custom = " ".join(args[1:]).strip() or None
self._system(self._do_export("file", custom))
return
if sub == "all":
self._system(self._do_export("all"))
return
if sub in ("clip", "copy"):
self._system(self._do_export("clip"))
return
self._system(self._do_export("file", " ".join(args).strip()))
def _prompt_export_filename(self) -> str:
from datetime import datetime as _dt
default = "export-" + _dt.now().strftime("%Y%m%d-%H%M%S") + ".md"
text = "/export " + default
def _fill():
try:
inp = self.query_one("#input", InputArea)
self._suppress_palette_open = True
inp.text = text
inp.move_cursor((0, len(text)))
inp.focus()
self._resize_input(inp)
except Exception:
pass
self.call_after_refresh(_fill)
return "✏️ 已填入默认文件名,按 Enter 确认或先编辑"
def _do_export(self, kind: str, filename: str | None = None) -> str:
sess = self.current
try:
if kind == "all":
log = getattr(sess.agent, "log_path", "")
if log and os.path.isfile(log):
return f"📂 完整日志:\n{log}"
return "❌ 尚无日志文件"
text = last_assistant_text(sess.agent)
if not text:
return "❌ 还没有可导出的回复"
if kind == "clip":
payload = wrap_for_clipboard(text)
if _copy_to_clipboard(payload):
return "✅ 已复制最后一轮回复到剪贴板"
return f"⚠️ 自动复制失败,请手动复制:\n\n{payload}"
if kind == "file":
if not filename:
from datetime import datetime as _dt
filename = "export-" + _dt.now().strftime("%Y%m%d-%H%M%S") + ".md"
path = export_to_temp(text, filename)
return f"✅ 已导出: {path}"
return f"❌ 未知选项: {kind}"
except Exception as e:
return f"❌ 导出失败: {type(e).__name__}: {e}"
def _cmd_restore(self, args, raw):
sess = self.current
try:
info, err = format_restore()
except Exception as e:
self._system(f"❌ 恢复失败: {e}"); return
if err:
self._system(err); return
restored, fname, count = info
try:
sess.agent.abort()
sess.agent.history.extend(restored)
self._system(f"✅ 已恢复 {count} 轮上下文,来源: {fname}")
except Exception as e:
self._system(f"❌ 注入失败: {e}")
def _cmd_quit(self, args, raw):
self._reset_terminal_title()
self.exit()
# ---------------- slash_cmds bundle ----------------
def _cmd_slash_inject(self, args, raw):
"""`/update /autorun /morphling /goal /hive /conductor` → prompt
injection. We strip the leading slash command from `raw`, hand the
tail to `slash_cmds.prompt_for`, and re-enter `submit_user_message`
so the agent sees it as a normal user turn (display bubble still
shows the original `/cmd ...` for clarity).
"""
from frontends import slash_cmds
text = (raw or "").strip()
# Pull just the leading token to look up the prompt builder.
head = text.split(None, 1)[0] if text else ""
if not head.startswith("/"):
self._system("❌ /slash 命令解析失败"); return
tail = text[len(head):].strip()
prompt = slash_cmds.prompt_for(head, tail)
if prompt is None:
self._system(f"❌ 未知命令 {head}"); return
sess = self.current
if sess.status == "running":
self._system(f"#{sess.agent_id} 正在跑,/stop 后再发。")
return
# Keep the user's original `/cmd ...` as the visible bubble so the
# transcript stays self-explanatory; the agent sees the long prompt.
self.submit_user_message(prompt, display_text=text or head)
def _cmd_scheduler(self, args, raw):
"""`/scheduler` lists reflect/*.py + sche_tasks/*.json and starts the
chosen reflect task(s). Usage:
/scheduler — interactive multi-select picker
(Space toggles, Enter launches every
checked task in one batch)
/scheduler start <name> — start one reflect task by stem (CLI)
/scheduler start a,b,c — start several at once (CSV, CLI)
Cron-style sche_tasks/*.json are read-only here; the launch.pyw
scheduler daemon already owns them.
"""
from frontends import slash_cmds
body = " ".join(args).strip()
parts = body.split(None, 1)
head = parts[0].lower() if parts else ""
if head in ("start", "run"):
names = (parts[1] if len(parts) > 1 else "").replace(",", " ").split()
if not names:
self._system("Usage: /scheduler start <reflect_name>[,<name2>...]"); return
self._launch_service_batch(names)
return
# Default: surface a MultiChoiceList picker for reflect/*.py so the
# user can tick several tasks at once (Space toggle, Enter submit).
# sche_tasks/*.json are read-only — shown below as a system advisory
# so the user still has visibility, but they can't be launched here.
services = slash_cmds.list_launchable_services()
if not services:
self._system("📋 没有可启动的服务(reflect/*.py 与 frontends/*app*.py 均为空)"); return
# bug#4: query what's actually alive *now* (psutil cmdline scan) so the
# picker can (a) pre-tick running services and (b) tag them visibly.
# Unticking a pre-ticked row therefore reads as "stop this service".
try:
running = slash_cmds.running_services() # {name: pid}
except Exception:
running = {}
# Mirror hub.pyw: reflect tasks + frontend apps, grouped by kind so the
# picker reads like the GUI launcher. Picker value = hub-style path.
choices = []
preselected = [] # indices into `choices` that are running right now
for kind in ("reflect", "frontend"):
for s in (svc for svc in services if svc["kind"] == kind):
doc = f" — {s['doc']}" if s["doc"] else ""
is_running = s["name"] in running
tag = " · running" if is_running else ""
if is_running:
preselected.append(len(choices))
label = f"{s['name']}{doc}{tag}"
# Functional green for already-running rows so they're
# distinguishable even when the [x] tick is small or the
# row scrolls off the visible window.
if is_running:
from rich.text import Text as _T
rich_label = _T(label, style="green")
choices.append((rich_label, s["name"]))
else:
choices.append((label, s["name"]))
sess = self.current
hint = ("选择要启动的服务(与 hub.pyw 一致:reflect 任务 + frontend 应用)"
" Space 勾选 · Enter 提交 · Esc 取消 — 提交后还需二次确认")
if running:
hint += f"\n 绿色 = 正在运行(已勾选);取消勾选即停止该服务(共 {len(running)} 个在运行)"
try:
cron_n = len(slash_cmds.list_scheduler_tasks())
except Exception:
cron_n = 0
if cron_n:
sch_running = 'reflect/scheduler.py' in running
cron_state = "已激活" if sch_running else "未激活(启动 reflect/scheduler.py 来调度)"
hint += f"\n cron:sche_tasks/*.json 共 {cron_n} 个任务 · {cron_state}"
msg = ChatMessage(
role="system",
content=hint,
kind="multi_choice",
choices=choices,
preselected_indices=preselected,
on_select=lambda names, base=dict(running): self._scheduler_confirm(names, base),
)
sess.messages.append(msg)
self._refresh_messages()
def _scheduler_diff(self, selected: list[str], running: dict) -> tuple[list[str], list[str]]:
"""Translate the picker's *final tick state* into actions relative to
the running baseline (bug#4):
starts = ticked but not currently running
stops = currently running but unticked
Order is preserved from `selected`/`running` for stable summaries."""
sel = list(dict.fromkeys(selected)) # dedupe, keep order
run_names = list(running.keys())
starts = [n for n in sel if n not in running]
stops = [n for n in run_names if n not in sel]
return starts, stops
def _scheduler_confirm(self, names: list[str], running: dict | None = None) -> None:
"""Picker submitted → ask one more ask_user-style submit-confirm card
before actually launching/stopping anything (user-requested safety).
UX mirrors `ask_user`'s `Ready to submit your answer?` confirmation:
- No ✅ glyph on the choice labels (style consistency).
- ←/→ Enter Esc hint in the title.
- Esc / "Edit selection" → re-open the picker (rollback to previous
screen) just like Esc rolls back free-text typing to the picker.
bug#4: the card now spells out the *diff* (start X / stop Y) so the
consequence of unticking a running service is explicit before commit.
"""
running = running or {}
starts, stops = self._scheduler_diff(names, running)
if not starts and not stops:
self._system("(选择无变化 — 没有要启动或停止的服务)"); return
sess = self.current
if sess is None: return
lines = []
if starts:
lines.append(f"▶ 启动 {len(starts)} 个: " + "、".join(starts))
if stops:
lines.append(f"■ 停止 {len(stops)} 个: " + "、".join(stops))
detail = "\n".join(" " + ln for ln in lines)
confirm = ChatMessage(
role="system",
content=(f"Ready to submit your selection?\n{detail}"
"\n ←/→ 选择 · Enter 确认 · Esc 回退"),
kind="choice",
choices=[("Submit", "__SCHED_GO__"), ("Edit selection", "__SCHED_EDIT__")],
on_select=lambda v, st=list(starts), sp=list(stops): self._scheduler_commit(v, st, sp),
# Esc on the confirm card → roll back to the picker (ask_user style).
on_cancel=lambda: self._cmd_scheduler([], ""),
)
sess.messages.append(confirm)
self._refresh_messages()
def _scheduler_commit(self, value: str, starts: list[str], stops: list[str]) -> str:
"""on_select for the submit-confirm card; returns the breadcrumb text
shown after the card collapses (see _collapse_choice).
- Submit (__SCHED_GO__) → apply the start/stop diff.
- Edit selection (__SCHED_EDIT__) → re-open the picker (rollback).
"""
if value == "__SCHED_EDIT__":
# Re-show the picker on the next tick so the breadcrumb settles
# first; using call_after_refresh keeps message order stable.
try:
self.call_after_refresh(self._cmd_scheduler, [], "")
except Exception:
self._cmd_scheduler([], "")
return "已回到选择界面"
if value != "__SCHED_GO__":
return "已取消,未改动任何服务"
self._apply_scheduler_diff(starts, stops)
bits = []
if starts: bits.append(f"启动 {len(starts)}")
if stops: bits.append(f"停止 {len(stops)}")
return "已确认 — " + "、".join(bits) if bits else "已确认(无改动)"
def _apply_scheduler_diff(self, starts: list[str], stops: list[str]) -> None:
"""Run the start/stop actions and print one ✅/❌ summary block.
Stops run first so a restart (stop+start of the same name) can't race
the cmdline scan — though the diff never produces such a pair."""
from frontends import slash_cmds
lines = []
for name in stops:
ok, detail = slash_cmds.stop_service(name)
lines.append(("■ " if ok else "❌ ") + detail)
for name in starts:
ok, detail = slash_cmds.start_service(name)
lines.append(("▶ " if ok else "❌ ") + detail)
if not lines:
lines = ["(无改动)"]
self._system("调度变更结果:\n" + "\n".join(lines))
def _launch_service_batch(self, names: list[str]) -> None:
"""Shared by `/scheduler start ...` (CLI) and the confirmed picker.
Launches every requested service via slash_cmds.start_service and
prints a single ✅/❌ summary block."""
from frontends import slash_cmds
if not names:
self._system("(未选择任何服务)"); return
lines = [f"批量启动 {len(names)} 个服务:"]
for n in names:
ok, msg = slash_cmds.start_service(n)
lines.append((" ✅ " if ok else " ❌ ") + msg)
self._system("\n".join(lines))
def _reset_terminal_title(self) -> None:
# Direct write on purpose: this runs at teardown when frames have stopped
# (so there's no writer-thread race to avoid) and the driver may already
# be stopped (enqueued writes would be silently dropped). See _term_write.
try:
out = sys.__stdout__
out.write("\x1b]0;\x07")
out.flush()
except Exception:
pass
def on_unmount(self) -> None:
self._reset_terminal_title()
# Drop this run's empty signal dirs on graceful exit; the startup
# sweep mops up anything a crash leaves behind.
for s in list(self.sessions.values()):
_rmdir_if_empty(getattr(s.agent, 'task_dir', None))
def _run_shell(self, cmd: str) -> None:
"""`!cmd` magic: run `cmd` in the user's shell (Git Bash / pwsh /
sh — see `detect_user_shell`), echo command + output into the
current session's scrollback, and append a `[!shell]` pair to
backend.history so the agent sees it on the next turn."""
if not cmd:
return
sess = self.current
sess.messages.append(ChatMessage("system",
f"! {cmd}",
kind="system"))
import subprocess
from frontends.slash_cmds import detect_user_shell
shell_argv, shell_name = detect_user_shell()
out = ''; rc = 0
try:
r = subprocess.run(
shell_argv + [cmd], 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"
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:
buf += str(item.get("next") or "")
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._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']` first, fallback to per-
# session message scan for a `plan_*/plan.md` reference. Strictly
# per-session via `plan_scan_baseline` to avoid /continue bleed.
new_items: list = []
msgs = sess.messages
base = sess.plan_scan_baseline
if plan_state.is_active(sess.agent, messages=msgs, start_idx=base):
path = plan_state.resolve_path(sess.agent, messages=msgs, start_idx=base)
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, messages=msgs, start_idx=base):
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
# 5-row budget: header(1) + step(0/1) + tasks(N) + overflow(0/1).
step = plan_state.current_step(msgs, start_idx=base)
budget = 4 - (1 if step else 0)
ordered = [(c, st) for c, st in items if st != "done"] + \
[(c, st) for c, st in items if st == "done"]
body_lines = budget - 1 if len(ordered) > budget else budget
shown = ordered[:body_lines]
overflow = max(0, len(ordered) - body_lines)
sig = (tuple(shown), overflow, step, bool(complete and sess and sess.plan_complete_since))
if getattr(bar, "_plan_sig", None) == sig and bar.display: return
bar._plan_sig = sig
body = Text()
head = f"✓ Plan complete ({n_total}/{n_total})\n" if complete else f"📋 Plan ({n_done}/{n_total})\n"
body.append(head, style=f"bold {C_GREEN}")
if step:
body.append(" ▸ ", style=C_GREEN)
body.append(step[:120] + "\n", style=C_MUTED)
for c, st in shown:
if st == "done": body.append(" ✔ ", style=C_GREEN); body.append(c + "\n", style=C_DIM)
else: body.append(" ☐ ", style=C_DIM); body.append(c + "\n", style=C_FG)
if overflow:
body.append(f" ⋮ +{overflow} more", style=C_DIM)
bar.update(body)
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 plan_state.find_path_in_messages(sess.messages, start_idx=base)
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 bar.display: return
bar._plan_sig = sig
body = Text()
body.append("📋 Plan 模式已激活\n", style=f"bold {C_GREEN}")
if step:
body.append(" ▸ ", style=C_GREEN)
body.append(step[:120] + "\n", style=C_MUTED)
body.append(f" 等待写入 {hint} …", style=C_DIM)
bar.update(body)
self._set_planbar_visible(bar, True)
def _set_planbar_visible(self, bar: Static, visible: bool) -> None:
# Repaint only on show→hide transition; idle ticks no-op.
if not visible:
if not bar.display: return
bar.display = False
bar.update(Text())
bar._plan_sig = None
return
if not bar.display: bar.display = True
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
msgs = sess.messages
base = sess.plan_scan_baseline
if not plan_state.is_active(sess.agent, messages=msgs, start_idx=base):
self._refresh_planbar(); return
path = plan_state.resolve_path(sess.agent, messages=msgs, start_idx=base)
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
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))
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:
text = _TASKLIST_OPEN_RE.sub(r"\1☐ ", text)
text = _TASKLIST_DONE_RE.sub(r"\1✔ ", text)
text = _TOOL_USE_RE.sub(_render_tool_use_block, text)
text = _META_TAG_RE.sub("", text)
from io import StringIO
from rich.console import Console
render_w = max(1, width - 1)
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")
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)
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")
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",
)
else:
widget = ChoiceList(m, classes="picker")
for cl, _ in m.choices:
widget.add_option(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:
self.call_after_refresh(widget.focus)
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)
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())