6051 lines
286 KiB
Python
6051 lines
286 KiB
Python
"""tui_v3 — scrollback-first TUI for GenericAgent, consolidated.
|
||
|
||
Merged from frontends/tui/ (cjk, clipboard, renderer, protocol, core/sb)
|
||
into a single file so the v3 frontend ships as one drop-in module.
|
||
Run: `python -m frontends.tui_v3` or `python frontends/tui_v3.py`.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio, atexit, json, locale, logging, os, queue, random, re, select, shutil, signal, subprocess
|
||
import sys, tempfile, threading, time
|
||
|
||
_IS_WINDOWS = os.name == 'nt'
|
||
|
||
|
||
# Make `frontends/` parent (project root) importable so `from agentmain import …`
|
||
# works whether this file is run as `python -m frontends.tui_v3` or directly
|
||
# via `python frontends/tui_v3.py`.
|
||
_proj_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
_front_dir = os.path.dirname(os.path.abspath(__file__))
|
||
for _p in (_proj_root, _front_dir):
|
||
if _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
|
||
from agentmain import GeneraticAgent
|
||
from dataclasses import dataclass
|
||
from dataclasses import dataclass, field
|
||
from functools import lru_cache
|
||
from io import StringIO
|
||
from rich.cells import cell_len
|
||
from rich.console import Console
|
||
from rich.markdown import Markdown
|
||
from rich.text import Text
|
||
from rich.theme import Theme
|
||
from typing import Callable
|
||
from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用)
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py)
|
||
#
|
||
# - Single nested dict `_I18N[lang][key]` → format string.
|
||
# - `t(key, **fmt)` returns the formatted string for the current language;
|
||
# falls back to English, then to the key itself (so missing keys are visible).
|
||
# - Language detection: persisted user choice > system locale > 'en'.
|
||
# - Persistence: temp/tui_v3_settings.json (workspace-local, matches v2 pattern).
|
||
#
|
||
# Strings that intentionally stay single-language (English):
|
||
# - Spinner gerunds (Pondering/Brewing/...) — ported from v2.
|
||
# - Tech jargon embedded in zh strings: 'tokens', 'ctx', 'model', 'session', …
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
|
||
_SETTINGS_PATH = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "..", "temp", "tui_v3_settings.json"
|
||
)
|
||
|
||
_SUPPORTED = ('zh', 'en')
|
||
_DEFAULT = 'en'
|
||
|
||
_LANG: str = _DEFAULT
|
||
|
||
|
||
# ---------------- spinner gerunds (English only, from v2) ----------------
|
||
|
||
SPINNER_GERUNDS = (
|
||
"Pondering", "Reticulating", "Sleuthing", "Hatching", "Pouncing",
|
||
"Brewing", "Sharpening", "Untangling", "Compiling", "Unraveling",
|
||
"Distilling", "Calibrating", "Marinating", "Conjuring", "Foraging",
|
||
"Spelunking", "Synthesizing", "Refactoring thoughts", "Tracing breadcrumbs",
|
||
"Following the rabbit hole",
|
||
"Routing", "Threading", "Polling", "Spinning", "Hooking",
|
||
"Patching", "Caching", "Yielding", "Hydrating", "Folding",
|
||
"Streaming", "Resolving", "Reaping", "Tuning",
|
||
)
|
||
|
||
|
||
# Language display names (always shown in their own script — never translated,
|
||
# so users always see them in a form they recognize).
|
||
LANG_LABELS = {
|
||
'zh': '简体中文',
|
||
'en': 'English',
|
||
}
|
||
|
||
|
||
# Rotating usage tips — one picked per launch, shown in the banner (v2 _TIPS).
|
||
# Only covers features v3 has actually adapted; the en/zh lists run parallel.
|
||
_TIPS = {
|
||
'en': [
|
||
"Tip: press / to open the command palette — arrow keys to pick, Enter drops it into the input box.",
|
||
"Tip: pasted images / files fold into [Image #N] / [File #N] placeholders; backspace deletes the whole block.",
|
||
"Tip: /btw <question> lets a side-agent answer without interrupting the main task.",
|
||
"Tip: /rewind [n] rewinds the last n turns; /stop aborts the current task.",
|
||
"Tip: /continue lists past sessions — arrow keys to pick, Enter to restore.",
|
||
"Tip: Ctrl+J / Shift+Enter inserts a newline in multi-line input; Enter sends.",
|
||
"Tip: put [multi-select] in an ask_user prompt to switch to a multi-pick picker.",
|
||
"Tip: /cost shows token usage; /llm views / switches the model.",
|
||
"Tip: /new [name] starts a fresh session; /language switches the interface language.",
|
||
"Tip: /export clip copies the last reply to your system clipboard; /export all prints the log path.",
|
||
"Tip: Ctrl+O folds / unfolds all completed tool chips — each fold collapses to one line.",
|
||
"Tip: /update auto-runs git pull and audits the impact; /autorun seeds an autonomous run.",
|
||
"Tip: /morphling <target> absorbs an external skill.",
|
||
"Tip: /goal <goal> enters Goal mode (will ask for budget / worker cap); /hive <target> for multi-worker.",
|
||
"Tip: /conductor <task> hands the task to frontends/conductor.py for multi-subagent orchestration.",
|
||
"Tip: /update runs a dual-branch upstream sync — previews the diff before fast-forwarding either side.",
|
||
"Tip: /scheduler is live — untick a running row to stop it; tick again to relaunch.",
|
||
"Tip: Ctrl+S stashes your draft input — it's waiting for you next time you open a picker.",
|
||
"Tip: /scheduler lists reflect tasks and starts them via `/scheduler start a,b,c`.",
|
||
"Tip: prefix `!` runs the rest as a host shell command — output is folded into LLM history.",
|
||
"Tip: /resume lists recent sessions you can pick from to restore prior context.",
|
||
],
|
||
'zh': [
|
||
"Tip: 按 / 唤起命令面板 —— 方向键选择,Enter 落入输入框。",
|
||
"Tip: 粘贴图片 / 文件会折叠成 [Image #N] / [File #N] 占位符,退格可整块删除。",
|
||
"Tip: /btw <问题> 让 side-agent 回答而不打断主任务。",
|
||
"Tip: /rewind [n] 回退最近 n 轮对话;/stop 中止当前任务。",
|
||
"Tip: /continue 列出历史会话 —— 方向键选择,Enter 恢复。",
|
||
"Tip: 多行输入用 Ctrl+J / Shift+Enter 换行;Enter 直接发送。",
|
||
"Tip: ask_user 题目里写 [多选] 会自动切到多选 picker。",
|
||
"Tip: /cost 查看 token 用量;/llm 查看 / 切换模型。",
|
||
"Tip: /new [name] 新建会话;/language 切换界面语言。",
|
||
"Tip: /export clip 把最后回复复制到系统剪贴板;/export all 打印日志路径。",
|
||
"Tip: Ctrl+O 折叠 / 展开所有已完成的工具 chip —— 每个 chip 折叠成一行。",
|
||
"Tip: /conductor <任务> 直接交给 frontends/conductor.py 做多 subagent 编排。",
|
||
"Tip: /update 是双分支 upstream 同步 —— 先 diff 预演,再分别快进。",
|
||
"Tip: /scheduler 里再点一下已勾选的任务可以 stop —— 取消勾选 = 停止。",
|
||
"Tip: Ctrl+S 把当前输入 stash 起来,下次 / 打开 picker 时还在。",
|
||
"Tip: 以 `!` 开头直接跑 shell —— 命令与输出都会进入 LLM 历史,agent 可以引用。",
|
||
"Tip: /resume 列出最近会话,可挑选一个恢复之前的上下文。",
|
||
],
|
||
}
|
||
|
||
|
||
# ---------------- translations ----------------
|
||
# Keys are dot-namespaced. Format placeholders use {name}.
|
||
|
||
_I18N: dict[str, dict[str, str]] = {
|
||
'en': {
|
||
# /help intro & rows — phrasing mirrors v2's COMMANDS table.
|
||
'help.title': 'Commands:',
|
||
'help.help': ' /help Show help',
|
||
'help.status': ' /status View session status',
|
||
'help.sessions': ' /sessions List all sessions',
|
||
'help.llm': ' /llm [n] View / switch model',
|
||
'help.btw': ' /btw <question> Side question — does not interrupt main agent',
|
||
'help.review': ' /review [request] In-session code review (report inline)',
|
||
'help.rewind': ' /rewind [n] Rewind the last n rounds',
|
||
'help.continue': ' /continue [n|name] List / restore historical sessions',
|
||
'help.new': ' /new [name] Start a new session (clears the current one)',
|
||
'help.rename': ' /rename <name> Rename the current session',
|
||
'help.clear': ' /clear Clear display (does not touch LLM history)',
|
||
'help.cost': ' /cost Token usage for the current session',
|
||
'help.verbose': ' /verbose Tool-call audit (↑↓ select · Enter switch · c copy · q quit)',
|
||
'help.export': ' /export [sub] Export last reply: clip / file [name] / all',
|
||
'help.stop': ' /stop Abort current task',
|
||
'help.language': ' /language [code] View / switch interface language',
|
||
'help.update': ' /update [note] Preview upstream commits & diff, then pull (no commit)',
|
||
'help.autorun': ' /autorun [seed] Enter autonomous-operation mode',
|
||
'help.morphling': ' /morphling [target] Distill / absorb an external skill',
|
||
'help.goal': ' /goal [goal] Enter Goal mode (asks for budget / worker cap)',
|
||
'help.hive': ' /hive [target] Enter Hive multi-worker mode',
|
||
'help.conductor': ' /conductor [task] Hand task to conductor.py for multi-subagent run',
|
||
'help.scheduler': ' /scheduler Multi-pick reflect tasks / view cron',
|
||
'help.emoji': ' /emoji [style] Pick the spinner pet face (picker / direct switch)',
|
||
'help.quit': ' /quit Quit',
|
||
'help.esc': ' Esc Cancel ask · clear draft · stop task (no exit)',
|
||
'help.cc': ' Ctrl+C × 2 Quit (when idle; only aborts the task while running)',
|
||
'help.cl': ' Ctrl+L Force repaint (recover from sleep/wake)',
|
||
'help.cz': ' Ctrl+Z / Ctrl+Y Undo / redo input edits',
|
||
'help.shift_arrow': ' Shift+←→↑↓ Select text (Ctrl+C copy / Ctrl+X cut / Ctrl+A all)',
|
||
|
||
# _CMDS palette entries — same wording as /help, condensed for one-line hint.
|
||
'cmd.help.desc': 'Show help',
|
||
'cmd.status.desc': 'View session status',
|
||
'cmd.llm.desc': 'View / switch model',
|
||
'cmd.btw.desc': 'Side question — does not interrupt main agent',
|
||
'cmd.review.desc': 'In-session code review',
|
||
'cmd.rewind.desc': 'Rewind the last n rounds',
|
||
'cmd.continue.desc': 'List / restore historical sessions',
|
||
'cmd.new.desc': 'Start a new session',
|
||
'cmd.rename.desc': 'Rename the current session',
|
||
'cmd.clear.desc': 'Clear display (LLM history untouched)',
|
||
'cmd.cost.desc': 'Token usage for the current session',
|
||
'cmd.verbose.desc': 'Tool-call audit',
|
||
'cmd.export.desc': 'Export the last reply (clip/file/all)',
|
||
'cmd.stop.desc': 'Abort current task',
|
||
'cmd.language.desc': 'View / switch interface language',
|
||
'cmd.quit.desc': 'Quit',
|
||
|
||
# _CMDS arg hints — mirror v2 (lowercase n, full word "question").
|
||
'cmd.llm.arg': '[n]',
|
||
'cmd.btw.arg': '<question>',
|
||
'cmd.review.arg': '[request]',
|
||
'cmd.rewind.arg': '[n]',
|
||
'cmd.continue.arg': '[n|name]',
|
||
'cmd.new.arg': '[name]',
|
||
'cmd.rename.arg': '<name>',
|
||
'cmd.export.arg': '[clip|file|all]',
|
||
'cmd.language.arg': '[code]',
|
||
'cmd.update.arg': '[note]',
|
||
'cmd.update.desc': 'preview upstream commits & diff, then pull (no commit)',
|
||
'cmd.autorun.arg': '[seed]',
|
||
'cmd.autorun.desc': 'enter autonomous operation mode',
|
||
'cmd.morphling.arg': '[target]',
|
||
'cmd.morphling.desc': 'distill / absorb external skills',
|
||
'cmd.goal.arg': '[goal]',
|
||
'cmd.goal.desc': 'enter Goal mode (needs condition)',
|
||
'cmd.hive.arg': '[target]',
|
||
'cmd.hive.desc': 'enter Hive multi-worker mode',
|
||
'cmd.conductor.arg': '[task]',
|
||
'cmd.conductor.desc': 'hand task to frontends/conductor.py for multi-subagent orchestration',
|
||
'cmd.scheduler.desc': 'multi-pick start/stop reflect tasks (cron is driven by reflect/scheduler.py)',
|
||
'cmd.emoji.arg': '[style]',
|
||
'cmd.emoji.desc': 'pick the spinner pet face — opens picker; arg switches directly',
|
||
|
||
# status line (one-liner above input box)
|
||
'status.asking': '◉ waiting · Esc cancel',
|
||
'status.running.tail': ' · Esc stop',
|
||
'status.tps': ' · {rate:.0f} tok/s',
|
||
'status.cc_confirm': 'Press Ctrl+C again to quit',
|
||
'status.ready': '○ ready',
|
||
|
||
# /status output rows
|
||
'status.title': ' Session status',
|
||
'status.label.model': 'model:',
|
||
'status.label.state': 'state:',
|
||
'status.label.rounds': 'rounds:',
|
||
'status.label.context': 'context:',
|
||
'status.label.cwd': 'cwd:',
|
||
'status.state.running': 'running · {verb} {elapsed}',
|
||
'status.state.waiting': 'waiting · ask_user pending',
|
||
'status.state.idle': 'idle',
|
||
'status.ctx.unknown': 'n/a',
|
||
'status.ctx.fmt': '{used:,} / {cap:,} ctx',
|
||
|
||
# banner
|
||
'banner.label.model': 'model:',
|
||
'banner.label.directory': 'directory:',
|
||
'banner.label.session': 'session:',
|
||
'banner.session.single': 'single · scrollback',
|
||
'banner.llm_hint': '/llm switch',
|
||
|
||
# messages — success / status
|
||
'msg.ask_cancelled': '✗ ask cancelled · type freely or ask again',
|
||
'msg.abort_requested': '⏹ abort requested · Esc',
|
||
'msg.abort_done': '⏹ abort requested',
|
||
'msg.idle_no_task': '(idle, no task)',
|
||
'msg.cleared': 'new conversation · context cleared',
|
||
'msg.new_session': 'new session · previous conversation cleared',
|
||
'msg.new_session_named': 'new session "{name}" · previous conversation cleared',
|
||
'msg.renamed': '✎ session renamed to "{name}"',
|
||
'msg.rewind': '↩ rewound {n} turn(s) (removed {removed} history entries; scrollback is not editable)',
|
||
'msg.no_rewindable': 'no rewindable turns',
|
||
'msg.continue_loading': '┄┄ loaded {name}, full context above ┄┄',
|
||
'msg.continue_ready': '┄┄ {msg} · continue typing ┄┄',
|
||
'msg.llm_switched': 'LLM → {name}',
|
||
'msg.export_done': 'exported: {path}',
|
||
'msg.export_clipped': 'copied to clipboard ({n} chars)',
|
||
'msg.export_clip_failed': '❌ copy failed: no clipboard tool found',
|
||
'msg.export_all': 'full log:\n{path}',
|
||
'msg.export_all_missing': 'no log file yet',
|
||
'msg.review_empty': '(review produced no output)',
|
||
'msg.no_export': '(no reply to export)',
|
||
'msg.no_tracker': '(no stats yet)',
|
||
'msg.no_history': ' no restorable historical sessions',
|
||
'msg.no_llms': '(no LLMs available)',
|
||
'msg.no_tools': ' (no tool-call records)',
|
||
'msg.lang_current': 'Current language: {label} ({code})',
|
||
'msg.lang_switched': 'Language → {label}',
|
||
'msg.btw_no_answer': '(no answer)',
|
||
'btw.title': 'side-questions · Esc to clear',
|
||
'btw.querying': ' ⋯ querying…',
|
||
|
||
# plan card
|
||
'plan.header': 'Plan ({done}/{total})',
|
||
'plan.complete': '✓ Plan complete ({n}/{n})',
|
||
'plan.placeholder': 'Plan mode activated',
|
||
'plan.waiting': 'waiting for {path} …',
|
||
'plan.overflow': '+{n} more',
|
||
|
||
# errors
|
||
'err.running_blocked': 'busy — /stop before using this command',
|
||
'err.continue_usage': 'usage: /continue or /continue N',
|
||
'err.index_oob': '❌ index out of range (valid: 1-{max})',
|
||
'err.btw_usage': 'usage: /btw <question> (does not pollute main context)',
|
||
'err.rewind_usage': 'usage: /rewind <n>',
|
||
'err.rename_usage': 'usage: /rename <name>',
|
||
'err.rewind_range': '❌ rewind failed: n must be 1-{max}',
|
||
'err.lang_usage': 'usage: /language [code] (codes: {available})',
|
||
'err.lang_unknown': 'unknown language code: {code} (available: {available})',
|
||
'err.unknown_cmd': 'unknown command /{name} — /help to list',
|
||
'err.multi_session': '/{name}: multi-session backend not wired yet; command reserved',
|
||
'err.menu_cb': '❌ menu callback failed: {err}',
|
||
'err.no_llm': 'No LLM configured — check mykey.py',
|
||
'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)',
|
||
'err.dep_missing': 'Error: {name} is not installed.',
|
||
'err.dep_install': 'Install with: pip install rich prompt_toolkit',
|
||
|
||
# menu / picker / palette
|
||
'menu.default_title': 'Pick',
|
||
'menu.hint': '↑↓ pick · Enter confirm · Esc cancel',
|
||
'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel',
|
||
'menu.search': 'filter workspaces, or type an abs path + Enter to create one',
|
||
'menu.no_match': 'no match',
|
||
'menu.free.hint': 'Enter to create/enter this path',
|
||
'ask.default_q': 'answer:',
|
||
'ask.title': '◉ answer',
|
||
'ask.pending': ' +{n} pending',
|
||
'ask.hint.multi': '↳ ↑↓ move · Space toggle · Enter submit · Esc cancel',
|
||
'ask.hint.single': '↳ ↑↓ navigate (options ⇄ input) · Enter confirm · Esc cancel',
|
||
'ask.hint.freetext': '↳ ↑↓ back to options · type to input · Enter submit · Esc cancel',
|
||
|
||
# continue picker
|
||
'continue.title': 'Restore historical session',
|
||
'continue.occupied.title': 'Session in use (pid {p}) — copy it and continue?',
|
||
'continue.occupied.copy': 'Copy & continue',
|
||
'continue.occupied.cancel': 'Cancel',
|
||
# /workspace (parity with v2; backed by workspace_cmd.py)
|
||
'cmd.workspace.arg': '[path|off]',
|
||
'cmd.workspace.desc': 'set working dir (abs path) and enter project mode',
|
||
'ws.entered': '✅ entered workspace「{n}」',
|
||
'ws.fail': '❌ workspace failed: {e}',
|
||
'ws.exited': 'left workspace (project mode off; junction & files kept)',
|
||
'ws.inactive': 'not in a workspace right now',
|
||
'ws.none': 'no registered workspace yet; /workspace <abs path> to create/enter',
|
||
'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)',
|
||
'ws.restored': 'restored working dir: {t}',
|
||
'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}',
|
||
'continue.unit.round': 'r',
|
||
|
||
# llm picker
|
||
'llm.title': 'Switch LLM',
|
||
|
||
# emoji picker (pet style)
|
||
'emoji.title': 'Pick spinner pet style',
|
||
'emoji.switched': 'pet style → `{style}`',
|
||
'emoji.unknown': 'unknown style `{choice}` — valid: {valid}',
|
||
'emoji.row.current': '● {name:<8} {sample}',
|
||
'emoji.row.other': ' {name:<8} {sample}',
|
||
'emoji.row.off': '(hide pet)',
|
||
|
||
# /scheduler picker (multi-pick reflect tasks / frontends)
|
||
'scheduler.pick.title': 'Pick services — checked = running (untick to stop)',
|
||
'scheduler.pick.hint': 'Space toggle · ↑↓ move · Enter next · Esc cancel · or /scheduler start a,b,c',
|
||
'scheduler.cron.active': 'cron: {n} task(s) in sche_tasks/*.json · active (reflect/scheduler.py running)',
|
||
'scheduler.cron.inactive': 'cron: {n} task(s) in sche_tasks/*.json · inactive (start reflect/scheduler.py to schedule)',
|
||
'scheduler.empty': '(no startable services: both reflect/*.py and frontends/*app*.py are empty)',
|
||
'scheduler.no_pick': '(no service picked)',
|
||
'scheduler.no_change': '(no change vs running set)',
|
||
'scheduler.running_tag': ' · running',
|
||
'scheduler.confirm.title': 'Ready to submit your answer?',
|
||
'scheduler.confirm.hint': '←/→ pick · Enter confirm · Esc go back',
|
||
'scheduler.confirm.submit': 'Submit ({n} service: {names})',
|
||
'scheduler.confirm.edit': 'Edit selection',
|
||
'scheduler.diff.start': '▶ start {n}: {names}',
|
||
'scheduler.diff.stop': '■ stop {n}: {names}',
|
||
'scheduler.cancelled': 'Cancelled — no change applied',
|
||
'scheduler.back_to_pick': 'Back to the picker',
|
||
'scheduler.usage_start': 'Usage: /scheduler start <service>[,<service2>...]',
|
||
|
||
# export picker
|
||
'export.title': 'Export the last reply',
|
||
'export.opt.clip': 'Copy to clipboard',
|
||
'export.opt.file': 'Save to file (temp/)',
|
||
'export.opt.all': 'Show full log path',
|
||
|
||
# rewind picker
|
||
'rewind.title': 'Rewind to which turn',
|
||
'rewind.option': 'rewind {n} turn(s) · {preview}',
|
||
|
||
# language picker
|
||
'lang.title': 'Switch interface language',
|
||
|
||
# verbose
|
||
'verbose.title': ' Tool Trace',
|
||
'verbose.hint': ' ↑↓ pick · PgUp/Dn scroll · Enter switch[{field}] · c copy · e export · q quit',
|
||
'verbose.empty': '(empty)',
|
||
|
||
# answer prefix when committing user reply to ask_user
|
||
'msg.answer_prefix': '[ans] {text}',
|
||
|
||
# pending input preview (queued while agent is busy)
|
||
'pending.head_running': 'queued {n} · injecting at next turn boundary · Esc to clear',
|
||
'pending.cleared': 'cleared {n} pending message(s)',
|
||
'pending.queued_marker': '[queued] {text}',
|
||
# Soft-guidance wrap: treat a mid-task user message as steering input
|
||
# for the ongoing task, rather than a deferred must-answer queue item.
|
||
'pending.inject_wrap': ('User sent a message while you were '
|
||
'working:\n{text}\n'
|
||
'Please take it into consideration and '
|
||
'adjust direction if needed.'),
|
||
|
||
# shell-mode magic (`!` prefix)
|
||
'shell.hint': '! for shell mode',
|
||
'shell.timeout': '[shell: timeout {sec}s]',
|
||
'shell.error': '[shell error: {err}]',
|
||
'shell.empty': '(no output)',
|
||
'shell.history': '[!shell {sh}] {cmd}\n```\n{out}\n```\n(exit {rc})',
|
||
|
||
# /resume
|
||
'cmd.resume.desc': 'list recent sessions and pick one to recover',
|
||
'help.resume': ' /resume List recent sessions and recover one',
|
||
},
|
||
|
||
'zh': {
|
||
# /help intro & rows — 措辞与 v2 COMMANDS 表对齐。
|
||
'help.title': '命令:',
|
||
'help.help': ' /help 显示帮助',
|
||
'help.status': ' /status 查看会话状态',
|
||
'help.sessions': ' /sessions 列出所有会话',
|
||
'help.llm': ' /llm [n] 查看 / 切换模型',
|
||
'help.btw': ' /btw <question> 旁问 — 不打断主 agent',
|
||
'help.review': ' /review [request] in-session 代码审查(直接输出报告)',
|
||
'help.rewind': ' /rewind [n] 回退最近 n 轮',
|
||
'help.continue': ' /continue [n|name] 列出 / 恢复历史会话',
|
||
'help.new': ' /new [name] 新建会话(清空当前会话)',
|
||
'help.rename': ' /rename <name> 重命名当前会话',
|
||
'help.clear': ' /clear 清空显示(不动 LLM 历史)',
|
||
'help.cost': ' /cost 显示当前会话 token 用量',
|
||
'help.verbose': ' /verbose 工具调用审计(↑↓ 选 · Enter 切换 · c 复制 · q 退)',
|
||
'help.export': ' /export [sub] 导出最后回复:clip / file [name] / all',
|
||
'help.stop': ' /stop 中止当前任务',
|
||
'help.language': ' /language [code] 查看 / 切换界面语言',
|
||
'help.update': ' /update [备注] 预览 upstream 提交与 diff,再 git pull(不 commit)',
|
||
'help.autorun': ' /autorun [seed] 进入 autonomous_operation 自主模式',
|
||
'help.morphling': ' /morphling [target] 启用 Morphling 蒸馏 / 吞噬外部技能',
|
||
'help.goal': ' /goal [goal] 进入 Goal 模式(需 condition 约束)',
|
||
'help.hive': ' /hive [target] 进入 Hive 多 worker 协作模式',
|
||
'help.conductor': ' /conductor [task] 交给 conductor.py 做多 subagent 编排',
|
||
'help.scheduler': ' /scheduler 多选启动 reflect 任务 / 查看 cron',
|
||
'help.emoji': ' /emoji [style] 切换 spinner 宠物样式(picker / 直接传参)',
|
||
'help.quit': ' /quit 退出',
|
||
'help.esc': ' Esc 撤回提问 · 清草稿 · 停任务(不退出)',
|
||
'help.cc': ' Ctrl+C × 2 退出(空闲时;运行中只 abort 任务)',
|
||
'help.cl': ' Ctrl+L 强制重画(睡眠唤醒后修复)',
|
||
'help.cz': ' Ctrl+Z / Ctrl+Y 撤销 / 重做 输入框编辑',
|
||
'help.shift_arrow': ' Shift+←→↑↓ 选中文字(Ctrl+C 复制 / Ctrl+X 剪切 / Ctrl+A 全选)',
|
||
|
||
# _CMDS palette entries — 与 /help 同源,命令面板单行显示。
|
||
'cmd.help.desc': '显示帮助',
|
||
'cmd.status.desc': '查看会话状态',
|
||
'cmd.llm.desc': '查看 / 切换模型',
|
||
'cmd.btw.desc': '旁问 — 不打断主 agent',
|
||
'cmd.review.desc': 'in-session 代码审查',
|
||
'cmd.rewind.desc': '回退最近 n 轮',
|
||
'cmd.continue.desc': '列出 / 恢复历史会话',
|
||
'cmd.new.desc': '新建会话',
|
||
'cmd.rename.desc': '重命名当前会话',
|
||
'cmd.clear.desc': '清空显示(不动 LLM 历史)',
|
||
'cmd.cost.desc': '显示当前会话 token 用量',
|
||
'cmd.verbose.desc': '工具调用审计',
|
||
'cmd.export.desc': '导出最后回复(剪贴板/文件/日志路径)',
|
||
'cmd.stop.desc': '中止当前任务',
|
||
'cmd.language.desc': '查看 / 切换界面语言',
|
||
'cmd.quit.desc': '退出',
|
||
|
||
# arg hints — 与 v2 对齐:小写 n、完整的 question 等。
|
||
'cmd.llm.arg': '[n]',
|
||
'cmd.btw.arg': '<question>',
|
||
'cmd.review.arg': '[request]',
|
||
'cmd.rewind.arg': '[n]',
|
||
'cmd.continue.arg': '[n|name]',
|
||
'cmd.new.arg': '[name]',
|
||
'cmd.rename.arg': '<name>',
|
||
'cmd.export.arg': '[clip|file|all]',
|
||
'cmd.language.arg': '[code]',
|
||
'cmd.update.arg': '[备注]',
|
||
'cmd.update.desc': '预览 upstream 提交与 diff,再 git pull(不 commit)',
|
||
'cmd.autorun.arg': '[seed]',
|
||
'cmd.autorun.desc': '进入 autonomous_operation 自主模式',
|
||
'cmd.morphling.arg': '[target]',
|
||
'cmd.morphling.desc': '启用 Morphling 蒸馏 / 吞噬外部技能',
|
||
'cmd.goal.arg': '[goal]',
|
||
'cmd.goal.desc': '进入 Goal 模式(需 condition 约束)',
|
||
'cmd.hive.arg': '[target]',
|
||
'cmd.hive.desc': '进入 Hive 多 worker 协作模式',
|
||
'cmd.conductor.arg': '[任务]',
|
||
'cmd.conductor.desc': '调用 frontends/conductor.py 做多 subagent 编排',
|
||
'cmd.scheduler.desc': '多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)',
|
||
'cmd.emoji.arg': '[样式]',
|
||
'cmd.emoji.desc': '切换 spinner 宠物表情 — 打开 picker;带参数则直接切换',
|
||
|
||
# status line
|
||
'status.asking': '◉ 待答 · Esc 撤回提问',
|
||
'status.running.tail': ' · Esc 停',
|
||
'status.tps': ' · {rate:.0f} tok/s',
|
||
'status.cc_confirm': '再按 Ctrl+C 退出',
|
||
'status.ready': '○ 就绪',
|
||
|
||
# /status
|
||
'status.title': ' 会话状态',
|
||
'status.label.model': 'model:',
|
||
'status.label.state': 'state:',
|
||
'status.label.rounds': 'rounds:',
|
||
'status.label.context': 'context:',
|
||
'status.label.cwd': 'cwd:',
|
||
'status.state.running': 'running · {verb} {elapsed}',
|
||
'status.state.waiting': 'waiting · ask_user pending',
|
||
'status.state.idle': 'idle',
|
||
'status.ctx.unknown': 'n/a',
|
||
'status.ctx.fmt': '{used:,} / {cap:,} ctx',
|
||
|
||
# banner
|
||
'banner.label.model': 'model:',
|
||
'banner.label.directory': 'directory:',
|
||
'banner.label.session': 'session:',
|
||
'banner.session.single': '单会话 · scrollback',
|
||
'banner.llm_hint': '/llm 切换',
|
||
|
||
# messages
|
||
'msg.ask_cancelled': '✗ 已撤回提问 · 可直接输入或重新发问',
|
||
'msg.abort_requested': '⏹ 已请求中止 · Esc',
|
||
'msg.abort_done': '⏹ 已请求中止',
|
||
'msg.idle_no_task': '(空闲,无任务)',
|
||
'msg.cleared': '新对话 · 上下文已清空',
|
||
'msg.new_session': '新会话 · 上一段对话已清空',
|
||
'msg.new_session_named': '新会话「{name}」· 上一段对话已清空',
|
||
'msg.renamed': '✎ 会话已重命名为「{name}」',
|
||
'msg.rewind': '↩ 回退 {n} 轮(移除 {removed} 条历史;scrollback 不可改,以此为界)',
|
||
'msg.no_rewindable': '没有可回退的轮次',
|
||
'msg.continue_loading': '┄┄ 载入 {name},以下为完整上文 ┄┄',
|
||
'msg.continue_ready': '┄┄ {msg} · 接着说即可 ┄┄',
|
||
'msg.llm_switched': 'LLM → {name}',
|
||
'msg.export_done': '已导出: {path}',
|
||
'msg.export_clipped': '已复制到剪贴板({n} 字符)',
|
||
'msg.export_clip_failed': '❌ 复制失败:未找到剪贴板工具',
|
||
'msg.export_all': '完整日志:\n{path}',
|
||
'msg.export_all_missing': '尚无日志文件',
|
||
'msg.review_empty': '(review 无输出)',
|
||
'msg.no_export': '(没有可导出的回答)',
|
||
'msg.no_tracker': '(暂无统计)',
|
||
'msg.no_history': ' 没有可恢复的历史会话',
|
||
'msg.no_llms': '(无可用 LLM)',
|
||
'msg.no_tools': ' (暂无工具调用记录)',
|
||
'msg.lang_current': '当前界面语言:{label}({code})',
|
||
'msg.lang_switched': '界面语言 → {label}',
|
||
'msg.btw_no_answer': '(无回答)',
|
||
'btw.title': '旁问 · Esc 清空',
|
||
'btw.querying': ' ⋯ 查询中…',
|
||
|
||
# plan card
|
||
'plan.header': '计划 ({done}/{total})',
|
||
'plan.complete': '✓ 计划完成 ({n}/{n})',
|
||
'plan.placeholder': '计划模式已激活',
|
||
'plan.waiting': '等待写入 {path} …',
|
||
'plan.overflow': '还有 {n} 项',
|
||
|
||
# errors
|
||
'err.running_blocked': '运行中,先 /stop 再用该命令',
|
||
'err.continue_usage': '用法: /continue 或 /continue N',
|
||
'err.index_oob': '❌ 索引越界(有效 1-{max})',
|
||
'err.btw_usage': '用法: /btw <旁问>(不污染主上下文)',
|
||
'err.rewind_usage': '用法:/rewind <n>',
|
||
'err.rename_usage': '用法:/rename <name>',
|
||
'err.rewind_range': '❌ 回退失败:n 应在 1-{max}',
|
||
'err.lang_usage': '用法:/language [code] (可选 code:{available})',
|
||
'err.lang_unknown': '未知语言代码:{code} (可选:{available})',
|
||
'err.unknown_cmd': '未知命令 /{name} — /help 看可用命令',
|
||
'err.multi_session': '/{name}:多会话后端尚未接入,命令已预留但暂未实现',
|
||
'err.menu_cb': '❌ 菜单回调失败: {err}',
|
||
'err.no_llm': 'No LLM configured — check mykey.py',
|
||
'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)',
|
||
'err.dep_missing': 'Error: {name} is not installed.',
|
||
'err.dep_install': 'Install with: pip install rich prompt_toolkit',
|
||
|
||
# menu / picker / palette
|
||
'menu.default_title': '选择',
|
||
'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消',
|
||
'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消',
|
||
'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区',
|
||
'menu.no_match': '无匹配',
|
||
'menu.free.hint': '回车以该路径新建/进入',
|
||
'ask.default_q': '请回答:',
|
||
'ask.title': '◉ 请回答',
|
||
'ask.pending': ' +{n} 待答',
|
||
'ask.hint.multi': '↳ ↑↓ 移动 · Space 标记 · Enter 提交 · Esc 撤回',
|
||
'ask.hint.single': '↳ ↑↓ 切换(选项 ⇄ 输入框)· Enter 确认 · Esc 撤回',
|
||
'ask.hint.freetext': '↳ ↑↓ 回到选项 · 输字符输入 · Enter 提交 · Esc 撤回',
|
||
|
||
# continue picker
|
||
'continue.title': '恢复历史会话',
|
||
'continue.occupied.title': '该会话正被占用(pid {p})—— 从原会话拷贝一份继续?',
|
||
'continue.occupied.copy': '拷贝一份继续',
|
||
'continue.occupied.cancel': '取消',
|
||
# /workspace(与 v2 一致;后端 workspace_cmd.py)
|
||
'cmd.workspace.arg': '[path|off]',
|
||
'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式',
|
||
'ws.entered': '✅ 已进入 workspace「{n}」',
|
||
'ws.fail': '❌ workspace 设定失败: {e}',
|
||
'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)',
|
||
'ws.inactive': '当前未处于 workspace 模式',
|
||
'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入',
|
||
'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)',
|
||
'ws.restored': '已恢复工作目录: {t}',
|
||
'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}',
|
||
'continue.unit.round': '轮',
|
||
|
||
# llm picker
|
||
'llm.title': '切换 LLM',
|
||
|
||
# emoji picker
|
||
'emoji.title': '选择 spinner 宠物样式',
|
||
'emoji.switched': '宠物样式 → `{style}`',
|
||
'emoji.unknown': '未知样式 `{choice}` — 可选:{valid}',
|
||
'emoji.row.current': '● {name:<8} {sample}',
|
||
'emoji.row.other': ' {name:<8} {sample}',
|
||
'emoji.row.off': '(隐藏 pet)',
|
||
|
||
# /scheduler picker (multi-pick reflect tasks / frontends)
|
||
'scheduler.pick.title': '挑选要启动的服务(已勾选 = 运行中,取消勾选即停止)',
|
||
'scheduler.pick.hint': 'Space 勾选 · ↑↓ 移动 · Enter 下一步 · Esc 取消 · 或 /scheduler start a,b,c',
|
||
'scheduler.cron.active': 'cron:sche_tasks/*.json 共 {n} 个任务 · 已激活(reflect/scheduler.py 在运行)',
|
||
'scheduler.cron.inactive': 'cron:sche_tasks/*.json 共 {n} 个任务 · 未激活(启动 reflect/scheduler.py 才会调度)',
|
||
'scheduler.empty': '(没有可启动的服务:reflect/*.py 与 frontends/*app*.py 均为空)',
|
||
'scheduler.no_pick': '(未选择任何服务)',
|
||
'scheduler.no_change': '(与当前运行集合相比无变化)',
|
||
'scheduler.running_tag': ' · 运行中',
|
||
'scheduler.confirm.title': '确认提交本次改动?',
|
||
'scheduler.confirm.hint': '←/→ 选择 · Enter 确认 · Esc 回退',
|
||
'scheduler.confirm.submit': '提交({n} 个服务:{names})',
|
||
'scheduler.confirm.edit': '回去修改选择',
|
||
'scheduler.diff.start': '▶ 启动 {n}:{names}',
|
||
'scheduler.diff.stop': '■ 停止 {n}:{names}',
|
||
'scheduler.cancelled': '已取消,未变更任何服务',
|
||
'scheduler.back_to_pick': '已回到选择界面',
|
||
'scheduler.usage_start': '用法:/scheduler start <服务名>[,<服务名2>...]',
|
||
|
||
# export picker
|
||
'export.title': '导出最后回复',
|
||
'export.opt.clip': '复制到剪贴板',
|
||
'export.opt.file': '保存到文件(temp/)',
|
||
'export.opt.all': '显示完整日志路径',
|
||
|
||
# rewind picker
|
||
'rewind.title': '选择回退到的轮次',
|
||
'rewind.option': '回退 {n} 轮 · {preview}',
|
||
|
||
# language picker
|
||
'lang.title': '切换界面语言',
|
||
|
||
# verbose
|
||
'verbose.title': ' Tool Trace',
|
||
'verbose.hint': ' ↑↓ 选 · PgUp/Dn 滚 · Enter 切换[{field}] · c 复制 · e 导出 · q 退',
|
||
'verbose.empty': '(空)',
|
||
|
||
# answer prefix
|
||
'msg.answer_prefix': '[答] {text}',
|
||
|
||
# pending input preview
|
||
'pending.head_running': '已排队 {n} 条 · 下个 turn 边界注入 · Esc 清空',
|
||
'pending.cleared': '已清空 {n} 条待发送消息',
|
||
'pending.queued_marker': '[排队] {text}',
|
||
'pending.inject_wrap': ('用户在你工作时发来了一条新消息:\n{text}\n'
|
||
'请将其纳入考虑,必要时调整方向。'),
|
||
|
||
# shell-mode magic (`!` prefix)
|
||
'shell.hint': '! 进入 shell 模式',
|
||
'shell.timeout': '[shell:{sec}s 超时]',
|
||
'shell.error': '[shell 错误:{err}]',
|
||
'shell.empty': '(无输出)',
|
||
'shell.history': '[!shell {sh}] {cmd}\n```\n{out}\n```\n(退出码 {rc})',
|
||
|
||
# /resume
|
||
'cmd.resume.desc': '列出最近会话并恢复其中一个',
|
||
'help.resume': ' /resume 列出最近会话并恢复其中一个',
|
||
},
|
||
}
|
||
|
||
|
||
def t(key: str, **fmt) -> str:
|
||
"""Translate `key` to current language; fall back to en then to key itself.
|
||
Missing format fields raise KeyError — caller bug, not i18n bug."""
|
||
val = _I18N.get(_LANG, {}).get(key)
|
||
if val is None and _LANG != 'en':
|
||
val = _I18N.get('en', {}).get(key)
|
||
if val is None:
|
||
val = key # visible breadcrumb for missing keys
|
||
if fmt:
|
||
try:
|
||
return val.format(**fmt)
|
||
except (KeyError, IndexError, ValueError):
|
||
return val
|
||
return val
|
||
|
||
|
||
def tip_count() -> int:
|
||
"""Number of rotating banner tips (same for every language)."""
|
||
return len(_TIPS['en'])
|
||
|
||
|
||
def tip(idx: int) -> str:
|
||
"""Banner tip at `idx`, resolved in the current language so a /language
|
||
switch relabels it on the next banner repaint."""
|
||
pool = _TIPS.get(_LANG) or _TIPS['en']
|
||
return pool[idx % len(pool)] if pool else ''
|
||
|
||
|
||
def get_lang() -> str:
|
||
return _LANG
|
||
|
||
|
||
def set_lang(code: str) -> bool:
|
||
"""Switch active language and persist. Returns True on success."""
|
||
global _LANG
|
||
if code not in _SUPPORTED:
|
||
return False
|
||
_LANG = code
|
||
_save_settings({'lang': code})
|
||
return True
|
||
|
||
|
||
def supported() -> tuple[str, ...]:
|
||
return _SUPPORTED
|
||
|
||
|
||
def _detect_system_lang() -> str:
|
||
"""System language: check LC_ALL → LC_MESSAGES → LANG → locale.getlocale().
|
||
Prefix `zh` → 'zh', else 'en'."""
|
||
for env in ('LC_ALL', 'LC_MESSAGES', 'LANG'):
|
||
v = os.environ.get(env)
|
||
if v:
|
||
if v.lower().startswith('zh'):
|
||
return 'zh'
|
||
return 'en'
|
||
try:
|
||
loc = locale.getlocale()[0] or ''
|
||
if loc.lower().startswith('zh'):
|
||
return 'zh'
|
||
except Exception:
|
||
pass
|
||
return _DEFAULT
|
||
|
||
|
||
def _load_settings() -> dict:
|
||
try:
|
||
with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _save_settings(patch: dict) -> None:
|
||
cur = _load_settings()
|
||
cur.update(patch)
|
||
try:
|
||
os.makedirs(os.path.dirname(_SETTINGS_PATH), exist_ok=True)
|
||
with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
||
json.dump(cur, f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def init_lang() -> str:
|
||
"""Resolve and install initial language: persisted > system > default.
|
||
Call once at startup; returns the resolved code."""
|
||
global _LANG
|
||
saved = _load_settings().get('lang')
|
||
if saved in _SUPPORTED:
|
||
_LANG = saved
|
||
else:
|
||
_LANG = _detect_system_lang()
|
||
return _LANG
|
||
|
||
|
||
# `_t` alias kept so the hundreds of existing `_t(...)` call sites are untouched.
|
||
_t = t
|
||
|
||
# Resolve language once on import so any module-level string (banner, _CMDS,
|
||
# /help) sees the right locale.
|
||
init_lang()
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
# Module-level `clip` shim: keep sb.py-style `clip.copy(...)` calls
|
||
# working without a separate clipboard module — the underlying funcs
|
||
# (copy, paste, paste_image) are defined later in this same file.
|
||
class _Clip:
|
||
@staticmethod
|
||
def copy(text): return copy(text)
|
||
@staticmethod
|
||
def paste(): return paste()
|
||
@staticmethod
|
||
def paste_image(): return paste_image()
|
||
clip = _Clip()
|
||
|
||
|
||
def _enable_windows_vt_mode() -> None:
|
||
"""Enable UTF-8 + ANSI escape processing on Windows consoles when possible."""
|
||
if not _IS_WINDOWS:
|
||
return
|
||
try:
|
||
import ctypes
|
||
kernel32 = ctypes.windll.kernel32
|
||
# Make classic conhost/cmd decode UTF-8 bytes written by _w(). This is
|
||
# harmless in mintty/Git Bash where these calls usually fail because the
|
||
# std handles are pipes/ptys rather than Win32 console handles.
|
||
kernel32.SetConsoleOutputCP(65001)
|
||
kernel32.SetConsoleCP(65001)
|
||
enable_vt = 0x0004
|
||
for handle_id in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE
|
||
handle = kernel32.GetStdHandle(handle_id)
|
||
mode = ctypes.c_uint32()
|
||
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
||
kernel32.SetConsoleMode(handle, mode.value | enable_vt)
|
||
except Exception:
|
||
# Safe fallback: modern terminals usually already support ANSI/UTF-8;
|
||
# older conhost may render escape codes, but the TUI should not crash.
|
||
pass
|
||
|
||
|
||
def _enter_utf8_charset() -> None:
|
||
"""Ask VT-compatible terminals to interpret subsequent bytes as UTF-8."""
|
||
# ESC % G is the ISO-2022/VT sequence for selecting UTF-8. It fixes some
|
||
# mintty/Git-Bash launches where the child process inherits a legacy locale
|
||
# and mojibakes UTF-8 box drawing/CJK into CP936-looking text.
|
||
_w('\x1b%G')
|
||
|
||
|
||
def _ptk_keypress_to_bytes(kp) -> bytes:
|
||
"""Map prompt_toolkit KeyPress objects to tui_v3's internal byte protocol.
|
||
|
||
prompt_toolkit normalizes platform-specific console input (Win32 console,
|
||
ConPTY, mintty/msys pty) into symbolic Keys. Keep the editor core small by
|
||
translating those symbols back to the bytes already handled by _feed/_keys.
|
||
"""
|
||
try:
|
||
from prompt_toolkit.keys import Keys
|
||
except Exception:
|
||
Keys = None # type: ignore[assignment]
|
||
|
||
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.
|
||
if isinstance(data, str) and data and (len(data) > 1 or (len(data) == 1 and ord(data) >= 0x20 and data != '\x7f')):
|
||
return data.encode('utf-8', 'replace')
|
||
|
||
name = getattr(key, 'name', str(key))
|
||
key_s = str(key)
|
||
norm = name.lower().replace('_', '-').replace('keys.', '')
|
||
norm_s = key_s.lower().replace('_', '-').replace('keys.', '')
|
||
aliases = {norm, norm_s}
|
||
|
||
def has(*needles: str) -> bool:
|
||
return any(n in a for a in aliases for n in needles)
|
||
|
||
# Enter submits; Ctrl+J / Shift+Enter insert newline when PTK can distinguish.
|
||
# Windows terminals send \r for both Enter/Shift+Enter — detect Shift physically.
|
||
if has('controlm', 'c-m') or key_s in ('\r', '\n'):
|
||
if _IS_WINDOWS:
|
||
import ctypes
|
||
if ctypes.windll.user32.GetAsyncKeyState(0x10) & 0x8000:
|
||
return b'\n'
|
||
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'):
|
||
return b'\x1c'
|
||
if has('down') and has('shift'):
|
||
return b'\x1d'
|
||
if has('left') and has('shift'):
|
||
return b'\x1e'
|
||
if has('right') and has('shift'):
|
||
return b'\x1f'
|
||
if has('up'):
|
||
return b'\x10'
|
||
if has('down'):
|
||
return b'\x0e'
|
||
if has('left'):
|
||
return b'\x02'
|
||
if has('right'):
|
||
return b'\x06'
|
||
# Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and
|
||
# 0x05 has no editor handler, so reusing them would break/no-op the keys.
|
||
if has('home'):
|
||
return b'\x07'
|
||
if has('end'):
|
||
return b'\x14'
|
||
if has('delete'):
|
||
return b'\x7f'
|
||
if has('backspace') or data == '\x7f' or data == '\x08':
|
||
return b'\x7f'
|
||
if has('escape') or data == '\x1b':
|
||
return b'\x1b'
|
||
|
||
ctrl = {
|
||
'controla': b'\x01', 'c-a': b'\x01',
|
||
'controlb': b'\x02', 'c-b': b'\x02',
|
||
'controlc': b'\x03', 'c-c': b'\x03',
|
||
'controld': b'\x04', 'c-d': b'\x04',
|
||
'controle': b'\x05', 'c-e': b'\x05',
|
||
'controlf': b'\x06', 'c-f': b'\x06',
|
||
'controlh': b'\x7f', 'c-h': b'\x7f',
|
||
'controlj': b'\n', 'c-j': b'\n',
|
||
'controlk': b'\x0b', 'c-k': b'\x0b',
|
||
'controll': b'\x0c', 'c-l': b'\x0c',
|
||
'controln': b'\x0e', 'c-n': b'\x0e',
|
||
'controlp': b'\x10', 'c-p': b'\x10',
|
||
'controlu': b'\x15', 'c-u': b'\x15',
|
||
'controlv': b'\x16', 'c-v': b'\x16',
|
||
'controlx': b'\x18', 'c-x': b'\x18',
|
||
'controly': b'\x19', 'c-y': b'\x19',
|
||
'controlz': b'\x1a', 'c-z': b'\x1a',
|
||
}
|
||
for a in aliases:
|
||
if a in ctrl:
|
||
return ctrl[a]
|
||
|
||
if isinstance(data, str) and data:
|
||
return data.encode('utf-8', 'replace')
|
||
return b''
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# cjk: CJK wrap monkey-patch for Rich
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
_CJK_RANGES = (
|
||
(0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x20000, 0x2A6DF),
|
||
(0x2A700, 0x2B73F), (0x2B740, 0x2B81F), (0x2B820, 0x2CEAF),
|
||
(0x2CEB0, 0x2EBEF), (0xF900, 0xFAFF), (0x2F800, 0x2FA1F),
|
||
(0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF),
|
||
(0x31F0, 0x31FF), (0xFF00, 0xFFEF), (0xAC00, 0xD7AF),
|
||
)
|
||
|
||
|
||
def _is_cjk(ch: str) -> bool:
|
||
cp = ord(ch)
|
||
return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
|
||
|
||
|
||
def _is_wide(ch: str) -> bool:
|
||
try:
|
||
from rich.cells import cell_len
|
||
return cell_len(ch) == 2
|
||
except ImportError:
|
||
return _is_cjk(ch)
|
||
|
||
|
||
def install_cjk_wrap() -> bool:
|
||
"""Monkey-patch Rich's word-wrap to handle CJK char-level breaks.
|
||
Returns True on success, False on fallback."""
|
||
try:
|
||
import rich._wrap as wrap_mod
|
||
from rich.cells import cell_len
|
||
except (ImportError, AttributeError) as e:
|
||
log.warning("CJK patch skipped: %s", e)
|
||
return False
|
||
|
||
orig_divide = getattr(wrap_mod, 'divide_line', None)
|
||
if orig_divide is None:
|
||
log.warning("CJK patch skipped: Rich lacks divide_line")
|
||
return False
|
||
|
||
def _patched_divide_line(text, width, fold=True):
|
||
divides = set()
|
||
line_width = 0
|
||
for i, ch in enumerate(text._text if hasattr(text, '_text') else str(text)):
|
||
char_w = cell_len(ch) if ch != '\n' else 0
|
||
if line_width + char_w > width and line_width > 0:
|
||
if _is_wide(ch) or fold:
|
||
divides.add(i)
|
||
line_width = char_w
|
||
continue
|
||
line_width += char_w
|
||
if ch == '\n':
|
||
line_width = 0
|
||
# Merge with original for non-CJK content
|
||
try:
|
||
orig_divides = orig_divide(text, width, fold)
|
||
divides.update(orig_divides)
|
||
except Exception:
|
||
pass
|
||
return sorted(divides)
|
||
|
||
try:
|
||
wrap_mod.divide_line = _patched_divide_line
|
||
log.info("CJK wrap patch installed for Rich %s", _rich_version())
|
||
return True
|
||
except Exception as e:
|
||
log.warning("CJK patch failed: %s", e)
|
||
return False
|
||
|
||
|
||
def _rich_version() -> str:
|
||
try:
|
||
from importlib.metadata import version
|
||
return version('rich')
|
||
except Exception:
|
||
return '?'
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# clipboard: cross-platform copy/paste via native tools
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
_TEMP_DIR = os.path.join(tempfile.gettempdir(), 'genericagent_tui')
|
||
_platform = sys.platform
|
||
_HAS_WAYLAND = bool(os.environ.get('WAYLAND_DISPLAY'))
|
||
|
||
|
||
def _run(cmd: list[str], input: bytes | None = None, timeout: float = 3.0) -> bytes | None:
|
||
try:
|
||
r = subprocess.run(cmd, input=input, capture_output=True, timeout=timeout)
|
||
return r.stdout if r.returncode == 0 else None
|
||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||
log.debug("clipboard cmd %s failed: %s", cmd, e)
|
||
return None
|
||
|
||
|
||
def copy(text: str) -> bool:
|
||
data = text.encode('utf-8')
|
||
if _platform == 'darwin':
|
||
return _run(['pbcopy'], input=data) is not None
|
||
if _platform == 'win32':
|
||
return _run(['clip.exe'], input=data) is not None
|
||
if _HAS_WAYLAND and shutil.which('wl-copy'):
|
||
return _run(['wl-copy'], input=data) is not None
|
||
if shutil.which('xclip'):
|
||
return _run(['xclip', '-selection', 'clipboard'], input=data) is not None
|
||
if shutil.which('xsel'):
|
||
return _run(['xsel', '--clipboard', '--input'], input=data) is not None
|
||
log.warning("No clipboard tool found")
|
||
return False
|
||
|
||
|
||
def paste() -> str | None:
|
||
out: bytes | None = None
|
||
if _platform == 'darwin':
|
||
out = _run(['pbpaste'])
|
||
elif _platform == 'win32':
|
||
out = _run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard'])
|
||
elif _HAS_WAYLAND and shutil.which('wl-paste'):
|
||
out = _run(['wl-paste', '--no-newline'])
|
||
elif shutil.which('xclip'):
|
||
out = _run(['xclip', '-selection', 'clipboard', '-o'])
|
||
elif shutil.which('xsel'):
|
||
out = _run(['xsel', '--clipboard', '--output'])
|
||
if out is not None:
|
||
return out.decode('utf-8', errors='replace')
|
||
return None
|
||
|
||
|
||
def paste_image() -> str | None:
|
||
"""Save clipboard image to temp file, return path or None."""
|
||
os.makedirs(_TEMP_DIR, exist_ok=True)
|
||
import time
|
||
path = os.path.join(_TEMP_DIR, f'clip_{int(time.time()*1000)}.png')
|
||
ok = False
|
||
if _platform == 'darwin':
|
||
script = (
|
||
'use framework "AppKit"\n'
|
||
'set pb to current application\'s NSPasteboard\'s generalPasteboard()\n'
|
||
'set imgData to pb\'s dataForType:"public.png"\n'
|
||
'if imgData is missing value then error "no image"\n'
|
||
'imgData\'s writeToFile:"' + path + '" atomically:true\n'
|
||
)
|
||
ok = _run(['osascript', '-e', script], timeout=5.0) is not None
|
||
elif _HAS_WAYLAND and shutil.which('wl-paste'):
|
||
data = _run(['wl-paste', '-t', 'image/png'])
|
||
if data:
|
||
with open(path, 'wb') as f:
|
||
f.write(data)
|
||
ok = True
|
||
elif shutil.which('xclip'):
|
||
data = _run(['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'])
|
||
if data and len(data) > 8:
|
||
with open(path, 'wb') as f:
|
||
f.write(data)
|
||
ok = True
|
||
return path if ok and os.path.isfile(path) else None
|
||
|
||
|
||
def _grab_clipboard_file() -> tuple[str, bool] | None:
|
||
"""Return (path, is_image) from the clipboard via PIL — ported from v2.
|
||
|
||
PIL.ImageGrab.grabclipboard() is the one cross-platform path that also
|
||
works on Windows (osascript/xclip/wl-paste below don't). It handles two
|
||
shapes: a list of copied file paths, or a raw bitmap Image (saved to a
|
||
temp PNG). is_image distinguishes images (→ `[Image #N]`, sent to the
|
||
model) from any other file (→ `[File #N]`, expanded to its path)."""
|
||
try:
|
||
from PIL import ImageGrab, Image
|
||
data = ImageGrab.grabclipboard()
|
||
except Exception:
|
||
return None
|
||
if isinstance(data, list):
|
||
for item in data:
|
||
if isinstance(item, str) and os.path.isfile(item):
|
||
return (item, os.path.splitext(item)[1].lower() in _IMAGE_EXTS)
|
||
return None
|
||
if isinstance(data, Image.Image):
|
||
try:
|
||
os.makedirs(_TEMP_DIR, exist_ok=True)
|
||
path = os.path.join(_TEMP_DIR, f'clip_{int(time.time() * 1000)}.png')
|
||
data.save(path, 'PNG')
|
||
return (path, True)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _cleanup():
|
||
if os.path.isdir(_TEMP_DIR):
|
||
shutil.rmtree(_TEMP_DIR, ignore_errors=True)
|
||
# Drop this run's signal dir if it never accumulated an in-flight file.
|
||
try: _rmdir_if_empty(os.path.join(_ROOT, 'temp', f'_tui_v3_{os.getpid()}'))
|
||
except Exception: pass
|
||
|
||
atexit.register(_cleanup)
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# renderer: markdown / ANSI sanitisation / fold
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Comprehensive ANSI sanitization — matches v2's thoroughness
|
||
_ANSI_INCOMPLETE_RE = re.compile(r'\x1b\[[0-9;]*$')
|
||
_ANSI_DEC_PRIVATE_RE = re.compile(r'\x1b\[\?[0-9;]*[a-zA-Z]')
|
||
_ANSI_OSC_RE = re.compile(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?')
|
||
_ANSI_MODE_SET_RE = re.compile(r'\x1b[=>][0-9]*')
|
||
# Keep SGR (color) codes, strip everything else
|
||
_ANSI_SGR_RE = re.compile(r'\x1b\[[0-9;]*m')
|
||
|
||
# agent_loop.py emits `**LLM Running (Turn N) ...**` by default but switches
|
||
# to the short `**Turn N ...**` when `handler.parent.task_dir` is set
|
||
# (agent_loop.py:52). TUI v3 sets task_dir (for `_stop` signal + `!cmd`
|
||
# shell history via `_intervene`), so we must match both forms.
|
||
_TURN_MARKER_RE = re.compile(r'\*\*(?:LLM Running \()?Turn (\d+)\)?[^\n]*?\*\*')
|
||
_META_TAG_RE = re.compile(r'<(?:thinking|summary|tool_use|file_content)>.*?</(?:thinking|summary|tool_use|file_content)>', re.DOTALL)
|
||
_TOOL_USE_BLOCK_RE = re.compile(r'```json\s*\{[^}]*"tool_name"[^}]*\}\s*```', re.DOTALL)
|
||
_TOOL_USE_TAG_RE = re.compile(r'<tool_use>\s*\{.*?"tool_name"\s*:\s*"([^"]+)".*?\}\s*</tool_use>', re.DOTALL)
|
||
_SUMMARY_RE = re.compile(r'<summary>\s*(.*?)\s*</summary>', re.DOTALL)
|
||
_QUAD_BACKTICK_RE = re.compile(r'(`{4,})')
|
||
_ASK_USER_RE = re.compile(r'"tool_name"\s*:\s*"ask_user".*?"question"\s*:\s*"([^"]*)"', re.DOTALL)
|
||
|
||
# v2 fold_turns helpers (tuiapp_v2.py:240-267). 4-fence stash keeps a tool
|
||
# result's `` ``` `` from being misread as a turn boundary; the per-turn
|
||
# `<summary>` regex uses a negative lookahead so two adjacent summaries don't
|
||
# merge; the title cleaner strips fenced code + thinking before extraction.
|
||
_FENCE4_STASH_RE = re.compile(r'^`{4,}.*?^`{4,}\n?', re.DOTALL | re.MULTILINE)
|
||
# Same as _TURN_MARKER_RE but capturing the WHOLE match for str.split() to
|
||
# keep the marker as a separator token in the result list.
|
||
_TURN_SPLIT_FOLD_RE = re.compile(r'(\*\*(?:LLM Running \()?Turn \d+\)? \.\.\.\*\*)')
|
||
_SUMMARY_PERTURN_RE = re.compile(r'<summary>\s*((?:(?!<summary>).)*?)\s*</summary>', re.DOTALL)
|
||
_TITLE_CLEAN_RE = re.compile(r'`{3,}.*?`{3,}|<thinking>.*?</thinking>', re.DOTALL)
|
||
_TITLE_ARGS_TAIL_RE = re.compile(r',?\s*args:.*$')
|
||
|
||
|
||
@dataclass
|
||
class FoldSegment:
|
||
title: str
|
||
body: str
|
||
turn: int
|
||
is_last: bool = False
|
||
|
||
|
||
@dataclass
|
||
class Block:
|
||
"""A unit of finalized scrollback history, stored as SOURCE (not rendered
|
||
lines). Resize replays each block through its renderer at the new width,
|
||
so width-baked structures (chip boxes, banner box) reflow correctly. The
|
||
actual scrollback bytes above the viewport stay frozen at the old width —
|
||
that's a terminal physics constraint — but the viewport and everything
|
||
new flows correctly."""
|
||
kind: str # 'user' | 'assistant' | 'plain' | 'banner'
|
||
source: str # source text (or '' for banner — regenerated on demand)
|
||
tool_n: int = 0 # tool count cached at last render (for stable tids)
|
||
|
||
|
||
def sanitize_ansi(text: str) -> str:
|
||
"""Strip non-SGR ANSI escapes and incomplete sequences from streaming chunks."""
|
||
text = _ANSI_DEC_PRIVATE_RE.sub('', text)
|
||
text = _ANSI_OSC_RE.sub('', text)
|
||
text = _ANSI_MODE_SET_RE.sub('', text)
|
||
text = _ANSI_INCOMPLETE_RE.sub('', text)
|
||
return text
|
||
|
||
|
||
def _render_checkboxes(text: str) -> str:
|
||
"""Convert markdown task lists to visual checkboxes."""
|
||
text = re.sub(r'^(\s*[-*+]\s)\[ \]', r'\1☐', text, flags=re.MULTILINE)
|
||
text = re.sub(r'^(\s*[-*+]\s)\[x\]', r'\1☑', text, flags=re.MULTILINE | re.IGNORECASE)
|
||
return text
|
||
|
||
|
||
def strip_meta_tags(text: str) -> str:
|
||
"""Strip internal tags, render tool_use as readable summaries."""
|
||
def _tool_replace(m):
|
||
name = m.group(1)
|
||
if name == 'ask_user':
|
||
q_match = _ASK_USER_RE.search(m.group(0))
|
||
if q_match:
|
||
return f'> {q_match.group(1)}'
|
||
return f'🔧 {name}'
|
||
text = _TOOL_USE_TAG_RE.sub(_tool_replace, text)
|
||
text = _META_TAG_RE.sub('', text)
|
||
text = _TOOL_USE_BLOCK_RE.sub('', text)
|
||
text = _render_checkboxes(text)
|
||
return re.sub(r'\n{3,}', '\n\n', text).strip()
|
||
|
||
|
||
def _extract_title(text: str, max_len: int = 72) -> str:
|
||
m = _SUMMARY_RE.search(text)
|
||
if m:
|
||
title = m.group(1).strip()
|
||
else:
|
||
first = text.strip().split('\n')[0] if text.strip() else ''
|
||
title = re.sub(r'^[#*>\-\s]+', '', first).strip()
|
||
if len(title) > max_len:
|
||
title = title[:max_len - 1] + '…'
|
||
return title or '...'
|
||
|
||
|
||
def fold_segments(text: str) -> list[FoldSegment]:
|
||
"""Split agent response into per-turn fold segments."""
|
||
if not text:
|
||
return []
|
||
cleaned = _QUAD_BACKTICK_RE.sub(lambda m: '~' * len(m.group(1)), text)
|
||
parts = _TURN_MARKER_RE.split(cleaned)
|
||
if len(parts) <= 1:
|
||
return [FoldSegment(title=_extract_title(text), body=text, turn=1, is_last=True)]
|
||
segments: list[FoldSegment] = []
|
||
if parts[0].strip():
|
||
segments.append(FoldSegment(title=_extract_title(parts[0]), body=parts[0], turn=0))
|
||
for i in range(1, len(parts), 2):
|
||
turn_num = int(parts[i]) if i < len(parts) else len(segments) + 1
|
||
body = parts[i + 1] if i + 1 < len(parts) else ''
|
||
body = body.replace('~' * 4, '````')
|
||
segments.append(FoldSegment(title=_extract_title(body), body=body, turn=turn_num))
|
||
if segments:
|
||
segments[-1].is_last = True
|
||
return segments
|
||
|
||
|
||
# Render cache: (content_hash, width) -> rendered object
|
||
_render_cache: dict[tuple[int, int], object] = {}
|
||
_CACHE_MAX = 200
|
||
|
||
|
||
class HardBreakMarkdown(Markdown):
|
||
"""Markdown that treats softbreaks as hardbreaks, preserving code blocks."""
|
||
def __init__(self, markup: str, **kwargs):
|
||
lines = []
|
||
in_code = False
|
||
for line in markup.split('\n'):
|
||
stripped = line.strip()
|
||
if stripped.startswith('```'):
|
||
in_code = not in_code
|
||
if in_code:
|
||
lines.append(line)
|
||
else:
|
||
lines.append(line + ' ')
|
||
super().__init__('\n'.join(lines), **kwargs)
|
||
|
||
|
||
def _markdown_to_text(cleaned: str, width: int) -> Text:
|
||
"""Render Markdown to a CONCRETE Text (v2 approach). A Textual Static holding
|
||
a live rich.markdown.Markdown does not re-composite reliably when scrolled
|
||
past the viewport (height measurement is unstable → frozen/blank scroll);
|
||
a pre-rendered Text has a fixed line count and scrolls correctly."""
|
||
from io import StringIO
|
||
from rich.console import Console
|
||
buf = StringIO()
|
||
Console(file=buf, width=max(1, width), force_terminal=True,
|
||
color_system='truecolor', legacy_windows=False
|
||
).print(HardBreakMarkdown(cleaned), end='')
|
||
return Text.from_ansi(buf.getvalue().rstrip('\n'))
|
||
|
||
|
||
def render_message(text: str, role: str = 'assistant', width: int = 0) -> Text:
|
||
"""Render a message to a concrete Text. width<=0 → provisional plain text
|
||
(the widget re-renders via on_resize once its real width is known)."""
|
||
cleaned = strip_meta_tags(text) if role == 'assistant' else text
|
||
if not cleaned.strip():
|
||
cleaned = '...'
|
||
if role == 'system':
|
||
return Text(cleaned, style='dim')
|
||
if width <= 0:
|
||
return Text(cleaned)
|
||
key = (hash(cleaned), width)
|
||
cached = _render_cache.get(key)
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
result = _markdown_to_text(cleaned, width)
|
||
except Exception:
|
||
result = Text(cleaned)
|
||
if len(_render_cache) >= _CACHE_MAX:
|
||
for k in list(_render_cache.keys())[:_CACHE_MAX // 4]:
|
||
_render_cache.pop(k, None)
|
||
_render_cache[key] = result
|
||
return result
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# protocol: AgentBridge + typed events over display_queue
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass(frozen=True)
|
||
class StreamEvent:
|
||
text: str
|
||
turn: int = 0
|
||
source: str = "user"
|
||
|
||
@dataclass(frozen=True)
|
||
class DoneEvent:
|
||
text: str
|
||
turn: int = 0
|
||
source: str = "user"
|
||
outputs: list[str] = field(default_factory=list)
|
||
|
||
@dataclass(frozen=True)
|
||
class AskUserEvent:
|
||
question: str
|
||
candidates: list[str] = field(default_factory=list)
|
||
|
||
@dataclass(frozen=True)
|
||
class SystemEvent:
|
||
text: str
|
||
|
||
@dataclass(frozen=True)
|
||
class ErrorEvent:
|
||
message: str
|
||
exception: Exception | None = None
|
||
|
||
AgentEvent = StreamEvent | DoneEvent | AskUserEvent | SystemEvent | ErrorEvent
|
||
|
||
_HOOK_KEY = '_tui_v3_ask_user'
|
||
|
||
|
||
def _extract_ask_user(ctx: dict | None) -> AskUserEvent | None:
|
||
er = (ctx or {}).get('exit_reason') or {}
|
||
if er.get('result') != 'EXITED':
|
||
return None
|
||
payload = er.get('data') or {}
|
||
if payload.get('status') != 'INTERRUPT' or payload.get('intent') != 'HUMAN_INTERVENTION':
|
||
return None
|
||
data = payload.get('data') or {}
|
||
candidates = data.get('candidates') or []
|
||
# v2 parity: skip the ask card when the agent didn't supply candidates —
|
||
# the 'Waiting for your answer ...' marker already lands in scrollback as
|
||
# part of the assistant stream, and the user replies via the normal input
|
||
# box. Pushing an empty-candidate event onto the queue would route us
|
||
# through _enter_ask → free-text ask card, which freezes the live region
|
||
# in some terminals.
|
||
if not candidates:
|
||
return None
|
||
return AskUserEvent(
|
||
question=data.get('question', ''),
|
||
candidates=candidates,
|
||
)
|
||
|
||
|
||
class AgentBridge:
|
||
"""Wraps GenericAgent for the TUI. One bridge per session."""
|
||
|
||
def __init__(self, llm_no: int = 0):
|
||
self.agent = GeneraticAgent()
|
||
self.agent.llm_no = llm_no
|
||
if llm_no and hasattr(self.agent, 'llmclients') and self.agent.llmclients:
|
||
self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)]
|
||
self.agent.inc_out = True
|
||
self.agent.verbose = True
|
||
from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT
|
||
self.agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT)
|
||
# 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。
|
||
# /workspace 绑定时改为项目名 + 真实路径。
|
||
self.agent._ga_project_mode_name = None
|
||
self.agent._ga_project_mode_workspace_path = ''
|
||
# task_dir path enables ga's `_keyinfo` / `_intervene` consume paths.
|
||
# PID-scoped so concurrent v3 processes don't share signal files.
|
||
# Only the *path* is set here; the dir is created lazily by the writer
|
||
# (`inject_intervene`) when a signal is actually injected. Eager
|
||
# makedirs left a stale empty `temp/_tui_v3_<pid>` behind for every
|
||
# run that never used intervene; `consume_file` tolerates a missing dir.
|
||
self.agent.task_dir = os.path.join(_ROOT, 'temp', f'_tui_v3_{os.getpid()}')
|
||
self.ask_user_queue: queue.Queue[AskUserEvent] = queue.Queue()
|
||
# Wrapped user messages we appended to `_intervene` since the last
|
||
# turn boundary. At a non-exit boundary the file was consumed and
|
||
# next_prompt now carries our text — clear the list. At an exit
|
||
# boundary the file was consumed but next_prompt is discarded — replay
|
||
# via put_task so the user's words aren't lost.
|
||
self._intervene_pending: list[str] = []
|
||
self._intervene_lk = threading.Lock()
|
||
# Display queue created when an exit-boundary replay re-submits queued
|
||
# user messages (see `_on_turn_end`). Handed to the UI via
|
||
# `take_replay_dq` so it drains the follow-up run; without this the
|
||
# replayed turn streams headless — recorded in model_responses but
|
||
# never shown in the current TUI session.
|
||
self._replay_dq: queue.Queue | None = None
|
||
self._install_hook()
|
||
self._healthy = True
|
||
self._init_error: str | None = None
|
||
if not getattr(self.agent, 'llmclient', None):
|
||
self._healthy = False
|
||
self._init_error = _t('err.no_llm')
|
||
# 原地复原:本会话出生即持有自己日志的锁,使占用检测对它可见(别的会话据此判活)。
|
||
try:
|
||
from frontends import continue_cmd as _cc
|
||
_cc.acquire_birth_lock(self.agent)
|
||
except Exception:
|
||
pass
|
||
self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent')
|
||
self._runner.start()
|
||
|
||
def inject_intervene(self, text: str, *, track: bool = False) -> bool:
|
||
"""Append `text` to `<task_dir>/_intervene`. ga.turn_end_callback
|
||
consumes the file at the next turn boundary and prepends `[MASTER]
|
||
...` to next_prompt. Returns False when the agent is idle (caller
|
||
falls back to put_task). Append-mode keeps us idempotent under
|
||
the consume_file race. `track=True` records the text so the
|
||
turn_end hook can replay it on an exit boundary (used for queued
|
||
user messages — `!cmd` shell facts don't need replay)."""
|
||
td = getattr(self.agent, 'task_dir', None)
|
||
if not td or not getattr(self.agent, 'is_running', False):
|
||
return False
|
||
try: os.makedirs(td, exist_ok=True)
|
||
except Exception: return False
|
||
fp = os.path.join(td, '_intervene')
|
||
try:
|
||
sep = ''
|
||
try:
|
||
if os.path.getsize(fp) > 0: sep = '\n\n'
|
||
except OSError: pass
|
||
with open(fp, 'a', encoding='utf-8') as f:
|
||
f.write(sep + text)
|
||
if track:
|
||
with self._intervene_lk:
|
||
self._intervene_pending.append(text)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _run_safe(self):
|
||
try:
|
||
self.agent.run()
|
||
except Exception as e:
|
||
self._healthy = False
|
||
self._init_error = str(e)
|
||
|
||
def _install_hook(self):
|
||
if not hasattr(self.agent, '_turn_end_hooks'):
|
||
self.agent._turn_end_hooks = {}
|
||
self.agent._turn_end_hooks[_HOOK_KEY] = self._on_turn_end
|
||
|
||
def _on_turn_end(self, ctx: dict):
|
||
ev = _extract_ask_user(ctx)
|
||
if ev:
|
||
self.ask_user_queue.put(ev)
|
||
with self._intervene_lk:
|
||
if not self._intervene_pending:
|
||
return
|
||
if (ctx or {}).get('exit_reason'):
|
||
combined = '\n\n'.join(self._intervene_pending)
|
||
self._intervene_pending = []
|
||
try: self._replay_dq = self.agent.put_task(combined, source='user')
|
||
except Exception: pass
|
||
else:
|
||
self._intervene_pending = []
|
||
|
||
def take_replay_dq(self) -> "queue.Queue | None":
|
||
"""Hand off the display_queue from an exit-boundary replay once.
|
||
|
||
If a queued mid-run user message is consumed on the same boundary that
|
||
exits the current task, ga discards next_prompt. `_on_turn_end` replays
|
||
it with put_task; the TUI must then drain that returned queue or the
|
||
reply is written only to model_responses and appears only after
|
||
/continue.
|
||
"""
|
||
with self._intervene_lk:
|
||
dq, self._replay_dq = self._replay_dq, None
|
||
return dq
|
||
|
||
def submit(self, query: str, images: list | None = None) -> queue.Queue:
|
||
return self.agent.put_task(query, source='user', images=images)
|
||
|
||
def abort(self):
|
||
self.agent.abort()
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
return self.agent.is_running
|
||
|
||
@property
|
||
def llm_name(self) -> str:
|
||
try:
|
||
return self.agent.get_llm_name()
|
||
except Exception:
|
||
return '?'
|
||
|
||
@property
|
||
def llm_model(self) -> str:
|
||
"""The concrete model id in use (e.g. claude-opus-4-8), not the
|
||
channel group `llm_name` returns. Empty string when unavailable."""
|
||
try:
|
||
return self.agent.get_llm_name(model=True) or ''
|
||
except Exception:
|
||
return ''
|
||
|
||
def list_llms(self) -> list[tuple[int, str, bool]]:
|
||
return self.agent.list_llms()
|
||
|
||
def switch_llm(self, n: int):
|
||
self.agent.next_llm(n)
|
||
|
||
def drain_display_queue(self, dq: queue.Queue, timeout: float = 0.25):
|
||
"""Generator: yields typed events from a display_queue."""
|
||
while True:
|
||
try:
|
||
item = dq.get(timeout=timeout)
|
||
except queue.Empty:
|
||
yield None
|
||
continue
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if 'done' in item:
|
||
yield DoneEvent(
|
||
text=item['done'],
|
||
turn=item.get('turn', 0),
|
||
source=item.get('source', 'user'),
|
||
outputs=item.get('outputs', []),
|
||
)
|
||
break
|
||
if 'next' in item:
|
||
yield StreamEvent(
|
||
text=item['next'],
|
||
turn=item.get('turn', 0),
|
||
source=item.get('source', 'user'),
|
||
)
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# sb: scrollback-first TUI core (input, paint, flow, ask, /verbose, …)
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Prose hierarchy via ATTRIBUTES only (bold/italic/underline) — NO dim for body
|
||
# content (dim on white = unreadable grey). Keep normal prose inherited from the
|
||
# surrounding tile; avoid Rich's inline-code reverse without pinning prose dark.
|
||
_MD_THEME = Theme({
|
||
'markdown.h1': 'bold underline', 'markdown.h2': 'bold underline',
|
||
'markdown.h3': 'bold', 'markdown.h4': 'bold',
|
||
'markdown.h5': 'bold', 'markdown.h6': 'bold',
|
||
'markdown.strong': 'bold', 'markdown.em': 'italic',
|
||
# Rich's default inline-code ``reverse`` can vanish on themed tiles; keep it
|
||
# bold but inherited so it stays readable on both light and dark surfaces.
|
||
'markdown.code': 'bold', 'markdown.code_block': 'none',
|
||
'markdown.block_quote': 'italic', 'markdown.hr': 'none',
|
||
'markdown.link': 'underline', 'markdown.link_url': 'underline',
|
||
# Bullet inherits the surrounding foreground (a pinned dark hue vanished on
|
||
# dark terminals); bold alone keeps it visible on any background.
|
||
'markdown.item.bullet': 'bold',
|
||
}, inherit=True)
|
||
|
||
|
||
PROMPT = '❯ '
|
||
CONT = ' '
|
||
# macOS Terminal.app quantises ALL truecolor escapes to their nearest 256-color
|
||
# slot, and the slot it picks for #5e6ad2 (iTerm lavender) is 62/#5f5fd7 — that's
|
||
# the "blue" border the user sees. It also renders \x1b[2m as a heavy 30%-opacity
|
||
# multiply instead of the gentle blend iTerm does — that's the "heavy shadow".
|
||
# Branching on TERM_PROGRAM lets iTerm keep its truecolor + dim look, while
|
||
# Apple_Terminal uses pinned 256-color slots that match iTerm's RENDERED result.
|
||
_IS_APPLE_TERMINAL = os.environ.get('TERM_PROGRAM') == 'Apple_Terminal'
|
||
_RST = '\x1b[0m'
|
||
if _IS_APPLE_TERMINAL:
|
||
_DIM = '\x1b[38;5;244m' # mid-gray — no \x1b[2m, no "shadow"
|
||
_ACCENT = '\x1b[38;5;105m' # 256-slot light purple, closest to iTerm rendered look
|
||
_BORDER = '\x1b[38;5;146m' # light lavender
|
||
else:
|
||
_DIM = '\x1b[2m'
|
||
_ACCENT = '\x1b[38;2;94;106;210m' # Linear lavender #5e6ad2
|
||
_BORDER = '\x1b[38;5;146m'
|
||
_INK_U = '\x1b[38;5;234m' # user ink — kept for legacy callers
|
||
# User-prompt panel. Charcoal block (RGB 55,55,55) with soft-white ink —
|
||
# full-row tile via _tile() means the band keeps its right edge on every
|
||
# terminal regardless of wrap-width math. Switched from xterm-
|
||
# 256 inverse (which renders muddy on Win Terminal dark themes) to truecolor.
|
||
_TILE_U = '\x1b[48;2;55;55;55m\x1b[38;2;230;230;230m'
|
||
_MARK = _ACCENT + '❯' + _RST # prompt mark — the single accent
|
||
# Shell-mode (`!` magic prefix) accents — vivid pink so it stands out from
|
||
# the normal accent purple without clashing with the heat-counter reds.
|
||
_SHELL_ACCENT = '\x1b[38;5;205m' # hot pink for border / prompt mark
|
||
_SHELL_BG = '\x1b[48;2;65;60;65m' # 65,60,65 charcoal-magenta (per spec)
|
||
_SHELL_MARK = _SHELL_ACCENT + '!' + _RST
|
||
# Full-row tile for committed shell rows (echo + each output line), so the
|
||
# pair reads as one block in scrollback, matching cc-style. Slightly
|
||
# warmer than _TILE_U (55,55,55) so the two row kinds are distinguishable
|
||
# when interleaved. Black-terminal only — light themes get the bare band.
|
||
_TILE_SHELL = '\x1b[48;2;65;60;65m\x1b[38;2;230;230;230m'
|
||
_BG_TOK = {str(n) for n in list(range(40, 48)) + [49] + list(range(100, 108))}
|
||
_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m')
|
||
_CSI_ERASE_RE = re.compile(r'\x1b\[[0-9;?]*[JK]')
|
||
_SGR_TOKEN_RE = re.compile(r'\x1b\[[0-9;]*m')
|
||
|
||
|
||
def _tile(s: str, style: str, width: int | None = None) -> str:
|
||
# Re-assert style after every reset so muted-markdown \x1b[0m can't punch
|
||
# a hole in the block. When `width` is provided we pad with explicit
|
||
# bg-active spaces — prompt-toolkit's cell renderer doesn't honour
|
||
# \x1b[K (erase-to-EOL) inside its own buffer, so PTK-bound scrollback
|
||
# would otherwise leave the row gap exposed. Fall back to \x1b[K for
|
||
# legacy callers writing straight to the terminal (no PTK), where the
|
||
# erase command still fills correctly.
|
||
body = style + s.replace(_RST, _RST + style)
|
||
if width is None:
|
||
return body + '\x1b[K' + _RST
|
||
visible = _SGR_TOKEN_RE.sub('', s)
|
||
pad = max(0, width - cell_len(visible))
|
||
return body + ' ' * pad + _RST
|
||
|
||
|
||
def _border(left: str, right: str, width: int, style: str = _BORDER) -> str:
|
||
width = max(1, width)
|
||
if width == 1:
|
||
return style + left + _RST
|
||
return style + left + '─' * max(0, width - 2) + right + _RST
|
||
|
||
|
||
def _strip_bg(s: str) -> str:
|
||
"""Drop only BACKGROUND SGR — keep foreground colour (curated syntax/diff
|
||
stays, Linear-style functional colour) but no ugly box behind code."""
|
||
def repl(m: re.Match) -> str:
|
||
toks = m.group(1).split(';') if m.group(1) else ['0']
|
||
out, i = [], 0
|
||
while i < len(toks):
|
||
t = toks[i]
|
||
if t == '48':
|
||
i += 3 if (i + 1 < len(toks) and toks[i + 1] == '5') else \
|
||
5 if (i + 1 < len(toks) and toks[i + 1] == '2') else 1
|
||
continue
|
||
if t in _BG_TOK:
|
||
i += 1; continue
|
||
out.append(t); i += 1
|
||
return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m'
|
||
return _SGR_RE.sub(repl, s)
|
||
# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows)
|
||
# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`.
|
||
_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.')
|
||
_FILE_REF_RE = re.compile(r'@([\w./\-~]+)')
|
||
_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]')
|
||
_FILE_PH_RE = re.compile(r'\[File #(\d+)\]')
|
||
_IMG_PH_RE = re.compile(r'\[Image #(\d+)\]')
|
||
# All paste placeholders — used for whole-block delete (v2 parity): backspace
|
||
# flush against any of these wipes the entire placeholder, not one char.
|
||
_PLACEHOLDER_RES = (_PASTE_PH_RE, _IMG_PH_RE, _FILE_PH_RE)
|
||
_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico'}
|
||
_TURN_MK_RE = re.compile(
|
||
r'\*\*(?:LLM Running \()?Turn \d+\)?[^\n]*\*\*' # native + task-mode short form
|
||
r'|^[ \t]*Turn \d+\s*\.{3,}[ \t]*$', # plain subagent form on its own line
|
||
re.M)
|
||
_TOOL_RE = re.compile(
|
||
r'🛠️ Tool: `([^`]+)`[^\n]*\n' # 1 = name
|
||
r'(`{3,})[^\n]*\n(.*?)\n\2[ \t]*\n*' # 2 = fence delim, 3 = args body
|
||
r'(?:'
|
||
r'(`{5,})[^\n]*\n(.*?)\n\4[ \t]*\n*' # 4 = result fence (5-bt), 5 = body
|
||
r'|' # ─ OR ─
|
||
# Trail-end sentinel: either form of the `**Turn N ...**` marker, or a
|
||
# bare `Turn N ...` on its own line, or the next tool / summary tag.
|
||
r'(.*?)(?=^🛠️ Tool: `|^\*\*(?:LLM Running \()?Turn \d+|^Turn \d+ \.\.\.$|^<summary>|\Z)' # 6 = live exec trace
|
||
r')',
|
||
re.DOTALL | re.MULTILINE)
|
||
# Prompted-style tool wrappers GA models emit AS TEXT in saved logs (no
|
||
# structured tool_use block). Fold them into chips too so /continue replays
|
||
# match live mode. Whitelist = every name in assets/tools_schema.json + the
|
||
# native metadata wrappers; user HTML (<div>/<svg>/<a>/<p>/<script>…) stays
|
||
# untouched on purpose.
|
||
_XML_TOOL_RE = re.compile(
|
||
r'<('
|
||
r'code_run|file_read|file_write|file_patch|'
|
||
r'web_scan|web_execute_js|web_search|'
|
||
r'update_working_checkpoint|start_long_term_update|ask_user|'
|
||
r'tool_use|tool_result|tool_call|all_urls'
|
||
r')>(.*?)</\1>',
|
||
re.DOTALL)
|
||
|
||
|
||
_ACTION_RE = re.compile(
|
||
r'^[ \t]*\[(?:Action|Status|Info|Debug|Warn|Warning|Error)\][ \t]*', re.M)
|
||
|
||
|
||
@dataclass
|
||
class ToolRecord:
|
||
id: int
|
||
name: str
|
||
args: str = ''
|
||
result: str = ''
|
||
status: str = '?' # ok | error | ? — GA emits ✅/❌; no duration
|
||
raw: str = ''
|
||
|
||
|
||
def _tool_status(result: str, trailing: str) -> str:
|
||
"""Infer status from GA's emitted markers ONLY. Read-tool results can
|
||
contain ❌ or the word 'error' as ordinary content (a doc on coding rules,
|
||
plan_sop with ⛔/❌ markers, etc.) — those MUST NOT flag the chip red."""
|
||
s = result + trailing
|
||
if re.search(r'^\[(?:Status|Error)\][^\n]*(?:fail|error|❌)', s, re.I | re.M):
|
||
return 'error'
|
||
if re.match(r'^(?:Error[:\s]|Exception[:\s]|Traceback|❌|⛔)', s.lstrip(), re.I):
|
||
return 'error'
|
||
# ga.do_ask_user yields a 'Waiting for your answer ...' marker BEFORE the
|
||
# user has actually answered (it's the "I'm blocking on input" signal).
|
||
# The plain s.strip() truthy check below would otherwise light the chip
|
||
# ✓ ok the instant that marker appears — making the user think the tool
|
||
# has finished while the input prompt is, in reality, still waiting.
|
||
# Mark it pending (· …) until something else (the answer, a real status
|
||
# line) lands in the result.
|
||
if 'Waiting for your answer' in s and '✅' not in s and '成功' not in s:
|
||
return '?'
|
||
if '✅' in s or '成功' in s or s.strip():
|
||
return 'ok'
|
||
return '?'
|
||
|
||
|
||
_BOLD = '\x1b[1m'
|
||
_OK = '\x1b[38;5;71m' # functional green (Linear-muted)
|
||
_ERR = '\x1b[38;5;167m' # functional red
|
||
_CHIP_RE = re.compile(r'^▸ t(\d+) (.+?) · (ok|error|\?)$')
|
||
|
||
|
||
def _arg_hint(name: str, args: str, body: str) -> str:
|
||
"""Pluck a useful one-line hint from a tool's args. agent_loop:40's
|
||
`.replace('\\n','\n')` un-escapes newlines inside JSON string values so
|
||
json.loads fails on multi-line scripts; regex fallback extracts the first
|
||
priority field. When args parse to empty/no-useful-field, return '' —
|
||
DON'T fall to body; the chip's result preview handles that and showing
|
||
`{"status":…}` as a hint is just noise."""
|
||
src = ''
|
||
if args:
|
||
try:
|
||
d = json.loads(args)
|
||
if isinstance(d, dict):
|
||
for k in ('command', 'script', 'path', 'file_path', 'url', 'query', 'question'):
|
||
v = d.get(k)
|
||
if isinstance(v, str) and v.strip(): src = v; break
|
||
if not src:
|
||
for v in d.values():
|
||
if isinstance(v, str) and v.strip(): src = v; break
|
||
except Exception: # un-escaped \n → invalid JSON
|
||
m = re.search(
|
||
r'"(command|script|path|file_path|url|query|question)"\s*:\s*"([^"\n]*)',
|
||
args)
|
||
if m: src = m.group(2)
|
||
elif body: # only when args is empty
|
||
src = body # (xrepl path for XML tools)
|
||
src = src.split('\n', 1)[0].strip()
|
||
if name in ('file_read', 'file_write', 'file_patch') and '/' in src:
|
||
src = '…/' + src.rsplit('/', 1)[1]
|
||
return src[:60]
|
||
|
||
|
||
_CHIP_PLACEHOLDER_RE = re.compile(r'(?:^|\n)▸ t(\d+) ([^\n]+?) · (ok|error|\?)(?:\n|$)')
|
||
_META_LINE_RE = re.compile(
|
||
r'^[ \t]*\[(?:Action|Status|Info|Debug|Warn|Warning|Error|Stdout|Stderr)\]')
|
||
|
||
|
||
def _result_preview(result: str, max_rows: int, row_w: int) -> list[str]:
|
||
"""First few content lines from a tool result (cc-style hanging content
|
||
preview), skipping GA's [Action]/[Status]/[Stdout] meta markers. If the
|
||
result is a JSON envelope (common in replayed code_run/web_* results),
|
||
unwrap the meaningful field so the preview shows the actual content
|
||
instead of `{"status": ...}` serialization noise. Each returned line is
|
||
≤ row_w cells — long lines truncated with '…' (one physical row each)."""
|
||
if not result:
|
||
return []
|
||
s = result.strip()
|
||
if s.startswith('{') and s.endswith('}'):
|
||
try:
|
||
d = json.loads(s)
|
||
if isinstance(d, dict):
|
||
for k in ('stdout', 'output', 'result', 'content', 'text'):
|
||
v = d.get(k)
|
||
if isinstance(v, str) and v.strip():
|
||
result = v
|
||
break
|
||
except Exception:
|
||
pass
|
||
lines = [ln for ln in result.split('\n') if not _META_LINE_RE.match(ln)]
|
||
while lines and not lines[0].strip(): lines.pop(0)
|
||
while lines and not lines[-1].strip(): lines.pop()
|
||
if not lines:
|
||
return []
|
||
out = []
|
||
for ln in lines[:max_rows]:
|
||
ln = _term_safe_text(ln)
|
||
if row_w and cell_len(ln) > row_w:
|
||
ln = _clip_cells(ln, max(1, row_w - 1)) + '…'
|
||
out.append(ln)
|
||
rest = len(lines) - max_rows
|
||
if rest > 0:
|
||
out.append(f'… +{rest} more')
|
||
return out
|
||
|
||
|
||
def _chip_box(tid_str: str, combo: str, st: str, w: int, result: str = '') -> list[str]:
|
||
"""Tool chip rendered as a fully-enclosed Linear-ish box:
|
||
╭─ name ✓ ok ·tN ─────────╮
|
||
│ hint chunk (CJK-safe wrap) │
|
||
│ chunk 2 if it wraps │
|
||
╰────────────────────────────╯
|
||
Every emitted string has visible width == inner (one physical terminal
|
||
row, no soft-wrap drift). fg-only SGR so _strip_bg / native copy stay
|
||
clean. Caller MUST bypass Rich Markdown for these — see _render_assistant."""
|
||
parts = combo.split(' ', 1)
|
||
name = parts[0]
|
||
hint = parts[1].strip() if len(parts) > 1 else ''
|
||
sti, stcol = (('✓ ok', _OK) if st == 'ok' else
|
||
('✕ error', _ERR) if st == 'error' else ('· …', _DIM))
|
||
tag = f'·t{tid_str}'
|
||
inner = max(1, w)
|
||
if inner < 24:
|
||
head = f'{name} {sti} {tag}'
|
||
out = [stcol + _clip_cells(head, inner) + _RST]
|
||
body_rows: list[str] = []
|
||
if hint:
|
||
body_rows.extend(_wrap_cells(hint, inner) or [''])
|
||
body_rows.extend(_result_preview(result, 3, inner))
|
||
out.extend(_DIM + _clip_cells(row, inner) + _RST for row in body_rows)
|
||
return out
|
||
name_max = max(1, inner - 10 - cell_len(sti) - cell_len(tag))
|
||
if cell_len(name) > name_max:
|
||
name = _clip_cells(name, name_max)
|
||
header_plain = f' {name} {sti} {tag} '
|
||
fill = max(1, inner - 3 - cell_len(header_plain))
|
||
header_c = (' ' + _BOLD + name + _RST + ' ' + stcol + sti + _RST +
|
||
' ' + _DIM + tag + _RST + ' ')
|
||
top = _ACCENT + '╭─' + _RST + header_c + _ACCENT + '─' * fill + '╮' + _RST
|
||
bot = _border('╰', '╯', inner, _ACCENT)
|
||
content_w = max(1, inner - 4)
|
||
body_rows: list[str] = []
|
||
if hint: # what was called (args)
|
||
body_rows.extend(_wrap_cells(hint, content_w) or [''])
|
||
body_rows.extend(_result_preview(result, 4, content_w)) # what came back
|
||
if not body_rows:
|
||
return [top, bot]
|
||
out = [top]
|
||
for ch in body_rows:
|
||
pad = content_w - cell_len(ch)
|
||
out.append(_ACCENT + '│' + _RST + ' ' + _DIM + ch + _RST +
|
||
' ' * pad + ' ' + _ACCENT + '│' + _RST)
|
||
out.append(bot)
|
||
return out
|
||
|
||
|
||
_FINAL_MARKER_RE = re.compile(
|
||
r'\n*(?:`{3,5}\n*)?\[Info\]\s*Final response to user\.\n*(?:`{3,5})?\s*$')
|
||
|
||
|
||
def _strip_final_marker(text: str) -> str:
|
||
"""Drop the trailing `[Info] Final response to user.` marker (emitted by
|
||
ga.do_no_tool, optionally fenced). It's a conductor protocol signal, not
|
||
user-facing content — desktop_bridge / app.js strip it the same way."""
|
||
return _FINAL_MARKER_RE.sub('', text)
|
||
|
||
|
||
def _extract_user_text(entry) -> str:
|
||
"""Pull the plain user text out of a backend.history entry (str content
|
||
or a content-block list). Ported from v2 — used to prefill the input box
|
||
after /rewind."""
|
||
c = entry.get('content') if isinstance(entry, dict) else None
|
||
if isinstance(c, str):
|
||
return c
|
||
if isinstance(c, list):
|
||
parts = [b.get('text', '') for b in c
|
||
if isinstance(b, dict) and b.get('type') == 'text']
|
||
return '\n'.join(p for p in parts if p)
|
||
return ''
|
||
|
||
|
||
# Slash-command spec for the `/` hint + Tab completion (only commands _cmd
|
||
# actually services). Built dynamically so /language switches relabel the
|
||
# palette and /help on the next render.
|
||
def _cmds() -> list[tuple[str, str, str]]:
|
||
return [
|
||
('/help', '', _t('cmd.help.desc')),
|
||
('/status', '', _t('cmd.status.desc')),
|
||
('/llm', _t('cmd.llm.arg'), _t('cmd.llm.desc')),
|
||
('/btw', _t('cmd.btw.arg'), _t('cmd.btw.desc')),
|
||
('/review', _t('cmd.review.arg'), _t('cmd.review.desc')),
|
||
# ── slash_cmds bundle (same set as v2; descriptions kept inline
|
||
# /scheduler stays an interactive multi-pick menu; the rest fold
|
||
# into prompt-injection turns. All bundle rows now route through
|
||
# _t() so zh/en stay in sync.
|
||
('/update', _t('cmd.update.arg'), _t('cmd.update.desc')),
|
||
('/autorun', _t('cmd.autorun.arg'), _t('cmd.autorun.desc')),
|
||
('/morphling', _t('cmd.morphling.arg'), _t('cmd.morphling.desc')),
|
||
('/goal', _t('cmd.goal.arg'), _t('cmd.goal.desc')),
|
||
('/hive', _t('cmd.hive.arg'), _t('cmd.hive.desc')),
|
||
('/conductor', _t('cmd.conductor.arg'), _t('cmd.conductor.desc')),
|
||
('/scheduler', '', _t('cmd.scheduler.desc')),
|
||
('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')),
|
||
('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')),
|
||
('/workspace', _t('cmd.workspace.arg', default='[path|off]'),
|
||
_t('cmd.workspace.desc', default='设定工作目录(绝对路径)并进入项目模式')),
|
||
('/new', _t('cmd.new.arg'), _t('cmd.new.desc')),
|
||
('/rename', _t('cmd.rename.arg'), _t('cmd.rename.desc')),
|
||
('/clear', '', _t('cmd.clear.desc')),
|
||
('/cost', '', _t('cmd.cost.desc')),
|
||
('/verbose', '', _t('cmd.verbose.desc')),
|
||
('/export', _t('cmd.export.arg'), _t('cmd.export.desc')),
|
||
('/stop', '', _t('cmd.stop.desc')),
|
||
('/language', _t('cmd.language.arg'), _t('cmd.language.desc')),
|
||
('/emoji', _t('cmd.emoji.arg'), _t('cmd.emoji.desc')),
|
||
('/resume', '', _t('cmd.resume.desc')),
|
||
('/quit', '', _t('cmd.quit.desc')),
|
||
]
|
||
|
||
|
||
def _heat(el: float) -> str:
|
||
"""Patience heat for the running spinner (ported from v2 _HEAT_RAMP):
|
||
cool mint → amber → orange → red as the wait grows."""
|
||
return ('\x1b[38;2;170;232;170m' if el < 20 else
|
||
'\x1b[38;2;212;167;44m' if el < 60 else
|
||
'\x1b[38;2;220;107;31m' if el < 180 else
|
||
'\x1b[1m\x1b[38;2;255;44;44m')
|
||
|
||
|
||
# Spinner gerund pool — English only (ported from v2 _SPINNER_GERUNDS).
|
||
# Rotates every ~6 s so a long wait feels alive instead of stuck on one phrase.
|
||
_GERUNDS = SPINNER_GERUNDS
|
||
|
||
|
||
def _gerund(el: float) -> str:
|
||
return _GERUNDS[int(el // 6) % len(_GERUNDS)]
|
||
|
||
|
||
# Pet faces, 4-frame cycle per heat tier so the face blinks/winks every ~1s
|
||
# (frame ticks every 0.1s in _ticker now — formerly 0.4s). Mood escalates
|
||
# with patience burn: happy → focused → sleepy → stressed.
|
||
_PETS_UNICODE = (
|
||
('(•‿•)', '(•‿•)', '(•‿•)', '(-‿-)'), # <20s calm, occasional blink
|
||
('(•_•)', '(•_-)', '(•_•)', '(-_•)'), # <60s focused, alternating wink
|
||
('(˘_˘)', '(˘_˘)', '(-_-)', '(˘_˘)'), # <180s sleepy, half-closed
|
||
('(>_<)', '(@_@)', '(>_<)', '(T_T)'), # ≥180s stressed (concerned!)
|
||
)
|
||
# ASCII fallback — some Windows consoles render CJK punctuation as double-
|
||
# width, making `(>_<)` look "fat" and shoving the heat counter sideways.
|
||
# `/emoji ascii` switches to bracketed glyphs that stay single-width on
|
||
# every terminal. `/emoji off` hides the pet entirely.
|
||
# Cat head — calm tier uses • (sleepy/cute look), `o` reserved for the
|
||
# focused tier, `-` for the sleepy tier so each row's mood reads
|
||
# distinctly. Each tier's 4 frames share a width within the tier.
|
||
_PETS_CAT = (
|
||
('=^•.•^=', '=^•.•^=', '=^-.-^=', '=^•.•^='),
|
||
('=^o.o^=', '=^o.-^=', '=^o.o^=', '=^-.o^='),
|
||
('=^-.-^=', '=^-.-^=', '=^v.v^=', '=^-.-^='),
|
||
('=^>.<^=', '=^@.@^=', '=^>.<^=', '=^T.T^='),
|
||
)
|
||
# Bracketed dot-eye — same mood arc; `•` for calm, `o` for focused.
|
||
_PETS_DOT = (
|
||
('[•.•]', '[•.•]', '[-.-]', '[•.•]'),
|
||
('[o.o]', '[o.-]', '[o.o]', '[-.o]'),
|
||
('[-.-]', '[-.-]', '[v.v]', '[-.-]'),
|
||
('[>.<]', '[@.@]', '[>.<]', '[T.T]'),
|
||
)
|
||
# Bear face — restored to the classic ʕ•ᴥ•ʔ for the calm tier (user
|
||
# preference; see screenshot 025734). Mood escalates the same way as the
|
||
# other styles: calm → focused → sleepy → stressed. Bullets are kept to
|
||
# the calm/focused tier where mixing them with `-` would jitter; tier 2
|
||
# and 3 stay dash-/bracket-internal so the heat counter never shifts.
|
||
_PETS_BEAR = (
|
||
('ʕ•ᴥ•ʔ', 'ʕ-ᴥ-ʔ', 'ʕ•ᴥ•ʔ', 'ʕ•ᴥ-ʔ'),
|
||
('ʕoᴥoʔ', 'ʕoᴥ-ʔ', 'ʕoᴥoʔ', 'ʕ-ᴥoʔ'),
|
||
('ʕ-ᴥ-ʔ', 'ʕ-ᴥ-ʔ', 'ʕ~ᴥ~ʔ', 'ʕ-ᴥ-ʔ'),
|
||
('ʕ>ᴥ<ʔ', 'ʕ@ᴥ@ʔ', 'ʕ>ᴥ<ʔ', 'ʕTᴥTʔ'),
|
||
)
|
||
_PET_STYLES = {
|
||
'bear': _PETS_BEAR,
|
||
'cat': _PETS_CAT,
|
||
'dot': _PETS_DOT,
|
||
'unicode': _PETS_UNICODE,
|
||
}
|
||
# `off` is rendered specially (empty string) — kept out of _PET_STYLES so the
|
||
# picker can iterate real styles, then surface the hide-pet row separately.
|
||
_PET_HIDDEN = 'off'
|
||
_pet_style = 'bear' # default per user request; mutated by /emoji <style>
|
||
|
||
|
||
def _pet(el: float, frame: int) -> str:
|
||
# `frame` ticks at the spinner rate (0.1s). Pet emotes feel frantic if
|
||
# they swap every tick, so callers divide the spin counter (currently /5)
|
||
# to land a ~0.5s pet-frame cadence while the spinner glyph stays snappy.
|
||
if _pet_style == _PET_HIDDEN:
|
||
return ''
|
||
tier = 0 if el < 20 else 1 if el < 60 else 2 if el < 180 else 3
|
||
pool = _PET_STYLES.get(_pet_style, _PETS_UNICODE)[tier]
|
||
return pool[frame % len(pool)]
|
||
_BP_START = b'\x1b[200~'
|
||
_BP_END = b'\x1b[201~'
|
||
_SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||
_ROOT = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
|
||
def _rmdir_if_empty(path: str | None) -> None:
|
||
"""Best-effort remove a signal task_dir once empty. `os.rmdir` only
|
||
succeeds on an empty dir, so a still-pending `_intervene` is never lost."""
|
||
if not path:
|
||
return
|
||
try: os.rmdir(path)
|
||
except OSError: pass
|
||
|
||
|
||
def _sweep_stale_task_dirs() -> None:
|
||
"""Delete empty `temp/_tui_v3_*` signal dirs left by prior runs (incl.
|
||
crashes). Empty == no pending signal; a live instance re-creates lazily
|
||
on its next inject."""
|
||
import glob as _glob
|
||
for d in _glob.glob(os.path.join(_ROOT, 'temp', '_tui_v3_*')):
|
||
if os.path.isdir(d):
|
||
_rmdir_if_empty(d)
|
||
|
||
|
||
# ── terminal window title (OSC 0) ────────────────────────────────────────
|
||
# Win Terminal / xterm honour `\x1b]0;<text>\x07`; legacy cmd.exe ignores it
|
||
# silently. Cache the last value so the 10 Hz _ticker doesn't spam the
|
||
# emulator with identical writes (xterm coalesces but tracers don't).
|
||
_last_term_title: str = ''
|
||
|
||
|
||
def _set_term_title(text: str) -> None:
|
||
# PTK app run redirects sys.stdout to sb_agent.log (see
|
||
# _run_prompt_toolkit), so sys.stdout.write here would write OSC 0
|
||
# into the log file instead of the terminal — title silently
|
||
# frozen at the very first pre-PTK call. Write straight to fd 1
|
||
# via os.write, same approach as _w().
|
||
global _last_term_title
|
||
if text == _last_term_title:
|
||
return
|
||
_last_term_title = text
|
||
try:
|
||
os.write(1, ('\x1b]0;' + text + '\x07').encode('utf-8', 'replace'))
|
||
except OSError:
|
||
pass # detached stdout / non-tty — let-it-crash § 14 (best-effort)
|
||
|
||
|
||
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:
|
||
os.write(1, s.encode('utf-8', 'replace'))
|
||
|
||
|
||
# Phrasing-based opt-in for multi-select picker — matches v2's _MULTI_RE so a
|
||
# question containing `[多选]`, `multi-select`, `select all` etc. (anywhere in
|
||
# the prompt) switches the inline picker from single- to multi-mode.
|
||
_MULTI_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE)
|
||
|
||
|
||
def _visible_text(s: str) -> str:
|
||
"""Strip SGR escapes for display-width measurement. sanitize_ansi keeps SGR
|
||
intentionally, so PTK's ANSI parser can colorize; for cell-width math we
|
||
need the bare visible characters."""
|
||
return _ANSI_SGR_RE.sub('', sanitize_ansi(s))
|
||
|
||
|
||
def _ptk_x_from_cell(line: str, cell_x: int) -> int:
|
||
"""Convert a 0-based terminal cell column to PTK's character-index x.
|
||
|
||
SB computes cursor columns in terminal cells, where CJK glyphs occupy two
|
||
columns. FormattedTextControl/ANSI cursor positions are indexes in the
|
||
formatted-text line, where the same CJK glyph is one character/fragment.
|
||
Feeding cell columns directly makes PTK place the cursor too far right and
|
||
it overwrites/clears cells around wide characters."""
|
||
target = max(0, int(cell_x))
|
||
acc = 0
|
||
idx = 0
|
||
for ch in _visible_text(line):
|
||
ch_w = max(1, cell_len(ch))
|
||
if acc + ch_w > target:
|
||
break
|
||
acc += ch_w
|
||
idx += 1
|
||
return idx
|
||
|
||
|
||
def _is_mouse_or_scroll_keypress(kp) -> bool:
|
||
"""Filter out mouse/scroll events from PTK's key sequence so they don't
|
||
feed into SB's Up/Down history navigation path."""
|
||
key = getattr(kp, 'key', None)
|
||
data = getattr(kp, 'data', '') or ''
|
||
name = getattr(key, 'name', '')
|
||
probe = f'{name} {key}'.lower()
|
||
if 'mouse' in probe or 'scroll' in probe:
|
||
return True
|
||
if isinstance(data, str) and (data.startswith('\x1b[<') or data.startswith('\x1b[M')):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _esc_repl(m: re.Match) -> bytes:
|
||
s = m.group(0)
|
||
if s == b'\x1b[A':
|
||
return b'\x10' # ↑ → internal prev-history / cursor-up
|
||
if s == b'\x1b[B':
|
||
return b'\x0e' # ↓ → internal next-history / cursor-down
|
||
if s == b'\x1b[D':
|
||
return b'\x02' # ← → internal cursor-left
|
||
if s == b'\x1b[C':
|
||
return b'\x06' # → → internal cursor-right
|
||
if s == b'\x1b[1;2D':
|
||
return b'\x1e' # Shift+← → extend selection left
|
||
if s == b'\x1b[1;2C':
|
||
return b'\x1f' # Shift+→ → extend selection right
|
||
if s == b'\x1b[1;2A':
|
||
return b'\x1c' # Shift+↑ → extend selection up
|
||
if s == b'\x1b[1;2B':
|
||
return b'\x1d' # Shift+↓ → extend selection down
|
||
if s in (b'\x1b[27;2;13~', b'\x1b[13;2u'):
|
||
return b'\n' # Shift+Enter (modifyOtherKeys / kitty) → newline
|
||
# Home/End arrive as raw VT sequences via PTK's KeyPress.data (special keys
|
||
# carry their original escape bytes), so _ptk_keypress_to_bytes returns them
|
||
# verbatim and they must be decoded here like the arrows above.
|
||
if s in (b'\x1b[H', b'\x1b[1~', b'\x1b[7~', b'\x1bOH'):
|
||
return b'\x07' # Home → internal jump-to-line-start
|
||
if s in (b'\x1b[F', b'\x1b[4~', b'\x1b[8~', b'\x1bOF'):
|
||
return b'\x14' # End → internal jump-to-line-end
|
||
return b'' # swallow every other escape sequence
|
||
|
||
|
||
def _holdback(rb: bytes, marker: bytes) -> int:
|
||
"""Length of the trailing suffix of rb that is a strict prefix of marker."""
|
||
for k in range(min(len(marker) - 1, len(rb)), 0, -1):
|
||
if marker.startswith(rb[-k:]):
|
||
return k
|
||
return 0
|
||
|
||
|
||
def _term() -> tuple[int, int]:
|
||
try:
|
||
c = os.get_terminal_size(1); return max(1, c.columns), max(3, c.lines)
|
||
except OSError:
|
||
return 80, 24
|
||
|
||
|
||
def _render(text: str, width: int, markdown: bool) -> list[str]:
|
||
width = max(1, width)
|
||
buf = StringIO()
|
||
Console(file=buf, width=width, force_terminal=True, color_system='truecolor',
|
||
legacy_windows=False, theme=_MD_THEME).print(
|
||
HardBreakMarkdown(text, code_theme='monokai') if markdown else Text(text),
|
||
end='')
|
||
out = _strip_bg(buf.getvalue()).split('\n')
|
||
if out and out[-1] == '':
|
||
out.pop()
|
||
return out or ['']
|
||
|
||
|
||
def _fit_rows(line: str, width: int) -> list[str]:
|
||
"""Fold an already-rendered ANSI line without changing its SGR styling."""
|
||
width = max(1, width)
|
||
erase = '\x1b[K' if _CSI_ERASE_RE.search(line) else ''
|
||
safe = _CSI_ERASE_RE.sub('', _term_safe_text(line))
|
||
rows: list[str] = []
|
||
cur = ''
|
||
cur_w = 0
|
||
active = ''
|
||
parts = _SGR_TOKEN_RE.split(safe)
|
||
sgrs = _SGR_TOKEN_RE.findall(safe)
|
||
for idx, part in enumerate(parts):
|
||
for ch in part:
|
||
ch_w = cell_len(ch)
|
||
if ch_w > width:
|
||
ch = '·'
|
||
ch_w = 1
|
||
if cur_w + ch_w > width and cur_w > 0:
|
||
rows.append(cur + erase + (_RST if active else ''))
|
||
cur = active
|
||
cur_w = 0
|
||
cur += ch
|
||
cur_w += ch_w
|
||
if idx < len(sgrs):
|
||
code = sgrs[idx]
|
||
cur += code
|
||
active = '' if code == _RST else active + code
|
||
rows.append(cur + erase)
|
||
return rows or ['']
|
||
|
||
|
||
def _clip_ansi_cells(s: str, width: int) -> str:
|
||
width = max(0, width)
|
||
if width == 0:
|
||
return ''
|
||
safe = _CSI_ERASE_RE.sub('', _term_safe_text(s))
|
||
cur = ''
|
||
cur_w = 0
|
||
active = ''
|
||
parts = _SGR_TOKEN_RE.split(safe)
|
||
sgrs = _SGR_TOKEN_RE.findall(safe)
|
||
for idx, part in enumerate(parts):
|
||
for ch in part:
|
||
ch_w = cell_len(ch)
|
||
if ch_w > width:
|
||
ch = '·'
|
||
ch_w = 1
|
||
if cur_w + ch_w > width:
|
||
return cur + (_RST if active else '')
|
||
cur += ch
|
||
cur_w += ch_w
|
||
if idx < len(sgrs):
|
||
code = sgrs[idx]
|
||
cur += code
|
||
active = '' if code == _RST else active + code
|
||
return cur
|
||
|
||
|
||
def _tail_fit_rows(lines: list[str], width: int, budget: int) -> list[str]:
|
||
if budget <= 0:
|
||
return []
|
||
out: list[str] = []
|
||
for ln in reversed(lines):
|
||
rows = _fit_rows(ln, width)
|
||
room = budget - len(out)
|
||
if room <= 0:
|
||
break
|
||
if len(rows) > room:
|
||
out[0:0] = rows[-room:]
|
||
break
|
||
out[0:0] = rows
|
||
return out
|
||
|
||
|
||
def _elapsed(s: float) -> str:
|
||
if s < 60:
|
||
return f'{int(s)}s'
|
||
m, sec = divmod(int(s), 60); return f'{m}:{sec:02d}'
|
||
|
||
|
||
def _human(n: int) -> str:
|
||
return f'{n / 1e6:.1f}M' if n >= 1e6 else f'{n / 1e3:.1f}k' if n >= 1000 else str(n)
|
||
|
||
|
||
def _wrap_cells(s: str, width: int) -> list[str]:
|
||
"""Hard-wrap by DISPLAY width (CJK = 2 cells). Each chunk ≤ width so one
|
||
emitted line == exactly one physical terminal row → row accounting stays
|
||
exact, no soft-wrap drift / ghosting."""
|
||
s = _term_safe_text(s)
|
||
out, cur, cw = [], '', 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if cw + c > width and cur:
|
||
out.append(cur); cur, cw = ch, c
|
||
else:
|
||
cur += ch; cw += c
|
||
out.append(cur)
|
||
return out
|
||
|
||
|
||
def _clip_cells(s: str, width: int) -> str:
|
||
"""Truncate to width display-cells so the line is always one physical row."""
|
||
s = _term_safe_text(s)
|
||
if cell_len(s) <= width:
|
||
return s
|
||
out, cw = '', 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if cw + c > width:
|
||
break
|
||
out += ch; cw += c
|
||
return out
|
||
|
||
|
||
def _cell_head(s: str, n: int) -> str:
|
||
"""Keep the head within n cells, suffix … if truncated (省略末尾 — names)."""
|
||
if cell_len(s) <= n:
|
||
return s
|
||
if n <= 1:
|
||
return '…'
|
||
out, w = '', 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if w + c > n - 1:
|
||
break
|
||
out += ch; w += c
|
||
return out + '…'
|
||
|
||
|
||
def _cell_mid(s: str, n: int) -> str:
|
||
"""Keep head+tail within n cells, … in the middle (省略中间 — paths: project
|
||
root and leaf both stay visible). CJK counts as 2 via cell_len."""
|
||
if cell_len(s) <= n:
|
||
return s
|
||
if n <= 1:
|
||
return '…'
|
||
avail = n - 1
|
||
head_budget = avail - avail // 2
|
||
tail_budget = avail // 2
|
||
head, w = '', 0
|
||
for ch in s:
|
||
c = cell_len(ch)
|
||
if w + c > head_budget:
|
||
break
|
||
head += ch; w += c
|
||
tail_rev, w = '', 0
|
||
for ch in reversed(s):
|
||
c = cell_len(ch)
|
||
if w + c > tail_budget:
|
||
break
|
||
tail_rev += ch; w += c
|
||
return head + '…' + tail_rev[::-1]
|
||
|
||
|
||
def _term_safe_text(s: str) -> str:
|
||
"""Normalize control chars whose terminal geometry is stateful.
|
||
|
||
A literal tab expands according to the current cursor column, while
|
||
``cell_len("\t")`` reports one cell. That mismatch made tool chips with
|
||
outputs such as ``git remote -v`` physically wrap even though the renderer
|
||
believed each row fit. Keep source text in ToolRecord; normalize only the
|
||
display path.
|
||
"""
|
||
return s.replace('\r', '').replace('\t', ' ')
|
||
|
||
|
||
def _indent_rows(rows: list[str], width: int) -> list[str]:
|
||
if width <= 1:
|
||
return rows
|
||
return [' ' + row for row in rows]
|
||
|
||
|
||
def _cost_str(agent) -> str:
|
||
"""Context-window usage view (cc/v2 style): used / cap of context_win*3."""
|
||
try:
|
||
from frontends import cost_tracker
|
||
be = agent.llmclient.backend
|
||
cap = cost_tracker.context_window_chars(be)
|
||
used = cost_tracker.current_input_chars(be)
|
||
if cap <= 0:
|
||
return ''
|
||
pct = min(100, used * 100 // cap)
|
||
n = round(pct / 100 * 8)
|
||
bar = '▰' * n + '▱' * (8 - n)
|
||
tot = sum(t.total_tokens for t in cost_tracker.all_trackers().values())
|
||
tok = f' · {_human(tot)} tok' if tot else ''
|
||
return f' │ ctx {bar} {pct}% ({_human(used)}/{_human(cap)}){tok}'
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
def _rel(mt: float) -> str:
|
||
d = max(0, time.time() - mt)
|
||
if d < 3600:
|
||
return f'{int(d // 60)}m'
|
||
if d < 86400:
|
||
return f'{int(d // 3600)}h'
|
||
return f'{int(d // 86400)}d'
|
||
|
||
|
||
class SB:
|
||
def __init__(self) -> None:
|
||
self.buf = ''; self.pos = 0; self._fd = 0; self._old = None
|
||
# Reentrant: helpers like _flush_esc lock internally yet are also called
|
||
# from already-locked contexts (the maintenance loop). A plain Lock
|
||
# deadlocks the whole TUI on a bare Esc; RLock lets the same thread
|
||
# re-enter while still excluding the agent-runner thread.
|
||
self._lk = threading.RLock()
|
||
self._live_rows = 0
|
||
self._stream = ''
|
||
self._sent = 0 # rendered lines of this msg already in scrollback
|
||
self._live_tail: list[str] = [] # small volatile tail still being redrawn
|
||
self._tools: dict[int, ToolRecord] = {} # structured tool audit log
|
||
self._tool_base = 0 # id offset; ids fixed at stream time (scrollback immutable)
|
||
self._last_tool_n = 0 # tools seen in the current message render
|
||
self._cur = (0, 1) # (row in live region, 1-based column) of caret
|
||
self._parked_up = 0 # rows the caret was parked above region bottom
|
||
self._running = False
|
||
self._bridge: AgentBridge | None = None
|
||
self._resized = False
|
||
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
|
||
# Plan card state (v2 parity). `_plan_items` is the last seen items;
|
||
# `_plan_complete_since` / `_plan_lost_since` track grace periods to
|
||
# avoid flicker on agent rewrites. `_plan_path` / `_plan_mtime` cache
|
||
# the last read so the 30ms spinner tick skips redundant file IO.
|
||
self._plan_items: list[tuple[str, str]] = []
|
||
self._plan_complete_since: float | None = None
|
||
self._plan_lost_since: float | None = None
|
||
self._plan_path: str = ''
|
||
self._plan_mtime: float = 0.0
|
||
self._fold_all = True # Ctrl+O toggles: when True, assistant blocks with a
|
||
# <summary> render as one `▸ {summary}` header in
|
||
# scrollback; the full source replays when flipped off.
|
||
self._cwd = os.path.join(os.getcwd(), 'temp')
|
||
# All three keyed by paste id (_pc): _pstore text-paste id→content,
|
||
# _fstore file-paste id→path, _imgs image-paste id→path. Keying by id
|
||
# lets a whole-block delete pop the right entry.
|
||
self._pstore: dict[int, str] = {}; self._fstore: dict[int, str] = {}
|
||
self._imgs: dict[int, str] = {}; self._pc = 0
|
||
self._t0 = 0.0; self._spin = 0
|
||
self._t0_anchor = 0.0 # frozen anchor for elapsed; resets only on state changes
|
||
self._painted: list[str] = []
|
||
self._prev_term_size: tuple[int, int] | None = None
|
||
self._blocks: list[Block] = [] # block-based scrollback history;
|
||
self._streaming_block: Block | None = None # the in-flight assistant block
|
||
self._stream_turn_seen = 0 # turn-marker count last seen in current stream;
|
||
# bumps trigger an incremental fold-repaint so
|
||
# turn N collapses the moment turn N+1 marker
|
||
# arrives (v2-style live folding).
|
||
# Self-pipe for SIGWINCH delivery. PEP 475 makes Python auto-retry
|
||
# os.read on EINTR even with siginterrupt(SIGWINCH, True) — especially
|
||
# under iTerm split panes where the signal can be delivered while the
|
||
# read is mid-syscall and silently dropped. The signal handler writes
|
||
# a byte to this pipe; the main select() polls both stdin and the
|
||
# pipe, so a resize always wakes the loop within select's timeout.
|
||
sr, sw = os.pipe()
|
||
if not _IS_WINDOWS:
|
||
import fcntl as _fcntl
|
||
for fd in (sr, sw):
|
||
fl = _fcntl.fcntl(fd, _fcntl.F_GETFL)
|
||
_fcntl.fcntl(fd, _fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||
self._sig_r, self._sig_w = sr, sw
|
||
self._last_render = 0.0
|
||
self._asking: AskUserEvent | None = None
|
||
self._quit = False
|
||
self._cc_t = 0.0 # last bare-Ctrl+C time (arm-to-quit window)
|
||
self._last_esc_t = 0.0 # last bare-Esc time (Esc Esc → /clear)
|
||
# Queued user messages: appended while running, written to the
|
||
# bridge's `_intervene` file with a "finish current task first"
|
||
# wrapper that subordinates the new message to the running task.
|
||
# Cleared when the bridge confirms consumption at the next turn
|
||
# boundary.
|
||
self._pending: list[str] = []
|
||
self._epend = b'' # held trailing ESC (split-read disambiguation)
|
||
self._undo: list[tuple[str, int]] = [] # buffer-edit history for Ctrl+Z
|
||
self._redo: list[tuple[str, int]] = [] # cleared on any new edit
|
||
self._sel: int | None = None # selection anchor (None=no selection)
|
||
# PTK Application state (populated by _run_prompt_toolkit_application)
|
||
self._ptk_app = None # prompt_toolkit.application.Application
|
||
self._ptk_loop: asyncio.AbstractEventLoop | None = None
|
||
self._ptk_cursor = None # prompt_toolkit Point set per render
|
||
# Scrollback emit queue: lines waiting to be print_text()'d above the
|
||
# PTK render area. Populated by _emit_lines, drained by the async
|
||
# maintenance loop via run_in_terminal(print_text).
|
||
self._sbq: list[str] = []
|
||
self._sbq_lk = threading.Lock()
|
||
# When set, the next drain wipes screen+scrollback and re-prints the
|
||
# entire rendered history at the current width. Toggled by
|
||
# _repaint_screen so banner/box borders reflow on terminal resize
|
||
# instead of leaving stale rows in scrollback.
|
||
self._pending_repaint = False
|
||
# Per-render live-region cache so PTK's preferred_height query and the
|
||
# text getter see the same content within a single render pass.
|
||
self._live_cache: list[str] = ['']
|
||
self._live_cache_w = -1
|
||
self._live_cache_h = -1
|
||
# ask_user picker state. None = no picker (free text), 'single' =
|
||
# arrow keys move highlight + Enter submits one, 'multi' = Space
|
||
# toggles + Enter submits the joined set. Any visible char typed
|
||
# while a picker is active switches it back to None (free-text
|
||
# escape hatch — the v2 "Type something" affordance).
|
||
self._picker_mode: str | None = None
|
||
self._picker_sel: int = 0
|
||
self._picker_checked: set[int] = set()
|
||
# Local menu picker — used by /llm, /continue, etc. to present an
|
||
# arrow-key selectable list in place of the input box. Distinct from
|
||
# the ask_user picker (which sits inside an ask_card with a question
|
||
# header and a free-text input field). When _menu_active, the live
|
||
# region renders _menu_card instead of _input_box; ↑↓/Enter/Esc are
|
||
# captured before the normal input pipeline.
|
||
self._menu_active: bool = False
|
||
self._menu_options: list[str] = []
|
||
self._menu_title: str = ''
|
||
self._menu_hint: str = ''
|
||
self._menu_sel: int = 0
|
||
self._menu_scroll: int = 0 # index of the first visible row in the viewport
|
||
self._menu_on_submit = None # Callable[[int|list[int]], None] | None
|
||
self._menu_on_cancel = None # Callable[[], None] | None
|
||
# Multi-select menus: Space toggles _menu_checked[i]; Enter calls
|
||
# on_submit(sorted(_menu_checked)). Single-select (default) calls
|
||
# on_submit(_menu_sel) and ignores _menu_checked.
|
||
self._menu_multi: bool = False
|
||
self._menu_checked: set[int] = set()
|
||
# opt-in 可过滤菜单(/workspace 用):输入过滤 + free_input 提交原文。
|
||
self._menu_filterable: bool = False
|
||
self._menu_free: bool = False
|
||
self._menu_on_free = None
|
||
self._menu_query: str = ''
|
||
self._menu_all_labels: list[str] = [] # 未过滤的全部显示行
|
||
self._menu_filter_keys: list[str] = [] # 与 all_labels 等长的可搜索文本(完整,不省略)
|
||
self._menu_map: list[int] = [] # 可见行 idx → all_labels 原始 idx
|
||
# Interactive command palette: index into _cmd_matches output when
|
||
# buf starts with `/`. ↑↓ steer the highlight, Tab completes the
|
||
# highlighted command, Enter still executes whatever is in buf.
|
||
self._palette_sel: int = 0
|
||
self._palette_scroll: int = 0 # viewport offset into the matches list
|
||
# workspace 绑定(单会话 → 进程级一个)。空 = 普通模式。
|
||
self._ws_name: str = ''
|
||
self._ws_path: str = ''
|
||
self._ws_link: str = ''
|
||
# @ 候选缓存:(buf, pos) → list[path],避免每次按键重算 fuzzy_rank。
|
||
self._at_cache: tuple | None = None
|
||
|
||
# ── live region ──
|
||
#
|
||
# The live region is now owned by PTK's Application renderer. See
|
||
# _build_live_lines / _get_ptk_text / _get_live_height further down.
|
||
# The old _paint / _goto_top helpers wrote ANSI to stdout directly; they
|
||
# are removed. Any remaining _painted/_live_rows/_parked_up writes left
|
||
# in callers are harmless legacy resets (they zero state PTK ignores).
|
||
|
||
def _status_line(self, w: int) -> str:
|
||
# Show the concrete model id; fall back to the channel group only when
|
||
# the model is unavailable (e.g. a mixin without a single .model).
|
||
name = (self._bridge.llm_model or self._bridge.llm_name) if self._bridge else '?'
|
||
if self._asking:
|
||
state = _t('status.asking')
|
||
elif self._running:
|
||
el = time.time() - self._t0_anchor
|
||
tps = ''
|
||
if el >= 1 and self._stream:
|
||
r = len(self._stream) / 4 / el # ~chars→tokens, rough live rate
|
||
if r >= 0.5:
|
||
tps = _t('status.tps', rate=r)
|
||
state = f'{_gerund(el)} {_elapsed(el)}{tps}' + _t('status.running.tail')
|
||
else:
|
||
state = _t('status.ready')
|
||
cost = _cost_str(self._bridge.agent) if self._bridge else ''
|
||
ws = ''
|
||
if self._ws_name:
|
||
disp = (os.path.basename((self._ws_path or '').rstrip('/\\'))
|
||
or re.sub(r'-[0-9a-f]{8}$', '', self._ws_name))
|
||
ws = f' │ ⌂ {_cell_head(disp, 18)}'
|
||
return f'[main] {name} │ {state}{cost}{ws}'
|
||
|
||
# v2-style plan card budget: 5 rows max — header(1) + optional step(1) +
|
||
# tasks(rest) + optional overflow(1). Grace periods avoid flicker when the
|
||
# agent rewrites plan.md (transient empty read) or completes (auto-hide).
|
||
_PLAN_GRACE_SEC = 3.0 # show "✓ complete" for this long, then hide
|
||
_PLAN_LOST_GRACE_SEC = 5.0 # keep prior items visible during agent rewrites
|
||
# / brief working-state clears (longer than v2's
|
||
# 1.5s because v3 polls only on render, not on
|
||
# turn boundaries — and ga.py auto-exits plan
|
||
# mode whenever plan.md momentarily has 0 `[ ]`)
|
||
|
||
def _agent_msgs(self, ag) -> list[str]:
|
||
"""Extract a list of text-content strings from agent history — what
|
||
`plan_state.current_step` / `find_path_in_messages` consume. History
|
||
entries can be dicts (raw backend) or objects with `.content`."""
|
||
hist = getattr(getattr(ag, 'llmclient', None), 'backend', None)
|
||
hist = getattr(hist, 'history', None) or []
|
||
out: list[str] = []
|
||
for h in hist:
|
||
c = h.get('content') if isinstance(h, dict) else getattr(h, 'content', None)
|
||
if isinstance(c, list):
|
||
c = '\n'.join(b.get('text', '') for b in c
|
||
if isinstance(b, dict) and b.get('type') == 'text')
|
||
if isinstance(c, str) and c:
|
||
out.append(c)
|
||
return out
|
||
|
||
def _plan_card(self, w: int) -> list[str]:
|
||
"""Render the plan/todo card above the input box (v2 parity, port of
|
||
tuiapp_v2._refresh_planbar). Returns [] when not in plan mode.
|
||
|
||
Layout (5 rows max):
|
||
📋 Plan (n_done/n_total) ← header
|
||
▸ current step… ← optional, from `当前步骤:`
|
||
✔ done item ← undone first, then done
|
||
☐ open item
|
||
⋮ +N more ← when overflowing
|
||
|
||
Cache file reads via mtime so the 30ms spinner tick doesn't open the
|
||
plan file every frame."""
|
||
if not self._bridge:
|
||
return []
|
||
ag = self._bridge.agent
|
||
try:
|
||
from frontends import plan_state
|
||
except Exception:
|
||
return []
|
||
|
||
# Fresh read (mtime-gated to skip redundant IO). Messages fallback
|
||
# (v2 parity) so the card survives even when the agent transiently
|
||
# pops `working['in_plan_mode']` — `plan_state.resolve_path` will
|
||
# walk the message history for the most recent `plan_*/plan.md`.
|
||
msgs_for_resolve = self._agent_msgs(ag)
|
||
path = plan_state.resolve_path(ag, messages=msgs_for_resolve)
|
||
active = plan_state.is_active(ag, messages=msgs_for_resolve)
|
||
new_items: list = self._plan_items
|
||
if path and os.path.isfile(path):
|
||
try:
|
||
mtime = os.path.getmtime(path)
|
||
except OSError:
|
||
mtime = 0.0
|
||
if path != self._plan_path or mtime != self._plan_mtime:
|
||
self._plan_path = path
|
||
self._plan_mtime = mtime
|
||
try:
|
||
with open(path, encoding='utf-8', errors='replace') as f:
|
||
new_items = plan_state.extract(f.read())
|
||
except OSError:
|
||
new_items = []
|
||
elif not active:
|
||
new_items = []
|
||
|
||
# Grace tracking: detect complete edge + handle transient disappearance.
|
||
now = time.time()
|
||
prev = self._plan_items
|
||
now_complete = bool(new_items) and plan_state.is_complete(new_items)
|
||
was_complete = bool(prev) and plan_state.is_complete(prev)
|
||
if now_complete and not was_complete:
|
||
self._plan_complete_since = now
|
||
elif not now_complete:
|
||
self._plan_complete_since = None
|
||
if not new_items and prev:
|
||
if self._plan_lost_since is None:
|
||
self._plan_lost_since = now
|
||
elif new_items:
|
||
self._plan_lost_since = None
|
||
self._plan_items = new_items
|
||
|
||
# Apply lost-grace expiry.
|
||
items = self._plan_items
|
||
if self._plan_lost_since is not None and now - self._plan_lost_since >= self._PLAN_LOST_GRACE_SEC:
|
||
self._plan_items = []
|
||
self._plan_lost_since = None
|
||
items = []
|
||
|
||
# Plan mode armed but no items written yet → placeholder card.
|
||
if not items:
|
||
if active:
|
||
return self._plan_placeholder(w, ag)
|
||
return []
|
||
|
||
n_done, n_total = plan_state.summary(items)
|
||
complete = plan_state.is_complete(items)
|
||
if complete and self._plan_complete_since is not None:
|
||
if now - self._plan_complete_since >= self._PLAN_GRACE_SEC:
|
||
return []
|
||
|
||
msgs = self._agent_msgs(ag)
|
||
step = plan_state.current_step(msgs) if msgs else ''
|
||
|
||
# 5-row budget allocation (matches v2 _refresh_planbar).
|
||
budget = 4 - (1 if step else 0)
|
||
ordered = ([x for x in items if x[1] != 'done'] +
|
||
[x for x in items if x[1] == 'done'])
|
||
body_lines = budget - 1 if len(ordered) > budget else budget
|
||
shown = ordered[:body_lines]
|
||
overflow = max(0, len(ordered) - body_lines)
|
||
|
||
head = (_t('plan.complete', n=n_total) if complete
|
||
else _t('plan.header', done=n_done, total=n_total))
|
||
rows = [_BOLD + _ACCENT + head + _RST]
|
||
if step:
|
||
rows.append(_ACCENT + ' ▸ ' + _RST + _DIM + step[:120] + _RST)
|
||
for c, st in shown:
|
||
if st == 'done':
|
||
rows.append(_ACCENT + ' ✔ ' + _RST + _DIM + c + _RST)
|
||
else:
|
||
rows.append(_DIM + ' ☐ ' + _RST + c)
|
||
if overflow:
|
||
rows.append(_DIM + ' ⋮ ' + _t('plan.overflow', n=overflow) + _RST)
|
||
# Clip each row to width and prepend the same left indent the btw card
|
||
# uses, so plan + btw share a visual margin.
|
||
out: list[str] = []
|
||
for r in rows:
|
||
out.append(' ' + _clip_cells(r, max(1, w - 1)))
|
||
return out
|
||
|
||
def _plan_placeholder(self, w: int, ag) -> list[str]:
|
||
"""Card shown when plan mode is armed but plan.md hasn't been written
|
||
yet — covers the enter_plan_mode → first write gap."""
|
||
try:
|
||
from frontends import plan_state
|
||
except Exception:
|
||
return []
|
||
msgs = self._agent_msgs(ag)
|
||
path = (plan_state._stashed_plan_path(ag)
|
||
or plan_state.find_path_in_messages(msgs)
|
||
or '')
|
||
hint = ('/'.join(path.replace('\\', '/').rstrip('/').split('/')[-2:])
|
||
if path else 'plan.md')
|
||
step = plan_state.current_step(msgs) if msgs else ''
|
||
rows = [_BOLD + _ACCENT + _t('plan.placeholder') + _RST]
|
||
if step:
|
||
rows.append(_ACCENT + ' ▸ ' + _RST + _DIM + step[:120] + _RST)
|
||
rows.append(_DIM + ' ' + _t('plan.waiting', path=hint) + _RST)
|
||
out: list[str] = []
|
||
for r in rows:
|
||
out.append(' ' + _clip_cells(r, max(1, w - 1)))
|
||
return out
|
||
|
||
@staticmethod
|
||
def _boxln(plain: str, colored: str, w: int, border: str = _BORDER) -> str:
|
||
if w < 4:
|
||
return _clip_cells(plain, max(1, w))
|
||
inner = max(0, w - 4)
|
||
plain_fit = _clip_cells(plain, inner)
|
||
colored_fit = _clip_ansi_cells(colored, inner)
|
||
pad = max(0, inner - cell_len(plain_fit)) # cell_len → CJK-safe alignment
|
||
return border + '│ ' + _RST + colored_fit + ' ' * pad + border + ' │' + _RST
|
||
|
||
def _segs(self, iw: int, text: str | None = None) -> list[tuple[int, str]]:
|
||
"""Flatten `text` (defaults to self.buf) into visual rows: (abs char
|
||
start, chunk text). One source of truth for both caret math and
|
||
←→↑↓ navigation. Pass `text` explicitly when rendering a virtual
|
||
body (e.g. shell-mode hides the leading `!`); pair it with the
|
||
matching `pos` in `_seg_at`."""
|
||
src = self.buf if text is None else text
|
||
segs, p = [], 0
|
||
for line in src.split('\n'):
|
||
for ch in _wrap_cells(line, iw) or ['']:
|
||
segs.append((p, ch)); p += len(ch)
|
||
p += 1 # the '\n' separator
|
||
return segs
|
||
|
||
def _seg_at(self, segs: list[tuple[int, str]], pos: int | None = None) -> tuple[int, int]:
|
||
"""(visual row index, char offset within its chunk) for `pos`
|
||
(defaults to self.pos). Caller passes a virtual `pos` when segs
|
||
were built from a virtual `text`."""
|
||
p = self.pos if pos is None else pos
|
||
for i, (st, ch) in enumerate(segs):
|
||
end = st + len(ch)
|
||
eol = i + 1 == len(segs) or segs[i + 1][0] != end # \n gap ⇒ row ends here
|
||
if st <= p and (p < end or (p == end and eol)):
|
||
return i, p - st
|
||
return len(segs) - 1, len(segs[-1][1])
|
||
|
||
def _line_region(self, pos: int | None = None) -> tuple[int, int, int, int]:
|
||
"""(行号, 总行数, 行起始偏移, 行结束偏移) 基于逻辑行(\\n分割)"""
|
||
p = self.pos if pos is None else pos
|
||
ls = self.buf.rfind('\n', 0, p) + 1
|
||
le = self.buf.find('\n', p)
|
||
if le == -1:
|
||
le = len(self.buf)
|
||
line_no = self.buf[:p].count('\n')
|
||
total = self.buf.count('\n') + 1
|
||
return line_no, total, ls, le
|
||
|
||
def _cur_v(self, d: int) -> None:
|
||
"""↑/↓ roam by VISUAL row (a long single-line paste wraps to many rows
|
||
yet stays one logical line — must still roam). At the top/bottom visual
|
||
row, one more ↑/↓ falls through to input history — including for a
|
||
multi-line draft (v2 parity)."""
|
||
segs = self._segs(max(1, _term()[0] - 6))
|
||
i, off = self._seg_at(segs); ni = i + d
|
||
if not 0 <= ni < len(segs):
|
||
self._nav_hist(d)
|
||
return
|
||
tcol = cell_len(segs[i][1][:off]) # keep display column
|
||
st, ch = segs[ni]
|
||
o = cw = 0
|
||
for c in ch:
|
||
if cw >= tcol:
|
||
break
|
||
cw += cell_len(c); o += 1
|
||
self.pos = st + o
|
||
|
||
def _picker_init(self, ae) -> None:
|
||
"""Initialize picker state for a fresh ask_user event. Pickers only
|
||
activate when the event has candidates; otherwise this is a no-op and
|
||
the user just types free text."""
|
||
if ae is None or not getattr(ae, 'candidates', None):
|
||
self._picker_mode = None
|
||
self._picker_sel = 0
|
||
self._picker_checked = set()
|
||
return
|
||
q = ae.question or ''
|
||
self._picker_mode = 'multi' if _MULTI_RE.search(q) else 'single'
|
||
self._picker_sel = 0
|
||
self._picker_checked = set()
|
||
|
||
def _picker_reset(self) -> None:
|
||
self._picker_mode = None
|
||
self._picker_sel = 0
|
||
self._picker_checked = set()
|
||
|
||
def _show_menu(self, title: str, options: list[str], on_submit,
|
||
hint: str | None = None, on_cancel=None,
|
||
multi_select: bool = False,
|
||
pre_checked: set[int] | None = None,
|
||
filterable: bool = False, free_input: bool = False,
|
||
on_free=None, filter_keys: list[str] | None = None) -> None:
|
||
"""Open a modal arrow-key menu in place of the input box.
|
||
|
||
The menu takes over the live region; ↑↓ move the highlight, Enter
|
||
invokes `on_submit(idx)`, Esc invokes `on_cancel()` (if provided).
|
||
Submission auto-closes the menu before invoking the callback so the
|
||
callback is free to open another menu / commit / start a task.
|
||
|
||
`multi_select=True` switches to checkbox mode: Space toggles the
|
||
highlighted row, the per-row prefix becomes `[x]/[ ]`, and Enter
|
||
delivers a sorted `list[int]` of all checked indices (may be empty
|
||
if the user submits with nothing ticked).
|
||
|
||
`pre_checked` seeds the multi-select tick state atomically. Callers
|
||
used to set `self._menu_checked` AFTER `_show_menu` returned, which
|
||
worked logically but left a one-frame window where PTK could render
|
||
the menu with the wrong state — observed as /scheduler picker
|
||
rendering all unchecked even though `reflect/scheduler.py` was alive.
|
||
Passing the set up-front makes the open atomic.
|
||
|
||
`filterable=True` adds a search box: printable chars / backspace edit a
|
||
live query that filters rows by `filter_keys` (defaults to the visible
|
||
labels; pass the full untruncated text so an elided middle still
|
||
matches). `free_input=True` + `on_free` lets Enter on a query with NO
|
||
match commit the raw query (e.g. an abs path → new workspace)."""
|
||
if not options:
|
||
if not (filterable and free_input):
|
||
return # 空列表但允许 free_input → 仍打开(可输路径新建)
|
||
self._menu_active = True
|
||
self._menu_filterable = bool(filterable)
|
||
self._menu_free = bool(free_input)
|
||
self._menu_on_free = on_free
|
||
self._menu_query = ''
|
||
self._menu_all_labels = list(options)
|
||
self._menu_filter_keys = list(filter_keys) if filter_keys else list(options)
|
||
self._menu_title = title
|
||
# In multi-select mode show a Space-aware hint by default so the user
|
||
# discovers the toggle key without reading the docstring.
|
||
if hint is not None:
|
||
self._menu_hint = hint
|
||
elif multi_select:
|
||
self._menu_hint = _t('menu.hint.multi', default='Space toggle · ↑↓ move · Enter submit · Esc cancel')
|
||
elif filterable:
|
||
self._menu_hint = _t('menu.hint.filter', default='输入过滤 · ↑↓ 选择 · Enter 确认 · Esc 取消')
|
||
else:
|
||
self._menu_hint = _t('menu.hint')
|
||
self._menu_on_submit = on_submit
|
||
self._menu_on_cancel = on_cancel
|
||
self._menu_multi = bool(multi_select)
|
||
self._menu_checked = set(pre_checked) if pre_checked else set()
|
||
self._menu_apply_filter() # 设 _menu_options + _menu_map(初始 query 为空 → 全量)
|
||
self._render_live()
|
||
|
||
def _menu_apply_filter(self) -> None:
|
||
"""Recompute visible rows (`_menu_options`) + map-to-original
|
||
(`_menu_map`) from the live query. No-op shape when not filterable."""
|
||
q = self._menu_query.strip().lower()
|
||
if not self._menu_filterable or not q:
|
||
self._menu_options = list(self._menu_all_labels)
|
||
self._menu_map = list(range(len(self._menu_all_labels)))
|
||
else:
|
||
terms = q.split()
|
||
self._menu_options, self._menu_map = [], []
|
||
for i, key in enumerate(self._menu_filter_keys):
|
||
kl = key.lower()
|
||
if all(t in kl for t in terms):
|
||
self._menu_options.append(self._menu_all_labels[i])
|
||
self._menu_map.append(i)
|
||
# Focus model (filterable): -1 = the input row itself (a selectable
|
||
# object in the ↑↓ ring, v2-continue style); 0..n-1 = a candidate.
|
||
# Typing keeps focus on the input row. Non-filterable menus start at 0.
|
||
self._menu_sel = -1 if self._menu_filterable else 0
|
||
self._menu_scroll = 0
|
||
|
||
def _close_menu(self) -> None:
|
||
self._menu_active = False
|
||
self._menu_options = []
|
||
self._menu_title = ''
|
||
self._menu_hint = ''
|
||
self._menu_sel = 0
|
||
self._menu_scroll = 0
|
||
self._menu_on_submit = None
|
||
self._menu_on_cancel = None
|
||
self._menu_multi = False
|
||
self._menu_checked = set()
|
||
self._menu_filterable = False
|
||
self._menu_free = False
|
||
self._menu_on_free = None
|
||
self._menu_query = ''
|
||
self._menu_all_labels = []
|
||
self._menu_filter_keys = []
|
||
self._menu_map = []
|
||
|
||
@staticmethod
|
||
def _scroll_window(sel: int, total: int, visible: int, scroll: int) -> int:
|
||
"""Adjust `scroll` so that `sel` stays within the visible window of
|
||
size `visible`. Returns the new scroll offset."""
|
||
if total <= visible:
|
||
return 0
|
||
if sel < scroll:
|
||
return max(0, sel)
|
||
if sel >= scroll + visible:
|
||
return min(total - visible, sel - visible + 1)
|
||
return max(0, min(total - visible, scroll))
|
||
|
||
def _menu_submit(self) -> None:
|
||
cb = self._menu_on_submit
|
||
sel = self._menu_sel
|
||
multi = self._menu_multi
|
||
checked = sorted(self._menu_checked) if multi else None
|
||
if not self._menu_active:
|
||
return
|
||
# Filterable: map the visible selection back to the original index; or,
|
||
# when the query matches nothing, commit it verbatim via on_free
|
||
# (free_input — e.g. a typed abs path → new workspace).
|
||
if self._menu_filterable:
|
||
mp = self._menu_map
|
||
on_free = self._menu_on_free
|
||
q = self._menu_query.strip()
|
||
# 焦点在某个候选 → 选它(映射回原始 idx)。
|
||
if sel >= 0 and self._menu_options:
|
||
orig = mp[sel] if 0 <= sel < len(mp) else mp[0]
|
||
self._close_menu()
|
||
if cb is not None:
|
||
try: cb(orig)
|
||
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
|
||
return
|
||
# 焦点在输入框(sel<0):有 query → free 提交(当作路径新建/进入)。
|
||
if self._menu_free and q and on_free is not None:
|
||
self._close_menu()
|
||
try: on_free(q)
|
||
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
|
||
return
|
||
# 输入框空 + 有候选 → 选第一个(便捷)。
|
||
if self._menu_options:
|
||
orig = mp[0]
|
||
self._close_menu()
|
||
if cb is not None:
|
||
try: cb(orig)
|
||
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
|
||
return
|
||
return # 无 query 无候选 → 维持菜单
|
||
self._close_menu()
|
||
if cb is not None:
|
||
try:
|
||
# Multi-select delivers a sorted list[int] (possibly empty)
|
||
# so callers can `if not picked: return` cleanly. Single-
|
||
# select keeps the legacy `int` contract.
|
||
cb(checked if multi else sel)
|
||
except Exception as e:
|
||
self.commit([_t('err.menu_cb', err=str(e))])
|
||
|
||
def _menu_cancel(self) -> None:
|
||
cb = self._menu_on_cancel
|
||
if not self._menu_active:
|
||
return
|
||
self._close_menu()
|
||
if cb is not None:
|
||
try:
|
||
cb()
|
||
except Exception:
|
||
pass
|
||
|
||
def _picker_submit(self) -> str | None:
|
||
"""Return the answer text for the current picker selection, or None
|
||
if picker is inactive or selection is empty."""
|
||
ae = self._asking
|
||
if not ae or not ae.candidates or not self._picker_mode:
|
||
return None
|
||
if self._picker_mode == 'multi':
|
||
if not self._picker_checked:
|
||
# treat Enter on empty multi as picking the highlighted row
|
||
return ae.candidates[self._picker_sel] if 0 <= self._picker_sel < len(ae.candidates) else None
|
||
picked = [ae.candidates[i] for i in sorted(self._picker_checked) if 0 <= i < len(ae.candidates)]
|
||
return '; '.join(picked) if picked else None
|
||
# single
|
||
if 0 <= self._picker_sel < len(ae.candidates):
|
||
return ae.candidates[self._picker_sel]
|
||
return None
|
||
|
||
def _at_root(self) -> str:
|
||
# @ 索引根 = workspace(绑定时真实路径),否则 agent 实际工作目录
|
||
# _ROOT/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
|
||
return self._ws_path or os.path.join(_ROOT, "temp")
|
||
|
||
def _at_active(self):
|
||
"""@ 补全:返回 (query, at_pos_in_buf) 或 None。基于光标前、当前逻辑行的
|
||
@token(@ 可在任意行任意位置;菜单 / ask 态不触发)。"""
|
||
if self._asking is not None or self._menu_active:
|
||
return None
|
||
ls = self.buf.rfind('\n', 0, self.pos) + 1
|
||
tok = at_complete.find_at_token(self.buf[ls:self.pos])
|
||
if tok is None:
|
||
return None
|
||
query, at_in_line = tok
|
||
return query, ls + at_in_line
|
||
|
||
def _at_candidates(self) -> list:
|
||
"""当前 @token 的候选(带 (buf,pos) 缓存,避免每键重算 fuzzy)。"""
|
||
act = self._at_active()
|
||
if act is None:
|
||
self._at_cache = None
|
||
return []
|
||
key = (self.buf, self.pos)
|
||
if self._at_cache is not None and self._at_cache[0] == key:
|
||
return self._at_cache[1]
|
||
# 未绑 workspace → 索引根是 temp,相对路径不直观,候选用绝对路径(_hint_lines
|
||
# 本就整条显示);绑了用相对(短)。
|
||
items = at_complete.candidates_for(act[0], self._at_root(), absolute=not self._ws_path)
|
||
self._at_cache = (key, items)
|
||
return items
|
||
|
||
def _palette_total(self) -> int:
|
||
# 当前 palette 候选数(↓ 键越界判断用):slash 命令 or @ 文件。
|
||
if self._slash_visible():
|
||
return len(self._cmd_matches(self.buf))
|
||
return len(self._at_candidates())
|
||
|
||
def _cmd_matches(self, prefix: str) -> list[tuple[str, str, str]]:
|
||
p = prefix.strip().lower()
|
||
return [c for c in _cmds() if c[0].startswith(p)]
|
||
|
||
def _slash_visible(self) -> bool:
|
||
"""True when the buffer is a live `/`-command prefix with matches."""
|
||
if '\n' in self.buf or not self.buf.startswith('/'):
|
||
return False
|
||
ms = self._cmd_matches(self.buf)
|
||
if not ms:
|
||
return False
|
||
if len(ms) == 1 and ms[0][0] == self.buf.strip():
|
||
return False
|
||
return True
|
||
|
||
def _palette_visible(self) -> bool:
|
||
"""True when the live palette should appear and own ↑↓/Tab — either the
|
||
`/`-command palette (buf starts with /) or the `@` file palette (cursor
|
||
sits in an @token). Same machinery, two candidate sources."""
|
||
if self._asking is not None or self._menu_active:
|
||
return False
|
||
return self._slash_visible() or bool(self._at_candidates())
|
||
|
||
def _hint_lines(self, w: int) -> list[str]:
|
||
"""Live palette: scrollback-style list with an arrow-key highlight +
|
||
scrolling viewport. ↑↓ move the highlight (handled in `_keys`), Tab /
|
||
Enter complete the highlighted entry into the input box. Renders the
|
||
`/`-command set when buf starts with `/`, else the `@` file candidates."""
|
||
if not self._palette_visible():
|
||
return []
|
||
if self._slash_visible():
|
||
rows = [f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
|
||
for n, a, d in self._cmd_matches(self.buf)]
|
||
else:
|
||
rows = [' @ ' + p for p in self._at_candidates()]
|
||
total = len(rows)
|
||
# Cap palette viewport to 6 rows so it doesn't squeeze the input box.
|
||
visible = min(total, 6)
|
||
if self._palette_sel < 0 or self._palette_sel >= total:
|
||
self._palette_sel = 0
|
||
self._palette_scroll = self._scroll_window(self._palette_sel, total, visible, self._palette_scroll)
|
||
start = self._palette_scroll
|
||
end = min(total, start + visible)
|
||
out: list[str] = []
|
||
for i in range(start, end):
|
||
styled = (_ACCENT + _BOLD) if i == self._palette_sel else _DIM
|
||
out.append(styled + _clip_cells(rows[i], w) + _RST)
|
||
return out
|
||
|
||
def _tab(self) -> None:
|
||
if self._asking is not None or self._menu_active:
|
||
return
|
||
# @ 文件补全:用选中候选替换光标处的 @token(format_pick 加引号/尾空格)。
|
||
if not self._slash_visible():
|
||
items = self._at_candidates()
|
||
act = self._at_active()
|
||
if not items or act is None:
|
||
return
|
||
idx = self._palette_sel if 0 <= self._palette_sel < len(items) else 0
|
||
rep = at_complete.format_pick(items[idx])
|
||
at_pos = act[1]
|
||
self.buf = self.buf[:at_pos] + rep + self.buf[self.pos:]
|
||
self.pos = at_pos + len(rep)
|
||
self._palette_sel = 0
|
||
self._palette_scroll = 0
|
||
self._at_cache = None
|
||
return
|
||
ms = self._cmd_matches(self.buf)
|
||
if not ms:
|
||
return
|
||
if len(ms) == 1:
|
||
n, a, _ = ms[0]; self.buf = n + (' ' if a else '')
|
||
else:
|
||
# Tab always completes to whichever entry the palette currently
|
||
# highlights (defaults to 0 = first match). Use the full match
|
||
# list — the palette's scrolling viewport may have walked past
|
||
# the first 6 items, so a raw `ms[:6]` index would be stale.
|
||
idx = self._palette_sel if 0 <= self._palette_sel < len(ms) else 0
|
||
n, a, _ = ms[idx]
|
||
self.buf = n + (' ' if a else '')
|
||
self.pos = len(self.buf)
|
||
self._palette_sel = 0
|
||
self._palette_scroll = 0
|
||
self.pos = len(self.buf)
|
||
|
||
def _esc_back(self) -> None:
|
||
"""Universal back: cancel menu → cancel ask → clear btw panel → clear
|
||
draft → stop running. Esc has NO exit capability — quitting is Ctrl+C×2
|
||
or Ctrl+D only. The btw panel is cleared outright (no history kept); an
|
||
in-flight side-question then lands on an orphan and never reappears.
|
||
Draft-clear comes before running-stop so typing the next prompt mid-run
|
||
and pressing Esc just discards what you typed."""
|
||
if self._menu_active:
|
||
self._menu_cancel(); return
|
||
if self._asking is not None:
|
||
if self._running and self._bridge:
|
||
self._bridge.abort()
|
||
if self._bridge: # drop any queued ask so _drain
|
||
q = self._bridge.ask_user_queue # can't re-enter ask-mode after
|
||
try: # the user already cancelled
|
||
while True:
|
||
q.get_nowait()
|
||
except queue.Empty:
|
||
pass
|
||
self._asking = None; self._running = False; self.buf = ''; self.pos = 0
|
||
self._undo.clear(); self._redo.clear(); self._sel = None
|
||
self._picker_reset()
|
||
self.commit([_DIM + _t('msg.ask_cancelled') + _RST]); return
|
||
if self._btws: # clear the side-question panel
|
||
self._btws = [] # in-flight answers land on orphans
|
||
return
|
||
if self.buf:
|
||
self._snap() # let Ctrl+Z restore the draft
|
||
self.buf = ''; self.pos = 0; self._sel = None; return
|
||
if self._pending: # cancel queued user messages
|
||
n = len(self._pending)
|
||
self._pending = []
|
||
if self._bridge:
|
||
td = getattr(self._bridge.agent, 'task_dir', None)
|
||
if td:
|
||
try: os.remove(os.path.join(td, '_intervene'))
|
||
except OSError: pass
|
||
with self._bridge._intervene_lk:
|
||
self._bridge._intervene_pending = []
|
||
self.commit([_DIM + _t('pending.cleared', n=n) + _RST])
|
||
return
|
||
if self._running and self._bridge:
|
||
self._bridge.abort()
|
||
self.commit([_DIM + _t('msg.abort_requested') + _RST]); return
|
||
|
||
def _ask_card(self, w: int) -> tuple[list[str], int, int]:
|
||
"""Single unified card for ask_user: question + candidates + INLINE
|
||
input + hint all inside one accent-bordered box. Returns the same
|
||
triple as _input_box (lines, caret_row_within_lines, caret_col) so
|
||
the caller treats it uniformly."""
|
||
ae = self._asking
|
||
if w < 24:
|
||
rows: list[str] = []
|
||
for ln in (ae.question or _t('ask.default_q')).strip().split('\n'):
|
||
rows.extend(_DIM + _clip_cells(x, w) + _RST for x in _wrap_cells(ln, w))
|
||
for i, c in enumerate((ae.candidates or [])[:3], 1):
|
||
rows.extend(_clip_cells(x, w) for x in _wrap_cells(f'{i}. {c}', w))
|
||
iw = max(1, w - 2)
|
||
segs = self._segs(iw)
|
||
ci, coff = self._seg_at(segs)
|
||
input_start = len(rows)
|
||
for i, (_st, ch) in enumerate(segs):
|
||
pre = '❯ ' if i == 0 and w >= 3 else '❯' if i == 0 else ''
|
||
rows.append(_clip_cells(pre + ch, w))
|
||
ccol = min(max(1, w), cell_len(('❯ ' if ci == 0 and w >= 3 else '❯' if ci == 0 else '') + segs[ci][1][:coff]) + 1)
|
||
return rows or [''], input_start + ci, ccol
|
||
inner = max(8, w)
|
||
content_w = max(1, inner - 4)
|
||
pending = self._bridge.ask_user_queue.qsize() if self._bridge else 0
|
||
label = _t('ask.title') + (_t('ask.pending', n=pending) if pending else '')
|
||
label_max = max(1, inner - 5)
|
||
if cell_len(label) > label_max:
|
||
label = _clip_cells(label, label_max)
|
||
fill = max(1, inner - 3 - cell_len(' ' + label + ' '))
|
||
top = (_ACCENT + '╭─' + _RST + ' ' + _BOLD + _ACCENT + label + _RST +
|
||
' ' + _ACCENT + '─' * fill + '╮' + _RST)
|
||
bot = _border('╰', '╯', inner, _ACCENT)
|
||
|
||
def row(text: str, style: str = '') -> list[str]:
|
||
r = []
|
||
for ch in (_wrap_cells(text, content_w) or ['']):
|
||
pad = content_w - cell_len(ch)
|
||
r.append(_ACCENT + '│' + _RST + ' ' + style + ch + _RST +
|
||
' ' * pad + ' ' + _ACCENT + '│' + _RST)
|
||
return r
|
||
|
||
rows = [top]
|
||
for ln in (ae.question or _t('ask.default_q')).strip().split('\n'):
|
||
rows.extend(row(ln, _BOLD))
|
||
if ae.candidates:
|
||
rows.extend(row(''))
|
||
# Render each candidate. Picker mode adds a highlight on the
|
||
# current row and (in multi) a [ ]/[x] marker; in free-text mode
|
||
# candidates show only as numbered hints — typing the number
|
||
# still picks that row at submit time.
|
||
multi = self._picker_mode == 'multi'
|
||
for i, c in enumerate(ae.candidates):
|
||
if multi:
|
||
mark = '[x]' if i in self._picker_checked else '[ ]'
|
||
body = f' {mark} {i + 1}. {c}'
|
||
else:
|
||
body = f' {i + 1}. {c}'
|
||
if self._picker_mode and i == self._picker_sel:
|
||
rows.extend(row(body, _ACCENT + _BOLD))
|
||
else:
|
||
rows.extend(row(body))
|
||
# Multi mode is "checkbox-only" — no input box. Free-text doesn't
|
||
# combine naturally with multi-pick semantics, and hiding the input
|
||
# keeps the candidate list (with its [x]/[ ] marks) always visible.
|
||
# Single mode keeps the input box for the focus-cycle UX.
|
||
if multi:
|
||
crow, ccol = len(rows) - 1, 0 # cursor is hidden in picker mode
|
||
else:
|
||
rows.extend(row('')) # gap between info & input
|
||
|
||
# Inline input area (same wrap math as the regular input box: `❯ chunk`
|
||
# inside `│ ... │`, caret column = cell_len(prefix+chunk[:off])+3).
|
||
iw = content_w - 2
|
||
segs = self._segs(iw)
|
||
ci, coff = self._seg_at(segs)
|
||
sel = self._sel_range()
|
||
crow, ccol = len(rows), cell_len('❯ ') + 3
|
||
for i, (st, ch) in enumerate(segs):
|
||
first = i == 0
|
||
pre_p = '❯ ' if first else ' '
|
||
pre_c = (_ACCENT + '❯' + _RST + ' ') if first else ' '
|
||
disp = ch
|
||
if sel:
|
||
lo = max(sel[0] - st, 0); hi = min(sel[1] - st, len(ch))
|
||
if lo < hi:
|
||
disp = ch[:lo] + '\x1b[7m' + ch[lo:hi] + '\x1b[27m' + ch[hi:]
|
||
pad = content_w - cell_len(pre_p + ch)
|
||
rows.append(_ACCENT + '│' + _RST + ' ' + pre_c + disp +
|
||
' ' * pad + ' ' + _ACCENT + '│' + _RST)
|
||
if i == ci:
|
||
crow = len(rows) - 1
|
||
ccol = cell_len(pre_p + ch[:coff]) + 3
|
||
|
||
rows.extend(row(''))
|
||
if self._picker_mode == 'multi':
|
||
hint = _t('ask.hint.multi')
|
||
elif self._picker_mode == 'single':
|
||
hint = _t('ask.hint.single')
|
||
else:
|
||
hint = _t('ask.hint.freetext')
|
||
rows.extend(row(hint, _DIM))
|
||
rows.append(bot)
|
||
return rows, crow, ccol
|
||
|
||
def _menu_visible_count(self, h: int) -> int:
|
||
"""How many menu rows fit in the current viewport. Leaves room for
|
||
title + bottom border + gap + hint + status line."""
|
||
# Budget: title 1 + bottom border 1 + gap 1 + hint 1 + status 2 ≈ 6
|
||
return max(3, h - 6)
|
||
|
||
def _menu_card(self, w: int) -> tuple[list[str], int, int]:
|
||
"""Modal arrow-key menu rendered in place of the input box.
|
||
|
||
Layout mirrors _ask_card's accent-bordered box: title at the top, one
|
||
row per option with the highlighted row shown in bold accent, a hint
|
||
line at the bottom. Long lists use a scrolling viewport — sel stays
|
||
within the visible window, scrolling adjusts as ↑↓ walk past edges.
|
||
|
||
Returns (lines, caret_row, caret_col) to match _input_box's signature;
|
||
caret column is set to (0, 1) so PTK hides the block cursor (no
|
||
visible caret while a menu is open)."""
|
||
total = len(self._menu_options)
|
||
# Use PTK's current viewport height (set on SB._h by the render loop).
|
||
h = max(8, getattr(self, '_h', 24))
|
||
visible = min(total, self._menu_visible_count(h))
|
||
# Clamp sel and recompute scroll so sel is in-window. Filterable menus
|
||
# allow sel == -1 (focus on the input row); clamp candidates to range
|
||
# but leave -1 intact. _scroll_window treats -1 as "top".
|
||
if self._menu_filterable:
|
||
self._menu_sel = -1 if self._menu_sel < 0 else (min(total - 1, self._menu_sel) if total else -1)
|
||
else:
|
||
self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
|
||
self._menu_scroll = self._scroll_window(max(0, self._menu_sel), total, visible, self._menu_scroll)
|
||
start = self._menu_scroll
|
||
end = min(total, start + visible)
|
||
|
||
if w < 24:
|
||
rows = [_clip_cells(self._menu_title, w)]
|
||
for i in range(start, end):
|
||
marker = '▌' if i == self._menu_sel else ' '
|
||
# In multi-select narrow mode prepend [x]/[ ] so the user can
|
||
# still tell what is ticked even when the box is too narrow
|
||
# for the bordered card.
|
||
if self._menu_multi:
|
||
box = '[x]' if i in self._menu_checked else '[ ]'
|
||
label_i = f'{marker} {box} {self._menu_options[i]}'
|
||
else:
|
||
label_i = f'{marker} {self._menu_options[i]}'
|
||
rows.append(_clip_cells(label_i, w))
|
||
return rows, 0, 1
|
||
inner = max(8, w)
|
||
content_w = max(1, inner - 4)
|
||
label = self._menu_title or _t('menu.default_title')
|
||
scroll_tag = f' {self._menu_sel + 1}/{total}' if total > visible else ''
|
||
full_label = label + (_DIM + scroll_tag + _RST if scroll_tag else '')
|
||
label_max = max(1, inner - 5)
|
||
# cell_len ignores ANSI so this measures real visible width
|
||
if cell_len(_visible_text(full_label)) > label_max:
|
||
full_label = _clip_cells(full_label, label_max)
|
||
fill = max(1, inner - 3 - cell_len(_visible_text(' ' + full_label + ' ')))
|
||
top = (_ACCENT + '╭─' + _RST + ' ' + _BOLD + _ACCENT + full_label + _RST +
|
||
' ' + _ACCENT + '─' * fill + '╮' + _RST)
|
||
bot = _border('╰', '╯', inner, _ACCENT)
|
||
|
||
def row(text: str, style: str = '') -> list[str]:
|
||
r = []
|
||
for ch in (_wrap_cells(text, content_w) or ['']):
|
||
pad = content_w - cell_len(ch)
|
||
r.append(_ACCENT + '│' + _RST + ' ' + style + ch + _RST +
|
||
' ' * pad + ' ' + _ACCENT + '│' + _RST)
|
||
return r
|
||
|
||
rows = [top]
|
||
# Filterable menu: a search line above the rows, showing the live query
|
||
# with a caret. Empty match set renders a hint instead of blank.
|
||
if self._menu_filterable:
|
||
q = self._menu_query
|
||
focused = (self._menu_sel < 0) # 焦点在输入框(可被 ↑↓ 选中的对象)
|
||
# 整行单一 style(纯文本交给 row,颜色走 style 参)——内嵌 ANSI 会让
|
||
# row() 的 pad=content_w-cell_len(ch) 把转义算进宽度,右边框就错位。
|
||
# focused → 整行 accent(箭头+占位/输入都高亮,焦点明显);否则 dim。
|
||
if q:
|
||
text = '› ' + q + ('▏' if focused else '')
|
||
else:
|
||
text = '› ' + _t('menu.search')
|
||
rows.extend(row(text, _ACCENT if focused else _DIM))
|
||
if not self._menu_options:
|
||
hint = (_t('menu.free.hint') if (self._menu_free and q.strip()) else _t('menu.no_match'))
|
||
rows.extend(row(hint, _DIM))
|
||
# Viewport scrolls as sel walks past the edges; the title's N/total tag
|
||
# signals position, so no "N more" indicator rows.
|
||
for i in range(start, end):
|
||
style = (_ACCENT + _BOLD) if i == self._menu_sel else ''
|
||
# Multi-select rows get a `[x]`/`[ ]` prefix so the user sees what
|
||
# is currently ticked. Single-select keeps the legacy clean look
|
||
# (bold accent on the highlighted row is enough).
|
||
if self._menu_multi:
|
||
box = '[x]' if i in self._menu_checked else '[ ]'
|
||
rows.extend(row(f'{box} {self._menu_options[i]}', style))
|
||
else:
|
||
rows.extend(row(self._menu_options[i], style))
|
||
rows.extend(row(''))
|
||
rows.extend(row(self._menu_hint or _t('menu.hint'), _DIM))
|
||
rows.append(bot)
|
||
return rows, 0, 1
|
||
|
||
def _btw_card(self, w: int) -> list[str]:
|
||
"""Ephemeral side-question panel above the input box: every /btw and
|
||
its answer, newest last. The question shows immediately (answer area
|
||
reads `querying…` until it lands). Lives only in the live region
|
||
(never scrollback); Esc clears it. Earlier replies are NOT folded."""
|
||
if not self._btws:
|
||
return []
|
||
# Side panel — left-aligned but inset a few columns from the margin,
|
||
# and narrower than the full input box, so it reads as an aside.
|
||
indent = 3
|
||
inner = max(8, min(w - indent, 72))
|
||
content_w = max(1, inner - 4)
|
||
n = len(self._btws)
|
||
label = _t('btw.title') + (_DIM + f' ×{n}' + _RST if n > 1 else '')
|
||
label_max = max(1, inner - 5)
|
||
if cell_len(_visible_text(label)) > label_max:
|
||
label = _clip_cells(label, label_max)
|
||
fill = max(1, inner - 3 - cell_len(_visible_text(' ' + label + ' ')))
|
||
top = (_ACCENT + '╭─' + _RST + ' ' + _BOLD + _ACCENT + label + _RST +
|
||
' ' + _ACCENT + '─' * fill + '╮' + _RST)
|
||
bot = _border('╰', '╯', inner, _ACCENT)
|
||
|
||
def row(text: str, style: str = '') -> list[str]:
|
||
r = []
|
||
for ch in (_wrap_cells(text, content_w) or ['']):
|
||
pad = content_w - cell_len(ch)
|
||
r.append(_ACCENT + '│' + _RST + ' ' + style + ch + _RST +
|
||
' ' * pad + ' ' + _ACCENT + '│' + _RST)
|
||
return r
|
||
|
||
out = [top]
|
||
for i, (q, a) in enumerate(self._btws):
|
||
if i:
|
||
out.extend(row('')) # blank gap between entries
|
||
if a is None: # still in flight
|
||
out.extend(row(f'> /btw {q}', _DIM))
|
||
out.extend(row(_t('btw.querying'), _ACCENT))
|
||
else:
|
||
for ln in a.split('\n'):
|
||
out.extend(row(ln, _DIM))
|
||
out.append(bot)
|
||
# Small left indent so the panel doesn't sit flush against the margin.
|
||
if indent and inner + indent <= w:
|
||
out = [' ' * indent + ln for ln in out]
|
||
return out
|
||
|
||
def _pending_card(self, w: int) -> list[str]:
|
||
if not self._pending:
|
||
return []
|
||
n = len(self._pending)
|
||
head = _t('pending.head_running', n=n)
|
||
rows = [_ACCENT + _BOLD + _clip_cells(' ↑ ' + head, w) + _RST]
|
||
body_w = max(20, w - 8)
|
||
for i, msg in enumerate(self._pending[-3:], 1):
|
||
preview = msg.replace('\n', ' ').strip()
|
||
if cell_len(preview) > body_w:
|
||
preview = _clip_cells(preview, body_w - 1) + '…'
|
||
rows.append(_DIM + _clip_cells(f' {i}. {preview}', w) + _RST)
|
||
if n > 3:
|
||
rows.append(_DIM + _clip_cells(f' … +{n - 3} more', w) + _RST)
|
||
rows.append('')
|
||
return rows
|
||
|
||
def _run_shell(self, cmd: str) -> None:
|
||
"""Execute `cmd` in the host shell and echo command + output into
|
||
scrollback as the `! cmd` / `└ output` pair seen in screenshot
|
||
034257. Both halves get appended to the agent's LLM history
|
||
(single user-role entry with a `[!shell]` tag) so a follow-up
|
||
question like "what did I just run?" finds the context.
|
||
|
||
Output capture is utf-8 / replace so binary spew never crashes
|
||
the decoder. 30 s timeout — anything longer wants `/conductor`
|
||
territory, not a magic prompt."""
|
||
if not cmd:
|
||
return
|
||
w = _term()[0]
|
||
# Echo the command line as a full-width charcoal tile (cc-style):
|
||
# pink `!` prompt embedded; `_tile` re-asserts the bg around every
|
||
# internal _RST so the pink fg coexists with the band.
|
||
head = _SHELL_ACCENT + '! ' + _RST + cmd
|
||
self.commit([_tile(' ' + head, _TILE_SHELL, w)])
|
||
import subprocess
|
||
from frontends.slash_cmds import detect_user_shell
|
||
shell_argv, shell_name = detect_user_shell()
|
||
out = ''
|
||
rc = 0
|
||
try:
|
||
r = subprocess.run(
|
||
shell_argv + [cmd], capture_output=True,
|
||
timeout=30, encoding='utf-8', errors='replace',
|
||
)
|
||
out = (r.stdout or '') + (r.stderr or '')
|
||
rc = r.returncode
|
||
except subprocess.TimeoutExpired:
|
||
out = _t('shell.timeout', sec=30); rc = -1
|
||
except Exception as e:
|
||
out = _t('shell.error', err=f'{type(e).__name__}: {e}'); rc = -1
|
||
body = (out.rstrip('\n') or _t('shell.empty')).split('\n')
|
||
rows: list[str] = []
|
||
for i, ln in enumerate(body):
|
||
# Output rows stay on bare terminal bg — only the `! cmd` echo
|
||
# carries the charcoal band so the eye reads it as "this is
|
||
# the command, that's its output" (cc-style). `└ ` only on
|
||
# the first line so multi-line output reads as a continuation.
|
||
prefix = _DIM + ' └ ' + _RST if i == 0 else _DIM + ' ' + _RST
|
||
rows.append(prefix + ln)
|
||
rows.append('') # blank gap separates the shell pair from the next chat block
|
||
self.commit(rows)
|
||
# Persist the exchange so the agent sees it on its next turn.
|
||
# Splitting on `is_running` avoids racing the agent thread:
|
||
# running → use the `_intervene` file hook (safe because the
|
||
# agent only reads it at turn boundaries, never
|
||
# while iterating `backend.history`).
|
||
# idle → direct append to backend.history is safe — there's
|
||
# no concurrent reader.
|
||
try:
|
||
txt = _t('shell.history', sh=shell_name, cmd=cmd, out=out.rstrip(), rc=rc)
|
||
if (self._bridge is not None
|
||
and getattr(self._bridge.agent, 'is_running', False)):
|
||
self._bridge.inject_intervene(txt)
|
||
else:
|
||
be = getattr(self._bridge.agent, 'llmclient', None) if self._bridge else None
|
||
be = getattr(be, 'backend', None) if be is not None else None
|
||
if be is not None and hasattr(be, 'history'):
|
||
be.history.append({"role": "user",
|
||
"content": [{"type": "text", "text": txt}]})
|
||
except Exception:
|
||
pass
|
||
|
||
def _sync_pending_from_bridge(self) -> None:
|
||
"""Drop UI mirror entries the bridge has confirmed consumed. The
|
||
bridge's turn_end hook clears `_intervene_pending` at every turn
|
||
boundary — that's our signal the model has now seen the wrapped
|
||
message (or that exit_reason kicked the replay)."""
|
||
if self._pending and self._bridge and not self._bridge_has_pending():
|
||
self._pending = []
|
||
|
||
def _bridge_has_pending(self) -> bool:
|
||
if not self._bridge: return False
|
||
with self._bridge._intervene_lk:
|
||
return bool(self._bridge._intervene_pending)
|
||
|
||
def _input_box(self, w: int) -> list[str]:
|
||
"""A full-width bordered, padded input box (cc-style). Lives in the
|
||
redraw region only — border glyphs never reach scrollback/copy. The
|
||
caret (row/col) is derived from self.pos so ←→↑↓ edit in place. In
|
||
ask-mode the answer is typed INSIDE the question card itself (one
|
||
unified component) — short-circuit to _ask_card. When a modal
|
||
menu (e.g. /llm picker) is active, _menu_card replaces the input box.
|
||
|
||
Shell mode: when the buffer starts with `!`, that `!` IS the
|
||
prompt mark — pink, same column as `❯`, followed by one space and
|
||
then the body. The leading `!` is stripped from the rendered body
|
||
so it shows once (the duplicate-`!` bug fixed in screenshot
|
||
040031). Selection / cursor math uses a virtual buf=buf[1:],
|
||
pos=max(0, pos-1) so ←→ navigation still tracks the user's
|
||
intuition (caret never goes before the prompt)."""
|
||
if self._menu_active:
|
||
return self._menu_card(w)
|
||
if self._asking is not None:
|
||
return self._ask_card(w)
|
||
shell_mode = self.buf.startswith('!')
|
||
if shell_mode:
|
||
border = _SHELL_ACCENT
|
||
accent = _SHELL_ACCENT
|
||
mark = '!'
|
||
body = self.buf[1:]
|
||
body_pos = max(0, self.pos - 1)
|
||
else:
|
||
border = _BORDER
|
||
accent = _ACCENT
|
||
mark = '❯'
|
||
body = self.buf
|
||
body_pos = self.pos
|
||
if w < 8:
|
||
iw = max(1, w - 2)
|
||
segs = self._segs(iw, body)
|
||
ci, coff = self._seg_at(segs, body_pos)
|
||
rows = []
|
||
for i, (_st, ch) in enumerate(segs):
|
||
pre = f'{mark} ' if i == 0 and w >= 3 else mark if i == 0 else ''
|
||
rows.append(_clip_cells(pre + ch, w))
|
||
ccol = min(max(1, w), cell_len((f'{mark} ' if ci == 0 and w >= 3 else mark if ci == 0 else '') + segs[ci][1][:coff]) + 1)
|
||
return rows or [''], ci, ccol
|
||
top = _border('╭', '╮', w, border)
|
||
bot = _border('╰', '╯', w, border)
|
||
segs = self._segs(max(1, w - 6), body)
|
||
ci, coff = self._seg_at(segs, body_pos)
|
||
# Selection range is computed against the original buf; offset by
|
||
# 1 for shell mode so the highlight aligns with the displayed
|
||
# body slice rather than the underlying buf indices.
|
||
sel_raw = self._sel_range()
|
||
sel = None
|
||
if sel_raw is not None:
|
||
shift = 1 if shell_mode else 0
|
||
lo, hi = max(0, sel_raw[0] - shift), max(0, sel_raw[1] - shift)
|
||
if lo < hi:
|
||
sel = (lo, hi)
|
||
rows = []
|
||
for i, (st, ch) in enumerate(segs):
|
||
first = i == 0
|
||
pre_p = f'{mark} ' if first else ' '
|
||
pre_c = (accent + mark + _RST + ' ') if first else ' '
|
||
disp = ch
|
||
if sel: # reverse-video the selected slice
|
||
lo = max(sel[0] - st, 0); hi = min(sel[1] - st, len(ch))
|
||
if lo < hi:
|
||
disp = ch[:lo] + '\x1b[7m' + ch[lo:hi] + '\x1b[27m' + ch[hi:]
|
||
rows.append(self._boxln(pre_p + ch, pre_c + disp, w, border))
|
||
pre = f'{mark} ' if ci == 0 else ' '
|
||
crow, ccol = ci, cell_len(pre + segs[ci][1][:coff]) + 3
|
||
box = [top] + rows + [bot]
|
||
caret_row = crow + 1 # +1 for the top border row
|
||
return box, caret_row, ccol
|
||
|
||
def _live_lines(self) -> list[str]:
|
||
# Live region MUST fit in (terminal_height − 1). If it overflows, the
|
||
# terminal scrolls and pushes header rows (chip tops, etc.) into the
|
||
# immutable scrollback — they accumulate as visible duplicates because
|
||
# \x1b[J can only clear at/below the cursor, not above. So we build the
|
||
# fixed "after-tail" block first (pet + input box + hint + status + plan),
|
||
# then size the volatile tail to the remaining budget.
|
||
w, h = _term()
|
||
max_live = max(1, h - 1)
|
||
after: list[str] = []
|
||
if self._running and self._asking is None:
|
||
el = time.time() - self._t0_anchor
|
||
# `_spin // 5` slows the pet-frame swap to ~0.5s (the spinner
|
||
# glyph itself still cycles at 0.1s for the snappy "alive" feel).
|
||
after.append(' ' + _heat(el) + _pet(el, self._spin // 5) + _RST +
|
||
' ' + _DIM + _gerund(el) + '…' + _RST)
|
||
# Plan card sits above the btw card — it's longer-lived context and
|
||
# belongs further from the input area than transient side-questions.
|
||
# Hidden while a modal menu owns the live region.
|
||
if not self._menu_active:
|
||
plan_rows = self._plan_card(w)
|
||
if plan_rows:
|
||
after += plan_rows
|
||
# Side-question panel sits just above the input box (hidden while a
|
||
# modal menu owns the live region).
|
||
if self._btws and not self._menu_active:
|
||
after += self._btw_card(w)
|
||
# Pending-input preview sits just above the input box too — appears
|
||
# whenever there's a queued message (during run OR during the
|
||
# short post-turn cooldown). Hidden when a modal menu is active
|
||
# so the picker keeps the whole live region for itself.
|
||
if self._pending and not self._menu_active:
|
||
after += self._pending_card(w)
|
||
box_start = len(after)
|
||
box, caret_row, caret_col = self._input_box(w)
|
||
after += box
|
||
after += self._hint_lines(w)
|
||
# A modal menu picker or the live `/`-command palette owns the live
|
||
# region — drop the status line so the list isn't crowded by the
|
||
# [main] … chrome.
|
||
if not self._menu_active and not self._palette_visible():
|
||
if 0 < time.time() - self._cc_t < 1:
|
||
# Ctrl+C armed: clear the status bar down to just the quit
|
||
# prompt so it stands out (v2 parity). Uses the input box's
|
||
# accent color so the prompt reads as part of that component.
|
||
after.append(_BOLD + _ACCENT + _clip_cells(' ' + _t('status.cc_confirm'), w) + _RST)
|
||
elif self.buf.startswith('!') and self._asking is None and not self._menu_active:
|
||
# Shell-mode hint replaces the status line — same hot pink
|
||
# so the user reads input box + hint as one component.
|
||
after.append(_BOLD + _SHELL_ACCENT + _clip_cells(' ' + _t('shell.hint'), w) + _RST)
|
||
elif self._running and self._asking is None:
|
||
lead = _heat(time.time() - self._t0_anchor) + _SPIN[self._spin % len(_SPIN)] + ' ' + _RST
|
||
after.append(lead + _DIM + _clip_cells(self._status_line(w), max(2, w - 2)) + _RST)
|
||
else:
|
||
after.append(_DIM + _clip_cells(self._status_line(w), w) + _RST)
|
||
cur_row = box_start + caret_row
|
||
if len(after) > max_live:
|
||
drop = len(after) - max_live
|
||
after = after[drop:]
|
||
cur_row = max(0, cur_row - drop)
|
||
budget = max(0, max_live - len(after))
|
||
tail = _tail_fit_rows(self._live_tail, w, budget)
|
||
self._cur = (min(len(tail) + cur_row, len(tail) + len(after) - 1), caret_col)
|
||
return tail + after
|
||
|
||
def _render_live(self) -> None:
|
||
"""Request a live-region repaint. PTK Application owns the live region
|
||
now — this method just invalidates the app so PTK rebuilds the cache and
|
||
rerenders. The old stdout-based paint/cursor-park logic is gone."""
|
||
self._invalidate_ptk()
|
||
|
||
def commit(self, content) -> None:
|
||
"""Append finalized history. Accepts either a Block (preferred — keeps
|
||
the source so resize can re-render at the new width) or a pre-rendered
|
||
list[str] (legacy: wrapped as a 'plain' block, which can reflow only
|
||
through _fit_rows — no un-rendering of width-baked structures). For
|
||
assistant blocks, _tool_base is anchored to the cumulative tool count
|
||
of prior blocks so chip tids continue the global sequence and stay
|
||
stable across resize repaints."""
|
||
w = _term()[0]
|
||
if isinstance(content, Block):
|
||
blk = content
|
||
if blk.kind == 'assistant':
|
||
self._tool_base = sum(b.tool_n for b in self._blocks)
|
||
self._blocks.append(blk)
|
||
self._cap_blocks()
|
||
lines = self._render_block(blk, w)
|
||
if blk.kind == 'assistant':
|
||
self._tool_base += blk.tool_n # advance for next message
|
||
else:
|
||
blk = Block('plain', '\n'.join(content))
|
||
self._blocks.append(blk)
|
||
self._cap_blocks()
|
||
lines = list(content)
|
||
self._emit_lines(lines, w)
|
||
|
||
def _cap_blocks(self) -> None:
|
||
if len(self._blocks) > 4000:
|
||
self._blocks = self._blocks[-3000:]
|
||
# streaming block (if any) is the last element by construction —
|
||
# the cap preserves it. Pre-streaming tool_base recompute will pick
|
||
# up the surviving blocks correctly.
|
||
|
||
def _emit_lines(self, lines: list[str], w: int) -> None:
|
||
"""Queue finalized lines for emission to the terminal's native scrollback.
|
||
|
||
Old behavior wrote ANSI directly via _w() and competed with PTK's
|
||
renderer for stdout. Now lines are queued and the async maintenance
|
||
loop drains them via run_in_terminal(app.print_text), which moves PTK
|
||
out of the way, prints into scrollback above the live region, then lets
|
||
PTK re-render its area on top."""
|
||
if not lines:
|
||
self._invalidate_ptk()
|
||
return
|
||
out: list[str] = []
|
||
for ln in lines:
|
||
out.extend(_fit_rows(ln, w))
|
||
with self._sbq_lk:
|
||
self._sbq.extend(out)
|
||
# Reset legacy paint state — PTK owns the live region; these counters
|
||
# are kept as zero so any stray legacy code path that reads them sees
|
||
# "nothing painted" rather than ghost positions.
|
||
self._painted = []; self._live_rows = 0; self._parked_up = 0
|
||
self._schedule_drain()
|
||
self._invalidate_ptk()
|
||
|
||
# ── PTK presentation plumbing ──
|
||
|
||
def _invalidate_ptk(self) -> None:
|
||
"""Tell PTK to schedule a re-render of the live region."""
|
||
app = self._ptk_app
|
||
if app is not None:
|
||
app.invalidate()
|
||
|
||
def _ptk_size(self) -> tuple[int, int]:
|
||
"""Current terminal size, preferring PTK's view of it (which can differ
|
||
from os.get_terminal_size on Windows ConPTY mid-resize)."""
|
||
app = self._ptk_app
|
||
if app is not None:
|
||
try:
|
||
sz = app.output.get_size()
|
||
return max(1, int(sz.columns)), max(1, int(sz.rows))
|
||
except Exception:
|
||
pass
|
||
return _term()
|
||
|
||
def _schedule_drain(self) -> None:
|
||
"""Wake the scrollback drain task from any thread."""
|
||
loop = self._ptk_loop
|
||
app = self._ptk_app
|
||
if loop is None or app is None:
|
||
return
|
||
loop.call_soon_threadsafe(self._kick_drain)
|
||
|
||
def _kick_drain(self) -> None:
|
||
app = self._ptk_app
|
||
if app is None:
|
||
return
|
||
app.create_background_task(self._drain_async())
|
||
|
||
async def _drain_async(self) -> None:
|
||
"""Drain the scrollback queue (or handle a pending repaint).
|
||
|
||
Resize path: when _pending_repaint is set, wipe screen+scrollback and
|
||
re-emit all rendered blocks at the current width — old scrollback
|
||
content was baked at the previous width and can't reflow on its own."""
|
||
from prompt_toolkit.application import run_in_terminal
|
||
from prompt_toolkit.formatted_text import ANSI as _ANSI
|
||
app = self._ptk_app
|
||
if app is None:
|
||
return
|
||
|
||
if self._pending_repaint:
|
||
w = self._ptk_size()[0]
|
||
with self._lk:
|
||
self._pending_repaint = False
|
||
# Drop queued items — they are about to be re-rendered as part
|
||
# of the full history below. Holding both would double-print.
|
||
with self._sbq_lk:
|
||
self._sbq = []
|
||
hist_all = self._render_all_blocks(w)
|
||
hist_fit: list[str] = []
|
||
for ln in hist_all:
|
||
hist_fit.extend(_fit_rows(ln, w))
|
||
body = '\r\n'.join(hist_fit) + ('\r\n' if hist_fit else '')
|
||
# \x1b[3J clears scrollback (xterm/iTerm/wt/most modern terms);
|
||
# \x1b[2J clears viewport; \x1b[H homes cursor. Combined they
|
||
# reset both regions so we can replay history from scratch.
|
||
payload = '\x1b[3J\x1b[2J\x1b[H' + body
|
||
|
||
def _do_repaint() -> None:
|
||
try:
|
||
app.output.write_raw(payload)
|
||
app.output.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
await run_in_terminal(_do_repaint)
|
||
except Exception:
|
||
pass
|
||
self._invalidate_ptk()
|
||
return
|
||
|
||
with self._sbq_lk:
|
||
if not self._sbq:
|
||
return
|
||
batch = self._sbq
|
||
self._sbq = []
|
||
text = '\n'.join(batch) + '\n'
|
||
|
||
def _do_print() -> None:
|
||
try:
|
||
app.print_text(_ANSI(text))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
await run_in_terminal(_do_print)
|
||
except Exception:
|
||
pass
|
||
|
||
def _build_live_lines(self) -> list[str]:
|
||
"""Build the live region (input box + status + spinner + plan + open
|
||
stream tail) at PTK's current size and cache the result. Same content
|
||
feeds both PTK's height query and its text getter within a render
|
||
pass."""
|
||
w, h = self._ptk_size()
|
||
with self._lk:
|
||
# _live_lines uses _term() internally; SB.{_w,_h} feed into
|
||
# _input_box width math. Sync both before building so a resize
|
||
# shows up in this very render pass.
|
||
try:
|
||
self._w, self._h = w, h # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
if self._stream and self._streaming_block is not None:
|
||
safe_src = self._streaming_block.source
|
||
open_src = sanitize_ansi(self._stream)[len(safe_src):]
|
||
saved_base = self._tool_base
|
||
self._tool_base = self._pre_streaming_tool_base() + self._streaming_block.tool_n
|
||
open_body = self._render_assistant(open_src, max(1, w - 2)) if open_src.strip() else []
|
||
self._tool_base = saved_base
|
||
self._live_tail = _indent_rows(open_body[-8:], w)
|
||
|
||
# _live_lines reads _term() for its budget; redirect it to PTK's
|
||
# size so the input-box border math matches the actual viewport.
|
||
this_mod = sys.modules[__name__]
|
||
saved_term = this_mod._term
|
||
this_mod._term = lambda: (w, h)
|
||
try:
|
||
live = self._live_lines()
|
||
finally:
|
||
this_mod._term = saved_term
|
||
|
||
clipped: list[str] = []
|
||
for ln in live:
|
||
visible = _visible_text(ln)
|
||
if cell_len(visible) > w:
|
||
clipped.append(_clip_ansi_cells(ln, w))
|
||
else:
|
||
clipped.append(ln)
|
||
|
||
from prompt_toolkit.data_structures import Point as _Point
|
||
row, col = self._cur
|
||
cy = max(0, min(max(0, len(clipped) - 1), row))
|
||
cell_cx = max(0, min(max(0, w - 1), col - 1))
|
||
cursor_line = clipped[cy] if 0 <= cy < len(clipped) else ''
|
||
self._ptk_cursor = _Point(x=_ptk_x_from_cell(cursor_line, cell_cx), y=cy)
|
||
|
||
self._live_cache = clipped if clipped else ['']
|
||
self._live_cache_w = w
|
||
self._live_cache_h = h
|
||
return self._live_cache
|
||
|
||
def _ensure_live_cache(self) -> list[str]:
|
||
"""Return _live_cache, rebuilding if the cached size doesn't match the
|
||
current terminal. Safety net for code paths that touch the getters
|
||
outside the normal render cycle."""
|
||
w, h = self._ptk_size()
|
||
if (self._live_cache_w != w or self._live_cache_h != h or not self._live_cache):
|
||
return self._build_live_lines()
|
||
return self._live_cache
|
||
|
||
def _get_ptk_text(self):
|
||
from prompt_toolkit.formatted_text import ANSI as _ANSI
|
||
return _ANSI('\n'.join(self._ensure_live_cache()))
|
||
|
||
def _get_ptk_cursor(self):
|
||
return self._ptk_cursor
|
||
|
||
def _get_live_height(self):
|
||
from prompt_toolkit.layout.dimension import Dimension as _Dimension
|
||
cache = self._ensure_live_cache()
|
||
n = max(1, len(cache))
|
||
return _Dimension(min=1, max=n, preferred=n)
|
||
|
||
def _fold_turns(self, text: str) -> list[dict]:
|
||
"""Split assistant text into fold/text segments (v2 fold_turns port,
|
||
tuiapp_v2.py:240-267). In v2 a fold unit is a TURN (which may contain
|
||
one or more tool calls), not a single tool — but in this agent's
|
||
format every turn is typically `<summary>…</summary> + 🛠️ Tool: …`,
|
||
so per-turn ≈ per-tool in practice.
|
||
|
||
Returns:
|
||
[{'type': 'text', 'content': str}, ...] - prose / final-turn body
|
||
[{'type': 'fold', 'title': str, 'content': str}, ...] - foldable turn
|
||
"""
|
||
placeholders: list[str] = []
|
||
def stash(m):
|
||
placeholders.append(m.group(0))
|
||
return f'\x00PH{len(placeholders) - 1}\x00'
|
||
safe = _FENCE4_STASH_RE.sub(stash, text)
|
||
parts = _TURN_SPLIT_FOLD_RE.split(safe)
|
||
parts = [re.sub(r'\x00PH(\d+)\x00',
|
||
lambda m: placeholders[int(m.group(1))], p) for p in parts]
|
||
if len(parts) < 4: # 0 or 1 turn marker
|
||
return [{'type': 'text', 'content': text}]
|
||
segs: list[dict] = []
|
||
if parts[0].strip():
|
||
segs.append({'type': 'text', 'content': parts[0]})
|
||
turns = [(parts[i], parts[i + 1] if i + 1 < len(parts) else '')
|
||
for i in range(1, len(parts), 2)]
|
||
for idx, (marker, content) in enumerate(turns):
|
||
if idx == len(turns) - 1: # final turn = text
|
||
segs.append({'type': 'text', 'content': marker + content})
|
||
continue
|
||
cleaned = _TITLE_CLEAN_RE.sub('', content)
|
||
ms = _SUMMARY_PERTURN_RE.findall(cleaned)
|
||
if ms:
|
||
title = ms[0].strip().split('\n', 1)[0]
|
||
else:
|
||
first = cleaned.strip().split('\n', 1)[0] or marker.strip('*')
|
||
title = _TITLE_ARGS_TAIL_RE.sub('', first)
|
||
if len(title) > 72:
|
||
title = title[:72] + '...'
|
||
segs.append({'type': 'fold', 'title': title, 'content': content})
|
||
return segs
|
||
|
||
def _render_block(self, b: Block, w: int) -> list[str]:
|
||
"""Render one block at width w. Mutates self._last_tool_n for
|
||
assistant blocks; callers needing stable tids must set _tool_base
|
||
before calling (see _render_all_blocks / _flow)."""
|
||
if b.kind == 'user':
|
||
parts = b.source.split('\n')
|
||
raw = [_MARK + ' ' + parts[0]] + [CONT + p for p in parts[1:]]
|
||
lines = [_tile(' ' + x, _TILE_U, w) for x in raw]
|
||
lines.append('')
|
||
return lines
|
||
if b.kind == 'assistant':
|
||
if self._fold_all:
|
||
# v2-parity per-turn fold (tuiapp_v2.py:fold_turns). Each non-
|
||
# last turn collapses to one `▸ {summary}` header; the final
|
||
# turn (current reply) stays as text. Fold-seg bodies are
|
||
# rendered internally and discarded — keeps tid counting
|
||
# exact so /verbose tids don't shift when Ctrl+O toggles.
|
||
segs = self._fold_turns(b.source)
|
||
if any(s['type'] == 'fold' for s in segs):
|
||
out: list[str] = []
|
||
saved_base = self._tool_base
|
||
total_tool_n = 0
|
||
for seg in segs:
|
||
self._tool_base = saved_base + total_tool_n
|
||
body = self._render_assistant(seg['content'], max(1, w - 2))
|
||
total_tool_n += self._last_tool_n
|
||
if seg['type'] == 'fold':
|
||
head = _ACCENT + '▸ ' + _RST + _DIM + seg['title'] + _RST
|
||
out.extend(_indent_rows([head], w))
|
||
else:
|
||
out.extend(_indent_rows(body, w))
|
||
self._tool_base = saved_base
|
||
self._last_tool_n = total_tool_n
|
||
b.tool_n = total_tool_n
|
||
return out + ['']
|
||
body = self._render_assistant(b.source, max(1, w - 2))
|
||
b.tool_n = self._last_tool_n # cache for tool_base recompute
|
||
return _indent_rows(body, w) + ['']
|
||
if b.kind == 'banner':
|
||
return self._make_banner_lines(w)
|
||
# 'plain': pre-formatted ANSI (system messages, dividers, /help text…)
|
||
out: list[str] = []
|
||
for ln in b.source.split('\n'):
|
||
out.extend(_fit_rows(ln, w))
|
||
return out
|
||
|
||
def _render_all_blocks(self, w: int) -> list[str]:
|
||
"""Re-render every finalized block at width w with stable tool ids.
|
||
Tool ids are assigned per-block from a running tool_base, so a chip's
|
||
tid stays the same across resize repaints (otherwise /verbose would
|
||
de-sync from what the user sees)."""
|
||
out: list[str] = []
|
||
saved_base = self._tool_base
|
||
tool_base = 0
|
||
for blk in self._blocks:
|
||
if blk.kind == 'assistant':
|
||
self._tool_base = tool_base
|
||
out.extend(self._render_block(blk, w))
|
||
tool_base += blk.tool_n
|
||
else:
|
||
out.extend(self._render_block(blk, w))
|
||
self._tool_base = saved_base
|
||
return out
|
||
|
||
def _pre_streaming_tool_base(self) -> int:
|
||
"""Cumulative tool count from blocks before the streaming block."""
|
||
total = 0
|
||
for blk in self._blocks:
|
||
if blk is self._streaming_block:
|
||
break
|
||
total += blk.tool_n
|
||
return total
|
||
|
||
def _make_banner_lines(self, w: int) -> list[str]:
|
||
"""Regenerate the startup banner at the given width — called once at
|
||
run-start AND again on every resize via the 'banner' block kind."""
|
||
d, r = _DIM, _RST
|
||
cwd = os.getcwd().replace(os.path.expanduser('~'), '~')
|
||
name = self._bridge.llm_name if self._bridge else '?'
|
||
lbl_model = _t('banner.label.model')
|
||
lbl_dir = _t('banner.label.directory')
|
||
lbl_sess = _t('banner.label.session')
|
||
sess_val = self._session_name or _t('banner.session.single')
|
||
llm_hint = _t('banner.llm_hint')
|
||
rows = [(_ACCENT + '>_' + _RST + ' GenericAgent', '>_ GenericAgent'),
|
||
('', ''),
|
||
(f'{d}{lbl_model}{r} {name} {d}{llm_hint}{r}',
|
||
f'{lbl_model} {name} {llm_hint}'),
|
||
(f'{d}{lbl_dir}{r} {cwd}', f'{lbl_dir} {cwd}'),
|
||
(f'{d}{lbl_sess}{r} {sess_val}', f'{lbl_sess} {sess_val}')]
|
||
top = _border('╭', '╮', w)
|
||
bot = _border('╰', '╯', w)
|
||
lines = ['', top]
|
||
lines += [self._boxln(p, c, w) for c, p in rows]
|
||
lines += [bot, '',
|
||
f' {d}{tip(self._tip_idx)}{r}', '']
|
||
if self._bridge and not self._bridge._healthy:
|
||
lines.append(f' {d}⚠ {self._bridge._init_error}{r}'); lines.append('')
|
||
return lines
|
||
|
||
def _repaint_screen(self) -> None:
|
||
"""Schedule a full repaint of viewport + scrollback at current width.
|
||
|
||
scrollback can't reflow on its own — once a banner is in the terminal
|
||
history buffer at width W1, resizing to W2 leaves it stretched. We
|
||
flip _pending_repaint and let the async drain wipe the screen and
|
||
replay all blocks at the new width. The in-flight stream tail is also
|
||
re-rendered into _live_tail so a mid-stream resize snaps to the new
|
||
width on the next PTK redraw."""
|
||
w, _h = self._ptk_size()
|
||
if self._stream and self._streaming_block is not None:
|
||
safe_src = self._streaming_block.source
|
||
open_src = sanitize_ansi(self._stream)[len(safe_src):]
|
||
saved_base = self._tool_base
|
||
self._tool_base = self._pre_streaming_tool_base() + self._streaming_block.tool_n
|
||
open_body = self._render_assistant(open_src, max(1, w - 2)) if open_src.strip() else []
|
||
self._tool_base = saved_base
|
||
self._live_tail = _indent_rows(open_body[-8:], w)
|
||
self._pending_repaint = True
|
||
self._schedule_drain()
|
||
self._invalidate_ptk()
|
||
|
||
# ── input / paste ──
|
||
|
||
# ── undo / selection ──
|
||
|
||
def _snap(self) -> None:
|
||
"""Push current (buf, pos) onto undo stack before a mutation. Any new
|
||
edit invalidates the redo stack — standard editor behavior."""
|
||
snap = (self.buf, self.pos)
|
||
if not self._undo or self._undo[-1] != snap:
|
||
self._undo.append(snap)
|
||
if len(self._undo) > 200:
|
||
self._undo.pop(0)
|
||
self._redo.clear()
|
||
|
||
def _do_undo(self) -> None:
|
||
if not self._undo:
|
||
return
|
||
self._redo.append((self.buf, self.pos))
|
||
self.buf, self.pos = self._undo.pop()
|
||
self._sel = None
|
||
|
||
def _do_redo(self) -> None:
|
||
if not self._redo:
|
||
return
|
||
self._undo.append((self.buf, self.pos))
|
||
self.buf, self.pos = self._redo.pop()
|
||
self._sel = None
|
||
|
||
def _sel_range(self) -> tuple[int, int] | None:
|
||
if self._sel is None or self._sel == self.pos:
|
||
return None
|
||
return (min(self._sel, self.pos), max(self._sel, self.pos))
|
||
|
||
def _sel_start(self) -> None: # arm selection at current pos
|
||
if self._sel is None:
|
||
self._sel = self.pos
|
||
|
||
def _kill_sel(self) -> bool:
|
||
"""Delete the selected range (if any). Returns True if it deleted."""
|
||
r = self._sel_range()
|
||
if not r:
|
||
self._sel = None; return False
|
||
self._snap()
|
||
a, b = r
|
||
self.buf = self.buf[:a] + self.buf[b:]; self.pos = a; self._sel = None
|
||
return True
|
||
|
||
def _sel_v(self, d: int) -> None: # Shift+↑/↓ → extend by visual row
|
||
self._sel_start()
|
||
segs = self._segs(max(1, _term()[0] - 6))
|
||
i, off = self._seg_at(segs); ni = i + d
|
||
if not 0 <= ni < len(segs):
|
||
return # don't fall through to history
|
||
tcol = cell_len(segs[i][1][:off])
|
||
st, ch = segs[ni]
|
||
o = cw = 0
|
||
for c in ch:
|
||
if cw >= tcol: break
|
||
cw += cell_len(c); o += 1
|
||
self.pos = st + o
|
||
|
||
def _insert(self, s: str) -> None:
|
||
if not self._kill_sel(): # _kill_sel snaps when it deletes;
|
||
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
|
||
(backspace target). Mirrors v2's `_placeholder_adjacent`."""
|
||
if self._sel is not None:
|
||
return None
|
||
for pat in _PLACEHOLDER_RES:
|
||
for m in pat.finditer(self.buf):
|
||
edge = m.end() if side == 'left' else m.start()
|
||
if edge == self.pos:
|
||
return (m.start(), m.end(), int(m.group(1)))
|
||
return None
|
||
|
||
def _handle_paste(self, text: str = '') -> None:
|
||
"""Unified paste entry. Bracketed paste passes the payload `text`;
|
||
Ctrl+V passes nothing so we read the clipboard ourselves.
|
||
|
||
v2 order: a copied file / bitmap on the clipboard wins over text — so
|
||
an image paste still works even when the terminal delivers an (empty)
|
||
bracketed-paste event instead of a raw Ctrl+V key."""
|
||
grab = _grab_clipboard_file() # PIL: copied file or bitmap
|
||
if grab:
|
||
self._add_clip_file(*grab); return
|
||
if text:
|
||
self._paste_text(text); return
|
||
# No text payload → Ctrl+V, or an empty bracketed paste with an image
|
||
# on the clipboard. Try the legacy bitmap grabber, then clipboard text.
|
||
img = clip.paste_image() # legacy fallback (osascript/xclip)
|
||
if img:
|
||
self._pc += 1; self._imgs[self._pc] = img
|
||
self._insert(f'[Image #{self._pc}]'); return
|
||
txt = clip.paste()
|
||
if txt:
|
||
self._paste_text(txt)
|
||
|
||
def _handle_clip_paste(self) -> None:
|
||
self._handle_paste()
|
||
|
||
def _add_clip_file(self, path: str, is_image: bool) -> None:
|
||
"""Fold a pasted file/image into a placeholder (v2 parity). Images go
|
||
through `_imgs` (sent to the model); other files get a `[File #N]`
|
||
placeholder that `_expand` later swaps for the path."""
|
||
self._pc += 1
|
||
if is_image:
|
||
self._imgs[self._pc] = path
|
||
self._insert(f'[Image #{self._pc}]')
|
||
else:
|
||
self._fstore[self._pc] = path
|
||
self._insert(f'[File #{self._pc}]')
|
||
|
||
def _try_paste_path(self, raw: str) -> bool:
|
||
"""Git-bash / mintty fallback (v2 `_paste_file_from_text`): some
|
||
screenshot tools put the file *path* on the clipboard as plain text.
|
||
A single-line, on-disk path is treated as a file/image paste."""
|
||
path = raw.strip().strip('"').strip("'")
|
||
if not path or '\n' in path or '\r' in path or len(path) > 1024:
|
||
return False
|
||
if not os.path.isfile(path):
|
||
return False
|
||
self._add_clip_file(path, os.path.splitext(path)[1].lower() in _IMAGE_EXTS)
|
||
return True
|
||
|
||
def _paste_text(self, txt: str) -> None:
|
||
txt = txt.replace('\r\n', '\n').replace('\r', '\n')
|
||
if self._try_paste_path(txt): # a pasted file path → file paste
|
||
return
|
||
lines = len(txt.splitlines()) or 1
|
||
if lines > 2: # fold multi-line paste (v2: >2 lines)
|
||
self._pc += 1; self._pstore[self._pc] = txt
|
||
self._insert(f'[Pasted text #{self._pc} +{lines} lines]')
|
||
else:
|
||
self._insert(txt)
|
||
|
||
def _nav_hist(self, d: int) -> None:
|
||
# v2 parity: a multi-line draft can still reach history (no '\n' guard);
|
||
# the live draft is stashed on entry and restored when walking back
|
||
# past the newest entry.
|
||
if not self.hist:
|
||
return
|
||
if self._hi == -1:
|
||
if d == -1:
|
||
self._hist_stash = self.buf # preserve the live draft
|
||
self._hi = len(self.hist) - 1
|
||
else:
|
||
return
|
||
else:
|
||
self._hi += d
|
||
if self._hi < 0:
|
||
self._hi = 0
|
||
self._snap() # let Ctrl+Z undo a history recall
|
||
if self._hi >= len(self.hist): # walked past newest → restore draft
|
||
self._hi = -1
|
||
self.buf = self._hist_stash; self.pos = len(self.buf)
|
||
return
|
||
self.buf = self.hist[self._hi]; self.pos = len(self.buf)
|
||
|
||
def _expand(self, raw: str) -> str:
|
||
t = raw
|
||
if '@' in t:
|
||
def _r(m):
|
||
p = m.group(1)
|
||
if os.path.isabs(p) or p.startswith('~'):
|
||
return m.group(0)
|
||
fp = os.path.normpath(os.path.join(self._cwd, p))
|
||
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]'
|
||
t = _FILE_REF_RE.sub(_r, t)
|
||
for num, content in self._pstore.items():
|
||
t = _PASTE_PH_RE.sub(lambda m: content if int(m.group(1)) == num else m.group(0), t)
|
||
# `[File #N]` / `[Image #N]` placeholders expand to the real path inline
|
||
# (v2 parity) — that's how the model actually sees the file/image; the
|
||
# bare `[Image #N]` block alone tells it nothing.
|
||
for num, path in self._fstore.items():
|
||
t = _FILE_PH_RE.sub(lambda m: path if int(m.group(1)) == num else m.group(0), t)
|
||
for num, path in self._imgs.items():
|
||
t = _IMG_PH_RE.sub(lambda m: path if int(m.group(1)) == num else m.group(0), t)
|
||
return t
|
||
|
||
def _on_enter(self) -> None:
|
||
if self._asking is not None:
|
||
ae = self._asking
|
||
ans = self.buf.strip()
|
||
# Focus model: picker mode active → submit the highlighted
|
||
# candidate (or multi-pick `;`-join), ignoring whatever's in the
|
||
# buf draft. The user can switch focus to the input via ↑↓ /
|
||
# printable char first if they want their typed text to win.
|
||
if self._picker_mode:
|
||
picked = self._picker_submit()
|
||
if picked is not None:
|
||
ans = picked
|
||
elif ans.isdigit() and 1 <= int(ans) <= len(ae.candidates):
|
||
ans = ae.candidates[int(ans) - 1]
|
||
if not ans:
|
||
return
|
||
self.buf = ''; self.pos = 0; self._asking = None
|
||
self._undo.clear(); self._redo.clear(); self._sel = None
|
||
self._picker_reset()
|
||
self._commit_user(_t('msg.answer_prefix', text=ans))
|
||
self._submit(ans, [])
|
||
try: # parallel sub-agent asks:
|
||
self._asking = self._bridge.ask_user_queue.get_nowait()
|
||
self._picker_init(self._asking)
|
||
except queue.Empty: # if more were queued behind
|
||
self._picker_reset() # this one, surface the next
|
||
return # immediately so the user
|
||
# can answer them in series
|
||
# Interactive command palette: Enter completes the highlighted match
|
||
# into the input box and stops — it does NOT dispatch. The user then
|
||
# reviews / edits the command and presses Enter again to run it (the
|
||
# palette is hidden once buf is an exact command name).
|
||
if self._palette_visible():
|
||
self._tab()
|
||
return
|
||
raw = self.buf.strip()
|
||
if not raw:
|
||
return
|
||
self.buf = ''; self.pos = 0; self._hi = -1; self._hist_stash = ''
|
||
self._undo.clear(); self._redo.clear(); self._sel = None
|
||
if len(self.hist) >= 500:
|
||
self.hist = self.hist[-250:]
|
||
if not self.hist or self.hist[-1] != raw: # v2: skip consecutive dupes
|
||
self.hist.append(raw)
|
||
if raw.startswith('!'):
|
||
# Shell-mode magic: run the rest as a host shell command, echo
|
||
# the command + output into scrollback, and append the pair to
|
||
# the agent's LLM history so the next real turn can reference
|
||
# it (per screenshot 034257 — agent recalls `echo hi`).
|
||
self._run_shell(raw[1:].strip())
|
||
return
|
||
if raw.startswith('/'):
|
||
# Expand pasted/file/image placeholders BEFORE dispatch so a command
|
||
# carries the real pasted text, not a `[Pasted text #N]` marker — e.g.
|
||
# `/morphling <pasted multi-line target>`. Without this the command
|
||
# path returned here before the expansion below ever ran, so the agent
|
||
# got the literal placeholder. `self.hist` already kept the raw input.
|
||
expanded = self._expand(raw)
|
||
# /btw owns its own live-region panel — keep the command itself
|
||
# out of the main scrollback.
|
||
cmd0 = (expanded[1:].split(None, 1)[0] or '').lower()
|
||
if cmd0 != 'btw':
|
||
self._commit_user(expanded)
|
||
self._cmd(expanded)
|
||
self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
|
||
return
|
||
# Expand placeholders FIRST so the agent receives the resolved text,
|
||
# not the [Image #N] / [Pasted #N] markers. This matches the idle
|
||
# submit path below — keeping the form identical means a queued
|
||
# message and an immediate one feed the LLM the same bytes.
|
||
imgs = [self._imgs[i] for i in
|
||
(int(m.group(1)) for m in _IMG_PH_RE.finditer(raw)) if i in self._imgs]
|
||
expanded = self._expand(raw)
|
||
# @ mentions: agent 收绝对路径(file_read 相对自身 cwd,否则找不到),
|
||
# scrollback 显示相对(简洁)。仅改路径根、不读内容。
|
||
agent_text = at_complete.absolutize_mentions(expanded, self._at_root()) if "@" in expanded else expanded
|
||
if self._running:
|
||
wrapped = _t('pending.inject_wrap', text=agent_text)
|
||
if self._bridge and self._bridge.inject_intervene(wrapped, track=True):
|
||
self._pending.append(agent_text)
|
||
self._commit_user(_t('pending.queued_marker', text=expanded))
|
||
self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
|
||
self._render_live()
|
||
return
|
||
# Agent went idle in the race — fall through to put_task.
|
||
self._commit_user(expanded) # scrollback 显示相对
|
||
self._submit(agent_text, imgs) # agent 收绝对
|
||
self._pstore.clear(); self._fstore.clear(); self._imgs.clear() # drop placeholders
|
||
|
||
def _cost_section(self, tname: str, t, be) -> list[str]:
|
||
"""A v2-style /cost block for one token tracker — total / cache /
|
||
context-window / requests. Labels stay English (jargon)."""
|
||
from frontends import cost_tracker
|
||
k = _human
|
||
model = self._bridge.llm_name if self._bridge else '?'
|
||
label = self._session_name or tname # show the session name when set
|
||
rows = [f'{label} · model: {model} · elapsed: {_elapsed(t.elapsed_seconds())}']
|
||
rows.append(f' Token usage: {k(t.total_tokens()):>8} total '
|
||
f'({k(t.total_input_side())} input + {k(t.output)} output)')
|
||
if t.cache_read or t.cache_create:
|
||
rows.append(f' Cache: {k(t.cache_read):>8} read · '
|
||
f'{k(t.cache_create)} created · {t.cache_hit_rate():.1f}% hit')
|
||
cap = cost_tracker.context_window_chars(be) if be else 0
|
||
used = cost_tracker.current_input_chars(be) if be else 0
|
||
if cap > 0:
|
||
pct = max(0.0, (cap - used) / cap * 100.0)
|
||
rows.append(f' Context window: {pct:>7.0f}% left '
|
||
f'({k(used)} chars used / {k(cap)} cap)')
|
||
rows.append(f' Requests: {t.requests:>8}')
|
||
return rows
|
||
|
||
def _set_session_name(self, ag, name: str) -> None:
|
||
"""Persist the session name (keyed by the agent's log file) so a later
|
||
/continue surfaces it — mirrors v2's session_names integration."""
|
||
try:
|
||
from frontends import session_names
|
||
lp = getattr(ag, 'log_path', '') or ''
|
||
if lp:
|
||
session_names.set_name(lp, name)
|
||
except Exception:
|
||
pass
|
||
|
||
def _reset_session(self, ag) -> None:
|
||
"""Wipe the conversation: drop LLM history, clear the screen and every
|
||
rendered block. Shared by /clear and /new."""
|
||
from frontends import continue_cmd
|
||
continue_cmd.begin_fresh_session(ag) # 切走:旧日志留作空闲会话 + 铸新 logid(不存快照)
|
||
_w('\x1b[2J\x1b[H'); self._painted = []; self._live_rows = 0
|
||
self._blocks = []; self._streaming_block = None; self._sent = 0
|
||
self._tool_base = 0; self._tools = {}
|
||
|
||
# ── workspace(与 v2 共用 workspace_cmd;单会话 → 进程级一个绑定)─────────
|
||
def _bind_workspace(self, info, persist: bool = True) -> None:
|
||
"""绑定 / 解绑 workspace。info=prepare() 的结果 dict → 绑定;None → 解绑。
|
||
同步 agent 的 project_mode 属性(插件据此注入项目上下文),刷新 @ 索引根。
|
||
|
||
persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的
|
||
reset(_bind_workspace(None)) 用它——原地续后 agent.log_path==被续文件,
|
||
若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表只应由
|
||
显式 /workspace、/workspace off 与成功的续接恢复来写。"""
|
||
ag = self._bridge.agent if self._bridge else None
|
||
if info:
|
||
self._ws_name = info.get('name') or ''
|
||
self._ws_path = info.get('target') or info.get('path') or ''
|
||
self._ws_link = info.get('link') or ''
|
||
pm_name, pm_path = (self._ws_name or None), self._ws_path
|
||
else:
|
||
self._ws_name = self._ws_path = self._ws_link = ''
|
||
pm_name, pm_path = None, ''
|
||
self._at_cache = None # 索引根可能变了,@ 候选缓存失效
|
||
if ag is not None:
|
||
try:
|
||
ag._ga_project_mode_name = pm_name
|
||
ag._ga_project_mode_workspace_path = pm_path
|
||
except Exception:
|
||
pass
|
||
# 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
|
||
if persist:
|
||
workspace_cmd.session_ws_set(getattr(ag, "log_path", "") or "", pm_path or "")
|
||
at_complete.get_index(self._at_root()).warm() # 预热新根(或 CWD)
|
||
|
||
def _do_workspace_activate(self, path: str) -> str:
|
||
r = workspace_cmd.prepare(path)
|
||
if not r.get('ok'):
|
||
return _t('ws.fail', default='❌ workspace 设定失败: {e}').format(e=r.get('error'))
|
||
self._bind_workspace(r)
|
||
# 显示名去 hash:真实目录 basename,退回剥 name 尾 hash。
|
||
disp = (os.path.basename((r.get('target') or '').rstrip('/\\'))
|
||
or re.sub(r'-[0-9a-f]{8}$', '', r.get('name') or ''))
|
||
out = _t('ws.entered', default='✅ 已进入 workspace「{n}」').format(n=disp)
|
||
if r.get('warning'):
|
||
out += ' ⚠ ' + r['warning']
|
||
return out
|
||
|
||
def _cmd_workspace(self, arg: str) -> None:
|
||
arg = (arg or '').strip()
|
||
if arg.lower() == 'off':
|
||
if self._ws_name:
|
||
self._bind_workspace(None)
|
||
self.commit([_DIM + _t('ws.exited',
|
||
default='已退出 workspace(项目模式关闭;junction 与文件保留)') + _RST])
|
||
else:
|
||
self.commit([_DIM + _t('ws.inactive', default='当前未处于 workspace 模式') + _RST])
|
||
return
|
||
if arg: # 直接路径:设定/进入
|
||
self.commit([self._do_workspace_activate(arg)])
|
||
return
|
||
# 无参 → 菜单选已登记 workspace(去 hash / 名称末尾省略 / 路径中间省略)。
|
||
items = workspace_cmd.registry_list()
|
||
if not items:
|
||
self.commit([_DIM + _t('ws.none',
|
||
default='暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入') + _RST])
|
||
return
|
||
options: list[str] = []
|
||
paths: list[str] = []
|
||
fkeys: list[str] = []
|
||
for it in items:
|
||
disp = (os.path.basename((it['path'] or '').rstrip('/\\'))
|
||
or re.sub(r'-[0-9a-f]{8}$', '', it['name']))
|
||
age = _rel(it['last_used']) if it['last_used'] else '—'
|
||
mem = (f"{it['mem_lines']}行" if it['mem_lines'] else '空')
|
||
flag = ' ⚠' if it['dangling'] else ''
|
||
options.append(f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}")
|
||
paths.append(it['path'])
|
||
# 搜索键含完整路径(显示中间省略,但搜索看完整 — 与 v2 _filter_choices 一致)。
|
||
fkeys.append(f"{disp} {it['path']}")
|
||
|
||
def _pick(idx: int) -> None:
|
||
self.commit([self._do_workspace_activate(paths[idx])])
|
||
|
||
def _free(q: str) -> None: # 输入框内回车一个绝对路径 → 新建/进入
|
||
self.commit([self._do_workspace_activate(q)])
|
||
|
||
self._show_menu(_t('ws.pick.title',
|
||
default='选择 workspace(输入过滤 / 绝对路径回车新建 · ↑↓ 选 · Esc 取消)'),
|
||
options, _pick, filterable=True, free_input=True,
|
||
on_free=_free, filter_keys=fkeys)
|
||
|
||
def _cmd(self, raw: str) -> None:
|
||
assert self._bridge is not None
|
||
parts = raw[1:].split(None, 1)
|
||
name = parts[0].lower() if parts else ''
|
||
arg = parts[1].strip() if len(parts) > 1 else ''
|
||
ag = self._bridge.agent
|
||
# /btw is deliberately NOT idle-only — a side question must be fireable
|
||
# while the main agent runs (that's its whole purpose).
|
||
idle_only = {'clear', 'export', 'review', 'rewind', 'continue'}
|
||
if name in idle_only and self._running:
|
||
self.commit([_t('err.running_blocked')]); return
|
||
if name in ('q', 'quit', 'exit'):
|
||
self._quit = True
|
||
elif name in ('stop', 'abort'):
|
||
if self._running:
|
||
self._bridge.abort()
|
||
self.commit([_t('msg.abort_done') if self._running else _t('msg.idle_no_task')])
|
||
elif name in ('status', 'sessions'):
|
||
# /status: full snapshot of the current session.
|
||
# /sessions: same output (v3 is single-session; multi-session listing
|
||
# is the same data as a 1-row table).
|
||
be = getattr(getattr(ag, 'llmclient', None), 'backend', None)
|
||
rounds = 0
|
||
try:
|
||
if be is not None and getattr(be, 'history', None):
|
||
rounds = sum(1 for m in be.history if m.get('role') == 'user')
|
||
except Exception:
|
||
pass
|
||
llm = self._bridge.llm_name if self._bridge else '?'
|
||
if self._running:
|
||
el = time.time() - self._t0_anchor
|
||
state = _t('status.state.running', verb=_gerund(el), elapsed=_elapsed(el))
|
||
elif self._asking is not None:
|
||
state = _t('status.state.waiting')
|
||
else:
|
||
state = _t('status.state.idle')
|
||
try:
|
||
from frontends import cost_tracker as _ct
|
||
cap = _ct.context_window_chars(be) if be is not None else 0
|
||
used = _ct.context_chars_used(be) if be is not None else 0
|
||
ctx_use = _t('status.ctx.fmt', used=used, cap=cap * 3) if cap else _t('status.ctx.unknown')
|
||
except Exception:
|
||
ctx_use = _t('status.ctx.unknown')
|
||
cwd = os.getcwd().replace(os.path.expanduser('~'), '~')
|
||
rows = ['',
|
||
_DIM + _t('status.title') + _RST,
|
||
'',
|
||
f' {_DIM}{_t("status.label.model"):<9}{_RST} {llm}',
|
||
f' {_DIM}{_t("status.label.state"):<9}{_RST} {state}',
|
||
f' {_DIM}{_t("status.label.rounds"):<9}{_RST} {rounds}',
|
||
f' {_DIM}{_t("status.label.context"):<9}{_RST} {ctx_use}',
|
||
f' {_DIM}{_t("status.label.cwd"):<9}{_RST} {cwd}',
|
||
'']
|
||
if self._bridge and not self._bridge._healthy:
|
||
rows.append(f' {_DIM}⚠ {self._bridge._init_error}{_RST}')
|
||
rows.append('')
|
||
self.commit(rows)
|
||
elif name == 'new':
|
||
# New session = wipe the current conversation and start fresh.
|
||
# Keeping prior sessions around (multi-session) is a separate
|
||
# workstream — see temp/plan_tui_v3_refactor/TODO.md.
|
||
self._reset_session(ag)
|
||
self._session_name = arg or ''
|
||
if arg:
|
||
self._set_session_name(ag, arg)
|
||
_set_term_title(self._term_title())
|
||
self.commit(Block('banner', '')) # fresh banner shows the new name
|
||
self.commit([_DIM + (_t('msg.new_session_named', name=arg) if arg
|
||
else _t('msg.new_session')) + _RST])
|
||
elif name == 'rename':
|
||
if not arg:
|
||
self.commit([_t('err.rename_usage')]); return
|
||
self._session_name = arg
|
||
self._set_session_name(ag, arg)
|
||
_set_term_title(self._term_title())
|
||
self.commit([_DIM + _t('msg.renamed', name=arg) + _RST])
|
||
# /switch /close /branch — 多会话后端尚未接入,命令未实现,先注释掉。
|
||
# elif name in ('switch', 'close', 'branch'):
|
||
# self.commit([_t('err.multi_session', name=name)])
|
||
elif name == 'continue':
|
||
from frontends import continue_cmd
|
||
sess = continue_cmd.list_sessions(exclude_log=os.path.basename(getattr(ag, "log_path", "") or ""))
|
||
if not sess:
|
||
self.commit([_DIM + _t('msg.no_history') + _RST]); return
|
||
|
||
def _rc_finish(path: str, msg: str) -> None:
|
||
# restore 后:重放对话到 scrollback + 恢复 workspace。读 ag.log_path
|
||
# (原地=源文件本身;拷贝=内容相同的新副本),内容一致。
|
||
self.commit([_DIM + '┄┄ ' + _t('msg.continue_loading', name=os.path.basename(path)) + ' ┄┄' + _RST])
|
||
for mm in continue_cmd.extract_ui_messages(getattr(ag, 'log_path', '') or path):
|
||
c = (mm.get('content') or '').strip()
|
||
if not c:
|
||
continue
|
||
if mm.get('role') == 'user':
|
||
self._commit_user(c)
|
||
else:
|
||
self._commit_assistant(c)
|
||
self.commit([_DIM + '┄┄ ' + _t('msg.continue_ready', msg=msg) + ' ┄┄' + _RST])
|
||
# 自动恢复 workspace:续接的会话若在某个已登记 workspace 里工作过,
|
||
# 重新绑定(必要时重建 junction),不触碰 project_mode 的进程锚。
|
||
# persist=False:reset 绑定绝不写 session→ws 映射表——原地续把
|
||
# agent.log_path 指回 path,若持久化会把本会话映射抹成 "",在读回
|
||
# 之前就毁掉自动恢复。
|
||
self._bind_workspace(None, persist=False)
|
||
try:
|
||
rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
|
||
if rec is not None:
|
||
ws_path = rec or None # "" → 该会话已 off,明确不恢复
|
||
else:
|
||
info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
|
||
ws_path = info['path'] if info else None
|
||
if ws_path:
|
||
r = workspace_cmd.prepare(ws_path)
|
||
if r.get('ok'):
|
||
self._bind_workspace(r)
|
||
self.commit([_DIM + '⌂ ' + _t('ws.restored',
|
||
default='已恢复工作目录: {t}').format(t=r['target']) + _RST])
|
||
except Exception:
|
||
pass
|
||
|
||
def _do_restore(path: str) -> None:
|
||
# 默认原地续。快照只能拷贝续;若被活进程占用 → 弹窗问是否拷贝一份。
|
||
if continue_cmd.is_snapshot(path):
|
||
msg, _ = continue_cmd.continue_copy(ag, path)
|
||
_rc_finish(path, msg); return
|
||
occ = continue_cmd.session_occupant(path)
|
||
if occ is not None:
|
||
def _pick(i):
|
||
if i == 0:
|
||
msg, _ = continue_cmd.continue_copy(ag, path)
|
||
_rc_finish(path, msg)
|
||
self._show_menu(
|
||
_t('continue.occupied.title', p=occ.get('pid', '?')),
|
||
[_t('continue.occupied.copy'),
|
||
_t('continue.occupied.cancel')],
|
||
_pick)
|
||
return
|
||
msg, _ = continue_cmd.continue_inplace(ag, path)
|
||
_rc_finish(path, msg)
|
||
|
||
if arg:
|
||
# Direct numeric argument still supported for power users / scripts.
|
||
if not arg.isdigit():
|
||
self.commit([_t('err.continue_usage')]); return
|
||
i = int(arg) - 1
|
||
if not (0 <= i < len(sess)):
|
||
self.commit([_t('err.index_oob', max=len(sess))]); return
|
||
_do_restore(sess[i][0]); return
|
||
|
||
# No arg → arrow-key menu. Show every session; the menu picker's
|
||
# scrolling viewport (↑/↓ slides the window) reaches older entries
|
||
# without needing /continue N as a fallback.
|
||
w = _term()[0]
|
||
try:
|
||
from frontends import session_names as _sn
|
||
except Exception:
|
||
_sn = None
|
||
options: list[str] = []
|
||
unit = _t('continue.unit.round')
|
||
for _p, mt, prev, rnd in sess:
|
||
nm = ''
|
||
if _sn is not None:
|
||
try:
|
||
nm = _sn.name_for(_p)
|
||
except Exception:
|
||
nm = ''
|
||
# Uniform 3-part row `{age} · {n}轮 · {text}` — a named session
|
||
# shows its name in the text slot, an unnamed one its preview,
|
||
# so the columns line up either way.
|
||
text = nm or (prev or '').replace('\n', ' ').strip()[:max(20, w - 30)]
|
||
options.append(f'{_rel(mt)} · {rnd}{unit} · {text}')
|
||
|
||
def _pick_session(idx: int) -> None:
|
||
_do_restore(sess[idx][0])
|
||
|
||
self._show_menu(_t('continue.title'), options, _pick_session)
|
||
elif name == 'workspace':
|
||
self._cmd_workspace(arg)
|
||
elif name == 'clear':
|
||
self._reset_session(ag)
|
||
self.commit([_DIM + _t('msg.cleared') + _RST])
|
||
elif name == 'rewind':
|
||
be = getattr(getattr(ag, 'llmclient', None), 'backend', None)
|
||
turns = self._rewindable_turns(be)
|
||
if not turns:
|
||
self.commit([_t('msg.no_rewindable')]); return
|
||
if arg:
|
||
# Direct numeric form: /rewind N.
|
||
if not arg.isdigit():
|
||
self.commit([_t('err.rewind_usage')]); return
|
||
n = int(arg)
|
||
if not (1 <= n <= len(turns)):
|
||
self.commit([_t('err.rewind_range', max=len(turns))]); return
|
||
msg, prefill = self._do_rewind(be, n)
|
||
self.commit([msg])
|
||
if prefill:
|
||
self.buf = prefill; self.pos = len(prefill)
|
||
return
|
||
# No arg → menu picker, one row per rewindable turn (recent first,
|
||
# capped at 20 like v2) with a content preview.
|
||
LIMIT = 20
|
||
recent = list(reversed(turns))[:LIMIT]
|
||
options: list[str] = []
|
||
for offset, (_idx, prev) in enumerate(recent, 1):
|
||
preview = (prev or '').replace('\n', ' ').strip()[:60]
|
||
options.append(_t('rewind.option', n=offset, preview=preview))
|
||
|
||
def _pick_rewind(idx: int) -> None:
|
||
msg, prefill = self._do_rewind(be, idx + 1)
|
||
self.commit([msg])
|
||
if prefill:
|
||
self.buf = prefill; self.pos = len(prefill)
|
||
|
||
self._show_menu(_t('rewind.title'), options, _pick_rewind)
|
||
elif name == 'btw':
|
||
if not arg:
|
||
self.commit([_t('err.btw_usage')]); return
|
||
# Background side question — does NOT touch self._running, so it
|
||
# never blocks idle-only commands nor makes /stop think the main
|
||
# agent is busy. Concurrent /btw is allowed. The entry shows in
|
||
# the panel immediately (answer slot None → `querying…`).
|
||
entry: list = [arg, None]
|
||
self._btws.append(entry)
|
||
threading.Thread(target=self._btw, args=(entry,), daemon=True).start()
|
||
self._render_live()
|
||
elif name == 'review':
|
||
from frontends import review_cmd
|
||
dq = queue.Queue()
|
||
prompt = review_cmd.handle(ag, arg, dq)
|
||
if prompt:
|
||
self._submit(prompt, [])
|
||
else:
|
||
try:
|
||
text = dq.get_nowait().get('done', '')
|
||
self.commit(Block('assistant', text)) # markdown re-renders on resize
|
||
except queue.Empty:
|
||
self.commit([_t('msg.review_empty')])
|
||
elif name == 'resume':
|
||
# GA's _handle_slash_cmd (agentmain.py:124) replaces `/resume`
|
||
# with a session-recovery prompt before the LLM sees it. We
|
||
# just forward the literal string — the agent expands it.
|
||
self._submit('/resume', [])
|
||
elif name in ('update', 'autorun', 'morphling', 'goal', 'hive', 'conductor'):
|
||
# slash_cmds bundle — build a long prompt and feed it back through
|
||
# _submit so the agent sees an ordinary user turn. Keeps the
|
||
# frontend ignorant of SOP details; see frontends/slash_cmds.py.
|
||
from frontends import slash_cmds
|
||
prompt = slash_cmds.prompt_for('/' + name, arg or '')
|
||
if prompt:
|
||
self._submit(prompt, [])
|
||
else:
|
||
self.commit([f'❌ unknown command /{name}'])
|
||
elif name == 'scheduler':
|
||
from frontends import slash_cmds
|
||
parts = (arg or '').split(None, 1)
|
||
head = parts[0].lower() if parts else ''
|
||
if head in ('start', 'run'):
|
||
names = (parts[1] if len(parts) > 1 else '').replace(',', ' ').split()
|
||
if not names:
|
||
self.commit([_t('scheduler.usage_start')])
|
||
else:
|
||
lines = []
|
||
for n in names:
|
||
ok, msg = slash_cmds.start_service(n)
|
||
lines.append(('✅ ' if ok else '❌ ') + msg)
|
||
self.commit(lines)
|
||
else:
|
||
# Mirror hub.pyw discover_services(): reflect tasks + frontend
|
||
# apps, so the picker shows the same set as the GUI launcher.
|
||
services = slash_cmds.list_launchable_services()
|
||
if not services:
|
||
self.commit([_t('scheduler.empty')]); return
|
||
ordered = ([s for s in services if s['kind'] == 'reflect'] +
|
||
[s for s in services if s['kind'] == 'frontend'])
|
||
# Snapshot currently-running services so the picker reflects
|
||
# real OS state: pre-check running rows + show "· running"
|
||
# suffix. The diff between checked-after vs running-now is the
|
||
# source of truth for start/stop in the confirm step.
|
||
try:
|
||
running = slash_cmds.running_services() # {name: pid}
|
||
except Exception:
|
||
running = {}
|
||
running_idxs = {i for i, s in enumerate(ordered)
|
||
if s['name'] in running}
|
||
options = []
|
||
for s in ordered:
|
||
is_running = s['name'] in running
|
||
doc = f" — {s['doc']}" if s['doc'] else ''
|
||
tag = _t('scheduler.running_tag') if is_running else ''
|
||
label = f"{s['name']}{tag}{doc}"
|
||
if is_running:
|
||
# Functional green so already-running rows pop out
|
||
# of the picker grid even without the checkbox tick;
|
||
# cell_len ignores ANSI so column math stays sane.
|
||
label = _OK + label + _RST
|
||
options.append(label)
|
||
|
||
# Two-step ask_user-style flow:
|
||
# picker (pre-checked = running) → diff vs running
|
||
# → confirm card (Submit / Edit selection) → apply
|
||
# Esc on the confirm card re-opens the picker with the in-
|
||
# progress ticks preserved (ask_user-style rollback).
|
||
def _open_picker(preset: set[int] | None = None) -> None:
|
||
initial = running_idxs if preset is None else preset
|
||
|
||
def _pick_services(idxs: list[int]) -> None:
|
||
chosen_idxs = list(idxs)
|
||
chosen_set = set(chosen_idxs)
|
||
starts = [ordered[i]['name']
|
||
for i in chosen_idxs
|
||
if i not in running_idxs]
|
||
stops = [ordered[i]['name']
|
||
for i in sorted(running_idxs)
|
||
if i not in chosen_set]
|
||
if not starts and not stops:
|
||
self.commit([_t('scheduler.no_change')]); return
|
||
|
||
bits = []
|
||
if starts:
|
||
bits.append(_t('scheduler.diff.start',
|
||
n=len(starts), names='、'.join(starts)))
|
||
if stops:
|
||
bits.append(_t('scheduler.diff.stop',
|
||
n=len(stops), names='、'.join(stops)))
|
||
submit_label = ' / '.join(bits)
|
||
|
||
def _confirm(ci: int) -> None:
|
||
if ci == 0:
|
||
lines = []
|
||
# Stop first so a name that appears in both
|
||
# lists (never produced by this diff, but
|
||
# cheap insurance) can't race the cmdline scan.
|
||
for nm in stops:
|
||
ok, msg = slash_cmds.stop_service(nm)
|
||
lines.append(('■ ' if ok else '❌ ') + msg)
|
||
for nm in starts:
|
||
ok, msg = slash_cmds.start_service(nm)
|
||
lines.append(('▶ ' if ok else '❌ ') + msg)
|
||
self.commit(lines)
|
||
elif ci == 1:
|
||
self.commit([_t('scheduler.back_to_pick')])
|
||
_open_picker(preset=chosen_set)
|
||
else:
|
||
self.commit([_t('scheduler.cancelled')])
|
||
|
||
def _confirm_cancel() -> None:
|
||
self.commit([_t('scheduler.back_to_pick')])
|
||
_open_picker(preset=chosen_set)
|
||
|
||
self._show_menu(
|
||
_t('scheduler.confirm.title'),
|
||
[submit_label, _t('scheduler.confirm.edit')],
|
||
_confirm,
|
||
hint=_t('scheduler.confirm.hint'),
|
||
on_cancel=_confirm_cancel,
|
||
multi_select=False,
|
||
)
|
||
|
||
hint_text = _t('scheduler.pick.hint')
|
||
try:
|
||
cron_n = len(slash_cmds.list_scheduler_tasks())
|
||
except Exception:
|
||
cron_n = 0
|
||
if cron_n:
|
||
sch_running = 'reflect/scheduler.py' in running
|
||
key = 'scheduler.cron.active' if sch_running else 'scheduler.cron.inactive'
|
||
hint_text += '\n' + _t(key, n=cron_n)
|
||
# Pre-check the running set (first open) or the in-progress
|
||
# selection (re-open via "Edit selection" / Esc) — passed
|
||
# to _show_menu so it lands atomically with the rest of
|
||
# the menu state, not as a post-open override.
|
||
self._show_menu(
|
||
_t('scheduler.pick.title'),
|
||
options,
|
||
_pick_services,
|
||
hint=hint_text,
|
||
multi_select=True,
|
||
pre_checked=initial if initial else None,
|
||
)
|
||
|
||
_open_picker()
|
||
elif name == 'llm':
|
||
if arg:
|
||
self._bridge.switch_llm(int(arg) if arg.isdigit() else -1)
|
||
self.commit([_t('msg.llm_switched', name=self._bridge.llm_name)])
|
||
else:
|
||
items = self._bridge.list_llms()
|
||
if not items:
|
||
self.commit([_t('msg.no_llms')]); return
|
||
options: list[str] = []
|
||
current = 0
|
||
for n, it in enumerate(items):
|
||
cur = len(it) > 2 and it[2]
|
||
if cur:
|
||
current = n
|
||
options.append(('● ' if cur else ' ') + f'{it[0]}. {it[1]}')
|
||
|
||
def _pick_llm(idx: int) -> None:
|
||
target = items[idx]
|
||
self._bridge.switch_llm(int(target[0]) if str(target[0]).isdigit() else -1)
|
||
self.commit([_t('msg.llm_switched', name=self._bridge.llm_name)])
|
||
|
||
self._show_menu(_t('llm.title'), options, _pick_llm)
|
||
self._menu_sel = current
|
||
elif name == 'cost':
|
||
from frontends import cost_tracker
|
||
trackers = cost_tracker.all_trackers()
|
||
if not trackers:
|
||
self.commit([_t('msg.no_tracker')]); return
|
||
# A mixin model spreads LLM calls across worker threads → many
|
||
# trackers for one logical session. Aggregate into one block.
|
||
agg = cost_tracker.TokenStats()
|
||
agg.started_at = min((t.started_at for t in trackers.values()),
|
||
default=agg.started_at)
|
||
for t in trackers.values():
|
||
agg.requests += t.requests
|
||
agg.input += t.input
|
||
agg.output += t.output
|
||
agg.cache_create += t.cache_create
|
||
agg.cache_read += t.cache_read
|
||
be = getattr(getattr(ag, 'llmclient', None), 'backend', None)
|
||
self.commit(['✦ Token usage']
|
||
+ self._cost_section(self._session_name or 'session', agg, be))
|
||
elif name == 'export':
|
||
from frontends import export_cmd
|
||
parts = arg.split(None, 1) if arg else []
|
||
head = parts[0].lower() if parts else ''
|
||
rest = parts[1] if len(parts) > 1 else ''
|
||
|
||
def _do_clip() -> None:
|
||
txt = export_cmd.last_assistant_text(ag)
|
||
if not txt:
|
||
self.commit([_t('msg.no_export')]); return
|
||
wrapped = export_cmd.wrap_for_clipboard(txt)
|
||
if copy(wrapped):
|
||
self.commit([_t('msg.export_clipped', n=len(wrapped))])
|
||
else:
|
||
self.commit([_t('msg.export_clip_failed')])
|
||
|
||
def _do_all() -> None:
|
||
lp = getattr(ag, 'log_path', '') or ''
|
||
if lp and os.path.isfile(lp):
|
||
self.commit([_t('msg.export_all', path=lp)])
|
||
else:
|
||
self.commit([_t('msg.export_all_missing')])
|
||
|
||
def _do_file(fname: str = '') -> None:
|
||
txt = export_cmd.last_assistant_text(ag)
|
||
if not txt:
|
||
self.commit([_t('msg.no_export')]); return
|
||
if not fname:
|
||
from datetime import datetime as _dt
|
||
fname = 'export-' + _dt.now().strftime('%Y%m%d-%H%M%S') + '.md'
|
||
p = export_cmd.export_to_temp(txt, fname)
|
||
self.commit([_t('msg.export_done', path=p)])
|
||
|
||
def _prefill_file() -> None:
|
||
# v2 parity: picker → file fills the input with a default name
|
||
# so the user can edit before committing.
|
||
from datetime import datetime as _dt
|
||
default = 'export-' + _dt.now().strftime('%Y%m%d-%H%M%S') + '.md'
|
||
text = '/export ' + default
|
||
self.buf = text
|
||
self.pos = len(text)
|
||
self._sel = None
|
||
self._render_live()
|
||
|
||
if not head:
|
||
opts = [_t('export.opt.clip'),
|
||
_t('export.opt.file'),
|
||
_t('export.opt.all')]
|
||
actions = (_do_clip, _prefill_file, _do_all)
|
||
self._show_menu(_t('export.title'), opts,
|
||
lambda i: actions[i]())
|
||
elif head in ('clip', 'copy'):
|
||
_do_clip()
|
||
elif head == 'all':
|
||
_do_all()
|
||
elif head == 'file':
|
||
_do_file(rest)
|
||
else:
|
||
# legacy: /export <name> → file with that name
|
||
_do_file(arg)
|
||
elif name in ('verbose', 'tools', 'trace'):
|
||
self._verbose_view()
|
||
elif name == 'language':
|
||
self._cmd_language(arg)
|
||
elif name == 'emoji':
|
||
self._cmd_emoji(arg)
|
||
elif name == 'help':
|
||
self.commit([_t('help.title'),
|
||
_t('help.help'),
|
||
_t('help.status'),
|
||
_t('help.sessions'),
|
||
_t('help.llm'),
|
||
_t('help.btw'),
|
||
_t('help.review'),
|
||
_t('help.rewind'),
|
||
_t('help.resume'),
|
||
_t('help.continue'),
|
||
_t('help.new'),
|
||
_t('help.rename'),
|
||
_t('help.clear'),
|
||
_t('help.cost'),
|
||
_t('help.verbose'),
|
||
_t('help.export'),
|
||
_t('help.stop'),
|
||
_t('help.language'),
|
||
_t('help.emoji'),
|
||
_t('help.update'),
|
||
_t('help.autorun'),
|
||
_t('help.morphling'),
|
||
_t('help.goal'),
|
||
_t('help.hive'),
|
||
_t('help.conductor'),
|
||
_t('help.scheduler'),
|
||
_t('help.quit'),
|
||
_t('help.esc'),
|
||
_t('help.cc'),
|
||
_t('help.cl'),
|
||
_t('help.cz'),
|
||
_t('help.shift_arrow')])
|
||
else:
|
||
self.commit([_t('err.unknown_cmd', name=name)])
|
||
|
||
def _btw(self, entry: list) -> None:
|
||
"""Answer a side question and fill `entry[1]` in place. If Esc cleared
|
||
the panel meanwhile, `entry` is just an orphan — the mutation is
|
||
invisible and the panel renders the current (empty) list."""
|
||
from frontends import btw_cmd
|
||
question = entry[0]
|
||
try:
|
||
ans = btw_cmd.handle_frontend_command(self._bridge.agent, '/btw ' + question)
|
||
except Exception as e:
|
||
ans = f'> /btw {question}\n\n❌ {type(e).__name__}: {e}'
|
||
# btw_cmd's formatter prefixes the header with a 🟡 glyph — drop it.
|
||
ans = (ans or '').replace('🟡 ', '').replace('🟡', '').strip()
|
||
with self._lk:
|
||
entry[1] = ans or f'> /btw {question}\n\n{_t("msg.btw_no_answer")}'
|
||
self._render_live()
|
||
|
||
def _cmd_emoji(self, arg: str) -> None:
|
||
"""`/emoji` opens an arrow-key picker (parity with /llm); `/emoji
|
||
<style>` switches directly. Styles are sourced from `_PET_STYLES`
|
||
so adding a new face dict automatically surfaces a new row. `off`
|
||
is rendered as a separate trailing row that hides the pet entirely.
|
||
"""
|
||
global _pet_style
|
||
choice = (arg or '').strip().lower()
|
||
valid_keys = list(_PET_STYLES.keys()) + [_PET_HIDDEN]
|
||
if choice:
|
||
if choice not in valid_keys:
|
||
self.commit([f'{_DIM}'
|
||
+ _t('emoji.unknown', choice=choice,
|
||
valid=', '.join(valid_keys))
|
||
+ _RST])
|
||
return
|
||
_pet_style = choice
|
||
self.commit([f'{_DIM}' + _t('emoji.switched', style=choice) + _RST])
|
||
return
|
||
# No arg → /llm-style picker. Sample = the first frame of tier 0
|
||
# so each row shows what the calm face looks like.
|
||
keys = list(_PET_STYLES.keys())
|
||
options: list[str] = []
|
||
current_idx = 0
|
||
for i, k in enumerate(keys):
|
||
sample = _PET_STYLES[k][0][0]
|
||
if k == _pet_style:
|
||
current_idx = i
|
||
options.append(_t('emoji.row.current', name=k, sample=sample))
|
||
else:
|
||
options.append(_t('emoji.row.other', name=k, sample=sample))
|
||
# Trailing "off" row hides the pet — appended last so the regular
|
||
# face styles cluster at the top of the menu.
|
||
off_label = (_t('emoji.row.current', name=_PET_HIDDEN, sample=_t('emoji.row.off'))
|
||
if _pet_style == _PET_HIDDEN
|
||
else _t('emoji.row.other', name=_PET_HIDDEN, sample=_t('emoji.row.off')))
|
||
options.append(off_label)
|
||
if _pet_style == _PET_HIDDEN:
|
||
current_idx = len(keys)
|
||
picks = keys + [_PET_HIDDEN]
|
||
|
||
def _pick(idx: int) -> None:
|
||
global _pet_style
|
||
_pet_style = picks[idx]
|
||
self.commit([f'{_DIM}' + _t('emoji.switched', style=picks[idx]) + _RST])
|
||
|
||
self._show_menu(_t('emoji.title'), options, _pick)
|
||
self._menu_sel = current_idx
|
||
|
||
def _cmd_language(self, arg: str) -> None:
|
||
"""`/language` — arrow-key picker (like /llm); `/language <code>` — direct switch."""
|
||
codes = supported()
|
||
labels = LANG_LABELS
|
||
cur = get_lang()
|
||
if arg:
|
||
code = arg.strip().lower()
|
||
if code not in codes:
|
||
avail = ', '.join(f'{c} ({labels.get(c, c)})' for c in codes)
|
||
self.commit([_t('err.lang_unknown', code=arg, available=avail)]); return
|
||
set_lang(code)
|
||
self.commit([_t('msg.lang_switched', label=labels.get(code, code))])
|
||
self._repaint_screen()
|
||
return
|
||
|
||
# No arg → menu picker. Build options in stable code order; a leading
|
||
# ● marks the active language (the ↑↓ highlight marks the cursor row).
|
||
options: list[str] = []
|
||
current_idx = 0
|
||
for i, code in enumerate(codes):
|
||
if code == cur:
|
||
current_idx = i
|
||
options.append(('● ' if code == cur else ' ')
|
||
+ f'{labels.get(code, code)} ({code})')
|
||
|
||
def _pick_lang(idx: int) -> None:
|
||
code = codes[idx]
|
||
if code == get_lang():
|
||
# No-op pick — still report current so the user sees confirmation.
|
||
self.commit([_t('msg.lang_current', label=labels.get(code, code), code=code)])
|
||
return
|
||
set_lang(code)
|
||
self.commit([_t('msg.lang_switched', label=labels.get(code, code))])
|
||
self._repaint_screen()
|
||
|
||
self._show_menu(_t('lang.title'), options, _pick_lang)
|
||
self._menu_sel = current_idx
|
||
|
||
def _rewindable_turns(self, be) -> list[tuple[int, str]]:
|
||
"""v2-parity turn detector: scan backend.history and return
|
||
(history_index, preview) for every *real* user turn. A user message
|
||
whose content is a tool_result block is skipped — otherwise a single
|
||
tool round-trip would be miscounted as an extra rewindable turn."""
|
||
turns: list[tuple[int, str]] = []
|
||
hist = getattr(be, 'history', None) or []
|
||
for i, m in enumerate(hist):
|
||
if not isinstance(m, dict) or m.get('role') != 'user':
|
||
continue
|
||
c = m.get('content')
|
||
if isinstance(c, str):
|
||
turns.append((i, c[:60])); continue
|
||
if isinstance(c, list):
|
||
if any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in c):
|
||
continue
|
||
texts = [b.get('text', '') for b in c
|
||
if isinstance(b, dict) and b.get('type') == 'text']
|
||
if texts and any(t.strip() for t in texts):
|
||
turns.append((i, texts[0][:60]))
|
||
return turns
|
||
|
||
def _do_rewind(self, be, n: int) -> tuple[str, str]:
|
||
"""Cut backend.history back `n` real user turns. Returns
|
||
(report_line, prefill_text) — prefill is the user text of the turn
|
||
rewound *to*, so the caller can drop it back into the input box."""
|
||
turns = self._rewindable_turns(be)
|
||
if not (1 <= n <= len(turns)):
|
||
return _t('err.rewind_range', max=len(turns)), ''
|
||
hist = be.history
|
||
cut = turns[-n][0]
|
||
prefill = _extract_user_text(hist[cut]) if cut < len(hist) else ''
|
||
removed = len(hist) - cut
|
||
hist[:] = hist[:cut]
|
||
return _t('msg.rewind', n=n, removed=removed), prefill
|
||
|
||
def _verbose_view(self) -> None:
|
||
"""Tool-call audit on a TEMP alt-screen — main scrollback is never
|
||
touched. Data is the already-captured ToolRecord log (self._tools)."""
|
||
recs = [self._tools[k] for k in sorted(self._tools)]
|
||
if not recs:
|
||
self.commit([_DIM + _t('msg.no_tools') + _RST]); return
|
||
sel, mode, scroll = 0, 0, 0
|
||
fields = ('result', 'args', 'raw')
|
||
_w('\x1b[?1049h\x1b[?2004l') # alt-screen; pause bracketed paste here
|
||
try:
|
||
while True:
|
||
w, h = _term()
|
||
r = recs[sel]
|
||
lines: list[str] = []
|
||
for ln in (getattr(r, fields[mode]) or _t('verbose.empty')).split('\n'):
|
||
lines += _wrap_cells(ln, w - 2) or ['']
|
||
avail = max(2, h - 4) # rows shared by list + detail
|
||
list_h = min(len(recs), max(3, avail // 3))
|
||
body_h = max(1, avail - list_h)
|
||
lo = max(0, min(sel - list_h // 2, len(recs) - list_h))
|
||
scroll = max(0, min(scroll, max(0, len(lines) - body_h)))
|
||
out = ['\x1b[2J\x1b[H', _BOLD + _t('verbose.title') + _RST + _DIM +
|
||
_t('verbose.hint', field=fields[mode]) + _RST, '']
|
||
for t in recs[lo:lo + list_h]:
|
||
mk = _ACCENT + '▌' + _RST if t is r else ' '
|
||
stc = (_OK + 'ok' if t.status == 'ok' else
|
||
_ERR + 'error' if t.status == 'error' else _DIM + '?')
|
||
out.append(f'{mk} {_BOLD}t{t.id}{_RST} {t.name} {stc}{_RST}')
|
||
out.append('')
|
||
detail_prefix = _DIM + ('│ ' if w >= 3 else '|' if w >= 1 else '') + _RST
|
||
detail_w = max(1, w - cell_len('│ ' if w >= 3 else '|' if w >= 1 else ''))
|
||
out += [detail_prefix + _clip_cells(ln, detail_w)
|
||
for ln in lines[scroll:scroll + body_h]]
|
||
_w('\r\n'.join(out))
|
||
d = os.read(self._fd, 32)
|
||
if d in (b'q', b'\x1b', b'\x03', b'\x04'):
|
||
break
|
||
elif d in (b'\x1b[A', b'k'):
|
||
sel = max(0, sel - 1); scroll = 0
|
||
elif d in (b'\x1b[B', b'j'):
|
||
sel = min(len(recs) - 1, sel + 1); scroll = 0
|
||
elif d == b'\x1b[5~':
|
||
scroll -= body_h
|
||
elif d == b'\x1b[6~':
|
||
scroll += body_h
|
||
elif d == b'\r':
|
||
mode = (mode + 1) % 3; scroll = 0
|
||
elif d == b'c':
|
||
clip.copy(getattr(r, fields[mode]) or '')
|
||
elif d == b'e':
|
||
from frontends import export_cmd
|
||
export_cmd.export_to_temp(getattr(r, fields[mode]) or '',
|
||
f'tool_t{r.id}_{fields[mode]}')
|
||
finally:
|
||
_w('\x1b[?1049l\x1b[?2004h') # leave alt-screen; resume bracketed paste
|
||
# NOTE: this /verbose alt-screen viewer reads keys via os.read(self._fd),
|
||
# which conflicts with PTK's input loop. In PTK Application mode the
|
||
# viewer is currently broken; a future rewrite should wrap it in
|
||
# app.run_in_terminal and use PTK's input pipeline. For now we just
|
||
# invalidate so PTK redraws the live region after we exit alt-screen.
|
||
self._painted = []; self._live_rows = 0; self._parked_up = 0
|
||
self._pending_repaint = True
|
||
self._schedule_drain()
|
||
self._invalidate_ptk()
|
||
|
||
# ── agent ──
|
||
|
||
def _submit(self, query: str, images: list) -> None:
|
||
assert self._bridge is not None
|
||
self._running = True; self._t0 = self._t0_anchor = time.time()
|
||
self._stream = ''; self._sent = 0; self._live_tail = []
|
||
dq = self._bridge.submit(query, images=images or None)
|
||
threading.Thread(target=self._drain, args=(dq,), daemon=True).start()
|
||
threading.Thread(target=self._ticker, daemon=True).start()
|
||
|
||
def _ticker(self) -> None:
|
||
# 0.1s cadence matches tui_v2's snappy "alive" feel; the 0.4s sleep
|
||
# that lived here previously made the spinner look stalled on long
|
||
# tool turns. _render_live is cheap (it diffs by hash before
|
||
# touching the TTY). Also drives the OSC 0 terminal-title spinner.
|
||
while self._running:
|
||
time.sleep(0.1)
|
||
with self._lk:
|
||
if self._running:
|
||
self._spin += 1; self._render_live()
|
||
_set_term_title(self._term_title())
|
||
|
||
def _term_title(self) -> str:
|
||
name = (self._session_name or '').strip()
|
||
head = (_SPIN[self._spin % len(_SPIN)] + ' ') if self._running else ''
|
||
tail = f'{name} · GenericAgent' if name else 'GenericAgent'
|
||
return f'{head}{tail}'
|
||
|
||
def _poll_ask(self, grace: float = 0.0) -> AskUserEvent | None:
|
||
"""Only pull a queued ask when none is currently being shown.
|
||
Otherwise an already-active card would be overwritten before the
|
||
user answered it (parallel sub-agent asks pile up in the queue;
|
||
each entry stays there until the previous one is dispatched)."""
|
||
assert self._bridge is not None
|
||
end = time.time() + grace
|
||
while True:
|
||
with self._lk:
|
||
if self._asking is not None:
|
||
return None
|
||
try:
|
||
return self._bridge.ask_user_queue.get_nowait()
|
||
except queue.Empty:
|
||
pass
|
||
if time.time() >= end:
|
||
return None
|
||
time.sleep(0.02)
|
||
|
||
def _drain(self, dq) -> None:
|
||
assert self._bridge is not None
|
||
for ev in self._bridge.drain_display_queue(dq):
|
||
if ev is None:
|
||
ae = self._poll_ask()
|
||
if ae:
|
||
with self._lk:
|
||
self._enter_ask(ae)
|
||
return
|
||
continue
|
||
if isinstance(ev, DoneEvent):
|
||
# ga.ask_user() emits its "Waiting for your answer …" marker
|
||
# to the stream *just before* it pushes the AskUserEvent onto
|
||
# ask_user_queue. When the agent then short-circuits with
|
||
# should_exit=True a DoneEvent can land here before the
|
||
# AskUserEvent.put() returns. The previous 2s grace was the
|
||
# right idea but too short under load (long sessions backed
|
||
# up the put), so the ae would be missed and the question
|
||
# silently dropped into scrollback — observed as 'input box
|
||
# never re-appears'. When the marker is present, treat the
|
||
# ae as REQUIRED and wait longer; the hook is synchronous
|
||
# with agent thread exit so 10s is comfortably above the
|
||
# real arrival time. Plain replies keep the snappy 0.4s.
|
||
if 'Waiting for your answer' in self._stream:
|
||
ae = self._poll_ask(grace=10.0)
|
||
else:
|
||
ae = self._poll_ask(grace=0.4)
|
||
with self._lk:
|
||
self._enter_ask(ae) if ae else self._finalize(ev.text)
|
||
break
|
||
with self._lk:
|
||
if isinstance(ev, StreamEvent):
|
||
self._stream += ev.text
|
||
now = time.time()
|
||
if now - self._last_render > 0.08: # throttle: ≤~12 fps
|
||
self._last_render = now; self._flow()
|
||
elif isinstance(ev, (SystemEvent, ErrorEvent)):
|
||
self._finalize(getattr(ev, 'text', None) or getattr(ev, 'message', '')); break
|
||
self._running = False
|
||
# _ticker stops as soon as _running goes False so the title would
|
||
# freeze on the last spinner frame. Repaint it now to drop the
|
||
# glyph and reveal a clean idle title.
|
||
_set_term_title(self._term_title())
|
||
with self._lk:
|
||
self._flow(final=True) if self._stream else self._render_live()
|
||
# Exit-boundary replay: a queued user message can be consumed at the
|
||
# same turn boundary that finishes the current task. The bridge replays
|
||
# it via put_task; drain that returned queue here so the reply is shown
|
||
# live instead of only appearing later through /continue.
|
||
replay = None
|
||
if self._bridge._replay_dq is not None:
|
||
with self._lk:
|
||
replay = self._bridge.take_replay_dq()
|
||
if replay is not None:
|
||
for msg in self._pending:
|
||
self._commit_user(_t('pending.queued_marker', text=msg))
|
||
self._pending = []
|
||
self._running = True
|
||
self._stream = ''; self._sent = 0; self._live_tail = []
|
||
self._t0 = self._t0_anchor = time.time()
|
||
if replay is not None:
|
||
threading.Thread(target=self._ticker, daemon=True).start()
|
||
self._drain(replay)
|
||
|
||
def _enter_ask(self, ae: AskUserEvent) -> None:
|
||
if self._stream.strip():
|
||
self._flow(final=True) # land the assistant lead-up in scrollback
|
||
self._stream = ''; self._sent = 0; self._live_tail = []
|
||
self._asking = ae; self._running = False; self.buf = ''; self.pos = 0
|
||
self._undo.clear(); self._redo.clear(); self._sel = None
|
||
self._picker_init(ae)
|
||
self._render_live()
|
||
|
||
def _commit_user(self, text: str) -> None:
|
||
self.commit(Block('user', text))
|
||
|
||
def _compress(self, t: str) -> str:
|
||
"""Capture each tool call as a structured ToolRecord (ids fixed at
|
||
stream time — scrollback is immutable) and replace it with a quiet
|
||
chip; then drop turn markers / status chatter / meta."""
|
||
idx = 0
|
||
|
||
def repl(m: re.Match) -> str:
|
||
nonlocal idx
|
||
idx += 1
|
||
tid = self._tool_base + idx
|
||
name = m.group(1)
|
||
args = (m.group(3) or '').strip() # args body (after fence-delim group 2)
|
||
result = (m.group(5) or m.group(6) or '').strip() # replay-fenced OR live trace
|
||
st = _tool_status(result, '')
|
||
hint = _arg_hint(name, args, result)
|
||
self._tools[tid] = ToolRecord(tid, name, args, result, st, m.group(0))
|
||
return f'\n▸ t{tid} {name}{(" " + hint) if hint else ""} · {st}\n'
|
||
|
||
out = _TOOL_RE.sub(repl, t)
|
||
|
||
def xrepl(m: re.Match) -> str: # prompted-XML form: body IS the result
|
||
nonlocal idx # no separate hint — the chip's preview
|
||
idx += 1 # shows the body directly (otherwise hint
|
||
tid = self._tool_base + idx # would duplicate preview line 1)
|
||
name = m.group(1)
|
||
body = (m.group(2) or '').strip()
|
||
st = _tool_status(body, '')
|
||
self._tools[tid] = ToolRecord(tid, name, '', body, st, m.group(0))
|
||
return f'\n▸ t{tid} {name} · {st}\n'
|
||
|
||
out = _XML_TOOL_RE.sub(xrepl, out)
|
||
self._last_tool_n = idx
|
||
out = _ACTION_RE.sub('· ', _TURN_MK_RE.sub('', out))
|
||
return strip_meta_tags(out) # empty when fully meta — render nothing,
|
||
# else early '...' placeholders pollute _sent
|
||
|
||
def _render_assistant(self, text: str, w: int) -> list[str]:
|
||
"""Compress, then alternately render prose (markdown) and emit chip
|
||
boxes DIRECTLY — Rich Markdown is never asked to render a chip
|
||
placeholder. Bypassing markdown is what guarantees the box stays
|
||
closed (top/right/bottom/left); otherwise Rich would wrap-break the
|
||
placeholder line and the box renders without its right/bottom edges."""
|
||
compressed = self._compress(text)
|
||
out: list[str] = []
|
||
last = 0
|
||
for m in _CHIP_PLACEHOLDER_RE.finditer(compressed):
|
||
prose = compressed[last:m.start()]
|
||
if prose.strip():
|
||
out.extend(_render(prose, w, markdown=True))
|
||
rec = self._tools.get(int(m.group(1)))
|
||
out.extend(_chip_box(m.group(1), m.group(2), m.group(3), w,
|
||
rec.result if rec else ''))
|
||
last = m.end()
|
||
tail = compressed[last:]
|
||
if tail.strip():
|
||
out.extend(_render(tail, w, markdown=True))
|
||
return out
|
||
|
||
def _commit_assistant(self, text: str) -> None:
|
||
self.commit(Block('assistant', text)) # commit() handles _tool_base
|
||
# and _render_block does the fold
|
||
|
||
def _safe_pos(self, stream: str) -> int:
|
||
"""Position up to which the stream is STRUCTURALLY stable — past this
|
||
point any commit risks duplication when the regex later matches and
|
||
reshapes body. Detects in-flight `🛠️ Tool:` (no closing boundary
|
||
yet), `**LLM Running` (no closing `**`), `<summary>` / `<thinking>`
|
||
(no closing tag). Falls back to last `\\n\\n` paragraph boundary."""
|
||
unsafe = []
|
||
for m in re.finditer(r'🛠️ Tool:', stream):
|
||
# Closing sentinel for an in-flight tool block: next tool, next
|
||
# turn marker (either form), or next assistant frame. Matches
|
||
# both `**LLM Running (Turn N) ...**` and the task-mode short
|
||
# `**Turn N ...**` so task_dir-enabled runs aren't mis-classified.
|
||
if not re.search(r'(?:^|\n)(?:\*\*(?:LLM Running \()?Turn \d+|🛠️ Tool:)', stream[m.end():]):
|
||
unsafe.append(m.start())
|
||
for m in re.finditer(r'\*\*(?:LLM Running \()?Turn \d+', stream):
|
||
if '**' not in stream[m.end():]:
|
||
unsafe.append(m.start())
|
||
for tag in ('summary', 'thinking'):
|
||
for m in re.finditer(f'<{tag}>', stream):
|
||
if f'</{tag}>' not in stream[m.end():]:
|
||
unsafe.append(m.start())
|
||
if unsafe:
|
||
return min(unsafe)
|
||
sep = stream.rfind('\n\n')
|
||
return sep + 2 if sep > 0 else 0
|
||
|
||
def _flow(self, final: bool = False) -> None:
|
||
"""cc-style flow with structural-boundary commit safety.
|
||
|
||
Split the stream at `_safe_pos` (last position with no in-flight
|
||
regex-matchable structure). Closed half renders & commits; open half
|
||
stays VOLATILE in the live tail. Without this, an in-flight tool
|
||
block (args fence still streaming) leaks its raw `🛠️ Tool:` header
|
||
into scrollback — then once the fence closes and `_TOOL_RE` matches,
|
||
the chip commits AFTER, leaving orphan headers. agent_loop emits a
|
||
following `**LLM Running` or next `🛠️ Tool:` once a tool's result
|
||
finishes, so detection of those markers gates the commit."""
|
||
w = _term()[0]
|
||
stream = _strip_final_marker(sanitize_ansi(self._stream))
|
||
|
||
if self._streaming_block is None and (stream.strip() or final):
|
||
self._streaming_block = Block('assistant', '')
|
||
self._blocks.append(self._streaming_block)
|
||
self._cap_blocks()
|
||
self._sent = 0
|
||
self._stream_turn_seen = 0 # new response → reset incremental fold tracker
|
||
|
||
if final:
|
||
if self._streaming_block is not None:
|
||
self._streaming_block.source = stream
|
||
base = self._pre_streaming_tool_base()
|
||
saved_base = self._tool_base; self._tool_base = base
|
||
body = self._render_assistant(stream, max(1, w - 2))
|
||
if self._streaming_block is not None:
|
||
self._streaming_block.tool_n = self._last_tool_n
|
||
self._tool_base = saved_base + self._last_tool_n # legacy tracker advance
|
||
full_lines = _indent_rows(body, w) + ['']
|
||
new = full_lines[self._sent:]
|
||
self._live_tail = []
|
||
self._emit_lines(new, w)
|
||
# Per-turn fold only kicks in with ≥2 turn markers (see _fold_turns).
|
||
# Anything less stays expanded and doesn't need a repaint — saves a
|
||
# screen flash on simple Q&A.
|
||
multi_turn = len(_TURN_SPLIT_FOLD_RE.findall(stream)) >= 2
|
||
self._streaming_block = None
|
||
self._stream = ''; self._sent = 0; self._live_tail = []
|
||
self._stream_turn_seen = 0 # response done → reset for next stream
|
||
# Auto-collapse after generation: streamed body was already pushed
|
||
# to scrollback above; trigger a full repaint so it gets replaced
|
||
# by per-turn folded headers (scrollback-first can't unwrite, full
|
||
# repaint is the only knob).
|
||
if multi_turn and self._fold_all:
|
||
self._repaint_screen()
|
||
return
|
||
|
||
safe = self._safe_pos(stream)
|
||
closed_text = stream[:safe]
|
||
open_text = stream[safe:]
|
||
|
||
if self._streaming_block is not None:
|
||
self._streaming_block.source = closed_text # block tracks committed src
|
||
|
||
base = self._pre_streaming_tool_base()
|
||
saved_base, saved_n = self._tool_base, self._last_tool_n
|
||
self._tool_base = base
|
||
closed_body = self._render_assistant(closed_text, max(1, w - 2)) if closed_text.strip() else []
|
||
closed_n = self._last_tool_n
|
||
if self._streaming_block is not None:
|
||
self._streaming_block.tool_n = closed_n
|
||
if open_text.strip():
|
||
self._tool_base = base + closed_n # open tids don't collide
|
||
open_body = self._render_assistant(open_text, max(1, w - 2))
|
||
else:
|
||
open_body = []
|
||
self._tool_base = saved_base
|
||
self._last_tool_n = saved_n # finalize uses closed_n
|
||
|
||
closed_lines = _indent_rows(closed_body, w)
|
||
new = closed_lines[self._sent:]
|
||
self._sent = len(closed_lines)
|
||
self._live_tail = _indent_rows(open_body[-8:], w) # cap volatile region —
|
||
# any larger and a growing
|
||
# live region scrolls past
|
||
# viewport, pushing old
|
||
# paints into scrollback as
|
||
# un-erasable "duplicates"
|
||
if new:
|
||
self._emit_lines(new, w)
|
||
else:
|
||
self._render_live()
|
||
# Incremental fold (v2 live-fold parity): the moment a new turn marker
|
||
# arrives, the PREVIOUS turn body is complete and can collapse into a
|
||
# `▸ {summary}` header. Trigger a full repaint each time the marker
|
||
# count climbs — already-committed expanded lines get cleared and
|
||
# replaced with the folded form. Without this users see every turn
|
||
# expanded all the way through the response and only fold at the very
|
||
# end (final=True), which feels bulk-and-flash.
|
||
turn_count = len(_TURN_SPLIT_FOLD_RE.findall(stream))
|
||
if (self._fold_all and turn_count > self._stream_turn_seen
|
||
and turn_count >= 2):
|
||
self._stream_turn_seen = turn_count
|
||
self._repaint_screen()
|
||
|
||
def _finalize(self, text: str) -> None:
|
||
if text and not self._stream:
|
||
self._stream = text # system/error: nothing was streamed
|
||
self._flow(final=True)
|
||
|
||
# ── byte feed ──
|
||
|
||
def _ingest(self, data: bytes):
|
||
"""Yield ('paste', str) and ('keys', bytes), holding partial markers."""
|
||
self._rb += data
|
||
while self._rb:
|
||
if self._bp:
|
||
i = self._rb.find(_BP_END)
|
||
if i == -1:
|
||
hold = _holdback(self._rb, _BP_END)
|
||
self._pbytes += self._rb[:len(self._rb) - hold]
|
||
self._rb = self._rb[len(self._rb) - hold:]
|
||
return
|
||
self._pbytes += self._rb[:i]; self._rb = self._rb[i + len(_BP_END):]
|
||
self._bp = False
|
||
yield ('paste', self._pbytes.decode('utf-8', 'replace')); self._pbytes = b''
|
||
else:
|
||
i = self._rb.find(_BP_START)
|
||
if i == -1:
|
||
hold = _holdback(self._rb, _BP_START)
|
||
emit = self._rb[:len(self._rb) - hold]
|
||
self._rb = self._rb[len(self._rb) - hold:]
|
||
if emit:
|
||
yield ('keys', emit)
|
||
return
|
||
if i:
|
||
yield ('keys', self._rb[:i])
|
||
self._rb = self._rb[i + len(_BP_START):]
|
||
self._bp = True
|
||
|
||
def _keys(self, data: bytes) -> None:
|
||
# escape-delay disambiguation: a lone trailing \x1b is held until the next
|
||
# read (~40ms later via select gate in run()) — distinguishes a bare Esc
|
||
# from the first byte of a split arrow `\x1b[A`. A `\x1b` followed by a
|
||
# non-`[`/`O` byte means a real bare Esc + a separate key.
|
||
data = self._epend + data; self._epend = b''
|
||
if data.startswith(b'\x1b') and len(data) >= 2 and data[1:2] not in (b'[', b'O'):
|
||
self._esc_back(); data = data[1:]
|
||
if data == b'\x1b':
|
||
self._epend = b'\x1b'; return
|
||
if data.endswith(b'\x1b'):
|
||
self._epend = b'\x1b'; data = data[:-1]
|
||
if not data:
|
||
return
|
||
self._tail += _ESC_RE.sub(_esc_repl, data)
|
||
try:
|
||
text = self._tail.decode('utf-8'); self._tail = b''
|
||
except UnicodeDecodeError as e:
|
||
text = self._tail[:e.start].decode('utf-8', 'ignore'); self._tail = self._tail[e.start:]
|
||
for ch in text:
|
||
o = ord(ch)
|
||
# ── menu picker key intercept (modal: blocks all input editing) ─
|
||
# /llm, /continue, … open an arrow-key menu. ↑↓ move highlight
|
||
# (saturating at endpoints — no wrap), Enter submits, Esc cancels.
|
||
# Other keys are swallowed — no free-text typing while a menu is up.
|
||
if self._menu_active:
|
||
n = len(self._menu_options)
|
||
if o == 0x10: # ↑
|
||
if self._menu_filterable:
|
||
# ring [input(-1), 0..n-1]: ↑ from input → last cand;
|
||
# ↑ from first cand → input; else step up.
|
||
if self._menu_sel < 0:
|
||
self._menu_sel = n - 1 if n else -1
|
||
elif self._menu_sel == 0:
|
||
self._menu_sel = -1
|
||
else:
|
||
self._menu_sel -= 1
|
||
elif n:
|
||
self._menu_sel = max(0, self._menu_sel - 1)
|
||
self._render_live(); continue
|
||
if o == 0x0e: # ↓
|
||
if self._menu_filterable:
|
||
# ↓ from input → first cand; ↓ from last cand → input.
|
||
if self._menu_sel < 0:
|
||
self._menu_sel = 0 if n else -1
|
||
elif self._menu_sel >= n - 1:
|
||
self._menu_sel = -1
|
||
else:
|
||
self._menu_sel += 1
|
||
elif n:
|
||
self._menu_sel = min(n - 1, self._menu_sel + 1)
|
||
self._render_live(); continue
|
||
if self._menu_multi and ch == ' ': # Space toggles
|
||
# Only meaningful in multi-select mode; in single mode we
|
||
# swallow Space to keep "modal, no free-text" invariant.
|
||
if n:
|
||
i = self._menu_sel
|
||
if i in self._menu_checked:
|
||
self._menu_checked.discard(i)
|
||
else:
|
||
self._menu_checked.add(i)
|
||
self._render_live(); continue
|
||
if ch == '\r': # Enter
|
||
self._menu_submit(); continue
|
||
if o == 0x1b: # Esc
|
||
self._menu_cancel(); continue
|
||
if o == 0x02: # ← cancel (directional Esc)
|
||
# The /scheduler confirm card spawns a menu with an
|
||
# on_cancel that re-opens the picker — ← gives users
|
||
# a one-handed rollback without reaching for Esc.
|
||
# Menus with no on_cancel just dismiss.
|
||
self._menu_cancel(); continue
|
||
# Filterable menu (e.g. /workspace): printable chars + Backspace
|
||
# edit a live query that filters rows; everything else (arrows
|
||
# 0x10/0x0e/0x02/0x06 are < 0x20) still falls through to swallow.
|
||
if self._menu_filterable:
|
||
if o in (0x08, 0x7f): # Backspace / Del
|
||
if self._menu_query:
|
||
self._menu_query = self._menu_query[:-1]
|
||
self._menu_apply_filter()
|
||
self._render_live(); continue
|
||
if o >= 0x20 and o != 0x7f: # printable → append to query
|
||
self._menu_query += ch
|
||
self._menu_apply_filter()
|
||
self._render_live(); continue
|
||
# swallow everything else while the menu is up
|
||
continue
|
||
# ── ask_user picker key intercept ───────────────────────────────
|
||
# Single mode: focus cycles [cand0..candN-1, input]. ↑↓ moves
|
||
# through candidates and the input box; printable chars switch
|
||
# focus to input and insert.
|
||
# Multi mode: NO input box (rendering skips it too). ↑↓ wrap
|
||
# inside candidates only; Space toggles current checkbox;
|
||
# non-Enter/Esc printable chars are swallowed (free-text would
|
||
# break the "submit checked items" semantics).
|
||
if self._asking is not None:
|
||
ae = self._asking
|
||
n = len(ae.candidates) if getattr(ae, 'candidates', None) else 0
|
||
if self._picker_mode == 'multi':
|
||
if o == 0x10 and n > 0: # ↑ wrap in multi
|
||
self._picker_sel = (self._picker_sel - 1) % n
|
||
self._render_live(); continue
|
||
if o == 0x0e and n > 0: # ↓ wrap in multi
|
||
self._picker_sel = (self._picker_sel + 1) % n
|
||
self._render_live(); continue
|
||
if ch == ' ': # Space toggle
|
||
i = self._picker_sel
|
||
if i in self._picker_checked:
|
||
self._picker_checked.discard(i)
|
||
else:
|
||
self._picker_checked.add(i)
|
||
self._render_live(); continue
|
||
if o >= 0x20: # swallow other printables
|
||
continue
|
||
elif o in (0x10, 0x0e) and n > 0: # ↑ / ↓ in single
|
||
self._sel = None
|
||
if not self._picker_mode:
|
||
# Input → picker. ↑ enters at bottom, ↓ enters at top.
|
||
self._picker_mode = 'single'
|
||
self._picker_sel = (n - 1) if o == 0x10 else 0
|
||
elif o == 0x10: # ↑ inside picker
|
||
if self._picker_sel <= 0: # top edge → input
|
||
self._picker_mode = None
|
||
else:
|
||
self._picker_sel -= 1
|
||
else: # ↓ inside picker
|
||
if self._picker_sel >= n - 1: # bottom edge → input
|
||
self._picker_mode = None
|
||
else:
|
||
self._picker_sel += 1
|
||
self._render_live(); continue
|
||
if self._picker_mode == 'single' and o >= 0x20:
|
||
self._picker_mode = None # keep sel alive
|
||
# fall through to normal insert below
|
||
if ch == '\r':
|
||
self._on_enter()
|
||
elif ch == '\n':
|
||
self._insert('\n')
|
||
elif o == 0x10: # ↑ visual-row up (history at top)
|
||
if self._palette_visible():
|
||
self._palette_sel = max(0, self._palette_sel - 1)
|
||
else:
|
||
self._sel = None
|
||
segs = self._segs(max(1, _term()[0] - 6))
|
||
vi, _ = self._seg_at(segs)
|
||
if vi == 0: # 视觉首行(必在第一逻辑行)
|
||
_, _, ls, _ = self._line_region()
|
||
if self.pos == ls:
|
||
self._nav_hist(-1)
|
||
else:
|
||
self.pos = ls # 先跳行首,下次再进历史
|
||
else:
|
||
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 = self._palette_total()
|
||
self._palette_sel = min(n - 1, self._palette_sel + 1) if n else 0
|
||
else:
|
||
self._sel = None
|
||
segs = self._segs(max(1, _term()[0] - 6))
|
||
vi, _ = self._seg_at(segs)
|
||
if vi == len(segs) - 1: # 视觉末行(必在最末逻辑行)
|
||
_, _, _, le = self._line_region()
|
||
if self.pos == le:
|
||
self._nav_hist(1)
|
||
else:
|
||
self.pos = le # 先跳行尾,下次再进历史
|
||
else:
|
||
self._cur_v(1)
|
||
elif o == 0x02: # ← caret left
|
||
self._sel = None; self.pos = max(0, self.pos - 1)
|
||
elif o == 0x06: # → caret right
|
||
self._sel = None; self.pos = min(len(self.buf), self.pos + 1)
|
||
elif o == 0x1e: # Shift+← extend selection left
|
||
self._sel_start(); self.pos = max(0, self.pos - 1)
|
||
elif o == 0x1f: # Shift+→ extend selection right
|
||
self._sel_start(); self.pos = min(len(self.buf), self.pos + 1)
|
||
elif o == 0x1c: # Shift+↑ extend selection up
|
||
self._sel_v(-1)
|
||
elif o == 0x1d: # Shift+↓ extend selection down
|
||
self._sel_v(1)
|
||
elif o == 0x1a: # Ctrl+Z — undo
|
||
self._do_undo()
|
||
elif o == 0x19: # Ctrl+Y — redo
|
||
self._do_redo()
|
||
elif o == 0x01: # Ctrl+A — select all
|
||
if self.buf:
|
||
self._sel = 0; self.pos = len(self.buf)
|
||
elif o == 0x07: # Home — jump to line start
|
||
self._sel = None
|
||
_, _, ls, _ = self._line_region()
|
||
self.pos = ls
|
||
elif o == 0x14: # End — jump to line end
|
||
self._sel = None
|
||
_, _, _, le = self._line_region()
|
||
self.pos = le
|
||
elif o == 0x18: # Ctrl+X — cut selection
|
||
r = self._sel_range()
|
||
if r:
|
||
clip.copy(self.buf[r[0]:r[1]]); self._kill_sel()
|
||
elif o == 0x1b: # Esc — universal back (Esc Esc handled in _handle_key)
|
||
self._esc_back()
|
||
elif o == 0x09: # Tab — slash-command completion
|
||
self._tab()
|
||
elif o == 0x0c: # Ctrl+L — force redraw (sleep/wake recovery)
|
||
self._redraw()
|
||
elif o == 0x0f: # Ctrl+O — silent toggle: fold/unfold tool chips
|
||
self._fold_all = not self._fold_all
|
||
self._repaint_screen()
|
||
elif o == 0x16: # Ctrl+V
|
||
self._handle_clip_paste()
|
||
elif o == 0x15: # Cmd+⌫ / Ctrl+U: kill to line start
|
||
if not self._kill_sel():
|
||
self._snap()
|
||
ls = self.buf.rfind('\n', 0, self.pos) + 1
|
||
self.buf = self.buf[:ls] + self.buf[self.pos:]; self.pos = ls
|
||
elif o in (0x7f, 0x08):
|
||
if not self._kill_sel():
|
||
hit = self._placeholder_at('left')
|
||
if hit: # backspace flush against a
|
||
st, end, sid = hit # placeholder → wipe the block
|
||
self._snap()
|
||
self.buf = self.buf[:st] + self.buf[end:]; self.pos = st
|
||
self._pstore.pop(sid, None)
|
||
self._fstore.pop(sid, None)
|
||
self._imgs.pop(sid, None)
|
||
elif self.pos:
|
||
self._snap()
|
||
self.buf = self.buf[:self.pos - 1] + self.buf[self.pos:]; self.pos -= 1
|
||
elif o >= 0x20:
|
||
self._insert(ch)
|
||
|
||
def _redraw(self) -> None: # caller must hold self._lk
|
||
"""Force a clean live-region repaint. Recovers from mac-sleep/wake
|
||
ghosting (two-box overlap), terminal resize, and any state where the
|
||
skip-identical _paint cache disagrees with what's actually on screen."""
|
||
self._painted = []; self._live_rows = 0; self._parked_up = 0
|
||
self._repaint_screen()
|
||
|
||
def _flush_esc(self) -> None: # called from run() when the 40ms gate expires
|
||
with self._lk:
|
||
if self._rb and not self._bp: # bracketed-paste holdback held a lone
|
||
data = self._rb; self._rb = b'' # \x1b (prefix of \x1b[200~); release it
|
||
self._keys(data) # so a bare Esc isn't invisibly stuck
|
||
if self._epend:
|
||
self._epend = b''; self._esc_back()
|
||
if self._resized:
|
||
self._resized = False; self._redraw()
|
||
else:
|
||
self._render_live()
|
||
|
||
def _feed(self, data: bytes) -> bool: # False → quit
|
||
if b'\x04' in data and not self._bp:
|
||
return False
|
||
if b'\x03' in data and not self._bp:
|
||
r = self._sel_range() # Ctrl+C with a selection = copy
|
||
if r: # (preserves abort/exit semantics
|
||
with self._lk: # when there is nothing selected)
|
||
clip.copy(self.buf[r[0]:r[1]])
|
||
self._sel = None
|
||
self._render_live()
|
||
return True
|
||
if self._running and self._bridge: # running task → abort (single press)
|
||
self._bridge.abort()
|
||
self._running = False # stop the spinner/pet animation now
|
||
with self._lk:
|
||
self._render_live()
|
||
return True
|
||
if time.time() - self._cc_t < 1: # idle: arm-to-quit; second press
|
||
return False # within the window actually exits
|
||
with self._lk:
|
||
if self.buf: # v2: first press clears the draft
|
||
self._snap()
|
||
self.buf = ''; self.pos = 0; self._sel = None
|
||
self._cc_t = time.time() # arm: second press quits + shows hint
|
||
self._render_live()
|
||
return True
|
||
for kind, chunk in self._ingest(data):
|
||
with self._lk:
|
||
if self._resized:
|
||
self._resized = False; self._redraw()
|
||
if kind == 'paste':
|
||
self._paste_text(chunk)
|
||
else:
|
||
self._keys(chunk)
|
||
self._render_live()
|
||
if self._quit:
|
||
return False
|
||
return True
|
||
|
||
def _run_prompt_toolkit(self) -> None:
|
||
"""prompt_toolkit Application backend (scrollback-first).
|
||
|
||
PTK owns: raw mode, key dispatch, resize polling, async lifecycle, and
|
||
rendering of the *live region only* (input box + status + spinner +
|
||
plan + open stream tail). The Window has dynamic height that follows
|
||
content, so PTK reserves only the live region's rows at the bottom of
|
||
the terminal.
|
||
|
||
Finalized history is pushed above the PTK render area via
|
||
app.print_text() wrapped in run_in_terminal() — the terminal's native
|
||
scrollback owns the conversation and the mouse wheel scrolls it.
|
||
"""
|
||
from prompt_toolkit.application import Application
|
||
from prompt_toolkit.filters import Condition
|
||
from prompt_toolkit.key_binding import KeyBindings
|
||
from prompt_toolkit.keys import Keys
|
||
from prompt_toolkit.layout import Layout
|
||
from prompt_toolkit.layout.containers import Window
|
||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||
from prompt_toolkit.output.defaults import create_output
|
||
|
||
self._bridge = AgentBridge()
|
||
try:
|
||
from frontends import cost_tracker
|
||
cost_tracker.install()
|
||
except Exception:
|
||
pass
|
||
|
||
os.makedirs(self._cwd, exist_ok=True)
|
||
logf = open(os.path.join(self._cwd, 'sb_agent.log'), 'w', buffering=1,
|
||
encoding='utf-8')
|
||
so, se = sys.stdout, sys.stderr
|
||
sys.stdout = sys.stderr = logf
|
||
|
||
kb = KeyBindings()
|
||
last_size: list[tuple[int, int] | None] = [None]
|
||
|
||
def _handle_key(event) -> None:
|
||
for kp in event.key_sequence:
|
||
if _is_mouse_or_scroll_keypress(kp):
|
||
continue
|
||
# Bracketed paste: PTK parses `\x1b[200~…\x1b[201~` into a
|
||
# single keypress whose `data` is the whole payload. Route it
|
||
# straight to the paste handler — otherwise the payload's
|
||
# newlines fall through `_keys` as Enter presses (premature
|
||
# submit) and multi-line / file / image folding never runs.
|
||
if getattr(kp, 'key', None) == Keys.BracketedPaste:
|
||
# A modal menu picker owns the live region — no input box
|
||
# to paste into, so swallow the paste.
|
||
if not self._menu_active:
|
||
with self._lk:
|
||
self._handle_paste(getattr(kp, 'data', '') or '')
|
||
self._render_live()
|
||
event.app.invalidate()
|
||
continue
|
||
data = _ptk_keypress_to_bytes(kp)
|
||
# PTK has already disambiguated a bare Escape from arrow-key
|
||
# sequences (arrows arrive as Keys.Up/… → distinct bytes), so a
|
||
# b'\x1b' here is unambiguously Esc. Handle it directly instead
|
||
# of routing through _keys, whose raw-terminal escape-delay
|
||
# hold (~30ms) would otherwise lag every menu/ask cancel.
|
||
#
|
||
# Esc Esc within 800 ms → /rewind (user-requested binding).
|
||
# The first Esc still runs _esc_back (cancel ask / clear
|
||
# draft / abort task), so this only fires when the user
|
||
# presses twice quickly — never surprises a single press.
|
||
if data == b'\x1b':
|
||
now = time.time()
|
||
if now - self._last_esc_t < 0.8:
|
||
self._last_esc_t = 0.0
|
||
with self._lk:
|
||
self._cmd('/rewind')
|
||
self._render_live()
|
||
else:
|
||
self._last_esc_t = now
|
||
with self._lk:
|
||
self._esc_back()
|
||
self._render_live()
|
||
event.app.invalidate()
|
||
continue
|
||
if data and not self._feed(data):
|
||
event.app.exit()
|
||
return
|
||
event.app.invalidate()
|
||
|
||
kb.add(Keys.Any, eager=True)(_handle_key)
|
||
|
||
control = FormattedTextControl(
|
||
text=self._get_ptk_text,
|
||
focusable=True,
|
||
show_cursor=True,
|
||
get_cursor_position=self._get_ptk_cursor,
|
||
)
|
||
# dont_extend_height + dynamic height makes PTK reserve only the live
|
||
# region's rows at the bottom of the terminal. Anything written via
|
||
# run_in_terminal/print_text scrolls above into native scrollback.
|
||
root = Window(
|
||
content=control,
|
||
wrap_lines=False,
|
||
# Hide the caret when focus is owned elsewhere:
|
||
# - a modal menu picker (no text field at all)
|
||
# - the ask card with picker mode active (focus on a candidate row)
|
||
# Free-text mode in the ask card (_picker_mode=None) keeps the
|
||
# cursor visible so the user can see where their typing lands.
|
||
always_hide_cursor=Condition(
|
||
lambda: self._menu_active
|
||
or (self._asking is not None and self._picker_mode is not None)
|
||
),
|
||
height=self._get_live_height,
|
||
dont_extend_height=True,
|
||
)
|
||
layout = Layout(root, focused_element=control)
|
||
|
||
def _before_render(app: Application) -> None:
|
||
ts = self._ptk_size()
|
||
size_changed = last_size[0] != ts
|
||
if size_changed:
|
||
last_size[0] = ts
|
||
with self._lk:
|
||
self._resized = False
|
||
self._repaint_screen()
|
||
# Rebuild the live-region cache every frame so the height query
|
||
# (PTK calls preferred_height before the text getter) and the text
|
||
# getter see exactly the same content — even when streaming
|
||
# mutates state between renders without a size change.
|
||
self._build_live_lines()
|
||
|
||
async def _maintenance_loop() -> None:
|
||
while True:
|
||
await asyncio.sleep(0.03)
|
||
dirty = False
|
||
if self._running and self._asking is None:
|
||
# spinner / elapsed text is time-based
|
||
dirty = True
|
||
if 0 < time.time() - self._cc_t < 1.1:
|
||
# Ctrl+C armed: keep re-rendering so the status line
|
||
# restores itself once the 1s window lapses (the extra
|
||
# 0.1s margin guarantees one render past expiry).
|
||
dirty = True
|
||
if self._pending:
|
||
with self._lk:
|
||
self._sync_pending_from_bridge()
|
||
dirty = True
|
||
if self._epend or (self._rb and not self._bp):
|
||
with self._lk:
|
||
self._flush_esc()
|
||
dirty = True
|
||
with self._sbq_lk:
|
||
has_q = bool(self._sbq)
|
||
if (has_q or self._pending_repaint) and self._ptk_app is not None:
|
||
self._ptk_app.create_background_task(self._drain_async())
|
||
if dirty and self._ptk_app is not None:
|
||
self._ptk_app.invalidate()
|
||
|
||
def _pre_run() -> None:
|
||
self._ptk_loop = asyncio.get_event_loop()
|
||
app = self._ptk_app
|
||
assert app is not None
|
||
app.create_background_task(_maintenance_loop())
|
||
_enable_windows_vt_mode()
|
||
_enter_utf8_charset()
|
||
_w('\x1b[?1007l') # disable alt-scroll so wheel scrolls scrollback
|
||
_w('\x1b[?2004h') # enable bracketed paste
|
||
_w('\x1b[>4;1m') # ask supporting terminals to distinguish Shift+Enter
|
||
self.commit(Block('banner', ''))
|
||
|
||
app = Application(
|
||
layout=layout,
|
||
key_bindings=kb,
|
||
full_screen=False,
|
||
erase_when_done=False,
|
||
# No refresh_interval: each auto-refresh re-positions PTK's cursor
|
||
# to the live region, which makes the terminal auto-scroll to the
|
||
# bottom — destroying the user's scrollback position. Maintenance
|
||
# loop invalidates explicitly only when running (spinner anim).
|
||
terminal_size_polling_interval=0.2,
|
||
mouse_support=False,
|
||
before_render=_before_render,
|
||
output=create_output(stdout=so),
|
||
)
|
||
self._ptk_app = app
|
||
|
||
try:
|
||
app.run(pre_run=_pre_run, handle_sigint=False)
|
||
finally:
|
||
_w('\x1b[>4;0m')
|
||
_w('\x1b[?2004l')
|
||
_w('\x1b[?1007h')
|
||
self._ptk_app = None
|
||
self._ptk_loop = None
|
||
sys.stdout, sys.stderr = so, se
|
||
logf.close()
|
||
try:
|
||
os.write(1, b'\n')
|
||
except Exception:
|
||
pass
|
||
|
||
def run(self) -> None:
|
||
_set_term_title(self._term_title())
|
||
try:
|
||
return self._run_prompt_toolkit()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
_set_term_title('GenericAgent')
|
||
|
||
|
||
# sb.py's original `main()` and __main__ guard intentionally dropped — the
|
||
# unified entry point lives below (combines __init__.py + __main__.py).
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# entry: equivalent of frontends/tui/__init__.py + __main__.py
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _ensure_deps():
|
||
try:
|
||
import rich # noqa: F401
|
||
import prompt_toolkit # noqa: F401
|
||
except ImportError as e:
|
||
import sys
|
||
print(_t('err.dep_missing', name=e.name))
|
||
print(_t('err.dep_install'))
|
||
sys.exit(2)
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
_ensure_deps()
|
||
install_cjk_wrap()
|
||
if not sys.stdin.isatty():
|
||
print(_t('err.no_tty'))
|
||
return 1
|
||
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
|
||
try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
|
||
except Exception: pass
|
||
try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
|
||
except Exception: pass
|
||
at_complete.get_index(os.path.join(_ROOT, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
|
||
SB().run()
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|