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:
commit
67d1850219
3 files changed
+868
-36
No files matched your search
+188
-7
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user