Merge pull request #496 from shenhao-stu:fix/tui-frontend-polish

fix(tui): polish continue picker and prevent stash freeze
This commit is contained in:
LJQ authored and GitHub committed 2026-05-27 10:36:20 +08:00
commit 67d1850219
3 files changed
+868 -36

No files matched your search

+188 -7
View File
@@ -8,6 +8,11 @@ _LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)',
re.DOTALL | re.MULTILINE)
_SUMMARY_RE = re.compile(r'<summary>\s*(.*?)\s*</summary>', re.DOTALL)
_ROUND_HEADER_RE = re.compile(rb'^=== (Prompt|Response) ===', re.MULTILINE)
_ROUNDS_CACHE_PATH = os.path.join(os.path.expanduser('~'), '.genericagent', 'continue_rounds_cache.json')
_ROUNDS_CACHE_VERSION = 1
_rounds_cache = None
_rounds_cache_dirty = False
def _rel_time(mtime):
d = int(time.time() - mtime)
@@ -98,21 +103,197 @@ def _parse_native_history(pairs):
history.append({'role': 'assistant', 'content': blocks})
return history
_PREVIEW_WIN = 32 * 1024
# Content-grep budget for `/continue` search box: read at most this many bytes
# per session (head window) so 17MB files don't stall the UI. Empirically the
# user-typed prompt + first model reply + early summaries live in the first MB,
# which is what users actually want to recall sessions by.
_GREP_WIN = 1 * 1024 * 1024
def file_contains_all(path, terms, max_bytes=_GREP_WIN):
"""True iff every lowercase term in `terms` appears in the first
`max_bytes` of `path` (case-insensitive). Empty `terms` returns True so
callers can short-circuit. Reads as bytes + .lower() to avoid utf-8 cost
and stays within a fixed memory envelope regardless of file size.
"""
if not terms:
return True
try:
with open(path, 'rb') as fh:
buf = fh.read(max_bytes)
except OSError:
return False
if not buf:
return False
hay = buf.lower()
for t in terms:
if t and t.encode('utf-8', errors='ignore') not in hay:
return False
return True
def search_sessions(query, sessions, max_bytes=_GREP_WIN):
"""Filter `sessions` ([(path, mtime, preview, n), ...]) by content grep.
`query` is whitespace-split into AND terms (case-insensitive). Each
session is kept iff its path/preview already match OR the first
`max_bytes` of its file contain every term. Order is preserved.
Empty/whitespace query returns the list as-is.
"""
q = (query or '').strip().lower()
if not q:
return list(sessions or [])
terms = [t for t in q.split() if t]
if not terms:
return list(sessions or [])
out = []
for item in sessions or []:
path = item[0] if len(item) > 0 else ''
preview = item[2] if len(item) > 2 else ''
meta = (os.path.basename(path) + '\n' + (preview or '')).lower()
if all(t in meta for t in terms):
out.append(item)
continue
if file_contains_all(path, terms, max_bytes=max_bytes):
out.append(item)
return out
def _preview_from_file(path):
"""Cheap preview: last <summary> in tail window, else first user line in head window."""
try:
sz = os.path.getsize(path)
with open(path, 'rb') as fh:
if sz <= _PREVIEW_WIN * 2:
head = tail = fh.read()
else:
head = fh.read(_PREVIEW_WIN)
fh.seek(-_PREVIEW_WIN, 2); tail = fh.read()
except OSError: return ''
tail_s = tail.decode('utf-8', errors='replace')
m = _SUMMARY_RE.findall(tail_s)
if m: return m[-1].strip()
for line in head.decode('utf-8', errors='replace').splitlines():
s = line.strip()
if s and not s.startswith('===') and not s.startswith('###') and '<history>' not in s:
return s
return ''
def _rounds_cache_key(path):
return os.path.normcase(os.path.abspath(path))
def _load_rounds_cache():
"""Load lazy mtime/size keyed round-count cache for /continue.
Cache is intentionally triggered only by list_sessions(): no TUI startup cost,
no logging-path coupling. Missing/stale entries are recomputed on demand.
"""
global _rounds_cache
if _rounds_cache is not None:
return _rounds_cache
_rounds_cache = {}
try:
with open(_ROUNDS_CACHE_PATH, encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict) and data.get('version') == _ROUNDS_CACHE_VERSION:
items = data.get('items')
if isinstance(items, dict):
_rounds_cache = items
except Exception:
_rounds_cache = {}
return _rounds_cache
def _save_rounds_cache(valid_keys=None):
global _rounds_cache_dirty
if not _rounds_cache_dirty or _rounds_cache is None:
return
try:
if valid_keys is not None:
keep = set(valid_keys)
for k in list(_rounds_cache.keys()):
if k not in keep:
_rounds_cache.pop(k, None)
os.makedirs(os.path.dirname(_ROUNDS_CACHE_PATH), exist_ok=True)
tmp = _ROUNDS_CACHE_PATH + '.tmp'
data = {'version': _ROUNDS_CACHE_VERSION, 'items': _rounds_cache}
with open(tmp, 'w', encoding='utf-8') as fh:
json.dump(data, fh, ensure_ascii=False, separators=(',', ':'))
os.replace(tmp, _ROUNDS_CACHE_PATH)
_rounds_cache_dirty = False
except Exception:
# Cache is a performance hint only; never break /continue on cache I/O.
pass
def _count_complete_rounds_from_file(path):
"""Count completed Prompt→Response pairs using only block headers.
Counting Prompt headers alone overcounts an in-flight/incomplete last round.
Header-pair counting matched `_pairs()` on sampled real logs while avoiding
expensive UTF-8 decode / body regex parsing.
"""
try:
with open(path, 'rb') as fh:
data = fh.read()
except OSError:
return 0
pending = False
rounds = 0
for m in _ROUND_HEADER_RE.finditer(data):
if m.group(1) == b'Prompt':
pending = True
elif pending:
rounds += 1
pending = False
return rounds
def _rounds_for_file(path, st):
global _rounds_cache_dirty
cache = _load_rounds_cache()
key = _rounds_cache_key(path)
size = int(getattr(st, 'st_size', 0))
mtime_ns = int(getattr(st, 'st_mtime_ns', int(getattr(st, 'st_mtime', 0) * 1_000_000_000)))
ent = cache.get(key)
if isinstance(ent, dict) and ent.get('size') == size and ent.get('mtime_ns') == mtime_ns:
try:
return int(ent.get('rounds', 0)), key
except Exception:
pass
n = _count_complete_rounds_from_file(path)
cache[key] = {'size': size, 'mtime_ns': mtime_ns, 'rounds': int(n)}
_rounds_cache_dirty = True
return n, key
def list_sessions(exclude_pid=None):
"""Newest-first list of (path, mtime, first_user_text, n_rounds)."""
"""Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only."""
files = glob.glob(_LOG_GLOB)
if exclude_pid is not None:
tag = f'model_responses_{exclude_pid}.txt'
files = [f for f in files if not f.endswith(tag)]
out = []
valid_keys = []
for f in files:
try:
with open(f, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception: continue
pairs = _pairs(content)
if not pairs: continue
out.append((f, os.path.getmtime(f), _preview_text(pairs), len(pairs)))
st = os.stat(f)
mtime, sz = st.st_mtime, st.st_size
except OSError:
continue
if sz < 32:
continue
preview = _preview_from_file(f)
if not preview:
continue
rounds, key = _rounds_for_file(f, st)
valid_keys.append(key)
out.append((f, mtime, preview, rounds))
_save_rounds_cache(valid_keys)
out.sort(key=lambda x: x[1], reverse=True)
return out
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
+38 -5
View File
@@ -641,6 +641,10 @@ def _ptk_keypress_to_bytes(kp) -> bytes:
key = getattr(kp, 'key', None)
data = getattr(kp, 'data', '') or ''
mods = {str(m).lower().replace('_', '-') for m in (getattr(kp, 'modifiers', None) or ())}
modded_s = str(key).lower().replace('_', '-') == 's' or data == 's'
if modded_s and any(m in mods for m in ('control', 'ctrl', 'command', 'cmd')):
return b'\x13'
# Printable text and paste chunks. PTK may deliver a multi-character data
# string for bracketed paste/typeahead; forwarding UTF-8 preserves CJK/emoji.
@@ -661,6 +665,8 @@ def _ptk_keypress_to_bytes(kp) -> bytes:
return b'\r'
if has('controlj', 'c-j', 's-enter', 'shift-enter'):
return b'\n'
if has('controls', 'control-s', 'c-s', 'commands', 'command-s', 'cmd-s'):
return b'\x13'
# Navigation. Existing _keys() uses these small control bytes.
if has('up') and has('shift'):
@@ -1602,7 +1608,14 @@ def _pet(el: float, frame: int) -> str:
_BP_START = b'\x1b[200~'
_BP_END = b'\x1b[201~'
_SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_ROOT = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _is_under_root(path: str) -> bool:
try:
return os.path.commonpath([_ROOT, os.path.realpath(path)]) == _ROOT
except (OSError, ValueError):
return False
def _w(s: str) -> None:
@@ -1890,6 +1903,7 @@ class SB:
self._rb = b''; self._tail = b''; self._bp = False; self._pbytes = b''
self.hist: list[str] = []; self._hi = -1
self._hist_stash = '' # live draft preserved while browsing history
self._draft_stash = ''
self._session_name = '' # set by /new <name>; shown in banner / status
self._tip_idx = random.randrange(max(1, tip_count())) # banner tip, fixed per launch
self._btws: list[list] = [] # [question, answer|None] — answer None while in flight
@@ -3239,6 +3253,21 @@ class SB:
self._snap() # only snap here when it didn't,
self.buf = self.buf[:self.pos] + s + self.buf[self.pos:]; self.pos += len(s)
def _stash_draft(self) -> None:
if self.buf:
self._snap()
self._draft_stash = self.buf
self.buf = ''; self.pos = 0
self._hi = -1; self._hist_stash = ''; self._sel = None
self._redo.clear()
elif self._draft_stash:
self._snap()
self.buf = self._draft_stash; self.pos = len(self.buf)
self._draft_stash = ''
self._hi = -1; self._hist_stash = ''; self._sel = None
self._redo.clear()
def _placeholder_at(self, side: str) -> tuple[int, int, int] | None:
"""If a paste placeholder sits flush against the caret, return
(start, end, sid). `side='left'` → the placeholder *ends* at the caret
@@ -3343,7 +3372,7 @@ class SB:
if os.path.isabs(p) or p.startswith('~'):
return m.group(0)
fp = os.path.normpath(os.path.join(self._cwd, p))
if not fp.startswith(_ROOT) or not os.path.isfile(fp):
if not _is_under_root(fp) or not os.path.isfile(fp):
return m.group(0)
with open(fp, encoding='utf-8', errors='replace') as f:
return f'[File: {p}]\n{f.read(100_000)}\n[/File]'
@@ -4402,6 +4431,8 @@ class SB:
self._palette_sel = max(0, self._palette_sel - 1)
else:
self._sel = None; self._cur_v(-1)
elif o == 0x13: # Ctrl+S stash/restore draft
self._stash_draft()
elif o == 0x0e: # ↓ visual-row down (history at bottom)
if self._palette_visible():
n = len(self._cmd_matches(self.buf))
@@ -4725,13 +4756,15 @@ def _ensure_deps():
sys.exit(2)
def main():
def main(argv: list[str] | None = None) -> int:
_ensure_deps()
install_cjk_wrap()
if not sys.stdin.isatty():
print(_t('err.no_tty')); return
print(_t('err.no_tty'))
return 1
SB().run()
return 0
if __name__ == '__main__':
main()
raise SystemExit(main())
+642 -24
View File
@@ -55,7 +55,7 @@ try:
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen
from textual.widgets import OptionList, SelectionList, Static, TextArea
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:
@@ -304,17 +304,23 @@ def _patch_markdown_table_overflow():
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="markdown.table.border",
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("markdown.table.header")
heading.stylize(header_style)
table.add_column(heading, overflow="fold")
if self.body is not None:
for row in self.body.rows:
@@ -516,6 +522,58 @@ def _strip_quote_deco(s: str) -> tuple:
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")]
@@ -541,12 +599,20 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
wide_lines = wide[wide_start:wi]
K, W = len(run_lines), len(wide_lines)
if W == 0:
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]))
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]))
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):
@@ -568,7 +634,7 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
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))
wrap_groups.append(((g_start, run_start + j), w_line, False))
source_parts: list = []
line_starts = [0] * len(narrow)
@@ -601,7 +667,7 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
last_was_content = True
continue
(g_start, g_end), wide_line = wrap_groups[group_idx]
(g_start, g_end), wide_line, passthrough = wrap_groups[group_idx]
single_line = (g_end - g_start == 1)
nt0 = narrow[g_start]
@@ -620,7 +686,17 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
source_parts.append("\n")
src_pos += 1
if is_centered:
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
@@ -777,6 +853,8 @@ def _markdown_rich_theme(p: dict[str, str], minimal: bool = False):
"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']}",
@@ -798,6 +876,8 @@ def _markdown_rich_theme(p: dict[str, str], minimal: bool = False):
"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']}",
})
@@ -891,7 +971,7 @@ Screen { background: $ga-bg; color: $ga-fg; }
#tipbar {
height: 1;
background: $ga-bg;
padding: 0 2;
padding: 0;
color: $ga-dim;
}
@@ -920,6 +1000,22 @@ 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;
@@ -930,7 +1026,10 @@ SelectionList.picker > .selection-list--button-highlighted { background: transpa
margin-bottom: 0;
}
.fold-header:hover { background: $ga-sel-bg; }
.spinner { height: 1; }
.spinner {
height: 1;
margin-top: 1;
}
#palette {
height: auto;
@@ -998,6 +1097,18 @@ class ChatMessage:
choices: list = field(default_factory=list) # [(label, value), ...]
on_select: Optional[Callable] = field(default=None, repr=False)
selected_label: Optional[str] = None
# 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)
@@ -1122,6 +1233,360 @@ class ChoiceList(OptionList):
pass
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 in ("down", "up", "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,
"up": self.picker.action_cursor_up,
"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 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.
@@ -1294,11 +1759,61 @@ class InputArea(TextArea):
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 (Claude Code muscle-memory).
# First press → stash text + clear input; second press on an empty
# input → restore the stashed draft. Independent of Up/Down history
# so a queued draft survives sending the previous one.
# NOTE: must use `self.reset()` (not `self.text = ""`) — assigning
# `.text` on a TextArea rebuilds the document + wrapped_document and
# blocks the UI for seconds on long pastes (cf. PR#479, user report
# 2026-05-27 "ctrl+s 完全卡死"). `reset()` clears in-place.
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.
- Non-empty input → save into `_draft_stash`, then `reset()` the
TextArea. `reset()` is O(1)-ish vs the multi-second relayout
that `self.text = ""` triggers on large drafts.
- Empty input + a previous stash → put the saved text back so the
user can keep typing where they left off.
- Empty input + no stash → no-op (accidental presses harmless).
"""
current = self.text
if current:
self._draft_stash = current
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
elif self._draft_stash:
stashed = self._draft_stash
self._draft_stash = ""
self._history_index = -1
self._history_stash = ""
# Suppress palette popup during the bulk insert — restoring
# a slash-command draft should not re-trigger the command picker.
try: self._suppress_palette_next_change()
except Exception: pass
self.text = stashed
try:
# `cursor_location = document.end` is multi-line safe;
# `move_cursor((0, len(stashed)))` clamps to row 0 and
# would mis-position the caret on multi-line drafts.
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
@@ -1410,6 +1925,9 @@ class InputArea(TextArea):
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 = ""
self._HISTORY_MAX = 200
def expand_placeholders(self, text: str) -> str:
@@ -2602,7 +3120,19 @@ class GenericAgentTUI(App[None]):
lines = inp.wrapped_document.height or inp.document.line_count
except Exception:
lines = inp.document.line_count
inp.styles.height = min(max(lines, 1), 3) + 2 # +2 for padding 1 2 top/bottom
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
@@ -2685,6 +3215,13 @@ class GenericAgentTUI(App[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
@@ -2710,8 +3247,14 @@ class GenericAgentTUI(App[None]):
- If any picked entry is the free-text sentinel, switch the whole
message into free-text mode (the user wants to type instead).
- Otherwise post a `Ready to submit your answers?` confirmation
card (Submit / Edit answer) before the agent sees it.
- 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)]
@@ -2719,6 +3262,7 @@ class GenericAgentTUI(App[None]):
self._enter_free_text_mode(msg)
return
labels = [lbl for lbl, _ in picked]
values = [val for _, val in picked]
joined = "; ".join(labels)
if not labels: return # nothing selected → keep the picker up
question = msg.content.split(" ")[0].rstrip() if " " in msg.content else msg.content
@@ -2735,6 +3279,14 @@ class GenericAgentTUI(App[None]):
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(
@@ -2779,10 +3331,22 @@ class GenericAgentTUI(App[None]):
body.append("✓ ", style=C_GREEN)
body.append(display, 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:
# 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()
@@ -3149,11 +3713,24 @@ class GenericAgentTUI(App[None]):
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 取消)"
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()
@@ -4143,7 +4720,15 @@ class GenericAgentTUI(App[None]):
if m._role_widget is None:
self._mount_message(container, m)
if was_at_bottom:
container.scroll_end(animate=False)
# 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:
@@ -4181,6 +4766,11 @@ class GenericAgentTUI(App[None]):
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)
@@ -4435,12 +5025,40 @@ class GenericAgentTUI(App[None]):
widget = MultiChoiceList(m, *(Selection(cl, idx) for idx, (cl, _) in enumerate(m.choices)),
classes="picker")
else:
widget = ChoiceList(m, classes="picker")
for cl, _ in m.choices:
widget.add_option(Option(cl))
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)
self.call_after_refresh(widget.focus)
# 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