Files
lsdefine--genericagent/frontends/tui_v3.py
T
2026-07-13 13:04:19 +08:00

6051 lines
286 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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': 'cronsche_tasks/*.json 共 {n} 个任务 · 已激活(reflect/scheduler.py 在运行)',
'scheduler.cron.inactive': 'cronsche_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/tempfile_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=Falsereset 绑定绝不写 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())