diff --git a/AGENTS.md b/AGENTS.md
index fb7e88ef..2c01638e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,7 +93,7 @@ attachment and optional App-control MCP tools are separate user choices.
transport, never the caller's Origin. Uvicorn trusts forwarded transport
metadata only from loopback Caddy. Never put tokens in URLs or protocol
message bodies; logging redacts token/password fields.
-- **Protocol version gate**: current wire protocol v66 is declared by
+- **Protocol version gate**: current wire protocol v67 is declared by
`PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`.
`deserialize` hard-rejects a version mismatch, and
`_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 007371c1..1c87142e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,10 @@
Claude-branded observations keep working, including Vertex/enterprise forms
such as `claude-sonnet-4-5@20250929`. Official `claude-*` selections and
native takeover are unchanged.
+- Add atomic server-owned queue reordering (protocol v67), with configurable
+ TUI editing, cancellation and ordering controls. Reject stale queue snapshots
+ and changes while a message is starting. Deploy Relay, Web, Wrapper and TUI
+ together; protocol v66 clients cannot connect to protocol v67 services.
- Backport shared improvements from the DSH branch without adding a third
engine (protocol v66): rounded Claude/Codex Goal dialogs with native save
confirmation and mobile keyboard recovery; directory links open `/open`, and
diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md
index 6362220b..e14492c4 100644
--- a/CHANGELOG_zh.md
+++ b/CHANGELOG_zh.md
@@ -10,6 +10,9 @@
不能覆盖显式选择,网关的原始上游名称不会冒充用户选定的模型。带 Claude 品牌
的观测值依旧可用,包括 Vertex/企业版形式如 `claude-sonnet-4-5@20250929`。
官方 `claude-*` 选择与原生接管行为保持不变。
+- 新增服务端队列原子排序(protocol v67),TUI 编辑、取消及排序操作支持配置
+ 快捷键。拒绝基于过期队列的排序和消息启动期间的调整。Relay、Web、Wrapper
+ 与 TUI 必须一起升级;protocol v66 客户端不能连接 protocol v67 服务。
- 整合 DSH 分支中的通用改进,保留 Claude/Codex 双引擎(protocol v66):圆角
Goal 小窗等待原生保存确认,恢复手机键盘收起后的布局;目录链接接入 `/open`,
XLSX 可预览已保存的单元格、切换工作表并下载原文件。
diff --git a/CLAUDE.md b/CLAUDE.md
index a6f3c61a..b53034c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,7 +88,7 @@ a separate choice; sharing alone does not authorize them.
`useLayoutEffect` is deliberately dependency-free — late virtualizer/image
measurements settle without a React render, and constraining it to its read
set reintroduces a full-viewport jump on touch release.
-- **Protocol version gate**: current wire protocol v66 is declared by
+- **Protocol version gate**: current wire protocol v67 is declared by
`PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`.
`deserialize` hard-rejects a version mismatch, and
`_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all
diff --git a/README.md b/README.md
index e04c45ec..d6ba458c 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · PWA
-**产品版本:v3.0.0** · Wire protocol v66
+**产品版本:v3.0.0** · Wire protocol v67
[English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) ·
[安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)
@@ -267,6 +267,17 @@ Code 默认权限较宽;Work 的私有目录策略不能替代独立系统用
| [Codex App 工具](docs/codex-app-tools.md) | 可选 App-control MCP |
| [更新记录](CHANGELOG_zh.md) | 版本变化与迁移记录 |
+## 终端工作台(预览)
+
+不习惯网页时,可以使用内置的 Python/Textual 全屏 TUI。它与网页共享
+relay/wrapper 会话,提供会话标签、Space e 目录树及搜索、Vim 风格阅读与输入
+模式、消息跳转,以及不改变阅读位置的复制和引用。
+支持思考/工具/进程详情、运行计时、Goal/Plan、
+用量、队列编辑及共享会话控制;支持 Markdown 预览和兼容终端的图片显示,
+其他图形内容保留明确的网页入口。
+安装、快捷键及终端边界见
+[终端工作台中文指南](docs/tui_zh.md)。原逐行客户端可用 `--line-mode` 启动。
+
## 开发
前端使用 Node 24;Python 依赖按仓库锁文件安装。常用零模型测试:
diff --git a/README_en.md b/README_en.md
index f582d542..60e66b77 100644
--- a/README_en.md
+++ b/README_en.md
@@ -4,7 +4,7 @@
Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · PWA
-**Product version: v3.0.0** · Wire protocol v66
+**Product version: v3.0.0** · Wire protocol v67
[中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) ·
[Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md)
@@ -306,6 +306,18 @@ policy is not a replacement for separate OS users, containers or virtual machine
| [Codex App tools](docs/codex-app-tools.md) | Optional App-control MCP |
| [Changelog](CHANGELOG.md) | Version changes and migrations |
+## Terminal workspace (preview)
+
+The built-in Python/Textual TUI shares relay/wrapper sessions with Web. It
+provides session tabs, a Space e directory tree with search, Vim-style reading
+and input modes, message jumps, and copy/quote actions that retain the reading
+position. Thinking/tool/process
+details, elapsed time, Goal/Plan, usage, queue editing and shared session
+controls are available. Markdown and compatible-terminal image previews are
+built in; other graphical content has an explicit Web handoff.
+See the [terminal workspace guide](docs/tui.md) for installation, keys and
+terminal boundaries. Use `--line-mode` for the original line-oriented client.
+
## Development
Use Node 24 for the web client and the repository's Python dependency locks.
diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py
index 2008f741..60e0cfdf 100644
--- a/cc_remote/protocol.py
+++ b/cc_remote/protocol.py
@@ -28,7 +28,7 @@
MAX_SINGLE_ATTACHMENT_BYTES,
)
-PROTOCOL_VERSION = 66
+PROTOCOL_VERSION = 67
# Codex Desktop renders a 53-week daily token-activity calendar. Keep the wire
# payload to that same bounded window so an account response can never turn a
@@ -376,6 +376,25 @@ class CancelQueuedQuery(_Command):
client_id: WireId
+class ReorderQueuedQueries(_Command):
+ """Compare-and-swap the server queue order without resubmitting prompts."""
+
+ type: Literal["reorder_queued_queries"] = "reorder_queued_queries"
+ sid: WireId
+ cmd_id: WireId
+ client_id: WireId
+ expected: list[WireId] = Field(max_length=MAX_QUERY_QUEUE_ITEMS)
+ order: list[WireId] = Field(max_length=MAX_QUERY_QUEUE_ITEMS)
+
+ @model_validator(mode="after")
+ def permutation(self):
+ if (len(set(self.expected)) != len(self.expected)
+ or len(set(self.order)) != len(self.order)
+ or set(self.expected) != set(self.order)):
+ raise ValueError("queue order must be a unique permutation")
+ return self
+
+
class GetQueuedQuery(_Command):
"""Read one wrapper-owned query without putting its payload in the ring."""
@@ -436,7 +455,7 @@ class QueuedQueryInfo(BaseModel):
class QueryQueueState(_Base):
- """Authoritative per-session wrapper queue, newest replacement first."""
+ """Authoritative per-session wrapper queue in execution order."""
type: Literal["query_queue"] = "query_queue"
items: list[QueuedQueryInfo] = Field(
@@ -2806,6 +2825,7 @@ def unread_requires_identity(self):
AnyMessage = Union[
+ ReorderQueuedQueries,
BrowseFiles, FilesListed, SetCodexContext, CodexContext,
GetTurnFileChanges, TurnFileChangesPage,
Hello, Query, CancelQueuedQuery, GetQueuedQuery, QueuedQueryDetail, UpdateQueuedQuery, QueuedQueryUpdated, QueryQueueState, Steer, Interrupt, Takeover, TakeoverState, SessionControl, SetModel, SetEffort, SetAutoCompact, SetServiceTier, SetCollaborationMode, SetPerm, GetPermissionProfiles, SetPermissionProfile, SetWebSearch, Fast, CollaborationMode, OpenBtw, CloseBtw, SyncBtw, BtwOpened, BtwSync, BtwClosed, GetContext, GetStatus, ConsumeRateLimitResetCredit, GetDiff, GetFilePreview, SaveMarkdown, GetPreviewAsset, AuthorizePreview, GetHistory, GetTurnDetail, GetAgentDetail, GetHistoryImage, GetModels, GetEngineCapabilities, ManageEnginePlugin, ManageEngineSkill, ManageEngineHook, ListSessions, SwitchSession, NewSession, DeleteWorkSession, DeleteSession, RollbackSession, RollbackResult, CompactSession, StartReview, GetWorkDashboard, CreateWorkProject, DeleteWorkProject, AddWorkSource, DeleteWorkSource, CreateWorkPlugin, DeleteWorkPlugin, CreateWorkSchedule, DeleteWorkSchedule, GetWorkArtifacts, ListDir, Ping, Pong, CommandAck,
@@ -2842,6 +2862,7 @@ def unread_requires_identity(self):
"hello": Hello,
"query": Query,
"cancel_queued_query": CancelQueuedQuery,
+ "reorder_queued_queries": ReorderQueuedQueries,
"get_queued_query": GetQueuedQuery,
"queued_query_detail": QueuedQueryDetail,
"update_queued_query": UpdateQueuedQuery,
diff --git a/cc_remote/tui.py b/cc_remote/tui.py
index 7f6a7456..b72fbec2 100644
--- a/cc_remote/tui.py
+++ b/cc_remote/tui.py
@@ -11,9 +11,9 @@
terminal. That's the trade for true two-way sync (see the design discussion).
Env:
- RELAY_URL default ws://127.0.0.1:8765/ws
+ RELAY_URL defaults to the active same-user relay, then loopback:8765
LOGIN_USERNAME required only when relay multi-user login is enabled
- LOGIN_PASSWORD optional; if omitted the TUI prompts without echo
+ LOGIN_PASSWORD optional locally; remote connections prompt without echo
PUBLIC_ORIGIN optional WebSocket Origin override (normally derived from URL)
ENGINE claude | codex (which store to list / which backend for /new)
ALLOW_INSECURE_HTTP 1/true to allow a non-loopback RELAY_URL to stay ws://
@@ -49,10 +49,11 @@
import uuid
from http.cookies import SimpleCookie
from typing import Optional
-from urllib.parse import urlsplit, urlunsplit
+from urllib.parse import urlencode, urlsplit, urlunsplit
from urllib.request import Request, urlopen
import cc_remote.config # noqa: F401 (import side-effect: loads .env)
+from cc_remote.config import valid_machine_id
from cc_remote.log import logger, setup
from cc_remote.protocol import (
Hello, Query, Interrupt, SetModel, SetEffort, GetContext,
@@ -60,6 +61,9 @@
Ping, is_reliable_command, serialize,
)
from cc_remote.relay.auth import SESSION_COOKIE_NAME
+from cc_remote.tui_local import (
+ discover_local_relay, is_loopback_url, same_local_endpoint,
+)
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, InvalidStatus
@@ -70,7 +74,7 @@
LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "")
LOGIN_USERNAME = os.environ.get("LOGIN_USERNAME", "")
PUBLIC_ORIGIN = os.environ.get("PUBLIC_ORIGIN", "")
-ENGINE = os.environ.get("ENGINE", "claude")
+ENGINE = os.environ.get("ENGINE", "codex")
# Same opt-in escape hatch as the relay/wrapper: lets RELAY_URL stay ws://
# against a non-loopback host instead of requiring wss://. Off by default.
ALLOW_INSECURE_HTTP = os.environ.get("ALLOW_INSECURE_HTTP", "").strip().lower() in {
@@ -80,6 +84,13 @@
TUI_OUTBOX_BYTES = 32 * 1024 * 1024
TUI_MAX_FRAME_BYTES = 16 * 1024 * 1024
MAX_REPLAY_SESSIONS = 128
+# Top-level modules supplied by requirements-tui.txt and its dependencies.
+# Do not hide missing cc_remote modules or broken imports within a package.
+OPTIONAL_TUI_MODULES = frozenset({
+ "textual", "textual_image", "rich", "markdown_it", "mdit_py_plugins",
+ "linkify_it", "uc_micro", "mdurl", "pygments", "platformdirs",
+ "typing_extensions", "PIL",
+})
# Terminal control input is a security boundary: model/tool/transcript text is
# untrusted even though the relay itself is authenticated. Keep ordinary LF so
@@ -94,7 +105,7 @@
})
_HISTORY_NARRATIVE_TYPES = frozenset({
"user_msg", "turn_steered", "assistant_msg_start", "delta", "tool_use",
- "tool_result", "assistant_msg_end",
+ "tool_result", "tool_delta", "assistant_msg_end",
})
@@ -199,10 +210,15 @@ def _login_cookie(ws_url: str, password: str, username: str = "") -> str:
class Tui:
def __init__(self, url: str, password: str, origin: str, engine: str,
- want_sid: Optional[str], username: str = ""):
+ want_sid: Optional[str], username: str = "",
+ machine_id: str = "default"):
self.url = _validate_relay_url(url)
+ if not valid_machine_id(machine_id):
+ raise ValueError("Invalid relay machine id")
self.username = username
+ self.machine_id = machine_id
self.password = password
+ self.local_login = False
self.origin = _ws_origin(url, origin)
self.cookie = ""
self.engine = engine
@@ -268,11 +284,6 @@ def _write(self, s: str) -> None:
# ---- lifecycle ----
async def run(self) -> None:
- if not self.password:
- self.password = await asyncio.to_thread(getpass.getpass, "Access password: ")
- if not self.password:
- print(RED("No password provided — cannot authenticate."))
- return
try:
await self._authenticate()
except Exception as e: # noqa: BLE001 — concise login failure for an interactive client
@@ -288,6 +299,25 @@ async def run(self) -> None:
self._stdin_task.cancel()
async def _authenticate(self) -> None:
+ if self.local_login or (not self.password and is_loopback_url(self.url)):
+ local = await asyncio.to_thread(discover_local_relay)
+ if (local is not None and same_local_endpoint(self.url, local.url)
+ and (not self.username or self.username == local.username)):
+ self.password = local.password
+ self.username = local.username
+ self.local_login = True
+ self.url = local.url
+ self.origin = local.origin
+ else:
+ raise ValueError(
+ "Local relay authentication is unavailable. Start the same-user "
+ "cc-remote-relay.service or configure LOGIN_PASSWORD explicitly."
+ )
+ if not self.password:
+ self.password = await asyncio.to_thread(
+ getpass.getpass, "Remote relay password: ")
+ if not self.password:
+ raise ValueError("No password provided")
self.cookie = await asyncio.to_thread(
_login_cookie, self.url, self.password, self.username)
@@ -308,7 +338,10 @@ async def _connection_loop(self) -> None:
auth_retry_used = False
while not self._quitting:
try:
- async with connect(self.url, additional_headers={"Cookie": self.cookie},
+ socket_url = self.url + "?" + urlencode(
+ {"machine": self.machine_id}
+ )
+ async with connect(socket_url, additional_headers={"Cookie": self.cookie},
origin=self.origin,
max_size=32 * 1024 * 1024, close_timeout=3, open_timeout=30) as ws:
self.ws = ws
@@ -355,9 +388,16 @@ async def _session(self, ws) -> None:
recv = asyncio.create_task(self._receiver(ws))
cmd = asyncio.create_task(self._cmd_consumer(ws))
hb = asyncio.create_task(self._heartbeat(ws))
- _, pending = await asyncio.wait({recv, cmd, hb}, return_when=asyncio.FIRST_COMPLETED)
- for t in pending:
- t.cancel()
+ tasks = {recv, cmd, hb}
+ try:
+ done, _ = await asyncio.wait(
+ tasks, return_when=asyncio.FIRST_COMPLETED)
+ for task in done:
+ task.result()
+ finally:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
async def _heartbeat(self, ws) -> None:
# Same half-open guard as the web client. If the WS silently dies (dead TCP,
@@ -473,6 +513,7 @@ async def _recovery_preamble(self) -> None:
cursors, generations = self._replay_state()
if not await self._send(Hello(
role="client", client_id=self.client_id,
+ machine_id=self.machine_id,
cursors=cursors or None, generations=generations or None)):
return
queued: list[tuple[str, str, Optional[str]]] = []
@@ -501,7 +542,7 @@ async def _recovery_preamble(self) -> None:
engine = self.session_engines.get(
sid, self.attached_engine if sid == self.attached_sid else "claude")
if not await self._send_raw(serialize(SwitchSession(
- session_id=sid, engine=engine))):
+ session_id=sid, engine=engine, space=self._session_space(sid)))):
return
for cmd_id, raw, command_sid in queued:
if command_sid != sid:
@@ -570,6 +611,12 @@ async def _send(self, msg) -> bool:
# ---- commands ----
+ def _session_space(self, sid: str) -> str:
+ return "code"
+
+ def _history_backed(self, sid: str) -> bool:
+ return True
+
async def _attach(self, sid: str, engine: Optional[str] = None) -> None:
self._pending_new_request = None
previous = self.attached_sid
@@ -581,7 +628,7 @@ async def _attach(self, sid: str, engine: Optional[str] = None) -> None:
# switch_session makes it resident (spawns/resumes) so queries land; then
# pull its history from the transcript like the web client does.
if not await self._send(SwitchSession(
- session_id=sid, engine=self.attached_engine)):
+ session_id=sid, engine=self.attached_engine, space=self._session_space(sid))):
self.attached_sid = previous
self.attached_engine = previous_engine
return
@@ -758,6 +805,7 @@ def _handle(self, d: dict) -> None:
self._touch_replay(sid)
refreshing_replay = (
isinstance(sid, str) and t == "replay_start"
+ and self._history_backed(sid)
and bool(d.get("rebuild") or d.get("truncated")
or generation_changed
or sid in self._history_refresh_after_replay)
@@ -781,10 +829,12 @@ def _handle(self, d: dict) -> None:
self._rebuilding_sessions.discard(sid)
if t == "replay_end" and isinstance(sid, str) and isinstance(d.get("to_seq"), int):
replay_to_seq = d["to_seq"]
- if d.get("truncated") or sid in self._history_refresh_after_replay:
+ if (self._history_backed(sid)
+ and (d.get("truncated") or sid in self._history_refresh_after_replay)):
self._history_refresh_after_replay.discard(sid)
self._history_refresh_now.add(sid)
elif (generation_changed and self.attached_sid
+ and self._history_backed(self.attached_sid)
and t != "replay_start"):
# Snapshot or wrapper_reconnected can be the first proof of a fresh
# process when the target was not resident during Hello replay.
@@ -820,6 +870,7 @@ def _handle(self, d: dict) -> None:
self._rebuilding_sessions.discard(sid)
self._history_replay_suppressed.discard(sid)
+ self._on_event(d)
if t == "session_list":
self._render_sessions(d.get("sessions") or [])
elif t == "history":
@@ -981,6 +1032,9 @@ def _handle(self, d: dict) -> None:
self._line(GREEN("[wrapper back]"))
# snapshot / dir_list / diff_report / replay_* / pong: quietly ignored
+ def _on_event(self, event: dict) -> None:
+ """Projection hook after replay deduplication, before line rendering."""
+
def _render_sessions(self, sessions: list[dict]) -> None:
# hide archived; keep order (wrapper sorts by recency)
self.sessions = [s for s in sessions if s.get("tag") != "archived"]
@@ -1089,17 +1143,89 @@ def _render_context(self, d: dict) -> None:
def main() -> None:
- want_sid = sys.argv[1] if len(sys.argv) > 1 else None
+ import argparse
+
+ parser = argparse.ArgumentParser(description="cc-remote terminal client")
+ parser.add_argument("session_id", nargs="?")
+ parser.add_argument("--line-mode", action="store_true")
+ parser.add_argument("--demo", action="store_true",
+ help="offline full-screen interaction preview")
+ parser.add_argument("--url", default=os.environ.get("RELAY_URL"))
+ parser.add_argument("--engine", choices=("claude", "codex"))
+ parser.add_argument("--machine", default="default")
+ parser.add_argument("--space", choices=("code", "work"))
+ parser.add_argument("--config", help="full-screen TUI shortcut TOML file")
+ parser.add_argument("--list-keys", nargs="?", const="", metavar="SEARCH",
+ help="print effective shortcut index without connecting")
+ parser.add_argument("--json", action="store_true",
+ help="emit --list-keys as machine-readable JSON")
+ args = parser.parse_args()
+ if args.demo and args.line_mode:
+ parser.error("--demo requires the full-screen workspace")
try:
- tui = Tui(
- RELAY_URL,
- LOGIN_PASSWORD,
- PUBLIC_ORIGIN,
- ENGINE if ENGINE in ("claude", "codex") else "claude",
- want_sid,
- username=LOGIN_USERNAME,
+ if args.list_keys is not None:
+ from cc_remote.tui_keys import load_keys
+
+ keys = load_keys(args.config)
+ rows = keys.index(args.list_keys)
+ if args.json:
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
+ else:
+ for row in rows:
+ print(f"{row['id']:32} {row['display']:24} "
+ f"{row['description']} ({row['scope']})")
+ return
+ if args.json:
+ parser.error("--json requires --list-keys")
+ engine = args.engine or os.environ.get("ENGINE") or ENGINE
+ if engine not in {"codex", "claude"}:
+ parser.error("ENGINE must be 'codex' or 'claude'; or pass --engine")
+ url = args.url
+ password, origin, username = LOGIN_PASSWORD, PUBLIC_ORIGIN, LOGIN_USERNAME
+ local_login = False
+ if not args.demo and (url is None or is_loopback_url(url)):
+ local = discover_local_relay()
+ if local is not None and (url is None or same_local_endpoint(url, local.url)):
+ url = local.url
+ origin = local.origin
+ if not password and not username:
+ password, username = local.password, local.username
+ local_login = True
+ url = url or RELAY_URL
+ if args.line_mode:
+ client_type = Tui
+ if args.space not in (None, "code") or args.config:
+ parser.error("--space/--config require the full-screen workspace")
+ else:
+ try:
+ from cc_remote.tui_app import WorkspaceClient, run_workspace
+ except ModuleNotFoundError as exc:
+ if exc.name not in OPTIONAL_TUI_MODULES:
+ raise
+ parser.exit(1, "Install requirements-tui.txt, or use --line-mode.\n")
+ client_type = WorkspaceClient
+ tui = client_type(
+ url,
+ password,
+ origin,
+ engine,
+ args.session_id,
+ username=username,
+ machine_id=args.machine,
)
- asyncio.run(tui.run())
+ tui.local_login = local_login
+ if args.line_mode:
+ asyncio.run(tui.run())
+ else:
+ from cc_remote.tui_keys import load_keys
+
+ tui.keys = load_keys(args.config)
+ tui.space = args.space or "code"
+ tui.explicit_engine = (
+ args.engine is not None or bool(os.environ.get("ENGINE"))
+ )
+ tui.explicit_space = args.space is not None
+ run_workspace(tui, demo=args.demo)
except ValueError as exc:
print(RED(f"Invalid configuration: {exc}"))
except KeyboardInterrupt:
diff --git a/cc_remote/tui_actions.py b/cc_remote/tui_actions.py
new file mode 100644
index 00000000..71c74b67
--- /dev/null
+++ b/cc_remote/tui_actions.py
@@ -0,0 +1,188 @@
+"""Protocol-backed terminal actions; never call an engine directly."""
+
+from __future__ import annotations
+
+import json
+import uuid
+
+from cc_remote import protocol as p
+
+# Explicit surface inventory. Wire additions do not silently become UI actions.
+ACTION_CLASSES = (
+ p.ListSessions,
+ p.NewSession,
+ p.DeleteSession,
+ p.RenameSession,
+ p.ArchiveSession,
+ p.PinSession,
+ p.ForkSession,
+ p.ForkSessionWorktree,
+ p.MigrateSession,
+ p.RollbackSession,
+ p.CompactSession,
+ p.StartReview,
+ p.Interrupt,
+ p.Takeover,
+ p.SetModel,
+ p.SetEffort,
+ p.SetAutoCompact,
+ p.SetCodexContext,
+ p.SetServiceTier,
+ p.SetCollaborationMode,
+ p.SetPerm,
+ p.SetPermissionProfile,
+ p.SetWebSearch,
+ p.GetModels,
+ p.GetPermissionProfiles,
+ p.GetEngineCapabilities,
+ p.ManageEnginePlugin,
+ p.ManageEngineSkill,
+ p.ManageEngineHook,
+ p.GetContext,
+ p.GetStatus,
+ p.ConsumeRateLimitResetCredit,
+ p.GetGoal,
+ p.SetGoal,
+ p.ClearGoal,
+ p.DismissGoal,
+ p.AcknowledgeCompletion,
+ p.GetQueuedQuery,
+ p.UpdateQueuedQuery,
+ p.CancelQueuedQuery,
+ p.ReorderQueuedQueries,
+ p.OpenBtw,
+ p.CloseBtw,
+ p.GetDiff,
+ p.GetTurnFileChanges,
+ p.BrowseFiles,
+ p.GetFilePreview,
+ p.SaveMarkdown,
+ p.GetPreviewAsset,
+ p.AuthorizePreview,
+ p.GetAgentDetail,
+ p.GetHistoryImage,
+ p.ListDir,
+ p.GetWorkDashboard,
+ p.GetWorkArtifacts,
+ p.CreateWorkProject,
+ p.DeleteWorkProject,
+ p.AddWorkSource,
+ p.DeleteWorkSource,
+ p.CreateWorkPlugin,
+ p.DeleteWorkPlugin,
+ p.CreateWorkSchedule,
+ p.DeleteWorkSchedule,
+ p.DeleteWorkSession,
+)
+ACTIONS = {c.model_fields["type"].default: c for c in ACTION_CLASSES}
+HIDDEN = set(p._Command.model_fields) | {"type", "request_id"}
+
+
+def is_read(name: str) -> bool:
+ return name.startswith(("get_", "list_")) or name == "browse_files"
+
+
+def defaults(
+ name: str, sid: str | None, engine: str, row: dict, presentation
+) -> dict:
+ result = {}
+ for key, f in ACTIONS[name].model_fields.items():
+ if key in HIDDEN and not (name == "authorize_preview" and key == "request_id"):
+ continue
+ if name == "authorize_preview" and key in {"request_id", "authorization_id"}:
+ result[key] = presentation.reports.get(
+ "preview_authorization_required", {}
+ ).get(key, "")
+ elif key == "session_id":
+ result[key] = sid or ""
+ elif key == "engine":
+ # A live diff must not accidentally select the archive API, which
+ # requires engine, turn and revision together.
+ result[key] = None if name == "get_diff" else engine
+ elif key == "space":
+ result[key] = row.get("space", "code")
+ elif key in {"claude_profile_id", "codex_profile_id"}:
+ result[key] = row.get(key)
+ elif key in {"last_turn_id", "checkpoint_id"}:
+ selected = presentation.turns.get(
+ presentation.selected_turn or presentation.active
+ )
+ result[key] = (
+ getattr(
+ selected,
+ "fork_id" if key == "last_turn_id" else "checkpoint_id",
+ None,
+ )
+ if key != "checkpoint_id" or engine == "claude"
+ else None
+ )
+ elif key in {"goal_id", "completion_id"}:
+ result[key] = (
+ presentation.goal_id
+ if key == "goal_id"
+ else presentation.completion.get("completion_id")
+ )
+ elif name == "set_goal" and key in {
+ "objective",
+ "status",
+ "token_budget",
+ }:
+ result[key] = (presentation.goal or {}).get(
+ "tokenBudget" if key == "token_budget" else key
+ )
+ elif key == "cwd":
+ result[key] = row.get("cwd")
+ elif name == "set_codex_context" and key == "max_context_tokens":
+ result[key] = presentation.settings.get("codex_context", {}).get(
+ "max_context_tokens"
+ )
+ elif not f.is_required():
+ result[key] = f.get_default(call_default_factory=True)
+ else:
+ result[key] = ""
+ return result
+
+
+def build_action(name: str, payload: str, sid: str | None, client_id: str):
+ if name not in ACTIONS:
+ raise ValueError("Unknown action")
+ if len(payload) > 2 * 1024 * 1024:
+ raise ValueError("Action payload is too large")
+ values = json.loads(payload)
+ if not isinstance(values, dict):
+ raise ValueError("Parameters must be a JSON object")
+ hidden = HIDDEN - ({"request_id"} if name == "authorize_preview" else set())
+ if set(values) & hidden:
+ raise ValueError("Transport/routing fields cannot be edited")
+ if "session_id" in values and values["session_id"] != sid:
+ raise ValueError(
+ "Session target is pinned; select another session first"
+ )
+ command_id = uuid.uuid4().hex
+ values.update(sid=sid, client_id=client_id, cmd_id=command_id)
+ cls = ACTIONS[name]
+ sessionless = {
+ "new_session",
+ "list_sessions",
+ "list_dir",
+ "get_models",
+ "get_engine_capabilities",
+ "manage_engine_plugin",
+ "manage_engine_skill",
+ "manage_engine_hook",
+ "get_work_dashboard",
+ "create_work_project",
+ "delete_work_project",
+ "add_work_source",
+ "delete_work_source",
+ "create_work_plugin",
+ "delete_work_plugin",
+ "create_work_schedule",
+ "delete_work_schedule",
+ }
+ if not sid and name not in sessionless:
+ raise ValueError("Select a target session first")
+ if "request_id" in cls.model_fields and name != "authorize_preview":
+ values["request_id"] = command_id
+ # Pydantic remains the one shared source of command validation.
+ return cls(**values)
diff --git a/cc_remote/tui_activity.py b/cc_remote/tui_activity.py
new file mode 100644
index 00000000..3a81151a
--- /dev/null
+++ b/cc_remote/tui_activity.py
@@ -0,0 +1,225 @@
+"""Two-level activity projection; original narrative blocks remain intact."""
+
+from collections import Counter
+from dataclasses import replace
+
+
+def activity(block):
+ return not block.data.get("questions") and (
+ block.role in {"tool", "process"}
+ or block.channel in {"commentary", "thinking"}
+ )
+
+
+def tool_item(block):
+ return block.role in {"tool", "process"} or block.channel == "thinking"
+
+
+def summary(items):
+ tools, other, files = Counter(), 0, set()
+ running = failed = False
+ for block in items:
+ data = block.data
+ kind = data.get("kind") or data.get("processKind")
+ name = data.get("tool")
+ if block.role == "tool" or name or kind in {"command", "file_change"}:
+ tools[str(name or data.get("title") or kind or "tool")[:80]] += 1
+ else:
+ other += 1
+ running |= data.get("status") in {"running", "pending", "in_progress"}
+ failed |= bool(data.get("is_error")) or data.get("status") in {
+ "failed",
+ "error",
+ "interrupted",
+ "declined",
+ "cancelled",
+ }
+ inputs = data.get("input") or {}
+ if not isinstance(inputs, dict):
+ continue
+ if kind == "file_change" or str(name).lower() in {
+ "edit",
+ "write",
+ "multiedit",
+ "apply_patch",
+ "notebookedit",
+ }:
+ paths = inputs.get("file_paths")
+ for path in paths if isinstance(paths, list) else []:
+ if isinstance(path, str):
+ files.add(path)
+ changes = inputs.get("changes")
+ for change in changes if isinstance(changes, list) else []:
+ if isinstance(change, dict) and isinstance(
+ change.get("path"), str
+ ):
+ files.add(change["path"])
+ path = inputs.get("file_path") or inputs.get("path")
+ if isinstance(path, str):
+ files.add(path)
+ parts = [f"{sum(tools.values())} 个工具调用"] if tools else []
+ if files:
+ parts.append(f"修改 {len(files)} 个文件")
+ if other:
+ parts.append(f"{other} 项活动")
+ if tools:
+ parts.extend(
+ f"{name} ×{count}" for name, count in list(tools.items())[:8]
+ )
+ if len(tools) > 8:
+ parts.append(f"另 {len(tools) - 8} 类")
+ return " · ".join(parts), (
+ "running" if running else "failed" if failed else "succeeded"
+ )
+
+
+def project(view):
+ from cc_remote.tui_state import Block
+
+ # Hydrated pages replace lightweight summaries, including truncated final
+ # text. Never replace user messages; newer live rows win by sequence.
+ source = list(view.blocks)
+ retained = {b.turn for b in source}
+ for tid, children in view.detail_blocks.items():
+ if tid not in retained:
+ continue
+ anchor = next(
+ (
+ i
+ for i, b in enumerate(source)
+ if b.turn == tid
+ and (b.channel == "final" or b.role == "detail")
+ ),
+ len(source),
+ )
+ # Merge against stable item IDs. A partial page containing just a tool
+ # must not move that tool before its already-visible progress message.
+ for child in reversed(children):
+ if child.role in {"user", "detail"}:
+ continue
+ existing = next(
+ (i for i, b in enumerate(source) if b.id == child.id), None
+ )
+ if existing is not None:
+ if source[existing].seq <= child.seq:
+ source[existing] = child
+ anchor = existing
+ else:
+ source.insert(anchor, child)
+
+ # Summary pages place their lazy-detail marker after the answer. Present
+ # that loader inside the activity section, before the corresponding final.
+ markers = {b.turn: b for b in source if b.role == "detail"}
+ moved = set()
+ ordered = []
+ for block in source:
+ if block.channel == "final" and block.turn in markers:
+ if block.turn not in moved:
+ ordered.append(markers[block.turn])
+ moved.add(block.turn)
+ if block.role == "detail":
+ if block.turn in moved:
+ continue
+ moved.add(block.turn)
+ ordered.append(block)
+
+ visible, parents = [], {}
+ active_outer = None
+ used_outer, used_tools = set(), set()
+ run = []
+
+ def outer_for(block):
+ tid = block.turn
+ outer = view.local_details.setdefault(
+ tid,
+ Block(
+ "detail:" + tid,
+ "detail",
+ turn=tid,
+ expanded=True,
+ data={"local": True, "nested": True},
+ ),
+ )
+ outer.data["nested"] = True
+ if tid in view.collapsed_details:
+ outer.expanded = False
+ if tid not in used_outer:
+ used_outer.add(tid)
+ visible.append(outer)
+ return outer
+
+ def flush():
+ if not run:
+ return
+ first = run[0]
+ identity = "tools:" + first.id
+ group = view.tool_groups.setdefault(
+ identity,
+ Block(identity, "tool_group", turn=first.turn),
+ )
+ group.turn = first.turn
+ used_tools.add(identity)
+ group.text, status = summary(run)
+ turn = view.presentation.turns.get(first.turn)
+ if status == "running" and turn and turn.status != "running":
+ status = turn.status
+ group.data.update(status=status, count=len(run))
+ parents[identity] = active_outer.id if active_outer else ""
+ for child in run:
+ parents[child.id] = identity
+ if not active_outer or active_outer.expanded:
+ visible.append(group)
+ if group.expanded:
+ # Opening the second layer shows contents, not a third fold.
+ visible.extend(replace(child, expanded=True) for child in run)
+ run.clear()
+
+ for block in ordered:
+ if block.role == "detail":
+ flush()
+ active_outer = outer_for(block)
+ identity = "tools:history:" + block.turn
+ parents[identity] = active_outer.id
+ if block.turn in view.detail_blocks:
+ continue
+ loader = view.tool_groups.setdefault(
+ identity,
+ Block(
+ identity,
+ "tool_group",
+ turn=block.turn,
+ text="载入历史进度和工具",
+ data={"request_detail": True},
+ ),
+ )
+ used_tools.add(identity)
+ if active_outer.expanded:
+ visible.append(loader)
+ continue
+ if not activity(block):
+ flush()
+ active_outer = None
+ visible.append(block)
+ continue
+ if active_outer is None or active_outer.turn != block.turn:
+ flush()
+ active_outer = outer_for(block)
+ if tool_item(block):
+ run.append(block)
+ else:
+ flush()
+ parents[block.id] = active_outer.id
+ if active_outer.expanded:
+ visible.append(block)
+ flush()
+ view.group_parents = parents
+ view.local_details = {
+ k: v for k, v in view.local_details.items() if k in used_outer
+ }
+ view.tool_groups = {
+ k: v for k, v in view.tool_groups.items() if k in used_tools
+ }
+ view.detail_blocks = {
+ k: v for k, v in view.detail_blocks.items() if k in retained
+ }
+ return visible + list(view.pending_messages.values())
diff --git a/cc_remote/tui_app.py b/cc_remote/tui_app.py
new file mode 100644
index 00000000..0d41c81c
--- /dev/null
+++ b/cc_remote/tui_app.py
@@ -0,0 +1,3261 @@
+"""Vim-style terminal workspace on the existing cc-remote control link."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+import re
+import uuid
+from contextlib import contextmanager
+from urllib.parse import urlsplit, urlunsplit
+
+from rich.text import Text
+from textual import events
+from textual.app import App, ComposeResult
+from textual.binding import BindingsMap
+from textual.containers import Horizontal, Vertical
+from textual.css.query import NoMatches
+from textual.geometry import Offset
+from textual.widgets import Static, TextArea
+from textual.widgets.text_area import Selection
+
+from cc_remote.protocol import (
+ AnswerQuestion,
+ GetHistory,
+ GetTurnDetail,
+ ListSessions,
+ PROTOCOL_VERSION,
+ GetGoal,
+ GetContext,
+ GetStatus,
+ GetModels,
+ GetEngineCapabilities,
+ GetPermissionProfiles,
+ ListDir,
+ NewSession,
+ AcknowledgeCompletion,
+ Steer,
+ GetQueuedQuery,
+ SyncBtw,
+ Query,
+ SwitchSession,
+ DeleteSession,
+ DeleteWorkSession,
+ Interrupt,
+)
+from cc_remote.tui import Tui, _safe_remote_text
+from cc_remote.tui_state import WorkspaceState, Block, clip
+from cc_remote.tui_questions import pending_async
+from cc_remote.tui_widgets import Composer, VimArea
+from cc_remote.tui_buffers import SessionBuffers, BufferPicker, tab_line
+from cc_remote.tui_preview import references, route_preview, detect_graphics
+from cc_remote.tui_preview_views import FileHints, FilePreviewScreen
+from cc_remote.tui_inline_images import TranscriptViewport
+from cc_remote.tui_tree import SessionExplorer, SessionTree, TreeSearch
+from cc_remote.tui_panels import (
+ ActionPicker,
+ DetailPanel,
+ QuestionDialog,
+ AsyncQuestionDialog,
+ QueuePanel,
+ SuggestionPicker,
+)
+from cc_remote.tui_actions import ACTIONS
+from cc_remote.tui_attachments import read_attachment
+from cc_remote.tui_clipboard import read_clipboard_image
+from cc_remote.tui_chrome import (
+ settings_text,
+ status_text as styled_status,
+)
+from cc_remote.attachments import validate_attachments
+from cc_remote.tui_presentation import bounded
+from cc_remote.tui_keys import KeyConfig, LAYERS
+from cc_remote.tui_navigation import SCOPES, scoped_catalog, select_session
+from cc_remote.tui_deletion import SessionDeletion
+
+
+class WorkspaceClient(Tui):
+ """Reuse the legacy client's transport/replay; replace only its projection."""
+
+ def __init__(self, *args, space: str = "code", **kwargs):
+ super().__init__(*args, **kwargs)
+ self.space = space
+ self.explicit_engine = False
+ self.explicit_space = False
+ self.keys = KeyConfig()
+ self.last_focus: dict[tuple[str, str], str] = {}
+ self.catalog_ready: set[tuple[str, str]] = set()
+ self.session_catalog_requests: dict[tuple[str, str], str] = {}
+ self.catalog_retries: set[tuple[str, str]] = set()
+ self.restore_pending = self.attached_sid is None
+ self.direct_session_pending: str | None = None
+ self.navigation_revision = 0
+ self.pending_new_space = space
+ self.workspace = WorkspaceState()
+ self.notice = "Connecting…"
+ self.catalog_dirty: set[tuple[str, str]] = set()
+ self.protocol_error = False
+ self.demo = False
+ self.panel_reads: set[str] = set()
+ self.read_tickets: dict[tuple[str, str], tuple[str, int]] = {}
+ self.goal_versions: dict[str, int] = {}
+ self.goal_read_requests: dict[str, str] = {}
+ self.queue_reads: dict[str, tuple[str, str]] = {}
+ self.queue_details: dict[str, dict] = {}
+ self.queue_updates: dict[str, tuple[str, str]] = {}
+ self.queue_update_results: dict[str, dict] = {}
+ self.capability_cache: dict[tuple, dict] = {}
+ self.capability_requests: set[tuple] = set()
+ self.catalog_reads: dict[str, dict] = {}
+ self.settings_catalogs: dict[tuple, dict] = {}
+ self.action_results: dict[str, dict] = {}
+ self.directory_cache: dict[str, tuple[float, dict]] = {}
+ self.directory_waiters: dict[str, asyncio.Future] = {}
+ self.preview_waiters: dict[
+ str, tuple[str, asyncio.Future, str | None]
+ ] = {}
+ self.buffers = SessionBuffers()
+ self.cached_history: set[str] = set()
+ self.tab_store = None
+ self.saved_workspace = False
+ self.tab_save_signature = None
+ self.deletions = SessionDeletion(self)
+
+ def restore_tabs(self, store) -> None:
+ self.tab_store = store
+ try:
+ saved = store.load()
+ except (OSError, ValueError):
+ self.notice = "Cannot read saved tabs; existing file left untouched"
+ self.tab_store = None
+ return
+ if saved is None:
+ return
+ self.saved_workspace = True
+ self.buffers.ids = saved["tabs"][:]
+ if not self.want_sid:
+ if not self.explicit_engine:
+ self.engine = saved["scope"][0]
+ if not self.explicit_space:
+ self.space = saved["scope"][1]
+ self.attached_engine = self.engine
+ if saved["active"] and self.scope == tuple(saved["scope"]):
+ self.last_focus[self.scope] = saved["active"]
+
+ def save_tabs(self) -> None:
+ if not self.tab_store or self.restore_pending:
+ return
+ signature = (tuple(self.buffers.ids), self.attached_sid, self.scope)
+ if signature == self.tab_save_signature:
+ return
+ try:
+ self.tab_store.save(self.buffers.ids, self.attached_sid, self.scope)
+ self.tab_save_signature = signature
+ except (OSError, ValueError):
+ self.notice = "Cannot save TUI tabs; check local state permissions"
+ self.tab_store = None
+
+ async def _send(self, message) -> bool:
+ if self.demo:
+ self.notice = "Offline demo: no command was sent"
+ return False
+ if isinstance(message, ListSessions):
+ message = message.model_copy(
+ update={"cmd_id": message.cmd_id or uuid.uuid4().hex}
+ )
+ scope = (message.engine, message.space)
+ previous_request = self.session_catalog_requests.get(scope)
+ # Install before transport can yield to an immediate reply, then
+ # roll back if reliable-outbox admission rejects the command.
+ self.session_catalog_requests[scope] = message.cmd_id
+ updates = {
+ name: self.workspace.rekeys[value]
+ for name in ("sid", "session_id")
+ if (value := getattr(message, name, None)) in self.workspace.rekeys
+ }
+ if updates:
+ message = message.model_copy(update=updates)
+ if isinstance(
+ message, (GetModels, GetEngineCapabilities, GetPermissionProfiles)
+ ):
+ message = message.model_copy(
+ update={"cmd_id": message.cmd_id or uuid.uuid4().hex}
+ )
+ self.catalog_reads[message.cmd_id] = message.model_dump()
+ self.catalog_reads = dict(list(self.catalog_reads.items())[-64:])
+ if (
+ isinstance(message, (GetGoal, GetStatus, GetContext))
+ and message.sid
+ ):
+ message = message.model_copy(
+ update={"cmd_id": message.cmd_id or uuid.uuid4().hex}
+ )
+ response = {
+ GetGoal: "goal_state",
+ GetStatus: "status_report",
+ GetContext: "context_report",
+ }[type(message)]
+ revision = (
+ self.goal_versions.get(message.sid, 0)
+ if isinstance(message, GetGoal)
+ else self.workspace.view(
+ message.sid
+ ).presentation.live_rate_revision
+ )
+ previous_ticket = self.read_tickets.get((message.sid, response))
+ self.read_tickets[message.sid, response] = (
+ message.cmd_id,
+ revision,
+ )
+ if isinstance(message, GetGoal):
+ self.goal_read_requests[message.cmd_id] = message.sid
+ self.goal_read_requests = dict(
+ list(self.goal_read_requests.items())[-128:]
+ )
+ if isinstance(message, GetQueuedQuery):
+ self.queue_reads[message.cmd_id] = (message.sid, message.msg_id)
+ accepted = await super()._send(message)
+ if isinstance(message, GetGoal) and message.sid and not accepted:
+ key = (message.sid, "goal_state")
+ if self.read_tickets.get(key, (None,))[0] == message.cmd_id:
+ if previous_ticket is None:
+ self.read_tickets.pop(key, None)
+ else:
+ self.read_tickets[key] = previous_ticket
+ self.goal_read_requests.pop(message.cmd_id, None)
+ if isinstance(message, ListSessions):
+ if self.session_catalog_requests.get(scope) == message.cmd_id:
+ if accepted:
+ self.catalog_retries.discard(scope)
+ else:
+ if previous_request is None:
+ self.session_catalog_requests.pop(scope, None)
+ else:
+ self.session_catalog_requests[scope] = previous_request
+ self.catalog_retries.add(scope)
+ return accepted
+
+ def _line(self, text: str) -> None:
+ # Trusted legacy color sequences are removed along with remote controls.
+ self.notice = _safe_remote_text(Text.from_ansi(text).plain)
+
+ def _write(self, text: str) -> None:
+ pass
+
+ def _nl(self) -> None:
+ pass
+
+ def _handle(self, event: dict) -> None:
+ if event.get("to") and event["to"] != self.client_id:
+ return
+ if event.get("v", PROTOCOL_VERSION) != PROTOCOL_VERSION:
+ self.protocol_error = True
+ self._quitting = True
+ self.notice = (
+ f"Protocol mismatch: client v{PROTOCOL_VERSION}, "
+ f"server v{event.get('v')}. Use a matching client checkout."
+ )
+ return
+ self.deletions.observe(event)
+ if (event.get("type") == "command_ack"
+ and event.get("client_id") == self.client_id):
+ pending = self._outbox.get(str(event.get("cmd_id", "")))
+ if pending:
+ command = json.loads(pending[0])
+ if command["type"] in {
+ "rename_session", "delete_session", "delete_work_session",
+ "archive_session", "pin_session",
+ }:
+ row = self.workspace.catalog.get(
+ command.get("session_id"), {}
+ )
+ engine = command.get("engine") or row.get("engine")
+ # Mutation replies use the mutation's request ID, not the
+ # latest ListSessions ID. Keep that stale-response fence
+ # and request a fresh catalog after handler completion.
+ self.catalog_dirty.update(
+ {(engine, command.get("space", "code"))}
+ if engine else SCOPES
+ )
+ generation = event.get("generation")
+ if (
+ generation
+ and self._wrapper_generation
+ and generation != self._wrapper_generation
+ ):
+ self.cached_history.clear()
+ if event.get("type") == "session_list":
+ scope = (event.get("engine", "claude"), event.get("space", "code"))
+ expected = self.session_catalog_requests.get(scope)
+ if (scope in self.catalog_dirty
+ or expected and event.get("request_id") != expected):
+ return # Old/replayed/other-client catalogs cannot restore focus.
+ new_focus = (
+ event.get("type") == "session_focus"
+ and event.get("request_id")
+ and event.get("request_id") == self._pending_new_request
+ )
+ notice = self.notice
+ ticket = event.get("request_id") or event.get("cmd_id")
+ pending_action = self.action_results.get(ticket)
+ if pending_action is not None and (
+ event.get("type") == "error"
+ and event.get("sid") == self.workspace.rekeys.get(
+ pending_action.get("sid"), pending_action.get("sid")
+ )
+ ):
+ pending_action["error"] = _safe_remote_text(
+ event.get("message", "Action rejected")
+ )
+ super()._handle(event)
+ if event.get("type") in {
+ "tool_use", "tool_result", "tool_delta", "process", "state",
+ "assistant_msg_start", "assistant_msg_end", "delta", "turn_end",
+ }:
+ # Legacy line-oriented output belongs in the transcript, not the
+ # persistent two-line workspace status area.
+ self.notice = notice
+ if (event.get("type") == "error" and event.get("sid")
+ and not self._for_me(event)):
+ # Keep the error in its session projection, not focused chrome.
+ self.notice = notice
+ if new_focus:
+ self.engine, self.space = (
+ self.attached_engine,
+ self.pending_new_space,
+ )
+ self.restore_pending = False
+ self.navigation_revision += 1
+ if event.get("type") == "session_rekey":
+ old, new = event.get("old_key"), event.get("session_id")
+ if old and new:
+ self.last_focus = {
+ scope: new if sid == old else sid
+ for scope, sid in self.last_focus.items()
+ }
+ self.remember_focus()
+ if (
+ event.get("type") == "ask_user"
+ and event.get("sid") in self.pending_asks
+ ):
+ self.pending_asks[event["sid"]].update(
+ {key: event.get(key, False) for key in ("secret", "allow_text")}
+ )
+
+ def _on_event(self, event: dict) -> None:
+ if route_preview(self, event):
+ return
+ kind = event.get("type")
+ sid = event.get("sid")
+ request = event.get("request_id")
+ update = self.queue_updates.get(request)
+ if update and kind in {"queued_query_updated", "error"}:
+ expected_sid = self.workspace.rekeys.get(update[0], update[0])
+ if sid == expected_sid and (
+ kind == "error" or event.get("msg_id") == update[1]
+ ):
+ self.queue_updates.pop(request, None)
+ self.queue_update_results[request] = dict(event)
+ if kind in {"dir_list", "error"}:
+ waiter = self.directory_waiters.get(event.get("request_id"))
+ if waiter and not waiter.done():
+ waiter.set_result(event)
+ read = self.catalog_reads.pop(event.get("request_id"), None)
+ if read and kind == "permission_profiles":
+ self.settings_catalogs[(kind, *self.capability_key(read))] = (
+ bounded(event)
+ )
+ if (
+ read
+ and kind in {"engine_capabilities", "permission_profiles"}
+ and read.get("sid")
+ ):
+ sid = self.workspace.rekeys.get(read["sid"], read["sid"])
+ event = {**event, "sid": sid}
+ if (
+ read
+ and kind == "error"
+ and read["type"] == "get_engine_capabilities"
+ ):
+ self.capability_requests.discard(self.capability_key(read))
+ if kind == "models":
+ # Models has no request_id/sid on the wire. Its native engine,
+ # profile and (for Claude) cwd identify the pending read surfaces.
+ for request_id, source in list(self.catalog_reads.items()):
+ if source["type"] != "get_models" or source.get(
+ "engine"
+ ) != event.get("engine"):
+ continue
+ profile = (
+ "codex_profile_id"
+ if event["engine"] == "codex"
+ else "claude_profile_id"
+ )
+ expected_profile = source.get(profile)
+ if expected_profile is None:
+ expected_profile = self.workspace.reports.get(
+ event["engine"] + "_profiles", {}
+ ).get("default_" + profile)
+ if expected_profile != event.get(profile):
+ continue
+ if event["engine"] == "claude" and source.get(
+ "cwd"
+ ) != event.get("cwd"):
+ continue
+ if source.get("sid"):
+ self.workspace.view(source["sid"]).presentation.reports[
+ "models"
+ ] = bounded(event)
+ self.settings_catalogs[(kind, *self.capability_key(source))] = (
+ bounded(event)
+ )
+ self.catalog_reads.pop(request_id)
+ self.settings_catalogs = dict(
+ list(self.settings_catalogs.items())[-64:]
+ )
+ if sid in self._history_replay_suppressed and kind in {
+ "process",
+ "turn_plan",
+ "turn_diff",
+ }:
+ # Rebuild history is the baseline for these too. Re-appending a
+ # replayed process delta to a retained tool duplicates its output.
+ return
+ if kind == "engine_capabilities":
+ key = self.capability_key(event)
+ # Completion needs names, not thousands of potentially large
+ # descriptions. Detailed capability output lives in Reports.
+ self.capability_cache.pop(key, None)
+ self.capability_cache[key] = {
+ "items": [
+ {
+ name: item[name]
+ for name in ("kind", "name", "enabled")
+ if name in item
+ }
+ for item in event.get("items", [])
+ ]
+ }
+ while len(self.capability_cache) > 32:
+ self.capability_cache.pop(next(iter(self.capability_cache)))
+ self.capability_requests.discard(key)
+ if read and read["type"] == "get_engine_capabilities":
+ requested_key = self.capability_key(read)
+ self.capability_cache[requested_key] = self.capability_cache[
+ key
+ ]
+ self.capability_requests.discard(requested_key)
+ while len(self.capability_cache) > 32:
+ self.capability_cache.pop(next(iter(self.capability_cache)))
+ if kind == "queued_query_detail":
+ request_id = event.get("request_id")
+ expected = self.queue_reads.pop(request_id, None)
+ if expected and (
+ self.workspace.rekeys.get(expected[0], expected[0]),
+ expected[1],
+ ) == (sid, event.get("msg_id")):
+ self.queue_details[request_id] = dict(event)
+ self.queue_details = dict(list(self.queue_details.items())[-4:])
+ return # Private one-shot payload; never retain it in Reports.
+ ticket = self.read_tickets.get((sid, kind))
+ if kind == "goal_state":
+ if request in self.goal_read_requests:
+ source_sid = self.goal_read_requests[request]
+ if (
+ self.workspace.rekeys.get(source_sid, source_sid) != sid
+ or not ticket or ticket[0] != request
+ ):
+ return
+ self.read_tickets.pop((sid, kind), None)
+ if ticket[1] != self.goal_versions.get(sid, 0):
+ return
+ else:
+ # Mutation confirmations and broadcasts are authoritative;
+ # only known GetGoal replies belong to the read fence.
+ self.goal_versions[sid] = self.goal_versions.get(sid, 0) + 1
+ if kind == "error" and request in self.goal_read_requests:
+ key = (sid, "goal_state")
+ if self.read_tickets.get(key, (None,))[0] == request:
+ self.read_tickets.pop(key, None)
+ if event.get("request_id") and kind in {
+ "context_report",
+ "status_report",
+ }:
+ if ticket and event["request_id"] != ticket[0]:
+ return
+ if ticket and kind == "status_report":
+ event = {
+ **event,
+ "_rates_current": ticket[1]
+ == self.workspace.view(sid).presentation.live_rate_revision,
+ }
+ self.workspace.event(event)
+ if kind == "history":
+ history_sid = event.get("session_id")
+ view = self.workspace.views.get(history_sid)
+ if (
+ history_sid
+ and view
+ and not event.get("before")
+ and not event.get("error")
+ and event.get("authoritative", True)
+ and view.pending_revision is None
+ and view.revision == event.get("revision", "")
+ and view.generation == event.get("generation")
+ and view.build_seq == event.get("build_seq", 0)
+ ):
+ self.cached_history.add(history_sid)
+ elif kind == "history_invalidated":
+ self.cached_history.discard(event.get("session_id"))
+ elif kind in {"replay_start", "replay_end"} and (
+ event.get("rebuild") or event.get("truncated")
+ ):
+ self.cached_history.discard(sid)
+ if kind == "session_list":
+ scope = (event.get("engine", "claude"), event.get("space", "code"))
+ self.catalog_ready.add(scope)
+ for row in event.get("sessions", []):
+ if row.get("state") is None:
+ # Evicted/native-only sessions have no continuous Wrapper
+ # stream. Revalidate once when reopened, keeping the local
+ # projection visible while the history request is in flight.
+ self.cached_history.discard(row.get("session_id"))
+ if kind == "session_rekey":
+ old, new = event["old_key"], event["session_id"]
+ self.buffers.rekey(old, new)
+ if old in self.cached_history:
+ self.cached_history.discard(old)
+ self.cached_history.add(new)
+ self.read_tickets = {
+ (new if key == old else key, kind): value
+ for (key, kind), value in self.read_tickets.items()
+ }
+ if old in self.goal_versions:
+ self.goal_versions[new] = self.goal_versions.pop(old)
+ if kind in {
+ "session_list_invalidated",
+ "session_focus",
+ "session_rekey",
+ "session_forked",
+ }:
+ if kind == "session_list_invalidated":
+ self.catalog_dirty.add(
+ (event["engine"], event.get("space", "code"))
+ )
+ else:
+ self.catalog_dirty.update(SCOPES)
+ if kind in {"snapshot", "session_focus", "session_rekey"}:
+ sid = event.get("session_id") or event.get("sid")
+ if sid:
+ self.panel_reads.add(sid)
+ if kind == "history_invalidated" and event.get("session_id"):
+ self._history_refresh_now.add(event["session_id"])
+ if kind == "error" and event.get("sid"):
+ self._history_requested.discard(event["sid"])
+ self.workspace.view(event["sid"]).loading = False
+ if kind == "session_list" and self.attached_sid:
+ self.panel_reads.add(self.attached_sid)
+ if kind == "notice" and sid == self.attached_sid:
+ self.notice = _safe_remote_text(
+ f"{event.get('severity', 'info')}: {event.get('title', '')} · "
+ f"{self.keys.label('notices')}: details"
+ )
+
+ @property
+ def scope(self) -> tuple[str, str]:
+ return self.engine, self.space
+
+ def visible_catalog(self) -> dict:
+ return scoped_catalog(self.workspace.catalog, *self.scope)
+
+ def remember_focus(self) -> None:
+ row = self.workspace.catalog.get(self.attached_sid)
+ if row:
+ self.last_focus[
+ (row.get("engine") or self.engine, row.get("space") or "code")
+ ] = self.attached_sid
+
+ async def switch_surface(self, engine: str, space: str) -> None:
+ if (engine, space) not in SCOPES:
+ raise ValueError("Choose claude/codex and code/work")
+ if (engine, space) == self.scope:
+ return
+ self.direct_session_pending = None
+ self.remember_focus()
+ self.navigation_revision += 1
+ self.engine, self.space = engine, space
+ self.attached_sid = None
+ self.attached_engine = engine
+ self._pending_new_request = None
+ self.restore_pending = True
+ self.notice = f"Loading {engine.title()} / {space.title()} sessions…"
+ if self.demo:
+ self.catalog_ready.add(self.scope)
+ await self.restore_surface()
+ else:
+ # A cached row may have been deleted by another client. Wait for
+ # this surface's authoritative catalog before making it writable.
+ self.catalog_ready.discard(self.scope)
+ await self._send(ListSessions(engine=engine, space=space))
+
+ async def restore_surface(self) -> None:
+ if self.direct_session_pending:
+ sid = self.direct_session_pending
+ row = self.workspace.catalog.get(sid)
+ if row:
+ await self._attach(sid, row["engine"])
+ elif SCOPES <= self.catalog_ready:
+ self.notice = "Requested session is not in the device catalog"
+ return
+ if not self.restore_pending or self.scope not in self.catalog_ready:
+ return
+ self.restore_pending = False
+ sid = select_session(
+ self.visible_catalog(), self.last_focus.get(self.scope)
+ )
+ if self.saved_workspace:
+ available = self.visible_catalog()
+ saved = self.last_focus.get(self.scope)
+ sid = (
+ saved if saved in available and saved in self.buffers.ids
+ else next((item for item in self.buffers.ids
+ if item in available), None)
+ )
+ if sid:
+ await self._attach(sid, self.engine)
+ elif self.saved_workspace:
+ self.notice = (
+ "No saved tabs available here · "
+ f"{self.keys.label('tree')}: open a session"
+ )
+ else:
+ command = (
+ ":new" if self.space == "work" else ":new /absolute/directory"
+ )
+ self.notice = (
+ f"No {self.engine.title()} / {self.space.title()} sessions · "
+ f"{command} creates one"
+ )
+
+ def _render_sessions(self, sessions: list[dict]) -> None:
+ # Keep the legacy /attach index and engine routing without printing rows.
+ self.sessions = [
+ {
+ **s,
+ "engine": s.get("engine")
+ or self.workspace.catalog.get(s["session_id"], {}).get("engine")
+ or self.engine,
+ }
+ for s in self.visible_catalog().values()
+ if s.get("tag") != "archived"
+ ]
+ for sid, row in self.workspace.catalog.items():
+ self.session_engines[sid] = row.get("engine", self.engine)
+
+ def _session_space(self, sid: str) -> str:
+ return self.workspace.catalog.get(sid, {}).get("space", self.space)
+
+ def _history_backed(self, sid: str) -> bool:
+ return not sid.startswith("btw-")
+
+ async def sync_side_chat(self, sid: str) -> None:
+ await self._send(
+ SyncBtw(
+ sid=sid,
+ cursor=self.cursors.get(sid),
+ generation=self.generations.get(sid),
+ )
+ )
+
+ async def _recovery_preamble(self) -> None:
+ # Keep projections/drafts visible immediately, but validate once after
+ # reconnect; old background buffers may be outside the replay window.
+ self.cached_history.clear()
+ self.catalog_ready.clear()
+ self.session_catalog_requests.clear()
+ self.catalog_retries.clear()
+ if (self.attached_sid == self.want_sid
+ and self.want_sid
+ and self.want_sid not in self.workspace.catalog):
+ # A positional ID is not evidence of its engine or workspace.
+ self.direct_session_pending = self.want_sid
+ self.attached_sid = None
+ self.restore_pending = True
+ await super()._recovery_preamble()
+ if self.attached_sid and not self._history_backed(self.attached_sid):
+ await self.sync_side_chat(self.attached_sid)
+ # Warm all four catalogs, but only the selected scope can pick focus.
+ # These are read-only catalog queries, never engine/model turns.
+ for engine, space in sorted(SCOPES):
+ if (engine, space) != (self.engine, "code"):
+ await self._send(ListSessions(engine=engine, space=space))
+
+ async def _attach(self, sid: str, engine: str | None = None) -> None:
+ self.direct_session_pending = None
+ previous_scope = self.scope
+ self.remember_focus()
+ self.navigation_revision += 1
+ revision = self.navigation_revision
+ self.restore_pending = False
+ row = self.workspace.catalog.get(sid, {})
+ engine = row.get("engine") or engine or self.engine
+ self.engine = engine
+ self.space = row.get("space", self.space)
+ if self.demo:
+ self.attached_sid, self.attached_engine = sid, engine
+ self.remember_focus()
+ return
+ if sid.startswith("btw-"):
+ if sid not in self.workspace.catalog:
+ self.notice = "This side conversation is no longer available"
+ return
+ self.attached_sid = sid
+ self.attached_engine = engine or self.engine
+ self.session_engines[sid] = self.attached_engine
+ await self.sync_side_chat(sid)
+ self.remember_focus()
+ return
+ self._pending_new_request = None
+ previous, previous_engine = self.attached_sid, self.attached_engine
+ self.attached_sid, self.attached_engine = sid, engine
+ self.session_engines[sid] = engine
+ self.sent_msg_ids.clear()
+ accepted = await self._send(
+ SwitchSession(
+ session_id=sid,
+ engine=engine,
+ space=self.space,
+ )
+ )
+ if revision != self.navigation_revision:
+ return # A newer UI selection owns focus, even if this send failed.
+ if not accepted:
+ self.attached_sid, self.attached_engine = previous, previous_engine
+ self.engine, self.space = previous_scope
+ return
+ self.remember_focus()
+ await self._request_history(sid, force=sid not in self.cached_history)
+ self.panel_reads.add(sid)
+
+ def _render_history(self, event: dict) -> None:
+ pass
+
+ async def _request_history(self, sid: str, *, force: bool = False) -> bool:
+ if not self._history_backed(sid):
+ return False # SyncBtw's bounded ring is this ephemeral baseline.
+ if sid in self._history_requested:
+ return False
+ if not force and sid in self._history_loaded:
+ return False
+ self._history_requested.add(sid)
+ accepted = await self._send(
+ GetHistory(
+ session_id=sid,
+ client_id=self.client_id,
+ detail="summary",
+ limit=4,
+ )
+ )
+ if not accepted:
+ self._history_requested.discard(sid)
+ return accepted
+
+ async def _flush_history_refreshes(self) -> None:
+ if self.protocol_error:
+ if self.ws:
+ await self.ws.close()
+ return
+ await super()._flush_history_refreshes()
+ dirty, self.catalog_dirty = self.catalog_dirty, set()
+ retries, self.catalog_retries = self.catalog_retries, set()
+ for engine, space in sorted(dirty | retries):
+ accepted = await self._send(ListSessions(engine=engine, space=space))
+ if not accepted and (engine, space) in dirty:
+ # A failed refresh cannot make an invalidated catalog valid.
+ self.catalog_dirty.add((engine, space))
+ await self.restore_surface()
+ pending, self.panel_reads = self.panel_reads, set()
+ for sid in pending:
+ if sid == self.attached_sid:
+ await self.refresh_panel(sid, "Goal / Plan")
+ await self.refresh_panel(sid, "Usage / Context", explicit=False)
+ await self.prefetch_capabilities(sid)
+
+ @staticmethod
+ def capability_key(row: dict) -> tuple:
+ return tuple(
+ row.get(k)
+ for k in (
+ "engine",
+ "space",
+ "cwd",
+ "claude_profile_id",
+ "codex_profile_id",
+ )
+ )
+
+ async def prefetch_capabilities(self, sid: str) -> None:
+ row = self.workspace.catalog.get(sid, {})
+ args = {
+ "engine": row.get("engine") or self.engine,
+ "space": row.get("space", "code"),
+ "cwd": row.get("cwd"),
+ "claude_profile_id": row.get("claude_profile_id"),
+ "codex_profile_id": row.get("codex_profile_id"),
+ }
+ key = self.capability_key(args)
+ if (
+ key not in self.capability_cache
+ and key not in self.capability_requests
+ ):
+ self.capability_requests.add(key)
+ if not await self._send(
+ GetEngineCapabilities(sid=sid, skills_only=True, **args)
+ ):
+ self.capability_requests.discard(key)
+
+ def web_url(self) -> str:
+ url = urlsplit(self.url)
+ return urlunsplit(
+ (
+ "https" if url.scheme == "wss" else "http",
+ url.netloc,
+ "/",
+ "",
+ "",
+ )
+ )
+
+ async def refresh_panel(
+ self, sid: str, name: str, *, explicit: bool = True
+ ) -> None:
+ row = self.workspace.catalog.get(sid, {})
+ messages = {
+ "Goal / Plan": lambda: [GetGoal(sid=sid)],
+ "Usage / Context": lambda: [GetContext(sid=sid, refresh=explicit)],
+ "Status": lambda: [GetStatus(sid=sid)],
+ "Settings": lambda: [
+ GetModels(
+ sid=sid,
+ engine=self.session_engines.get(sid, self.engine),
+ cwd=row.get("cwd"),
+ claude_profile_id=row.get("claude_profile_id"),
+ codex_profile_id=row.get("codex_profile_id"),
+ ),
+ GetPermissionProfiles(
+ sid=sid,
+ cwd=row.get("cwd"),
+ codex_profile_id=row.get("codex_profile_id"),
+ ),
+ ],
+ "Reports": lambda: [
+ GetEngineCapabilities(
+ sid=sid,
+ engine=self.session_engines.get(sid, self.engine),
+ space=self._session_space(sid),
+ cwd=self.workspace.catalog.get(sid, {}).get("cwd"),
+ claude_profile_id=self.workspace.catalog.get(sid, {}).get(
+ "claude_profile_id"
+ ),
+ codex_profile_id=self.workspace.catalog.get(sid, {}).get(
+ "codex_profile_id"
+ ),
+ )
+ ],
+ }.get(name, lambda: [])()
+ if name == "Usage / Context" and (
+ explicit or self.session_engines.get(sid, self.engine) == "codex"
+ ):
+ messages.append(GetStatus(sid=sid))
+ for message in messages:
+ await self._send(message)
+
+ async def execute_action(self, message) -> bool:
+ if message.cmd_id:
+ self.action_results[message.cmd_id] = {"sid": message.sid}
+ self.action_results = dict(list(self.action_results.items())[-128:])
+ if isinstance(message, (DeleteSession, DeleteWorkSession)):
+ if self.demo:
+ self.notice = "Offline demo: no command was sent"
+ return False
+ return self.deletions.start(message)
+ if isinstance(message, NewSession):
+ self._pending_new_request = message.request_id
+ self._pending_new_engine = message.engine
+ self.pending_new_space = message.space
+ self.restore_pending = False
+ accepted = await self._send(message)
+ if not accepted and isinstance(message, NewSession):
+ self._pending_new_request = None
+ return accepted
+
+ async def list_directories(self, path: str, *, refresh=False) -> dict:
+ cached = self.directory_cache.get(path)
+ if cached and not refresh and time.monotonic() - cached[0] < 30:
+ return cached[1]
+ if self.demo:
+ known = [row.get("cwd") for row in self.workspace.catalog.values()]
+ base = "/example" if path == "~" else path
+ return {
+ "path": base,
+ "parent": "/" if base != "/" else None,
+ "dirs": [
+ {"path": value}
+ for value in dict.fromkeys(known)
+ if value and value.startswith(base.rstrip("/") + "/")
+ ],
+ "type": "dir_list",
+ }
+ request_id = uuid.uuid4().hex
+ future = asyncio.get_running_loop().create_future()
+ self.directory_waiters[request_id] = future
+ try:
+ if not await self._send(
+ ListDir(path=path, cmd_id=request_id, client_id=self.client_id)
+ ):
+ raise ValueError(self.notice)
+ event = await asyncio.wait_for(future, timeout=15)
+ if event.get("type") == "error":
+ raise ValueError(event["message"])
+ self.directory_cache[path] = (time.monotonic(), event)
+ self.directory_cache = dict(
+ list(self.directory_cache.items())[-64:]
+ )
+ return event
+ except TimeoutError as exc:
+ raise ValueError("Directory listing timed out; use Refresh") from exc
+ finally:
+ self.directory_waiters.pop(request_id, None)
+
+ async def _command(self, text: str) -> None:
+ parts = text.split(maxsplit=1)
+ name = parts[0].lstrip("/") if parts else ""
+ arg = parts[1].strip() if len(parts) > 1 else ""
+ if name == "engine":
+ await self.switch_surface(arg, self.space)
+ elif name == "space":
+ await self.switch_surface(self.engine, arg)
+ elif name == "sessions":
+ await self._send(ListSessions(engine=self.engine, space=self.space))
+ elif name == "new":
+ if self.space == "work" and arg:
+ raise ValueError(
+ "Work assigns its own directory; use :new without a path"
+ )
+ self.restore_pending = False
+ await self.execute_action(
+ NewSession(
+ cwd=(arg or "~") if self.space == "code" else None,
+ engine=self.engine,
+ space=self.space,
+ request_id=uuid.uuid4().hex,
+ )
+ )
+ else:
+ await super()._command(text)
+
+ async def submit(
+ self, text: str, *, queue: bool = False, sid: str | None = None,
+ async_questions: tuple[str, ...] = (),
+ ) -> bool:
+ sid = sid or self.attached_sid
+ if not sid:
+ self.notice = "Select a session first, or :new /absolute/directory"
+ return False
+ view = self.workspace.view(sid)
+ if view.write_state != "writable":
+ self.notice = (
+ f"Input unavailable: {view.write_state}. Draft retained."
+ )
+ return False
+ if max(len(view.pending_messages), len(view.pending_attachments)) >= 64:
+ self.notice = "Too many unconfirmed messages. Draft retained."
+ return False
+ if queue and (
+ len(view.pending_queued_text) + len(view.recovered_queued_text) >= 64
+ or sum(len(r["prompt"].encode("utf-8"))
+ for r in view.pending_queued_text.values())
+ + sum(len(p.encode("utf-8")) for p in view.recovered_queued_text)
+ + len(text.encode("utf-8")) > 8 * 1024 * 1024
+ ):
+ self.notice = "Too much unconfirmed queue text. Draft retained."
+ return False
+ args = dict(
+ prompt=text,
+ msg_id=uuid.uuid4().hex,
+ sid=sid,
+ cmd_id=uuid.uuid4().hex,
+ client_id=self.client_id,
+ )
+ attachments = [] if async_questions else view.attachments[:]
+ images = [a["content"] for a in attachments if a["image"]]
+ files = [a["content"] for a in attachments if not a["image"]]
+ error = validate_attachments(images, files)
+ if error:
+ self.notice = error
+ return False
+ args.update(images=images or None, files=files or None)
+ if (
+ not queue
+ and self.session_engines.get(sid, self.engine) == "codex"
+ and view.state == "running"
+ ):
+ msg = Steer(**args)
+ else:
+ msg = Query(**args, delivery="queue" if queue else "immediate")
+ receipt = None
+ if queue:
+ receipt = {"cmd_id": msg.cmd_id, "prompt": text, "rejected": False}
+ view.pending_queued_text[msg.msg_id] = receipt
+ if not queue:
+ # Paint immediately, including while the socket write is awaiting
+ # transport. Do not invent a native turn or completion state.
+ view.pending_messages[msg.msg_id] = Block(
+ "user:" + msg.msg_id, "user",
+ clip(view.with_attachments(text, args)),
+ data={"status": "awaiting confirmation",
+ "async_questions": async_questions},
+ )
+ view.version += 1
+ if attachments:
+ view.pending_attachments[msg.msg_id] = (msg.cmd_id, attachments)
+ view.attachments.clear()
+ if not await self._send(msg):
+ view.pending_queued_text.pop(msg.msg_id, None)
+ view.restore_attachments(msg.msg_id)
+ if not queue:
+ view.pending_messages.pop(msg.msg_id, None)
+ view.version += 1
+ return False
+ if receipt and receipt["rejected"]:
+ # A rejection can arrive while send yields; do not clear the draft.
+ if text in view.recovered_queued_text:
+ view.recovered_queued_text.remove(text)
+ return False
+ # Only the server echo confirms delivery. Queued text stays in queue.
+ self.notice = (
+ "Queued command submitted" if queue else "Message submitted"
+ )
+ return True
+
+ async def answer_async(self, sid, identities, text) -> bool:
+ if not text.strip() or len(text) > 2 * 1024 * 1024:
+ self.notice = "Answer must be non-empty and at most 2 MiB of text."
+ return False
+ sid = self.workspace.rekeys.get(sid, sid)
+ view = self.workspace.view(sid)
+ pending = {block.id for block in pending_async(view)}
+ if not identities or not set(identities) <= pending:
+ self.notice = "This question is no longer pending"
+ return False
+ if self.session_engines.get(sid, self.engine) != "codex":
+ self.notice = "Async answers require a Codex session"
+ return False
+ if view.state not in {"running", "idle"}:
+ self.notice = "Session is synchronizing; retry the retained answer."
+ return False
+ return await self.submit(
+ text, sid=sid, async_questions=tuple(identities)
+ )
+
+ async def answer(self, text: str) -> bool:
+ ask = self._pending_ask_for_attached()
+ if not ask:
+ self.notice = "No pending question in this session"
+ return False
+ return await self.answer_for(ask, text)
+
+ def _pending_ask_for_attached(self):
+ if not self.attached_sid:
+ return None
+ questions = self.workspace.view(
+ self.attached_sid
+ ).presentation.questions
+ return next(iter(questions.values()), None)
+
+ async def answer_for(self, ask: dict, text: str) -> bool:
+ sid = self.workspace.rekeys.get(ask["sid"], ask["sid"])
+ pending = self.workspace.view(sid).presentation.questions
+ if ask["ask_id"] not in pending:
+ self.notice = "This question is no longer pending"
+ return False
+ options = ask.get("options", [])
+ picks = [p.strip() for p in text.split(",")]
+ if picks and all(p.isdigit() for p in picks) and options:
+ indices = [int(p) - 1 for p in picks]
+ if (
+ any(i < 0 or i >= len(options) for i in indices)
+ or len(set(indices)) != len(indices)
+ or (len(indices) > 1 and not ask.get("multi_select"))
+ ):
+ self.notice = "Invalid option selection"
+ return False
+ labels = [options[i]["label"] for i in indices]
+ answer = labels if ask.get("multi_select") else labels[0]
+ elif ask.get("allow_text"):
+ if not text.strip():
+ self.notice = "Enter a non-empty answer; nothing was sent"
+ return False
+ answer = text
+ else:
+ self.notice = "Enter option number(s), separated by commas"
+ return False
+ accepted = await self._send(
+ AnswerQuestion(ask_id=ask["ask_id"], answer=answer, sid=ask["sid"])
+ )
+ if accepted:
+ if (self.pending_asks.get(sid) or {}).get("ask_id") == ask["ask_id"]:
+ self.pending_asks.pop(sid, None)
+ pending.pop(ask["ask_id"], None)
+ self.notice = "Answer submitted"
+ return accepted
+
+
+def location(text: str, offset: int) -> tuple[int, int]:
+ offset = max(0, min(offset, len(text)))
+ return text.count("\n", 0, offset), offset - text.rfind("\n", 0, offset) - 1
+
+
+def offset(text: str, point: tuple[int, int]) -> int:
+ lines = text.splitlines(keepends=True)
+ row, col = point
+ return min(len(text), sum(map(len, lines[:row])) + col)
+
+
+class Transcript(VimArea):
+ """Read-only document with a logical cursor independent of the composer."""
+
+ line_styles: dict[int, str] = {}
+ resize_bookmark = None
+ follow_scroll_sid = None
+ follow_revision = 0
+ follow_layout_pending = False
+ _presentation_depth = 0
+
+ @contextmanager
+ def presentation_update(self):
+ """A reader projection must not invoke an editor's cursor scrolling."""
+ self._presentation_depth += 1
+ try:
+ yield
+ finally:
+ self._presentation_depth -= 1
+
+ def scroll_cursor_visible(self, center=False, animate=False):
+ if self._presentation_depth:
+ return Offset(0, 0)
+ return super().scroll_cursor_visible(center=center, animate=animate)
+
+ def reconcile_read_cursor(self) -> None:
+ """Bring an off-screen cursor to the viewport, never the reverse."""
+ top = int(self.scroll_y)
+ bottom = top + max(1, self.scrollable_content_region.height) - 1
+ cursor = self.wrapped_document.location_to_offset(self.cursor_location)
+ if top <= cursor.y <= bottom:
+ return
+ if self.scroll_y >= self.max_scroll_y:
+ point = self.document.end
+ else:
+ point = self.wrapped_document.offset_to_location(
+ Offset(cursor.x, max(top, min(bottom, cursor.y)))
+ )
+ with self.presentation_update():
+ self.move_cursor(point)
+
+ def _size_updated(self, size, virtual_size, container_size, layout=True):
+ # Resize events run after the widget geometry changes. Capture before
+ # ScrollView clamps offsets or TextArea replaces its old wrapping map.
+ if (size != self._size and self._size.width and self._size.height
+ and self.is_mounted and self.resize_bookmark is None
+ and self.app.shown_sid == self.app.client.attached_sid
+ and self.app.shown_sid and self.app.starts):
+ self.app.remember()
+ view = self.app.client.workspace.view(self.app.shown_sid)
+ bottom = (view.follow if self.follow_layout_pending else
+ self.scroll_y >= self.max_scroll_y)
+ view.follow = bottom and not view.tail_hidden
+ self.resize_bookmark = (
+ self.app.shown_sid, view.anchor, view.selection, view.viewport,
+ bottom,
+ self.selection.end == self.document.end,
+ self.selection.start == self.document.end,
+ )
+ changed = super()._size_updated(
+ size, virtual_size, container_size, layout=layout
+ )
+ if self.resize_bookmark is not None and self.resize_bookmark[4]:
+ # The compositor can paint the new height before Resize dispatch.
+ # Keep the first such frame pinned, too; width rewrap follows.
+ self.scroll_end(animate=False, immediate=True, force=True)
+ return changed
+
+ def restore_resize(self, bookmark):
+ if bookmark is not self.resize_bookmark:
+ return
+ if (bookmark[0] != self.app.shown_sid
+ or bookmark[0] != self.app.client.attached_sid):
+ self.resize_bookmark = None
+ return
+ view = self.app.client.workspace.view(bookmark[0])
+ (_, anchor, selection, viewport, bottom,
+ anchor_end, selection_end) = bookmark
+
+ def resolve(point):
+ return location(self.text, self.projection.resolve(
+ view, point, self.app.starts, len(self.text)
+ ))
+
+ with self.presentation_update():
+ self.selection = Selection(
+ self.document.end if selection_end else resolve(selection),
+ self.document.end if anchor_end else resolve(anchor),
+ )
+ if bottom:
+ self.scroll_end(animate=False, immediate=True, force=True)
+ else:
+ top = self.wrapped_document.location_to_offset(resolve(viewport))
+ self.scroll_to(top.x, top.y, animate=False,
+ immediate=True, force=True)
+ self.resize_bookmark = None
+ if view.follow:
+ # Streaming or image reflow can capture a transient non-bottom
+ # offset. A following reader must not restore that stale offset.
+ self.app.follow_tail(bookmark[0])
+ self.app.read_position_signature = None
+ self.app.remember()
+ self.parent.sync()
+
+ @property
+ def projection(self):
+ return self.parent.projection
+
+ @property
+ def selected_text(self):
+ start, end = self.selection
+ position = self.document.get_index_from_location
+ return self.projection.extract(self.text, position(start), position(end))
+
+ def yank_text(self, start, end):
+ position = self.document.get_index_from_location
+ return self.projection.extract(
+ self.text, position(start), position(end)
+ )
+
+ def watch_scroll_y(self, old_value, new_value):
+ super().watch_scroll_y(old_value, new_value)
+ if isinstance(self.parent, TranscriptViewport):
+ self.call_after_refresh(self.parent.sync)
+ if (self.follow_scroll_sid
+ and new_value >= self.max_scroll_y - 1):
+ self.call_after_refresh(
+ self.app.resume_following_at_bottom,
+ False, self.follow_scroll_sid, self.follow_revision,
+ )
+
+ def get_line(self, line_index: int) -> Text:
+ line = super().get_line(line_index)
+ rich_lines = (self.parent.rich_lines
+ if isinstance(self.parent, TranscriptViewport) else [])
+ if (line_index < len(rich_lines)
+ and rich_lines[line_index].plain == line.plain):
+ line = rich_lines[line_index].copy()
+ line.stylize(self.line_styles.get(line_index, ""))
+ return line
+
+ def style_messages(self, text: str, starts: list) -> None:
+ # Inline styles can change while the rendered characters stay equal.
+ self.notify_style_update()
+ self.line_styles = {}
+ row = 0
+ for index, (start, block) in enumerate(starts):
+ end = starts[index + 1][0] if index + 1 < len(starts) else len(text)
+ length = text[start:end].count("\n")
+ muted = block.channel == "thinking" or block.role in {
+ "tool",
+ "process",
+ "detail",
+ "tool_group",
+ }
+ body = "bright_black" if muted else ""
+ if block.role == "user":
+ body = "on #202a36"
+ failed = block.data.get("is_error") or block.data.get("status") in {
+ "failed",
+ "error",
+ "interrupted",
+ }
+ if failed:
+ body = "red"
+ for line in range(row, row + length):
+ self.line_styles[line] = body
+ self.line_styles[row] = (
+ "bold cyan on #202a36"
+ if block.role == "user"
+ else "bold red"
+ if failed
+ else "bright_black"
+ if muted
+ else "bold green"
+ )
+ if block.role == "detail" and block.expanded:
+ sections = block.data.get("sections", [])
+ projection = self.parent.projection
+ source = projection.content.original
+ source_start = projection.source(start)
+ body_start = source.find("\n", source_start) + 1
+ source_rows = [body_start]
+ source_end = projection.source(end)
+ for line in source[body_start:source_end].splitlines(
+ keepends=True
+ ):
+ source_rows.append(source_rows[-1] + len(line))
+
+ def section_row(line):
+ point = source_rows[min(line, len(source_rows) - 1)]
+ return text.count("\n", 0, projection.display(point))
+
+ for n, section in enumerate(sections):
+ begin = section_row(section["line"])
+ end_row = (section_row(sections[n + 1]["line"])
+ if n + 1 < len(sections) else row + length)
+ if section["role"] == "assistant" and (
+ section["channel"] != "thinking"
+ ):
+ style = "red" if section.get("is_error") or (
+ section.get("status") in
+ {"failed", "error", "interrupted"}
+ ) else ""
+ for line in range(begin, min(end_row, row + length)):
+ self.line_styles[line] = style
+ self.line_styles[begin] = (
+ "bold red" if style else "bold green"
+ )
+ row += length
+
+ async def _on_key(self, event: events.Key) -> None:
+ self.resize_bookmark = None
+ self.follow_scroll_sid = None
+ self.follow_revision += 1
+ if await self.app.read_key(event.key):
+ event.stop()
+ event.prevent_default()
+ else:
+ await TextArea._on_key(self, event)
+ if event.key in {"j", "ctrl+d", "down", "pagedown", "end"}:
+ self.call_after_refresh(
+ self.app.resume_following_at_bottom,
+ True, self.app.shown_sid, self.follow_revision,
+ )
+
+ def on_mouse_down(self) -> None:
+ self.resize_bookmark = None
+ self.app.pending_jump = None
+ self.app.stop_following()
+
+ def on_mouse_scroll_up(self) -> None:
+ self.resize_bookmark = None
+ self.app.pending_jump = None
+ self.app.stop_following()
+
+ def on_mouse_scroll_down(self) -> None:
+ self.resize_bookmark = None
+ self.follow_revision += 1
+ self.follow_scroll_sid = self.app.shown_sid
+ self.call_after_refresh(
+ self.app.resume_following_at_bottom,
+ False, self.app.shown_sid, self.follow_revision,
+ )
+
+ def on_mouse_up(self) -> None:
+ self.resize_bookmark = None
+ self.call_after_refresh(
+ self.app.resume_following_at_bottom,
+ False, self.app.shown_sid, self.follow_revision,
+ )
+
+ def on_resize(self) -> None:
+ bookmark = self.resize_bookmark
+ if (self.parent.projection.slots
+ or (self.parent.projection.content.responsive
+ and self.parent.render_width != max(8, self.wrap_width))):
+ self.app.rendered_version = -1
+ self.app.paint()
+ if bookmark is not None:
+ self.call_after_refresh(self.restore_resize, bookmark)
+ else:
+ self.call_after_refresh(self.app.follow_tail, self.app.shown_sid)
+
+ def on_focus(self) -> None:
+ self.app.mode = "NORMAL"
+
+
+class WorkspaceApp(App, inherit_bindings=False):
+ TITLE = "cc-remote"
+ ENABLE_COMMAND_PALETTE = False
+ CSS = """
+ #conversation { width: 1fr; height: 1fr; }
+ #session-tabs, #session-title {
+ height: 1; background: $boost;
+ text-wrap: nowrap; text-overflow: ellipsis;
+ }
+ #transcript { height: 100%; border: none; }
+ #question { height: auto; max-height: 8; background: $boost; }
+ #progress, #usage, #attachments, #settings, #suggestions {
+ height: auto; max-height: 2;
+ text-overflow: ellipsis;
+ }
+ #composer { height: 7; border: solid $primary; }
+ #status { height: 2; background: $boost; text-wrap: nowrap; }
+ """
+ BINDINGS = []
+
+ def __init__(self, client: WorkspaceClient, *, connect: bool = True):
+ super().__init__()
+ self.client = client
+ # Instance-local: a second workspace/config must not inherit bindings.
+ self._bindings = BindingsMap(client.keys.bindings())
+ self.shortcut_prefix: tuple[str, ...] = ()
+ self.explorer_return_draft = False
+ self.connect_enabled = connect
+ self.mode = "NORMAL"
+ self.prefix = ""
+ self.shown_sid: str | None = None
+ self.rendered_version = -1
+ self.rendered_clock = None
+ self.starts = []
+ self.question_text = None
+ self.status_text = None
+ self.command_mode = False
+ self.command_backup = ""
+ self.answer_mode = False
+ self.network = None
+ self.submitting = False
+ self.clipboard_busy = False
+ self.acknowledged: set[tuple[str, str]] = set()
+ self.chrome_signature: dict[str, object] = {}
+ self.read_position_signature = None
+ self.pending_jump = None
+ self.graphics = None
+
+ def compose(self) -> ComposeResult:
+ with Horizontal(id="workspace-body"):
+ yield SessionExplorer()
+ with Vertical(id="conversation"):
+ yield Static("", id="session-tabs", markup=False)
+ yield Static("", id="session-title", markup=False)
+ yield TranscriptViewport(
+ Transcript(read_only=True, id="transcript")
+ )
+ yield Static("", id="progress", markup=False)
+ yield Static("", id="usage", markup=False)
+ yield Static("", id="settings", markup=False)
+ yield Static("", id="attachments", markup=False)
+ yield Static("", id="suggestions", markup=False)
+ yield Static("", id="question", markup=False)
+ yield Composer(
+ id="composer",
+ placeholder=(
+ f"{self.client.keys.label('focus_draft')}: draft · "
+ f"i: insert · {self.client.keys.label('focus_read')}: read"
+ ),
+ )
+ yield Static("", id="status", markup=False)
+
+ def on_screen_resume(self) -> None:
+ if len(self.screen_stack) == 1:
+ for editor in self.query(Composer):
+ editor.set_mode("NORMAL")
+ self.mode = "NORMAL"
+ self.prefix = ""
+ self.shortcut_prefix = ()
+
+ def on_mount(self) -> None:
+ # Do not leave hidden Screen Tab/Ctrl+c actions behind the registry.
+ self.screen._bindings = BindingsMap([])
+ self.query_one(Transcript).focus()
+ if self.client.demo:
+ self.seed_demo_settings()
+ self.set_interval(0.1, self.paint)
+ if self.connect_enabled:
+ self.network = self.run_worker(self.client._connection_loop())
+ self.paint()
+
+ def seed_demo_settings(self) -> None:
+ for row in self.client.workspace.catalog.values():
+ for kind in ("models", "permission_profiles"):
+ args = {"cwd": row.get("cwd")}
+ if kind == "models":
+ args["engine"] = row.get("engine")
+ data = {
+ "default_model": "demo-model",
+ "models": [
+ {
+ "id": "demo-model",
+ "display_name": "Demo model (offline)",
+ "efforts": ["low", "high"],
+ },
+ ],
+ }
+ else:
+ data = {
+ "profiles": [
+ {"id": ":workspace", "allowed": True},
+ {"id": ":danger-full-access", "allowed": True},
+ ]
+ }
+ key = (kind, *self.client.capability_key(args))
+ self.client.settings_catalogs[key] = data
+
+ def suggestions(self) -> tuple[int, list[str]]:
+ editor = self.query_one(Composer)
+ cursor = offset(editor.text, editor.cursor_location)
+ match = re.search(r"(?:^|\s)([$/])([\w-]*)$", editor.text[:cursor])
+ if not match:
+ return cursor, []
+ prefix = match[1] + match[2]
+ if match[1] == "/":
+ names = {
+ "goal",
+ "plan",
+ "context",
+ "status",
+ "settings",
+ "queue",
+ "actions",
+ "help",
+ "web",
+ *ACTIONS,
+ }
+ else:
+ row = self.client.workspace.catalog.get(
+ self.client.attached_sid, {}
+ )
+ key = self.client.capability_key(
+ {**row, "space": row.get("space", "code")}
+ )
+ report = self.client.capability_cache.get(key, {})
+ names = {
+ item["name"]
+ for item in report.get("items", [])
+ if item.get("kind") == "skill"
+ and item.get("enabled") is not False
+ }
+ return cursor - len(prefix), sorted(
+ match[1] + name
+ for name in names
+ if (match[1] + name).startswith(prefix)
+ )
+
+ def completion_hint(self, choices: list[str]) -> str:
+ return (
+ " · ".join(choices[:4])
+ + f" [{self.client.keys.label('complete')}]"
+ if choices
+ else ""
+ )
+
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
+ if (
+ isinstance(event.text_area, Composer)
+ and len(self.screen_stack) == 1
+ ):
+ _, choices = self.suggestions()
+ self.update_chrome(
+ "#suggestions",
+ self.completion_hint(choices),
+ )
+
+ def action_complete(self) -> None:
+ self.shortcut_prefix = ()
+ if len(self.screen_stack) > 1 or self.focused is not self.query_one(
+ Composer
+ ):
+ return
+ editor = self.query_one(Composer)
+ start, choices = self.suggestions()
+ if not choices:
+ self.client.notice = (
+ "No cached completions; "
+ f"{self.client.keys.label('reports')} refreshes capabilities"
+ )
+ return
+ end = offset(editor.text, editor.cursor_location)
+ original = editor.text
+
+ def chosen(value: str | None) -> None:
+ if value and editor.text == original:
+ editor.replace(
+ value + " ",
+ location(original, start),
+ location(original, end),
+ maintain_selection_offset=False,
+ )
+ editor.set_mode("INSERT")
+ editor.focus()
+
+ self.push_screen(SuggestionPicker(choices), chosen)
+
+ async def on_unmount(self) -> None:
+ self.client.deletions.close()
+ self.client.save_tabs()
+ self.client._quitting = True
+ if self.network:
+ self.network.cancel()
+ if self.client.ws:
+ await self.client.ws.close()
+
+ def stop_following(self) -> None:
+ reader = self.query_one(Transcript)
+ reader.anchor(False)
+ reader.follow_scroll_sid = None
+ reader.follow_revision += 1
+ if self.shown_sid:
+ self.client.workspace.view(self.shown_sid).follow = False
+
+ def resume_following_at_bottom(
+ self, keyboard=False, sid=None, revision=None
+ ) -> None:
+ if (not self.shown_sid or len(self.screen_stack) != 1
+ or (sid is not None and sid != self.shown_sid)):
+ return
+ reader = self.query_one(Transcript)
+ if revision is not None and revision != reader.follow_revision:
+ return
+ view = self.client.workspace.view(self.shown_sid)
+ view.follow = not view.tail_hidden and reader.scroll_y >= reader.max_scroll_y
+ reader.anchor(view.follow)
+ if view.follow:
+ reader.follow_scroll_sid = None
+
+ def sample_follow_position(self) -> None:
+ """Sample the old viewport before content changes its scroll range."""
+ if not self.shown_sid or self.shown_sid != self.client.attached_sid:
+ return
+ reader = self.query_one(Transcript)
+ if reader.resize_bookmark is None and not reader.follow_layout_pending:
+ self.resume_following_at_bottom()
+
+ def finish_follow_layout(self, sid) -> None:
+ sid = self.client.workspace.rekeys.get(sid, sid)
+ if sid != self.shown_sid:
+ return
+ reader = self.query_one(Transcript)
+ reader.follow_layout_pending = False
+ self.follow_tail(sid)
+
+ def follow_tail(self, sid) -> None:
+ # TextArea rewraps after load/resize. Pin only after layout is ready,
+ # and never let a stale callback steal another buffer's reading cursor.
+ if sid != self.shown_sid or not sid:
+ return
+ if not self.client.workspace.view(sid).follow:
+ return
+ reader = self.query_one(Transcript)
+ if reader.resize_bookmark is not None:
+ return
+ reader.anchor()
+
+ def on_resize(self) -> None:
+ self.call_after_refresh(self.follow_tail, self.shown_sid)
+
+ def remember(self) -> None:
+ if not self.shown_sid:
+ return
+ self.shown_sid = self.client.workspace.rekeys.get(
+ self.shown_sid, self.shown_sid
+ )
+ view = self.client.workspace.view(self.shown_sid)
+ reader = self.query_one(Transcript)
+ editor = self.query_one(Composer)
+ signature = (
+ self.shown_sid,
+ self.rendered_version,
+ reader.selection,
+ reader.scroll_offset,
+ reader.size.width,
+ )
+ if (reader.resize_bookmark is None
+ and signature != self.read_position_signature):
+ position = reader.document.get_index_from_location
+ view.anchor = reader.projection.locate(
+ view, position(reader.selection.end), self.starts
+ )
+ view.selection = reader.projection.locate(
+ view, position(reader.selection.start), self.starts
+ )
+ top = reader.wrapped_document.offset_to_location(
+ reader.scroll_offset
+ )
+ view.viewport = reader.projection.locate(
+ view, position(top), self.starts
+ )
+ selected = next(
+ (b for _, b in self.starts if b.id == view.anchor[0]), None
+ )
+ if selected:
+ view.presentation.selected_turn = selected.turn
+ self.read_position_signature = signature
+ if not self.command_mode and not self.answer_mode:
+ view.draft = editor.text
+ view.draft_cursor = editor.cursor_location
+
+ async def acknowledge_completion(self, sid, completion_id):
+ accepted = False
+ try:
+ accepted = await self.client._send(
+ AcknowledgeCompletion(sid=sid, completion_id=completion_id)
+ )
+ finally:
+ if not accepted:
+ self.acknowledged.discard((sid, completion_id))
+
+ def paint(self) -> None:
+ if len(self.screen_stack) > 1:
+ return
+ # A queued timer can run after Screen children begin unmounting.
+ try:
+ reader = self.query_one(Transcript)
+ editor = self.query_one(Composer)
+ question_widget = self.query_one("#question", Static)
+ status_widget = self.query_one("#status", Static)
+ except NoMatches:
+ return
+ self.sample_follow_position()
+ explorer = self.query_one(SessionExplorer)
+ if explorer.display:
+ explorer.refresh_catalog()
+ self.shown_sid = self.client.workspace.rekeys.get(
+ self.shown_sid, self.shown_sid
+ )
+ sid = self.client.attached_sid
+ self.client.buffers.open(sid)
+ self.client.save_tabs()
+ tabs = tab_line(self.client, self.size.width)
+ signature = (tabs.plain, tuple(tabs.spans))
+ if signature != getattr(self, "tab_signature", None):
+ self.query_one("#session-tabs", Static).update(tabs)
+ self.tab_signature = signature
+ if sid != self.shown_sid:
+ reader.clear_yank()
+ reader.follow_layout_pending = False
+ reader.follow_scroll_sid = None
+ reader.follow_revision += 1
+ self.remember()
+ self.shown_sid = sid
+ self.pending_jump = None
+ self.starts = []
+ self.rendered_version = -1
+ self.command_mode = self.answer_mode = False
+ self.mode = "NORMAL"
+ self.shortcut_prefix = ()
+ editor.set_mode("NORMAL")
+ reader.set_mode("NORMAL")
+ self.prefix = ""
+ if sid:
+ view = self.client.workspace.view(sid)
+ editor.load_text(view.draft)
+ editor.move_cursor(view.draft_cursor)
+ else:
+ editor.load_text("")
+ reader.load_text("")
+ reader.parent.reset(None)
+ reader.focus()
+ elif sid:
+ self.remember()
+ view = self.client.workspace.view(sid) if sid else None
+ if view and view.recovered_queued_text and not (
+ self.command_mode or self.answer_mode
+ ):
+ text = editor.text
+ for prompt in view.recovered_queued_text:
+ if text != prompt:
+ text += ("\n\n" if text else "") + prompt
+ view.recovered_queued_text.clear()
+ if text != editor.text:
+ editor.load_text(text)
+ editor.move_cursor(editor.document.end)
+ view.draft = text
+ self.client.notice = "Queue rejected; text restored to draft"
+ row = self.client.workspace.catalog.get(sid, {})
+ projection_identity = (
+ (sid, row.get("cwd"), view.artifact_epoch) if view else None
+ )
+ if reader.parent.identity != projection_identity:
+ self.rendered_version = -1
+ title = row.get("summary") or row.get("first_prompt") or sid
+ heading = (
+ "Session: " + " ".join(_safe_remote_text(title[:512]).split())
+ if title
+ else f"No session · {self.client.keys.label('tree')}: session tree"
+ )
+ self.update_chrome(
+ "#session-title",
+ f"{self.client.engine.title()} / {self.client.space.title()} · {heading}",
+ )
+ if view:
+ p = view.presentation
+ completed = (p.visible_goal() or {}).get(
+ "status"
+ ) == "complete" or (
+ not p.visible_goal() and p.plan and p.plan_terminal()
+ )
+ failed = p.plan and p.plan.get("status") in {
+ "failed",
+ "interrupted",
+ "cancelled",
+ }
+ color = "red" if failed else "green" if completed else "cyan"
+ self.update_chrome(
+ "#progress",
+ p.progress_label(self.client.keys.label("goal")),
+ color,
+ )
+ self.update_chrome("#usage", p.usage_label(), "dim")
+ self.update_chrome("#settings", settings_text(p))
+ self.update_chrome(
+ "#attachments",
+ "Attachments: " + ", ".join(a["name"] for a in view.attachments)
+ if view.attachments
+ else "",
+ )
+ _, choices = self.suggestions()
+ self.update_chrome(
+ "#suggestions",
+ self.completion_hint(choices),
+ )
+ completion = p.completion
+ completion_id = completion.get("completion_id")
+ key = (sid, completion_id)
+ if (
+ completion.get("unread")
+ and completion_id
+ and key not in self.acknowledged
+ and view.follow
+ and self.app_focus
+ and not self.client.demo
+ ):
+ self.acknowledged.add(key)
+ self.run_worker(
+ self.acknowledge_completion(sid, completion_id)
+ )
+ if view and view.version != self.rendered_version:
+ # A replaced history page may no longer contain the stored block.
+ # Keep the old logical row as a bounded fallback in this buffer,
+ # but never leak another session's viewport into a newly opened one.
+ previous_selection = reader.selection if self.starts else (
+ Selection.cursor((0, 0))
+ )
+ previous_top = reader.wrapped_document.offset_to_location(
+ reader.scroll_offset
+ ) if self.starts else (0, 0)
+ text, starts = view.render(
+ detail_key=self.client.keys.layer_label("reader", "details"),
+ older_key=self.client.keys.layer_label("reader", "older"),
+ newer_key=self.client.keys.layer_label("reader", "newer"),
+ close_key=self.client.keys.layer_label("reader", "close"),
+ )
+ text, starts = reader.parent.project(
+ text, starts, projection_identity
+ )
+ reader.style_messages(text, starts)
+ reader.follow_layout_pending = True
+ with reader.presentation_update():
+ if text != reader.text:
+ reader.load_text(text)
+ else:
+ reader.refresh() # Styles may change without new text.
+ if view.follow and not view.anchor[0]:
+ reader.move_cursor(location(text, len(text)))
+ else:
+ reader.selection = Selection(
+ location(
+ text, reader.projection.resolve(
+ view, view.selection, starts, len(text),
+ fallback=offset(text, previous_selection.start),
+ )
+ ),
+ location(
+ text, reader.projection.resolve(
+ view, view.anchor, starts, len(text),
+ fallback=offset(text, previous_selection.end),
+ )
+ ),
+ )
+ if view.follow:
+ # Commit the viewport before the next frame, not afterwards.
+ self.follow_tail(sid)
+ if not view.follow:
+ top = location(
+ text, reader.projection.resolve(
+ view, view.viewport, starts, len(text),
+ fallback=offset(text, previous_top),
+ )
+ )
+ scroll = reader.wrapped_document.location_to_offset(top)
+ reader.scroll_to(scroll.x, scroll.y, animate=False, force=True)
+ self.call_after_refresh(self.finish_follow_layout, sid)
+ self.starts = starts
+ self.rendered_version = view.version
+ self.rendered_clock = None
+ if self.pending_jump and not view.loading:
+ _, role, direction = self.pending_jump
+ self.pending_jump = None
+ self.jump(role, direction=direction)
+ if view:
+ self.refresh_turn_clocks(view, reader)
+ if self.app_focus and not view.loading and self.starts:
+ view.read_tab()
+ reader.parent.sync()
+ self.call_after_refresh(reader.parent.sync)
+ if view is None:
+ for selector in (
+ "#progress",
+ "#usage",
+ "#settings",
+ "#attachments",
+ "#suggestions",
+ ):
+ self.update_chrome(selector, "")
+ ask = self.client._pending_ask_for_attached()
+ async_questions = pending_async(view) if view else []
+ question = ""
+ if ask:
+ options = " ".join(
+ f"{i}. {o['label']}"
+ for i, o in enumerate(ask.get("options", []), 1)
+ )
+ question = (
+ f"? {ask.get('question', '')}\n{options}\n"
+ f"{self.client.keys.label('answer')}: answer"
+ )
+ elif async_questions:
+ count = sum(len(b.data["questions"]) for b in async_questions)
+ question = (
+ f"? {count} non-blocking question(s) · task may continue\n"
+ f"{self.client.keys.label('answer')}: answer without stopping"
+ )
+ question = _safe_remote_text(question)
+ if question != self.question_text:
+ question_widget.update(question)
+ question_widget.display = bool(question)
+ self.question_text = question
+ state = view.state if view else "Select a session"
+ if view:
+ if view.write_state not in {"unknown", "writable"}:
+ state += " · " + view.write_state
+ if view.queue:
+ state += f" · queued {len(view.queue)}"
+ current = view.presentation.turns.get(view.presentation.active)
+ if current:
+ if view.state == "running" and current.status != "running":
+ state += " · Processing (synchronizing turn)"
+ elif view.state != "idle" or current.status != "running":
+ activity, _, elapsed = current.label().rpartition(" · ")
+ state += " · " + elapsed
+ if activity != view.state:
+ state += " · " + " ".join(activity.split())
+ if view.presentation.control.get("reason"):
+ state += " · " + " ".join(
+ view.presentation.control["reason"].split()
+ )
+ reading = " · reading" if view and not view.follow else ""
+ demo = "OFFLINE DEMO · " if self.client.demo else ""
+ pane = "DRAFT" if self.focused is editor else "READ"
+ status = (
+ f"{demo}{pane} {self.mode} · {state}{reading}\n"
+ f"{self.client.keys.label('help')}: help · {self.client.notice}"
+ )
+ status_key = (status, self.size.width)
+ if status_key != self.status_text:
+ status_widget.update(styled_status(status, width=self.size.width))
+ self.status_text = status_key
+
+ def update_chrome(
+ self, selector: str, text: str | Text, color: str = ""
+ ) -> None:
+ rendered = text if isinstance(text, Text) else Text(text, style=color)
+ signature = (rendered.plain, tuple(rendered.spans), rendered.style)
+ if self.chrome_signature.get(selector) != signature:
+ widget = self.query_one(selector, Static)
+ widget.update(rendered)
+ widget.display = bool(text)
+ self.chrome_signature[selector] = signature
+
+ def refresh_turn_clocks(self, view, reader) -> None:
+ """Update elapsed headers without reloading history or the document."""
+ now = time.time()
+ tick = int(now)
+ if self.rendered_clock == tick:
+ return
+ self.rendered_clock = tick
+ seen = set()
+ updates = []
+ for start, block in self.starts:
+ if block.turn in seen:
+ continue
+ seen.add(block.turn)
+ turn = view.presentation.turns.get(block.turn)
+ if not turn or turn.status != "running" or turn.started is None:
+ continue
+ if turn.ended is not None or turn.duration_ms is not None:
+ continue
+ row, _ = reader.document.get_location_from_index(start)
+ old = reader.document.get_line(row)
+ new = view.block_header(
+ block, show_turn=True, now=now,
+ detail_key=self.client.keys.layer_label("reader", "details"),
+ )
+ # Clock updates only replace a single header line. Never touch
+ # multi-line activity text or replace content from another block.
+ if old != new and "\n" not in new:
+ updates.append((start, row, old, new))
+ if not updates:
+ return
+ top = reader.wrapped_document.offset_to_location(reader.scroll_offset)
+ with reader.presentation_update():
+ for _, row, old, new in reversed(updates):
+ reader.replace(
+ new, (row, 0), (row, len(old)), maintain_selection_offset=True
+ )
+ # Earlier headers can gain a digit (9s -> 10s). Rebase block offsets
+ # before remembering selections, or the next data event shifts them.
+ deltas = {start: len(new) - len(old) for start, _, old, new in updates}
+ reader.projection.shift_headers(updates)
+ shift = 0
+ starts = []
+ for start, block in self.starts:
+ starts.append((start + shift, block))
+ shift += deltas.get(start, 0)
+ self.starts = starts
+ if view.follow:
+ self.follow_tail(self.shown_sid)
+ self.call_after_refresh(self.follow_tail, self.shown_sid)
+ else:
+ scroll = reader.wrapped_document.location_to_offset(top)
+ reader.scroll_to(scroll.x, scroll.y, animate=False, force=True)
+ # Clock replacements are presentation only, not user undo history.
+ reader.history.clear()
+ self.read_position_signature = None
+ self.remember()
+
+ def cancel_editor_overlay(self) -> None:
+ if self.command_mode or self.answer_mode:
+ editor = self.query_one(Composer)
+ editor.load_text(self.command_backup)
+ editor.move_cursor(self.command_backup_cursor)
+ self.command_mode = self.answer_mode = False
+
+ def normal(self) -> None:
+ self.cancel_editor_overlay()
+ self.query_one(Composer).set_mode("NORMAL")
+ self.query_one(Transcript).set_mode("NORMAL")
+ self.mode = "NORMAL"
+ self.prefix = ""
+ self.shortcut_prefix = ()
+ reader = self.query_one(Transcript)
+ reader.reconcile_read_cursor()
+ reader.focus(scroll_visible=False)
+ self.remember()
+
+ def action_sessions(self) -> None:
+ self.shortcut_prefix = ()
+ if len(self.screen_stack) > 1:
+ return
+ self.remember()
+ self.prefix = ""
+ self.query_one(Composer).prefix = ""
+ explorer = self.query_one(SessionExplorer)
+ explorer.display = not explorer.display
+ if explorer.display:
+ self.explorer_return_draft = self.focused is self.query_one(
+ Composer
+ )
+ explorer.refresh_catalog()
+ explorer.query_one(SessionTree).focus()
+ else:
+ if self.explorer_return_draft:
+ self.query_one(Composer).focus()
+ else:
+ self.query_one(Transcript).focus()
+
+ async def action_toggle_engine(self) -> None:
+ await self.change_surface(
+ "claude" if self.client.engine == "codex" else "codex",
+ self.client.space,
+ )
+
+ async def action_toggle_space(self) -> None:
+ await self.change_surface(
+ self.client.engine,
+ "work" if self.client.space == "code" else "code",
+ )
+
+ async def change_surface(self, engine: str, space: str) -> None:
+ if len(self.screen_stack) > 1:
+ return
+ from_tree = isinstance(self.focused, (SessionTree, TreeSearch))
+ self.remember()
+ self.normal()
+ await self.client.switch_surface(engine, space)
+ self.paint()
+ if from_tree:
+ self.query_one(SessionTree).focus()
+
+ async def normal_shortcut(self, key: str) -> bool:
+ """Leader actions only in the main Normal/Visual panes, never typing."""
+ if len(self.screen_stack) > 1 or self.command_mode or self.answer_mode:
+ self.shortcut_prefix = ()
+ return False
+ chords = dict(self.client.keys.chords)
+ if self.focused is self.query_one(Transcript):
+ chords.update({tuple(k.split()): LAYERS["reader"][name].action
+ for name, keys in self.client.keys.layers["reader"].items()
+ for k in keys})
+ chord = (*self.shortcut_prefix, key)
+ pending = bool(self.shortcut_prefix)
+ self.shortcut_prefix = ()
+ if action := chords.get(chord):
+ self.prefix = ""
+ self.query_one(Composer).prefix = ""
+ await self.run_action(action)
+ return True
+ if any(keys[: len(chord)] == chord for keys in chords):
+ self.shortcut_prefix = chord
+ self.client.notice = "Shortcut: " + " ".join(chord) + " …"
+ return True
+ if (self.focused is self.query_one(Transcript)
+ and chord in {("g", "e"), ("g", "E")}):
+ reader = self.query_one(Transcript)
+ reader.prefix = "g"
+ reader.edit_key(key)
+ self.stop_following()
+ self.remember()
+ return True
+ return pending # Invalid/cancelled chord never edits the draft.
+
+ def action_cancel_draft_command(self):
+ self.cancel_editor_overlay()
+
+ def action_command_editor(self):
+ self.open_editor()
+
+ def action_latest_message(self, role):
+ self.jump(role)
+ self.remember()
+
+ def action_read_start(self):
+ self.record_jump()
+ self.stop_following()
+ self.query_one(Transcript).move_cursor((0, 0))
+ self.remember()
+
+ async def action_read_follow(self):
+ self.record_jump()
+ sid = self.client.attached_sid
+ if sid:
+ view = self.client.workspace.view(sid)
+ view.follow = True
+ reader = self.query_one(Transcript)
+ reader.move_cursor(reader.document.end)
+ self.follow_tail(sid)
+ if view.tail_hidden:
+ self.client.notice = "Loading newest turns…"
+ await self.client._request_history(sid, force=True)
+ self.remember()
+
+ async def action_read_details(self):
+ if self.client.attached_sid:
+ await self.expand()
+ self.remember()
+
+ def action_read_close(self):
+ if self.mode == "NORMAL" and self.collapse_detail():
+ return
+ reader = self.query_one(Transcript)
+ reader.selection = Selection.cursor(reader.cursor_location)
+ self.normal()
+
+ async def action_read_older(self):
+ sid = self.client.attached_sid
+ if not sid:
+ return
+ view = self.client.workspace.view(sid)
+ block = self.current_block()
+ while block and (parent := view.group_parents.get(block.id)):
+ owner = next((b for _, b in self.starts if b.id == parent), None)
+ if owner is None or owner is block:
+ break
+ block = owner
+ if block and block.role == "detail" and block.expanded:
+ if view.details.get(block.turn):
+ await self.request_detail(block, older=True)
+ elif view.has_more and view.oldest and not view.loading:
+ view.loading = True
+ if not await self.client._send(GetHistory(
+ session_id=sid, detail="summary", before=view.oldest,
+ limit=4, client_id=self.client.client_id,
+ )):
+ view.loading = False
+
+ async def action_read_newer(self):
+ sid = self.client.attached_sid
+ if not sid:
+ return
+ view = self.client.workspace.view(sid)
+ block = self.current_block()
+ while block and (parent := view.group_parents.get(block.id)):
+ owner = next((b for _, b in self.starts if b.id == parent), None)
+ if owner is None or owner is block:
+ break
+ block = owner
+ if (block and block.role == "detail" and block.expanded
+ and view.details_newer.get(block.turn)):
+ await self.request_detail(block, newer=True)
+
+ def action_quote_selection(self) -> None:
+ self.quote()
+
+ def action_toggle_pane(self) -> None:
+ self.shortcut_prefix = ()
+ if len(self.screen_stack) > 1:
+ self.screen.focus_next()
+ return
+ editor = self.query_one(Composer)
+ if isinstance(self.focused, (SessionTree, TreeSearch)):
+ editor.set_mode("NORMAL")
+ editor.focus()
+ return
+ if self.focused is editor:
+ self.action_focus_read()
+ else:
+ self.action_focus_draft()
+
+ def action_focus_draft(self) -> None:
+ self.shortcut_prefix = ()
+ if isinstance(self.focused, (SessionTree, TreeSearch)):
+ self.query_one(SessionExplorer).move_selection(True)
+ return
+ if len(self.screen_stack) > 1:
+ if hasattr(self.screen, "move_selection"):
+ self.screen.move_selection(down=True)
+ return
+ editor = self.query_one(Composer)
+ if self.focused is editor:
+ return
+ self.remember()
+ self.prefix = ""
+ self.shortcut_prefix = ()
+ self.stop_following()
+ editor.set_mode("NORMAL")
+ editor.focus()
+
+ def action_focus_read(self) -> None:
+ self.shortcut_prefix = ()
+ if isinstance(self.focused, (SessionTree, TreeSearch)):
+ self.query_one(SessionExplorer).move_selection(False)
+ return
+ if len(self.screen_stack) > 1:
+ if hasattr(self.screen, "move_selection"):
+ self.screen.move_selection(down=False)
+ return
+ if self.focused is self.query_one(Transcript):
+ self.query_one(Transcript).reconcile_read_cursor()
+ self.remember()
+ return
+ self.remember()
+ self.normal()
+
+ async def pick_session(self, sid: str | None) -> None:
+ if sid is None:
+ return
+ await self.attach_session(sid)
+
+ async def action_cycle_buffer(self, direction: int) -> None:
+ if len(self.screen_stack) != 1:
+ return
+ sid = self.client.buffers.neighbor(self.client.attached_sid, direction)
+ if sid and sid != self.client.attached_sid:
+ await self.attach_session(sid)
+
+ def action_search_buffers(self) -> None:
+ if len(self.screen_stack) == 1:
+ self.remember()
+ self.push_screen(BufferPicker(self.client), self.pick_buffer)
+
+ async def pick_buffer(self, sid) -> None:
+ if sid in self.client.buffers.ids:
+ await self.attach_session(sid)
+
+ async def action_close_buffer(self) -> None:
+ if len(self.screen_stack) != 1:
+ return
+ self.remember()
+ sid = self.client.attached_sid
+ if sid not in self.client.buffers.ids:
+ return
+ target = self.client.buffers.close(sid)
+ self.client.navigation_revision += 1
+ self.client.restore_pending = False
+ self.client._pending_new_request = None
+ # Clear focus before repainting, otherwise paint would reopen the tab.
+ self.client.attached_sid = None
+ if target:
+ await self.attach_session(target)
+ else:
+ self.paint()
+ self.normal()
+ self.client.notice = (
+ "Closed local tab; server session and tasks are unchanged"
+ )
+
+ async def attach_session(self, sid: str) -> None:
+ self.remember()
+ row = self.client.workspace.catalog.get(sid, {})
+ await self.client._attach(sid, row.get("engine", self.client.engine))
+ self.paint()
+ self.normal()
+ self.set_class(self.size.width < 85, "narrow")
+
+ async def action_submit(self) -> None:
+ if len(self.screen_stack) > 1:
+ if hasattr(self.screen, "action_submit"):
+ await self.screen.action_submit()
+ return
+ await self.send(False)
+
+ def action_paste_image(self) -> None:
+ editor = self.query_one("#composer", Composer)
+ if len(self.screen_stack) != 1 or self.focused is not editor:
+ # Keep Textual's ordinary text-paste behavior in modal forms.
+ if isinstance(self.focused, TextArea):
+ self.focused.action_paste()
+ return
+ sid = self.client.attached_sid
+ if not sid:
+ self.client.notice = "Select a session before pasting an image"
+ return
+ if self.clipboard_busy:
+ self.client.notice = "Reading clipboard image…"
+ return
+ self.clipboard_busy = True
+ self.client.notice = "Reading clipboard image…"
+ self.run_worker(self.paste_clipboard_image(sid), group="clipboard")
+
+ async def paste_clipboard_image(self, sid):
+ try:
+ attachment = await read_clipboard_image()
+ # A switch/re-key while the helper runs must not move the image
+ # to another session. Stage only; never send a Query here.
+ sid = self.client.workspace.rekeys.get(sid, sid)
+ view = self.client.workspace.view(sid)
+ candidate = [*view.attachments, attachment]
+ error = validate_attachments(
+ [a["content"] for a in candidate if a["image"]],
+ [a["content"] for a in candidate if not a["image"]],
+ )
+ if error:
+ raise ValueError(error)
+ view.attachments.append(attachment)
+ self.client.notice = (
+ "Image attached to this draft; not sent"
+ if self.client.attached_sid == sid
+ else f"Image attached to original session {sid}; not sent"
+ )
+ except (ValueError, OSError) as exc:
+ self.client.notice = _safe_remote_text(str(exc))
+ finally:
+ self.clipboard_busy = False
+
+ async def action_queue(self) -> None:
+ await self.send(True)
+
+ async def action_stop(self) -> None:
+ # Tree selection and modal targets may differ from the attached chat.
+ # Never stop a background session from one of those local key scopes.
+ if len(self.screen_stack) > 1 or isinstance(
+ self.focused, (SessionTree, TreeSearch)
+ ):
+ return
+ sid = self.client.attached_sid
+ if not sid:
+ self.client.notice = "Select a session first"
+ return
+ view = self.client.workspace.view(sid)
+ if view.write_state != "writable":
+ self.client.notice = f"Stop unavailable: {view.write_state}"
+ return
+ if view.state != "running":
+ self.client.notice = "No running turn to stop"
+ return
+ self.shortcut_prefix = ()
+ if await self.client._send(Interrupt(sid=sid)):
+ # Only the server's terminal event ends the turn. Preserve the
+ # draft, attachments, queue, and reading position in the meantime.
+ self.client.notice = "Stop requested; queued messages are kept"
+
+ async def send(self, queue: bool) -> None:
+ self.shortcut_prefix = ()
+ if isinstance(self.focused, (SessionTree, TreeSearch)):
+ return
+ if len(self.screen_stack) > 1:
+ return
+ if self.submitting:
+ return
+ if queue and (self.command_mode or self.answer_mode):
+ self.client.notice = (
+ "Queue is for prompts; "
+ f"{self.client.keys.label('send')} submits commands/answers"
+ )
+ return
+ editor = self.query_one(Composer)
+ text = editor.text
+ current_view = (
+ self.client.workspace.view(self.client.attached_sid)
+ if self.client.attached_sid
+ else None
+ )
+ if not text.strip() and not (current_view and current_view.attachments):
+ return
+ self.submitting = True
+ self.sample_follow_position()
+ sid = self.client.attached_sid
+ try:
+ if self.command_mode:
+ self.normal()
+ await self.command(text.lstrip(":/"))
+ if self.client._quitting:
+ self.exit()
+ return
+ if text.startswith("/") and text.split(maxsplit=1)[0][1:] in {
+ "goal",
+ "plan",
+ "status",
+ "context",
+ "settings",
+ "queue",
+ "actions",
+ "reports",
+ "background",
+ "notices",
+ "web",
+ "help",
+ *ACTIONS,
+ }:
+ editor.load_text("")
+ self.normal()
+ await self.command(text[1:])
+ return
+ if self.answer_mode:
+ if await self.client.answer(text.strip()):
+ self.normal()
+ return
+ if await self.client.submit(text, queue=queue):
+ sid = self.client.workspace.rekeys.get(sid, sid)
+ if sid:
+ self.client.workspace.view(sid).draft = ""
+ if self.client.attached_sid == sid and editor.text == text:
+ editor.load_text("")
+ if not queue and self.client.attached_sid == sid:
+ self.pending_jump = None
+ self.paint()
+ self.call_after_refresh(self.follow_tail, sid)
+ except (ValueError, OSError) as exc:
+ self.client.notice = _safe_remote_text(exc)
+ finally:
+ self.submitting = False
+
+ def action_answer(self) -> None:
+ self.shortcut_prefix = ()
+ if isinstance(self.focused, (SessionTree, TreeSearch)):
+ return
+ if len(self.screen_stack) > 1:
+ return
+ ask = self.client._pending_ask_for_attached()
+ if not ask:
+ sid = self.client.attached_sid
+ questions = (
+ pending_async(self.client.workspace.view(sid)) if sid else []
+ )
+ if questions:
+ self.remember()
+ self.push_screen(
+ # One native question message per canonical Web envelope.
+ AsyncQuestionDialog(self.client, sid, questions[:1])
+ )
+ return
+ self.client.notice = "No pending question"
+ return
+ self.remember()
+ self.push_screen(QuestionDialog(self.client, ask))
+
+ def open_editor(self, *, answer: bool = False) -> None:
+ if self.command_mode or self.answer_mode:
+ return
+ self.remember()
+ editor = self.query_one(Composer)
+ self.command_backup = editor.text
+ self.command_backup_cursor = editor.cursor_location
+ self.answer_mode = answer
+ self.command_mode = not answer
+ editor.load_text("")
+ editor.set_mode("INSERT")
+ editor.focus()
+ self.mode = "INSERT"
+ self.client.notice = (
+ f"Answer, then {self.client.keys.label('send')}"
+ if answer
+ else (
+ "Command: new /directory | stop | sessions | engine codex | "
+ f"space work | quit; {self.client.keys.label('send')}"
+ )
+ )
+
+ async def read_key(self, key: str) -> bool:
+ # A subsequent reading gesture supersedes an outstanding page jump.
+ self.pending_jump = None
+ reader = self.query_one(Transcript)
+ reader.vim_mode = self.mode
+ if key == "escape" and reader.prefix:
+ reader.prefix = ""
+ return True
+ if (
+ not self.prefix
+ and not reader.prefix
+ and await self.normal_shortcut(key)
+ ):
+ return True
+ # These keys have workspace-specific meanings only outside a pending
+ # Vim command. For example ya( must not open a menu, and yi( must not
+ # focus the draft. Share the exact parser with every text editor.
+ special = {
+ "g",
+ "i",
+ "escape",
+ "v",
+ "V",
+ "j",
+ "k",
+ "h",
+ "l",
+ "ctrl+d",
+ "ctrl+u",
+ }
+ if not self.prefix and (
+ reader.prefix
+ or key not in special
+ or (key == "i" and self.mode.startswith("VISUAL"))
+ ):
+ before = reader.selection
+ if reader.edit_key(key):
+ if (reader.prefix or reader.selection != before
+ or reader.vim_mode.startswith("VISUAL")):
+ self.stop_following()
+ self.remember()
+ return True
+ prefix, self.prefix = self.prefix, ""
+ if prefix:
+ if prefix == "g" and key == "g":
+ self.stop_following()
+ if self.mode == "VISUAL":
+ reader.move_visual((0, 0))
+ else:
+ reader.move_cursor((0, 0))
+ elif prefix == "g" and key in {"e", "E"}:
+ reader.prefix = "g"
+ reader.edit_key(key)
+ self.stop_following()
+ self.remember()
+ return True
+ if key == "g":
+ self.prefix = key
+ elif key == "i":
+ self.client.notice = (
+ f"{self.client.keys.label('focus_draft')} focuses the draft; "
+ "i enters Insert there"
+ )
+ elif key == "escape":
+ reader.selection = Selection.cursor(reader.cursor_location)
+ self.normal()
+ elif key in {"v", "V"}:
+ self.stop_following()
+ self.mode = "VISUAL LINE" if key == "V" else "VISUAL"
+ reader.set_mode(self.mode)
+ if key == "V":
+ row, _ = reader.cursor_location
+ reader.selection = Selection(
+ (row, 0), (row, len(reader.document.get_line(row)))
+ )
+ elif key in {"j", "k", "h", "l", "ctrl+d", "ctrl+u"}:
+ self.stop_following()
+ row, col = reader.vim_cursor_location()
+ step = max(1, reader.size.height // 2)
+ row += {"j": 1, "k": -1, "ctrl+d": step, "ctrl+u": -step}.get(
+ key, 0
+ )
+ col += {"h": -1, "l": 1}.get(key, 0)
+ row = min(max(0, row), reader.document.line_count - 1)
+ col = min(max(0, col), len(reader.document.get_line(row)))
+ if self.mode == "VISUAL LINE":
+ start_row = reader.selection.start[0]
+ if row >= start_row:
+ reader.selection = Selection(
+ (start_row, 0),
+ (row, len(reader.document.get_line(row))),
+ )
+ else:
+ reader.selection = Selection(
+ (start_row, len(reader.document.get_line(start_row))),
+ (row, 0),
+ )
+ reader.scroll_cursor_visible()
+ elif self.mode == "VISUAL":
+ reader.move_visual((row, col))
+ else:
+ reader.move_cursor((row, col))
+ else:
+ if key in {
+ "up",
+ "down",
+ "left",
+ "right",
+ "pageup",
+ "pagedown",
+ "home",
+ }:
+ self.stop_following()
+ return False
+ self.remember()
+ return True
+
+ def jump_position(self):
+ self.remember()
+ view = self.client.workspace.view(self.shown_sid)
+ return view.anchor, view.viewport
+
+ def record_jump(self) -> None:
+ if not self.shown_sid:
+ return
+ view = self.client.workspace.view(self.shown_sid)
+ point = self.jump_position()
+ if not view.jump_back or view.jump_back[-1] != point:
+ view.jump_back.append(point)
+ del view.jump_back[:-100]
+ view.jump_forward.clear()
+
+ def action_jump_history(self, direction: int) -> None:
+ if (len(self.screen_stack) != 1 or not self.shown_sid
+ or self.focused is not self.query_one(Transcript)):
+ return
+ view = self.client.workspace.view(self.shown_sid)
+ source, target = ((view.jump_back, view.jump_forward)
+ if direction < 0
+ else (view.jump_forward, view.jump_back))
+ if not source:
+ return
+ target.append(self.jump_position())
+ del target[:-100]
+ anchor, viewport = source.pop()
+ self.stop_following()
+ self.pending_jump = None
+ reader = self.query_one(Transcript)
+ reader.move_cursor(location(
+ reader.text, reader.projection.resolve(
+ view, anchor, self.starts, len(reader.text)
+ )
+ ))
+ top = location(
+ reader.text, reader.projection.resolve(
+ view, viewport, self.starts, len(reader.text)
+ )
+ )
+ scroll = reader.wrapped_document.location_to_offset(top)
+ reader.scroll_to(scroll.x, scroll.y, animate=False, force=True)
+ self.remember()
+
+ def jump(self, role: str | None = None, *, direction: int = 0) -> bool:
+ reader = self.query_one(Transcript)
+ position = offset(reader.text, reader.cursor_location)
+ candidates = [
+ (start, block)
+ for start, block in self.starts
+ if block.role in {"user", "assistant"}
+ and (role is None or block.role == role)
+ and block.channel != "thinking"
+ ]
+ if role == "assistant" and not direction:
+ latest_turn = candidates[-1][1].turn if candidates else ""
+ candidates = [
+ (start, b) for start, b in candidates if b.turn == latest_turn
+ ]
+ finals = [
+ (start, b) for start, b in candidates if b.channel == "final"
+ ]
+ candidates = finals or candidates
+ elif role == "assistant":
+ # One answer anchor per turn, preferring the final over progress.
+ answers = {}
+ for start, block in candidates:
+ previous = answers.get(block.turn)
+ if previous is None or (
+ block.channel == "final" and previous[1].channel != "final"
+ ):
+ answers[block.turn] = (start, block)
+ candidates = sorted(answers.values(), key=lambda item: item[0])
+ if direction:
+ candidates = [
+ (start, b)
+ for start, b in candidates
+ if (start - position) * direction > 0
+ ]
+ if candidates:
+ start, _ = candidates[0] if direction == 1 else candidates[-1]
+ self.record_jump()
+ self.stop_following()
+ reader.move_cursor(location(reader.text, start), center=True)
+ return True
+ else:
+ self.client.notice = (
+ "No matching message on this page; "
+ f"{self.client.keys.layer_label('reader', 'older')} "
+ "loads older turns"
+ )
+ return False
+
+ async def action_jump_message(self, role: str, direction: int) -> None:
+ self.query_one(Transcript).focus()
+ found = self.jump(role or None, direction=direction)
+ self.remember()
+ sid = self.client.attached_sid
+ if found or direction != -1 or not sid:
+ return
+ view = self.client.workspace.view(sid)
+ if view.has_more and view.oldest and not view.loading:
+ view.loading = True
+ self.pending_jump = (sid, role or None, direction)
+ if not await self.client._send(
+ GetHistory(
+ session_id=sid,
+ detail="summary",
+ before=view.oldest,
+ limit=4,
+ client_id=self.client.client_id,
+ )
+ ):
+ view.loading = False
+ self.pending_jump = None
+
+ def quote(self) -> None:
+ reader = self.query_one(Transcript)
+ selected = reader.selected_text
+ if not selected:
+ self.client.notice = "Select text with v/V first"
+ return
+ editor = self.query_one(Composer)
+ quote = "\n".join("> " + line for line in selected.splitlines())
+ editor.insert(
+ ("\n\n" if editor.text else "") + quote + "\n",
+ location(editor.text, len(editor.text)),
+ maintain_selection_offset=False,
+ )
+ editor.move_cursor(location(editor.text, len(editor.text)))
+ reader.selection = Selection.cursor(reader.cursor_location)
+ self.mode = "NORMAL"
+ self.client.notice = "Added to draft; not sent"
+ self.remember()
+
+ def current_block(self):
+ reader = self.query_one(Transcript)
+ sid = self.client.attached_sid
+ if not sid:
+ return None
+ view = self.client.workspace.view(sid)
+ identity, _ = view.locate(
+ offset(reader.text, reader.cursor_location), self.starts
+ )
+ return next((b for _, b in self.starts if b.id == identity), None)
+
+ def collapse_detail(self) -> bool:
+ block = self.current_block()
+ if not block:
+ return False
+ view = self.client.workspace.view(self.client.attached_sid)
+ parent = view.group_parents.get(block.id)
+ if parent and block.role not in {"detail", "tool_group"}:
+ block = next((b for _, b in self.starts if b.id == parent), block)
+ if (
+ not block.expanded
+ or not (
+ block.role in {"detail", "tool_group", "tool", "process"}
+ or block.channel == "thinking"
+ )
+ ):
+ return False
+ block.expanded = False
+ if block.role == "detail":
+ view.collapsed_details.add(block.turn)
+ view.version += 1
+ self.stop_following()
+ # Collapse to the owning header, not an offset into a vanished body.
+ start = next(
+ start for start, item in self.starts if item.id == block.id
+ )
+ reader = self.query_one(Transcript)
+ reader.move_cursor(location(reader.text, start))
+ self.remember()
+ return True
+
+ async def request_detail(self, block, *, older=False, newer=False) -> None:
+ sid = self.client.attached_sid
+ view = self.client.workspace.view(sid)
+ view.collapsed_details.discard(block.turn)
+ if block.turn and view.revision:
+ await self.client._send(
+ GetTurnDetail(
+ session_id=sid,
+ turn_id=block.turn,
+ revision=view.revision,
+ before=(
+ view.details_newer.get(block.turn) if newer else
+ view.details.get(block.turn) if older else None
+ ),
+ client_id=self.client.client_id,
+ )
+ )
+
+ async def expand(self) -> None:
+ if self.collapse_detail():
+ return
+ block = self.current_block()
+ if not block:
+ return
+ view = self.client.workspace.view(self.client.attached_sid)
+ self.stop_following()
+ if block.role == "tool_group":
+ if block.data.get("request_detail"):
+ await self.request_detail(block)
+ else:
+ block.expanded = True
+ view.version += 1
+ return
+ if block.role in {"assistant", "user"} and (
+ block.channel != "thinking"
+ ) and block.turn:
+ # Following the tail leaves the cursor on the final answer, not
+ # on its earlier folded header. Prefer that same turn's detail.
+ target = next((b for _, b in self.starts
+ if b.role == "detail" and b.turn == block.turn), None)
+ if target:
+ block = target
+ reader = self.query_one(Transcript)
+ start = next(i for i, b in self.starts if b is target)
+ reader.move_cursor(location(reader.text, start))
+ if self.collapse_detail():
+ return
+ if block.role in {"tool", "process"} or block.channel == "thinking":
+ block.expanded = True
+ view.version += 1
+ elif block.role == "detail" and (
+ block.turn in view.details or block.data.get("local")
+ ):
+ block.expanded = True
+ view.collapsed_details.discard(block.turn)
+ view.version += 1
+ else:
+ await self.request_detail(block)
+
+ def action_actions(self) -> None:
+ if len(self.screen_stack) == 1:
+ self.remember()
+ self.push_screen(
+ ActionPicker(self.client, self.client.attached_sid)
+ )
+
+ async def action_diagram_browser(self) -> None:
+ if len(self.screen_stack) != 1 or not self.client.attached_sid:
+ return
+ from cc_remote.tui_diagram_browser import open_session_browser
+ try:
+ self.client.notice = await open_session_browser(
+ self.client, self.client.attached_sid,
+ )
+ except ValueError as error:
+ self.client.notice = str(error)
+
+ def action_preview_files(self) -> None:
+ if len(self.screen_stack) != 1 or not self.client.attached_sid:
+ return
+ self.remember()
+ sid = self.client.attached_sid
+ view = self.client.workspace.view(sid)
+ found = {}
+ for block in reversed(view.blocks):
+ if block.role not in {"assistant", "detail"}:
+ continue
+ for ref in references(block.text):
+ found.setdefault(ref.path, ref)
+ if len(found) == 64:
+ break
+ if len(found) == 64:
+ break
+
+ def chosen(ref):
+ if ref and self.client.attached_sid == sid:
+ self.push_screen(
+ FilePreviewScreen(self.client, sid, ref, self.graphics)
+ )
+
+ refs = list(found.values())
+ if len(refs) == 1:
+ chosen(refs[0])
+ elif refs:
+ self.push_screen(FileHints(refs, open_preview=chosen))
+ else:
+ self.client.notice = (
+ "No Markdown/image paths in loaded assistant messages"
+ )
+
+ def action_new_session(self) -> None:
+ if len(self.screen_stack) != 1:
+ return
+ from cc_remote.tui_settings import SettingsForm
+
+ self.remember()
+ self.push_screen(SettingsForm(self.client, None, new=True))
+
+ def action_choose_setting(self, field: str | None = None) -> None:
+ if len(self.screen_stack) != 1 or not self.client.attached_sid:
+ return
+ from cc_remote.tui_settings import SettingsForm
+
+ self.remember()
+ self.push_screen(
+ SettingsForm(
+ self.client, self.client.attached_sid, initial_field=field
+ )
+ )
+
+ async def action_panel(self, name: str) -> None:
+ if name == "Settings":
+ self.action_choose_setting()
+ return
+ if len(self.screen_stack) > 1 or (
+ not self.client.attached_sid and name != "Help"
+ ):
+ return
+ self.remember()
+ sid = self.client.attached_sid or ""
+ self.push_screen(
+ QueuePanel(self.client, sid)
+ if name == "Queue"
+ else DetailPanel(self.client, sid, name)
+ )
+ await self.client.refresh_panel(sid, name)
+
+ async def command(self, text: str) -> None:
+ parts = text.split(maxsplit=1)
+ if not parts:
+ return
+ name, arg = parts[0], parts[1] if len(parts) > 1 else ""
+ panels = {
+ "goal": "Goal / Plan",
+ "plan": "Goal / Plan",
+ "status": "Status",
+ "context": "Usage / Context",
+ "settings": "Settings",
+ "queue": "Queue",
+ "reports": "Reports",
+ "background": "Background",
+ "notices": "Notices",
+ "web": "Web handoff",
+ }
+ if name in {"engine", "space"}:
+ await self.change_surface(
+ arg if name == "engine" else self.client.engine,
+ arg if name == "space" else self.client.space,
+ )
+ elif name == "sessions":
+ self.action_sessions()
+ elif name == "help":
+ await self.action_panel("Help")
+ elif name == "actions":
+ self.action_actions()
+ elif name == "new" and not arg:
+ self.action_new_session()
+ elif name in panels and not arg:
+ await self.action_panel(panels[name])
+ elif name == "goal" and arg:
+ from cc_remote.tui_panels import ActionForm
+ from cc_remote.protocol import SetGoal
+
+ sid = self.client.attached_sid
+ if not sid:
+ raise ValueError("Select a session first")
+ if arg == "clear":
+ self.push_screen(ActionForm(self.client, sid, "clear_goal"))
+ else:
+ message = (
+ SetGoal(
+ sid=sid,
+ status={"resume": "active", "pause": "paused"}[arg],
+ )
+ if arg in {"resume", "pause"}
+ else SetGoal(sid=sid, objective=arg)
+ )
+ await self.client._send(message)
+ elif name in ACTIONS:
+ from cc_remote.tui_panels import ActionForm
+
+ self.push_screen(
+ ActionForm(self.client, self.client.attached_sid, name)
+ )
+ elif name in {"file", "image", "detach", "attachments"}:
+ sid = self.client.attached_sid
+ if not sid:
+ raise ValueError("Select a session first")
+ view = self.client.workspace.view(sid)
+ if name in {"file", "image"}:
+ attachment = await asyncio.to_thread(
+ read_attachment, arg, image=name == "image"
+ )
+ candidate = [*view.attachments, attachment]
+ error = validate_attachments(
+ [a["content"] for a in candidate if a["image"]],
+ [a["content"] for a in candidate if not a["image"]],
+ )
+ if error:
+ raise ValueError(error)
+ view.attachments.append(attachment)
+ elif name == "detach":
+ if arg == "all":
+ view.attachments.clear()
+ elif arg.isdigit() and 1 <= int(arg) <= len(view.attachments):
+ view.attachments.pop(int(arg) - 1)
+ else:
+ raise ValueError(
+ "detach requires an attachment number or all"
+ )
+ self.client.notice = "Attachments: " + ", ".join(
+ a["name"] for a in view.attachments
+ )
+ else:
+ await self.client._command("/" + text)
+
+
+def seed_demo(client: WorkspaceClient) -> None:
+ """Read/copy/draft preview only: all network writes remain disabled."""
+ client.demo = True
+ client.attached_sid = "demo-review"
+ client.workspace.event(
+ {
+ "type": "session_list",
+ "engine": "codex",
+ "sessions": [
+ {
+ "session_id": "demo-review",
+ "summary": "Review a change",
+ "cwd": "/example/project",
+ "state": "running",
+ "engine": "codex",
+ "last_modified": "2",
+ },
+ {
+ "session_id": "demo-tests",
+ "summary": "Inspect test results",
+ "cwd": "/example/tests",
+ "state": "idle",
+ "engine": "codex",
+ "last_modified": "1",
+ },
+ ],
+ }
+ )
+ for engine, space in sorted(SCOPES - {("codex", "code")}):
+ sid = f"demo-{engine}-{space}"
+ client.workspace.event(
+ {
+ "type": "session_list",
+ "engine": engine,
+ "space": space,
+ "sessions": [
+ {
+ "session_id": sid,
+ "summary": f"{engine.title()} {space.title()} example",
+ "cwd": "/example/" + space,
+ "state": "idle",
+ "last_modified": "1",
+ }
+ ],
+ }
+ )
+ view = client.workspace.view(sid)
+ view.event(
+ {
+ "type": "user_msg",
+ "msg_id": sid,
+ "prompt": "Offline scope example.",
+ }
+ )
+ view.write_state = "writable"
+ client.catalog_ready.update(SCOPES)
+ client.attached_sid = select_session(client.visible_catalog(), None)
+ client.attached_engine = client.engine
+ client.restore_pending = False
+ client.remember_focus()
+ for sid, prompt in (
+ ("demo-review", "Explain this change and its tests."),
+ ("demo-tests", "Summarize the test results."),
+ ):
+ view = client.workspace.view(sid)
+ view.write_state = "writable"
+ view.state = "running" if sid == "demo-review" else "idle"
+ view.event(
+ {
+ "type": "user_msg",
+ "msg_id": sid + "-question",
+ "prompt": prompt,
+ "ts": time.time() - 97,
+ }
+ )
+ view.event(
+ {
+ "type": "delta",
+ "message_id": sid + "-answer",
+ "channel": "final",
+ "text": "This is an offline interaction preview.\n\n"
+ f"Try {client.keys.layer_label('reader', 'latest_user')} / "
+ f"{client.keys.layer_label('reader', 'latest_assistant')} to jump between messages.\n"
+ "Use j/k, v/V and y to select and copy text.\n"
+ f"{client.keys.label('quote')} quotes into the draft without sending.\n"
+ "In the draft, i inserts and Esc returns to Normal.\n"
+ "Use hjkl/w/b, ci(, daw, dd/dw, x, p and u to edit the draft.\n"
+ f"{client.keys.label('focus_draft')} focuses the draft; "
+ f"{client.keys.label('focus_read')} returns here.\n\n"
+ f"{client.keys.label('tree')} opens the left session tree; "
+ f"{client.keys.layer_label('tree', 'search')} searches.\n"
+ f"{client.keys.label('engine')} switches Claude/Codex; "
+ f"{client.keys.label('space')} switches Code/Work.\n"
+ "The active shortcuts are listed in Help.\n"
+ f"{client.keys.label('answer')} answers a pending model question.\n"
+ "No server, model or task is started by this demo.",
+ }
+ )
+ view.event(
+ {
+ "type": "delta",
+ "message_id": sid + "-thinking",
+ "channel": "thinking",
+ "text": "Offline sample thinking summary.\nExpand this block with "
+ + client.keys.layer_label("reader", "details") + ".",
+ }
+ )
+ view.event(
+ {
+ "type": "turn_plan",
+ "item_id": sid + "-plan",
+ "plan": [
+ {"step": "Read the change", "status": "completed"},
+ {"step": "Check regressions", "status": "inProgress"},
+ {"step": "Report results", "status": "pending"},
+ ],
+ }
+ )
+ view.event(
+ {
+ "type": "process",
+ "item_id": sid + "-command",
+ "kind": "command",
+ "title": "Running tests (offline sample)",
+ "status": "running",
+ "phase": "start",
+ "command": "pytest",
+ "output": "Sample output only; nothing is executed.",
+ }
+ )
+ view.event({"type": "model", "model": "Demo model"})
+ view.event({"type": "effort", "effort": "high"})
+ view.event(
+ {
+ "type": "context_report",
+ "total_tokens": 42000,
+ "max_tokens": 128000,
+ "percentage": 32.8125,
+ "available": True,
+ }
+ )
+ view.event(
+ {
+ "type": "rate_limit_update",
+ "primary": {"used_percent": 27},
+ "limit_id": "codex",
+ "secondary": {
+ "used_percent": 41,
+ "window_duration_mins": 10080,
+ },
+ }
+ )
+ view.event(
+ {
+ "type": "rate_limit_update",
+ "limit_id": "codex",
+ "primary": {"window_duration_mins": 300},
+ }
+ )
+ if sid == "demo-review":
+ view.event(
+ {
+ "type": "goal_state",
+ "goal_id": "demo-goal",
+ "goal": {
+ "objective": "Review the example change",
+ "status": "active",
+ "tokensUsed": 124000,
+ "tokenBudget": 500000,
+ "timeUsedSeconds": 97,
+ "engine": "codex",
+ },
+ }
+ )
+ else:
+ view.event(
+ {
+ "type": "turn_end",
+ "result": {
+ "subtype": "success",
+ "is_error": False,
+ "duration_ms": 97000,
+ },
+ "ts": time.time(),
+ }
+ )
+ client.notice = (
+ f"Offline demo · {client.keys.label('help')} help · "
+ f"{client.keys.label('engine')} engine · {client.keys.label('space')} space"
+ )
+
+
+def run_workspace(client: WorkspaceClient, *, demo: bool = False) -> None:
+ def launch(connect=True):
+ # Terminal probes must complete before Textual starts reading stdin.
+ graphics = detect_graphics()
+ app = WorkspaceApp(client, connect=connect)
+ app.graphics = graphics
+ app.run()
+
+ if demo:
+ seed_demo(client)
+ launch(connect=False)
+ return
+ # Local login reuses same-user service configuration without prompting.
+ # Remote login, if needed, completes before Textual takes terminal input.
+ try:
+ asyncio.run(client._authenticate())
+ except Exception as exc:
+ raise ValueError(f"Login failed: {_safe_remote_text(exc)}") from None
+ from cc_remote.tui_tab_store import TabStore
+
+ client.restore_tabs(TabStore(client.url, client.machine_id, client.username))
+ try:
+ launch()
+ finally:
+ client.save_tabs()
diff --git a/cc_remote/tui_attachments.py b/cc_remote/tui_attachments.py
new file mode 100644
index 00000000..8b86eb50
--- /dev/null
+++ b/cc_remote/tui_attachments.py
@@ -0,0 +1,36 @@
+"""Explicit local attachment reads using the wrapper's shared validation."""
+
+import base64
+import mimetypes
+import os
+from pathlib import Path
+import stat
+
+from cc_remote.attachments import (
+ MAX_SINGLE_ATTACHMENT_BYTES,
+ validate_attachments,
+)
+
+
+def read_attachment(path: str, *, image: bool = False) -> dict:
+ source = Path(path).expanduser()
+ fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK)
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
+ os.close(fd)
+ raise ValueError("An attachment must be a regular file")
+ with os.fdopen(fd, "rb") as stream:
+ raw = stream.read(MAX_SINGLE_ATTACHMENT_BYTES + 1)
+ if len(raw) > MAX_SINGLE_ATTACHMENT_BYTES:
+ raise ValueError("One attachment exceeds the 6 MiB limit")
+ data = base64.b64encode(raw).decode("ascii")
+ content = (
+ {"media_type": mimetypes.guess_type(source.name)[0] or "", "data": data}
+ if image
+ else {"filename": source.name, "data": data}
+ )
+ error = validate_attachments(
+ [content] if image else None, None if image else [content]
+ )
+ if error:
+ raise ValueError(error)
+ return {"name": source.name, "image": image, "content": content}
diff --git a/cc_remote/tui_buffers.py b/cc_remote/tui_buffers.py
new file mode 100644
index 00000000..df99d50b
--- /dev/null
+++ b/cc_remote/tui_buffers.py
@@ -0,0 +1,191 @@
+"""Local open-session tabs; never delete, interrupt or take over a session."""
+
+from rich.text import Text
+from textual import events
+from textual.app import ComposeResult
+from textual.containers import Vertical
+from textual.widgets import Label
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_modal import ModalEditor, PickerList, hints
+from cc_remote.tui_panels import ActionPicker
+
+
+class SessionBuffers:
+ def __init__(self):
+ self.ids: list[str] = []
+
+ def open(self, sid):
+ if sid and sid not in self.ids:
+ self.ids.append(sid)
+
+ def rekey(self, old, new):
+ self.ids = list(
+ dict.fromkeys(new if sid == old else sid for sid in self.ids)
+ )
+
+ def neighbor(self, sid, direction):
+ if not self.ids:
+ return None
+ if sid not in self.ids:
+ return self.ids[0]
+ return self.ids[(self.ids.index(sid) + direction) % len(self.ids)]
+
+ def close(self, sid):
+ if sid not in self.ids:
+ return None
+ index = self.ids.index(sid)
+ self.ids.remove(sid)
+ return self.ids[min(index, len(self.ids) - 1)] if self.ids else None
+
+
+def buffer_title(client, sid):
+ row = client.workspace.catalog.get(sid, {})
+ return " ".join(
+ _safe_remote_text(
+ row.get("summary") or row.get("first_prompt") or sid
+ ).split()
+ )
+
+
+def tab_line(client, width):
+ width = max(0, width)
+ if not width:
+ return Text()
+ ids = client.buffers.ids
+ if not ids:
+ line = Text(
+ f"No open sessions · {client.keys.label('tree')}: tree · "
+ f"{client.keys.label('new_session')}: new",
+ style="dim",
+ )
+ line.truncate(width, overflow="ellipsis")
+ return line
+ active = ids.index(client.attached_sid) if client.attached_sid in ids else 0
+ labels = {}
+
+ def label_at(index):
+ if index in labels:
+ return labels[index]
+ sid = ids[index]
+ title = Text(buffer_title(client, sid))
+ # Bound individual prompt-like titles, not the number of visible tabs.
+ title.truncate(32, overflow="ellipsis")
+ view = client.workspace.views.get(sid)
+ badge = view.tab_badge() if view else None
+ labels[index] = Text(
+ f" {index + 1} {title.plain}",
+ "bold white on #334466" if sid == client.attached_sid else "dim",
+ )
+ if badge:
+ color = ("cyan" if badge == "running" else "green"
+ if badge == "completed" else "red")
+ labels[index].append(" ●", f"bold not dim {color}")
+ labels[index].append(" │")
+ return labels[index]
+
+ def markers(start, end):
+ return 2 * (int(start > 0) + int(end < len(ids)))
+
+ start, end = active, active + 1
+ used = label_at(active).cell_len
+ if used + markers(start, end) > width:
+ # Very narrow terminals still show the active tab, not just arrows.
+ label = label_at(active).copy()
+ available = width - markers(start, end)
+ if available < len(str(active + 1)) + 4:
+ label.truncate(width, overflow="ellipsis")
+ return label
+ label.truncate(available, overflow="ellipsis")
+ line = Text("‹ " if start else "")
+ line.append_text(label)
+ if end < len(ids):
+ line.append(" ›")
+ return line
+ while True:
+ choices = []
+ if start:
+ choices.append((start - 1, start - 1, end))
+ if end < len(ids):
+ choices.append((end, start, end + 1))
+ # Grow a contiguous window around focus, trying the other side when
+ # one neighbor is too wide. All costs include CJK cells and markers.
+ choices.sort(key=lambda choice: abs(choice[0] - active))
+ for index, left, right in choices:
+ size = label_at(index).cell_len
+ if used + size + markers(left, right) <= width:
+ start, end = left, right
+ used += size
+ break
+ else:
+ break
+ line = Text("‹ " if start else "")
+ for index in range(start, end):
+ line.append_text(label_at(index))
+ if end < len(ids):
+ line.append(" ›")
+ return line
+
+
+class BufferPicker(ActionPicker):
+ """Search only opened tabs, including tabs in other engines/spaces."""
+
+ def __init__(self, client):
+ super().__init__(client, None, scope="Open sessions")
+ self.initial_insert = True
+
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tui-panel"):
+ yield Label("Open sessions · title / directory / ID", markup=False)
+ yield ModalEditor(
+ classes="search-editor",
+ id="search",
+ placeholder="Search open sessions",
+ )
+ yield PickerList()
+ yield hints()
+
+ def on_mount(self, event: events.Mount):
+ # Install this specialized picker once, without also running the
+ # inherited mount handler. Search remains ready for typing.
+ event.prevent_default()
+ self.install_keys()
+ self.filter("")
+ editor = self.query_one(ModalEditor)
+ editor.focus()
+ editor.set_mode("INSERT")
+
+ def filter(self, value):
+ listing = self.query_one(OptionList)
+ selected = (
+ listing.get_option_at_index(listing.highlighted).id
+ if listing.highlighted is not None
+ else None
+ )
+ listing.clear_options()
+ terms = value.casefold().split()
+ matches = []
+ for sid in self.client.buffers.ids:
+ row = self.client.workspace.catalog.get(sid, {})
+ label = (
+ f"{buffer_title(self.client, sid)} · "
+ f"{row.get('engine', '')}/{row.get('space', 'code')} · "
+ f"{row.get('cwd', '')} · {sid}"
+ )
+ if all(term in label.casefold() for term in terms):
+ listing.add_option(
+ Option(Text(_safe_remote_text(label)), id=sid)
+ )
+ matches.append(sid)
+ listing.highlighted = (
+ matches.index(selected)
+ if selected in matches
+ else (0 if matches else None)
+ )
+
+ def open_form(self, sid):
+ sid = self.client.workspace.rekeys.get(sid, sid)
+ if sid in self.client.buffers.ids:
+ self.dismiss(sid)
diff --git a/cc_remote/tui_chrome.py b/cc_remote/tui_chrome.py
new file mode 100644
index 00000000..04cc2f46
--- /dev/null
+++ b/cc_remote/tui_chrome.py
@@ -0,0 +1,93 @@
+"""Quiet status-line hierarchy: neutral text, one accent, semantic warnings."""
+
+from rich.text import Text
+
+from cc_remote.tui import _safe_remote_text
+
+ACCENT = "#94afc4"
+WARNING = "#c4aa80"
+MUTED = "dim"
+
+
+def setting_style(kind, value):
+ if kind == "model":
+ return f"bold {ACCENT}"
+ if kind in {"perm", "permission_profile"}:
+ if (
+ value in {"never", "bypassPermissions"}
+ or "danger-full-access" in value
+ ):
+ return WARNING
+ return "default"
+ if kind == "effort":
+ return "default"
+ return MUTED
+
+
+def settings_text(presentation):
+ text = Text()
+ for kind, key in (
+ ("model", "model"),
+ ("effort", "effort"),
+ ("perm", "mode"),
+ ("permission_profile", "profile"),
+ ("web_search", "mode"),
+ ("collaboration_mode", "mode"),
+ ):
+ value = presentation.settings.get(kind, {}).get(key)
+ if value:
+ if text:
+ text.append(" · ", MUTED)
+ text.append(
+ _safe_remote_text(value), setting_style(kind, str(value))
+ )
+ if "fast" in presentation.settings:
+ if text:
+ text.append(" · ", MUTED)
+ text.append(
+ "Fast" if presentation.settings["fast"].get("on") else "Standard",
+ ACCENT if presentation.settings["fast"].get("on") else MUTED,
+ )
+ return text
+
+
+def hint_text(value):
+ text = Text()
+ for index, part in enumerate(value.split(" · ")):
+ if index:
+ text.append(" · ", MUTED)
+ key, separator, label = part.partition(":")
+ text.append(key, ACCENT if separator else MUTED)
+ if separator:
+ text.append(separator + label, MUTED)
+ return text
+
+
+def status_text(value, *, width=None):
+ head, _, foot = value.partition("\n")
+ foot = " ".join(foot.split())
+ text = Text()
+ for index, part in enumerate(head.split(" · ")):
+ if index:
+ text.append(" · ", MUTED)
+ if part.startswith(("DRAFT", "READ")):
+ style = f"bold {ACCENT}"
+ elif part in {"read_only", "failed", "interrupted"}:
+ style = WARNING
+ elif (
+ part.startswith(("running", "Processing", "queued "))
+ and part != "queued 0"
+ ):
+ style = ACCENT
+ else:
+ style = MUTED
+ text.append(_safe_remote_text(part), style)
+ if foot:
+ text.append("\n")
+ text.append(hint_text(_safe_remote_text(foot)))
+ if width is not None:
+ lines = text.split("\n")
+ for line in lines:
+ line.truncate(max(1, width), overflow="ellipsis")
+ text = Text("\n").join(lines)
+ return text
diff --git a/cc_remote/tui_clipboard.py b/cc_remote/tui_clipboard.py
new file mode 100644
index 00000000..cc6f2742
--- /dev/null
+++ b/cc_remote/tui_clipboard.py
@@ -0,0 +1,108 @@
+"""Explicit, bounded desktop clipboard image reads; no polling or shell."""
+
+import asyncio
+import base64
+import os
+import shutil
+
+from cc_remote.attachments import (
+ MAX_SINGLE_ATTACHMENT_BYTES,
+ validate_attachments,
+)
+
+TIMEOUT = 3
+IMAGE_TYPES = {
+ "image/png": "png",
+ "image/jpeg": "jpg",
+ "image/jpg": "jpg",
+ "image/webp": "webp",
+}
+
+
+class NoClipboardImage(ValueError):
+ pass
+
+
+async def read_command(args, limit):
+ process = await asyncio.create_subprocess_exec(
+ *args,
+ stdin=asyncio.subprocess.DEVNULL,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL,
+ )
+ try:
+ async with asyncio.timeout(TIMEOUT):
+ data = bytearray()
+ while chunk := await process.stdout.read(
+ min(65536, limit + 1 - len(data))
+ ):
+ data.extend(chunk)
+ if len(data) > limit:
+ raise ValueError("Clipboard data exceeds the size limit")
+ if await process.wait():
+ raise ValueError("Desktop clipboard is unavailable")
+ return bytes(data)
+ except TimeoutError as exc:
+ raise ValueError("Desktop clipboard timed out") from exc
+ finally:
+ if process.returncode is None:
+ try:
+ process.kill()
+ except ProcessLookupError:
+ pass
+ await process.wait()
+
+
+def providers():
+ result = []
+ if os.environ.get("WAYLAND_DISPLAY") and (exe := shutil.which("wl-paste")):
+ result.append(([exe, "--list-types"], [exe, "--no-newline", "--type"]))
+ if os.environ.get("DISPLAY") and (exe := shutil.which("xclip")):
+ base = [exe, "-selection", "clipboard", "-out", "-target"]
+ result.append(([*base, "TARGETS"], base))
+ return result
+
+
+async def read_clipboard_image():
+ choices = providers()
+ if not choices:
+ raise ValueError(
+ "No desktop clipboard: use wl-paste on Wayland or xclip on X11. "
+ "SSH needs access to that desktop; alternatively use :image /path"
+ )
+ failure = None
+ for listing, read in choices:
+ try:
+ offered = (
+ (await read_command(listing, 16384))
+ .decode("utf-8", errors="replace")
+ .splitlines()
+ )
+ except (OSError, ValueError) as exc:
+ failure = exc
+ continue
+ media_type = next((t for t in IMAGE_TYPES if t in offered), None)
+ if not media_type:
+ # A working desktop clipboard is authoritative. Do not accidentally
+ # attach an older image from another display's clipboard.
+ raise NoClipboardImage(
+ "No PNG/JPEG/WebP image in clipboard; use :image /path for files"
+ )
+ raw = await read_command(
+ [*read, media_type], MAX_SINGLE_ATTACHMENT_BYTES
+ )
+ content = {
+ "media_type": media_type,
+ "data": base64.b64encode(raw).decode("ascii"),
+ }
+ error = validate_attachments([content], None)
+ if error:
+ raise ValueError(error)
+ return {
+ "name": "clipboard." + IMAGE_TYPES[media_type],
+ "image": True,
+ "content": content,
+ }
+ raise ValueError(
+ "Cannot access the desktop clipboard; use :image /path"
+ ) from failure
diff --git a/cc_remote/tui_deletion.py b/cc_remote/tui_deletion.py
new file mode 100644
index 00000000..972cb2fc
--- /dev/null
+++ b/cc_remote/tui_deletion.py
@@ -0,0 +1,135 @@
+"""Confirmed native deletion, including Codex Code's archive prerequisite."""
+
+import asyncio
+from dataclasses import dataclass
+import uuid
+
+from cc_remote.protocol import ArchiveSession
+from cc_remote.tui import _safe_remote_text
+
+
+@dataclass
+class Receipt:
+ command: object
+ future: asyncio.Future
+ acked: bool = False
+ confirmed: bool = False
+
+
+class SessionDeletion:
+ TIMEOUT = 30
+
+ def __init__(self, client):
+ self.client = client
+ self.pending = {}
+ self.tasks = {}
+
+ def start(self, command):
+ sid = self.client.workspace.rekeys.get(
+ command.session_id, command.session_id
+ )
+ command = command.model_copy(update={"session_id": sid})
+ if sid in self.tasks:
+ self.client.notice = "Deletion already pending for this session"
+ return False
+ row = self.client.workspace.catalog.get(sid)
+ if row is None or (row.get("engine"), row.get("space", "code")) != (
+ command.engine, command.space
+ ):
+ self.client.notice = "Session scope changed; reopen the session tree"
+ return False
+ archive = (command.engine == "codex" and command.space == "code"
+ and row.get("tag") != "archived")
+ self.tasks[sid] = asyncio.create_task(self.run(command, archive))
+ self.client.notice = (
+ "Archiving before deletion…" if archive
+ else "Delete requested; waiting for server confirmation"
+ )
+ return True
+
+ async def wait_for(self, command):
+ command = command.model_copy(update={
+ "cmd_id": uuid.uuid4().hex, "client_id": self.client.client_id,
+ })
+ receipt = Receipt(command, asyncio.get_running_loop().create_future())
+ self.pending[command.cmd_id] = receipt
+ try:
+ if not await self.client._send(command):
+ raise ValueError("Command could not be queued")
+ await asyncio.wait_for(receipt.future, timeout=self.TIMEOUT)
+ finally:
+ self.pending.pop(command.cmd_id, None)
+ if not receipt.future.done():
+ receipt.future.cancel()
+
+ async def run(self, command, archive):
+ sid = command.session_id
+ stage = "Archive" if archive else "Deletion"
+ try:
+ if archive:
+ await self.wait_for(ArchiveSession(
+ session_id=sid, engine=command.engine,
+ space=command.space, archived=True,
+ ))
+ stage = "Deletion"
+ self.client.notice = "Deleting; waiting for server confirmation…"
+ await self.wait_for(command)
+ # Only the correlated successful deletion may retire a local tab.
+ self.client.workspace.catalog.pop(sid, None)
+ self.client.buffers.close(sid)
+ if self.client.attached_sid == sid:
+ self.client.attached_sid = None
+ self.client.restore_pending = False
+ self.client.save_tabs()
+ self.client.notice = "Session deleted"
+ except TimeoutError:
+ self.client.notice = (
+ f"{stage} not confirmed; refresh before retrying. "
+ + ("Deletion was not sent." if stage == "Archive" else "")
+ )
+ except Exception as exc:
+ self.client.notice = f"{stage} failed: {_safe_remote_text(str(exc))}"
+ finally:
+ self.tasks.pop(sid, None)
+
+ def observe(self, event):
+ kind = event.get("type")
+ if kind == "command_ack":
+ if event.get("client_id") != self.client.client_id:
+ return
+ request = event.get("cmd_id")
+ else:
+ request = event.get("request_id")
+ receipt = self.pending.get(request)
+ if receipt is None or receipt.future.done():
+ return
+ command = receipt.command
+ if kind == "error":
+ receipt.future.set_exception(ValueError(
+ event.get("message") or "Server rejected the command"
+ ))
+ return
+ if kind == "command_ack":
+ receipt.acked = True
+ elif kind == "session_list" and (
+ event.get("engine"), event.get("space", "code")
+ ) == (command.engine, command.space):
+ if any(profile.get("error") for profile in event.get(
+ command.engine + "_profiles", []
+ )):
+ receipt.confirmed = False
+ return # An unavailable catalog cannot prove absence.
+ row = next((row for row in event.get("sessions", [])
+ if row.get("session_id") == command.session_id), None)
+ receipt.confirmed = (
+ row is not None and row.get("tag") == "archived"
+ if command.type == "archive_session" else row is None
+ )
+ # An ACK means handled, not successful. Errors and the exact catalog
+ # reply precede it; neither an unrelated list nor ACK alone is proof.
+ if receipt.acked and receipt.confirmed:
+ receipt.future.set_result(None)
+
+ def close(self):
+ for task in list(self.tasks.values()):
+ task.cancel()
diff --git a/cc_remote/tui_details.py b/cc_remote/tui_details.py
new file mode 100644
index 00000000..98a75480
--- /dev/null
+++ b/cc_remote/tui_details.py
@@ -0,0 +1,95 @@
+"""Readable, bounded descriptions of control state, not protocol dumps."""
+
+import re
+from datetime import datetime
+
+from cc_remote.tui_presentation import bounded, tokens
+
+
+LABELS = {
+ "cwd": "Working directory",
+ "perm": "Approval policy",
+ "permission_profile": "Filesystem permissions",
+ "codex_context": "Context limits",
+ "max_context_tokens": "Context limit",
+ "model_context_window": "Model context window",
+ "model_auto_compact_token_limit": "Automatic compaction threshold",
+ "used_percent": "Consumed",
+ "percentage": "Context used",
+ "resets_at": "Resets at",
+ "window_duration_mins": "Window duration",
+ "primary": "Primary window",
+ "secondary": "Secondary window",
+ "effort": "Reasoning effort",
+ "fast": "Fast mode",
+ "web_search": "Web search",
+ "ds": "Description",
+ "sid": "Session",
+ "cmd_id": "Command ID",
+ "request_id": "Request ID",
+}
+
+
+def field_label(name):
+ return LABELS.get(
+ name,
+ re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
+ .replace("_", " ")
+ .capitalize(),
+ )
+
+
+def scalar(value, key=""):
+ if value is None:
+ return "Not available / default"
+ if isinstance(value, bool):
+ return "Yes" if value else "No"
+ if isinstance(value, (int, float)):
+ if key in {"percentage", "used_percent"}:
+ return f"{value:.0f}%"
+ if key == "resets_at":
+ try:
+ return (
+ datetime.fromtimestamp(value)
+ .astimezone()
+ .strftime("%Y-%m-%d %H:%M:%S %Z")
+ )
+ except (ValueError, OSError, OverflowError):
+ return "Unknown reset time"
+ if key == "window_duration_mins":
+ return (
+ f"{value / 60:g} hours"
+ if value % 60 == 0
+ else f"{value} minutes"
+ )
+ if "token" in key.lower():
+ return f"{tokens(value)} tokens ({value:g})"
+ return str(value)
+
+
+def details(value):
+ """Keep unknown fields visible without exposing JSON as the default UI."""
+
+ def walk(item, depth=0, key=""):
+ indent = " " * depth
+ if isinstance(item, dict):
+ for name, child in item.items():
+ label = field_label(name)
+ if isinstance(child, (dict, list)) and child:
+ yield f"{indent}{label}"
+ yield from walk(child, depth + 1, name)
+ else:
+ yield f"{indent}{label}: {scalar(child, name) if child not in ([], {}) else 'None'}"
+ elif isinstance(item, list):
+ for index, child in enumerate(item, 1):
+ if isinstance(child, (dict, list)):
+ yield f"{indent}• Item {index}"
+ yield from walk(child, depth + 1)
+ else:
+ yield f"{indent}• {scalar(child, key)}"
+ else:
+ yield indent + scalar(item, key)
+
+ if value is None or value == {} or value == []:
+ return "No data available."
+ return "\n".join(walk(bounded(value)))
diff --git a/cc_remote/tui_diagram_browser.py b/cc_remote/tui_diagram_browser.py
new file mode 100644
index 00000000..862237db
--- /dev/null
+++ b/cc_remote/tui_diagram_browser.py
@@ -0,0 +1,53 @@
+"""User-triggered Web handoff; no source uploads, credentials or callbacks."""
+
+import asyncio
+import json
+import re
+from urllib.parse import quote, urlsplit, urlunsplit
+import webbrowser
+
+WIRE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}\Z")
+
+
+def session_browser_url(client, sid):
+ """Reuse Web's notification route; browser login/device checks still apply."""
+ base = urlsplit(client.web_url())
+ row = client.workspace.catalog.get(sid, {})
+ route = {
+ "machine_id": client.machine_id,
+ "session_id": sid,
+ "engine": row.get("engine", client.engine),
+ "space": row.get("space", client.space),
+ }
+ if (
+ base.scheme not in {"http", "https"}
+ or not base.hostname
+ or base.username is not None
+ or base.password is not None
+ or any(
+ not isinstance(route[key], str) or not WIRE_ID.fullmatch(route[key])
+ for key in ("machine_id", "session_id")
+ )
+ or route["engine"] not in {"claude", "codex"}
+ or route["space"] not in {"code", "work"}
+ ):
+ raise ValueError("Select a valid session and HTTP(S) Web endpoint")
+ fragment = "notification=" + quote(
+ json.dumps(route, separators=(",", ":")), safe=""
+ )
+ return urlunsplit(
+ (base.scheme, base.netloc, base.path or "/", "", fragment)
+ )
+
+
+async def open_session_browser(client, sid):
+ url = session_browser_url(client, sid)
+ try:
+ opened = await asyncio.to_thread(webbrowser.open, url, new=2)
+ except (webbrowser.Error, OSError):
+ opened = False
+ return (
+ "Opened session in browser"
+ if opened
+ else "No browser available; open manually: " + url
+ )
diff --git a/cc_remote/tui_diagram_families.py b/cc_remote/tui_diagram_families.py
new file mode 100644
index 00000000..19784a11
--- /dev/null
+++ b/cc_remote/tui_diagram_families.py
@@ -0,0 +1,424 @@
+"""Family-specific text views of Mermaid data, relationships and chronology.
+
+No eval, callbacks, includes, image loads or styling instructions execute here.
+Unrecognized records are explicitly marked rather than silently discarded.
+"""
+
+import csv
+import json
+import math
+import re
+
+from cc_remote.tui_diagram_text import Canvas, label, statements, web_statement
+
+NUMBER = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
+GRAPH_KINDS = {
+ "classDiagram",
+ "classDiagram-v2",
+ "stateDiagram",
+ "stateDiagram-v2",
+ "erDiagram",
+ "requirementDiagram",
+ "requirement",
+ "architecture",
+ "architecture-beta",
+ "block",
+ "block-beta",
+ "swimlane-beta",
+ "eventmodeling",
+}
+TREE_KINDS = {
+ "mindmap",
+ "kanban",
+ "treeView-beta",
+ "treemap",
+ "treemap-beta",
+ "ishikawa",
+ "ishikawa-beta",
+ "wardley-beta",
+ "cynefin-beta",
+ "venn-beta",
+}
+DATA_KINDS = {
+ "pie",
+ "xychart",
+ "xychart-beta",
+ "radar-beta",
+ "quadrantChart",
+ "sankey",
+ "sankey-beta",
+ "packet",
+ "packet-beta",
+}
+
+
+def number(text):
+ try:
+ value = float(text)
+ except (ValueError, OverflowError):
+ return None
+ return value if math.isfinite(value) else None
+
+
+def bars(canvas, rows, *, percentage=False):
+ largest = max((abs(v) for _, v in rows), default=0)
+ # Scale before summing: individually finite values may overflow their sum.
+ total = sum(v / largest for _, v in rows) if largest else 0
+ count = max(1, min(30, canvas.width // 3))
+ for name, value in rows:
+ ratio = abs(value) / largest if largest else 0
+ units = round(ratio * count)
+ suffix = f" · {ratio / total:.1%}" if percentage and total else ""
+ canvas.line(f"{name} · {value:g}{suffix}")
+ canvas.line(("−" if value < 0 else "") + "█" * units, 2, "cyan")
+
+
+class Projection:
+ def __init__(self, document, width):
+ self.document = document
+ self.canvas = Canvas(width, document.kind + " · terminal text")
+ self.browser = document.browser[:]
+ self.unknown = False
+ if document.title:
+ self.canvas.line(document.title, style="bold")
+
+ def source(self, line):
+ self.unknown = True
+ self.canvas.line("Source · " + line, style="dim")
+
+ def rows(self):
+ for row in statements(self.document.body):
+ text = row.strip()
+ if web_statement(text, self.document.kind):
+ self.browser.append(text.split()[0])
+ continue
+ if re.search(r"\b(?:animate|animation|href|callback)\s*:", text):
+ self.browser.append("animation or interaction metadata")
+ if text.startswith(("title ", "title:", "accTitle:", "accDescr:")):
+ self.canvas.line(
+ text.partition(":" if ":" in text else " ")[2], style="bold"
+ )
+ continue
+ yield row, text
+
+ def sequence(self):
+ from cc_remote.tui_diagram_sequence import render_sequence
+
+ render_sequence(self)
+
+ def graph(self):
+ depth = 0
+ for _, text in self.rows():
+ if text in {"}", "end"}:
+ depth = max(0, depth - 1)
+ self.canvas.line("└", depth * 2, "dim")
+ continue
+ # Keep UML cardinalities, crow's-foot notation, direction ports,
+ # stereotypes and relationship labels verbatim alongside the edge.
+ if re.search(
+ r"(?:[-.=]{2,}|[|}][|o}][.-]{2}|\s->\s|<-->|-->|<\|)", text
+ ):
+ self.canvas.line("↳ " + text, depth * 2)
+ elif text.endswith("{"):
+ self.canvas.line("┌ " + label(text[:-1]), depth * 2, "bold")
+ depth = min(16, depth + 1)
+ elif depth:
+ self.canvas.line("│ " + text, depth * 2)
+ elif re.match(
+ r"(?:class|state|namespace|requirement|functionalRequirement|performanceRequirement|interfaceRequirement|physicalRequirement|designConstraint|element|group|service|junction|block|columns|space|lane|pool|event|command|view|actor|swimlane)\b",
+ text,
+ ):
+ self.canvas.box(text)
+ elif re.match(r"(?:direction|hideEmptyMembers)\b", text):
+ self.canvas.line("Layout · " + text, style="dim")
+ elif re.match(r"(?:note|[\w]+\s*:|<<|\[\*\])", text):
+ self.canvas.line(text)
+ elif re.fullmatch(r"\w+", text):
+ self.canvas.box(text)
+ elif self.document.kind in {"block", "block-beta"}:
+ from cc_remote.tui_diagram_flow import (
+ FlowParser,
+ UnsupportedFlowchart,
+ )
+
+ parser = FlowParser("graph TB\n")
+ rest, nodes = text, []
+ try:
+ while rest:
+ identity, rest = parser.node(rest)
+ nodes.append(identity)
+ except UnsupportedFlowchart:
+ self.source(text)
+ else:
+ self.browser.extend(parser.graph.browser)
+ for identity in nodes:
+ self.canvas.box(
+ identity + " · " + parser.graph.nodes[identity]
+ )
+ else:
+ self.source(text)
+
+ def hierarchy(self):
+ levels = []
+ for raw, text in self.rows():
+ indent = len(raw) - len(raw.lstrip())
+ while levels and indent <= levels[-1]:
+ levels.pop()
+ depth = min(len(levels), 16)
+ levels.append(indent)
+ # Preserve attribute values (ticket IDs, assignees, weights, set
+ # membership and coordinates); never infer hierarchy from names.
+ shape = re.match(
+ r"([\w-]*)\s*([\[({]+)(.*?)([\])}]+)(.*)$", text, re.S
+ )
+ value = text
+ if shape:
+ identity, _, caption, _, tail = shape.groups()
+ value = (
+ (identity + " · " if identity else "")
+ + label(caption)
+ + tail
+ )
+ self.canvas.line("├─ " + value, depth * 2)
+
+ def chronology(self):
+ section = ""
+ for _, text in self.rows():
+ if text.startswith("section "):
+ section = label(text[8:])
+ self.canvas.line("── " + section + " ──", style="bold")
+ elif self.document.kind == "gantt" and re.match(
+ r"(?:dateFormat|axisFormat|tickInterval|excludes|includes|todayMarker|weekday|weekend)\b",
+ text,
+ ):
+ self.canvas.line("Schedule · " + text, style="dim")
+ if text.startswith("todayMarker"):
+ self.browser.append("today marker styling")
+ elif ":" in text:
+ name, _, value = text.partition(":")
+ if self.document.kind == "journey":
+ score, _, actors = value.partition(":")
+ self.canvas.box(
+ label(name),
+ [
+ "Score: " + score.strip(),
+ "Actors: " + actors.strip(),
+ ],
+ )
+ elif self.document.kind == "gantt":
+ # Dates, dependency expressions and exclusions are kept
+ # exact: do not fabricate a calendar from incomplete data.
+ self.canvas.box(label(name), ["Schedule: " + value.strip()])
+ else:
+ self.canvas.line(
+ (label(name) or section) + " → " + label(value)
+ )
+ else:
+ self.source(text)
+
+ def data(self):
+ kind = self.document.kind
+ values = []
+ bit = 0
+ for _, text in self.rows():
+ if kind == "pie":
+ if text == "showData":
+ continue
+ match = re.fullmatch(
+ r'(".*?"|[^:]+)\s*:\s*(' + NUMBER + r")", text
+ )
+ if (
+ match
+ and (value := number(match[2])) is not None
+ and value >= 0
+ ):
+ values.append((label(match[1]), value))
+ else:
+ self.source(text)
+ elif kind.startswith("sankey"):
+ try:
+ row = next(csv.reader([text], strict=True))
+ except (csv.Error, StopIteration):
+ row = []
+ if len(row) == 3 and number(row[2]) is not None:
+ self.canvas.line(
+ f"{label(row[0])} ── {row[2].strip()} ─▶ {label(row[1])}"
+ )
+ else:
+ self.source(text)
+ elif kind.startswith("packet"):
+ match = re.fullmatch(r"(\+\d+|\d+(?:-\d+)?)\s*:\s*(.+)", text)
+ if not match:
+ self.source(text)
+ continue
+ size = match[1]
+ if len(size) > 32:
+ self.source(text)
+ continue
+ if size.startswith("+"):
+ start, end = bit, bit + int(size[1:]) - 1
+ else:
+ pieces = size.split("-")
+ start, end = int(pieces[0]), int(pieces[-1])
+ if end < start or end - start > 1_000_000:
+ self.source(text)
+ continue
+ bit = end + 1
+ self.canvas.box(
+ f"Bits {start}–{end} ({end - start + 1})", [label(match[2])]
+ )
+ elif kind.startswith("xychart"):
+ series = re.fullmatch(
+ r'(bar|line)(?:\s+"([^"]*)")?\s*(\[.*\])', text
+ )
+ if series:
+ try:
+ data = json.loads(series[3])
+ except (ValueError, RecursionError):
+ data = None
+ if (
+ not isinstance(data, list)
+ or len(data) > 500
+ or any(
+ isinstance(v, bool)
+ or not isinstance(v, (int, float))
+ or number(str(v)) is None
+ for v in data
+ )
+ ):
+ self.source(text)
+ continue
+ self.canvas.line(series[2] or series[1], style="bold")
+ bars(
+ self.canvas,
+ [(str(i + 1), float(v)) for i, v in enumerate(data)],
+ )
+ elif re.match(r"(?:x-axis|y-axis|horizontal)\b", text):
+ self.canvas.line("Axis · " + text)
+ else:
+ self.source(text)
+ elif kind == "radar-beta":
+ if re.match(
+ r"(?:axis|curve|showLegend|min|max|ticks|graticule)\b", text
+ ):
+ self.canvas.line(text)
+ else:
+ self.source(text)
+ elif kind == "quadrantChart":
+ if re.match(r"(?:x-axis|y-axis|quadrant-[1-4])\b", text):
+ self.canvas.line(text, style="bold")
+ else:
+ point = re.fullmatch(
+ r"(.+?):\s*\[\s*("
+ + NUMBER
+ + r"),\s*("
+ + NUMBER
+ + r")\s*\]",
+ text,
+ )
+ if point and all(
+ number(point[i]) is not None for i in (2, 3)
+ ):
+ self.canvas.line(
+ f"● {label(point[1])} · x={point[2]}, y={point[3]}"
+ )
+ else:
+ self.source(text)
+ if kind == "pie":
+ bars(self.canvas, values, percentage=True)
+
+ def c4(self):
+ depth = 0
+ for _, text in self.rows():
+ if text == "}":
+ depth = max(0, depth - 1)
+ self.canvas.line("└", depth * 2)
+ continue
+ call = re.fullmatch(r"(\w+)\s*\((.*)\)\s*(\{)?", text, re.S)
+ if not call:
+ self.source(text)
+ continue
+ try:
+ args = next(csv.reader([call[2]], skipinitialspace=True))
+ except (csv.Error, StopIteration):
+ self.source(text)
+ continue
+ if call[1].startswith(("Rel", "BiRel")) and len(args) >= 2:
+ arrow = "↔" if call[1].startswith("BiRel") else "→"
+ self.canvas.line(
+ f"{args[0]} {arrow} {args[1]} · " + " · ".join(args[2:]),
+ depth * 2,
+ )
+ elif call[1] in {
+ "LAYOUT_TOP_DOWN",
+ "LAYOUT_LEFT_RIGHT",
+ "SHOW_LEGEND",
+ }:
+ self.browser.append("graphical layout/legend")
+ else:
+ self.canvas.box(
+ call[1] + " · " + (args[0] if args else ""),
+ args[1:],
+ depth * 2,
+ )
+ if call[3]:
+ depth = min(16, depth + 1)
+
+ def git(self):
+ branch, index = "main", 0
+ for _, text in self.rows():
+ match = re.match(
+ r"(branch|checkout|switch|commit|merge|cherry-pick)\b\s*(.*)",
+ text,
+ )
+ if not match:
+ if text in {"LR:", "TB:", "BT:", "LR", "TB", "BT"}:
+ self.canvas.line("Layout · " + text, style="dim")
+ else:
+ self.source(text)
+ continue
+ action, args = match.groups()
+ if action in {"checkout", "switch"}:
+ branch = label(args)
+ self.canvas.line("│ checkout " + branch)
+ elif action == "branch":
+ self.canvas.line(f"├─ branch {args} from {branch}")
+ # Native gitGraph creates and checks out the new branch.
+ branch = label(args.split(" order:", 1)[0])
+ else:
+ index += 1
+ self.canvas.line(f"● {index} [{branch}] {action} {args}")
+
+
+def render_family(document, width, browser_key):
+ projection = Projection(document, width)
+ kind = document.kind
+ if kind == "sequenceDiagram":
+ projection.sequence()
+ elif kind in GRAPH_KINDS:
+ projection.graph()
+ elif kind in TREE_KINDS or kind == "zenuml":
+ projection.hierarchy()
+ elif kind in DATA_KINDS:
+ projection.data()
+ elif kind in {"gantt", "journey", "timeline"}:
+ projection.chronology()
+ elif kind.startswith("C4"):
+ projection.c4()
+ elif kind == "gitGraph":
+ projection.git()
+ elif kind.startswith("railroad"):
+ for _, text in projection.rows():
+ projection.canvas.line("Production · " + text.replace("::=", "→"))
+ elif kind == "info":
+ projection.canvas.line("Mermaid source information · terminal renderer")
+ projection.browser.append("engine version information")
+ for _, text in projection.rows():
+ projection.source(text)
+ else:
+ for _, text in projection.rows():
+ projection.source(text)
+ if projection.unknown:
+ projection.browser.append("unprojected syntax (source rows retained)")
+ projection.canvas.browser_hint(browser_key, projection.browser)
+ return projection.canvas.text
diff --git a/cc_remote/tui_diagram_flow.py b/cc_remote/tui_diagram_flow.py
new file mode 100644
index 00000000..31843bc5
--- /dev/null
+++ b/cc_remote/tui_diagram_flow.py
@@ -0,0 +1,268 @@
+"""Flowchart structure and relations, independent of graphical layout/CSS."""
+
+from dataclasses import dataclass, field
+import re
+
+from cc_remote.tui_diagram_text import (
+ DiagramLimit,
+ bounded,
+ diagram_source,
+ label,
+ statements,
+ web_statement,
+)
+
+MAX_NODES = 64
+MAX_EDGES = 128
+IDENTIFIER = re.compile(r"[\w](?:[\w./]|:(?!::)|-(?![-.=>ox]))*", re.UNICODE)
+# Longer delimiters win: shapes only change presentation, never node identity.
+SHAPES = (
+ ("(((", ")))"),
+ ("((", "))"),
+ ("([", "])"),
+ ("[[", "]]"),
+ ("[(", ")]"),
+ ("{{", "}}"),
+ ("[/", "/]"),
+ ("[\\", "\\]"),
+ ("[", "]"),
+ ("(", ")"),
+ ("{", "}"),
+ (">", "]"),
+)
+ARROW = re.compile(r"(?:<|o|x)?(?:-\.+-+|-{2,}|={2,}|~{3,})(?:>|o|x)?")
+
+
+class UnsupportedFlowchart(ValueError):
+ pass
+
+
+@dataclass
+class Flowchart:
+ direction: str
+ nodes: dict[str, str] = field(default_factory=dict)
+ edges: list[tuple[str, str, str]] = field(default_factory=list)
+ groups: dict[str, tuple[str, str | None]] = field(default_factory=dict)
+ membership: dict[str, str] = field(default_factory=dict)
+ browser: list[str] = field(default_factory=list)
+ annotations: list[str] = field(default_factory=list)
+
+
+class FlowParser:
+ def __init__(self, source):
+ bounded(source)
+ document = diagram_source(source)
+ if not document or document.kind not in {
+ "graph",
+ "flowchart",
+ "flowchart-elk",
+ }:
+ raise UnsupportedFlowchart()
+ match = re.match(r"\s*(TB|TD|BT|LR|RL)\b", document.body)
+ if not match:
+ raise UnsupportedFlowchart()
+ self.graph = Flowchart(match[1], browser=document.browser[:])
+ if document.title:
+ self.graph.annotations.append(document.title)
+ self.body, self.groups = document.body[match.end() :], []
+
+ def node(self, text):
+ text = text.lstrip()
+ match = IDENTIFIER.match(text)
+ if not match:
+ raise UnsupportedFlowchart()
+ identity, rest = match[0], text[match.end() :].lstrip()
+ value = None
+ if rest.startswith("@{"):
+ # Shape/icon/image metadata is inert. Images/icons remain labelled
+ # text; fetching an image or executing a callback is never allowed.
+ end = self.metadata_end(rest)
+ metadata, rest = rest[2:end], rest[end + 1 :].lstrip()
+ match = re.search(
+ r'\blabel\s*:\s*("(?:\\.|[^"\\])*"|[^,}]+)', metadata
+ )
+ value = label(match[1]) if match else identity
+ if re.search(r"\b(?:img|icon|animate|animation)\s*:", metadata):
+ self.graph.browser.append("node/edge graphics or animation")
+ else:
+ for opening, closing in SHAPES:
+ if not rest.startswith(opening):
+ continue
+ start = len(opening)
+ if opening in {"[/", "[\\"}:
+ # Parallelograms and trapezoids have either slanted end.
+ closings = [c for c in ("/]", "\\]") if c in rest[start:]]
+ if not closings:
+ raise UnsupportedFlowchart()
+ closing = min(closings, key=lambda c: rest.find(c, start))
+ if rest[start : start + 1] == '"':
+ end = start + 1
+ while end < len(rest):
+ if rest[end] == "\\":
+ end += 2
+ elif rest[end] == '"':
+ break
+ else:
+ end += 1
+ if not rest.startswith(closing, end + 1):
+ raise UnsupportedFlowchart()
+ value, rest = (
+ label(rest[start : end + 1]),
+ rest[end + 1 + len(closing) :],
+ )
+ else:
+ end = rest.find(closing, start)
+ if end < 0:
+ raise UnsupportedFlowchart()
+ value, rest = (
+ label(rest[start:end]),
+ rest[end + len(closing) :],
+ )
+ break
+ if rest.lstrip().startswith(":::"):
+ match = re.match(r"\s*:::[\w,-]+", rest)
+ if not match:
+ raise UnsupportedFlowchart()
+ rest = rest[match.end() :]
+ self.graph.browser.append("node styles")
+ self.graph.nodes.setdefault(identity, identity)
+ if value is not None:
+ self.graph.nodes[identity] = value
+ if len(self.graph.nodes) > MAX_NODES:
+ raise DiagramLimit("Too many flowchart nodes")
+ if self.groups:
+ self.graph.membership[identity] = self.groups[-1]
+ return identity, rest.lstrip()
+
+ @staticmethod
+ def metadata_end(text):
+ quote, depth, index = None, 0, 1
+ while index < len(text):
+ char = text[index]
+ if quote:
+ if char == "\\":
+ index += 2
+ continue
+ if char == quote:
+ quote = None
+ elif char in {'"', "'"}:
+ quote = char
+ elif char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return index
+ index += 1
+ raise UnsupportedFlowchart()
+
+ def node_set(self, text):
+ node, text = self.node(text)
+ nodes = [node]
+ while text.startswith("&"):
+ node, text = self.node(text[1:])
+ nodes.append(node)
+ return nodes, text
+
+ def parse(self):
+ graph = self.graph
+ for statement in statements(self.body, brackets=True):
+ text = statement.strip()
+ if web_statement(text, "flowchart"):
+ graph.browser.append(text.split()[0])
+ continue
+ if re.match(r"(?:direction|accTitle|accDescr)\b", text):
+ # Accessibility metadata is preserved as an explicit annotation.
+ graph.annotations.append(text)
+ continue
+ if text == "end":
+ if not self.groups:
+ raise UnsupportedFlowchart()
+ self.groups.pop()
+ continue
+ if text.startswith("subgraph "):
+ definition = text[9:].strip()
+ parent = self.groups[-1] if self.groups else None
+ # A bare multi-word title is also a legal anonymous subgraph.
+ if not any(c in definition for c in "[({"):
+ identity, title = definition, label(definition)
+ else:
+ identity, rest = self.node(definition)
+ if rest:
+ raise UnsupportedFlowchart()
+ title = graph.nodes.pop(identity)
+ graph.membership.pop(identity, None)
+ graph.groups[identity] = (title, parent)
+ self.groups.append(identity)
+ if len(self.groups) > 16 or len(graph.groups) > MAX_NODES:
+ raise DiagramLimit("Too many nested subgraphs")
+ continue
+ left, rest = self.node_set(text)
+ while rest:
+ edge_id = re.match(r"[\w]+@(?=[<.=-])", rest)
+ if edge_id:
+ rest = rest[edge_id.end() :]
+ # Mermaid accepts both -->|label| and -- label --> (also
+ # -. label .-> and == label ==>).
+ inline = re.match(
+ r"(--|==|-\.)\s+(.+?)\s+(--+>|==+>|\.->)", rest, re.S
+ )
+ edge_label = ""
+ if inline:
+ arrow = {"--": "-->", "==": "==>", "-.": "-.->"}[inline[1]]
+ edge_label, rest = (
+ label(inline[2]),
+ rest[inline.end() :].lstrip(),
+ )
+ else:
+ match = ARROW.match(rest)
+ if not match:
+ raise UnsupportedFlowchart()
+ arrow, rest = match[0], rest[match.end() :].lstrip()
+ if rest.startswith("|"):
+ end = rest.find("|", 1)
+ if end < 0:
+ raise UnsupportedFlowchart()
+ edge_label, rest = (
+ label(rest[1:end]),
+ rest[end + 1 :].lstrip(),
+ )
+ right, rest = self.node_set(rest)
+ notation = "↔" if arrow.startswith("<") else "→"
+ if not arrow.endswith((">", "o", "x")):
+ notation = "←" if arrow.startswith("<") else "─"
+ notes = [edge_label] if edge_label else []
+ if "." in arrow:
+ notes.append("dotted")
+ if "=" in arrow:
+ notes.append("thick")
+ if "~" in arrow:
+ notes.append("invisible layout link")
+ if "o" in arrow or "x" in arrow:
+ notes.append("endpoints " + arrow)
+ if notation != "→":
+ notes.insert(0, notation)
+ for a in left:
+ for b in right:
+ if len(graph.edges) >= MAX_EDGES:
+ raise DiagramLimit("Too many flowchart edges")
+ if notation == "←":
+ a_edge, b_edge = b, a
+ else:
+ a_edge, b_edge = a, b
+ graph.edges.append((a_edge, b_edge, " · ".join(notes)))
+ left = right
+ if self.groups or not (graph.nodes or graph.groups):
+ raise UnsupportedFlowchart()
+ # Subgraph IDs are endpoints too, not duplicate anonymous nodes.
+ for identity, (title, _) in graph.groups.items():
+ if identity in graph.nodes:
+ graph.nodes[identity] = "Group: " + title
+ return graph
+
+
+def parse_flowchart(source):
+ try:
+ return FlowParser(source).parse()
+ except DiagramLimit as error:
+ raise UnsupportedFlowchart(str(error)) from error
diff --git a/cc_remote/tui_diagram_sequence.py b/cc_remote/tui_diagram_sequence.py
new file mode 100644
index 00000000..a24013f5
--- /dev/null
+++ b/cc_remote/tui_diagram_sequence.py
@@ -0,0 +1,315 @@
+"""Cell-aligned sequence diagrams; no browser or graphics dependency."""
+
+import re
+
+from rich.cells import cell_len
+from rich.text import Text
+
+from cc_remote.tui_diagram_text import DiagramLimit, MAX_OUTPUT, label
+
+MESSAGE = re.compile(
+ r"([^\s:]+?)\s*(<<-->>|<<->>|--?>>?|--?\)|--?[xX])"
+ r"\s*([+-]?[^\s:]+)\s*:\s*(.*)",
+ re.S,
+)
+PARTICIPANT = re.compile(
+ r"(?:(create)\s+)?(participant|actor)\s+([^\s@]+)"
+ r"(?:\s+as\s+(.+))?\Z",
+ re.S,
+)
+NOTE = re.compile(
+ r"note\s+(over|left of|right of)\s+([^:]+):\s*(.*)",
+ re.I | re.S,
+)
+BLOCK = re.compile(r"(?:loop|alt|opt|par|critical|break|rect|box)\b")
+
+
+def arrow_head(arrow, right):
+ if arrow.lower().endswith("x"):
+ return "×"
+ if arrow.endswith(")"):
+ return "▷" if right else "◁"
+ if arrow.endswith(">>"):
+ return "▶" if right else "◀"
+ return "┤" if right else "├"
+
+
+class Sequence:
+ def __init__(self, projection):
+ self.projection = projection
+ self.canvas = projection.canvas
+ self.people = {}
+ self.events = []
+ self.active = {}
+ self.dead = set()
+ self.unborn = set()
+ self.pending_destroy = None
+ self.depth = 0
+ self.counter = None
+ self.saved_counter = 1
+ self.increment = 1
+
+ def person(self, identity):
+ if identity not in self.people:
+ if len(self.people) >= 64:
+ raise DiagramLimit("Too many sequence participants")
+ self.people[identity] = identity
+
+ def parse(self):
+ for _, text in self.projection.rows():
+ participant = PARTICIPANT.fullmatch(text)
+ message = MESSAGE.fullmatch(text)
+ note = NOTE.fullmatch(text)
+ if participant:
+ create, kind, identity, alias = participant.groups()
+ self.person(identity)
+ self.people[identity] = (
+ "actor · " if kind == "actor" else ""
+ ) + (alias or identity)
+ if create:
+ self.unborn.add(identity)
+ self.events.append(("create", identity))
+ elif message:
+ sender, arrow, receiver, value = message.groups()
+ change = receiver[0] if receiver[0] in "+-" else ""
+ receiver = receiver.lstrip("+-")
+ self.person(sender)
+ self.person(receiver)
+ self.events.append(
+ ("message", sender, arrow, receiver, value, change)
+ )
+ elif note:
+ position, identities, value = note.groups()
+ people = [v.strip() for v in identities.split(",")]
+ if len(people) > 2 or any(not p for p in people):
+ self.events.append(("source", text))
+ continue
+ for identity in people:
+ self.person(identity)
+ self.events.append(("note", position.lower(), people, value))
+ elif re.fullmatch(r"(?:activate|deactivate|destroy)\s+\S+", text):
+ action, identity = text.split()
+ self.person(identity)
+ self.events.append((action, identity))
+ elif BLOCK.match(text):
+ self.events.append(("open", text))
+ if text.startswith(("rect ", "box ")):
+ self.projection.browser.append("sequence group styling")
+ elif text == "end":
+ self.events.append(("close",))
+ elif re.match(r"(?:else|and|option)\b", text):
+ self.events.append(("branch", text))
+ elif re.fullmatch(
+ r"autonumber(?:\s+(?:off|resume|\d{1,32}(?:\s+\d{1,32})?))?",
+ text,
+ ):
+ self.events.append(("number", text.split()[1:]))
+ else:
+ self.events.append(("source", text))
+
+ def emit(self, row):
+ # Rows are already cell-aligned. Do not pass them through label(),
+ # which would decode entities a second time or strip literal quotes.
+ self.canvas.text.append(row.rstrip() + "\n")
+ if len(self.canvas.text) > MAX_OUTPUT:
+ raise DiagramLimit("Sequence exceeds terminal output limit")
+
+ def base(self):
+ row = [" "] * self.width
+ for identity, x in self.positions.items():
+ if identity not in self.dead | self.unborn:
+ row[x] = "┃" if self.active.get(identity, 0) else "│"
+ for level in range(min(self.depth, self.margin)):
+ row[level] = "│"
+ row[-1 - level] = "│"
+ return row
+
+ def caption(self, value, left, right, *, frame=False):
+ if frame:
+ self.rule("╭", "╮", left, right)
+ left, right = left + 1, right - 1
+ available = max(2, right - left + 1)
+ for part in label(value).splitlines() or [""]:
+ for line in Text(part).wrap(self.canvas.console, available):
+ row = self.base()
+ if frame:
+ row[left - 1], row[right + 1] = "│", "│"
+ padding = max(0, (available - line.cell_len) // 2)
+ self.emit(
+ "".join(row[:left])
+ + " " * padding
+ + line.plain
+ + " " * (available - padding - line.cell_len)
+ + "".join(row[left + available :])
+ )
+ if frame:
+ self.rule("╰", "╯", left - 1, right + 1)
+
+ def rule(self, start, end, left, right):
+ row = self.base()
+ row[left : right + 1] = ["─"] * (right - left + 1)
+ row[left], row[right] = start, end
+ self.emit("".join(row))
+
+ def headers(self):
+ rows = [
+ Text(label(name)).wrap(self.canvas.console, self.lane - 1)
+ for name in self.people.values()
+ ]
+ for index in range(max(map(len, rows), default=0)):
+ cells = []
+ for parts in rows:
+ text = parts[index].plain if index < len(parts) else ""
+ missing = self.lane - cell_len(text)
+ cells.append(
+ " " * (missing // 2) + text + " " * (missing - missing // 2)
+ )
+ self.emit(" " * self.margin + "".join(cells))
+ self.emit("".join(self.base()))
+
+ def message(self, sender, arrow, receiver, value, change):
+ a, b = self.positions[sender], self.positions[receiver]
+ left, right = sorted((a, b))
+ if self.counter is not None:
+ value = f"{self.counter}. {value}"
+ self.counter += self.increment
+ stroke = "┄" if "--" in arrow else "─"
+ if sender == receiver:
+ side = 1 if self.width - a > 4 else -1
+ end = a + side * min(5, self.lane // 2)
+ left, right = sorted((a, end))
+ self.caption(
+ value,
+ max(self.margin, left - 2),
+ min(self.width - self.margin - 1, right + 2),
+ )
+ row = self.base()
+ row[left : right + 1] = [stroke] * (right - left + 1)
+ row[a] = "├" if side == 1 else "┤"
+ row[end] = "╮" if side == 1 else "╭"
+ self.emit("".join(row))
+ row[end] = "│"
+ for i in range(left + 1, right):
+ row[i] = " "
+ row[a] = "┃" if self.active.get(sender) else "│"
+ self.emit("".join(row))
+ row[left : right + 1] = [stroke] * (right - left + 1)
+ row[end] = "╯" if side == 1 else "╰"
+ row[a] = arrow_head(arrow, side < 0)
+ else:
+ self.caption(value, left + 1, right - 1)
+ row = self.base()
+ row[left : right + 1] = [stroke] * (right - left + 1)
+ row[a] = (
+ ("◀" if a < b else "▶")
+ if arrow.startswith("<<")
+ else ("├" if a < b else "┤")
+ )
+ row[b] = arrow_head(arrow, a < b)
+ self.emit("".join(row))
+ if change == "+":
+ self.active[receiver] = self.active.get(receiver, 0) + 1
+ elif change == "-":
+ self.active[sender] = max(0, self.active.get(sender, 0) - 1)
+ if self.pending_destroy:
+ row = self.base()
+ row[self.positions[self.pending_destroy]] = "×"
+ self.emit("".join(row))
+ self.dead.add(self.pending_destroy)
+ self.pending_destroy = None
+ self.emit("".join(self.base()))
+
+ def render(self):
+ self.parse()
+ count = max(1, len(self.people))
+ self.margin = 2 if self.canvas.width >= count * 5 + 4 else 0
+ self.lane = (self.canvas.width - 2 * self.margin) // count
+ if self.lane < 4:
+ self.canvas.line("Narrow terminal: sequence records", style="dim")
+ for identity, name in self.people.items():
+ self.canvas.line(f"{identity}: {name}")
+ for event in self.events:
+ if event[0] == "source":
+ self.projection.source(event[1])
+ else:
+ self.canvas.line(" · ".join(map(str, event)))
+ return
+ self.width = self.lane * count + 2 * self.margin
+ self.positions = {
+ name: self.margin + i * self.lane + self.lane // 2
+ for i, name in enumerate(self.people)
+ }
+ self.headers()
+ for event in self.events:
+ action, *args = event
+ if action == "message":
+ self.message(*args)
+ elif action == "note":
+ position, people, value = args
+ a, b = sorted(
+ self.positions[p] for p in (people[0], people[-1])
+ )
+ if position == "left of":
+ left, right = self.margin, max(self.margin + 3, a - 1)
+ elif position == "right of":
+ left, right = min(a + 1, self.width - 4), self.width - 1
+ else:
+ left = max(self.margin, a - self.lane // 2)
+ right = min(self.width - 1, b + self.lane // 2 - 1)
+ self.caption("Note: " + value, left, right, frame=True)
+ elif action in {"open", "branch", "close"}:
+ # Saturate only drawing indentation, never logical nesting.
+ level = min(
+ max(0, self.margin - 1),
+ max(0, self.depth - (action != "open")),
+ )
+ self.caption(
+ args[0] if args else "end",
+ level + 1,
+ self.width - level - 2,
+ )
+ corners = {
+ "open": ("┌", "┐"),
+ "branch": ("├", "┤"),
+ "close": ("└", "┘"),
+ }
+ self.rule(*corners[action], level, self.width - level - 1)
+ if action == "open":
+ self.depth += 1
+ elif action == "close":
+ self.depth = max(0, self.depth - 1)
+ elif action == "number":
+ parts = args[0]
+ if parts == ["off"]:
+ self.counter = None
+ elif parts == ["resume"]:
+ self.counter = self.saved_counter
+ else:
+ self.counter = int(parts[0]) if parts else 1
+ self.increment = int(parts[1]) if len(parts) > 1 else 1
+ elif action in {"activate", "deactivate"}:
+ identity = args[0]
+ self.active[identity] = max(
+ 0,
+ self.active.get(identity, 0)
+ + (1 if action == "activate" else -1),
+ )
+ self.emit("".join(self.base()))
+ elif action == "create":
+ self.unborn.discard(args[0])
+ self.caption(
+ "create " + args[0],
+ self.margin,
+ self.width - self.margin - 1,
+ )
+ elif action == "destroy":
+ self.pending_destroy = args[0]
+ else:
+ self.projection.source(args[0])
+ if self.counter is not None:
+ self.saved_counter = self.counter
+
+
+def render_sequence(projection):
+ sequence = Sequence(projection)
+ sequence.render()
diff --git a/cc_remote/tui_diagram_text.py b/cc_remote/tui_diagram_text.py
new file mode 100644
index 00000000..44a530b2
--- /dev/null
+++ b/cc_remote/tui_diagram_text.py
@@ -0,0 +1,246 @@
+"""Bounded text primitives for inert Mermaid projections, not a JS runtime."""
+
+from dataclasses import dataclass, field
+from html import unescape
+import re
+
+from rich.console import Console
+from rich.text import Text
+
+from cc_remote.tui import _safe_remote_text
+
+MAX_SOURCE = 32 * 1024
+MAX_STATEMENTS = 500
+MAX_OUTPUT = 128 * 1024
+ENTITY = re.compile(
+ r"(?:&(?:#[xX][0-9a-fA-F]{1,8}|#\d{1,10}|[A-Za-z][A-Za-z0-9]{0,31})"
+ r"|#\d{1,10});"
+)
+
+
+class DiagramLimit(ValueError):
+ pass
+
+
+def label(value):
+ value = value.strip()
+ if value.startswith('"') and value.endswith('"'):
+ value = value[1:-1]
+ if value.startswith("`") and value.endswith("`"):
+ value = value[1:-1]
+ value = re.sub(r"
", "\n", value, flags=re.I)
+ # Mermaid's numeric entities also allow #NN; without the HTML ampersand.
+ value = re.sub(r"(? MAX_SOURCE
+ or len(source.encode("utf-8", errors="surrogatepass")) > MAX_SOURCE
+ or len(source.splitlines()) > MAX_STATEMENTS
+ ):
+ raise DiagramLimit("Diagram exceeds terminal source limits")
+
+
+def statements(source, *, brackets=False):
+ """Split outside quotes/labels; comments and directives are separate rows.
+
+ Braces are deliberately not structural here: class/state blocks and
+ flowchart shape metadata assign different meanings to the same character.
+ """
+ result, start, index, quote, stack = [], 0, 0, None, []
+ while index < len(source):
+ char = source[index]
+ if quote:
+ if char == "\\" and index + 1 < len(source):
+ index += 2
+ continue
+ if char == quote:
+ quote = None
+ elif char in {'"', "`"}:
+ quote = char
+ elif char in "" and (entity := ENTITY.match(source, index)):
+ # An encoded semicolon in message text is not a statement break.
+ index = entity.end()
+ continue
+ elif source.startswith("%%", index) and not stack:
+ if source[start:index].strip():
+ result.append(source[start:index])
+ if source.startswith("%%{", index):
+ end = source.find("}%%", index + 3)
+ end = len(source) if end < 0 else end + 3
+ result.append(source[index:end])
+ else:
+ end = source.find("\n", index)
+ end = len(source) if end < 0 else end
+ start = index = end
+ continue
+ elif brackets and char in "[({":
+ stack.append({"[": "]", "(": ")", "{": "}"}[char])
+ elif brackets and stack and char == stack[-1]:
+ stack.pop()
+ elif char in "\n;" and not stack:
+ if source[start:index].strip():
+ result.append(source[start:index])
+ start = index + 1
+ index += 1
+ if len(result) > MAX_STATEMENTS:
+ raise DiagramLimit("Too many diagram statements")
+ if source[start:].strip():
+ result.append(source[start:])
+ return result
+
+
+@dataclass
+class DiagramSource:
+ kind: str
+ body: str
+ title: str = ""
+ browser: list[str] = field(default_factory=list)
+
+
+# Match the diagram families registered by the pinned Web dependency. Aliases
+# are normalized by the projection dispatcher, not by executing Mermaid.
+KINDS = (
+ "flowchart-elk",
+ "flowchart",
+ "graph",
+ "sequenceDiagram",
+ "classDiagram-v2",
+ "classDiagram",
+ "stateDiagram-v2",
+ "stateDiagram",
+ "erDiagram",
+ "requirementDiagram",
+ "requirement",
+ "gitGraph",
+ "gantt",
+ "pie",
+ "journey",
+ "timeline",
+ "mindmap",
+ "kanban",
+ "quadrantChart",
+ "xychart-beta",
+ "xychart",
+ "sankey-beta",
+ "sankey",
+ "packet-beta",
+ "packet",
+ "radar-beta",
+ "block-beta",
+ "block",
+ "architecture-beta",
+ "architecture",
+ "C4Context",
+ "C4Container",
+ "C4Component",
+ "C4Dynamic",
+ "C4Deployment",
+ "treeView-beta",
+ "treemap-beta",
+ "treemap",
+ "swimlane-beta",
+ "eventmodeling",
+ "ishikawa-beta",
+ "ishikawa",
+ "venn-beta",
+ "wardley-beta",
+ "cynefin-beta",
+ "railroad-ebnf-beta",
+ "railroad-abnf-beta",
+ "railroad-peg-beta",
+ "railroad-beta",
+ "zenuml",
+ "info",
+)
+HEADER = re.compile(r"\s*(" + "|".join(map(re.escape, KINDS)) + r")\b")
+WEB_STATEMENT = re.compile(
+ r"^(?:click|style|classDef|linkStyle|cssClass|callback|link|links|"
+ r"Update\w*Style|update\w*Style)\b|^%%\{"
+)
+
+
+def diagram_source(source):
+ bounded(source)
+ source = source.lstrip()
+ browser, title = [], ""
+ if source.startswith("---\n") or source.startswith("---\r\n"):
+ lines = source.splitlines(keepends=True)
+ end = next(
+ (i for i, line in enumerate(lines[1:], 1) if line.strip() == "---"),
+ None,
+ )
+ if end is None:
+ return None
+ frontmatter = "".join(lines[1:end])
+ match = re.search(r"(?m)^title:\s*(.+)$", frontmatter)
+ title = label(match[1]) if match else ""
+ if re.search(r"(?m)^config\s*:", frontmatter):
+ browser.append("frontmatter configuration")
+ source = "".join(lines[end + 1 :]).lstrip()
+ while source.startswith("%%"):
+ if source.startswith("%%{"):
+ end = source.find("}%%")
+ if end < 0:
+ return None
+ browser.append("initialization directive")
+ source = source[end + 3 :].lstrip()
+ else:
+ source = source.partition("\n")[2].lstrip()
+ match = HEADER.match(source)
+ if not match:
+ return None
+ return DiagramSource(match[1], source[match.end() :], title, browser)
+
+
+class Canvas:
+ def __init__(self, width, title):
+ self.width = max(8, min(int(width), 240))
+ self.console = Console(width=self.width)
+ self.text = Text()
+ self.line(title, style="bold")
+
+ def line(self, value="", indent=0, style=""):
+ prefix = " " * min(indent, max(0, self.width - 8))
+ for row in Text(label(str(value)), style=style).wrap(
+ self.console,
+ self.width - len(prefix),
+ ):
+ self.text.append(prefix)
+ self.text.append_text(row)
+ self.text.append("\n")
+ if len(self.text) > MAX_OUTPUT:
+ raise DiagramLimit("Diagram exceeds terminal output limit")
+
+ def box(self, title, lines=(), indent=0):
+ indent = min(indent, max(0, self.width - 8))
+ width = self.width - indent - 4
+ rows = []
+ for value in (title, *lines):
+ for part in label(value).splitlines() or [""]:
+ rows.extend(Text(part).wrap(self.console, width))
+ size = max((row.cell_len for row in rows), default=1)
+ self.line("╭" + "─" * (size + 2) + "╮", indent, "dim")
+ for row in rows:
+ self.line(
+ "│ " + row.plain + " " * (size - row.cell_len) + " │", indent
+ )
+ self.line("╰" + "─" * (size + 2) + "╯", indent, "dim")
+
+ def browser_hint(self, shortcut, reasons):
+ if reasons:
+ self.line(
+ "Web-only: " + ", ".join(dict.fromkeys(reasons)), style="dim"
+ )
+ self.line(f"[{shortcut}: open session in browser]", style="cyan")
+
+
+def web_statement(statement, kind):
+ if WEB_STATEMENT.match(statement):
+ return True
+ # 'class' is a semantic declaration in UML, but a CSS assignment in graphs.
+ return kind.startswith(("flowchart", "graph")) and bool(
+ re.match(r"class\s", statement)
+ )
diff --git a/cc_remote/tui_directories.py b/cc_remote/tui_directories.py
new file mode 100644
index 00000000..459aea1a
--- /dev/null
+++ b/cc_remote/tui_directories.py
@@ -0,0 +1,360 @@
+"""fzf matching over wrapper-owned directory listings, without a shell."""
+
+import asyncio
+import os
+import shutil
+from collections import deque
+from functools import partial
+
+from rich.text import Text
+from textual.containers import Vertical
+from textual.widgets import Label, OptionList, Static
+from textual.widgets.option_list import Option
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_modal import ModalEditor, PickerList, hints
+from cc_remote.tui_panels import ActionPicker
+
+
+async def fuzzy_directories(paths: list[str], query: str) -> list[str]:
+ """Use fzf's real ranking while retaining the workspace's Vim key layers."""
+ binary = shutil.which("fzf")
+ if not binary:
+ raise ValueError(
+ "fzf is required for directory selection; install fzf first"
+ )
+ # Personal fzf bindings/default commands may execute programs. This picker
+ # only filters a supplied, NUL-delimited list and never executes shell code.
+ env = {
+ key: value
+ for key, value in os.environ.items()
+ if not key.startswith("FZF_")
+ }
+ process = await asyncio.create_subprocess_exec(
+ binary,
+ "--read0",
+ "--print0",
+ "--filter=" + query,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ )
+ try:
+ output, _ = await asyncio.wait_for(
+ process.communicate("\0".join(paths).encode() + b"\0"),
+ timeout=3,
+ )
+ if process.returncode not in (0, 1):
+ raise ValueError("fzf could not filter directories")
+ allowed = set(paths)
+ return [path for path in output.decode().split("\0") if path in allowed]
+ finally:
+ if process.returncode is None:
+ try:
+ process.kill()
+ except ProcessLookupError:
+ pass
+ await process.wait()
+
+
+class DirectoryPicker(ActionPicker):
+ """Recursively browse bounded, fresh wrapper listings while filtering."""
+
+ MAX_READS = 256
+ MAX_PATHS = 8192
+ REFRESH_SECONDS = 5
+
+ local_actions = {
+ "cancel",
+ "search",
+ "edit",
+ "down",
+ "up",
+ "parent",
+ "open",
+ "refresh",
+ }
+
+ def __init__(self, client, path: str):
+ super().__init__(client, None)
+ self.path = path
+ self.parent_path = None
+ self.paths = []
+ self.matches = []
+ self.loading = False
+ self.load_revision = 0
+ self.filter_revision = 0
+ self.filter_worker = None
+ self.load_worker = None
+ self.select_worker = None
+ self.matched_query = None
+ self.selected_match = None
+ self.scanning = False
+ self.scan_note = ""
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label(
+ "Directory · fzf · "
+ + self.client.keys.layer_label("picker", "choose")
+ + " selects; "
+ + self.client.keys.layer_label("picker", "parent") + "/"
+ + self.client.keys.layer_label("picker", "open")
+ + " browses", markup=False
+ )
+ yield Label(self.path, id="directory-path", markup=False)
+ yield ModalEditor(
+ classes="search-editor",
+ id="search",
+ placeholder="Fuzzy search directories",
+ )
+ yield PickerList()
+ yield Static("", id="directory-status", markup=False)
+ yield hints()
+
+ def on_mount(self):
+ self.action_search()
+ self.start_load(self.path, refresh=True)
+ self.set_interval(self.REFRESH_SECONDS, self.refresh_if_idle)
+
+ def refresh_if_idle(self):
+ if self.app.screen is self and not self.scanning and not self.loading:
+ self.start_load(self.path, refresh=True, preserve=True)
+
+ def filter(self, value):
+ if not self.is_mounted or not self.display:
+ return
+ self.filter_revision += 1
+ revision = self.filter_revision
+ if self.filter_worker:
+ self.filter_worker.cancel()
+ listing = self.query_one(OptionList)
+ if self.matches and listing.highlighted is not None:
+ self.selected_match = self.matches[listing.highlighted]
+ # Retain selection while fresh listings arrive for the same query.
+ # After typing, stale highlighted matches must never be selectable.
+ if value != self.matched_query:
+ listing.clear_options()
+ self.matches = []
+ self.selected_match = None
+ self.filter_worker = self.run_worker(
+ partial(self.apply_filter, value, revision)
+ )
+
+ async def apply_filter(self, query, revision):
+ await asyncio.sleep(0.04)
+ if not self.is_mounted or not self.display:
+ return
+ try:
+ matches = await fuzzy_directories(self.paths, query)
+ except (ValueError, OSError, TimeoutError) as exc:
+ if (revision == self.filter_revision
+ and self.is_mounted and self.display):
+ self.query_one("#directory-status", Static).update(str(exc))
+ return
+ if (revision != self.filter_revision or self.loading
+ or not self.is_mounted or not self.display):
+ return
+ self.matches = matches
+ self.matched_query = query
+ listing = self.query_one(OptionList)
+ listing.clear_options()
+ for index, path in enumerate(matches):
+ label = path + (
+ " [current directory]" if path == self.path else ""
+ )
+ listing.add_option(
+ Option(Text(_safe_remote_text(label)), id=str(index))
+ )
+ listing.highlighted = (
+ matches.index(self.selected_match) if self.selected_match in matches
+ else (0 if matches else None)
+ )
+ self.query_one("#directory-status", Static).update(
+ f"{len(matches)} matches · recursive · {self.scan_note}"
+ )
+
+ def start_load(self, path, *, refresh=True, preserve=False):
+ # Called from Mount before Textual sets is_mounted.
+ if not self.display:
+ return
+ self.loading = not preserve
+ self.load_revision += 1
+ self.filter_revision += 1
+ if self.filter_worker:
+ self.filter_worker.cancel()
+ if self.load_worker:
+ self.load_worker.cancel()
+ if not preserve:
+ self.matches = []
+ self.query_one(OptionList).clear_options()
+ self.query_one(ModalEditor).load_text("")
+ self.query_one("#directory-status", Static).update(
+ "Reading wrapper directories…"
+ )
+ self.load_worker = self.run_worker(
+ partial(self.load, path, self.load_revision, refresh)
+ )
+
+ async def load(self, path, revision, refresh):
+ self.scanning = True
+ try:
+ await self.scan(path, revision, refresh)
+ finally:
+ if revision == self.load_revision:
+ self.scanning = False
+
+ async def scan(self, path, revision, refresh):
+ try:
+ result = await self.client.list_directories(path, refresh=refresh)
+ except (ValueError, OSError) as exc:
+ if (revision == self.load_revision
+ and self.is_mounted and self.display):
+ self.loading = False
+ self.query_one("#directory-status", Static).update(
+ _safe_remote_text(str(exc))
+ )
+ return
+ if (revision != self.load_revision
+ or not self.is_mounted or not self.display):
+ return
+ self.path, self.parent_path = result["path"], result.get("parent")
+ candidates = [
+ self.path,
+ *(row.get("path") for row in result.get("dirs", [])),
+ ]
+ candidates += [
+ row.get("cwd") for row in self.client.workspace.catalog.values()
+ ]
+ self.paths = list(
+ dict.fromkeys(
+ path
+ for path in candidates
+ if isinstance(path, str)
+ and path.startswith("/")
+ and "\0" not in path
+ )
+ )[:self.MAX_PATHS]
+ self.loading = False
+ self.query_one("#directory-path", Label).update(
+ _safe_remote_text(self.path)
+ )
+ pending = deque(row.get("path") for row in result.get("dirs", []))
+ visited = {self.path}
+ requested = {self.path}
+ count = 1
+ self.scan_note = "scanning…"
+ self.filter(self.query_one(ModalEditor).text.replace("\n", " "))
+ # Resolve each listing on the wrapper. Never walk the TUI host, escape
+ # the selected subtree through symlinks, or launch unbounded RPCs.
+ async def read(child):
+ try:
+ return await self.client.list_directories(child, refresh=True)
+ except (ValueError, OSError):
+ return None
+
+ while (pending and count < self.MAX_READS
+ and len(self.paths) < self.MAX_PATHS):
+ batch = []
+ while pending and len(batch) < min(4, self.MAX_READS - count):
+ child = pending.popleft()
+ if (not isinstance(child, str) or child in requested
+ or not child.startswith(self.path.rstrip("/") + "/")):
+ continue
+ requested.add(child)
+ batch.append(child)
+ if not batch:
+ continue
+ count += len(batch)
+ pages = await asyncio.gather(*(read(child) for child in batch))
+ if (revision != self.load_revision
+ or not self.is_mounted or not self.display):
+ return
+ for page in pages:
+ if len(self.paths) >= self.MAX_PATHS:
+ break
+ if not page:
+ continue
+ canonical = page["path"]
+ if (canonical in visited or not canonical.startswith(
+ self.path.rstrip("/") + "/")):
+ continue
+ visited.add(canonical)
+ for row in page.get("dirs", []):
+ child = row.get("path")
+ if (isinstance(child, str) and "\0" not in child
+ and child.startswith(self.path.rstrip("/") + "/")
+ and child not in self.paths):
+ self.paths.append(child)
+ pending.append(child)
+ if len(self.paths) >= self.MAX_PATHS:
+ break
+ # Publish batches without waiting for slow/unreadable descendants.
+ self.filter(self.query_one(ModalEditor).text.replace("\n", " "))
+ await asyncio.sleep(0.05)
+ if not self.is_mounted or not self.display:
+ return
+ self.scan_note = (
+ "scan limit reached; narrow the root directory"
+ if pending else "live refresh every 5s"
+ )
+ self.filter(self.query_one(ModalEditor).text.replace("\n", " "))
+
+ def selected_path(self):
+ listing = self.query_one(OptionList)
+ query = self.query_one(ModalEditor).text.replace("\n", " ")
+ if (
+ self.loading
+ or listing.highlighted is None
+ or query != self.matched_query
+ ):
+ return None
+ item = listing.get_option_at_index(listing.highlighted)
+ index = int(item.id)
+ return self.matches[index] if index < len(self.matches) else None
+
+ def action_open(self):
+ if path := self.selected_path():
+ self.start_load(path)
+
+ def action_parent(self):
+ if self.parent_path and not self.loading:
+ self.start_load(self.parent_path)
+
+ def action_refresh(self):
+ self.start_load(self.path, refresh=True, preserve=True)
+
+ def open_form(self, name):
+ if path := self.selected_path():
+ if self.load_worker:
+ self.load_worker.cancel()
+ self.loading = True
+ self.query_one("#directory-status", Static).update(
+ "Checking selected directory…"
+ )
+ self.select_worker = self.run_worker(partial(self.select, path))
+
+ async def select(self, path):
+ try:
+ result = await self.client.list_directories(path, refresh=True)
+ except (ValueError, OSError) as exc:
+ if not self.is_mounted or not self.display:
+ return
+ self.loading = False
+ self.query_one("#directory-status", Static).update(
+ _safe_remote_text(str(exc))
+ )
+ return
+ if self.is_mounted and self.display:
+ self.dismiss((result["path"],))
+
+ def on_unmount(self):
+ self.filter_revision += 1
+ self.load_revision += 1
+ if self.filter_worker:
+ self.filter_worker.cancel()
+ if self.load_worker:
+ self.load_worker.cancel()
+ if self.select_worker:
+ self.select_worker.cancel()
diff --git a/cc_remote/tui_fields.py b/cc_remote/tui_fields.py
new file mode 100644
index 00000000..a380e64c
--- /dev/null
+++ b/cc_remote/tui_fields.py
@@ -0,0 +1,126 @@
+"""Named protocol fields with plain text editing and explicit advanced data."""
+
+import json
+
+from textual.containers import VerticalScroll
+from textual.widgets import Label
+
+from cc_remote.tui_details import details, field_label
+from cc_remote.tui_modal import ModalEditor
+
+
+def field_schema(schema, root):
+ if "$ref" in schema:
+ return field_schema(
+ root.get("$defs", {}).get(schema["$ref"].rsplit("/", 1)[-1], {}),
+ root,
+ )
+ return schema
+
+
+class ParameterFields(VerticalScroll):
+ DEFAULT_CSS = """
+ ParameterFields { height: 1fr; }
+ ParameterFields .parameter { height: 4; min-height: 3; }
+ ParameterFields .complex { height: 7; }
+ ParameterFields Label { height: auto; max-height: 100%; }
+ """
+
+ def __init__(self, values, schema):
+ super().__init__()
+ self.values, self.schema = values, schema
+ self.rows = []
+ self.advanced = set()
+
+ def compose(self):
+ for index, (key, value) in enumerate(self.values.items()):
+ spec = field_schema(
+ self.schema.get("properties", {}).get(key, {}), self.schema
+ )
+ variants = [
+ field_schema(s, self.schema) for s in spec.get("anyOf", [spec])
+ ]
+ types = {s.get("type") for s in variants}
+ choices = [v for s in variants for v in s.get("enum", [])]
+ complex_value = bool(types & {"object", "array"})
+ if complex_value and value == "":
+ value = [] if "array" in types else {}
+ text = (
+ details(value)
+ if complex_value
+ else ""
+ if value is None
+ else "yes"
+ if value is True
+ else "no"
+ if value is False
+ else str(value)
+ )
+ info = " / ".join(str(v) for v in choices)
+ if not info:
+ info = " / ".join(sorted(t for t in types if t)) or "text"
+ if "null" in types:
+ info += "; blank = default"
+ if complex_value:
+ info += "; advanced editor available via form shortcuts"
+ yield Label(f"{field_label(key)} · {info}", markup=False)
+ editor = ModalEditor(
+ text,
+ id=f"parameter-{index}",
+ locked=complex_value or key == "session_id",
+ classes="parameter complex" if complex_value else "parameter",
+ )
+ self.rows.append((key, types, value, text, editor))
+ yield editor
+
+ def focus_editor(self):
+ for _, _, _, _, editor in self.rows:
+ if not editor.locked:
+ editor.focus()
+ return editor
+ if self.rows:
+ self.rows[0][-1].focus()
+ return None
+
+ def toggle_advanced(self, focused):
+ for key, types, value, _, editor in self.rows:
+ if editor is focused and types & {"object", "array"}:
+ if key in self.advanced:
+ return
+ editor.load_text(
+ json.dumps(value, ensure_ascii=False, indent=2)
+ )
+ editor.locked = False
+ editor.set_mode("INSERT")
+ self.advanced.add(key)
+ return
+
+ def payload(self):
+ values = {}
+ for key, types, original, initial, editor in self.rows:
+ raw = editor.text
+ if key in self.advanced:
+ try:
+ values[key] = json.loads(raw)
+ except ValueError as exc:
+ raise ValueError(
+ f"{field_label(key)}: invalid advanced data"
+ ) from exc
+ elif raw == initial or editor.locked:
+ values[key] = original
+ elif not raw.strip() and "null" in types:
+ values[key] = None
+ elif "boolean" in types:
+ if raw.strip().lower() not in {"yes", "no", "true", "false"}:
+ raise ValueError(f"{field_label(key)}: enter yes or no")
+ values[key] = raw.strip().lower() in {"yes", "true"}
+ elif "string" in types or not types - {None}:
+ values[key] = raw
+ else:
+ try:
+ values[key] = json.loads(raw)
+ except ValueError as exc:
+ raise ValueError(
+ f"{field_label(key)}: enter a number"
+ ) from exc
+ return json.dumps(values, ensure_ascii=False)
diff --git a/cc_remote/tui_graphics.py b/cc_remote/tui_graphics.py
new file mode 100644
index 00000000..1f988cb9
--- /dev/null
+++ b/cc_remote/tui_graphics.py
@@ -0,0 +1,103 @@
+"""Stable, individually owned Kitty placements for textual-image 0.13.2."""
+
+from math import ceil, floor
+
+from textual_image.renderable import tgp
+from textual_image.widget._base import Image as BaseImage
+
+
+class OwnedTGP(tgp.Image):
+ placement_size = None
+ source_box = None
+ viewport_size = None
+ placement_signature = None
+ segments_signature = None
+ segments = ()
+
+ def __rich_console__(self, console, options):
+ size = self.viewport_size or self._render_size.get_cell_size(
+ options.max_width, options.max_height, tgp.get_cell_size()
+ )
+ if max(size) > len(tgp._NUMBER_TO_DIACRITIC):
+ raise ValueError("Image too large to render")
+ if self.terminal_image_id is None:
+ self.upload_original()
+ box = self.source_box or (
+ 0, 0, self._image_data.width, self._image_data.height,
+ )
+ signature = (self.terminal_image_id, size, box)
+ if signature != self.placement_signature:
+ x, y, right, bottom = box
+ # One named virtual placement, updated in place without deleting
+ # the uploaded pixels. Kitty applies cropping/scaling itself.
+ tgp._send_tgp_message(
+ a="p", i=self.terminal_image_id, p=1, U=1, q=2, C=1,
+ c=size[0], r=size[1], x=x, y=y, w=right-x, h=bottom-y,
+ )
+ self.placement_signature = signature
+ self.placement_size = size
+ # Scrolling moves these same Unicode cells; do not rebuild the dense
+ # diacritic strings on every repaint of an unchanged image.
+ signature = (self.terminal_image_id, size)
+ if signature != self.segments_signature:
+ self.segments = tuple(self._render_diacritics(*size))
+ self.segments_signature = signature
+ yield from self.segments
+
+ def upload_original(self):
+ # Avoid the dependency's redundant full-size resize/copies and its
+ # quadratic slicing of the unconsumed PNG payload on every chunk.
+ data = self._image_data.to_base64()
+ self.terminal_image_id = next(tgp.Image._image_id_counter)
+ for offset in range(0, len(data), 4096):
+ tgp._send_tgp_message(
+ i=self.terminal_image_id, f=100, q=2,
+ m=int(offset + 4096 < len(data)),
+ payload=data[offset:offset + 4096],
+ )
+
+ def cleanup(self):
+ if self.terminal_image_id is not None:
+ # The dependency sends a=d,I=, which defaults to deleting all
+ # visible placements. IDs require d=I,i=, not image numbers.
+ # https://sw.kovidgoyal.net/kitty/graphics-protocol/#deleting-images
+ tgp._send_tgp_message(
+ a="d", d="I", i=self.terminal_image_id, q=2
+ )
+ self.terminal_image_id = None
+
+
+class StableTGPImage(BaseImage, Renderable=OwnedTGP):
+ """Keep uploaded pixels until image replacement or cache eviction."""
+
+ render_size = None
+ viewport = None
+
+ def set_view(self, source, box, columns, rows):
+ if self.image is not source:
+ self.image = source
+ viewport = ((floor(box[0]), floor(box[1]),
+ ceil(box[2]), ceil(box[3])), (columns, rows))
+ if self.viewport != viewport:
+ self.viewport = viewport
+ self.refresh()
+
+ def render(self):
+ if self.image is None:
+ return ""
+ size = self._get_styled_size()
+ if self._renderable is None:
+ self._renderable = self._Renderable(self.image, *size)
+ elif self.render_size != size:
+ self._renderable._render_size = tgp.ImageSize(
+ self._image_width, self._image_height, *size,
+ )
+ self.render_size = size
+ if self.viewport:
+ self._renderable.source_box, self._renderable.viewport_size = (
+ self.viewport
+ )
+ return self._renderable
+
+ def on_unmount(self):
+ self.image = None
diff --git a/cc_remote/tui_image_viewer.py b/cc_remote/tui_image_viewer.py
new file mode 100644
index 00000000..bff6fcb9
--- /dev/null
+++ b/cc_remote/tui_image_viewer.py
@@ -0,0 +1,132 @@
+"""Bounded source-pixel image navigation; never fetch again to pan or zoom."""
+
+from dataclasses import dataclass
+
+from PIL import Image
+from textual.containers import Container
+from textual.message import Message
+from textual_image._terminal import get_cell_size
+
+
+@dataclass
+class ImageView:
+ width: int
+ height: int
+ zoom: float = 1
+ x: float = 0.5
+ y: float = 0.5
+
+ def bounds(self, pixels):
+ scale = min(pixels[0] / self.width, pixels[1] / self.height)
+ scale *= self.zoom
+ width = min(self.width, pixels[0] / scale)
+ height = min(self.height, pixels[1] / scale)
+ left = max(0, min(self.width - width, self.x * self.width - width / 2))
+ top = max(0, min(
+ self.height - height, self.y * self.height - height / 2,
+ ))
+ self.x = (left + width / 2) / self.width
+ self.y = (top + height / 2) / self.height
+ return (left, top, left + width, top + height), scale
+
+ def pan(self, dx, dy, pixels):
+ box, _ = self.bounds(pixels)
+ self.x += dx * (box[2] - box[0]) / self.width * 0.15
+ self.y += dy * (box[3] - box[1]) / self.height * 0.15
+ self.bounds(pixels)
+
+
+class ImageCanvas(Container, can_focus=True, inherit_bindings=False):
+ DEFAULT_CSS = """
+ ImageCanvas { height: 1fr; width: 1fr; overflow: hidden hidden;
+ align: center middle; }
+ """
+ BINDINGS = []
+
+ class Changed(Message):
+ def __init__(self, zoom):
+ super().__init__()
+ self.zoom = zoom
+
+ def __init__(self, factory, source):
+ super().__init__()
+ self.source = source
+ self.view = ImageView(*source.size)
+ self.picture = factory(None)
+ self.frame = None
+ self.signature = None
+ self.pending = False
+
+ def compose(self):
+ yield self.picture
+
+ def pixels(self):
+ cell = get_cell_size()
+ # Kitty's Unicode placeholder coordinates have a bounded alphabet.
+ return (max(1, min(256, self.size.width)) * cell.width,
+ max(1, min(256, self.size.height)) * cell.height)
+
+ def redraw(self):
+ if not self.pending:
+ self.pending = True
+ self.call_after_refresh(self.draw_frame)
+
+ def on_mount(self):
+ self.redraw()
+
+ def on_resize(self):
+ self.redraw()
+
+ def draw_frame(self):
+ self.pending = False
+ if not self.is_mounted or not self.size.width or not self.size.height:
+ return
+ pixels = self.pixels()
+ box, scale = self.view.bounds(pixels)
+ signature = (box, scale, pixels)
+ if signature == self.signature:
+ return
+ self.signature = signature
+ cell = get_cell_size()
+ cols = max(1, round((box[2] - box[0]) * scale / cell.width))
+ rows = max(1, round((box[3] - box[1]) * scale / cell.height))
+ cols = min(cols, self.size.width, 256)
+ rows = min(rows, self.size.height, 256)
+ self.picture.styles.width = cols
+ self.picture.styles.height = rows
+ if hasattr(self.picture, "set_view"):
+ # Kitty retains the original pixels; pan/zoom only updates a small
+ # placement command. No PIL resampling or PNG upload per keypress.
+ self.picture.set_view(self.source, box, cols, rows)
+ self.post_message(self.Changed(self.view.zoom))
+ return
+ # Resample directly from the original into a bounded viewport frame.
+ # No giant intermediate scaled image and no repeated network reads.
+ target = (cols * cell.width, rows * cell.height)
+ ratio = min(1, 1600 / target[0], 1200 / target[1])
+ target = tuple(max(1, round(n * ratio)) for n in target)
+ frame = self.source.resize(target, Image.Resampling.BILINEAR, box)
+ old = self.frame
+ self.picture.image = frame
+ self.frame = frame
+ if old:
+ old.close()
+ self.post_message(self.Changed(self.view.zoom))
+
+ def pan(self, dx, dy):
+ self.view.pan(dx, dy, self.pixels())
+ self.redraw()
+
+ def zoom(self, direction):
+ self.view.zoom = max(1, min(32, self.view.zoom * 1.5 ** direction))
+ self.redraw()
+
+ def fit(self):
+ self.view = ImageView(*self.source.size)
+ self.redraw()
+
+ def on_unmount(self):
+ self.picture.image = None
+ if self.frame:
+ self.frame.close()
+ self.source.close()
diff --git a/cc_remote/tui_inline_images.py b/cc_remote/tui_inline_images.py
new file mode 100644
index 00000000..c4771898
--- /dev/null
+++ b/cc_remote/tui_inline_images.py
@@ -0,0 +1,377 @@
+"""Inline image layout without putting pixels in the Vim source document."""
+
+import asyncio
+from dataclasses import dataclass
+
+from markdown_it import MarkdownIt
+from rich.text import Text
+from textual.containers import Container
+from textual.widgets import Static
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_markdown import MarkdownProjection
+from cc_remote.tui_preview import (
+ decode_image, local_reference, preview_request, references,
+)
+
+
+def close_decoded(task):
+ """Release a decoder's eventual result after its view is cancelled."""
+ if not task.cancelled() and task.exception() is None:
+ task.result().close()
+
+
+async def decode_async(result, **options):
+ task = asyncio.create_task(
+ asyncio.to_thread(decode_image, result, **options)
+ )
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ task.add_done_callback(close_decoded)
+ raise
+
+
+@dataclass
+class ImageSlot:
+ key: tuple
+ path: str
+ start: int
+ end: int
+ row: int
+ height: int
+
+
+class ImageProjection:
+ """Map image rows to source offsets for copy and jump history."""
+
+ def __init__(self):
+ self.slots = []
+ self.content = MarkdownProjection()
+
+ def without_images(self, position):
+ return position - sum(
+ max(0, min(position, s.end) - s.start) for s in self.slots
+ )
+
+ def source(self, position):
+ return self.content.source(self.without_images(position))
+
+ def with_images(self, position):
+ shift = 0
+ for slot in self.slots:
+ if slot.start - shift > position:
+ break
+ shift += slot.end - slot.start
+ return position + shift
+
+ def display(self, position):
+ return self.with_images(self.content.display(position))
+
+ def extract(self, text, start, end):
+ start, end = sorted((start, end))
+ parts = []
+ for slot in self.slots:
+ if slot.end <= start or slot.start >= end:
+ continue
+ parts.append(text[start:max(start, slot.start)])
+ start = min(end, slot.end)
+ return "".join(parts) + text[start:end]
+
+ def locate(self, view, position, starts):
+ return view.locate(self.source(position), [
+ (self.source(start), block) for start, block in starts
+ ])
+
+ def resolve(self, view, anchor, starts, length, *, fallback=0):
+ return self.display(view.resolve(anchor, [
+ (self.source(start), block) for start, block in starts
+ ], self.source(length), fallback=self.source(fallback)))
+
+ def shift_headers(self, updates):
+ for start, _, old, new in reversed(updates):
+ self.content.replace_header(self.without_images(start), old, new)
+ changes = {start: len(new) - len(old) for start, _, old, new in updates}
+ for slot in self.slots:
+ delta = sum(n for start, n in changes.items() if start < slot.start)
+ slot.start += delta
+ slot.end += delta
+
+
+class InlinePicture(Container):
+ DEFAULT_CSS = """
+ InlinePicture { position: absolute; padding: 0; margin: 0; }
+ InlinePicture > * { padding: 0; margin: 0; }
+ """
+
+ def __init__(self, factory, decoded, width, height):
+ super().__init__()
+ self.picture = factory(decoded)
+ self.styles.width = width
+ self.styles.height = height
+ self.picture.styles.width = width
+ self.picture.styles.height = height
+
+ def compose(self):
+ yield self.picture
+
+ def on_unmount(self):
+ self.picture.image = None
+
+
+class TranscriptViewport(Container):
+ """Clip images inside the reader viewport, never in bottom chrome."""
+
+ DEFAULT_CSS = """
+ TranscriptViewport { height: 1fr; overflow: hidden hidden; }
+ TranscriptViewport > .image-notice {
+ position: absolute; height: 1; color: $text-muted;
+ text-wrap: nowrap; text-overflow: ellipsis;
+ }
+ """
+ MAX_SLOTS = 64
+ MAX_CACHE = 8
+
+ def __init__(self, reader):
+ super().__init__(reader, id="transcript-viewport")
+ self.reader = reader
+ self.identity = None
+ self.cache = {}
+ self.errors = {}
+ self.pending = {}
+ self.pictures = {}
+ self.parked = {}
+ self.projection = ImageProjection()
+ self.rich_lines = []
+ self.render_width = 0
+ self.generation = 0
+ self.read_limit = asyncio.Semaphore(2)
+
+ def on_mount(self):
+ # Also hide placements when a modal covers the base screen (its paint
+ # loop is intentionally paused while a picker owns the keyboard).
+ self.set_interval(0.1, self.sync)
+
+ def reset(self, identity):
+ if identity == self.identity:
+ return
+ self.identity = identity
+ self.generation += 1
+ for task in self.pending.values():
+ task.cancel()
+ self.pending.clear()
+ self.clear_pictures()
+ for image in self.cache.values():
+ image.close()
+ self.cache.clear()
+ self.errors.clear()
+ self.projection = ImageProjection()
+ self.rich_lines = []
+
+ def clear_pictures(self):
+ for _, widget in (*self.pictures.values(), *self.parked.values()):
+ widget.display = False
+ widget.remove()
+ self.pictures.clear()
+ self.parked.clear()
+
+ def dimensions(self, key):
+ image = self.cache.get(key)
+ if image is None:
+ return max(1, self.reader.content_size.width), 1
+ from textual_image._terminal import get_cell_size
+
+ cell = get_cell_size()
+ width = max(1, min(160, self.reader.content_size.width - 2))
+ scale = min(width * cell.width / image.width,
+ 12 * cell.height / image.height)
+ return (max(1, int(image.width * scale / cell.width)),
+ max(1, int(image.height * scale / cell.height)))
+
+ def project(self, text, starts, identity):
+ self.reset(identity)
+ self.projection = ImageProjection()
+ self.projection.content = MarkdownProjection(
+ text, self.app.client.keys.label("diagram_browser"),
+ )
+ self.render_width = max(8, self.reader.wrap_width)
+ markdown, markdown_starts = self.projection.content.project(
+ starts, self.render_width
+ )
+ # Parse complete paragraphs, retaining Markdown's link handling (and
+ # rejecting external URLs). Only actual rendered assistant text counts.
+ edits = []
+ for index, (start, block) in enumerate(starts):
+ if not self.app.graphics:
+ break
+ if block.role != "assistant" or block.channel == "thinking":
+ continue
+ end = starts[index + 1][0] if index + 1 < len(starts) else len(text)
+ body_start = text.find("\n", start, end) + 1
+ body = text[body_start:end][:65536]
+ seen = set()
+ lines = body.splitlines(keepends=True)
+ for token in MarkdownIt("commonmark").parse(body):
+ if token.type != "inline" or not token.map:
+ continue
+ position = body_start + sum(
+ len(line) for line in lines[:token.map[1]]
+ )
+ refs = references(token.content)
+ # Reference-style Markdown links need the full document's
+ # resolved tokens, not a fresh parse of the inline source.
+ for child in token.children or []:
+ if child.type in {"image", "link_open"}:
+ ref = local_reference(
+ child.attrGet("src") or child.attrGet("href") or ""
+ )
+ if ref:
+ refs.append(ref)
+ for ref in refs:
+ if ref.kind != "image" or ref.path in seen:
+ continue
+ seen.add(ref.path)
+ if len(edits) >= self.MAX_SLOTS:
+ break
+ key = (block.id, ref.path)
+ edits.append((self.projection.content.paragraph_end(position),
+ key, ref.path))
+ parts, new_starts = Text(), []
+ text = markdown.plain
+ previous = shift = 0
+ for position, key, path in sorted(edits, key=lambda e: e[0]):
+ parts.append_text(markdown[previous:position])
+ height = self.dimensions(key)[1]
+ begin = position + shift
+ parts.append("\n" * height)
+ self.projection.slots.append(ImageSlot(
+ key, path, begin, begin + height,
+ text[:position].count("\n") + shift, height,
+ ))
+ previous = position
+ shift += height
+ parts.append_text(markdown[previous:])
+ for start, block in markdown_starts:
+ new_starts.append((self.projection.with_images(start), block))
+ keys = {s.key for s in self.projection.slots}
+ self.errors = {k: v for k, v in self.errors.items() if k in keys}
+ for key in set(self.pending) - keys:
+ self.pending.pop(key).cancel()
+ self.rich_lines = parts.split("\n", allow_blank=True)
+ return parts.plain, new_starts
+
+ def sync(self):
+ if not self.is_mounted:
+ return
+ reader = self.reader
+ visible = {}
+ if self.app.graphics and len(self.app.screen_stack) == 1:
+ for slot in self.projection.slots:
+ y = reader.wrapped_document.location_to_offset((slot.row, 0)).y
+ y -= int(reader.scroll_y)
+ if (y + slot.height > 0
+ and y < reader.scrollable_content_region.height):
+ visible[slot.key] = (slot, y)
+ for key in set(self.pictures) - visible.keys():
+ entry = self.pictures.pop(key)
+ _, widget = entry
+ widget.display = False
+ self.parked[key] = entry
+ keys = {s.key for s in self.projection.slots}
+ for key in list(self.parked):
+ if key not in keys or key not in self.cache:
+ self.parked.pop(key)[1].remove()
+ for key, (slot, y) in visible.items():
+ width, height = self.dimensions(key)
+ signature = (key in self.cache, width, height, self.errors.get(key))
+ old = self.pictures.get(key) or self.parked.pop(key, None)
+ if old and old[0] != signature:
+ old[1].display = False
+ old[1].remove()
+ old = None
+ if old is None:
+ if key in self.cache:
+ widget = InlinePicture(
+ self.app.graphics, self.cache[key], width, height
+ )
+ else:
+ widget = Static(
+ Text(self.errors.get(key, "Loading image…")),
+ classes="image-notice",
+ )
+ widget.styles.width = width
+ self.pictures[key] = (signature, widget)
+ self.mount(widget)
+ else:
+ self.pictures[key] = old
+ widget = self.pictures[key][1]
+ widget.display = True
+ widget.styles.offset = (reader.gutter.left, y + reader.gutter.top)
+ if (key not in self.cache and key not in self.errors
+ and key not in self.pending):
+ self.pending[key] = asyncio.create_task(
+ self.load(slot, self.generation)
+ )
+ while len(self.parked) > self.MAX_CACHE:
+ self.parked.pop(next(iter(self.parked)))[1].remove()
+ self.trim_cache()
+
+ def trim_cache(self):
+ # A tall viewport can temporarily show more than MAX_CACHE images.
+ # Bound residency again immediately when those images scroll away.
+ while len(self.cache) > self.MAX_CACHE:
+ victim = next(
+ (k for k in self.cache if k not in self.pictures), None
+ )
+ if victim is None:
+ break
+ if victim in self.parked:
+ self.parked.pop(victim)[1].remove()
+ self.cache.pop(victim).close()
+
+ async def load(self, slot, generation):
+ decoded = None
+ try:
+ sid = self.identity[0]
+ async with self.read_limit:
+ result = await preview_request(self.app.client, sid, slot.path)
+ if result.get("type") == "preview_authorization_required":
+ raise ValueError("Image requires authorization · "
+ + self.app.client.keys.label("preview"))
+ # Bounded decode off the input loop; cancellation still owns
+ # and disposes the eventual result of the worker thread.
+ decoded = await decode_async(result)
+ if generation != self.generation or not self.is_mounted:
+ return
+ self.app.remember()
+ self.cache[slot.key] = decoded
+ decoded = None
+ # Evict only offscreen images; current page is bounded by the
+ # viewport. Their slots keep a cheap placeholder for lazy reload.
+ self.trim_cache()
+ self.app.rendered_version = -1
+ self.app.paint()
+ except (ValueError, OSError) as exc:
+ if generation == self.generation:
+ self.errors[slot.key] = _safe_remote_text(str(exc))
+ self.sync()
+ finally:
+ if decoded is not None:
+ decoded.close()
+ if self.pending.get(slot.key) is asyncio.current_task():
+ self.pending.pop(slot.key, None)
+
+ def on_mouse_scroll_up(self, event):
+ # Pixel children cover the blank source rows, so wheel events there
+ # must reach the same reader as wheel events on ordinary message text.
+ self.reader.on_mouse_scroll_up()
+ self.reader.scroll_relative(y=-3, animate=False)
+ event.stop()
+
+ def on_mouse_scroll_down(self, event):
+ self.reader.scroll_relative(y=3, animate=False)
+ self.reader.on_mouse_scroll_down()
+ event.stop()
+
+ def on_unmount(self):
+ self.reset(None)
diff --git a/cc_remote/tui_keys.py b/cc_remote/tui_keys.py
new file mode 100644
index 00000000..b8f75d0b
--- /dev/null
+++ b/cc_remote/tui_keys.py
@@ -0,0 +1,479 @@
+"""Validated, local-only workspace key configuration (no executable actions)."""
+
+from dataclasses import dataclass
+import os
+from pathlib import Path
+import re
+import string
+import tomllib
+
+from textual.binding import Binding
+from textual.keys import Keys, _character_to_key
+
+
+@dataclass(frozen=True)
+class Shortcut:
+ action: str
+ label: str
+ keys: tuple[str, ...]
+
+
+GLOBAL = {
+ "paste_image": Shortcut("paste_image", "Paste image", ("ctrl+v",)),
+ "toggle_pane": Shortcut("toggle_pane", "Read/Draft", ()),
+ "focus_draft": Shortcut("focus_draft", "Draft / next result", ("ctrl+j",)),
+ "focus_read": Shortcut("focus_read", "Read / previous result", ("ctrl+k",)),
+ "send": Shortcut("submit", "Send (global alias)", ()),
+ "queue": Shortcut("queue", "Queue", ("ctrl+e",)),
+ "stop": Shortcut("stop", "Stop current turn (keep queue)", ("ctrl+x",)),
+ "sessions": Shortcut("sessions", "Session tree", ()),
+ "answer": Shortcut("answer", "Answer question", ("ctrl+t",)),
+ "complete": Shortcut("complete", "Completion", ("ctrl+space", "ctrl+@")),
+ "quit": Shortcut("quit", "Quit", ("ctrl+q",)),
+}
+NORMAL = {
+ "jump_back": Shortcut("jump_history(-1)", "Jump back", ("ctrl+o",)),
+ "jump_forward": Shortcut(
+ "jump_history(1)", "Jump forward (Tab)", ("ctrl+i", "tab")
+ ),
+ "preview": Shortcut("preview_files", "Preview files", ("space v",)),
+ "diagram_browser": Shortcut(
+ "diagram_browser", "Open diagram session in browser", ("space B",)
+ ),
+ "help": Shortcut("panel('Help')", "Help", ("space h",)),
+ "goal": Shortcut("panel('Goal / Plan')", "Goal/Plan", ("space g",)),
+ "usage": Shortcut("panel('Usage / Context')", "Usage", ("space u",)),
+ "queue_details": Shortcut("panel('Queue')", "Queue details", ("space l",)),
+ "actions": Shortcut("actions", "Actions", ("space a",)),
+ "settings": Shortcut("panel('Settings')", "Settings", ("space s",)),
+ "reports": Shortcut("panel('Reports')", "Reports/skills", ("space r",)),
+ "background": Shortcut("panel('Background')", "Background", ("space b b",)),
+ "buffer_previous": Shortcut(
+ "cycle_buffer(-1)", "Previous session tab", ("H",)
+ ),
+ "buffer_next": Shortcut("cycle_buffer(1)", "Next session tab", ("L",)),
+ "buffer_search": Shortcut(
+ "search_buffers", "Search open sessions", ("space comma",)
+ ),
+ "buffer_close": Shortcut(
+ "close_buffer", "Close session tab", ("space b d",)
+ ),
+ "status": Shortcut("panel('Status')", "Status", ("space t",)),
+ "notices": Shortcut("panel('Notices')", "Notices", ("space n",)),
+ "tree": Shortcut("sessions", "Session tree", ("space e",)),
+ "engine": Shortcut("toggle_engine", "Claude/Codex", ("space c",)),
+ "space": Shortcut("toggle_space", "Code/Work", ("space w",)),
+ "quote": Shortcut("quote_selection", "Quote selection", ("space q",)),
+ "new_session": Shortcut("new_session", "New session", ("space enter",)),
+ "model": Shortcut("choose_setting('model')", "Model", ("space m",)),
+ "permissions": Shortcut(
+ "choose_setting('permission_profile')", "Permissions", ("space p",)
+ ),
+ "previous_user": Shortcut(
+ "jump_message('user', -1)", "Previous user", ("left_square_bracket u",)
+ ),
+ "next_user": Shortcut(
+ "jump_message('user', 1)", "Next user", ("right_square_bracket u",)
+ ),
+ "previous_assistant": Shortcut(
+ "jump_message('assistant', -1)",
+ "Previous answer",
+ ("left_square_bracket a",),
+ ),
+ "next_assistant": Shortcut(
+ "jump_message('assistant', 1)",
+ "Next answer",
+ ("right_square_bracket a",),
+ ),
+ "previous_message": Shortcut(
+ "jump_message('', -1)", "Previous message", ("left_square_bracket m",)
+ ),
+ "next_message": Shortcut(
+ "jump_message('', 1)", "Next message", ("right_square_bracket m",)
+ ),
+}
+
+
+def key_label(value: str) -> str:
+ labels = {"space": "Space", "enter": "Enter", "escape": "Esc",
+ "comma": ",", "colon": ":", "slash": "/"}
+ return " ".join(labels.get(k, k.replace("ctrl+", "Ctrl+").replace("alt+", "Alt+"))
+ for k in value.split())
+
+
+LAYERS = {
+ "reader": {
+ "latest_user": Shortcut("latest_message('user')", "Latest user", ("g u",)),
+ "latest_assistant": Shortcut("latest_message('assistant')", "Latest answer", ("g a",)),
+ "first": Shortcut("read_start", "Start of loaded history", ("g g",)),
+ "follow": Shortcut("read_follow", "Latest output / follow", ("G",)),
+ "details": Shortcut("read_details", "Expand / collapse details", ("enter",)),
+ "older": Shortcut("read_older", "Older history / detail page", ("o",)),
+ "newer": Shortcut("read_newer", "Newer detail page", ("O",)),
+ "close": Shortcut("read_close", "Collapse details / clear selection", ("escape",)),
+ "command": Shortcut("command_editor", "Command editor", ("colon",)),
+ },
+ "draft": {
+ "send": Shortcut("submit", "Send (Normal)", ("enter",)),
+ "cancel": Shortcut("cancel_draft_command", "Cancel command editor", ("escape",)),
+ },
+ "tree_search": {
+ "down": Shortcut("down", "Next result", ("down",)),
+ "up": Shortcut("up", "Previous result", ("up",)),
+ "choose": Shortcut("choose", "Open result", ("enter",)),
+ "close": Shortcut("close", "Clear search / return to tree", ("escape",)),
+ },
+ "tree": {
+ "down": Shortcut("down", "Move down (count)", ("j", "down")),
+ "up": Shortcut("up", "Move up (count)", ("k", "up")),
+ "first": Shortcut("first", "First / numbered row", ("g g",)),
+ "last": Shortcut("last", "Last / numbered row", ("G",)),
+ "fold": Shortcut("fold", "Fold / parent", ("h", "left")),
+ "expand": Shortcut("expand", "Expand folder", ("l", "right")),
+ "fold_all": Shortcut("fold_all", "Collapse all folders", ("H",)),
+ "expand_all": Shortcut("expand_all", "Expand all folders", ("L",)),
+ "rename": Shortcut("rename", "Rename session", ("r",)),
+ "delete": Shortcut("delete", "Delete session (confirm)", ("d",)),
+ "delete_direct": Shortcut(
+ "delete_direct", "Delete session without confirmation", ("D",)
+ ),
+ "search": Shortcut("search", "Search", ("slash",)),
+ "close": Shortcut("close", "Close tree", ("escape",)),
+ "choose": Shortcut("choose", "Open session / toggle folder", ("enter",)),
+ },
+ "file_hints": {
+ "close": Shortcut("cancel", "Back", ("escape",)),
+ "down": Shortcut("down", "Next file", ("j", "down")),
+ "up": Shortcut("up", "Previous file", ("k", "up")),
+ "parent": Shortcut("parent", "Previous page", ("h",)),
+ "open": Shortcut("open", "Next page", ("l",)),
+ "choose": Shortcut("choose", "Open selected file", ("enter",)),
+ **{f"select_{n}": Shortcut(f"select_file({n})", f"Open file {n}", (str(n),))
+ for n in range(1, 10)},
+ },
+ "preview": {
+ "close": Shortcut("cancel", "Back", ("escape",)),
+ "browser": Shortcut("browser", "Open session in browser", ("alt+b",)),
+ "refresh": Shortcut("refresh", "Reload file", ("r",)),
+ "authorize": Shortcut("authorize", "Allow exact file read", ("a",)),
+ },
+ "panel": {
+ "close": Shortcut("cancel", "Back", ("escape",)),
+ "refresh": Shortcut("refresh", "Refresh", ("r",)),
+ "actions": Shortcut("actions", "Actions", ("a",)),
+ "edit": Shortcut("edit", "Edit", ("i",)),
+ "hide": Shortcut("hide", "Hide goal", ("x",)),
+ "search": Shortcut("search", "Search shortcut index (Help)", ("slash",)),
+ },
+ "picker": {
+ "close": Shortcut("cancel", "Back", ("escape",)),
+ "search": Shortcut("search", "Search (Insert)", ("slash",)),
+ "edit": Shortcut("edit", "Edit / search", ("i",)),
+ "down": Shortcut("down", "Next", ("j", "down")),
+ "up": Shortcut("up", "Previous", ("k", "up")),
+ "choose": Shortcut("choose", "Choose highlighted item", ("enter",)),
+ "parent": Shortcut("parent", "Parent directory", ("h", "ctrl+left")),
+ "open": Shortcut("open", "Browse directory", ("l", "ctrl+right")),
+ "refresh": Shortcut("refresh", "Refresh", ("r", "ctrl+r")),
+ },
+ "form": {
+ "close": Shortcut("cancel", "Normal / Back", ("escape",)),
+ "help": Shortcut("field_help", "Field help", ("question_mark",)),
+ "confirm": Shortcut("submit", "Apply / confirm action", ("enter",)),
+ "advanced": Shortcut("advanced", "Edit structured field as JSON", ("ctrl+r",)),
+ },
+ "confirmation": {
+ "yes": Shortcut("yes", "Yes / confirm", ("y",)),
+ "close": Shortcut("cancel", "No / cancel", ("n", "escape")),
+ "choose": Shortcut("choose", "Confirm highlighted choice", ("enter",)),
+ "down": Shortcut("down", "Next choice", ("j", "down")),
+ "up": Shortcut("up", "Previous choice", ("k", "up")),
+ },
+}
+
+LAYERS["image"] = {
+ **LAYERS["preview"],
+ "left": Shortcut("pan(-1, 0)", "Pan left", ("h", "left")),
+ "right": Shortcut("pan(1, 0)", "Pan right", ("l", "right")),
+ "down": Shortcut("pan(0, 1)", "Pan down", ("j", "down")),
+ "up": Shortcut("pan(0, -1)", "Pan up", ("k", "up")),
+ "zoom_in": Shortcut("zoom(1)", "Zoom in", ("z i",)),
+ "zoom_out": Shortcut("zoom(-1)", "Zoom out", ("z o",)),
+ "fit": Shortcut("fit", "Fit image", ("z f",)),
+}
+
+LAYERS["question"] = {
+ "close": Shortcut("cancel", "Normal / close (task continues)", ("escape",)),
+ "confirm": Shortcut("submit", "Submit answer / next question", ("enter",)),
+ "edit": Shortcut("edit", "Write an answer", ("i",)),
+ "down": Shortcut("down", "Next option", ("j", "down")),
+ "up": Shortcut("up", "Previous option", ("k", "up")),
+ "next": Shortcut("question(1)", "Next question", ("ctrl+right",)),
+ "previous": Shortcut("question(-1)", "Previous question", ("ctrl+left",)),
+}
+
+LAYERS["queue"] = {
+ "close": Shortcut("cancel", "Back", ("escape",)),
+ "down": Shortcut("down", "Next message", ("j", "down")),
+ "up": Shortcut("up", "Previous message", ("k", "up")),
+ "choose": Shortcut("choose", "Read full prompt", ("enter",)),
+ "edit": Shortcut("edit", "Edit prompt", ("i",)),
+ "delete": Shortcut("delete", "Cancel message (confirm)", ("d",)),
+ "move_up": Shortcut("move(-1)", "Move earlier", ("K",)),
+ "move_down": Shortcut("move(1)", "Move later", ("J",)),
+}
+
+MODAL_LAYERS = {
+ "panel", "picker", "form", "file_hints", "preview", "confirmation",
+ "image", "question", "queue",
+}
+for _layer in ("picker", "file_hints", "queue"):
+ LAYERS[_layer].update({
+ "first": Shortcut("first", "First result", ("home",)),
+ "last": Shortcut("last", "Last result", ("end",)),
+ "page_up": Shortcut("page_up", "Previous results page", ("pageup",)),
+ "page_down": Shortcut("page_down", "Next results page", ("pagedown",)),
+ })
+
+for _layer in MODAL_LAYERS:
+ LAYERS[_layer].update({
+ "next_field": Shortcut("next_field", "Next field", ("tab",)),
+ "previous_field": Shortcut("previous_field", "Previous field", ("shift+tab",)),
+ })
+
+SCOPES = {
+ "keys": "Global", "normal": "Main Normal / Visual",
+ "reader": "Reader Normal / Visual", "draft": "Draft Normal",
+ "tree": "Session tree", "tree_search": "Session tree search",
+ "panel": "Detail panel", "picker": "Picker / search",
+ "form": "Form Normal", "file_hints": "File hints", "preview": "File preview",
+ "confirmation": "Deletion confirmation", "image": "Image preview",
+ "question": "Async question",
+ "queue": "Server queue",
+}
+
+
+class KeyConfig:
+ def __init__(self, data: dict | None = None):
+ data = {} if data is None else data
+ if not isinstance(data, dict) or data.keys() - {
+ "keys",
+ "normal",
+ "vim",
+ *LAYERS,
+ }:
+ raise ValueError("Unknown TUI key layer")
+ vim = data.get("vim", {})
+ if not isinstance(vim, dict) or vim.keys() - {"yank_highlight_ms"}:
+ raise ValueError("Unknown TUI Vim option")
+ self.yank_highlight_ms = vim.get("yank_highlight_ms", 200)
+ if (type(self.yank_highlight_ms) is not int
+ or not 0 <= self.yank_highlight_ms <= 5000):
+ raise ValueError("vim.yank_highlight_ms must be 0..5000")
+ self.global_keys = self._section(data.get("keys", {}), GLOBAL, False)
+ self.normal_keys = self._section(data.get("normal", {}), NORMAL, True)
+ self.layers = {
+ name: self._section(data.get(name, {}), specs, True)
+ for name, specs in LAYERS.items()
+ }
+ for name, layer in self.layers.items():
+ flat = [key for values in layer.values() for key in values]
+ if name not in {"tree", "reader", "image"} and any(
+ len(key.split()) != 1 for key in flat
+ ):
+ raise ValueError("Panel shortcuts must be single keys")
+ if len(flat) != len(set(flat)):
+ raise ValueError("Conflicting panel shortcuts")
+ if name in {"tree", "reader", "image"}:
+ chords = [tuple(key.split()) for key in flat]
+ if name == "tree" and any(chord[0].isdigit() for chord in chords):
+ raise ValueError("Tree digits are reserved for Vim counts")
+ if any(
+ a != b and b[:len(a)] == a
+ for a in chords for b in chords
+ ):
+ raise ValueError("Conflicting tree shortcut prefixes")
+ global_flat = {
+ key for values in self.global_keys.values() for key in values
+ }
+ if any(key.split()[0] in global_flat for key in flat):
+ raise ValueError(
+ "Panel shortcuts conflict with global shortcuts"
+ )
+ seen: dict[tuple[str, ...], str] = {}
+ for section in (self.global_keys, self.normal_keys):
+ for name, shortcuts in section.items():
+ for shortcut in shortcuts:
+ chord = tuple(shortcut.split())
+ for previous, owner in seen.items():
+ if chord[: len(previous)] == previous or (
+ previous[: len(chord)] == chord
+ ):
+ raise ValueError(
+ f"Conflicting TUI shortcuts: {owner} / {name}"
+ )
+ seen[chord] = name
+ self.chords = {
+ tuple(key.split()): NORMAL[name].action
+ for name, shortcuts in self.normal_keys.items()
+ for key in shortcuts
+ }
+ for layer in ("reader", "draft", "tree"):
+ combined = [tuple(k.split()) for keys in self.layers[layer].values()
+ for k in keys]
+ for chord in combined:
+ for normal in self.chords:
+ # H/L intentionally mean folder folding in the tree and
+ # buffer switching in chat. Prefix shadowing is unsafe.
+ if layer == "tree" and chord == normal:
+ continue
+ if chord[:len(normal)] == normal or normal[:len(chord)] == chord:
+ raise ValueError(f"Conflicting {layer}/normal shortcut prefixes")
+
+ @staticmethod
+ def _section(data: dict, specs: dict, normal: bool) -> dict:
+ if not isinstance(data, dict) or data.keys() - specs.keys():
+ raise ValueError("Unknown TUI shortcut action")
+ result = {name: spec.keys for name, spec in specs.items()}
+ known = {key.value for key in Keys} | {
+ "space",
+ "tab",
+ "escape",
+ "ctrl+space",
+ }
+ known |= {_character_to_key(char) for char in string.punctuation}
+ for name, values in data.items():
+ if not isinstance(values, list) or not all(
+ isinstance(value, str) for value in values
+ ):
+ raise ValueError(f"TUI shortcut {name} must be a string array")
+ for value in values:
+ tokens = value.split()
+ if not 1 <= len(tokens) <= (3 if normal else 1):
+ raise ValueError(f"Invalid TUI chord for {name}")
+ for token in tokens:
+ if token not in known and not re.fullmatch(
+ r"(?:(?:ctrl|alt)\+)?[a-zA-Z0-9@]", token
+ ):
+ raise ValueError(f"Invalid TUI key: {token}")
+ if not normal and not (
+ tokens[0] in known or tokens[0].startswith("ctrl+")
+ ):
+ raise ValueError("Printable shortcuts belong in [normal]")
+ result[name] = tuple(" ".join(value.split()) for value in values)
+ return result
+
+ def bindings(self) -> list[Binding]:
+ return [
+ Binding(
+ key,
+ GLOBAL[name].action,
+ GLOBAL[name].label,
+ priority=True,
+ show=name
+ in {"toggle_pane", "sessions", "send", "queue", "quit"},
+ )
+ for name, keys in self.global_keys.items()
+ for key in keys
+ ]
+
+ def label(self, name: str) -> str:
+ keys = {**self.global_keys, **self.normal_keys}[name]
+ if name == "send" and not keys:
+ return self.layer_label("draft", "send") + " (Normal)"
+ if name == "sessions" and not keys:
+ return self.label("tree")
+ if name == "tree" and not keys:
+ keys = self.global_keys["sessions"]
+ return " / ".join(map(key_label, keys)) if keys else "disabled"
+
+ def layer_label(self, layer: str, name: str) -> str:
+ keys = self.layers[layer][name]
+ return " / ".join(map(key_label, keys)) if keys else "disabled"
+
+ def match(self, layer: str, key: str) -> str | None:
+ return next((name for name, keys in self.layers[layer].items()
+ if key in keys), None)
+
+ def index(self, query: str = "", *, include_disabled=True) -> list[dict]:
+ """Stable configuration IDs and effective bindings; safe for JSON/UI."""
+ result = []
+ sections = {"keys": GLOBAL, "normal": NORMAL, **LAYERS}
+ values = {"keys": self.global_keys, "normal": self.normal_keys, **self.layers}
+ for layer, specs in sections.items():
+ for name, spec in specs.items():
+ keys = values[layer][name]
+ item = dict(id=f"{layer}.{name}", layer=layer, scope=SCOPES[layer],
+ action=spec.action, description=spec.label,
+ keys=list(keys), enabled=bool(keys),
+ display=" / ".join(map(key_label, keys)) or "disabled")
+ haystack = " ".join(str(v) for v in item.values()).casefold()
+ if (include_disabled or keys) and all(
+ word in haystack for word in query.casefold().split()
+ ):
+ result.append(item)
+ return result
+
+ def lookup(self, key: str, *, layer: str | None = None) -> list[dict]:
+ """Reverse lookup retains scope: Enter can mean different local actions."""
+ key = " ".join(key.split())
+ return [row for row in self.index(include_disabled=False)
+ if key in row["keys"] and (layer is None or row["layer"] == layer)]
+
+ def help(self) -> str:
+ groups = {}
+ for item in self.index(include_disabled=False):
+ groups.setdefault(item["scope"], []).append(
+ f"{item['display']:24} {item['description']} [{item['id']}]"
+ )
+ groups["Vim feedback"] = [
+ f"Yank highlight: {self.yank_highlight_ms} ms "
+ "[vim.yank_highlight_ms] (0 disables)"
+ ]
+ return "\n\n".join(title + "\n" + "\n".join(rows)
+ for title, rows in groups.items())
+
+ def layer_bindings(self, layer: str) -> list[Binding]:
+ return [
+ Binding(
+ key,
+ f"dispatch_shortcut({name!r}, {key!r})",
+ LAYERS[layer][name].label,
+ priority=True,
+ show=False,
+ )
+ for name, keys in self.layers[layer].items()
+ for key in keys
+ if layer != "image" or len(key.split()) == 1
+ ]
+
+ def layer_help(self, layer: str, actions: set | None = None) -> str:
+ return " · ".join(
+ f"{'/'.join(map(key_label, keys))}: {LAYERS[layer][name].label}"
+ for name, keys in self.layers[layer].items()
+ if keys
+ and (actions is None
+ or LAYERS[layer][name].action.split("(", 1)[0] in actions)
+ )
+
+
+def load_keys(path: str | None = None) -> KeyConfig:
+ explicit = path or os.environ.get("CC_REMOTE_TUI_CONFIG")
+ target = (
+ Path(explicit).expanduser()
+ if explicit
+ else (
+ Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
+ / "cc-remote"
+ / "tui.toml"
+ )
+ )
+ try:
+ with target.open("rb") as stream:
+ return KeyConfig(tomllib.load(stream))
+ except FileNotFoundError as exc:
+ if not explicit:
+ return KeyConfig()
+ raise ValueError(f"TUI config not found: {target}") from exc
+ except (OSError, ValueError) as exc:
+ raise ValueError(f"Invalid TUI config {target}: {exc}") from exc
diff --git a/cc_remote/tui_local.py b/cc_remote/tui_local.py
new file mode 100644
index 00000000..f2fcb195
--- /dev/null
+++ b/cc_remote/tui_local.py
@@ -0,0 +1,129 @@
+"""Discover a same-user local relay without weakening its authentication.
+
+Only the active user service's own process environment supplies credentials.
+They are never copied to disk, logged, or sent to an unrelated URL.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+import ipaddress
+import os
+from pathlib import Path
+import subprocess
+import sys
+from urllib.parse import urlsplit
+
+
+_ENV_LIMIT = 1024 * 1024
+_KEYS = {
+ "RELAY_HOST", "RELAY_PORT", "PUBLIC_ORIGIN", "LOGIN_PASSWORD",
+ "LOGIN_USERNAME", "LOGIN_USERS_JSON", "ALLOW_PRIVATE_ORIGINS",
+}
+
+
+@dataclass(frozen=True)
+class LocalRelay:
+ url: str
+ password: str = field(repr=False)
+ origin: str = ""
+ username: str = ""
+
+
+def is_loopback_url(url: str) -> bool:
+ try:
+ parsed = urlsplit(url)
+ host = parsed.hostname or ""
+ return (parsed.scheme in {"ws", "wss"}
+ and (host == "localhost" or ipaddress.ip_address(host).is_loopback))
+ except ValueError:
+ return False
+
+
+def same_local_endpoint(left: str, right: str) -> bool:
+ try:
+ a, b = urlsplit(left), urlsplit(right)
+ return (
+ is_loopback_url(left) and is_loopback_url(right)
+ and a.scheme == b.scheme == "ws"
+ and (a.port or 80) == (b.port or 80)
+ and a.path == b.path == "/ws"
+ and not any((a.username, a.password, a.query, a.fragment,
+ b.username, b.password, b.query, b.fragment))
+ and (a.hostname == b.hostname
+ or {a.hostname, b.hostname} <= {"localhost", "127.0.0.1"})
+ )
+ except ValueError:
+ return False
+
+
+def _service_environment() -> dict[str, str]:
+ if sys.platform != "linux":
+ return {}
+ try:
+ result = subprocess.run(
+ ["systemctl", "--user", "show", "cc-remote-relay.service",
+ "--property=MainPID", "--property=ActiveState"],
+ capture_output=True, text=True, timeout=2, check=True,
+ )
+ fields = dict(line.split("=", 1) for line in result.stdout.splitlines()
+ if "=" in line)
+ pid = fields.get("MainPID", "")
+ if fields.get("ActiveState") != "active" or not pid.isdecimal() or int(pid) < 1:
+ return {}
+ directory = os.open(Path("/proc") / pid, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ if os.fstat(directory).st_uid != os.getuid():
+ return {}
+ descriptor = os.open("environ", os.O_RDONLY | os.O_NOFOLLOW,
+ dir_fd=directory)
+ with os.fdopen(descriptor, "rb") as stream:
+ if os.fstat(stream.fileno()).st_uid != os.getuid():
+ return {}
+ raw = stream.read(_ENV_LIMIT + 1)
+ finally:
+ os.close(directory)
+ if len(raw) > _ENV_LIMIT:
+ return {}
+ settings = {}
+ for entry in raw.split(b"\0"):
+ key, separator, value = entry.partition(b"=")
+ name = key.decode("ascii", errors="replace")
+ if separator and name in _KEYS:
+ settings[name] = value.decode("utf-8")
+ return settings
+ except (OSError, UnicodeError, subprocess.SubprocessError):
+ return {}
+
+
+def discover_local_relay() -> LocalRelay | None:
+ settings = _service_environment()
+ password = settings.get("LOGIN_PASSWORD", "")
+ # Multi-user deployments require an explicitly selected login; never pick
+ # an account or inherit a legacy password that the relay no longer accepts.
+ if not password or settings.get("LOGIN_USERS_JSON", "").strip():
+ return None
+ host = settings.get("RELAY_HOST", "127.0.0.1").strip()
+ if host in {"0.0.0.0", "localhost"}:
+ host = "127.0.0.1"
+ elif host == "::":
+ host = "::1"
+ try:
+ address = ipaddress.ip_address(host)
+ port = int(settings.get("RELAY_PORT", "8765"))
+ except ValueError:
+ return None
+ if not address.is_loopback or not 1 <= port <= 65535:
+ return None
+ authority = f"[{host}]" if address.version == 6 else host
+ target = f"{authority}:{port}"
+ origin = f"http://{target}"
+ private_origins = settings.get("ALLOW_PRIVATE_ORIGINS", "").strip().lower()
+ if (private_origins not in {"1", "true", "yes", "on"}
+ and settings.get("PUBLIC_ORIGIN", "").rstrip("/") != origin):
+ return None
+ return LocalRelay(
+ url=f"ws://{target}/ws", password=password,
+ origin=origin,
+ username=settings.get("LOGIN_USERNAME", ""),
+ )
diff --git a/cc_remote/tui_markdown.py b/cc_remote/tui_markdown.py
new file mode 100644
index 00000000..99055ac9
--- /dev/null
+++ b/cc_remote/tui_markdown.py
@@ -0,0 +1,469 @@
+"""Styled chat Markdown with visible URLs and stable source coordinates."""
+
+from dataclasses import dataclass
+from difflib import SequenceMatcher
+from functools import lru_cache
+import re
+import unicodedata
+from urllib.parse import unquote
+
+from markdown_it import MarkdownIt
+from rich import box
+from rich.console import Console
+from rich.syntax import Syntax
+from rich.table import Table
+from rich.text import Text
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_mermaid import render_diagram
+
+MAX_RENDER_CHARS = 256 * 1024
+
+
+class RenderLimit(ValueError):
+ """Expanding repeated reference links must not exhaust the UI process."""
+
+
+def display_url(url):
+ """Expose readable Unicode without decoding URL syntax or controls."""
+ def decode(match):
+ try:
+ text = unquote(match[0], errors="strict")
+ except UnicodeDecodeError:
+ return match[0]
+ if any(
+ not c.isprintable() or c.isspace()
+ or unicodedata.category(c).startswith("C") for c in text
+ ):
+ return match[0]
+ return text
+
+ return _safe_remote_text(
+ re.sub(r"(?:%[89a-fA-F][0-9a-fA-F])+", decode, url)
+ )
+
+
+@lru_cache(maxsize=256)
+def matching_runs(source, rendered):
+ """Bound diff work even for long, repetitive generated Markdown.
+
+ Global SequenceMatcher's autojunk loses repeated prose, while disabling it
+ globally makes streamed repetitive text quadratic. Match small windows and
+ extend their equal runs linearly; decorations map to the preceding run.
+ """
+ matches = []
+ suffix = 0
+ while (
+ suffix < min(len(source), len(rendered))
+ and source[-1 - suffix] == rendered[-1 - suffix]
+ ):
+ suffix += 1
+ source_end, rendered_end = len(source) - suffix, len(rendered) - suffix
+ a = b = 0
+ while a < source_end and b < rendered_end:
+ size = 0
+ while (
+ a + size < source_end
+ and b + size < rendered_end
+ and source[a + size] == rendered[b + size]
+ ):
+ size += 1
+ if size:
+ matches.append((a, b, size))
+ a += size
+ b += size
+ continue
+ runs = SequenceMatcher(
+ None,
+ source[a : min(a + 256, source_end)],
+ rendered[b : min(b + 256, rendered_end)],
+ autojunk=False,
+ ).get_matching_blocks()
+ x, y, size = runs[0]
+ if not size:
+ a += 256
+ b += 256
+ else:
+ a += x
+ b += y
+ if suffix:
+ matches.append((source_end, rendered_end, suffix))
+ return tuple(matches)
+
+
+def inline(tokens):
+ """Use parsed tokens, never Rich markup or terminal escape sequences."""
+ result = Text()
+ styles, links = [], []
+ for token in tokens:
+ kind = token.type
+ if kind in {"strong_open", "em_open", "s_open"}:
+ styles.append(
+ {
+ "strong_open": "bold",
+ "em_open": "italic",
+ "s_open": "strike",
+ }[kind]
+ )
+ elif kind in {"strong_close", "em_close", "s_close"}:
+ if styles:
+ styles.pop()
+ elif kind == "link_open":
+ links.append(
+ (len(result), _safe_remote_text(token.attrGet("href") or ""))
+ )
+ elif kind == "link_close" and links:
+ start, url = links.pop()
+ visible = display_url(url)
+ result.stylize("underline", start)
+ if result.plain[start:] != visible:
+ result.append(" (")
+ result.append(visible, "underline cyan")
+ result.append(")")
+ elif kind == "image":
+ label = _safe_remote_text(token.content)
+ path = display_url(token.attrGet("src") or "")
+ result.append(label + " (" if label else "")
+ result.append(path, "underline cyan")
+ if label:
+ result.append(")")
+ elif kind in {"softbreak", "hardbreak"}:
+ # Keep streamed prose line boundaries; TextArea owns soft wrapping.
+ result.append("\n")
+ elif kind in {"text", "code_inline", "html_inline"}:
+ style = " ".join(
+ styles + (["cyan"] if kind == "code_inline" else [])
+ )
+ content = token.content
+ if kind == "text" and links and content == links[-1][1]:
+ content = display_url(content)
+ result.append(_safe_remote_text(content), style)
+ elif token.children:
+ result.append_text(inline(token.children))
+ if len(result) > MAX_RENDER_CHARS:
+ raise RenderLimit()
+ return result
+
+
+def table_text(tokens, width):
+ rows, row, cell, urls = [], [], None, []
+ characters = 0
+ for token in tokens:
+ if token.type == "tr_open":
+ row = []
+ elif token.type in {"th_open", "td_open"}:
+ alignment = token.attrGet("style") or ""
+ cell = Text(
+ justify="right"
+ if "right" in alignment
+ else "center"
+ if "center" in alignment
+ else "left"
+ )
+ elif token.type == "inline" and cell is not None:
+ cell.append_text(inline(token.children or []))
+ characters += len(cell)
+ if characters > MAX_RENDER_CHARS:
+ raise RenderLimit()
+ for child in token.children or []:
+ if child.type in {"link_open", "image"}:
+ url = display_url(
+ child.attrGet("href") or child.attrGet("src") or ""
+ )
+ if url and url not in urls:
+ urls.append(url)
+ elif token.type in {"th_close", "td_close"}:
+ row.append(cell)
+ cell = None
+ elif token.type == "tr_close":
+ rows.append(row)
+ if not rows:
+ return Text()
+ result = Text()
+ if width < len(rows[0]) * 8:
+ # A narrow pane must not hide columns or truncate their values.
+ if len(rows) == 1:
+ result = Text("\n").join(rows[0])
+ result.append("\n")
+ for values in rows[1:]:
+ for header, value in zip(rows[0], values):
+ result.append_text(header)
+ result.append(": ")
+ result.append_text(value)
+ result.append("\n")
+ result.append("\n")
+ else:
+ table = Table(
+ box=box.ROUNDED,
+ header_style="bold",
+ padding=(0, 1),
+ border_style="bright_black",
+ expand=False,
+ )
+ for heading in rows[0]:
+ table.add_column(heading, overflow="fold")
+ for values in rows[1:]:
+ table.add_row(*values)
+ console = Console(width=width, color_system="truecolor")
+ for line in console.render_lines(table, pad=False):
+ result.append_text(
+ Text.assemble(
+ *(
+ (segment.text, segment.style)
+ for segment in line
+ if not segment.control
+ )
+ )
+ )
+ result.append("\n")
+ # Table cells wrap at column boundaries. Also retain contiguous URLs for
+ # Kitty's URL hints instead of offering only fragments from those cells.
+ for url in urls:
+ result.append(url, "underline cyan")
+ result.append("\n")
+ return result
+
+
+@lru_cache(maxsize=256)
+def render_markdown(source, width, browser_key="Space B"):
+ """Cache bounded message bodies, not every tick or the entire transcript."""
+ try:
+ return _render_markdown(source, width, browser_key)
+ except RenderLimit:
+ return Text(_safe_remote_text(source)), False, ()
+
+
+def _render_markdown(source, width, browser_key):
+ source = _safe_remote_text(source)
+ diagram = render_diagram(source, width, browser_key, standalone=True)
+ if diagram is not None:
+ return diagram, True, ((len(source), len(diagram)),)
+ parser = MarkdownIt("commonmark").enable("table").enable("strikethrough")
+ env = {}
+ tokens = parser.parse(source, env)
+ offsets = [0]
+ for line in source.splitlines(keepends=True):
+ offsets.append(offsets[-1] + len(line))
+ edits = []
+ index = 0
+ has_table = False
+ while index < len(tokens):
+ token = tokens[index]
+ if token.type == "table_open":
+ end = index + 1
+ while tokens[end].type != "table_close":
+ end += 1
+ rendered = table_text(tokens[index : end + 1], width)
+ has_table = True
+ index = end
+ elif token.type == "inline" and token.map:
+ rendered = inline(token.children or [])
+ first = source[offsets[token.map[0]] : offsets[token.map[0] + 1]]
+ content = token.content.split("\n", 1)[0]
+ prefix = first[: max(0, first.find(content))] if content else ""
+ heading = index > 0 and tokens[index - 1].type == "heading_open"
+ if heading:
+ rendered.stylize("bold")
+ prefix = ""
+ # Setext headings also consume their underline source row.
+ token.map = tokens[index - 1].map
+ else:
+ prefix = re.sub(r"([-+*]) (?=\S|$)", "• ", prefix)
+ prefix = prefix.replace(">", "│")
+ if prefix:
+ lines = rendered.split("\n", allow_blank=True)
+ continuation = re.sub(r"[•\d.)-]", " ", prefix)
+ rendered = Text("\n").join(
+ [
+ Text(prefix if n == 0 else continuation) + line
+ for n, line in enumerate(lines)
+ ]
+ )
+ rendered.append("\n")
+ elif token.type in {"fence", "code_block"} and token.map:
+ language = (
+ token.info.split(None, 1)[0] if token.info.strip() else "text"
+ )
+ rendered = (render_diagram(token.content, width, browser_key)
+ if language.lower() == "mermaid" else None)
+ if rendered is not None:
+ has_table = True # Diagrams reflow with the terminal width.
+ else:
+ rendered = Syntax(
+ token.content, language, theme="ansi_dark"
+ ).highlight(_safe_remote_text(token.content))
+ if language.lower() == "mermaid":
+ notice = Text(
+ "[Unsupported diagram; showing source]\n"
+ f"[{browser_key}: open session in browser]\n", "dim"
+ )
+ rendered = notice + rendered
+ elif token.type == "hr" and token.map:
+ rendered = Text("─" * min(width, 60) + "\n", "bright_black")
+ has_table = True # Like tables, rules need width-aware reflow.
+ else:
+ index += 1
+ continue
+ if token.map:
+ edits.append(
+ (offsets[token.map[0]], offsets[token.map[1]], rendered)
+ )
+ index += 1
+ for reference in env.get("references", {}).values():
+ if reference.get("map"):
+ start, end = reference["map"]
+ edits.append((offsets[start], offsets[end], Text()))
+ result, previous, ends = Text(), 0, []
+ for start, end, text in sorted(edits, key=lambda edit: edit[0]):
+ if start < previous:
+ continue
+ result.append(source[previous:start])
+ result.append_text(text)
+ if len(result) > MAX_RENDER_CHARS:
+ raise RenderLimit()
+ ends.append((end, len(result)))
+ previous = end
+ result.append(source[previous:])
+ # Avoid an added line break on a streaming paragraph's unfinished last line.
+ if not source.endswith("\n") and result.plain.endswith("\n"):
+ result = result[:-1]
+ if len(result) > MAX_RENDER_CHARS:
+ raise RenderLimit()
+ return (
+ result,
+ has_table,
+ tuple(
+ (source_end, min(display_end, len(result)))
+ for source_end, display_end in ends
+ ),
+ )
+
+
+@dataclass
+class Replacement:
+ source_start: int
+ source_end: int
+ start: int
+ end: int
+ equal: tuple
+
+ def map(self, position, reverse=False):
+ """Exact unchanged runs; generated borders map to nearby source text."""
+ matches = self.equal
+ previous = 0
+ for a, b, size in matches:
+ x, y = (b, a) if reverse else (a, b)
+ if position < x:
+ return previous
+ if position < x + size:
+ return y + position - x
+ previous = y + size
+ return previous
+
+
+class MarkdownProjection:
+ """Compose source-preserving Markdown with the existing image projection."""
+
+ def __init__(self, source="", browser_key="Space B"):
+ self.original = source
+ self.browser_key = browser_key
+ self.replacements = []
+ self.responsive = False
+ self.ends = {}
+
+ def paragraph_end(self, position):
+ # Images belong below a parsed paragraph, not in a nearest-match gap
+ # inside a generated reference URL or a reflowed table cell.
+ return self.ends.get(position, self.display(position))
+
+ def source(self, position):
+ shift = 0
+ for item in self.replacements:
+ if position < item.start:
+ break
+ if position < item.end:
+ return item.source_start + item.map(position - item.start, True)
+ shift += (item.end - item.start) - (
+ item.source_end - item.source_start
+ )
+ return position - shift
+
+ def display(self, position):
+ shift = 0
+ for item in self.replacements:
+ if position < item.source_start:
+ break
+ if position < item.source_end:
+ return item.start + item.map(position - item.source_start)
+ shift += (item.end - item.start) - (
+ item.source_end - item.source_start
+ )
+ return position + shift
+
+ def replace_header(self, position, old, new):
+ source = self.source(position)
+ self.original = (
+ self.original[:source] + new + self.original[source + len(old) :]
+ )
+ delta = len(new) - len(old)
+ self.ends = {
+ (key + delta if key > source else key): (
+ value + delta if value > position else value
+ )
+ for key, value in self.ends.items()
+ }
+ for item in self.replacements:
+ if item.start > position:
+ item.start += delta
+ item.end += delta
+ item.source_start += delta
+ item.source_end += delta
+
+ def project(self, starts, width):
+ edits = []
+ source = self.original
+ for index, (start, block) in enumerate(starts):
+ end = (
+ starts[index + 1][0] if index + 1 < len(starts) else len(source)
+ )
+ body = source.find("\n", start, end) + 1
+ if block.role == "assistant" and block.channel != "thinking":
+ edits.append((body, end))
+ elif block.role == "detail" and block.expanded:
+ lines = source[body:end].splitlines(keepends=True)
+ positions = [body]
+ for line in lines:
+ positions.append(positions[-1] + len(line))
+ sections = block.data.get("sections", [])
+ for n, section in enumerate(sections):
+ if (
+ section["role"] != "assistant"
+ or section["channel"] == "thinking"
+ ):
+ continue
+ a = min(section["line"] + 1, len(lines))
+ b = (
+ min(sections[n + 1]["line"], len(lines))
+ if n + 1 < len(sections)
+ else len(lines)
+ )
+ edits.append((positions[a], positions[b]))
+ result, previous = Text(), 0
+ for a, b in edits:
+ result.append(source[previous:a])
+ rendered, responsive, ends = render_markdown(
+ source[a:b], width, self.browser_key,
+ )
+ self.responsive |= responsive
+ self.ends.update({a + x: len(result) + y for x, y in ends})
+ if source[a:b] != rendered.plain:
+ matches = matching_runs(source[a:b], rendered.plain)
+ self.replacements.append(
+ Replacement(
+ a, b, len(result), len(result) + len(rendered), matches
+ )
+ )
+ result.append_text(rendered)
+ previous = b
+ result.append(source[previous:])
+ return result, [(self.display(start), block) for start, block in starts]
diff --git a/cc_remote/tui_mermaid.py b/cc_remote/tui_mermaid.py
new file mode 100644
index 00000000..f913afac
--- /dev/null
+++ b/cc_remote/tui_mermaid.py
@@ -0,0 +1,119 @@
+"""Cached, inert terminal Mermaid projections; no browser or JS execution."""
+
+from cc_remote.tui_diagram_flow import (
+ Flowchart,
+ UnsupportedFlowchart,
+ parse_flowchart,
+)
+from cc_remote.tui_diagram_text import (
+ Canvas,
+ DiagramLimit,
+ HEADER,
+ MAX_SOURCE,
+ diagram_source,
+)
+
+# Compatibility exports for callers inspecting the flowchart projection.
+__all__ = [
+ "Flowchart",
+ "UnsupportedFlowchart",
+ "parse_flowchart",
+ "HEADER",
+ "MAX_SOURCE",
+ "render_flowchart",
+ "render_diagram",
+]
+
+
+def flow_text(graph, width, browser_key):
+ canvas = Canvas(
+ width, "Flowchart · text layout; edge annotations preserve direction"
+ )
+ for annotation in graph.annotations:
+ canvas.line(annotation, style="bold")
+ for identity, (title, parent) in graph.groups.items():
+ canvas.line(
+ "Group "
+ + identity
+ + " · "
+ + title
+ + (" (inside " + parent + ")" if parent else ""),
+ style="bold",
+ )
+ children = {node: [] for node in graph.nodes}
+ incoming = set()
+ for left, right, text in graph.edges:
+ children[left].append((right, text))
+ incoming.add(right)
+ visited = set()
+
+ def draw(node, depth=0):
+ indent = min(depth * 4, max(0, canvas.width - 8))
+ if node in visited or depth > 12:
+ canvas.line("↪ " + node + " (see node)", indent)
+ return
+ visited.add(node)
+ group = graph.membership.get(node)
+ title = node + " · " + graph.nodes[node]
+ if group:
+ title += " [group: " + group + "]"
+ canvas.box(title, indent=indent)
+ for target, text in children[node]:
+ canvas.line(
+ "└─▶ " + target + (" · " + text if text else ""), indent
+ )
+ if indent + 12 >= canvas.width:
+ canvas.line("→ " + target + " (see node)", indent)
+ else:
+ draw(target, depth + 1)
+
+ for node in [n for n in graph.nodes if n not in incoming] + list(
+ graph.nodes
+ ):
+ if node not in visited:
+ draw(node)
+ canvas.browser_hint(browser_key, graph.browser)
+ return canvas.text
+
+
+def render_flowchart(source, width, browser_key="Space B"):
+ try:
+ return flow_text(parse_flowchart(source), width, browser_key)
+ except (UnsupportedFlowchart, DiagramLimit):
+ return None
+
+
+def render_diagram(source, width, browser_key="Space B", *, standalone=False):
+ """Semantic text where known, explicit source rows for unknown extensions.
+
+ Recognizing a diagram family is not a claim of validating every version's
+ grammar. Unprojected constructs stay visible and carry a Web affordance.
+ """
+ try:
+ document = diagram_source(source)
+ if document is None:
+ return None
+ if standalone:
+ # A prose paragraph such as 'info about the server' is not a
+ # diagram. Bare headers must occupy their own declaration line.
+ option = document.body.split("\n", 1)[0].strip()
+ if option and option not in {
+ "TB",
+ "TD",
+ "LR",
+ "RL",
+ "BT",
+ "LR:",
+ "TB:",
+ "BT:",
+ "showData",
+ "horizontal",
+ }:
+ return None
+ if document.kind in {"graph", "flowchart", "flowchart-elk"}:
+ return render_flowchart(source, width, browser_key)
+ from cc_remote.tui_diagram_families import render_family
+
+ return render_family(document, width, browser_key)
+ except DiagramLimit:
+ return None
diff --git a/cc_remote/tui_modal.py b/cc_remote/tui_modal.py
new file mode 100644
index 00000000..4fa6fd95
--- /dev/null
+++ b/cc_remote/tui_modal.py
@@ -0,0 +1,269 @@
+"""Independent modal key scope and shared Vim editing for terminal forms."""
+
+import inspect
+
+from textual import actions, events
+from textual.binding import BindingsMap
+from textual.screen import ModalScreen
+from textual.widgets import Input, OptionList, Static
+from textual.keys import key_to_character
+
+from cc_remote.tui_widgets import Composer
+from cc_remote.tui_keys import LAYERS
+
+
+class PickerList(OptionList, inherit_bindings=False):
+ """Only the owning dialog's registry may activate or navigate choices."""
+
+ BINDINGS = []
+
+
+class Overlay(ModalScreen, inherit_bindings=False):
+ DEFAULT_CSS = """
+ Overlay { align: center middle; background: $background 65%; }
+ .tui-panel { width: 90%; max-width: 110; height: 85%; border: solid $primary; }
+ .tui-panel TextArea, .tui-panel OptionList { height: 1fr; }
+ .tui-panel .search-editor { height: 3; }
+ .tui-panel Input { height: 3; }
+ .tui-panel Label { height: auto; max-height: 6; }
+ .tui-panel .key-hints { height: auto; color: $text-muted; }
+ """
+ BINDINGS = []
+ key_layer = "panel"
+ local_actions = {"cancel"}
+
+ def on_mount(self) -> None:
+ self.install_keys()
+
+ def install_keys(self) -> None:
+ self._bindings = BindingsMap(
+ self.app.client.keys.layer_bindings(self.key_layer)
+ )
+ self.refresh_bindings()
+ self.update_hint()
+
+ def update_hint(self) -> None:
+ mode = getattr(self.focused, "vim_mode", "NORMAL")
+ text = self.app.client.keys.layer_help(
+ self.key_layer, self.available_actions()
+ )
+ text = f"{self.key_layer.upper()} {mode} · " + text
+ if self.key_layer == "form":
+ text += " · i: edit"
+ for hint in self.query(".key-hints"):
+ hint.update(text)
+
+ def check_action(self, action: str, parameters: tuple) -> bool:
+ if action != "dispatch_shortcut":
+ return action in self.available_actions()
+ name, key = parameters
+ _, target, _ = actions.parse(LAYERS[self.key_layer][name].action)
+ if target not in self.available_actions():
+ return False
+ editor = self.focused
+ if name in {"next_field", "previous_field"}:
+ return True
+ # Arrow/control navigation and confirmation also work while searching;
+ # letters remain literal Insert input and never trigger panel actions.
+ if (getattr(editor, "has_class", lambda _: False)("search-editor")
+ and name in {"down", "up", "choose", "close", "first",
+ "last", "page_up", "page_down", "parent",
+ "open", "refresh"}
+ and (key_to_character(key) is None
+ or not key_to_character(key).isprintable())):
+ return True
+ if getattr(editor, "vim_mode", "NORMAL") != "NORMAL" or getattr(
+ editor, "prefix", ""
+ ):
+ return False
+ # Normal Enter reviews first; mutating forms still require a second
+ # Enter on their immutable prepared action. Insert never submits.
+ return True
+
+ def available_actions(self):
+ return self.local_actions | {"next_field", "previous_field"} | (
+ {"choose", "first", "last", "page_up", "page_down"}
+ if self.query(OptionList) else set()
+ )
+
+ async def action_dispatch_shortcut(self, name, key):
+ if not self.check_action("dispatch_shortcut", (name, key)):
+ return
+ _, action, args = actions.parse(LAYERS[self.key_layer][name].action)
+ result = getattr(self, "action_" + action)(*args)
+ if inspect.isawaitable(result):
+ await result
+
+ def action_next_field(self):
+ self.focus_next()
+
+ def action_previous_field(self):
+ self.focus_previous()
+
+ def action_choose(self):
+ listings = self.query(OptionList)
+ if listings:
+ listings.first().action_select()
+
+ def action_first(self):
+ self.query_one(OptionList).action_first()
+
+ def action_last(self):
+ self.query_one(OptionList).action_last()
+
+ def action_page_up(self):
+ self.query_one(OptionList).action_page_up()
+
+ def action_page_down(self):
+ self.query_one(OptionList).action_page_down()
+
+ def action_cancel(self) -> None:
+ editor = self.focused
+ if getattr(editor, "vim_mode", "NORMAL") != "NORMAL" or getattr(
+ editor, "prefix", ""
+ ):
+ editor.set_mode("NORMAL")
+ return
+ self.dismiss(None)
+
+ def on_screen_resume(self) -> None:
+ for editor in self.query(ModalEditor):
+ editor.set_mode(
+ "INSERT" if editor.has_class("search-editor")
+ or getattr(self, "initial_insert", False) else "NORMAL"
+ )
+ if hasattr(self, "initial_insert"):
+ self.initial_insert = False
+ self.update_hint()
+
+ def on_unmount(self) -> None:
+ # Never return from a nested dialog into an unnoticed Insert mode.
+ self.app.mode = "NORMAL"
+ self.app.shortcut_prefix = ()
+ self.app.prefix = ""
+ for editor in self.app.screen_stack[0].query(Composer):
+ editor.set_mode("NORMAL")
+
+ def move_selection(self, down: bool) -> None:
+ listings = self.query(OptionList)
+ if listings:
+ listing = listings.first()
+ (listing.action_cursor_down if down else listing.action_cursor_up)()
+ else:
+ (self.focus_next if down else self.focus_previous)()
+
+ def action_down(self) -> None:
+ self.move_selection(True)
+
+ def action_up(self) -> None:
+ self.move_selection(False)
+
+
+class ModalEditor(Composer):
+ """The same text objects/operators as the draft, scoped to this form."""
+
+ def __init__(self, text: str = "", *, locked: bool = False, **kwargs):
+ super().__init__(**kwargs)
+ self.load_text(text)
+ self.locked = locked
+
+ def set_mode(self, mode: str) -> None:
+ if mode != "NORMAL":
+ self.clear_yank()
+ if self.locked and mode == "INSERT":
+ return
+ self.vim_mode = mode
+ self.read_only = mode != "INSERT"
+ self.prefix = ""
+ self.history.begin() if mode == "INSERT" else self.history.finish()
+ # This override keeps modal state local, but must retain VimArea's
+ # inclusive Visual selection lifecycle on every form/reader surface.
+ if mode == "VISUAL":
+ self.begin_visual()
+ else:
+ self.visual_anchor = self.visual_cursor = None
+ if self.is_mounted:
+ self.screen.update_hint()
+
+ def on_focus(self) -> None:
+ if self.is_mounted:
+ if self.has_class("search-editor"):
+ self.set_mode("INSERT")
+ self.screen.update_hint()
+
+ def on_blur(self) -> None:
+ self.clear_yank()
+ self.set_mode("NORMAL")
+
+ async def _on_key(self, event: events.Key) -> None:
+ if event.key == "escape":
+ event.stop()
+ event.prevent_default()
+ if (
+ self.has_class("search-editor")
+ and "escape" in self.app.client.keys.layers[
+ self.screen.key_layer
+ ]["close"]
+ ):
+ self.screen.action_cancel()
+ return
+ if self.vim_mode != "NORMAL" or self.prefix:
+ self.set_mode("NORMAL")
+ elif (
+ "escape"
+ in self.app.client.keys.layers[self.screen.key_layer]["close"]
+ ):
+ self.screen.action_cancel()
+ return
+ # The shared Vim grammar consumes whole commands (yi(, 2yaw, …).
+ # Locking is enforced at the mutation boundary, not per-character:
+ # filtering i/a here would also reject read-only text objects.
+ await super()._on_key(event)
+
+
+class ModalInput(Input):
+ """Masked answer input: never copy secret replies to a shared register."""
+
+ vim_mode = "NORMAL"
+
+ def action_submit(self):
+ # Form confirmation is registry-owned, never Input's built-in Enter.
+ pass
+
+ def set_mode(self, mode: str) -> None:
+ self.vim_mode = mode
+ self.screen.update_hint()
+
+ def _on_paste(self, event: events.Paste) -> None:
+ if self.vim_mode != "INSERT":
+ event.stop()
+ event.prevent_default()
+ return
+ super()._on_paste(event)
+
+ async def _on_key(self, event: events.Key) -> None:
+ if event.key == "escape":
+ event.stop()
+ event.prevent_default()
+ if self.vim_mode != "NORMAL":
+ self.set_mode("NORMAL")
+ elif (
+ "escape"
+ in self.app.client.keys.layers[self.screen.key_layer]["close"]
+ ):
+ self.screen.action_cancel()
+ elif self.vim_mode == "NORMAL":
+ event.stop()
+ event.prevent_default()
+ if event.key == "i":
+ self.set_mode("INSERT")
+ elif event.key in {"h", "left"}:
+ self.action_cursor_left()
+ elif event.key in {"l", "right"}:
+ self.action_cursor_right()
+ else:
+ await super()._on_key(event)
+
+
+def hints() -> Static:
+ return Static("", classes="key-hints", markup=False)
diff --git a/cc_remote/tui_navigation.py b/cc_remote/tui_navigation.py
new file mode 100644
index 00000000..9117eee7
--- /dev/null
+++ b/cc_remote/tui_navigation.py
@@ -0,0 +1,50 @@
+"""Scoped session selection, matching web/src/session-order.ts."""
+
+from datetime import datetime, timezone
+import math
+
+SCOPES = {
+ (engine, space) for engine in ("claude", "codex") for space in ("code", "work")
+}
+
+
+def activity(row: dict) -> float:
+ value = row.get("last_modified")
+ if value is None or value == "":
+ return -math.inf
+ try:
+ number = float(value)
+ if math.isfinite(number):
+ return number if number > 10_000_000_000 else number * 1000
+ except (ValueError, TypeError):
+ pass
+ try:
+ stamp = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ if stamp.tzinfo is None:
+ stamp = stamp.replace(tzinfo=timezone.utc)
+ return stamp.timestamp() * 1000
+ except (ValueError, OverflowError, OSError):
+ return -math.inf
+
+
+def scoped_catalog(catalog: dict, engine: str, space: str) -> dict:
+ return dict(
+ sorted(
+ (
+ (sid, row)
+ for sid, row in catalog.items()
+ if (row.get("engine") or engine, row.get("space") or "code")
+ == (engine, space)
+ ),
+ key=lambda item: (-activity(item[1]), item[0]),
+ )
+ )
+
+
+def select_session(catalog: dict, remembered: str | None) -> str | None:
+ if remembered in catalog:
+ return remembered
+ return next(
+ (sid for sid, row in catalog.items() if row.get("tag") != "archived"),
+ next(iter(catalog), None),
+ )
diff --git a/cc_remote/tui_panels.py b/cc_remote/tui_panels.py
new file mode 100644
index 00000000..f0baeaab
--- /dev/null
+++ b/cc_remote/tui_panels.py
@@ -0,0 +1,972 @@
+"""Focusable terminal panels for public Web-equivalent controls/reports."""
+
+from __future__ import annotations
+
+import uuid
+
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.containers import Vertical
+from textual.widgets import Input, Label, OptionList, Static, TextArea
+from textual.widgets.option_list import Option
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_actions import ACTIONS, build_action, defaults, is_read
+from cc_remote.tui_presentation import describe
+from cc_remote.tui_details import details
+from cc_remote.tui_fields import ParameterFields
+from cc_remote.tui_questions import supplemental_answer_prompt
+from cc_remote.tui_modal import Overlay, ModalEditor, ModalInput, PickerList, hints
+
+PANELS = (
+ "Goal / Plan",
+ "Usage / Context",
+ "Settings",
+ "Queue",
+ "Background",
+ "Questions",
+ "Status",
+ "Notices",
+ "Reports",
+ "Web handoff",
+)
+
+# Only the explicit main action palette exposes the complete command inventory.
+PANEL_ACTIONS = {
+ "Goal / Plan": {"get_goal", "set_goal", "clear_goal", "dismiss_goal"},
+ "Usage / Context": {
+ "get_context",
+ "set_codex_context",
+ "get_status",
+ "consume_rate_limit_reset_credit",
+ },
+ "Settings": {
+ "get_models",
+ "get_permission_profiles",
+ "set_model",
+ "set_effort",
+ "set_perm",
+ "set_permission_profile",
+ "set_service_tier",
+ "set_web_search",
+ "set_collaboration_mode",
+ "set_auto_compact",
+ "set_codex_context",
+ },
+ "Queue": {"get_queued_query", "update_queued_query", "cancel_queued_query",
+ "reorder_queued_queries"},
+ "Background": {"get_status"},
+ "Status": {"get_status", "get_context"},
+ "Notices": {"get_status"},
+ "Reports": {
+ "get_engine_capabilities",
+ "manage_engine_plugin",
+ "manage_engine_skill",
+ "manage_engine_hook",
+ "get_diff",
+ "get_turn_file_changes",
+ "browse_files",
+ "get_file_preview",
+ "get_agent_detail",
+ },
+}
+
+
+class PanelReader(ModalEditor):
+ """Independent detail cursor; copying never jumps back into the composer."""
+
+ def __init__(self, text: str = "", **kwargs):
+ kwargs.pop("read_only", None)
+ super().__init__(text, locked=True, **kwargs)
+
+
+class DetailPanel(Overlay):
+ """Pinned to the session at open; background focus changes cannot retarget it."""
+
+ local_actions = {"cancel", "refresh", "actions", "edit", "hide"}
+
+ def __init__(self, client, sid: str, name: str):
+ super().__init__()
+ self.client, self.sid, self.panel_name = client, sid, name
+ self.last_text = None
+ if name != "Goal / Plan":
+ self.local_actions = {"cancel", "refresh", "actions"}
+ if name not in PANEL_ACTIONS:
+ self.local_actions = {"cancel", "refresh"}
+ if name == "Help":
+ self.local_actions = {"cancel", "search"}
+
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tui-panel"):
+ yield Label(f"{self.panel_name} · {self.sid}", markup=False)
+ yield PanelReader(read_only=True)
+ yield hints()
+
+ def on_mount(self) -> None:
+ self.paint()
+ self.set_interval(0.25, self.paint)
+ self.query_one(PanelReader).focus()
+
+ def paint(self) -> None:
+ sid = self.client.workspace.rekeys.get(self.sid, self.sid)
+ view = self.client.workspace.view(sid)
+ if self.panel_name == "Queue":
+ text = (
+ details(view.queue)
+ + "\n\nUse Get/Update/Cancel queued query in Actions."
+ )
+ elif self.panel_name == "Reports":
+ text = details(
+ {**self.client.workspace.reports, **view.presentation.reports}
+ )
+ elif self.panel_name == "Help":
+ text = (
+ self.client.keys.help() + "\n\n"
+ "Read / draft\n"
+ "i/Esc Vim Insert/Normal\n"
+ "v/V select · y copy; quoting keeps the reading cursor\n"
+ "yi(/yaw/viw text objects · 2yaw/y2w counts · f/t then ;/,\n"
+ "\n"
+ "Startup opens the newest session in the selected engine/space.\n"
+ "Switching surfaces restores their independent last focus.\n"
+ "The tree only lists the current engine and Code/Work.\n"
+ "Closing a local tab leaves server tasks running.\n"
+ "For each layer.action ID, configure [layer] action = [keys]\n"
+ "in ~/.config/cc-remote/tui.toml\n"
+ "or pass --config PATH; restart the TUI to load changes.\n\n"
+ "Command editor: file /local/path or image /local/path attaches.\n"
+ "detach all removes unsent attachments; web shows the Web address.\n"
+ "/goal, /goal resume, /goal pause, /goal clear are local controls.\n"
+ "Actions exposes session, settings, Goal, plugin and Work controls.\n"
+ "Settings apply on Enter; destructive actions require review.\n"
+ "Actions use named fields; structured data has an explicit advanced editor.\n"
+ "Closing TUI never stops server tasks.\n\n"
+ "File previews use numbered hints and read-only Vim navigation.\n"
+ "Mermaid uses terminal text; graphical/interactive details use Web.\n"
+ "This is a terminal presentation of the shared session, not another model process."
+ )
+ elif self.panel_name == "Web handoff":
+ text = (
+ "PDF, graphical Mermaid details and interactive Viewer use Web.\n"
+ "Open this address manually; no browser is launched automatically:\n\n"
+ + self.client.web_url()
+ + "\n\nSession: "
+ + sid
+ )
+ else:
+ text = view.presentation.panel(self.panel_name)
+ text = _safe_remote_text(text)
+ if text != self.last_text:
+ editor = self.query_one(PanelReader)
+ selection, scroll = editor.selection, editor.scroll_offset
+ editor.load_text(text)
+ editor.selection = selection
+ editor.scroll_to(scroll.x, scroll.y, animate=False)
+ self.last_text = text
+
+ async def action_refresh(self) -> None:
+ await self.client.refresh_panel(self.sid, self.panel_name)
+
+ def action_actions(self) -> None:
+ self.app.push_screen(
+ ActionPicker(self.client, self.sid, scope=self.panel_name)
+ )
+
+ def action_search(self):
+ self.app.push_screen(ShortcutPicker(self.client))
+
+ def action_edit(self) -> None:
+ if self.panel_name == "Goal / Plan":
+ self.app.push_screen(
+ ActionForm(self.client, self.sid, "set_goal", insert=True)
+ )
+
+ async def action_hide(self) -> None:
+ if self.panel_name == "Goal / Plan":
+ goal_id = self.client.workspace.view(self.sid).presentation.goal_id
+ if goal_id:
+ from cc_remote.protocol import DismissGoal
+
+ await self.client._send(
+ DismissGoal(sid=self.sid, goal_id=goal_id)
+ )
+
+
+class ActionPicker(Overlay):
+ key_layer = "picker"
+ local_actions = {"cancel", "search", "edit", "down", "up"}
+
+ def __init__(self, client, sid: str | None, *, scope: str | None = None):
+ super().__init__()
+ self.client, self.sid = client, sid
+ self.scope = scope
+ self.allowed_actions = (
+ set(ACTIONS) if scope is None else PANEL_ACTIONS.get(scope, set())
+ )
+
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tui-panel"):
+ yield Label(
+ f"{self.scope or 'All'} actions · search by name · "
+ f"{self.app.client.keys.label('focus_draft')}/"
+ f"{self.app.client.keys.label('focus_read')} or arrows",
+ markup=False,
+ )
+ yield ModalEditor(
+ classes="search-editor",
+ id="search",
+ placeholder="Search actions (see shortcuts below)",
+ )
+ yield PickerList()
+ yield hints()
+
+ def on_mount(self) -> None:
+ self.filter("")
+ self.action_search()
+
+ def on_screen_resume(self) -> None:
+ self.action_search()
+
+ def action_search(self) -> None:
+ editor = self.query_one(ModalEditor)
+ editor.focus()
+ editor.set_mode("INSERT")
+
+ def action_edit(self) -> None:
+ self.action_search()
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
+
+ def filter(self, value: str) -> None:
+ listing = self.query_one(OptionList)
+ listing.clear_options()
+ for name in ACTIONS:
+ if name not in self.allowed_actions:
+ continue
+ label = name.replace("_", " ")
+ if all(term in label for term in value.casefold().split()):
+ listing.add_option(Option(Text(label), id=name))
+ listing.highlighted = 0 if listing.option_count else None
+
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
+ self.filter(event.text_area.text.replace("\n", " "))
+
+ def on_input_submitted(self) -> None:
+ listing = self.query_one(OptionList)
+ if listing.highlighted is not None:
+ self.open_form(listing.get_option_at_index(listing.highlighted).id)
+
+ def on_option_list_option_selected(
+ self, event: OptionList.OptionSelected
+ ) -> None:
+ event.stop()
+ self.open_form(event.option.id)
+
+ def open_form(self, name: str) -> None:
+ if name not in self.allowed_actions:
+ return
+ from cc_remote.tui_settings import SettingsForm
+
+ if name in {
+ "new_session",
+ "set_model",
+ "set_permission_profile",
+ "set_perm",
+ }:
+ field = {
+ "set_model": "model",
+ "set_perm": "permission_mode",
+ "set_permission_profile": "permission_profile",
+ }.get(name)
+ self.app.push_screen(
+ SettingsForm(
+ self.client,
+ None if name == "new_session" else self.sid,
+ new=name == "new_session",
+ initial_field=field,
+ )
+ )
+ else:
+ self.app.push_screen(ActionForm(self.client, self.sid, name))
+
+
+class ShortcutPicker(ActionPicker):
+ """Search the effective registry without executing the highlighted action."""
+
+ def __init__(self, client):
+ super().__init__(client, None, scope="Shortcut index")
+
+ def filter(self, value):
+ listing = self.query_one(OptionList)
+ listing.clear_options()
+ for row in self.client.keys.index(value):
+ listing.add_option(Option(Text(
+ f"{row['display']} · {row['description']}\n"
+ f"{row['id']} · {row['scope']}"
+ ), id=row["id"]))
+ listing.highlighted = 0 if listing.option_count else None
+
+ def open_form(self, name):
+ # Inspection only: pressing choose never triggers the indexed command.
+ self.client.notice = "Shortcut configuration: " + name
+
+
+class ActionForm(Overlay):
+ """Schema validated parameters, with an immutable confirmation step."""
+
+ key_layer = "form"
+ local_actions = {"cancel", "field_help", "submit", "advanced"}
+
+ def __init__(self, client, sid: str | None, name: str, *, insert=False):
+ super().__init__()
+ self.client, self.sid, self.panel_name = client, sid, name
+ self.prepared = None
+ self.submitted = False
+ self.initial_insert = insert
+ self.ticket = None
+
+ def compose(self) -> ComposeResult:
+ row = self.client.workspace.catalog.get(
+ self.sid, {"engine": self.client.engine, "space": self.client.space}
+ )
+ view = self.client.workspace.view(self.sid or "form")
+ values = defaults(
+ self.panel_name,
+ self.sid,
+ row.get("engine", self.client.engine),
+ row,
+ view.presentation,
+ )
+ with Vertical(classes="tui-panel"):
+ yield Label(
+ f"{self.panel_name.replace('_', ' ')} · target {self.sid or 'new session'}",
+ markup=False,
+ )
+ yield Label(
+ "Edit named fields. Enter submits; destructive actions require review.",
+ markup=False,
+ )
+ yield ParameterFields(values, ACTIONS[self.panel_name].model_json_schema())
+ yield Static("", id="action-result", markup=False)
+ yield hints()
+
+ def on_mount(self) -> None:
+ editor = self.query_one(ParameterFields).focus_editor()
+ if self.initial_insert and editor:
+ editor.set_mode("INSERT")
+ self.set_interval(0.25, self.check_result)
+
+ def check_result(self):
+ result = self.client.action_results.get(self.ticket, {})
+ if result.get("error"):
+ self.query_one("#action-result", Static).update(result["error"])
+ self.submitted = False
+ self.ticket = None
+ self.prepared = None
+ fields = self.query_one(ParameterFields)
+ for key, types, _, _, editor in fields.rows:
+ editor.locked = key == "session_id" or (
+ bool(types & {"object", "array"}) and key not in fields.advanced
+ )
+
+ def action_advanced(self):
+ if self.prepared is None:
+ self.query_one(ParameterFields).toggle_advanced(self.focused)
+
+ def action_field_help(self) -> None:
+ self.app.push_screen(FieldHelp(self.panel_name))
+
+ async def action_submit(self) -> None:
+ if self.submitted:
+ return
+ result = self.query_one("#action-result", Static)
+ try:
+ if self.prepared is None:
+ message = build_action(
+ self.panel_name,
+ self.query_one(ParameterFields).payload(),
+ self.sid,
+ self.client.client_id,
+ )
+ if not is_read(self.panel_name) and not self.panel_name.startswith(
+ ("set_", "rename_", "pin_", "dismiss_", "acknowledge_")
+ ):
+ self.prepared = message
+ for editor in self.query(ModalEditor):
+ editor.set_mode("NORMAL")
+ editor.locked = True
+ result.update(
+ "Parameters locked. "
+ + self.client.keys.layer_label("form", "confirm")
+ + " confirms; "
+ + self.client.keys.layer_label("form", "close")
+ + " cancels."
+ )
+ return
+ else:
+ message = self.prepared
+ self.submitted = True
+ self.ticket = message.cmd_id
+ if await self.client.execute_action(message):
+ result.update(
+ "Submitted. The authoritative result appears in Reports / session state."
+ )
+ else:
+ self.submitted = False
+ result.update(self.client.notice)
+ except (ValueError, TypeError, OSError) as exc:
+ self.submitted = False
+ result.update(_safe_remote_text(str(exc)))
+
+
+class FieldHelp(Overlay):
+ """Show native schema types/enums instead of maintaining a second schema."""
+
+ def __init__(self, name: str):
+ super().__init__()
+ self.action_name = name
+
+ def compose(self) -> ComposeResult:
+ from cc_remote.tui_actions import HIDDEN
+
+ schema = ACTIONS[self.action_name].model_json_schema()
+ fields = {
+ key: value
+ for key, value in schema.get("properties", {}).items()
+ if key not in HIDDEN
+ }
+ with Vertical(classes="tui-panel"):
+ yield Label("Field help · " + self.action_name, markup=False)
+ yield PanelReader(
+ details(
+ {"fields": fields, "definitions": schema.get("$defs", {})}
+ ),
+ read_only=True,
+ )
+ yield hints()
+
+
+class SuggestionPicker(ActionPicker):
+ def __init__(self, choices: list[str]):
+ super().__init__(None, None)
+ self.choices = choices
+
+ def filter(self, value: str) -> None:
+ listing = self.query_one(OptionList)
+ listing.clear_options()
+ for choice in self.choices:
+ if value.casefold() in choice.casefold():
+ listing.add_option(Option(Text(choice), id=choice))
+ listing.highlighted = 0 if listing.option_count else None
+
+ def open_form(self, name: str) -> None:
+ self.dismiss(name)
+
+
+class QuestionLayout(Overlay):
+ """Scrollable question text with a reserved, independently editable reply."""
+
+ DEFAULT_CSS = """
+ QuestionLayout { align: center middle; }
+ QuestionLayout .tui-panel { height: 90%; }
+ QuestionLayout #question-body { height: 2fr; min-height: 3; }
+ QuestionLayout #answer { height: 5; min-height: 3; }
+ QuestionLayout OptionList { height: 1fr; min-height: 3; }
+ QuestionLayout #reading-position { height: 1; color: $text-muted; }
+ QuestionLayout .key-hints { max-height: 2; }
+ QuestionLayout #answer-result { height: auto; max-height: 2; }
+ """
+
+ def start_reading_status(self):
+ self.set_interval(0.2, self.reading_status)
+
+ def reading_status(self):
+ reader = self.query_one("#question-body", PanelReader)
+ text = (
+ f"Question line {reader.cursor_location[0] + 1}"
+ f"/{reader.document.line_count} · "
+ + self.client.keys.label("focus_read") + ": read · "
+ + self.client.keys.label("focus_draft") + ": answer"
+ )
+ if text != getattr(self, "last_reading_status", None):
+ self.last_reading_status = text
+ self.query_one("#reading-position", Static).update(text)
+
+ def action_up(self):
+ self.query_one(PickerList).action_cursor_up()
+
+ def action_down(self):
+ self.query_one(PickerList).action_cursor_down()
+
+ def move_selection(self, down):
+ if not down:
+ self.query_one("#question-body", PanelReader).focus()
+ elif isinstance(self.focused, PickerList):
+ self.focused.action_cursor_down()
+ else:
+ listings = self.query(PickerList)
+ target = (listings.first() if listings and listings.first().display
+ else self.query_one("#answer"))
+ target.focus()
+
+ def check_action(self, action, parameters):
+ if action == "dispatch_shortcut":
+ name, _ = parameters
+ if self.focused is self.query_one("#question-body"):
+ if name in {"confirm", "down", "up", "edit"}:
+ return False
+ return super().check_action(action, parameters)
+
+
+class QuestionDialog(QuestionLayout):
+ """A scoped pending question. Secret replies never enter the normal draft."""
+
+ key_layer = "form"
+ local_actions = {"cancel", "submit"}
+
+ def __init__(self, client, ask: dict):
+ super().__init__()
+ self.client, self.ask = client, dict(ask)
+
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tui-panel"):
+ yield Label("Answer question", markup=False)
+ labels = [self.ask.get("question", ""), ""]
+ for index, option in enumerate(self.ask.get("options", []), 1):
+ labels.append(
+ f"{index}. {option['label']}\n {option.get('ds', '')}"
+ )
+ yield PanelReader(
+ _safe_remote_text("\n".join(labels)), id="question-body"
+ )
+ yield Static("", id="reading-position", markup=False)
+ if self.ask.get("secret"):
+ yield ModalInput(
+ placeholder="i: enter secret answer",
+ password=True,
+ id="answer",
+ )
+ else:
+ yield ModalEditor(
+ placeholder="i: option number(s), or text if allowed",
+ id="answer",
+ )
+ yield Static("", id="answer-result", markup=False)
+ yield hints()
+
+ def on_mount(self) -> None:
+ self.query_one("#answer").focus()
+ self.start_reading_status()
+
+ async def on_input_submitted(self) -> None:
+ await self.send()
+
+ async def action_submit(self) -> None:
+ if self.focused is self.query_one("#question-body"):
+ return
+ await self.send()
+
+ async def send(self) -> None:
+ editor = self.query_one("#answer")
+ value = editor.value if isinstance(editor, Input) else editor.text
+ if await self.client.answer_for(self.ask, value):
+ if isinstance(editor, Input):
+ editor.value = ""
+ else:
+ editor.load_text("")
+ self.dismiss(None)
+ else:
+ self.query_one("#answer-result", Static).update(self.client.notice)
+
+
+class AsyncQuestionDialog(QuestionLayout):
+ """Collect non-blocking answers separately from the normal draft."""
+
+ key_layer = "question"
+ local_actions = {"cancel", "submit", "edit", "down", "up", "question"}
+
+ def __init__(self, client, sid, blocks):
+ super().__init__()
+ self.client, self.sid = client, sid
+ self.identities = tuple(b.id for b in blocks)
+ self.questions = [q for b in blocks for q in b.data["questions"]]
+ self.answers = [""] * len(self.questions)
+ self.drafts = [""] * len(self.questions)
+ self.index = 0
+ self.submitting = False
+ self.read_positions = {}
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label("", id="question-title", markup=False)
+ yield PanelReader(id="question-body")
+ yield Static("", id="reading-position", markup=False)
+ yield PickerList()
+ yield ModalEditor(
+ placeholder="i: write a free-text answer", id="answer"
+ )
+ yield Static("", id="answer-result", markup=False)
+ yield hints()
+
+ def on_mount(self):
+ self.show_question()
+ self.start_reading_status()
+
+ def show_question(self):
+ q = self.questions[self.index]
+ self.query_one("#question-title", Label).update(_safe_remote_text(
+ f"Question {self.index + 1}/{len(self.questions)} · non-blocking"
+ ))
+ reader = self.query_one("#question-body", PanelReader)
+ reader.load_text(_safe_remote_text(q["title"]))
+ cursor, scroll = self.read_positions.get(self.index, ((0, 0), 0))
+ reader.move_cursor(cursor)
+ reader.call_after_refresh(reader.scroll_to, y=scroll, animate=False)
+ listing = self.query_one(PickerList)
+ listing.clear_options()
+ for n, label in enumerate(q["options"], 1):
+ listing.add_option(Option(Text(_safe_remote_text(f"{n}. {label}"))))
+ listing.add_option(Option(Text("Write a different answer…")))
+ listing.highlighted = 0
+ listing.display = bool(q["options"])
+ editor = self.query_one("#answer", ModalEditor)
+ editor.load_text(self.drafts[self.index])
+ editor.set_mode("NORMAL")
+ (listing if q["options"] else editor).focus()
+ self.update_hint()
+
+ def check_action(self, action, parameters):
+ if action == "dispatch_shortcut":
+ name, _ = parameters
+ if isinstance(self.focused, ModalEditor) and name in {
+ "down", "up", "edit"
+ }:
+ return False # Preserve the editor's complete Vim grammar.
+ return super().check_action(action, parameters)
+
+ def action_edit(self):
+ editor = self.query_one("#answer", ModalEditor)
+ editor.focus()
+ editor.set_mode("INSERT")
+
+ def action_question(self, direction):
+ if self.submitting:
+ return
+ reader = self.query_one("#question-body", PanelReader)
+ self.read_positions[self.index] = (reader.cursor_location, reader.scroll_y)
+ draft = self.query_one("#answer", ModalEditor).text
+ self.drafts[self.index] = draft
+ if draft.strip() != self.answers[self.index]:
+ self.answers[self.index] = ""
+ self.index = (self.index + direction) % len(self.questions)
+ self.show_question()
+
+ async def action_submit(self):
+ if self.submitting or self.focused is self.query_one("#question-body"):
+ return
+ editor = self.query_one("#answer", ModalEditor)
+ if isinstance(self.focused, PickerList):
+ index = self.query_one(PickerList).highlighted
+ options = self.questions[self.index]["options"]
+ if index is None or index >= len(options):
+ self.action_edit()
+ return
+ editor.load_text(options[index])
+ self.answers[self.index] = editor.text.strip()
+ self.drafts[self.index] = editor.text
+ result = self.query_one("#answer-result", Static)
+ if not self.answers[self.index]:
+ result.update("Enter an answer; nothing has been sent.")
+ return
+ if not all(self.answers):
+ reader = self.query_one("#question-body", PanelReader)
+ self.read_positions[self.index] = (
+ reader.cursor_location, reader.scroll_y
+ )
+ self.index = self.answers.index("")
+ self.show_question()
+ return
+ text = supplemental_answer_prompt(
+ (q["title"], answer)
+ for q, answer in zip(self.questions, self.answers)
+ )
+ self.submitting = True
+ try:
+ if await self.client.answer_async(self.sid, self.identities, text):
+ self.dismiss(None)
+ else:
+ result.update(self.client.notice)
+ except (ValueError, OSError):
+ result.update("Could not submit answer. Your answer is retained.")
+ finally:
+ self.submitting = False
+
+
+class QueuePanel(Overlay):
+ key_layer = "queue"
+ local_actions = {"cancel", "down", "up", "edit", "delete", "move"}
+
+ def __init__(self, client, sid: str):
+ super().__init__()
+ self.client, self.sid = client, sid
+ self.signature = None
+ self.last_notice = client.notice
+ self.requested_order = None
+
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tui-panel"):
+ yield Label(
+ "Server-owned queue · "
+ + self.client.keys.layer_label("queue", "choose")
+ + ": full prompt / edit", markup=False
+ )
+ yield PickerList()
+ yield Static("", id="queue-status", markup=False)
+ yield hints()
+
+ def on_mount(self) -> None:
+ self.paint()
+ self.set_interval(0.25, self.paint)
+ self.query_one(OptionList).focus()
+
+ def paint(self) -> None:
+ if self.client.notice != self.last_notice:
+ self.last_notice = self.client.notice
+ self.query_one("#queue-status", Static).update(
+ _safe_remote_text(self.last_notice)
+ )
+ queue = self.client.workspace.view(self.sid).queue
+ signature = describe(queue)
+ if signature == self.signature:
+ return
+ listing = self.query_one(OptionList)
+ old = listing.highlighted
+ selected = self.selected_id()
+ listing.clear_options()
+ for q in queue:
+ label = f"{q['kind']} · {q['prompt_preview']}\n{q['msg_id']} · {q['image_count']} images / {q['file_count']} files"
+ listing.add_option(
+ Option(Text(_safe_remote_text(label)), id=q["msg_id"])
+ )
+ ids = [q["msg_id"] for q in queue]
+ if self.requested_order == ids:
+ self.query_one("#queue-status", Static).update(
+ "Server confirmed queue order."
+ )
+ self.requested_order = None
+ listing.highlighted = (
+ ids.index(selected) if selected in ids else
+ min(old or 0, len(queue) - 1) if queue else None
+ )
+ self.signature = signature
+
+ def selected_id(self):
+ listing = self.query_one(OptionList)
+ if listing.highlighted is not None and listing.option_count:
+ return listing.get_option_at_index(listing.highlighted).id
+ return None
+
+ def action_edit(self):
+ if msg_id := self.selected_id():
+ self.app.push_screen(
+ QueueEdit(self.client, self.sid, msg_id, edit=True)
+ )
+
+ def action_delete(self):
+ if msg_id := self.selected_id():
+ self.app.push_screen(QueueCancel(self.client, self.sid, msg_id))
+
+ async def action_move(self, offset):
+ from cc_remote.protocol import ReorderQueuedQueries
+
+ msg_id = self.selected_id()
+ ids = [q["msg_id"] for q in self.client.workspace.view(self.sid).queue]
+ if msg_id not in ids:
+ return
+ index = ids.index(msg_id)
+ target = index + offset
+ if not 0 <= target < len(ids):
+ return
+ order = ids.copy()
+ order[index], order[target] = order[target], order[index]
+ self.requested_order = order
+ status = self.query_one("#queue-status", Static)
+ status.update("Order requested; waiting for server list.")
+ sent = await self.client._send(ReorderQueuedQueries(
+ sid=self.sid, expected=ids, order=order,
+ cmd_id=uuid.uuid4().hex, client_id=self.client.client_id,
+ ))
+ if not sent:
+ self.requested_order = None
+ status.update(_safe_remote_text(self.client.notice))
+
+ def on_option_list_option_selected(
+ self, event: OptionList.OptionSelected
+ ) -> None:
+ event.stop()
+ self.app.push_screen(QueueEdit(self.client, self.sid, event.option.id))
+
+
+class QueueCancel(Overlay):
+ key_layer = "confirmation"
+ local_actions = {"cancel", "yes", "down", "up"}
+
+ def __init__(self, client, sid, msg_id):
+ super().__init__()
+ self.client, self.sid, self.msg_id = client, sid, msg_id
+ self.submitting = False
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label("Cancel this queued message?", markup=False)
+ yield Label(self.msg_id, markup=False)
+ yield PickerList(Option("No", id="no"), Option("Yes", id="yes"))
+ yield Static("", id="cancel-result", markup=False)
+ yield hints()
+
+ def on_mount(self):
+ self.query_one(OptionList).focus()
+
+ async def on_option_list_option_selected(self, event):
+ event.stop()
+ if event.option.id == "yes":
+ await self.action_yes()
+ else:
+ self.dismiss(None)
+
+ async def action_yes(self):
+ from cc_remote.protocol import CancelQueuedQuery
+
+ if self.submitting:
+ return
+ self.submitting = True
+ if await self.client._send(CancelQueuedQuery(
+ sid=self.sid, msg_id=self.msg_id,
+ cmd_id=uuid.uuid4().hex, client_id=self.client.client_id,
+ )):
+ self.dismiss(True)
+ else:
+ self.submitting = False
+ self.query_one("#cancel-result", Static).update(self.client.notice)
+
+
+class QueueEdit(Overlay):
+ key_layer = "form"
+ local_actions = {"cancel", "submit", "field_help"}
+
+ def __init__(self, client, sid: str, msg_id: str, *, edit=False):
+ super().__init__()
+ self.client, self.sid, self.msg_id = client, sid, msg_id
+ self.request_id = uuid.uuid4().hex
+ self.loaded = False
+ self.update_request_id = None
+ self.edit_on_load = edit
+
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tui-panel"):
+ yield Label(f"Queued message · {self.msg_id}", markup=False)
+ yield ModalEditor(locked=True, id="queued-prompt")
+ yield Static(
+ "Loading full prompt…", id="queue-result", markup=False
+ )
+ yield Label(
+ f"{self.client.keys.layer_label('form', 'help')}: "
+ "review cancellation of this queued message", markup=False
+ )
+ yield hints()
+
+ async def on_mount(self) -> None:
+ from cc_remote.protocol import GetQueuedQuery
+
+ accepted = await self.client._send(
+ GetQueuedQuery(
+ sid=self.sid,
+ msg_id=self.msg_id,
+ cmd_id=self.request_id,
+ client_id=self.client.client_id,
+ )
+ )
+ if not accepted:
+ self.query_one("#queue-result", Static).update(self.client.notice)
+ self.set_interval(0.1, self.receive_detail)
+
+ def receive_detail(self) -> None:
+ if self.update_request_id:
+ result = self.client.queue_update_results.pop(
+ self.update_request_id, None
+ )
+ if result:
+ self.update_request_id = None
+ message = (
+ "Queue edit saved by server."
+ if result.get("updated")
+ else "Queue edit rejected: " + str(
+ result.get("error") or result.get("message")
+ or "Message already left the queue"
+ )
+ )
+ self.query_one("#queue-result", Static).update(
+ _safe_remote_text(message)
+ )
+ detail = self.client.queue_details.pop(self.request_id, None)
+ if not detail:
+ return
+ result = self.query_one("#queue-result", Static)
+ if detail.get("prompt") is None:
+ result.update(
+ detail.get("error") or "Message already left the queue"
+ )
+ return
+ editor = self.query_one(ModalEditor)
+ editor.load_text(detail["prompt"])
+ editor.locked = False
+ editor.focus()
+ if self.edit_on_load:
+ editor.set_mode("INSERT")
+ self.edit_on_load = False
+ self.loaded = True
+ result.update(_safe_remote_text(
+ "Full prompt. i: edit; "
+ + self.client.keys.layer_label("form", "confirm") + ": save. "
+ "Attachments are preserved. " + (detail.get("error") or "")
+ ))
+
+ def on_unmount(self) -> None:
+ self.client.queue_reads.pop(self.request_id, None)
+ self.client.queue_details.pop(self.request_id, None)
+ if self.update_request_id:
+ self.client.queue_updates.pop(self.update_request_id, None)
+ self.client.queue_update_results.pop(self.update_request_id, None)
+
+ def action_field_help(self) -> None:
+ self.app.push_screen(QueueCancel(self.client, self.sid, self.msg_id))
+
+ async def action_submit(self) -> None:
+ from cc_remote.protocol import UpdateQueuedQuery
+
+ if self.update_request_id:
+ return
+ args = dict(
+ sid=self.sid,
+ msg_id=self.msg_id,
+ cmd_id=uuid.uuid4().hex,
+ client_id=self.client.client_id,
+ )
+ if self.loaded:
+ command = UpdateQueuedQuery(
+ **args, prompt=self.query_one(TextArea).text
+ )
+ else:
+ return
+ self.update_request_id = command.cmd_id
+ self.client.queue_updates[command.cmd_id] = (self.sid, self.msg_id)
+ self.query_one("#queue-result", Static).update(
+ "Waiting for server confirmation; edit not yet confirmed."
+ )
+ if await self.client._send(command):
+ self.query_one(ModalEditor).set_mode("NORMAL")
+ else:
+ self.client.queue_updates.pop(command.cmd_id, None)
+ self.update_request_id = None
+ self.query_one("#queue-result", Static).update(self.client.notice)
diff --git a/cc_remote/tui_presentation.py b/cc_remote/tui_presentation.py
new file mode 100644
index 00000000..bcc7eed5
--- /dev/null
+++ b/cc_remote/tui_presentation.py
@@ -0,0 +1,553 @@
+"""Terminal presentation of the same public state consumed by Web.
+
+No inferred completion, model RPC, or transcript replay side effects belong
+here. Native identities/timestamps are used wherever the protocol has them.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+import json
+import math
+import time
+
+from cc_remote.tui import _safe_remote_text
+
+LIMIT = 64 * 1024
+TERMINAL = {"completed", "failed", "declined", "cancelled", "interrupted"}
+SETTING_TYPES = {
+ "model",
+ "effort",
+ "auto_compact",
+ "codex_context",
+ "fast",
+ "collaboration_mode",
+ "perm",
+ "permission_profile",
+ "web_search",
+}
+
+
+def bounded(value):
+ """Never retain/render binary bodies or terminal escape sequences."""
+ if isinstance(value, str):
+ return _safe_remote_text(value[:LIMIT]) + (
+ "\n[Display truncated; open Web for the full artifact.]"
+ if len(value) > LIMIT
+ else ""
+ )
+ if isinstance(value, dict):
+ return {
+ k: (
+ "[binary attachment; open in Web]"
+ if k in {"data", "data_url"}
+ else bounded(v)
+ )
+ for k, v in list(value.items())[:256]
+ }
+ if isinstance(value, list):
+ return [bounded(v) for v in value[:512]]
+ return value
+
+
+def describe(value) -> str:
+ if not value:
+ return "No data yet."
+ return json.dumps(bounded(value), ensure_ascii=False, indent=2)
+
+
+def duration(ms: float | None) -> str:
+ if ms is None:
+ return "duration unavailable"
+ seconds = max(0, int(ms / 1000))
+ hours, seconds = divmod(seconds, 3600)
+ minutes, seconds = divmod(seconds, 60)
+ return (
+ (f"{hours}h " if hours else "")
+ + (f"{minutes}m " if minutes else "")
+ + f"{seconds}s"
+ )
+
+
+def tokens(value: int | None) -> str:
+ if value is None:
+ return "—"
+ for base, unit in ((10**12, "兆"), (10**8, "亿"), (10**4, "万")):
+ if value >= base:
+ return f"{value / base:.2f}".rstrip("0").rstrip(".") + unit
+ return str(value)
+
+
+def bar(percent: float | None, width: int = 10) -> str:
+ if percent is None or not math.isfinite(percent):
+ return "[" + "?" * width + "]"
+ filled = round(max(0, min(100, percent)) * width / 100)
+ return "[" + "█" * filled + "░" * (width - filled) + "]"
+
+
+def quota_windows(
+ rates: dict[str, dict], engine: str, now: float
+) -> list[tuple[str, dict | None]]:
+ """Mirror Web's account bucket selection; never mix model quotas into it."""
+ limits = []
+ for key, source in rates.items():
+ windows = [source.get(name) for name in ("primary", "secondary")]
+ windows = [
+ w
+ for w in windows
+ if w and (w.get("resets_at") is None or w["resets_at"] > now)
+ ]
+ if windows:
+ identity = source.get("limit_id") or (
+ None if key == "default" else key
+ )
+ limits.append((identity, windows))
+ account = next((windows for key, windows in limits if key == engine), None)
+ if account is None:
+ legacy = [
+ windows
+ for key, windows in limits
+ if key is None
+ and any(
+ w.get("window_duration_mins") in {300, 10080} for w in windows
+ )
+ ]
+ account = next(
+ (
+ windows
+ for windows in legacy
+ if {w.get("window_duration_mins") for w in windows}
+ >= {300, 10080}
+ ),
+ next(iter(legacy), []),
+ )
+ five = next(
+ (w for w in account if w.get("window_duration_mins") == 300), None
+ )
+ week = next(
+ (w for w in account if w.get("window_duration_mins") == 10080), None
+ )
+ if five is None and week is None and account:
+ result = [("Overall", account[0])]
+ else:
+ result = [("5h", five), ("Week", week)]
+ if engine == "claude":
+ for key, windows in limits:
+ if key in {"claude-seven-day-opus", "claude-seven-day-sonnet"}:
+ result.append((key.rsplit("-", 1)[1].title(), windows[0]))
+ return result
+
+
+def merge_rate_window(
+ current: dict, update: dict, *, replace: bool = False
+) -> dict:
+ """Match Web's sparse-safe quota periods and one-minute reset jitter."""
+ if replace:
+ return dict(update)
+ old_duration, new_duration = (
+ current.get("window_duration_mins"),
+ update.get("window_duration_mins"),
+ )
+ if (
+ old_duration is not None
+ and new_duration is not None
+ and old_duration != new_duration
+ ):
+ return dict(update)
+ old_reset, new_reset = current.get("resets_at"), update.get("resets_at")
+ if (
+ old_reset is not None
+ and new_reset is not None
+ and new_reset < old_reset - 60
+ ):
+ return current
+ new_period = (
+ old_reset is not None
+ and new_reset is not None
+ and new_reset > old_reset + 60
+ )
+ result = dict(current)
+ used = update.get("used_percent")
+ if new_period and used is None:
+ result.pop("used_percent", None)
+ elif used is not None and (
+ new_period
+ or current.get("used_percent") is None
+ or used >= current["used_percent"]
+ ):
+ result["used_percent"] = used
+ if new_reset is not None:
+ result["resets_at"] = (
+ new_reset
+ if old_reset is None or new_period
+ else max(old_reset, new_reset)
+ )
+ if new_duration is not None:
+ result["window_duration_mins"] = new_duration
+ return result
+
+
+@dataclass
+class TurnDisplay:
+ fork_id: str | None = None
+ checkpoint_id: str | None = None
+ started: float | None = None
+ ended: float | None = None
+ duration_ms: float | None = None
+ status: str = "running"
+ activity: str = "Processing"
+ seq: int = 0
+
+ def label(self, now: float | None = None) -> str:
+ elapsed = self.duration_ms
+ if elapsed is None and self.started is not None:
+ end = self.ended if self.ended is not None else (now or time.time())
+ if self.status == "running" or self.ended is not None:
+ elapsed = (end - self.started) * 1000
+ title = self.activity if self.status == "running" else self.status
+ return f"{title} · {duration(elapsed)}"
+
+
+@dataclass
+class SessionPresentation:
+ engine: str = "codex"
+ selected_turn: str = ""
+ turns: dict[str, TurnDisplay] = field(default_factory=dict)
+ active: str = ""
+ plan: dict | None = None
+ retired_plans: set[str] = field(default_factory=set)
+ goal: dict | None = None
+ goal_id: str | None = None
+ goal_dismissed: bool = False
+ retired_goal: str | None = None
+ completion: dict = field(default_factory=dict)
+ settings: dict = field(default_factory=dict)
+ context: dict = field(default_factory=dict)
+ status: dict = field(default_factory=dict)
+ rates: dict[str, dict] = field(default_factory=dict)
+ live_rate_revision: int = 0
+ reports: dict[str, dict] = field(default_factory=dict)
+ background: list[dict] = field(default_factory=list)
+ notices: dict[str, dict] = field(default_factory=dict)
+ questions: dict[str, dict] = field(default_factory=dict)
+ control: dict = field(default_factory=dict)
+
+ def bind(self, old: str, new: str) -> None:
+ if old == new:
+ return
+ if old in self.turns:
+ original = self.turns.pop(old)
+ self.turns.setdefault(new, original)
+ if self.active == old:
+ self.active = new
+ if self.selected_turn == old:
+ self.selected_turn = new
+ if self.plan and self.plan.get("turn_id") == old:
+ self.plan["turn_id"] = new
+
+ def event(self, e: dict, tid: str) -> None:
+ kind = e.get("type")
+ seq = e.get("seq") or 0
+ ts = e.get("ts")
+ known_turn = tid in self.turns
+ if tid and kind in {
+ "user_msg",
+ "assistant_msg_start",
+ "delta",
+ "tool_use",
+ "tool_result",
+ "process",
+ "turn_plan",
+ }:
+ turn = self.turns.setdefault(tid, TurnDisplay())
+ turn.seq = max(turn.seq, seq)
+ if turn.started is None and isinstance(ts, (int, float)):
+ turn.started = ts
+ else:
+ turn = self.turns.get(tid)
+ if kind == "user_msg":
+ if (
+ self.plan
+ and self.plan.get("turn_id") != tid
+ and (self.plan_terminal() or self.plan.get("done"))
+ ):
+ self.retired_plans.add(self.plan.get("turn_id", ""))
+ self.plan = None
+ completed_at = (self.goal or {}).get("updatedAt")
+ if (
+ self.goal
+ and self.goal.get("status") == "complete"
+ and (
+ (
+ completed_at is not None
+ and ts is not None
+ and ts > completed_at
+ )
+ or (completed_at is None and not known_turn)
+ )
+ ):
+ self.retired_goal = self.goal_id
+ self.active = tid
+ elif kind in {"assistant_msg_start", "delta", "tool_use", "process"}:
+ if turn and turn.status == "running" and not e.get("background"):
+ if not self.active:
+ self.active = tid
+ title = {"thinking": "Thinking", "final": "Answering"}.get(
+ e.get("channel"), "Processing"
+ )
+ turn.activity = str(e.get("title") or e.get("tool") or title)
+ elif kind == "state" and turn and turn.status == "running":
+ turn.activity = str(
+ e.get("detail") or e.get("phase") or "Processing"
+ )
+ elif kind == "turn_end" and turn:
+ turn.fork_id = e.get("turn_id") or turn.fork_id
+ turn.checkpoint_id = e.get("checkpoint_id") or turn.checkpoint_id
+ result = e.get("result") or {}
+ subtype = result.get("subtype", "")
+ turn.status = (
+ "interrupted"
+ if "interrupt" in subtype or subtype == "error_during_execution"
+ else "failed"
+ if result.get("is_error")
+ else "completed"
+ )
+ turn.duration_ms = result.get("duration_ms")
+ turn.ended = ts
+ if (
+ self.plan
+ and self.plan.get("turn_id") == tid
+ and subtype != "steered"
+ ):
+ self.plan.update(done=True, status=turn.status)
+ elif kind == "turn_plan":
+ if tid not in self.retired_plans:
+ self.plan = {
+ **bounded(e),
+ "turn_id": tid,
+ "done": bool(turn and turn.status != "running"),
+ }
+ elif kind == "goal_state":
+ self.goal = bounded(e.get("goal"))
+ self.goal_id = e.get("goal_id")
+ self.goal_dismissed = e.get("dismissed", False)
+ if not self.goal or self.goal.get("status") != "complete":
+ self.retired_goal = None
+ elif kind == "completion_state":
+ if e.get("revision", 0) >= self.completion.get("revision", -1):
+ self.completion = bounded(e)
+ elif kind in SETTING_TYPES:
+ self.settings[kind] = bounded(
+ {
+ k: v
+ for k, v in e.items()
+ if k not in {"v", "type", "sid", "seq", "ts"}
+ }
+ )
+ elif kind == "context_report":
+ self.context = bounded(e)
+ elif kind == "status_report":
+ self.status = bounded(e)
+ # A status read may have started before a newer rolling update.
+ # Only bootstrap rates here; live sparse updates remain authoritative.
+ if e.get("_rates_current", not self.live_rate_revision):
+ self.rates = {
+ r.get("limit_id") or "default": bounded(r)
+ for r in e.get("rate_limits", [])
+ }
+ elif kind == "rate_limit_update":
+ self.live_rate_revision += 1
+ key = e.get("limit_id") or "default"
+ if not e.get("limit_id") and len(self.rates) == 1:
+ key = next(iter(self.rates))
+ previous = self.rates.setdefault(key, {})
+ replace = (
+ bool(
+ previous.get("reached_type")
+ or previous.get("rate_limit_reached_type")
+ )
+ and e.get("reached_type") == ""
+ )
+ for name, value in e.items():
+ if value is not None and name in {
+ "name",
+ "plan_type",
+ "reached_type",
+ "primary",
+ "secondary",
+ }:
+ if isinstance(value, dict):
+ previous[name] = merge_rate_window(
+ previous.get(name) or {}, value, replace=replace
+ )
+ else:
+ previous[name] = value
+ self.rates = dict(list(self.rates.items())[-16:])
+ elif kind == "background_process_sync":
+ self.background = bounded(e.get("items", []))
+ elif kind == "notice":
+ self.notices[e["notice_id"]] = bounded(e)
+ self.notices = dict(list(self.notices.items())[-16:])
+ elif kind == "ask_user_sync":
+ self.questions.clear()
+ elif kind == "ask_user":
+ self.questions[e["ask_id"]] = bounded(e)
+ elif kind == "ask_user_closed":
+ self.questions.pop(e["ask_id"], None)
+ self.turns = dict(list(self.turns.items())[-160:])
+ if len(self.retired_plans) > 160:
+ self.retired_plans.intersection_update(self.turns)
+
+ def plan_terminal(self) -> bool:
+ if not self.plan:
+ return False
+ steps = self.plan.get("plan", [])
+ return (
+ bool(steps)
+ and all(s.get("status") == "completed" for s in steps)
+ or self.plan.get("status") in TERMINAL
+ )
+
+ def visible_goal(self) -> dict | None:
+ if self.goal_dismissed or (
+ self.goal_id and self.goal_id == self.retired_goal
+ ):
+ return None
+ return self.goal
+
+ def progress_label(self, shortcut: str = "Space g") -> str:
+ goal = self.visible_goal()
+ if goal:
+ return f"Goal: {goal.get('status')} · {goal.get('objective', '')[:90]} [{shortcut}: details]"
+ if self.plan:
+ steps = self.plan.get("plan", [])
+ completed = sum(s.get("status") == "completed" for s in steps)
+ state = self.plan.get("status", "")
+ if (
+ self.plan.get("done")
+ and completed < len(steps)
+ and state not in {"failed", "interrupted"}
+ ):
+ state = "turn ended; steps not updated"
+ return f"Plan {completed}/{len(steps)} · {state} [{shortcut}: details]"
+ return ""
+
+ def usage_label(self) -> str:
+ c = self.context or self.status.get("context", {})
+ percentage = (
+ None if c.get("available") is False else c.get("percentage")
+ )
+ parts = [
+ f"Context {bar(percentage)} {percentage:.0f}% used"
+ if isinstance(percentage, (int, float))
+ and math.isfinite(percentage)
+ else "Context unavailable"
+ ]
+ for label, window in quota_windows(
+ self.rates, self.engine, time.time()
+ ):
+ used = (window or {}).get("used_percent")
+ if not isinstance(used, (int, float)) or not math.isfinite(used):
+ continue
+ remaining = max(0, min(100, 100 - used))
+ parts.append(f"{label} {bar(remaining)} {remaining:.0f}% remaining")
+ return " · ".join(parts)
+
+ def settings_label(self) -> str:
+ parts = []
+ for kind, key in (
+ ("model", "model"),
+ ("effort", "effort"),
+ ("perm", "mode"),
+ ("permission_profile", "profile"),
+ ("web_search", "mode"),
+ ("collaboration_mode", "mode"),
+ ):
+ value = self.settings.get(kind, {}).get(key)
+ if value:
+ parts.append(str(value))
+ if "fast" in self.settings:
+ parts.append(
+ "Fast" if self.settings["fast"].get("on") else "Standard"
+ )
+ return " · ".join(parts)
+
+ def panel(self, name: str) -> str:
+ from cc_remote.tui_details import details
+
+ if name == "Goal / Plan":
+ g = self.goal
+ parts = []
+ if g:
+ parts += [
+ f"Goal — {g.get('status')}",
+ g.get("objective", ""),
+ f"Tokens: {tokens(g.get('tokensUsed'))} / {tokens(g.get('tokenBudget'))}",
+ f"Elapsed: {duration(g.get('timeUsedSeconds', 0) * 1000)}",
+ "Hidden on other surfaces" if self.goal_dismissed else "",
+ ]
+ for key in ("iterations", "lastReason"):
+ if g.get(key) is not None:
+ parts.append(f"{key}: {g[key]}")
+ if self.plan:
+ parts += ["\nPlan", self.plan.get("explanation") or ""]
+ for entry in self.plan.get("plan", []):
+ mark = {"completed": "✓", "inProgress": "→"}.get(
+ entry.get("status"), "○"
+ )
+ parts.append(f"{mark} {entry.get('step')}")
+ if self.plan.get("done"):
+ parts.append(
+ "Turn ended; uncompleted steps are not assumed complete."
+ )
+ return "\n".join(parts) or "No Goal or Plan."
+ if name == "Usage / Context":
+ parts = [
+ self.usage_label(),
+ "\nContext",
+ details(self.context or self.status.get("context")),
+ "\nCodex context settings",
+ details(self.settings.get("codex_context")),
+ "\nQuota (consumed, not remaining)",
+ details(self.rates),
+ ]
+ usage = self.status.get("usage") or {}
+ if usage:
+ parts += [
+ f"\nLifetime: {tokens(usage.get('lifetime_tokens'))} Tokens"
+ ]
+ buckets = usage.get("daily_usage_buckets", [])
+ peak = max((b.get("tokens", 0) for b in buckets), default=0)
+ for b in buckets:
+ value = b.get("tokens", 0)
+ parts.append(
+ f"{b.get('start_date')} {bar(value / peak * 100 if peak else 0)} {tokens(value)} Tokens"
+ )
+ return "\n".join(parts)
+ if name == "Settings":
+ return (
+ details(self.settings)
+ + "\n\nControl\n"
+ + details(self.control)
+ + "\n\nAvailable models\n"
+ + details(self.reports.get("models"))
+ + "\n\nPermission profiles\n"
+ + details(self.reports.get("permission_profiles"))
+ )
+ if name == "Background":
+ return details(self.background)
+ if name == "Notices":
+ return details(list(self.notices.values()))
+ if name == "Questions":
+ return details(list(self.questions.values()))
+ return details(self.status if name == "Status" else self.reports)
+
+
+def timestamp(value: float | None) -> str:
+ if value is None:
+ return ""
+ try:
+ return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
+ except (ValueError, OSError, OverflowError):
+ return ""
diff --git a/cc_remote/tui_preview.py b/cc_remote/tui_preview.py
new file mode 100644
index 00000000..49eb63e1
--- /dev/null
+++ b/cc_remote/tui_preview.py
@@ -0,0 +1,225 @@
+"""Bounded preview discovery/reads on the existing authenticated control link."""
+
+import asyncio
+import base64
+import io
+import os
+import re
+import sys
+import uuid
+import warnings
+from dataclasses import dataclass
+from pathlib import PurePosixPath
+from urllib.parse import unquote
+
+from markdown_it import MarkdownIt
+from PIL import Image
+
+from cc_remote.protocol import GetFilePreview, AuthorizePreview
+
+MAX_REFS = 64
+MAX_IMAGE_BYTES = 5 * 1024 * 1024
+IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"}
+MARKDOWN_SUFFIXES = {".md", ".markdown"}
+PATH = re.compile(
+ r"[^\s`<>\[\]{}\"']+\.(?:markdown|md|png|jpe?g|gif|webp|avif)(?=$|[\s),;。:#!?])",
+ re.I,
+)
+
+
+@dataclass(frozen=True)
+class PreviewRef:
+ path: str
+ kind: str
+
+
+def local_reference(raw):
+ raw = unquote(raw.strip()).split("#", 1)[0].split("?", 1)[0]
+ raw = re.sub(r":\d+(?::\d+)?$", "", raw)
+ if (
+ not raw
+ or len(raw.encode()) > 4096
+ or raw.startswith("//")
+ or re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", raw)
+ or any(ord(c) < 32 or ord(c) == 127 for c in raw)
+ ):
+ return None
+ suffix = PurePosixPath(raw).suffix.lower()
+ kind = (
+ "markdown"
+ if suffix in MARKDOWN_SUFFIXES
+ else ("image" if suffix in IMAGE_SUFFIXES else None)
+ )
+ return PreviewRef(raw, kind) if kind else None
+
+
+def references(text):
+ """Links, inline code and plain paths; no URL fetching or disk discovery."""
+ found = {}
+
+ def add(raw):
+ ref = local_reference(raw)
+ if ref and len(found) < MAX_REFS:
+ found.setdefault(ref.path, ref)
+
+ def walk(tokens):
+ in_link = False
+ for token in tokens:
+ if token.type == "link_close":
+ in_link = False
+ if token.type in {"link_open", "image"}:
+ add(token.attrGet("href") or token.attrGet("src") or "")
+ in_link = token.type == "link_open"
+ if token.type in {"code_inline", "fence", "code_block"}:
+ # Quoted/code paths may contain spaces.
+ if local_reference(token.content):
+ add(token.content)
+ else:
+ for match in PATH.finditer(token.content):
+ add(match[0])
+ elif token.type == "text" and not in_link:
+ for match in PATH.finditer(token.content):
+ add(match[0].lstrip("("))
+ if token.children and token.type != "image":
+ walk(token.children)
+
+ walk(MarkdownIt("commonmark").parse(text[:65536]))
+ return list(found.values())
+
+
+async def preview_request(client, sid, path=None, *, challenge=None):
+ """One correlated, cancellable private read. Never synthesize a model turn."""
+ request_id = (
+ challenge["request_id"] if challenge
+ else "tui-preview-" + uuid.uuid4().hex
+ )
+ if request_id in client.preview_waiters:
+ raise ValueError("This preview request is already pending")
+ future = asyncio.get_running_loop().create_future()
+ authorization_id = challenge["authorization_id"] if challenge else None
+ client.preview_waiters[request_id] = (sid, future, authorization_id)
+ command = (
+ AuthorizePreview(
+ sid=sid,
+ authorization_id=challenge["authorization_id"],
+ request_id=request_id,
+ decision="allow",
+ client_id=client.client_id,
+ )
+ if challenge
+ else GetFilePreview(
+ sid=sid,
+ path=path,
+ request_id=request_id,
+ client_id=client.client_id,
+ )
+ )
+ try:
+ if not await client._send(command):
+ raise ValueError(client.notice)
+ response = await asyncio.wait_for(future, 20)
+ if response.get("error") or response.get("type") == "error":
+ raise ValueError(response.get("error") or response.get("message"))
+ return response
+ except TimeoutError as exc:
+ raise ValueError("Preview timed out; use Reload file") from exc
+ finally:
+ client.preview_waiters.pop(request_id, None)
+
+
+def route_preview(client, event):
+ request_id = event.get("request_id") or ""
+ if not request_id.startswith("tui-preview-"):
+ return False
+ entry = client.preview_waiters.get(request_id)
+ if entry:
+ sid, future, authorization_id = entry
+ expected = client.workspace.rekeys.get(sid, sid)
+ kind = event.get("type")
+ expected_reply = (
+ kind == "preview_authorization_result"
+ and event.get("authorization_id") == authorization_id
+ if authorization_id else kind in {
+ "file_preview", "preview_authorization_required",
+ }
+ )
+ if (
+ event.get("sid") == expected
+ and not future.done()
+ and (kind == "error" or expected_reply)
+ ):
+ future.set_result(event)
+ # Late or mismatched binary replies never enter transcript/report storage.
+ return True
+
+
+def decode_image(event, *, thumbnail=True):
+ data = event.get("data") or ""
+ if (
+ event.get("format") != "image"
+ or len(data) > (MAX_IMAGE_BYTES + 2) // 3 * 4
+ ):
+ raise ValueError("Image exceeds the terminal preview limit")
+ if event.get("media_type") == "image/svg+xml":
+ raise ValueError("SVG preview requires the Web UI")
+ try:
+ raw = base64.b64decode(data, validate=True)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
+ with Image.open(io.BytesIO(raw)) as source:
+ if source.width * source.height > 16_000_000:
+ raise ValueError(
+ "Image dimensions exceed the preview limit"
+ )
+ source.seek(0)
+ if thumbnail:
+ source.thumbnail((1024, 768))
+ return source.convert("RGB")
+ except (
+ OSError,
+ SyntaxError,
+ Image.DecompressionBombError,
+ Image.DecompressionBombWarning,
+ ) as exc:
+ raise ValueError("Cannot decode this image safely") from exc
+
+
+def detect_graphics():
+ """Probe once BEFORE Textual owns stdin; never infer support from TERM."""
+ if not (
+ sys.__stdin__
+ and sys.__stdout__
+ and sys.__stdin__.isatty()
+ and sys.__stdout__.isatty()
+ ):
+ return None
+ try:
+ # tmux advertises SIXEL for its own parser even when the attached
+ # terminal cannot display it (Kitty, for example). That produces
+ # tmux's "SIXEL IMAGE" / '+' placeholders. Probe the end-to-end Kitty
+ # transport instead; never change the user's tmux configuration.
+ if os.environ.get("TMUX"):
+ from textual_image.renderable.tgp import query_terminal_support
+ from textual_image.widget import HalfcellImage
+ from cc_remote.tui_graphics import StableTGPImage
+
+ return StableTGPImage if query_terminal_support() else HalfcellImage
+ from textual_image.renderable import Image as Renderer
+ from textual_image.widget import Image as Widget
+
+ if Renderer.__module__.rsplit(".", 1)[-1] == "tgp":
+ from cc_remote.tui_graphics import StableTGPImage
+
+ return StableTGPImage
+ if Renderer.__module__.rsplit(".", 1)[-1] == "sixel":
+ return Widget
+ except (
+ ImportError,
+ OSError,
+ RuntimeError,
+ ValueError,
+ TimeoutError,
+ ZeroDivisionError,
+ ):
+ pass
+ return None
diff --git a/cc_remote/tui_preview_views.py b/cc_remote/tui_preview_views.py
new file mode 100644
index 00000000..a318152d
--- /dev/null
+++ b/cc_remote/tui_preview_views.py
@@ -0,0 +1,334 @@
+"""Numbered file hints, read-only Vim Markdown, and pan/zoom image previews."""
+
+from functools import partial
+
+from rich.console import Console
+from rich.text import Text
+from textual.containers import Vertical
+from textual.widgets import Label, OptionList, Static
+from textual.widgets.option_list import Option
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_modal import Overlay, PickerList, hints
+from cc_remote.tui_panels import PanelReader
+from cc_remote.tui_preview import preview_request
+from cc_remote.tui_inline_images import decode_async
+from cc_remote.tui_image_viewer import ImageCanvas
+from cc_remote.tui_keys import LAYERS
+from cc_remote.tui_markdown import render_markdown
+
+
+class FileHints(Overlay):
+ """Nine hints per page: digits select immediately, without 1/10 ambiguity."""
+
+ key_layer = "file_hints"
+ local_actions = {"cancel", "down", "up", "parent", "open", "select_file"}
+
+ def __init__(self, refs, open_preview=None):
+ super().__init__()
+ self.refs, self.page = refs, 0
+ self.open_preview = open_preview
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label("File previews · choose a numbered file")
+ yield PickerList()
+ yield Static("", id="hint-page", markup=False)
+ yield hints()
+
+ def on_mount(self):
+ self.fill()
+ self.query_one(OptionList).focus()
+
+ def fill(self):
+ listing = self.query_one(OptionList)
+ listing.clear_options()
+ for n, ref in enumerate(
+ self.refs[self.page * 9 : self.page * 9 + 9], 1
+ ):
+ listing.add_option(
+ Option(
+ Text(f"{self.app.client.keys.layer_label('file_hints', f'select_{n}')}"
+ f" {ref.kind} · " + _safe_remote_text(ref.path)),
+ id=str(n),
+ )
+ )
+ listing.highlighted = 0
+ self.query_one("#hint-page", Static).update(
+ f"Page {self.page + 1}/{(len(self.refs) + 8) // 9}"
+ )
+
+ def action_parent(self):
+ self.page = max(0, self.page - 1)
+ self.fill()
+
+ def action_open(self):
+ self.page = min((len(self.refs) - 1) // 9, self.page + 1)
+ self.fill()
+
+ def choose(self, number):
+ index = self.page * 9 + number - 1
+ if index < len(self.refs):
+ self.query_one(OptionList).highlighted = number - 1
+ if self.open_preview is not None:
+ self.open_preview(self.refs[index])
+ else:
+ self.dismiss(self.refs[index])
+
+ def action_select_file(self, number):
+ self.choose(number)
+
+ def on_option_list_option_selected(self, event):
+ event.stop()
+ self.choose(int(event.option.id))
+
+
+class MarkdownReader(PanelReader):
+ """Rendered text/styles, with the same read-only Vim grammar as chat."""
+
+ source = ""
+ rich_lines = None
+ source_truncated = False
+ local_truncated = False
+
+ def show_markdown(self, source):
+ self.source_truncated = len(source) > 256 * 1024
+ self.source = _safe_remote_text(source[: 256 * 1024])
+ self.reflow()
+
+ def reflow(self):
+ width = max(10, self.size.width - 2)
+ console = Console(width=width)
+ browser_key = (self.app.client.keys.layer_label("preview", "browser")
+ if self.is_mounted else "Space B")
+ lines = console.render_lines(
+ render_markdown(self.source, width, browser_key)[0],
+ console.options, pad=False,
+ )
+ self.rich_lines = [
+ Text.assemble(*((s.text, s.style) for s in line if not s.control))
+ for line in lines[:5000]
+ ]
+ self.local_truncated = self.source_truncated or len(lines) > 5000
+ if self.local_truncated:
+ self.rich_lines.append(Text(
+ "[Preview truncated locally — open the full file in Web]",
+ style="yellow",
+ ))
+ selection, scroll = self.selection, self.scroll_offset
+ self.load_text("\n".join(line.plain for line in self.rich_lines))
+ self.selection = selection
+ self.scroll_to(scroll.x, scroll.y, animate=False)
+ if self.is_mounted and isinstance(self.screen, FilePreviewScreen):
+ self.screen.update_preview_status()
+
+ def get_line(self, index):
+ if self.rich_lines is not None and index < len(self.rich_lines):
+ return self.rich_lines[index].copy()
+ return super().get_line(index)
+
+ def on_resize(self):
+ if self.source:
+ self.reflow()
+
+
+class FilePreviewScreen(Overlay):
+ DEFAULT_CSS = """
+ FilePreviewScreen #preview-image { height: 1fr; }
+ FilePreviewScreen #preview-status { height: auto; }
+ """
+ key_layer = "preview"
+ local_actions = {"cancel", "refresh", "browser"}
+
+ async def action_browser(self):
+ from cc_remote.tui_diagram_browser import open_session_browser
+ try:
+ self.client.notice = await open_session_browser(self.client, self.sid)
+ except ValueError as error:
+ self.client.notice = str(error)
+ if self.is_mounted:
+ self.query_one("#preview-status", Static).update(self.client.notice)
+
+ def __init__(self, client, sid, ref, graphics=None):
+ super().__init__()
+ self.client, self.sid, self.ref, self.graphics = (
+ client,
+ sid,
+ ref,
+ graphics,
+ )
+ self.challenge = None
+ self.worker = None
+ self.revision = 0
+ self.image_prefix = ()
+ self.wrapper_truncated = False
+ if ref.kind == "image":
+ self.key_layer = "image"
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label(_safe_remote_text(self.ref.path), markup=False)
+ yield Static(
+ "Reading from wrapper…", id="preview-status", markup=False
+ )
+ yield MarkdownReader()
+ yield Vertical(id="preview-image")
+ yield hints()
+
+ def on_mount(self):
+ reader = self.query_one(MarkdownReader)
+ reader.display = self.ref.kind != "image"
+ self.query_one("#preview-image").display = not reader.display
+ if reader.display:
+ reader.focus()
+ self.action_refresh()
+
+ async def on_key(self, event):
+ if self.key_layer != "image":
+ return
+ # Textual bindings handle single keys. Multi-key actions are resolved
+ # against the same configurable, indexed registry, not hidden Vim keys.
+ event.stop()
+ event.prevent_default()
+ sequence = self.image_prefix + (event.key,)
+ self.image_prefix = ()
+ await self.consume_image_chord(sequence)
+
+ async def consume_image_chord(self, sequence):
+ keys = self.client.keys.layers["image"]
+ for name, chords in keys.items():
+ for chord in chords:
+ parts = tuple(chord.split())
+ if parts == sequence:
+ await super().action_dispatch_shortcut(name, chord)
+ return True
+ if parts[:len(sequence)] == sequence:
+ self.image_prefix = sequence
+ return bool(self.image_prefix)
+
+ async def action_dispatch_shortcut(self, name, key):
+ prefix = self.image_prefix
+ self.image_prefix = ()
+ if (self.key_layer == "image" and prefix
+ and LAYERS["image"][name].action != "cancel"):
+ if await self.consume_image_chord(prefix + tuple(key.split())):
+ return
+ await super().action_dispatch_shortcut(name, key)
+
+ def action_pan(self, dx, dy):
+ self.query_one(ImageCanvas).pan(dx, dy)
+
+ def action_zoom(self, direction):
+ self.query_one(ImageCanvas).zoom(direction)
+
+ def action_fit(self):
+ self.query_one(ImageCanvas).fit()
+
+ def on_image_canvas_changed(self, event):
+ if "pan" not in self.local_actions:
+ return
+ self.query_one("#preview-status", Static).update(
+ f"Read-only · {event.zoom:.0%} of fit size"
+ )
+
+ def action_refresh(self):
+ self.revision += 1
+ if self.worker:
+ self.worker.cancel()
+ self.challenge = None
+ self.local_actions = {"cancel", "refresh", "browser"}
+ self.update_hint()
+ self.worker = self.run_worker(partial(self.load, self.revision))
+
+ async def load(self, revision, authorization=None):
+ status = self.query_one("#preview-status", Static)
+ if self.ref.kind == "image" and not self.graphics:
+ status.update(
+ "No supported terminal graphics protocol; use the Web UI"
+ )
+ return
+ status.update("Reading from wrapper…")
+ try:
+ if authorization:
+ result = await preview_request(
+ self.client, self.sid, challenge=authorization
+ )
+ if result.get("status") != "granted":
+ raise ValueError(
+ "File authorization was not granted; use Reload file"
+ )
+ result = await preview_request(self.client, self.sid, self.ref.path)
+ if revision != self.revision:
+ return
+ if result["type"] == "preview_authorization_required":
+ self.challenge = {
+ "authorization_id": result["authorization_id"],
+ "request_id": result["request_id"],
+ }
+ self.local_actions = {"cancel", "refresh", "authorize", "browser"}
+ self.update_hint()
+ status.update(
+ "Outside session directory. "
+ + self.client.keys.layer_label(self.key_layer, "authorize")
+ + ": allow this exact file read\n"
+ + _safe_remote_text(result["resolved_path"])
+ )
+ return
+ self.challenge = None
+ self.local_actions = {"cancel", "refresh", "browser"}
+ self.update_hint()
+ self.wrapper_truncated = bool(result.get("truncated"))
+ reader = self.query_one(MarkdownReader)
+ image_area = self.query_one("#preview-image", Vertical)
+ await image_area.remove_children()
+ if result.get("format") == "image":
+ if not self.graphics:
+ raise ValueError(
+ "No supported terminal graphics protocol; use the Web UI"
+ )
+ decoded = await decode_async(result, thumbnail=False)
+ if revision != self.revision or not self.is_mounted:
+ decoded.close()
+ return
+ reader.display = False
+ canvas = ImageCanvas(self.graphics, decoded)
+ try:
+ await image_area.mount(canvas)
+ finally:
+ if not canvas.is_mounted:
+ decoded.close()
+ self.local_actions |= {"pan", "zoom", "fit"}
+ self.update_hint()
+ canvas.focus()
+ else:
+ if result.get("format") not in {"markdown", "text"}:
+ raise ValueError("This format requires the Web UI")
+ reader.display = True
+ reader.show_markdown(result.get("content", ""))
+ reader.focus()
+ self.update_preview_status()
+ except (ValueError, OSError) as exc:
+ if revision == self.revision and self.is_mounted:
+ status.update(_safe_remote_text(str(exc)))
+
+ def update_preview_status(self):
+ reader = self.query_one(MarkdownReader)
+ self.query_one("#preview-status", Static).update(
+ "Read-only"
+ + (" · truncated by wrapper" if self.wrapper_truncated else "")
+ + (" · truncated locally — open full file in Web"
+ if reader.display and reader.local_truncated else "")
+ )
+
+ def action_authorize(self):
+ if self.challenge:
+ authorization, self.challenge = self.challenge, None
+ self.local_actions = {"cancel", "refresh", "browser"}
+ self.update_hint()
+ self.worker = self.run_worker(
+ partial(self.load, self.revision, authorization)
+ )
+
+ def on_unmount(self):
+ if self.worker:
+ self.worker.cancel()
diff --git a/cc_remote/tui_questions.py b/cc_remote/tui_questions.py
new file mode 100644
index 00000000..89ad57f9
--- /dev/null
+++ b/cc_remote/tui_questions.py
@@ -0,0 +1,94 @@
+"""Native async question metadata, separate from blocking ask-user RPCs."""
+
+from cc_remote.tui import _safe_remote_text
+
+
+def supplemental_answer_prompt(answers):
+ """Match Web's async-question-presentation.ts envelope exactly."""
+ return "补充回答:\n\n" + "\n\n".join(
+ f"问题:{question}\n回答:{answer}"
+ for question, answer in answers
+ )
+
+
+def matches_reply(prompt, questions):
+ if not prompt.startswith("补充回答:\n\n"):
+ return False
+ rest = prompt[len("补充回答:\n\n"):]
+ answers = []
+ next_index = 0
+ while rest:
+ index = next((
+ i for i in range(next_index, len(questions))
+ if rest.startswith(f"问题:{questions[i]['title']}\n回答:")
+ ), None)
+ if index is None:
+ return False
+ title = questions[index]["title"]
+ body = rest[len(f"问题:{title}\n回答:"):]
+ boundaries = [
+ end for q in questions[index + 1:]
+ if (end := body.find(f"\n\n问题:{q['title']}\n回答:")) >= 0
+ ]
+ end = min(boundaries, default=len(body))
+ answer = body[:end]
+ if not answer or answer != answer.strip():
+ return False
+ answers.append((title, answer))
+ rest = body[end + 2:] if end < len(body) else ""
+ next_index = index + 1
+ return bool(answers) and supplemental_answer_prompt(answers) == prompt
+
+
+def message_metadata(message: dict) -> dict:
+ if message.get("delivery") != "async":
+ return {}
+ result = {"delivery": "async"}
+ questions = message.get("questions")
+ if isinstance(questions, list):
+ result["questions"] = [
+ {"title": q["title"], "options": q.get("options") or []}
+ for q in questions[:16]
+ if isinstance(q, dict) and isinstance(q.get("title"), str)
+ ]
+ return result
+
+
+def question_text(questions: list[dict], text: str = "") -> str:
+ titles = [q["title"] for q in questions]
+ lines = [text] if text.strip() and text.strip() not in titles else []
+ for q in questions:
+ lines.append(q["title"])
+ lines.extend(
+ f" {index}. {label}"
+ for index, label in enumerate(q.get("options") or [], 1)
+ )
+ return _safe_remote_text("\n".join(lines))
+
+
+def pending_async(view) -> list:
+ """Keep unrelated questions pending after a canonical supplemental reply.
+
+ Derive this from the shared narrative, including history and other clients'
+ replies. A transport-pending answer temporarily disables resubmission;
+ rejection removes that receipt and makes the question answerable again.
+ """
+ in_flight = {
+ identity
+ for block in view.pending_messages.values()
+ for identity in block.data.get("async_questions", [])
+ }
+ pending = {}
+ for block in view.blocks:
+ if block.role == "user" and block.data.get("status") != "failed":
+ matches = [
+ identity for identity, question in pending.items()
+ if matches_reply(block.text, question.data["questions"])
+ ]
+ if len(matches) == 1:
+ pending.pop(matches[0])
+ elif not block.text.startswith("补充回答:\n\n"):
+ pending.clear()
+ if block.data.get("questions"):
+ pending.setdefault(block.id, block)
+ return [b for b in pending.values() if b.id not in in_flight]
diff --git a/cc_remote/tui_settings.py b/cc_remote/tui_settings.py
new file mode 100644
index 00000000..2b4476a5
--- /dev/null
+++ b/cc_remote/tui_settings.py
@@ -0,0 +1,475 @@
+"""Keyboard-native session setup using the same catalogs/commands as Web."""
+
+from __future__ import annotations
+
+import uuid
+
+from rich.text import Text
+from textual.containers import Vertical
+from textual.widgets import Label, OptionList, Static
+from textual.widgets.option_list import Option
+
+from cc_remote import protocol as p
+from cc_remote.tui_actions import build_action
+from cc_remote.tui_modal import ModalEditor, Overlay, PickerList, hints
+from cc_remote.tui_panels import ActionPicker
+
+
+class ValuePicker(ActionPicker):
+ def __init__(self, title: str, choices: list[tuple[str, object]], *, selected=...):
+ super().__init__(None, None)
+ self.title_text, self.choices = title, choices
+ self.selected = selected
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label(self.title_text, markup=False)
+ yield ModalEditor(
+ classes="search-editor",
+ id="search",
+ placeholder="Search choices (see shortcuts below)",
+ )
+ yield PickerList()
+ yield hints()
+
+ def filter(self, text: str) -> None:
+ listing = self.query_one(OptionList)
+ listing.clear_options()
+ for index, (label, _) in enumerate(self.choices):
+ if all(
+ word in label.casefold() for word in text.casefold().split()
+ ):
+ listing.add_option(Option(Text(label), id=str(index)))
+ listing.highlighted = 0 if listing.option_count else None
+ if not text and self.selected is not ...:
+ for index, (_, value) in enumerate(self.choices):
+ if value == self.selected:
+ listing.highlighted = index
+ break
+
+ def open_form(self, name: str) -> None:
+ # A wrapper distinguishes choosing the engine default (None) from Esc.
+ self.dismiss((self.choices[int(name)][1],))
+
+
+class TextValue(Overlay):
+ key_layer = "form"
+ local_actions = {"cancel", "submit"}
+
+ def __init__(self, title: str, value: str):
+ super().__init__()
+ self.title_text, self.value = title, value
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label(self.title_text, markup=False)
+ yield ModalEditor(self.value)
+ yield hints()
+
+ def on_mount(self):
+ self.query_one(ModalEditor).focus()
+
+ async def action_submit(self):
+ self.dismiss((self.query_one(ModalEditor).text.strip(),))
+
+
+class SettingsForm(Overlay):
+ key_layer = "picker"
+ local_actions = {"cancel", "edit", "down", "up"}
+
+ def __init__(self, client, sid, *, new=False, initial_field=None):
+ super().__init__()
+ self.client, self.sid, self.new = client, sid, new
+ self.initial_field = initial_field
+ row = client.workspace.catalog.get(sid or client.attached_sid, {})
+ self.engine = client.engine if new else row.get("engine", client.engine)
+ self.space = client.space if new else row.get("space", client.space)
+ self.values = {"cwd": "~" if new else row.get("cwd") or "~"}
+ self.profiles = {
+ key: row.get(key)
+ for key in ("claude_profile_id", "codex_profile_id")
+ }
+ self.profiles[
+ "claude_profile_id"
+ if self.engine == "codex"
+ else "codex_profile_id"
+ ] = None
+ profile_key = self.engine + "_profile_id"
+ if not self.profiles.get(profile_key):
+ self.profiles[profile_key] = client.workspace.reports.get(
+ self.engine + "_profiles", {}
+ ).get("default_" + profile_key)
+ if new:
+ self.values.update(
+ {name: None for name in self.fields() if name != "cwd"}
+ )
+ else:
+ settings = client.workspace.view(sid).presentation.settings
+ for field, (event, attr, _) in SETTING_FIELDS.items():
+ self.values[field] = settings.get(event, {}).get(attr)
+ self.signature = None
+ self.submitting = False
+ self.pending_change = None
+ self.last_choices = {}
+
+ def fields(self):
+ fields = ["cwd"] if self.new and self.space == "code" else []
+ fields += ["model", "effort"]
+ if self.engine == "codex":
+ if self.space == "code":
+ fields += [
+ "permission_profile",
+ "permission_mode",
+ "web_search",
+ ]
+ fields += ["collaboration_mode", "service_tier"]
+ elif not self.new and self.space == "code":
+ fields += ["permission_mode"]
+ return fields
+
+ def args(self, kind):
+ values = {"cwd": self.values["cwd"], **self.profiles}
+ if kind == "models":
+ values["engine"] = self.engine
+ else:
+ values.pop("claude_profile_id", None)
+ return values
+
+ def catalog(self, kind):
+ key = (kind, *self.client.capability_key(self.args(kind)))
+ return self.client.settings_catalogs.get(key, {})
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label(
+ f"{'New session' if self.new else 'Settings'} · "
+ f"{self.engine.title()} / {self.space.title()} · {self.sid or 'new'}",
+ markup=False,
+ )
+ yield Label(
+ self.client.keys.layer_label("picker", "choose")
+ + ": choose/edit field · "
+ + (
+ "Select the create row to submit"
+ if self.new
+ else "Choose a value and press Enter to apply"
+ ),
+ markup=False,
+ )
+ if self.space == "work":
+ yield Label(
+ "Work assigns its directory and safe permissions.",
+ markup=False,
+ )
+ yield PickerList()
+ yield Static("", id="form-result", markup=False)
+ yield hints()
+
+ async def on_mount(self):
+ self.paint()
+ self.query_one(OptionList).focus()
+ self.set_interval(0.25, self.paint)
+ await self.fetch()
+ if self.initial_field in self.fields():
+ self.query_one(OptionList).highlighted = self.fields().index(
+ self.initial_field
+ )
+
+ async def fetch(self):
+ await self.client._send(
+ p.GetModels(sid=self.sid, **self.args("models"))
+ )
+ if self.engine == "codex" and self.space == "code":
+ await self.client._send(
+ p.GetPermissionProfiles(
+ sid=self.sid, **self.args("permission_profiles")
+ )
+ )
+
+ def paint(self):
+ if not self.new:
+ sid = self.client.workspace.rekeys.get(self.sid, self.sid)
+ settings = self.client.workspace.view(sid).presentation.settings
+ for field, (event, attr, _) in SETTING_FIELDS.items():
+ if event in settings:
+ self.values[field] = settings[event].get(attr)
+ if "fast" in settings:
+ self.values["service_tier"] = (
+ "fast" if settings["fast"].get("on") else "default"
+ )
+ if self.pending_change:
+ field, value, ticket, previous = self.pending_change
+ result = self.client.action_results.get(ticket, {})
+ if result.get("error"):
+ self.query_one("#form-result", Static).update(result["error"])
+ self.pending_change = None
+ event = "fast" if field == "service_tier" else SETTING_FIELDS[field][0]
+ if self.pending_change and event in settings and settings[event] is not previous:
+ self.query_one("#form-result", Static).update(
+ "Effective setting confirmed by the server."
+ )
+ self.pending_change = None
+ signature = (
+ repr(self.values),
+ repr(self.catalog("models")),
+ repr(self.catalog("permission_profiles")),
+ )
+ if signature == self.signature:
+ return
+ self.signature = signature
+ listing = self.query_one(OptionList)
+ selected = listing.highlighted
+ listing.clear_options()
+ for field in self.fields():
+ value = self.values.get(field)
+ label = str(value) if value is not None else "Engine default"
+ listing.add_option(
+ Option(
+ Text(f"{field.replace('_', ' ').title()}: {label}"),
+ id=field,
+ )
+ )
+ if self.new:
+ listing.add_option(Option("Create session", id="review"))
+ listing.highlighted = min(selected or 0, listing.option_count - 1)
+
+ def choices(self, field):
+ choices = [("Engine default (no override)", None)] if self.new else []
+ if field == "model":
+ models = self.catalog("models").get("models", [])
+ if self.engine == "claude":
+ # Claude has no model/list RPC; same curated choices as Web.
+ models = [{"id": value} for value in CLAUDE_MODELS]
+ for model in models:
+ identity = model.get("id")
+ if identity:
+ choices.append(
+ (str(model.get("display_name") or identity), identity)
+ )
+ elif field == "effort":
+ catalog = self.catalog("models")
+ selected = self.values.get("model") or catalog.get("default_model")
+ model = next(
+ (
+ m
+ for m in catalog.get("models", [])
+ if m.get("id") == selected
+ ),
+ {},
+ )
+ efforts = model.get("efforts", [])
+ if self.engine == "claude":
+ from cc_remote.wrapper.claude_controls import CLAUDE_EFFORTS
+
+ efforts = sorted(CLAUDE_EFFORTS)
+ for effort in efforts:
+ value = (
+ effort.get("id") or effort.get("effort")
+ if isinstance(effort, dict)
+ else effort
+ )
+ if value:
+ choices.append((str(value), value))
+ elif field == "permission_profile":
+ for profile in self.catalog("permission_profiles").get(
+ "profiles", []
+ ):
+ if profile.get("allowed"):
+ choices.append(
+ (
+ profile["id"]
+ + " · "
+ + (profile.get("description") or ""),
+ profile["id"],
+ )
+ )
+ else:
+ values = {
+ "permission_mode": ["never", "on-request", "untrusted"]
+ if self.engine == "codex"
+ else [
+ "default",
+ "acceptEdits",
+ "auto",
+ "bypassPermissions",
+ "plan",
+ ],
+ "web_search": ["cached", "live"],
+ "collaboration_mode": ["default", "plan"],
+ "service_tier": ["default", "fast"],
+ }.get(field, [])
+ choices += [(value, value) for value in values]
+ return choices
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected):
+ event.stop()
+ self.open_field(event.option.id)
+
+ def action_edit(self):
+ listing = self.query_one(OptionList)
+ if listing.highlighted is not None:
+ self.open_field(listing.get_option_at_index(listing.highlighted).id)
+
+ def open_field(self, field):
+ if self.submitting:
+ return
+ if self.pending_change:
+ self.query_one("#form-result", Static).update(
+ "Still waiting for the server; no duplicate command sent."
+ )
+ return
+ if field == "review" and self.new:
+ self.run_worker(self.action_submit())
+ return
+ if field == "cwd":
+ from cc_remote.tui_directories import DirectoryPicker
+
+ dialog = DirectoryPicker(self.client, self.values["cwd"])
+ else:
+ choices = self.choices(field)
+ if not choices or (self.new and len(choices) == 1):
+ self.query_one("#form-result", Static).update(
+ "Catalog not available yet; reopen or wait for the server. "
+ "No permissions were changed."
+ )
+ # No catalog must not offer a guessed Full Access option.
+ return
+ dialog = ValuePicker(
+ field.replace("_", " ").title(), choices,
+ selected=self.last_choices.get(field, ...),
+ )
+
+ async def chosen(result):
+ if result is None:
+ return
+ value = result[0]
+ if field != "cwd" and value not in [
+ v for _, v in self.choices(field)
+ ]:
+ self.query_one("#form-result", Static).update(
+ "Available choices changed; reopen this field before applying."
+ )
+ return
+ if field == "cwd" and (not value or "\n" in value):
+ self.query_one("#form-result", Static).update(
+ "Enter one nonempty directory."
+ )
+ return
+ if self.new:
+ self.values[field] = value
+ if field == "cwd":
+ self.values["permission_profile"] = None
+ self.values["model"] = self.values["effort"] = None
+ await self.fetch()
+ elif field == "model":
+ self.values["effort"] = None
+ self.paint()
+ else:
+ import json
+
+ _, attr, command = SETTING_FIELDS[field]
+ try:
+ message = build_action(
+ command,
+ json.dumps({attr: value}),
+ self.sid,
+ self.client.client_id,
+ )
+ except ValueError as exc:
+ self.query_one("#form-result", Static).update(str(exc))
+ return
+
+ self.submitting = True
+ self.last_choices[field] = value
+ event = "fast" if field == "service_tier" else SETTING_FIELDS[field][0]
+ previous = self.client.workspace.view(self.sid).presentation.settings.get(event)
+ self.pending_change = (field, value, message.cmd_id, previous)
+ try:
+ if await self.client.execute_action(message):
+ self.query_one("#form-result", Static).update(
+ "Submitted; displayed settings follow the server confirmation."
+ )
+ self.paint()
+ else:
+ self.pending_change = None
+ self.query_one("#form-result", Static).update(
+ self.client.notice
+ )
+ except (OSError, ValueError) as exc:
+ self.pending_change = None
+ self.query_one("#form-result", Static).update(str(exc))
+ finally:
+ self.submitting = False
+
+ self.app.push_screen(dialog, chosen)
+
+ async def action_submit(self):
+ if not self.new or self.submitting:
+ return
+ try:
+ args = {name: self.values.get(name) for name in self.fields()}
+ profile = args.get("permission_profile")
+ if profile and profile not in [
+ value for _, value in self.choices("permission_profile")
+ ]:
+ raise ValueError(
+ "Selected permission is not allowed in this directory."
+ )
+ for field in ("model", "effort"):
+ if args.get(field) is not None and args[field] not in [
+ value for _, value in self.choices(field)
+ ]:
+ raise ValueError(
+ f"Selected {field} is no longer available."
+ )
+ message = p.NewSession(
+ **args,
+ **self.profiles,
+ engine=self.engine,
+ space=self.space,
+ request_id=uuid.uuid4().hex,
+ cmd_id=uuid.uuid4().hex,
+ client_id=self.client.client_id,
+ )
+ except ValueError as exc:
+ self.query_one("#form-result", Static).update(str(exc))
+ return
+ self.submitting = True
+ try:
+ if await self.client.execute_action(message):
+ self.created(True)
+ else:
+ self.query_one("#form-result", Static).update(self.client.notice)
+ finally:
+ self.submitting = False
+
+ def created(self, success):
+ if success:
+ self.dismiss(None)
+
+
+SETTING_FIELDS = {
+ "model": ("model", "model", "set_model"),
+ "effort": ("effort", "effort", "set_effort"),
+ "permission_profile": (
+ "permission_profile",
+ "profile",
+ "set_permission_profile",
+ ),
+ "permission_mode": ("perm", "mode", "set_perm"),
+ "web_search": ("web_search", "mode", "set_web_search"),
+ "collaboration_mode": (
+ "collaboration_mode",
+ "mode",
+ "set_collaboration_mode",
+ ),
+ "service_tier": ("fast", "service_tier", "set_service_tier"),
+}
+
+CLAUDE_MODELS = (
+ "claude-opus-5[1m]",
+ "claude-mythos-5-1",
+ "claude-sonnet-5",
+ "claude-haiku-4-5",
+ "claude-fable-5-1",
+)
diff --git a/cc_remote/tui_state.py b/cc_remote/tui_state.py
new file mode 100644
index 00000000..b1e4ef6b
--- /dev/null
+++ b/cc_remote/tui_state.py
@@ -0,0 +1,1225 @@
+"""Small, bounded terminal projection. No engine or filesystem access."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field, replace
+import json
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.tui_questions import message_metadata, question_text
+from cc_remote.tui_presentation import (
+ SessionPresentation,
+ TurnDisplay,
+ bounded,
+ describe,
+ duration,
+ timestamp,
+)
+
+MAX_BLOCK_CHARS = 64 * 1024
+MAX_BLOCKS = 160
+TRUNCATED = "\n[Terminal display limit reached; inspect turn details.]"
+REPORT_TYPES = {
+ "models",
+ "engine_capabilities",
+ "permission_profiles",
+ "diff_report",
+ "turn_file_changes",
+ "turn_file_changes_page",
+ "files_listed",
+ "file_preview",
+ "file_save_result",
+ "preview_asset",
+ "history_image",
+ "preview_authorization_required",
+ "preview_authorization_result",
+ "dir_list",
+ "agent_detail",
+ "work_dashboard",
+ "work_artifacts",
+ "queued_query_updated",
+ "rollback_result",
+ "rate_limit_reset_result",
+ "session_forked",
+ "btw_opened",
+ "btw_sync",
+ "btw_closed",
+ "takeover_state",
+}
+
+
+def clip(text: str) -> str:
+ if len(text) <= MAX_BLOCK_CHARS:
+ return text
+ return text[: MAX_BLOCK_CHARS - len(TRUNCATED)] + TRUNCATED
+
+
+def detail_sections(text, starts):
+ """Keep typed section boundaries; never infer roles from model text."""
+ return [dict(
+ line=text.count("\n", 0, start), role=block.role,
+ channel=block.channel, status=block.data.get("status"),
+ is_error=block.data.get("is_error"),
+ ) for start, block in starts if start < len(text)]
+
+
+@dataclass
+class Block:
+ id: str
+ role: str
+ text: str = ""
+ turn: str = ""
+ channel: str = "unknown"
+ seq: int = 0
+ expanded: bool = False
+ data: dict = field(default_factory=dict)
+
+
+@dataclass
+class SessionView:
+ blocks: list[Block] = field(default_factory=list)
+ # Local receipts are not native turns and must survive history rebuilds.
+ pending_messages: dict[str, Block] = field(default_factory=dict)
+ tab_terminal: tuple[str, str] | None = None
+ tab_read_ids: list[str] = field(default_factory=list)
+ draft: str = ""
+ draft_cursor: tuple[int, int] = (0, 0)
+ attachments: list[dict] = field(default_factory=list)
+ pending_queued_text: dict[str, dict] = field(default_factory=dict)
+ recovered_queued_text: list[str] = field(default_factory=list)
+ pending_attachments: dict[str, tuple[str, list[dict]]] = field(
+ default_factory=dict
+ )
+ artifact_epoch: int = 0
+ # Stable block id + offset, not wrapped screen coordinates.
+ anchor: tuple[str, int] = ("", 0)
+ selection: tuple[str, int] = ("", 0)
+ viewport: tuple[str, int] = ("", 0)
+ follow: bool = True
+ tail_hidden: bool = False
+ state: str = "unknown"
+ write_state: str = "unknown"
+ control_generation: str | None = None
+ control_revision: int = -1
+ queue: list[dict] = field(default_factory=list)
+ revision: str = ""
+ continuity_revision: str | None = None
+ pending_revision: str | None = None
+ generation: str | None = None
+ build_seq: int = -1
+ oldest: str | None = None
+ has_more: bool = False
+ loading: bool = False
+ active_turn: str = ""
+ aliases: dict[str, str] = field(default_factory=dict)
+ details: dict[str, str | None] = field(default_factory=dict)
+ details_newer: dict[str, str | None] = field(default_factory=dict)
+ collapsed_details: set[str] = field(default_factory=set)
+ local_details: dict[str, Block] = field(default_factory=dict)
+ tool_groups: dict[str, Block] = field(default_factory=dict)
+ group_parents: dict[str, str] = field(default_factory=dict)
+ detail_blocks: dict[str, list[Block]] = field(default_factory=dict)
+ jump_back: list[tuple] = field(default_factory=list)
+ jump_forward: list[tuple] = field(default_factory=list)
+ version: int = 0
+ presentation: SessionPresentation = field(
+ default_factory=SessionPresentation
+ )
+ presentation_generation: str | None = None
+
+ def tab_identity(self, identity: str) -> str:
+ identity = self.aliases.get(identity, identity)
+ if identity in self.presentation.turns:
+ return identity
+ for tid, turn in self.presentation.turns.items():
+ if identity in {tid, turn.fork_id, turn.checkpoint_id}:
+ return tid
+ return identity
+
+ def tab_badge(self) -> str | None:
+ if self.state == "running":
+ return "running"
+ read = {self.tab_identity(identity) for identity in self.tab_read_ids}
+ if self.tab_terminal:
+ identity, status = self.tab_terminal
+ if self.tab_identity(identity) not in read:
+ return status
+ receipt = self.presentation.completion
+ identity = receipt.get("completion_id")
+ if (receipt.get("unread") and identity
+ and self.tab_identity(identity) not in read):
+ return "completed"
+ return None
+
+ def mark_tab_read(self, *identities: str) -> None:
+ for identity in identities:
+ if identity:
+ identity = self.tab_identity(identity)
+ if identity not in self.tab_read_ids:
+ self.tab_read_ids.append(identity)
+ del self.tab_read_ids[:-64]
+
+ def read_tab(self) -> None:
+ self.mark_tab_read(
+ self.tab_terminal[0] if self.tab_terminal else "",
+ self.presentation.completion.get("completion_id") or "",
+ )
+
+ def put(self, block: Block, *, append: bool = False) -> None:
+ if block.role == "user":
+ identity = block.id.removeprefix("user:")
+ for msg_id in list(self.pending_messages):
+ if self.aliases.get(msg_id, msg_id) == identity:
+ self.pending_messages.pop(msg_id)
+ previous = next((b for b in self.blocks if b.id == block.id), None)
+ if previous is not None:
+ if block.role == "user" and "status" not in block.data:
+ previous.data.pop("status", None)
+ if block.role in {"tool", "process"}:
+ # Native item IDs may first arrive as commentary scaffolding.
+ # Once typed, the item must use tool folding, not prose layout.
+ if previous.role not in {"tool", "process"}:
+ previous.expanded = False
+ previous.role = block.role
+ previous.channel = "unknown"
+ previous.text = (previous.text if append else "") + block.text
+ previous.text = clip(previous.text)
+ previous.seq = max(previous.seq, block.seq)
+ if block.channel != "unknown":
+ previous.channel = block.channel
+ previous.data.update(block.data)
+ if previous.data.get("delivery") == "async":
+ previous.channel = "commentary"
+ else:
+ block.text = clip(block.text)
+ self.blocks.append(block)
+ self.trim()
+ self.version += 1
+
+ def trim(self, *, older_page: bool = False) -> None:
+ if len(self.blocks) <= MAX_BLOCKS:
+ return
+ protected = {self.anchor[0], self.selection[0], self.viewport[0]}
+ dropped = self.blocks[:-MAX_BLOCKS]
+ if older_page or (
+ not self.follow and any(b.id in protected for b in dropped)
+ ):
+ # The server remains the canonical tail store. Keep the reading
+ # window pinned; G fetches a fresh newest page before following.
+ self.blocks = self.blocks[:MAX_BLOCKS]
+ self.tail_hidden = True
+ else:
+ self.blocks = self.blocks[-MAX_BLOCKS:]
+ if self.blocks[0].turn:
+ self.oldest = self.blocks[0].turn
+ self.has_more = True
+
+ def control(self, value: dict) -> None:
+ generation = value.get("generation")
+ revision = value.get("revision", -1)
+ if self.control_generation and not generation:
+ return
+ if (
+ generation == self.control_generation
+ and revision <= self.control_revision
+ ):
+ return
+ self.control_generation = generation
+ self.control_revision = revision
+ self.write_state = value.get("write_state", "unknown")
+ self.presentation.control = bounded(value)
+
+ def block_header(self, block: Block, *, show_turn=False, now=None,
+ detail_key="Enter") -> str:
+ title = {
+ "user": "You",
+ "assistant": "Assistant",
+ "tool": "Tool",
+ "process": "Activity",
+ "detail": "Turn details",
+ "tool_group": "工具活动",
+ }.get(block.role, block.role)
+ if block.role == "assistant":
+ title = {
+ "thinking": "Thinking",
+ "commentary": "Progress",
+ "final": "Assistant · final",
+ }.get(block.channel, title)
+ if (block.channel == "final"
+ and (turn := self.presentation.turns.get(block.turn))
+ and turn.status == "running"):
+ title = "Assistant"
+ if block.data.get("questions"):
+ title = "Question · non-blocking"
+ if block.data.get("nested") or block.role == "tool_group":
+ title += " ▾" if block.expanded else " ▸"
+ if block.data.get("status"):
+ title += " · " + block.data["status"]
+ if block.data.get("duration_ms") is not None:
+ title += " · " + duration(block.data["duration_ms"])
+ if block.data.get("exit_code") is not None:
+ title += f" · exit {block.data['exit_code']}"
+ suffix = ""
+ turn = self.presentation.turns.get(block.turn)
+ if show_turn and turn:
+ suffix = f" [{timestamp(turn.started)} · {turn.label(now)}]"
+ if block.data.get("nested"):
+ suffix += f" [{detail_key}]"
+ return f"── {title} ──{suffix}"
+
+ def display_blocks(self) -> list[Block]:
+ from cc_remote.tui_activity import project
+
+ return project(self)
+
+ def render(self, *, fold=True, detail_key="Enter", older_key="o", newer_key="O",
+ close_key="Esc") -> tuple[str, list[tuple[int, Block]]]:
+ parts: list[str] = []
+ starts: list[tuple[int, Block]] = []
+ offset = 0
+ seen_turns: set[str] = set()
+ for block in self.display_blocks() if fold else self.blocks:
+ starts.append((offset, block))
+ content = _safe_remote_text(block.text)
+ page_hint = ""
+ if block.data.get("questions"):
+ content = question_text(block.data["questions"], block.text)
+ if block.role in {"tool", "process"} and not block.expanded:
+ content = content.split("\n", 1)[0][:160] + f" [{detail_key}: details]"
+ if (block.role == "detail" and not block.expanded
+ and not block.data.get("nested")):
+ content = f"{detail_key}: show this turn's details"
+ elif block.role == "detail" and block.expanded:
+ paging = []
+ if self.details.get(block.turn):
+ paging.append(f"{older_key}: load older detail page")
+ if self.details_newer.get(block.turn):
+ paging.append(f"{newer_key}: load newer detail page")
+ if paging:
+ page_hint = " · ".join(paging)
+ page_hint += f" · {detail_key}/{close_key}: collapse"
+ content += "\n" + page_hint
+ if block.role == "assistant" and block.channel == "thinking":
+ if not block.expanded:
+ content = (
+ content.split("\n", 1)[0][:120] + f" [{detail_key}: expand]"
+ )
+ header = self.block_header(
+ block, show_turn=block.turn not in seen_turns,
+ detail_key=detail_key,
+ )
+ seen_turns.add(block.turn)
+ if block.role == "tool_group":
+ arrow = "▾" if block.expanded else "▸"
+ status = block.data.get("status") or ""
+ text = (
+ f" {arrow} {_safe_remote_text(block.text)}"
+ + (f" · {status}" if status else "")
+ + f" [{detail_key}]\n\n"
+ )
+ elif block.data.get("nested"):
+ text = header + ("\n" + page_hint if page_hint else "") + "\n\n"
+ else:
+ text = f"{header}\n{content}\n\n"
+ parts.append(text)
+ offset += len(text)
+ return "".join(parts), starts
+
+ @staticmethod
+ def locate(offset: int, starts: list[tuple[int, Block]]) -> tuple[str, int]:
+ for start, block in reversed(starts):
+ if start <= offset:
+ return block.id, offset - start
+ return "", 0
+
+ def resolve(
+ self,
+ anchor: tuple[str, int],
+ starts: list[tuple[int, Block]],
+ length: int,
+ *,
+ fallback: int = 0,
+ ) -> int:
+ for prefix in ("user:", "detail:", "tools:history:"):
+ if anchor[0].startswith(prefix):
+ identity = anchor[0][len(prefix):]
+ anchor = (prefix + self.aliases.get(identity, identity),
+ anchor[1])
+ break
+ visible_ids = {block.id for _, block in starts}
+ visited = set()
+ while anchor[0] not in visible_ids and anchor[0] in self.group_parents:
+ if anchor[0] in visited:
+ break
+ visited.add(anchor[0])
+ anchor = (self.group_parents[anchor[0]], 0)
+ if anchor[0] not in visible_ids:
+ original = next((b for b in self.blocks if b.id == anchor[0]), None)
+ if original and "detail:" + original.turn in visible_ids:
+ anchor = ("detail:" + original.turn, 0)
+ for index, (start, block) in enumerate(starts):
+ if block.id == anchor[0]:
+ end = (
+ starts[index + 1][0] if index + 1 < len(starts) else length
+ )
+ return min(start + anchor[1], max(start, end - 1))
+ return min(length, max(0, fallback))
+
+ def restore_attachments(self, msg_id):
+ pending = self.pending_attachments.pop(msg_id, None)
+ if pending:
+ self.attachments.extend(pending[1])
+ self.version += 1
+
+ def history(self, event: dict) -> None:
+ self.loading = False
+ if isinstance(event.get("control"), dict):
+ self.control(event["control"])
+ if not event.get("authoritative", True) or event.get("error"):
+ return
+ if (
+ self.pending_revision
+ and event.get("revision") != self.pending_revision
+ ):
+ return
+ generation = event.get("generation")
+ build_seq = event.get("build_seq", 0)
+ if generation == self.generation and build_seq < self.build_seq:
+ return
+ if event.get("before") and self.revision != event.get("revision"):
+ return
+ same_revision = self.revision == event.get(
+ "revision"
+ ) and not event.get("reset")
+ continuous = (
+ generation is not None and generation == self.generation
+ and event.get("continuity_revision") is not None
+ and event["continuity_revision"]
+ == (self.continuity_revision or self.revision)
+ )
+ discontinuous = bool(self.revision) and not event.get("before") and (
+ generation != self.generation or not (same_revision or continuous)
+ )
+ if not same_revision:
+ self.details.clear()
+ self.details_newer.clear()
+ self.detail_blocks.clear()
+ if event.get("reset") or discontinuous:
+ live_seq = event.get("live_seq")
+ fresh = [b for b in self.blocks if (
+ not event.get("reset") and generation == self.generation
+ and live_seq is not None and b.seq > live_seq
+ )]
+ fresh_turns = {
+ b.turn: self.presentation.turns[b.turn]
+ for b in fresh if b.turn in self.presentation.turns
+ }
+ fresh_active = (
+ self.presentation.active
+ if self.presentation.active in fresh_turns else ""
+ )
+ fresh_plan = self.presentation.plan
+ if not (
+ fresh_plan and fresh_plan.get("turn_id") in fresh_turns
+ and live_seq is not None
+ and (fresh_plan.get("seq") or 0) > live_seq
+ ):
+ fresh_plan = None
+ fresh_aliases = {
+ alias: tid for alias, tid in self.aliases.items()
+ if tid in fresh_turns
+ }
+ self.blocks.clear()
+ self.details.clear()
+ self.details_newer.clear()
+ self.collapsed_details.clear()
+ self.local_details.clear()
+ self.tool_groups.clear()
+ self.group_parents.clear()
+ self.aliases.clear()
+ self.oldest = None
+ self.active_turn = ""
+ self.presentation.turns.clear()
+ self.presentation.active = ""
+ self.presentation.plan = None
+ self.presentation.retired_plans.clear()
+ self.blocks.extend(fresh)
+ self.presentation.turns.update(fresh_turns)
+ self.active_turn = self.presentation.active = fresh_active
+ self.presentation.plan = fresh_plan
+ self.aliases.update(fresh_aliases)
+ self.artifact_epoch += 1
+ if not event.get("before"):
+ self.tail_hidden = False
+ self.pending_revision = None
+ self.generation = generation
+ self.build_seq = build_seq
+ self.revision = event.get("revision", "")
+ if not event.get("before"):
+ self.continuity_revision = (
+ event.get("continuity_revision") or self.revision
+ )
+ if (event.get("before") or not self.oldest
+ or event.get("reset") or discontinuous):
+ self.oldest = event.get("oldest_id")
+ self.has_more = event.get("has_more", False)
+ incoming: list[Block] = []
+ history_plans: list[dict] = []
+ covered: set[str] = set()
+ live_seq = event.get("live_seq")
+ for turn in event.get("turns", []):
+ tid = turn["id"]
+ for msg_id in (tid, turn.get("clientMsgId")):
+ self.pending_attachments.pop(msg_id, None)
+ covered.add(tid)
+ for alias in (turn.get("clientMsgId"), turn.get("forkPointId")):
+ if alias:
+ self.aliases[alias] = tid
+ self.presentation.bind(alias, tid)
+ covered.add(alias)
+ for attr in ("anchor", "selection", "viewport"):
+ anchor = getattr(self, attr)
+ if anchor[0] == "user:" + alias:
+ setattr(self, attr, ("user:" + tid, anchor[1]))
+ current = self.presentation.turns.get(tid)
+ live_seq = event.get("live_seq")
+ if current is None or (
+ live_seq is not None and current.seq <= live_seq
+ ):
+ native = turn.get("forkPointId") or tid
+ continuing = native in event.get(
+ "compaction_continuation_turn_ids", []
+ )
+ done = turn.get("done") and not continuing
+ current = TurnDisplay(
+ fork_id=turn.get("forkPointId"),
+ checkpoint_id=turn.get("checkpointId"),
+ started=(turn.get("ts") or 0) / 1000 or None,
+ ended=((turn.get("doneTs") or 0) / 1000 or None)
+ if done
+ else None,
+ duration_ms=turn.get("durationMs") if done else None,
+ status=(
+ "interrupted"
+ if turn.get("interrupted")
+ else "failed"
+ if turn.get("error")
+ else "completed"
+ )
+ if done
+ else "running",
+ )
+ self.presentation.turns[tid] = current
+ if (
+ turn.get("prompt")
+ or turn.get("images")
+ or turn.get("imageRefs")
+ or turn.get("files")
+ ):
+ prompt = self.with_attachments(turn.get("prompt", ""), turn)
+ incoming.append(Block("user:" + tid, "user", prompt, tid))
+ for msg_id in list(self.pending_messages):
+ if self.aliases.get(msg_id, msg_id) == tid:
+ self.pending_messages.pop(msg_id)
+ completed_at = (self.presentation.goal or {}).get("updatedAt")
+ if (
+ self.presentation.goal
+ and self.presentation.goal.get("status") == "complete"
+ and completed_at is not None
+ and turn.get("ts", 0) > completed_at * 1000
+ ):
+ self.presentation.retired_goal = self.presentation.goal_id
+ for index, block in enumerate(turn.get("blocks", [])):
+ if block.get("kind") == "text":
+ text = block.get("text", "")
+ data = message_metadata(block)
+ incoming.append(
+ Block(
+ block.get("message_id") or f"{tid}:{index}",
+ "assistant",
+ text,
+ tid,
+ ("commentary" if data.get("delivery") == "async"
+ else block.get("channel", "unknown")),
+ data=data,
+ )
+ )
+ elif block.get("kind") in {"process", "tool"}:
+ data = bounded(block)
+ data.update(data.get("result") or {})
+ identity = (
+ block.get("item_id")
+ or block.get("tool_use_id")
+ or f"{tid}:{index}"
+ )
+ content = str(
+ block.get("title")
+ or block.get("tool")
+ or block.get("processKind")
+ )
+ content += "\n" + describe(data)
+ incoming.append(
+ Block(identity, block["kind"], content, tid, data=data)
+ )
+ if (
+ block.get("plan")
+ and tid not in self.presentation.retired_plans
+ ):
+ history_plans.append(
+ {
+ **data,
+ "turn_id": tid,
+ "done": current.status != "running",
+ "status": current.status,
+ }
+ )
+ if turn.get("detailEventCount") or turn.get("detailReasons"):
+ incoming.append(
+ Block(
+ "detail:" + tid,
+ "detail",
+ "Load this turn's details",
+ tid,
+ )
+ )
+ if turn.get("error"):
+ incoming.append(
+ Block("error:" + tid, "error", turn["error"], tid)
+ )
+ if not event.get("turns") and event.get("detail") == "full":
+ # Old-compatible event pages remain useful to embedded relays.
+ for item in event.get("events", []):
+ self.event(item)
+ return
+ old = self.blocks
+ if history_plans:
+ current_plan = self.presentation.plan
+ candidate = history_plans[-1]
+ can_replace = not current_plan or (
+ not event.get("before")
+ and (
+ (
+ live_seq is not None
+ and (current_plan.get("seq") or 0) <= live_seq
+ )
+ or (
+ not current_plan.get("seq")
+ and current_plan.get("turn_id") in covered
+ )
+ )
+ )
+ if can_replace:
+ self.presentation.plan = candidate
+ for block in old:
+ if block.id.startswith("user:"):
+ identity = block.id[5:]
+ block.id = "user:" + self.aliases.get(identity, identity)
+ block.turn = self.aliases.get(block.turn, block.turn)
+ for alias, tid in self.aliases.items():
+ if alias == tid:
+ continue
+ if alias in self.local_details:
+ outer = self.local_details.pop(alias)
+ outer.id, outer.turn = "detail:" + tid, tid
+ self.local_details.setdefault(tid, outer)
+ if alias in self.collapsed_details:
+ self.collapsed_details.discard(alias)
+ self.collapsed_details.add(tid)
+ if alias in self.detail_blocks:
+ self.detail_blocks.setdefault(
+ tid, [replace(b, turn=tid)
+ for b in self.detail_blocks.pop(alias)],
+ )
+ if alias in self.details:
+ self.details.setdefault(tid, self.details.pop(alias))
+ if alias in self.details_newer:
+ self.details_newer.setdefault(tid, self.details_newer.pop(alias))
+ # A history read begun before newer live deltas must not roll them back.
+ fresh = {
+ b.id: b
+ for b in old
+ if b.seq and (live_seq is None or b.seq > live_seq)
+ }
+ retained_details = {
+ b.id: b
+ for b in old
+ if same_revision and b.role == "detail" and b.turn in self.details
+ }
+ incoming = [retained_details.get(b.id, b) for b in incoming]
+ incoming = [fresh.get(b.id, b) for b in incoming]
+ ids = {b.id for b in incoming}
+ remaining = [
+ b
+ for b in old
+ if b.id not in ids and (b.turn not in covered or b.id in fresh)
+ ]
+ self.blocks = (
+ incoming + remaining
+ if event.get("before")
+ else [b for b in remaining if b.id not in fresh]
+ + incoming
+ + [b for b in remaining if b.id in fresh]
+ )
+ for fence in event.get("terminal_fences", []):
+ tid = self.aliases.get(fence["turn_id"], fence["turn_id"])
+ if fence["turn_id"] in event.get(
+ "compaction_continuation_turn_ids", []
+ ):
+ continue
+ turn = self.presentation.turns.get(tid)
+ if turn:
+ turn.status = fence["status"]
+ turn.duration_ms = fence.get("duration_ms", turn.duration_ms)
+ turn.ended = fence.get("completed_at", turn.ended)
+ if (
+ self.presentation.plan
+ and self.presentation.plan.get("turn_id") == tid
+ ):
+ self.presentation.plan.update(done=True, status=turn.status)
+ plan = self.presentation.plan
+ if event.get("turns") and not event.get("before"):
+ active = self.presentation.turns.get(self.presentation.active)
+ if active is None or (
+ live_seq is not None and active.seq <= live_seq
+ ):
+ newest = event["turns"][-1]["id"]
+ self.presentation.active = newest
+ if not self.active_turn:
+ self.active_turn = newest
+ order = [b.turn for b in self.blocks if b.role == "user"]
+ if (
+ plan
+ and plan.get("turn_id") in order
+ and (plan.get("done") or self.presentation.plan_terminal())
+ ):
+ if order.index(plan["turn_id"]) < len(order) - 1:
+ self.presentation.retired_plans.add(plan["turn_id"])
+ self.presentation.plan = None
+ # Pagination retains the page just requested, not only the live tail.
+ self.trim(older_page=bool(event.get("before")))
+ for block in self.blocks:
+ block.text = clip(block.text)
+ self.version += 1
+
+ @staticmethod
+ def with_attachments(prompt: str, event: dict) -> str:
+ attachments = [
+ str(f.get("filename", "file")) for f in event.get("files") or []
+ ]
+ for index, _ in enumerate(
+ event.get("images") or event.get("imageRefs") or []
+ ):
+ attachments.append(f"Image {index + 1} [open in Web]")
+ return prompt + (
+ "\nAttachments: " + ", ".join(attachments) if attachments else ""
+ )
+
+ def event(self, event: dict) -> None:
+ kind = event.get("type")
+ if kind in {"user_msg", "turn_steered"}:
+ for key in ("msg_id", "client_msg_id"):
+ self.pending_attachments.pop(event.get(key), None)
+ self.pending_queued_text.pop(event.get(key), None)
+ elif kind == "query_queue":
+ for item in event.get("items", []):
+ self.pending_attachments.pop(item.get("msg_id"), None)
+ self.pending_queued_text.pop(item.get("msg_id"), None)
+ elif kind == "error":
+ for msg_id, receipt in list(self.pending_queued_text.items()):
+ if (msg_id == event.get("msg_id")
+ or receipt["cmd_id"] == event.get("request_id")):
+ receipt["rejected"] = True
+ self.pending_queued_text.pop(msg_id, None)
+ self.recovered_queued_text.append(receipt["prompt"])
+ self.version += 1
+ for msg_id, (cmd_id, _) in list(self.pending_attachments.items()):
+ if (msg_id == event.get("msg_id")
+ or cmd_id == event.get("request_id")):
+ self.restore_attachments(msg_id)
+ if kind in {"user_msg", "turn_steered"}:
+ for key in ("msg_id", "client_msg_id"):
+ self.pending_messages.pop(event.get(key), None)
+ elif kind == "error" and event.get("msg_id") in self.pending_messages:
+ pending = self.pending_messages.pop(event["msg_id"])
+ pending.data["status"] = "failed"
+ self.put(pending)
+ if (
+ kind == "replay_start"
+ and (event.get("sid") or "").startswith("btw-")
+ and event.get("rebuild")
+ ):
+ self.blocks.clear()
+ self.details.clear()
+ self.details_newer.clear()
+ self.detail_blocks.clear()
+ self.local_details.clear()
+ self.tool_groups.clear()
+ self.group_parents.clear()
+ self.collapsed_details.clear()
+ self.aliases.clear()
+ self.active_turn = ""
+ self.presentation.turns.clear()
+ self.presentation.active = ""
+ self.presentation.plan = None
+ self.presentation.retired_plans.clear()
+ self.version += 1
+ if kind in {"snapshot", "replay_start"} and event.get("generation"):
+ generation = event["generation"]
+ if (
+ self.presentation_generation
+ and generation != self.presentation_generation
+ ):
+ self.presentation.completion.clear()
+ self.presentation.questions.clear()
+ self.presentation.reports.clear()
+ self.presentation.rates.clear()
+ self.presentation.live_rate_revision = 0
+ self.write_state = "unknown"
+ self.presentation_generation = generation
+ seq = event.get("seq") or 0
+ tid = event.get("turn_id") or self.active_turn
+ tid = self.aliases.get(tid, tid)
+ if kind == "turn_binding":
+ old, new = event["msg_id"], event["turn_id"]
+ self.aliases[old] = new
+ self.presentation.bind(old, new)
+ for block in self.blocks:
+ if block.turn == old:
+ block.turn = new
+ if self.active_turn == old:
+ self.active_turn = new
+ elif kind == "user_msg":
+ tid = event.get("client_msg_id") or event.get("msg_id", "")
+ tid = self.aliases.get(tid, tid)
+ elif kind == "turn_end" and tid not in self.presentation.turns:
+ checkpoint = event.get("checkpoint_id")
+ if checkpoint:
+ tid = self.aliases.get(checkpoint, checkpoint)
+ elif (
+ self.active_turn in self.presentation.turns
+ and self.active_turn not in self.aliases.values()
+ ):
+ # Claude's terminal assistant UUID differs from its user UUID.
+ # Never apply this fallback to an already-bound Codex owner.
+ tid = self.active_turn
+ self.presentation.event(event, tid)
+ if kind == "turn_end" and tid and tid == self.active_turn:
+ turn = self.presentation.turns.get(tid)
+ subtype = (event.get("result") or {}).get("subtype")
+ if turn and subtype not in {"steered", "compacted"}:
+ self.tab_terminal = (tid, turn.status)
+ elif kind == "completion_state" and not self.presentation.completion.get(
+ "unread"
+ ):
+ self.mark_tab_read(
+ self.presentation.completion.get("completion_id") or ""
+ )
+ if kind in {"user_msg", "turn_steered"}:
+ identity = event.get("client_msg_id") or event.get("msg_id", "")
+ identity = self.aliases.get(identity, identity)
+ if kind == "user_msg":
+ if identity != self.active_turn:
+ self.read_tab()
+ self.tab_terminal = None
+ self.active_turn = identity
+ self.put(
+ Block(
+ "user:" + identity,
+ "user",
+ self.with_attachments(event.get("prompt", ""), event),
+ tid if kind == "turn_steered" else identity,
+ seq=seq,
+ )
+ )
+ elif kind in {"assistant_msg_start", "delta", "assistant_msg_end"}:
+ identity = event.get("message_id", "")
+ data = message_metadata(event)
+ self.put(
+ Block(
+ identity,
+ "assistant",
+ event.get("text", ""),
+ tid,
+ ("commentary" if data.get("delivery") == "async"
+ else event.get("channel", "unknown")),
+ seq,
+ data=data,
+ ),
+ append=True,
+ )
+ elif kind == "tool_use":
+ inputs = bounded(event.get("input") or {})
+ if len(json.dumps(inputs, ensure_ascii=False)) > MAX_BLOCK_CHARS:
+ inputs = {}
+ content = (
+ str(event.get("title") or event.get("tool", "tool"))
+ + "\n"
+ + json.dumps(
+ event.get("input", {}), ensure_ascii=False, indent=2
+ )
+ )
+ self.put(
+ Block(
+ event.get("tool_use_id", ""),
+ "tool",
+ content,
+ tid,
+ seq=seq,
+ data={
+ "status": "running",
+ "category": event.get("category"),
+ "tool": event.get("tool"),
+ "input": inputs,
+ },
+ )
+ )
+ elif kind in {"tool_result", "tool_delta"}:
+ content = event.get("content", event.get("delta", ""))
+ self.put(
+ Block(
+ event.get("tool_use_id", ""),
+ "tool",
+ "\n" + str(content),
+ tid,
+ seq=seq,
+ data=(
+ {
+ "status": event.get("status")
+ or (
+ "failed"
+ if event.get("is_error")
+ else "succeeded"
+ ),
+ "duration_ms": event.get("duration_ms"),
+ "exit_code": event.get("exit_code"),
+ }
+ if kind == "tool_result"
+ else {}
+ ),
+ ),
+ append=True,
+ )
+ if kind == "tool_result":
+ self.put(
+ Block(
+ event.get("tool_use_id", ""),
+ "tool",
+ "\n"
+ + str(event.get("diff") or event.get("summary") or ""),
+ tid,
+ seq=seq,
+ ),
+ append=True,
+ )
+ elif kind in {"process", "turn_plan", "turn_diff"}:
+ identity = event["item_id"]
+ previous = next((b for b in self.blocks if b.id == identity), None)
+ # Native serialized events contain nulls for absent fields. An end
+ # event without output must not erase already-streamed output.
+ updates = bounded({k: v for k, v in event.items() if v is not None})
+ if previous and updates.get("status") == "unknown":
+ updates.pop("status")
+ data = {**(previous.data if previous else {}), **updates}
+ if event.get("append_to") and event.get("delta"):
+ target = event["append_to"]
+ data[target] = clip(
+ str((previous.data if previous else {}).get(target) or "")
+ + event["delta"]
+ )
+ title = str(
+ data.get("title")
+ or ("Plan" if kind == "turn_plan" else "Changes")
+ )
+ content = (
+ title
+ + "\n"
+ + describe(
+ {
+ k: v
+ for k, v in data.items()
+ if k
+ not in {"v", "seq", "sid", "type", "delta", "append_to"}
+ }
+ )
+ )
+ self.put(
+ Block(identity, "process", content, tid, seq=seq, data=data)
+ )
+ elif kind in {"state", "snapshot", "session_activity"}:
+ self.state = event.get("state", self.state)
+ if isinstance(event.get("control"), dict):
+ self.control(event["control"])
+ elif kind == "session_control":
+ self.control(event)
+ elif kind == "query_queue":
+ self.queue = event.get("items", [])
+ elif kind == "error":
+ self.put(
+ Block(
+ "error:" + str(seq),
+ "error",
+ event.get("message", "Unknown error"),
+ tid,
+ seq=seq,
+ )
+ )
+ elif kind == "turn_end":
+ self.version += 1
+
+
+class WorkspaceState:
+ def __init__(self) -> None:
+ self.views: dict[str, SessionView] = {}
+ self.catalog: dict[str, dict] = {}
+ self.rekeys: dict[str, str] = {}
+ self.reports: dict[str, dict] = {}
+ self.connection = "connecting"
+ self.btw_revision = -1
+ self.btw_generation = None
+ self.generation = None
+
+ def view(self, sid: str) -> SessionView:
+ sid = self.rekeys.get(sid, sid)
+ if sid not in self.views:
+ self.views[sid] = SessionView()
+ return self.views[sid]
+
+ def _reconcile_side_chats(self) -> None:
+ # Hello sends BTW before parent catalogs. Refresh derived metadata
+ # after either arrives, and after parent renames or cwd migrations.
+ for sid, row in self.catalog.items():
+ if not sid.startswith("btw-"):
+ continue
+ parent = self.catalog.get(row.get("parent_sid"))
+ if parent is not None:
+ row.update(
+ space=parent.get("space", "code"),
+ cwd=parent.get("cwd"),
+ summary="BTW · " + (
+ parent.get("summary") or row["parent_sid"]
+ ),
+ )
+
+ def event(self, event: dict) -> None:
+ kind = event.get("type")
+ generation = event.get("generation")
+ if kind == "queued_query_detail":
+ return # Full queued prompts belong only to the one-shot editor.
+ if generation and kind in {"snapshot", "replay_start", "btw_sync"}:
+ if self.generation and generation != self.generation:
+ # A wrapper epoch also resets cold sessions' receipt domains;
+ # those sessions may receive only a catalog, never a Snapshot.
+ for resident in self.views.values():
+ resident.presentation.completion.clear()
+ resident.presentation.questions.clear()
+ resident.presentation.rates.clear()
+ resident.presentation.live_rate_revision = 0
+ resident.presentation.control.clear()
+ resident.state = resident.write_state = "unknown"
+ self.generation = generation
+ if kind in {"btw_sync", "btw_opened", "btw_closed"}:
+ old_sides = {sid for sid in self.catalog if sid.startswith("btw-")}
+ generation = event.get("generation", self.btw_generation)
+ if generation != self.btw_generation:
+ self.btw_revision = -1
+ self.btw_generation = generation
+ if event.get("revision", 0) < self.btw_revision:
+ return
+ self.btw_revision = event.get("revision", 0)
+ if kind == "btw_sync":
+ self.catalog = {
+ k: v
+ for k, v in self.catalog.items()
+ if not k.startswith("btw-")
+ }
+ sessions = event.get("sessions", [])
+ elif kind == "btw_opened":
+ sessions = [event]
+ else:
+ self.catalog.pop(event["btw_sid"], None)
+ sessions = []
+ for row in sessions:
+ parent = self.catalog.get(row["parent_sid"], {})
+ self.catalog[row["btw_sid"]] = {
+ "session_id": row["btw_sid"],
+ "summary": "BTW · "
+ + (parent.get("summary") or row["parent_sid"]),
+ "parent_sid": row["parent_sid"],
+ "engine": row["engine"],
+ "cwd": parent.get("cwd"),
+ "state": row.get("state", "idle"),
+ "space": parent.get("space", "code"),
+ }
+ for sid in old_sides - self.catalog.keys():
+ self.view(sid).write_state = "unavailable"
+ self._reconcile_side_chats()
+ if kind in {"wrapper_disconnected", "wrapper_reconnected"}:
+ self.connection = (
+ "wrapper offline"
+ if kind == "wrapper_disconnected"
+ else "connected"
+ )
+ if kind == "session_migrated":
+ sid = event["session_id"]
+ if sid in self.catalog:
+ self.catalog[sid]["cwd"] = event["cwd"]
+ self._reconcile_side_chats()
+ self.view(sid).presentation.reports.clear()
+ if kind in REPORT_TYPES:
+ sid = event.get("sid") or event.get("session_id")
+ reports = (
+ self.view(sid).presentation.reports if sid else self.reports
+ )
+ reports[str(kind)] = bounded(event)
+ return
+ if kind == "session_list":
+ engine = event.get("engine", "claude")
+ previous = set(self.catalog)
+ profile_key = engine + "_profile_id"
+ profiles_key = engine + "_profiles"
+ metadata = dict(self.reports.get(profiles_key, {}))
+ for key in (profiles_key, "default_" + profile_key):
+ if event.get(key):
+ metadata[key] = event[key]
+ self.reports[profiles_key] = bounded(metadata)
+ unavailable = {
+ profile["id"] for profile in metadata.get(profiles_key, [])
+ if profile.get("error")
+ }
+ self.catalog = {
+ sid: row
+ for sid, row in self.catalog.items()
+ if sid.startswith("btw-")
+ or (row.get("engine", "claude"), row.get("space", "code"))
+ != (engine, event.get("space", "code"))
+ or (not row.get("provisional_fork")
+ and row.get(profile_key) in unavailable)
+ }
+ for row in event.get("sessions", []):
+ row = {
+ **row,
+ "engine": row.get("engine") or engine,
+ "space": row.get("space") or event.get("space", "code"),
+ }
+ self.catalog[row["session_id"]] = row
+ view = self.view(row["session_id"])
+ view.presentation.engine = row.get("engine", engine)
+ if row.get("completion_revision") is not None:
+ view.presentation.event(
+ {
+ "type": "completion_state",
+ "completion_id": row.get("completion_id"),
+ "unread": row.get("completion_unread", False),
+ "revision": row["completion_revision"],
+ },
+ "",
+ )
+ if view.state == "unknown" and row.get("state"):
+ view.state = row["state"]
+ for sid in previous - self.catalog.keys():
+ self.view(sid).write_state = "unavailable"
+ self._reconcile_side_chats()
+ return
+ if kind == "session_rekey":
+ old, new = event.get("old_key"), event.get("session_id")
+ if old in self.views and new:
+ self.views[new] = self.views.pop(old)
+ self.rekeys[old] = new
+ if old in self.catalog:
+ self.catalog[new] = {**self.catalog.pop(old), "session_id": new}
+ return
+ sid = (
+ event.get("session_id")
+ if kind
+ in {
+ "history",
+ "turn_detail",
+ "session_activity",
+ "history_invalidated",
+ }
+ else event.get("sid")
+ )
+ if not sid:
+ return
+ view = self.view(sid)
+ if kind == "history_invalidated":
+ # A rollback barrier must invalidate the visible projection before
+ # a late pre-rollback page can arrive.
+ view.blocks.clear()
+ view.details.clear()
+ view.details_newer.clear()
+ view.detail_blocks.clear()
+ view.local_details.clear()
+ view.tool_groups.clear()
+ view.group_parents.clear()
+ view.collapsed_details.clear()
+ view.aliases.clear()
+ view.presentation.turns.clear()
+ view.presentation.active = ""
+ view.active_turn = ""
+ view.presentation.plan = None
+ view.presentation.retired_plans.clear()
+ view.presentation.reports.clear()
+ view.revision = event["revision"]
+ view.pending_revision = event["revision"]
+ view.version += 1
+ elif kind == "artifact_invalidated":
+ view.presentation.reports.clear()
+ view.artifact_epoch += 1
+ view.version += 1
+ elif kind == "history":
+ view.history(event)
+ elif kind == "turn_detail":
+ if (
+ event.get("revision") != view.revision
+ or not event.get("authoritative", True)
+ or event.get("error")
+ ):
+ return
+ if event.get("reset_required"):
+ view.details.pop(event["turn_id"], None)
+ view.details_newer.pop(event["turn_id"], None)
+ view.detail_blocks.pop(event["turn_id"], None)
+ view.version += 1
+ return
+ turn = event["turn_id"]
+ detail_view = SessionView(active_turn=turn)
+ for item in event.get("events", []):
+ detail_view.event(item)
+ for block in detail_view.blocks:
+ block.expanded = True
+ text, starts = detail_view.render(fold=False)
+ cursor = (
+ event.get("oldest_cursor") if event.get("has_more") else None
+ )
+ view.details[turn] = cursor
+ view.details_newer[turn] = (
+ event.get("newer_cursor") if event.get("has_newer") else None
+ )
+ # Bound typed pages as well as the legacy flattened representation.
+ # Nested folds must not introduce an unbounded raw event cache.
+ children, remaining = [], MAX_BLOCK_CHARS
+ for block in detail_view.blocks:
+ metadata = dict(block.data)
+ cost = len(json.dumps(metadata, ensure_ascii=False))
+ if cost > remaining // 2:
+ metadata = {}
+ cost = 2
+ room = max(0, remaining - cost)
+ if len(block.text) > room:
+ body = block.text[:max(0, room - len(TRUNCATED))]
+ children.append(replace(block, text=body + TRUNCATED,
+ data=metadata))
+ break
+ children.append(replace(block, data=metadata))
+ remaining -= cost + len(block.text)
+ if remaining < len(TRUNCATED):
+ break
+ view.detail_blocks[turn] = children
+ view.put(Block("detail:" + turn, "detail", text, turn,
+ data={"sections": detail_sections(clip(text), starts)}))
+ for block in view.blocks:
+ if block.id == "detail:" + turn:
+ block.expanded = turn not in view.collapsed_details
+ else:
+ view.event(event)
diff --git a/cc_remote/tui_tab_store.py b/cc_remote/tui_tab_store.py
new file mode 100644
index 00000000..2a635e55
--- /dev/null
+++ b/cc_remote/tui_tab_store.py
@@ -0,0 +1,89 @@
+"""Private, endpoint-scoped tab identities; no transcript or credentials."""
+
+import fcntl
+import hashlib
+import json
+import os
+from pathlib import Path
+import tempfile
+
+
+def clean(value):
+ if not isinstance(value, dict):
+ return {"tabs": [], "active": None, "scope": ["codex", "code"]}
+ tabs = value.get("tabs", [])
+ tabs = list(dict.fromkeys(
+ sid for sid in tabs[:1024]
+ if isinstance(sid, str) and 0 < len(sid) <= 256
+ and not any(ord(char) < 32 for char in sid)
+ and not sid.startswith(("tmp-", "btw-"))
+ )) if isinstance(tabs, list) else []
+ scope = value.get("scope")
+ if (not isinstance(scope, list) or len(scope) != 2
+ or scope[0] not in ("codex", "claude")
+ or scope[1] not in ("code", "work")):
+ scope = ["codex", "code"]
+ return {"tabs": tabs, "active": value.get("active")
+ if value.get("active") in tabs else None, "scope": scope}
+
+
+class TabStore:
+ def __init__(self, url, machine, username, *, directory=None):
+ base = Path(directory) if directory else Path(
+ os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state"
+ ) / "cc-remote" / "tui-tabs"
+ identity = json.dumps([url, machine, username]).encode()
+ self.path = base / (hashlib.sha256(identity).hexdigest() + ".json")
+ self.previous = clean(None)
+
+ def _read(self):
+ try:
+ # Bound corrupted/untrusted local state before decoding it.
+ with self.path.open("rb") as stream:
+ raw = stream.read(512 * 1024 + 1)
+ if len(raw) > 512 * 1024:
+ raise ValueError("TUI tab state exceeds size limit")
+ return clean(json.loads(raw))
+ except FileNotFoundError:
+ return None
+
+ def load(self):
+ value = self._read()
+ self.previous = value or clean(None)
+ return value
+
+ def save(self, tabs, active, scope):
+ current = clean({"tabs": tabs, "active": active, "scope": list(scope)})
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ lock_path = self.path.with_suffix(".lock")
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ with os.fdopen(fd, "a") as lock:
+ fcntl.flock(lock, fcntl.LOCK_EX)
+ latest = self._read() or clean(None)
+ removed = set(self.previous["tabs"]) - set(current["tabs"])
+ added = [sid for sid in current["tabs"]
+ if sid not in self.previous["tabs"]]
+ # Merge local edits, not a stale whole-window snapshot. A second
+ # terminal must not resurrect tabs another terminal just closed.
+ merged = list(dict.fromkeys(
+ [sid for sid in latest["tabs"] if sid not in removed] + added
+ ))
+ result = clean({**current, "tabs": merged})
+ if result["active"] is None and latest["active"] in merged:
+ result["active"] = latest["active"]
+ result["scope"] = latest["scope"]
+ temporary = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w", dir=self.path.parent, delete=False,
+ prefix=".tabs-", encoding="utf-8",
+ ) as stream:
+ temporary = stream.name
+ json.dump(result, stream, ensure_ascii=False)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, self.path)
+ finally:
+ if temporary and os.path.exists(temporary):
+ os.unlink(temporary)
+ self.previous = current
diff --git a/cc_remote/tui_text_objects.py b/cc_remote/tui_text_objects.py
new file mode 100644
index 00000000..d19d4802
--- /dev/null
+++ b/cc_remote/tui_text_objects.py
@@ -0,0 +1,102 @@
+"""Shared local Vim text-object ranges for readers and editors.
+
+Ranges are half-open character offsets. This is not a language parser: bracket
+objects match nested delimiters, while quote objects stay on the current line.
+"""
+
+from __future__ import annotations
+
+import re
+
+
+def text_object(
+ text: str, cursor: int, kind: str, around: bool, count: int = 1
+) -> tuple[int, int] | None:
+ if not text:
+ return None
+ cursor = max(0, min(cursor, len(text) - 1))
+ count = max(1, min(count, 9999))
+ if kind in {"w", "W"}:
+ pattern = r"\s+|\S+" if kind == "W" else r"\s+|\w+|[^\w\s]+"
+ spans = list(re.finditer(pattern, text))
+ for index, match in enumerate(spans):
+ start, end = match.span()
+ if not start <= cursor < end:
+ continue
+ # Subsequent objects include the separator and next word, but
+ # not trailing whitespace unless the operator requested "around".
+ last = index
+ for _ in range(count - 1):
+ last += 1
+ if last < len(spans) and spans[last].group().isspace():
+ last += 1
+ if last >= len(spans):
+ last = len(spans) - 1
+ break
+ end = spans[last].end()
+ if around:
+ if match.group().isspace():
+ if index + 1 < len(spans):
+ end = spans[index + 1].end()
+ elif (
+ last + 1 < len(spans) and spans[last + 1].group().isspace()
+ ):
+ end = spans[last + 1].end()
+ elif index and spans[index - 1].group().isspace():
+ start = spans[index - 1].start()
+ return start, end
+ return None
+ if kind == "p":
+ paragraphs = list(re.finditer(r"[^\n]+(?:\n(?!\s*\n)[^\n]+)*", text))
+ paragraphs = [p for p in paragraphs if p[0].strip()]
+ index = next(
+ (i for i, p in enumerate(paragraphs) if p.end() > cursor), None
+ )
+ if index is None:
+ return None
+ start = paragraphs[index].start()
+ end = paragraphs[min(index + count - 1, len(paragraphs) - 1)].end()
+ if around:
+ while end < len(text) and text[end].isspace():
+ end += 1
+ return start, end
+ if kind in {'"', "'", "`"}:
+ start = text.rfind("\n", 0, cursor) + 1
+ end = text.find("\n", cursor)
+ end = len(text) if end < 0 else end
+ marks = []
+ escaped = False
+ for position in range(start, end):
+ char = text[position]
+ if char == kind and not escaped:
+ marks.append(position)
+ escaped = char == "\\" and not escaped
+ pairs = list(zip(marks[::2], marks[1::2]))
+ # Vim quote objects can select the next quoted string on this line.
+ pair = next((p for p in pairs if p[0] <= cursor <= p[1]), None)
+ if pair is None:
+ pair = next((p for p in pairs if p[0] > cursor), None)
+ else:
+ delimiters = next(
+ (p for p in ("()", "[]", "{}", "<>") if kind in p), None
+ )
+ if delimiters is None:
+ delimiters = {"b": "()", "B": "{}"}.get(kind)
+ if delimiters is None:
+ return None
+ opening, closing = delimiters
+ stack = []
+ pairs = []
+ for position, char in enumerate(text):
+ if char == opening:
+ stack.append(position)
+ elif char == closing and stack:
+ begin = stack.pop()
+ if begin <= cursor <= position:
+ pairs.append((begin, position))
+ pairs.sort(key=lambda p: p[1] - p[0])
+ pair = pairs[count - 1] if len(pairs) >= count else None
+ if pair is None:
+ return None
+ start, end = pair
+ return (start, end + 1) if around else (start + 1, end)
diff --git a/cc_remote/tui_tree.py b/cc_remote/tui_tree.py
new file mode 100644
index 00000000..7b2ac59a
--- /dev/null
+++ b/cc_remote/tui_tree.py
@@ -0,0 +1,442 @@
+"""A collapsible, catalog-backed session explorer (never scans the disk)."""
+
+from rich.text import Text
+from pathlib import PurePosixPath
+from textual import events
+from textual.app import ComposeResult
+from textual.containers import Vertical
+from textual.widgets import Input, Static, Tree
+
+from cc_remote.tui import _safe_remote_text
+from cc_remote.protocol import CloseBtw, DeleteSession, RenameSession
+from cc_remote.tui_modal import Overlay, PickerList, hints
+from cc_remote.tui_settings import TextValue
+from textual.widgets import Label, OptionList, TextArea
+
+
+def tree_nodes(root):
+ for child in root.children:
+ yield child
+ yield from tree_nodes(child)
+
+
+def expanded_folders(tree):
+ return {node.data for node in tree_nodes(tree.root)
+ if node.data and node.data[0] != "session" and node.is_expanded}
+
+
+class RenameDialog(TextValue):
+ async def action_submit(self):
+ title = self.query_one(TextArea).text.strip()
+ if not 1 <= len(title) <= 200:
+ self.query_one(Label).update("Name must contain 1–200 characters")
+ return
+ await super().action_submit()
+
+
+class DeleteDialog(Overlay):
+ """A captured session target; cancellation is the default choice."""
+
+ key_layer = "confirmation"
+ local_actions = {"cancel", "yes", "down", "up"}
+ DEFAULT_CSS = """
+ DeleteDialog .tui-panel { height: auto; max-height: 85%; }
+ DeleteDialog OptionList { height: 4; }
+ """
+
+ def __init__(self, sid, title, cwd, *, archive=False, side_chat=False):
+ super().__init__()
+ self.sid, self.title_text, self.cwd = sid, title, cwd
+ self.archive = archive
+ self.side_chat = side_chat
+
+ def compose(self):
+ with Vertical(classes="tui-panel"):
+ yield Label("Close side chat?" if self.side_chat else "Delete session?",
+ markup=False)
+ yield Label(_safe_remote_text(self.title_text), markup=False)
+ yield Label(_safe_remote_text(self.sid), markup=False)
+ yield Label(_safe_remote_text(self.cwd), markup=False)
+ yield Label(
+ "Discard this side chat; the parent session stays open."
+ if self.side_chat else
+ "Permanent deletion; not just closing a local tab.\n"
+ "Server deletion rules still apply.", markup=False,
+ )
+ if self.archive:
+ yield Label(
+ "Codex must first archive its conversation tree.\n"
+ "Delete is sent only after archive confirmation.",
+ markup=False,
+ )
+ yield PickerList("No — keep session", "Yes — close side chat"
+ if self.side_chat else "Yes — delete session")
+ yield hints()
+
+ def on_mount(self):
+ self.query_one(OptionList).highlighted = 0
+ self.query_one(OptionList).focus()
+
+ def action_yes(self):
+ self.dismiss(True)
+
+ def action_cancel(self):
+ self.dismiss(False)
+
+ def on_option_list_option_selected(self, event):
+ event.stop()
+ self.dismiss(event.option_index == 1)
+
+
+class SessionTree(Tree, inherit_bindings=False):
+ BINDINGS = []
+ count = ""
+ chord: tuple[str, ...] = ()
+
+ def on_blur(self):
+ self.count = ""
+ self.chord = ()
+
+ async def _on_key(self, event: events.Key) -> None:
+ key = event.key
+ if self.app.shortcut_prefix:
+ self.on_blur()
+ await self.app.normal_shortcut(key)
+ event.stop()
+ event.prevent_default()
+ return
+ bindings = {
+ tuple(keys.split()): name
+ for name, values in self.app.client.keys.layers["tree"].items()
+ for keys in values
+ }
+ if len(key) == 1 and key.isascii() and key.isdigit():
+ self.count = str(min(100000, int((self.count or "0") + key)))
+ event.stop()
+ event.prevent_default()
+ return
+ chord = (*self.chord, key)
+ pending = bool(self.chord)
+ self.chord = ()
+ action = bindings.get(chord)
+ if not action and any(k[:len(chord)] == chord for k in bindings):
+ self.chord = chord
+ elif action:
+ count, self.count = self.count, ""
+ amount = max(1, int(count or "1"))
+ if action in {"down", "up", "first", "last"}:
+ self.get_node_at_line(0)
+ line = (
+ self.cursor_line + amount * (1 if action == "down" else -1)
+ if action in {"down", "up"}
+ else int(count) - 1 if count
+ else 0 if action == "first" else self.last_line
+ )
+ self.move_cursor(self.get_node_at_line(
+ max(0, min(self.last_line, line))
+ ))
+ else:
+ await self.local_action(action)
+ else:
+ self.count = ""
+ if not pending and not await self.app.normal_shortcut(key):
+ await super()._on_key(event)
+ return
+ event.stop()
+ event.prevent_default()
+
+ async def local_action(self, action):
+ if action == "fold":
+ node = self.cursor_node
+ if node and node.is_expanded:
+ node.collapse()
+ elif node and node.parent and node.parent is not self.root:
+ self.move_cursor(node.parent)
+ elif action == "expand":
+ node = self.cursor_node
+ if node:
+ node.expand()
+ elif action in {"fold_all", "expand_all"}:
+ node = self.cursor_node
+ if action == "fold_all" and node:
+ while node.parent and node.parent is not self.root:
+ node = node.parent
+ self.move_cursor(node)
+ selected = self.cursor_node
+ for folder in self.root.children:
+ (folder.collapse_all if action == "fold_all"
+ else folder.expand_all)()
+ self.get_node_at_line(0)
+ self.move_cursor(selected)
+ elif action == "rename":
+ self.parent.rename_selected()
+ elif action in {"delete", "delete_direct"}:
+ await self.parent.delete_selected(confirm=action == "delete")
+ elif action == "search":
+ self.parent.start_search()
+ elif action == "close":
+ self.app.action_sessions()
+ elif action == "choose":
+ self.action_select_cursor()
+
+
+class TreeSearch(Input, inherit_bindings=False):
+ BINDINGS = [b for b in Input.BINDINGS if b.action != "submit"]
+
+ async def _on_key(self, event: events.Key) -> None:
+ action = self.app.client.keys.match("tree_search", event.key)
+ if action == "close":
+ self.value = ""
+ self.display = False
+ self.parent.query_one(SessionTree).focus()
+ elif action in {"up", "down"}:
+ self.parent.move_selection(action == "down")
+ elif action == "choose":
+ await self.parent.open_search_result()
+ else:
+ await super()._on_key(event)
+ return
+ event.stop()
+ event.prevent_default()
+
+
+class SessionExplorer(Vertical):
+ DEFAULT_CSS = """
+ SessionExplorer {
+ width: 34%; max-width: 48; min-width: 20; height: 1fr;
+ border-right: solid $primary;
+ }
+ SessionExplorer Static { height: auto; }
+ SessionExplorer Input { height: 3; }
+ SessionExplorer Tree { height: 1fr; }
+ """
+
+ def __init__(self):
+ super().__init__(id="session-explorer")
+ self.signature = None
+ self.scope = None
+ self.expanded: dict[tuple[str, str], set[tuple[str, str]]] = {}
+
+ def compose(self) -> ComposeResult:
+ yield Static("", id="tree-heading", markup=False)
+ yield TreeSearch(placeholder="Search title / folder / ID")
+ yield SessionTree("Sessions")
+ yield Static(
+ f"{self.app.client.keys.label('help')}: help",
+ markup=False,
+ )
+
+ def on_mount(self) -> None:
+ self.query_one(TreeSearch).display = False
+ self.query_one(SessionTree).show_root = False
+ self.query_one(SessionTree).auto_expand = False
+ self.display = False
+
+ def start_search(self) -> None:
+ search = self.query_one(TreeSearch)
+ search.display = True
+ search.focus()
+
+ def rename_selected(self):
+ node = self.query_one(SessionTree).cursor_node
+ client = self.app.client
+ if not node or not node.data or node.data[0] != "session":
+ client.notice = "Select a session to rename, not a folder"
+ return
+ sid = node.data[1]
+ row = client.visible_catalog().get(sid)
+ if row is None:
+ return
+ engine, space = client.scope
+ title = row.get("summary") or row.get("first_prompt") or ""
+
+ async def renamed(result):
+ if result is None:
+ return
+ if sid not in client.workspace.catalog:
+ client.notice = "Session no longer exists; rename cancelled"
+ return
+ await client.execute_action(RenameSession(
+ session_id=sid, title=result[0], engine=engine, space=space,
+ client_id=client.client_id,
+ ))
+
+ self.app.push_screen(RenameDialog("Rename session", title), renamed)
+
+ async def delete_selected(self, *, confirm):
+ node = self.query_one(SessionTree).cursor_node
+ client = self.app.client
+ if not node or not node.data or node.data[0] != "session":
+ client.notice = "Select a session to delete, not a folder"
+ return
+ sid = node.data[1]
+ row = client.visible_catalog().get(sid)
+ if row is None:
+ client.notice = "Session no longer exists; deletion cancelled"
+ return
+ engine, space = client.scope
+
+ async def deleted(confirmed):
+ if not confirmed:
+ return
+ current = client.workspace.catalog.get(sid)
+ if current is None or (
+ current.get("engine", engine), current.get("space", "code")
+ ) != (engine, space):
+ client.notice = "Session changed or disappeared; deletion cancelled"
+ return
+ command = (CloseBtw(sid=sid, client_id=client.client_id)
+ if sid.startswith("btw-") else DeleteSession(
+ session_id=sid, engine=engine, space=space,
+ client_id=client.client_id,
+ ))
+ await client.execute_action(command)
+ # Wait for an authoritative catalog or BTW closure response;
+ # the server may reject the command. Never remove optimistically.
+
+ if confirm:
+ title = row.get("summary") or row.get("first_prompt") or sid
+ self.app.push_screen(
+ DeleteDialog(sid, title, row.get("cwd") or "",
+ side_chat=sid.startswith("btw-"), archive=(
+ engine == "codex" and space == "code"
+ and not sid.startswith("btw-")
+ and row.get("tag") != "archived"
+ )), deleted
+ )
+ else:
+ await deleted(True)
+
+ def move_selection(self, down: bool) -> None:
+ tree = self.query_one(SessionTree)
+ (tree.action_cursor_down if down else tree.action_cursor_up)()
+
+ def refresh_catalog(self) -> None:
+ client = self.app.client
+ tree = self.query_one(SessionTree)
+ search = self.query_one(TreeSearch)
+ if self.scope != client.scope:
+ if self.scope and not search.value:
+ self.expanded[self.scope] = expanded_folders(tree)
+ self.scope = client.scope
+ search.value = ""
+ rows = client.visible_catalog()
+ signature = (
+ client.scope,
+ search.value,
+ tuple(
+ (
+ sid,
+ row.get("cwd"),
+ row.get("summary"),
+ row.get("first_prompt"),
+ row.get("tag"),
+ )
+ for sid, row in rows.items()
+ ),
+ )
+ if signature == self.signature:
+ return
+ previous = tree.cursor_node.data if tree.cursor_node else None
+ if (
+ self.signature
+ and self.signature[0] == client.scope
+ and not self.signature[1]
+ ):
+ self.expanded[client.scope] = expanded_folders(tree)
+ opened = self.expanded.get(client.scope)
+ self.signature = signature
+ tree.clear()
+ tree.root.expand()
+ groups = {}
+ archive_root = None
+ matches = []
+ terms = search.value.casefold().split()
+ # Stable partition: normal folders first, then one virtual archive.
+ for sid, row in sorted(rows.items(), key=lambda item:
+ item[1].get("tag") == "archived"):
+ cwd = row.get("cwd") or "(no directory)"
+ title = row.get("summary") or row.get("first_prompt") or sid
+ archived = row.get("tag") == "archived"
+ if not all(
+ t in f"{cwd} {title} {sid} "
+ f"{'archived' if archived else ''}".casefold() for t in terms
+ ):
+ continue
+ parent = tree.root
+ if archived:
+ if archive_root is None:
+ archive_root = tree.root.add(
+ Text("Archived", style="dim"), ("archive", ""),
+ expand=bool(terms) or (
+ opened is not None and ("archive", "") in opened
+ ),
+ )
+ parent = archive_root
+ key = ("archived_folder" if archived else "folder", cwd)
+ if key not in groups:
+ expand = bool(terms) or (
+ key in opened
+ if opened is not None
+ else not archived
+ and cwd == rows.get(client.attached_sid, {}).get("cwd")
+ )
+ groups[key] = parent.add(
+ Text(_safe_remote_text(PurePosixPath(cwd).name or cwd)),
+ key,
+ expand=expand,
+ )
+ node = groups[key].add_leaf(
+ Text(" ".join(_safe_remote_text(title).split())),
+ ("session", sid),
+ )
+ matches.append(node)
+ candidates = [*groups.values(), *matches]
+ if archive_root is not None:
+ candidates.append(archive_root)
+ target = next((n for n in candidates if n.data == previous), None)
+ if terms and (target is None or target.data[0] != "session"):
+ target = matches[0] if matches else None
+ if target is None:
+ target = next(
+ (n for n in matches if n.data[1] == client.attached_sid), None
+ )
+ parent = target.parent if target else None
+ while parent and parent is not tree.root:
+ if not parent.is_expanded:
+ target = parent
+ parent = parent.parent
+ # Materialize the new visible lines before using a node's line index.
+ # Newly added nodes otherwise still point at the default folder row.
+ tree.get_node_at_line(0)
+ tree.move_cursor(target or next(iter(tree.root.children), tree.root))
+ self.query_one("#tree-heading", Static).update(
+ f"{client.engine.title()} / {client.space.title()} · {len(matches)} sessions"
+ )
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop()
+ self.refresh_catalog()
+
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ await self.open_search_result()
+
+ async def open_search_result(self):
+ node = self.query_one(SessionTree).cursor_node
+ if (
+ node
+ and node.data
+ and node.data[0] == "session"
+ and node.data[1] in self.app.client.visible_catalog()
+ ):
+ await self.app.attach_session(node.data[1])
+
+ async def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
+ event.stop()
+ if event.node.data and event.node.data[0] == "session":
+ # Revalidate against the current catalog after a live deletion/move.
+ if event.node.data[1] in self.app.client.visible_catalog():
+ await self.app.attach_session(event.node.data[1])
+ elif event.node.data:
+ event.node.toggle()
diff --git a/cc_remote/tui_vim.py b/cc_remote/tui_vim.py
new file mode 100644
index 00000000..da5c81af
--- /dev/null
+++ b/cc_remote/tui_vim.py
@@ -0,0 +1,331 @@
+"""Shared operator/count/motion grammar for TextArea readers and editors.
+
+No transport, filesystem access, key replay or shell commands live here.
+All ranges use TextArea's half-open character offsets (not terminal columns).
+"""
+
+import re
+
+from textual.keys import key_to_character
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_text_objects import text_object
+
+
+KEYS = {
+ "left_parenthesis": "(",
+ "right_parenthesis": ")",
+ "left_square_bracket": "[",
+ "right_square_bracket": "]",
+ "left_curly_bracket": "{",
+ "right_curly_bracket": "}",
+ "less_than_sign": "<",
+ "greater_than_sign": ">",
+ "quotation_mark": '"',
+ "apostrophe": "'",
+ "grave_accent": "`",
+ "dollar_sign": "$",
+ "circumflex_accent": "^",
+ "percent_sign": "%",
+ "semicolon": ";",
+ "comma": ",",
+ "space": " ",
+}
+MOTIONS = {
+ "h",
+ "j",
+ "k",
+ "l",
+ "w",
+ "W",
+ "b",
+ "B",
+ "e",
+ "E",
+ "0",
+ "^",
+ "$",
+ "G",
+ "gg",
+ "ge",
+ "gE",
+ "%",
+ "{",
+ "}",
+ ";",
+ ",",
+}
+
+
+class VimGrammar:
+ """Mixin: host provides prefix, vim_mode, operate(), set_mode()."""
+
+ last_find = None
+ visual_anchor = None
+ visual_cursor = None
+
+ def vim_cursor_location(self):
+ if self.vim_mode == "VISUAL" and self.visual_cursor is not None:
+ return self.visual_cursor
+ return self.cursor_location
+
+ def begin_visual(self):
+ self.visual_anchor = self.cursor_location
+ self.move_visual(self.cursor_location)
+
+ def move_visual(self, point):
+ # Vim includes the character under BOTH endpoints. TextArea uses an
+ # exclusive end, so keep the logical cursor separate from its boundary.
+ row, col = point
+ col = min(col, max(0, len(self.document.get_line(row)) - 1))
+ point = (row, col)
+ anchor = self.visual_anchor or point
+ self.visual_anchor, self.visual_cursor = anchor, point
+
+ def after(location):
+ r, c = location
+ return r, min(c + 1, len(self.document.get_line(r)))
+
+ self.selection = (Selection(anchor, after(point)) if point >= anchor
+ else Selection(after(anchor), point))
+
+ def vim_command(self, key: str) -> bool:
+ char = key_to_character(key) or KEYS.get(key, key)
+ pending = self.prefix
+ visual = self.vim_mode.startswith("VISUAL")
+ if not pending and visual and char in {"i", "a"}:
+ self.prefix = "v" + char
+ return True
+ if not pending and visual and char in {"d", "c", "y"}:
+ self.operate(char, *sorted(self.selection))
+ return True
+ if not pending and not (
+ char in MOTIONS
+ or char in {"g", "d", "c", "y", "f", "F", "t", "T"}
+ or char in "123456789"
+ ):
+ return False
+ self.prefix = ""
+ command = pending + char
+ match = re.fullmatch(
+ r"([1-9][0-9]*)?([dcyv]?)([1-9][0-9]*)?(.*)", command
+ )
+ if not match:
+ return True
+ before, operator, after, tail = match.groups()
+ count = min(int(before or 1) * int(after or 1), 9999)
+ # Keep one bounded pending command; zero is a motion unless it follows
+ # an existing count. Find targets and object keys are consumed below.
+ if (
+ not tail
+ or tail in {"g", "f", "F", "t", "T"}
+ or (operator and tail in {"i", "a"})
+ ):
+ self.prefix = command[:24]
+ return True
+ cursor = self.document.get_index_from_location(self.vim_cursor_location())
+ span = None
+ linewise = False
+ if operator and len(tail) == 2 and tail[0] in "ia":
+ span = text_object(
+ self.text, cursor, tail[1], tail[0] == "a", count
+ )
+ elif operator and tail == operator:
+ row = self.cursor_location[0]
+ end_row = min(row + count, self.document.line_count)
+ start = self.document.get_index_from_location((row, 0))
+ end = (
+ self.document.get_index_from_location((end_row, 0))
+ if end_row < self.document.line_count
+ else len(self.text)
+ )
+ span, linewise = (start, end), True
+ elif tail in MOTIONS or (len(tail) == 2 and tail[0] in "fFtT"):
+ target = self.vim_motion(tail, count, cursor, bool(before or after))
+ if target is not None:
+ if not operator:
+ point = self.document.get_location_from_index(target)
+ if self.vim_mode == "VISUAL LINE":
+ anchor = self.selection.start[0]
+ last = point[0]
+ if last >= anchor:
+ self.selection = Selection(
+ (anchor, 0),
+ (last, len(self.document.get_line(last))),
+ )
+ else:
+ self.selection = Selection(
+ (anchor, len(self.document.get_line(anchor))),
+ (last, 0),
+ )
+ elif self.vim_mode == "VISUAL":
+ self.move_visual(point)
+ else:
+ self.move_cursor(point, select=visual)
+ return True
+ start, end = sorted((cursor, target))
+ if tail in {"j", "k", "G", "gg", "{", "}"}:
+ start = self.text.rfind("\n", 0, start) + 1
+ next_line = self.text.find("\n", end)
+ end = len(self.text) if next_line < 0 else next_line + 1
+ linewise = True
+ elif (
+ tail in {"e", "E", "ge", "gE", "%"}
+ or (len(tail) == 2 and tail[0] in "fFtT")
+ or (tail in {";", ","} and self.last_find)
+ ):
+ end = min(end + 1, len(self.text))
+ if target < cursor and tail not in {"ge", "gE", "%"}:
+ end = cursor
+ # Vim cw/cW changes through the end of the last word, not its
+ # following separator. Whitespace at the cursor still uses w.
+ if (
+ operator == "c"
+ and tail in {"w", "W"}
+ and (
+ cursor < len(self.text)
+ and not self.text[cursor].isspace()
+ )
+ ):
+ target = self.vim_motion(
+ "e" if tail == "w" else "E",
+ count,
+ cursor,
+ False,
+ change=True,
+ )
+ end = min(target + 1, len(self.text))
+ span = start, end
+ if span is None:
+ self.app.client.notice = "No matching Vim text object or motion"
+ return True
+ start, end = (self.document.get_location_from_index(p) for p in span)
+ if operator == "v":
+ self.visual_anchor = start
+ last = self.document.get_location_from_index(max(span[0], span[1] - 1))
+ self.move_visual(last)
+ self.selection = Selection(start, end)
+ else:
+ self.operate(operator, start, end, linewise=linewise)
+ return True
+
+ def vim_motion(self, key, count, cursor, explicit=False, *, change=False):
+ text = self.text
+ row, col = self.document.get_location_from_index(cursor)
+ if key in {"w", "W", "b", "B", "e", "E", "ge", "gE"}:
+ pattern = (
+ r"\S+" if key.endswith(("W", "B", "E")) else r"\w+|[^\w\s]+"
+ )
+ words = list(re.finditer(pattern, text))
+ backward = key in {"b", "B", "ge", "gE"}
+ ends = key in {"e", "E", "ge", "gE"}
+ points = [m.end() - 1 if ends else m.start() for m in words]
+ points = [
+ p
+ for p in points
+ if (
+ p < cursor
+ if backward
+ else p >= cursor
+ if change
+ else p > cursor
+ )
+ ]
+ if backward:
+ points.reverse()
+ return (
+ points[min(count, len(points)) - 1]
+ if len(points) >= count
+ else (
+ 0
+ if backward
+ else max(0, len(text) - 1)
+ if ends
+ else len(text)
+ )
+ )
+ if key in {";", ","}:
+ if self.last_find is None:
+ return None
+ kind, target = self.last_find
+ if key == ",":
+ kind = kind.swapcase()
+ return self.find_character(kind, target, count, cursor, repeat=True)
+ if len(key) == 2 and key[0] in "fFtT":
+ self.last_find = key[0], key[1]
+ return self.find_character(key[0], key[1], count, cursor)
+ if key == "%":
+ line_end = text.find("\n", cursor)
+ line_end = len(text) if line_end < 0 else line_end
+ position = next(
+ (p for p in range(cursor, line_end) if text[p] in "()[]{}"),
+ None,
+ )
+ if position is None:
+ return None
+ pairs = {"(": ")", "[": "]", "{": "}", ")": "(", "]": "[", "}": "{"}
+ char = text[position]
+ step = 1 if char in "([{" else -1
+ depth = 1
+ for p in range(
+ position + step, len(text) if step > 0 else -1, step
+ ):
+ depth += (text[p] == char) - (text[p] == pairs[char])
+ if not depth:
+ return p
+ return None
+ if key in {"{", "}"}:
+ points = [m.start() for m in re.finditer(r"\n\s*\n", text)]
+ points = (
+ [p for p in points if p < cursor]
+ if key == "{"
+ else [p for p in points if p > cursor]
+ )
+ if key == "{":
+ points.reverse()
+ return (
+ points[min(count, len(points)) - 1]
+ if points
+ else (0 if key == "{" else len(text))
+ )
+ if key in {"gg", "G"}:
+ row = (
+ count - 1
+ if explicit
+ else (0 if key == "gg" else self.document.line_count - 1)
+ )
+ col = 0
+ else:
+ row += {"j": count, "k": -count}.get(key, 0)
+ row = max(0, min(row, self.document.line_count - 1))
+ line = self.document.get_line(row)
+ col += {"l": count, "h": -count}.get(key, 0)
+ if key == "0":
+ col = 0
+ elif key == "^":
+ col = len(line) - len(line.lstrip())
+ elif key == "$":
+ row = min(row + count - 1, self.document.line_count - 1)
+ col = len(self.document.get_line(row))
+ return self.document.get_index_from_location(
+ (row, max(0, min(col, len(self.document.get_line(row)))))
+ )
+
+ def find_character(self, kind, target, count, cursor, *, repeat=False):
+ row, col = self.document.get_location_from_index(cursor)
+ line = self.document.get_line(row)
+ forward = kind in "ft"
+ offset = 1 if forward else -1
+ if repeat and kind in "tT":
+ col += offset
+ for _ in range(count):
+ col = (
+ line.find(target, col + 1)
+ if forward
+ else line.rfind(target, 0, max(0, col))
+ )
+ if col < 0:
+ return None
+ if kind in "tT":
+ col -= offset
+ return self.document.get_index_from_location((row, col))
diff --git a/cc_remote/tui_vim_history.py b/cc_remote/tui_vim_history.py
new file mode 100644
index 00000000..e98f3ed8
--- /dev/null
+++ b/cc_remote/tui_vim_history.py
@@ -0,0 +1,37 @@
+"""Group one Vim change plus Insert input as a single undo transaction."""
+
+from textual.document._history import EditHistory
+
+
+class VimHistory(EditHistory):
+ # Textual's default splits deletion, insertion, paste and newlines into
+ # separate batches. Vim's c{motion} ... Esc is one edit instead. Keep this
+ # version-specific adapter here, covered by keyboard undo/redo tests.
+ group_open = False
+ group_started = False
+
+ def begin(self):
+ if not self.group_open:
+ self.checkpoint()
+ self.group_open = True
+ self.group_started = False
+
+ def finish(self):
+ self.group_open = self.group_started = False
+ self.checkpoint()
+
+ def record(self, edit):
+ result = edit._edit_result
+ if result is not None and not edit.text and not result.replaced_text:
+ return
+ if self.group_open and self.group_started and self._undo_stack:
+ self._undo_stack[-1].append(edit)
+ self._redo_stack.clear()
+ else:
+ super().record(edit)
+ if self.group_open:
+ self.group_started = True
+
+ def clear(self):
+ self.group_open = self.group_started = False
+ super().clear()
diff --git a/cc_remote/tui_widgets.py b/cc_remote/tui_widgets.py
new file mode 100644
index 00000000..f87b9261
--- /dev/null
+++ b/cc_remote/tui_widgets.py
@@ -0,0 +1,414 @@
+"""Local editing and session selection widgets; no transport side effects."""
+
+from __future__ import annotations
+
+from textual import events
+from textual.strip import Strip
+from textual.widgets import TextArea
+from textual.widgets.text_area import Selection
+from rich.cells import get_character_cell_size
+from rich.segment import Segment
+from rich.text import Text
+from textual.expand_tabs import expand_text_tabs_from_widths
+
+from cc_remote.tui_vim import VimGrammar
+from cc_remote.tui_vim_history import VimHistory
+
+
+class VimArea(VimGrammar, TextArea):
+ """Shared Vim text commands; locked readers never mutate their source."""
+
+ COMPONENT_CLASSES = TextArea.COMPONENT_CLASSES | {"vim--yank"}
+ DEFAULT_CSS = """
+ VimArea > .vim--yank { background: $primary 45%; color: $text; }
+ """
+
+ def __init__(self, **kwargs):
+ self.yank_range = None
+ self.yank_timer = None
+ self.yank_revision = 0
+ self.locked = kwargs.pop("read_only", False)
+ super().__init__(read_only=True, **kwargs)
+ self.vim_mode = "NORMAL"
+ self.prefix = ""
+ self.register = ""
+ self.line_register = False
+ self.history = VimHistory(
+ self.history.max_checkpoints,
+ self.history.checkpoint_timer,
+ self.history.checkpoint_max_characters,
+ )
+
+ def clear_yank(self, revision=None):
+ if revision is not None and revision != self.yank_revision:
+ return
+ self.yank_revision += 1
+ if self.yank_timer is not None:
+ self.yank_timer.stop()
+ self.yank_timer = None
+ if self.yank_range is not None:
+ self.yank_range = None
+ self._line_cache.clear()
+ self.refresh()
+
+ def _set_document(self, text, language=None):
+ self.clear_yank()
+ return super()._set_document(text, language)
+
+ def edit(self, edit):
+ self.clear_yank()
+ return super().edit(edit)
+
+ def undo(self):
+ self.clear_yank()
+ return super().undo()
+
+ def redo(self):
+ self.clear_yank()
+ return super().redo()
+
+ def on_unmount(self):
+ self.clear_yank()
+
+ def paint_yank(self, strip, y):
+ if self.yank_range is None:
+ return strip
+ wrapped = self.wrapped_document
+ offset = y + int(self.scroll_y)
+ if not 0 <= offset < len(wrapped._offset_to_line_info):
+ return strip
+ row, section = wrapped._offset_to_line_info[offset]
+ start, end = self.yank_range
+ if not start[0] <= row <= end[0]:
+ return strip
+ line = self.document.get_line(row)
+ breaks = [0, *wrapped.get_offsets(row), len(line)]
+ begin, limit = breaks[section:section + 2]
+ left = max(begin, start[1] if row == start[0] else 0)
+ right = min(limit, end[1] if row == end[0] else len(line))
+ # Use Textual's cached tab widths and Rich's terminal-cell widths,
+ # not Python character indices, for CJK/emoji and wrapped sections.
+ tabs = wrapped.get_tab_widths(row)[line[:begin].count("\t"):]
+
+ def cell(column):
+ prefix = Text(line[begin:column])
+ return expand_text_tabs_from_widths(prefix, tabs).cell_len
+
+ shift = self.gutter_width - int(self.scroll_x)
+ x1, x2 = cell(left) + shift, cell(right) + shift
+ if not line and row < end[0]:
+ x2 = x1 + 1
+ x1, x2 = max(0, x1), min(strip.cell_length, x2)
+ if left > right or x1 >= x2:
+ return strip
+ style = self.get_component_rich_style("vim--yank")
+ return Strip.join([
+ strip.crop(0, x1),
+ Strip(Segment.apply_style(
+ strip.crop(x1, x2), post_style=style
+ ), cell_length=x2 - x1),
+ strip.crop(x2, strip.cell_length),
+ ])
+
+ def yank_text(self, start, end):
+ return self.document.get_text_range(start, end)
+
+ def finish_yank(self, start, end, original, linewise):
+ # Line motions keep the column; text objects/character motions land
+ # at the copied range's start. This applies to locked readers too.
+ target = start
+ if linewise:
+ target = (start[0], min(original[1], max(
+ 0, len(self.document.get_line(start[0])) - 1
+ )))
+ self.move_cursor(target)
+ self.set_mode("NORMAL")
+ self.app.client.notice = f"Copied {len(self.register)} characters"
+ timeout = self.app.client.keys.yank_highlight_ms
+ if timeout and start != end:
+ self.yank_range = (start, end)
+ self._line_cache.clear()
+ self.refresh()
+ revision = self.yank_revision
+ self.yank_timer = self.set_timer(
+ timeout / 1000, lambda: self.clear_yank(revision)
+ )
+
+ def set_mode(self, mode: str) -> None:
+ if mode != "NORMAL":
+ self.clear_yank()
+ if self.locked and mode == "INSERT":
+ return
+ self.vim_mode = mode
+ self.read_only = self.locked or mode != "INSERT"
+ self.prefix = ""
+ self.app.mode = mode
+ self.history.begin() if mode == "INSERT" else self.history.finish()
+ if mode == "VISUAL":
+ self.begin_visual()
+ else:
+ self.visual_anchor = self.visual_cursor = None
+
+ def on_focus(self) -> None:
+ self.app.mode = self.vim_mode
+
+ def on_blur(self) -> None:
+ self.clear_yank()
+ self.prefix = ""
+ self.history.finish()
+
+ @property
+ def visual_caret(self):
+ point = (self.visual_cursor
+ if getattr(self, "vim_mode", "NORMAL") == "VISUAL" else None)
+ # A Visual deletion may shrink the document before leaving the mode.
+ return self.clamp_visitable(point) if point is not None else None
+
+ def render_line(self, y):
+ # Modal editors own their mode transitions too. Refresh the caret map
+ # before TextArea builds its render cache key, including on Visual exit.
+ if self.vim_mode != getattr(self, "_painted_vim_mode", None):
+ self._painted_vim_mode = self.vim_mode
+ self._line_cache.clear()
+ self._recompute_cursor_offset()
+ return self.paint_yank(super().render_line(y), y)
+
+ @property
+ def _draw_cursor(self):
+ # TextArea paints selection.end, the exclusive boundary. Visual mode
+ # keeps that range for selection/copy but paints its logical caret.
+ return self.visual_caret is None and super()._draw_cursor
+
+ def _recompute_cursor_offset(self):
+ if self.visual_caret is None:
+ super()._recompute_cursor_offset()
+ else:
+ self._cursor_offset = self.wrapped_document.location_to_offset(
+ self.visual_caret
+ )
+
+ def _render_line(self, y):
+ strip = super()._render_line(y)
+ point = self.visual_caret
+ if point is None or not super()._draw_cursor or not self._theme:
+ return strip
+ cursor_x, cursor_y = self._cursor_offset
+ if cursor_y != y + self.scroll_y:
+ return strip
+ style = self._theme.cursor_style
+ if not style:
+ return strip
+ x = cursor_x + self.gutter_width - int(self.scroll_x)
+ row, column = point
+ line = self.document.get_line(row)
+ char = line[column:column + 1] or " "
+ width = max(1, get_character_cell_size(char))
+ left, right = max(0, x), min(strip.cell_length, x + width)
+ if left >= right:
+ return strip
+ return Strip.join([
+ strip.crop(0, left),
+ Strip(Segment.apply_style(
+ strip.crop(left, right), post_style=style
+ ), cell_length=right - left),
+ strip.crop(right, strip.cell_length),
+ ])
+
+ def paste_text(self, text: str) -> None:
+ # Normal mode blocks typing, not an explicit paste. Only locked
+ # readers are truly read-only. Keep pasted newlines out of key dispatch.
+ if self.locked or not text:
+ return
+ self.prefix = ""
+ normal = self.vim_mode != "INSERT"
+ if normal:
+ self.history.finish()
+ result = self.replace(
+ text, *self.selection, maintain_selection_offset=False
+ )
+ self.move_cursor(result.end_location)
+ if normal:
+ self.set_mode("NORMAL")
+
+ async def _on_paste(self, event: events.Paste) -> None:
+ event.stop()
+ event.prevent_default()
+ self.paste_text(event.text)
+
+ def action_paste(self) -> None:
+ self.paste_text(self.app.clipboard)
+
+ async def _on_key(self, event: events.Key) -> None:
+ key = event.key
+ if (
+ self.vim_mode != "INSERT"
+ and not self.prefix
+ and (await self.app.normal_shortcut(key))
+ ):
+ event.stop()
+ event.prevent_default()
+ return
+ if key == "escape":
+ event.stop()
+ event.prevent_default()
+ if self.vim_mode == "INSERT":
+ row, col = self.cursor_location
+ self.move_cursor((row, max(0, col - 1)))
+ self.selection = Selection.cursor(self.cursor_location)
+ self.set_mode("NORMAL")
+ return
+ if self.vim_mode == "INSERT":
+ await super()._on_key(event)
+ return
+ if self.edit_key(key):
+ event.stop()
+ event.prevent_default()
+
+ def edit_key(self, key: str) -> bool:
+ if self.vim_command(key):
+ return True
+ if self.locked and key not in {"v", "V"}:
+ return len(key) == 1 or key in {
+ "space",
+ "enter",
+ "backspace",
+ "delete",
+ "ctrl+r",
+ }
+ row, col = self.cursor_location
+ line = self.document.get_line(row)
+ if key in {"i", "a", "I", "A"}:
+ if key == "a":
+ self.move_cursor((row, min(col + 1, len(line))))
+ elif key == "I":
+ self.move_cursor((row, len(line) - len(line.lstrip())))
+ elif key == "A":
+ self.move_cursor((row, len(line)))
+ self.set_mode("INSERT")
+ elif key in {"o", "O"}:
+ target = (row, len(line)) if key == "o" else (row, 0)
+ self.history.begin()
+ self.insert("\n", target, maintain_selection_offset=False)
+ self.move_cursor((row + 1 if key == "o" else row, 0))
+ self.set_mode("INSERT")
+ elif key == "v":
+ self.set_mode("VISUAL")
+ elif key == "V":
+ self.selection = Selection((row, 0), (row, len(line)))
+ self.set_mode("VISUAL LINE")
+ elif key == "x":
+ if col < len(line):
+ self.operate("d", (row, col), (row, col + 1))
+ elif key == "p":
+ text, linewise = getattr(
+ self.app, "vim_register", (self.app.clipboard, False)
+ )
+ if text:
+ if linewise:
+ self.insert(
+ "\n" + text.removesuffix("\n"),
+ (row, len(line)),
+ maintain_selection_offset=False,
+ )
+ self.move_cursor((row + 1, 0))
+ else:
+ self.insert(
+ text,
+ (row, min(col + 1, len(line))),
+ maintain_selection_offset=False,
+ )
+ self.history.checkpoint()
+ elif key == "u":
+ self.undo()
+ elif key == "ctrl+r":
+ self.redo()
+ else:
+ # Normal mode must never turn an unimplemented printable binding
+ # into literal input. Navigation keys may use TextArea defaults.
+ return len(key) == 1 or key in {
+ "space",
+ "enter",
+ "backspace",
+ "delete",
+ }
+ return True
+
+ def operate(
+ self,
+ operator: str,
+ start: tuple[int, int],
+ end: tuple[int, int],
+ *,
+ linewise: bool = False,
+ ) -> None:
+ if self.locked and operator != "y":
+ self.prefix = ""
+ self.app.client.notice = (
+ "Read-only text: use y to copy or "
+ + self.app.client.keys.label("quote") + " to quote"
+ )
+ return
+ original = self.vim_cursor_location()
+ self.clear_yank()
+ start, end = sorted((start, end))
+ if operator == "y" and start == end:
+ self.app.client.notice = "Nothing to copy"
+ self.prefix = ""
+ return
+ self.register = (self.yank_text(start, end) if operator == "y"
+ else self.document.get_text_range(start, end))
+ self.line_register = linewise
+ self.app.vim_register = self.register, linewise
+ if operator == "y":
+ self.app.copy_to_clipboard(self.register)
+ self.finish_yank(start, end, original, linewise)
+ return
+ else:
+ self.history.checkpoint()
+ if operator == "c":
+ self.history.begin()
+ delete_start, delete_end = start, end
+ if linewise and operator == "c":
+ # cc changes the line's contents, retaining its line boundary.
+ last_row = (
+ end[0] - 1 if end[1] == 0 and end[0] > start[0] else end[0]
+ )
+ delete_end = (last_row, len(self.document.get_line(last_row)))
+ elif (
+ linewise
+ and start[0] > 0
+ and end
+ == (
+ self.document.line_count - 1,
+ len(self.document.get_line(self.document.line_count - 1)),
+ )
+ ):
+ # The final line has no following newline; remove its preceding
+ # separator without adding that separator to the yank register.
+ delete_start = (
+ start[0] - 1,
+ len(self.document.get_line(start[0] - 1)),
+ )
+ self.delete(
+ delete_start, delete_end, maintain_selection_offset=False
+ )
+ self.move_cursor(start)
+ self.set_mode("INSERT" if operator == "c" else "NORMAL")
+
+
+class Composer(VimArea):
+ """Editable draft (separate from readers for focus and widget queries)."""
+
+ async def _on_key(self, event: events.Key) -> None:
+ if (self.vim_mode == "NORMAL" and not self.prefix
+ and not self.app.shortcut_prefix
+ and len(self.app.screen_stack) == 1):
+ name = self.app.client.keys.match("draft", event.key)
+ if name:
+ from cc_remote.tui_keys import LAYERS
+
+ event.stop()
+ event.prevent_default()
+ await self.app.run_action(LAYERS["draft"][name].action)
+ return
+ await super()._on_key(event)
diff --git a/cc_remote/wrapper/command_router.py b/cc_remote/wrapper/command_router.py
index 09175f4c..8a65ac2b 100644
--- a/cc_remote/wrapper/command_router.py
+++ b/cc_remote/wrapper/command_router.py
@@ -15,6 +15,7 @@
COMMAND_HANDLER_NAMES = MappingProxyType({
"query": "_handle_query",
"cancel_queued_query": "_handle_cancel_queued_query",
+ "reorder_queued_queries": "_handle_reorder_queued_queries",
"get_queued_query": "_handle_get_queued_query",
"update_queued_query": "_handle_update_queued_query",
"steer": "_handle_steer",
diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py
index eac30952..7d23b5b0 100644
--- a/cc_remote/wrapper/machine.py
+++ b/cc_remote/wrapper/machine.py
@@ -1981,7 +1981,8 @@ class WrapperMachine:
# the owner check before its handler is allowed to read or mutate state.
BTW_SID_COMMANDS = frozenset({
"query", "cancel_queued_query", "get_queued_query",
- "update_queued_query", "steer", "interrupt", "takeover",
+ "update_queued_query", "reorder_queued_queries",
+ "steer", "interrupt", "takeover",
"set_model", "set_effort", "set_auto_compact", "set_codex_context",
"set_service_tier", "set_collaboration_mode", "open_btw", "close_btw",
"sync_btw",
@@ -10765,6 +10766,39 @@ async def _enqueue_deferred_query(
self._schedule_query_queue_drain(ctx)
return None
+ async def _handle_reorder_queued_queries(self, cmd):
+ """Reorder only an unchanged pending queue, under the drain lock."""
+ ctx = self._ctx_for(cmd.sid)
+ if ctx is None:
+ return await self._missing_session_error(cmd, "调整排队顺序")
+ error = None
+ async with ctx.emit_lock:
+ async with ctx.queued_query_lock:
+ current = [q.msg_id for q in ctx.queued_queries]
+ if current != cmd.expected:
+ error = "队列已变化,请按最新列表重试。"
+ elif ctx.queued_query_starting_msg_id:
+ error = "排队消息正在启动,请稍后再调整顺序。"
+ else:
+ queries = {q.msg_id: q for q in ctx.queued_queries}
+ ctx.queued_queries[:] = [queries[mid] for mid in cmd.order]
+ try:
+ await self._emit_locked(ctx, self._query_queue_state(ctx))
+ except Exception as exc:
+ log.warning(
+ "query queue reorder projection delayed",
+ session_id=self._ctx_wire_sid(ctx),
+ error_type=type(exc).__name__,
+ )
+ if error:
+ result = Error(
+ sid=self._ctx_wire_sid(ctx), request_id=cmd.cmd_id,
+ code="queue_changed", message=error, to=cmd.client_id,
+ )
+ await self.transport.send(result)
+ return result
+ self._schedule_query_queue_drain(ctx)
+
async def _handle_cancel_queued_query(self, cmd) -> None:
ctx = self._ctx_for(getattr(cmd, "sid", None))
if ctx is None:
@@ -28331,6 +28365,7 @@ async def _handle_delete_work_session(self, cmd):
code=ERR_AUTH,
message="只能删除已注册的 Work 会话",
sid=sid,
+ request_id=getattr(cmd, "cmd_id", None),
to=getattr(cmd, "client_id", None),
)
await self.transport.send(error)
@@ -28348,6 +28383,7 @@ async def _handle_delete_work_session(self, cmd):
code=ERR_BUSY,
message="Work 会话仍在运行或有排队消息,请先停止并取消排队后再删除",
sid=sid,
+ request_id=getattr(cmd, "cmd_id", None),
to=getattr(cmd, "client_id", None),
)
await self.transport.send(error)
@@ -28404,6 +28440,7 @@ async def _handle_delete_work_session(self, cmd):
code=ERR_INTERNAL,
message="Work 会话删除失败,原始资料未被删除",
sid=sid,
+ request_id=getattr(cmd, "cmd_id", None),
to=getattr(cmd, "client_id", None),
)
await self.transport.send(error)
diff --git a/deploy/README.md b/deploy/README.md
index 4897a032..4104799b 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -152,11 +152,11 @@ deployment.
migration transaction, restores matching pre-release data before an older
wrapper is restarted, and verifies both engines' Work ownership backfills.
-Protocol v66 is a coordinated upgrade: publish freshly built Relay/Web and
+Protocol v67 is a coordinated upgrade: publish freshly built Relay/Web and
Wrapper artifacts from the same tagged commit. The strict protocol gate is
intentional and mixed protocol versions will not communicate. `setup-vps.sh`
rejects a missing or mismatched web build manifest. Stop the wrapper first;
-activate the v66 relay/web release; then start the v66 wrapper.
+activate the v67 relay/web release; then start the v67 wrapper.
The wrapper installer treats local Work data and versioned private control state
as part of the release
@@ -169,8 +169,8 @@ the previous code. If data restoration fails, it leaves the
wrapper stopped instead of running old code against a new schema. A manual or
legacy-layout deployment must use the same order: stop the wrapper, run
`work_registry_snapshot.py snapshot` from the new staging tree, activate and
-verify v66, and retain that snapshot with the previous release. To roll back,
-stop v66, run `work_registry_snapshot.py restore`, then switch and start the old
+verify v67, and retain that snapshot with the previous release. To roll back,
+stop v67, run `work_registry_snapshot.py restore`, then switch and start the old
release. Never copy only `registry.sqlite3` while the wrapper is live because
committed state may still be in its WAL file. Restoring a pre-release snapshot
also restores pre-release Work metadata: sessions, projects, or schedule state
diff --git a/docs/installation.md b/docs/installation.md
index 9632f99a..f7578f0a 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -166,7 +166,7 @@ npm --prefix web run build # 产出 web/dist/
网页构建不需要任何登录密钥。
**所有目标先 staging,再改动线上服务。** 下文分别描述 Relay 和 Wrapper,
-不能在 Wrapper staging 未验证时先激活 Relay。协议 v66 不允许混用旧客户端:
+不能在 Wrapper staging 未验证时先激活 Relay。协议 v67 不允许混用旧客户端:
停止不兼容的旧 Wrapper,激活 Relay + Web,再激活 Wrapper 并硬刷新网页。
Wrapper 激活须通过 `deploy/work_registry_snapshot.py` 保存 Work SQLite 与私有账号
控制状态,不再按“是否来自某个旧协议”决定是否保护。回滚先恢复匹配状态,再启动
@@ -224,7 +224,7 @@ sudo bash ~/cc-remote-upload/deploy/setup-vps.sh \
脚本会:装 `python3-venv` + Caddy、建 `ccremote` 系统用户、创建不可变 release
和 release-local venv、合并 Caddy 配置、原子切换 `current`,再重启 relay。若新
relay 重启或健康检查失败,`current`、Caddyfile、systemd unit 会作为一个事务全部
-恢复,并验证旧 release 的 `/healthz`。成功后再启动 v66 wrapper。
+恢复,并验证旧 release 的 `/healthz`。成功后再启动 v67 wrapper。
验证:
diff --git a/docs/installation_en.md b/docs/installation_en.md
index 54b0d6b5..69b1ecb7 100644
--- a/docs/installation_en.md
+++ b/docs/installation_en.md
@@ -188,7 +188,7 @@ as described in the deployment contract. No browser secret is needed for a build
**Stage every target before changing live services.** The commands below describe
the Relay and Wrapper separately; do not activate Relay until every Wrapper stage
-has passed validation. Protocol v66 cannot be mixed with older clients. Stop old
+has passed validation. Protocol v67 cannot be mixed with older clients. Stop old
incompatible Wrappers, activate Relay + Web, then activate Wrappers and hard-refresh
browser tabs. Wrapper activation must snapshot Work SQLite and private profile
control state with `deploy/work_registry_snapshot.py`; this is not limited to
@@ -250,7 +250,7 @@ The script installs `python3-venv` + Caddy, creates the `ccremote` service user,
builds an immutable release and its venv, merges Caddy configuration, atomically
switches `current`, and restarts the relay. If restart/readiness fails, `current`,
the Caddyfile, and the systemd unit roll back as one transaction and the previous
-release's `/healthz` is verified. Start the v66 wrapper after success.
+release's `/healthz` is verified. Start the v67 wrapper after success.
Verify:
diff --git a/docs/tui-keys.example.toml b/docs/tui-keys.example.toml
new file mode 100644
index 00000000..8e2d7d31
--- /dev/null
+++ b/docs/tui-keys.example.toml
@@ -0,0 +1,207 @@
+# Copy to ~/.config/cc-remote/tui.toml, or pass --config PATH.
+# Arrays replace defaults; [] unbinds. Relaunch only the TUI after editing.
+# [keys] applies globally, including Insert; prefer Ctrl combinations.
+[vim]
+yank_highlight_ms = 200 # 0 disables the flash; maximum 5000 ms.
+
+[keys]
+paste_image = ["ctrl+v"] # Main draft: desktop clipboard image; forms: text
+toggle_pane = [] # Use Ctrl+j / Ctrl+k; Tab is jump-forward in the reader.
+focus_draft = ["ctrl+j"] # Draft, or next picker result
+focus_read = ["ctrl+k"] # Read, or previous picker result
+send = [] # Optional global alias; [draft].send controls Normal submission.
+queue = ["ctrl+e"]
+stop = ["ctrl+x"] # Main chat/draft: interrupt current turn, keep queued items.
+sessions = [] # Optional global alias for the tree; Ctrl+p is unbound.
+answer = ["ctrl+t"]
+complete = ["ctrl+space", "ctrl+@"]
+quit = ["ctrl+q"]
+
+# Sequences in main-pane Normal/Visual mode; never consume inserted text.
+[normal]
+jump_back = ["ctrl+o"]
+jump_forward = ["ctrl+i", "tab"]
+preview = ["space v"]
+diagram_browser = ["space B"] # Open the current session in authenticated Web.
+help = ["space h"]
+goal = ["space g"]
+usage = ["space u"]
+queue_details = ["space l"]
+actions = ["space a"]
+settings = ["space s"]
+reports = ["space r"]
+background = ["space b b"]
+buffer_previous = ["H"]
+buffer_next = ["L"]
+buffer_search = ["space comma"]
+buffer_close = ["space b d"]
+status = ["space t"]
+notices = ["space n"]
+tree = ["space e"]
+engine = ["space c"]
+space = ["space w"]
+quote = ["space q"]
+new_session = ["space enter"]
+model = ["space m"]
+permissions = ["space p"]
+previous_user = ["left_square_bracket u"]
+next_user = ["right_square_bracket u"]
+previous_assistant = ["left_square_bracket a"]
+next_assistant = ["right_square_bracket a"]
+previous_message = ["left_square_bracket m"]
+next_message = ["right_square_bracket m"]
+
+# Local, single-key bindings. Insert mode suspends printable actions.
+[draft]
+send = ["enter"]
+cancel = ["escape"] # Command-editor cancellation; Vim Esc still exits Insert.
+
+[reader]
+latest_user = ["g u"]
+latest_assistant = ["g a"]
+first = ["g g"]
+follow = ["G"]
+details = ["enter"]
+older = ["o"]
+newer = ["O"]
+close = ["escape"]
+command = ["colon"]
+
+[tree_search]
+down = ["down"]
+up = ["up"]
+choose = ["enter"]
+close = ["escape"]
+
+[tree]
+down = ["j", "down"]
+up = ["k", "up"]
+first = ["g g"] # Tree also accepts multi-key chords and Vim counts.
+last = ["G"]
+fold = ["h", "left"]
+expand = ["l", "right"]
+fold_all = ["H"]
+expand_all = ["L"]
+rename = ["r"]
+delete = ["d"]
+delete_direct = ["D"] # Permanent deletion, without a local confirmation.
+search = ["slash"]
+close = ["escape"]
+choose = ["enter"]
+
+[panel]
+close = ["escape"]
+refresh = ["r"]
+actions = ["a"]
+edit = ["i"]
+hide = ["x"]
+search = ["slash"] # Help: search the shortcut index, never execute results.
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[picker]
+first = ["home"]
+last = ["end"]
+page_up = ["pageup"]
+page_down = ["pagedown"]
+close = ["escape"]
+search = ["slash"]
+edit = ["i"]
+down = ["j", "down"]
+up = ["k", "up"]
+parent = ["h", "ctrl+left"] # Directory picker only
+open = ["l", "ctrl+right"] # Directory picker only
+refresh = ["r", "ctrl+r"] # Directory picker only
+choose = ["enter"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[form]
+advanced = ["ctrl+r"]
+close = ["escape"]
+help = ["question_mark"]
+confirm = ["enter"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[file_hints]
+first = ["home"]
+last = ["end"]
+page_up = ["pageup"]
+page_down = ["pagedown"]
+close = ["escape"]
+down = ["j", "down"]
+up = ["k", "up"]
+parent = ["h"] # Previous page (digits 1-9 open a file)
+open = ["l"] # Next page
+choose = ["enter"]
+select_1 = ["1"]
+select_2 = ["2"]
+select_3 = ["3"]
+select_4 = ["4"]
+select_5 = ["5"]
+select_6 = ["6"]
+select_7 = ["7"]
+select_8 = ["8"]
+select_9 = ["9"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[preview]
+browser = ["alt+b"]
+close = ["escape"]
+refresh = ["r"]
+authorize = ["a"] # Only when an exact-file authorization is required
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[image]
+browser = ["alt+b"]
+close = ["escape"]
+refresh = ["r"]
+authorize = ["a"]
+left = ["h", "left"]
+right = ["l", "right"]
+down = ["j", "down"]
+up = ["k", "up"]
+zoom_in = ["z i"]
+zoom_out = ["z o"]
+fit = ["z f"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[confirmation]
+yes = ["y"]
+close = ["n", "escape"]
+choose = ["enter"]
+down = ["j", "down"]
+up = ["k", "up"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[queue]
+close = ["escape"]
+down = ["j", "down"]
+up = ["k", "up"]
+choose = ["enter"]
+edit = ["i"]
+delete = ["d"]
+move_up = ["K"]
+move_down = ["J"]
+first = ["home"]
+last = ["end"]
+page_up = ["pageup"]
+page_down = ["pagedown"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
+
+[question]
+close = ["escape"]
+confirm = ["enter"]
+edit = ["i"]
+down = ["j", "down"]
+up = ["k", "up"]
+next = ["ctrl+right"]
+previous = ["ctrl+left"]
+next_field = ["tab"]
+previous_field = ["shift+tab"]
diff --git a/docs/tui-scroll-debug.md b/docs/tui-scroll-debug.md
new file mode 100644
index 00000000..40f9b4de
--- /dev/null
+++ b/docs/tui-scroll-debug.md
@@ -0,0 +1,120 @@
+# TUI 流式输出抖动:文本重载与光标滚动争抢视窗
+
+## 结论
+
+抖动来自客户端刷新,不是模型输出重复,也不是服务端断线重放。
+聊天区复用了 Textual 的编辑器,但此前在后台重载文本、恢复选择时,
+没有隔离编辑器“让光标可见”的默认滚动行为。
+随后执行的底部跟随只能修正最终位置,不能撤回已经显示的中间帧。[1][2]
+
+修复将后台投影更新与用户光标操作分开,并在绘制前定位视窗。
+“仅处于聊天区最底部时跟随”的规则不变,与焦点和 Vim 模式无关。
+输入框、用户选择、主动导航、排队和模型任务不需要重启或改变。[1]
+
+## 可复现证据
+
+确认复现的基线为 `1f5235abc0e035715be8f8e89050d38caed22be5`,
+依赖为仓库固定的 Textual `6.12.0`、textual-image `0.13.2`。[3]
+测试使用离线客户端和合成消息,不发送模型请求,不读取实际会话内容。
+
+保持阅读光标在第 25 行,把视窗滚到底部,再追加五批内容。
+在 `Transcript.watch_scroll_y` 记录每次位置变化,而不是只在测试末尾
+读取 `scroll_y`。修复前的一段实际轨迹是:
+
+```text
+154 -> 0 -> 13 -> 161 -> 0 -> 13 -> 165 -> ...
+```
+
+无论焦点在聊天区还是输入框,均能复现。每次最终都回到底部,
+所以旧的 `scroll_y == max_scroll_y` 终态断言仍然通过。
+新测试额外监听实际非空 `_display` 调用,检查绘制时的位置,
+并对纯追加场景要求所有滚动转换不倒退。[4]
+
+缩放还有独立的时序证据:旧逻辑出现了视窗位置与底部位置为
+`(191, 199)`、`(191, 259)` 的中间帧,之后才到 `(259, 259)`。
+这是“新几何尺寸已生效,但位置修复仍等待绘制后回调”的表现。[4]
+
+## 根因链路
+
+`WorkspaceApp.paint()` 对变化的投影调用 `TextArea.load_text()`。
+Textual 的 `_set_document()` 会构建新文档并调用 `move_cursor((0, 0))`。
+`_watch_selection()` 随即调用 `scroll_cursor_visible()`,把视窗滚到开头。
+应用恢复保存的选择时,选择监听器再次把旧阅读光标滚入视窗。
+最后 `call_after_refresh` 才执行底部跟随,造成往返跳动。[1][2]
+
+这一模式可追溯到终端工作台实现提交
+`c4dadb9936b61952300cdcf3de1ec0ffe483c55a` 中的同一刷新路径。
+这确认了代码来源,但没有对所有历史版本做真实终端帧级回放,
+因此不把它称为“首次可见抖动”的已证实版本。
+`1f5235a` 保留旧阅读光标的规则没有移除这些编辑器副作用。[1]
+
+计时更新的 `TextArea.replace()` 和缩放后的选择恢复同样可能触发
+选择监听器,不能只处理 `load_text()` 一处。[2]
+
+## 修复结构
+
+`Transcript.presentation_update()` 是同步、可嵌套的投影更新范围。
+范围内只抑制 `scroll_cursor_visible()`,不禁止文档更新、选择恢复或重绘。
+退出后立即恢复正常行为,用户的 Vim 导航仍可以滚动到光标。[1]
+
+普通投影更新、计时文本替换和缩放选择恢复都使用该范围。
+未变化的投影不再重载文档,但仍允许样式刷新。
+新内容的底部位置在当前绘制之前设置,而不是仅等待绘制后的回调。[1]
+
+底部跟随使用 Textual 的 `Widget.anchor()` 公共接口,使合成器布局期间
+也保持底部位置。离开底部时解除锚定,保留原文位置。
+尺寸更新的第一帧还需在 `_size_updated()` 完成尺寸设置后立即校正底部;
+否则 Resize 消息处理之前仍可能显示旧高度对应的位置。[1][2]
+
+## 验证与边界
+
+帧级回归位于 `tests/test_tui_scroll_frames.py`:[4]
+
+- 输入框与聊天框分别聚焦,测试连续换行及中英文长行折行。
+- 所有追加过程的滚动转换都不倒退,所有绘制帧保持底部。
+- 停在历史位置时,后台追加不产生任何中间滚动。
+- 完成折叠与计时更新不显示旧光标所在位置。
+- 图片异步解码、占位区域展开时仍保持底部。
+- 窗口连续变窄、变宽和改变高度时,每个绘制帧保持底部。
+- 仅版本变化而正文不变时,不重载编辑器文档。
+
+可在 TUI 工作树执行以下零模型调用验证:
+
+```sh
+.venv/bin/python -m pytest -q tests/test_tui_scroll_frames.py
+.venv/bin/python -m pytest -q tests/test_tui*.py
+uvx --from ruff==0.15.13 ruff check cc_remote tests deploy
+```
+
+本次帧级用例 10 项通过,完整 TUI 回归重跑后 568 项通过,Ruff 通过。
+首次完整运行有 1 项目录搜索关闭竞态失败(异步过滤访问已移除的列表),
+其余 567 项通过;目录搜索单独回归 9 项通过,未修改基线的同一用例
+连续 12 次也通过。该偶发现象尚未稳定复现,不能据此断言其引入版本,
+也不把完整重跑通过称为目录竞态已经修复;本提交仅处理滚动路径。
+
+这些测试运行真实的 Textual 消息循环、布局与合成器,观察滚动转换及绘制
+边界;不等同于已经在每一种 Kitty/tmux/SSH 组合中做过人工视觉验收。
+图片测试覆盖实际解码和内联布局,像素输出使用测试控件;GPU 绘制性能
+不是这些断言的结论。部署和 daemon 不在本次修改范围内。
+
+## 后续维护约束
+
+新增后台文本修改路径必须放入投影更新范围。不要通过移动阅读光标
+实现底部跟随,也不要重新引入“画完之后再纠正位置”作为唯一措施。
+升级 Textual 时需检查文档重载、选择监听器、尺寸更新及锚定的实现,
+重跑帧级测试;只验证最终截图或最终滚动位置不足以发现这一类回归。
+
+## 来源
+
+1. cc-remote:[当前客户端实现](../cc_remote/tui_app.py),
+ `Transcript`、`WorkspaceApp.paint()`、`refresh_turn_clocks()`;
+ 历史版本通过上文完整 Git 提交标识定位。
+2. Textual `6.12.0`,本机已安装依赖源码:
+ `textual/widgets/_text_area.py` 的 `load_text`、`_set_document`、
+ `_watch_selection`、`edit`;`textual/widget.py` 的 `anchor`、
+ `_size_updated`;`textual/_compositor.py` 的布局锚定逻辑。
+ 这是与被测运行时一致的原始源码,不是对其他版本文档的推测。
+3. cc-remote:[固定的 TUI 依赖](../requirements-tui.txt)。
+4. cc-remote:[帧级复现与回归](../tests/test_tui_scroll_frames.py);
+ [位置规则回归](../tests/test_tui_viewport_follow.py);
+ [缩放原文锚点回归](../tests/test_tui_resize_anchor.py)。
diff --git a/docs/tui.md b/docs/tui.md
new file mode 100644
index 00000000..4dc09175
--- /dev/null
+++ b/docs/tui.md
@@ -0,0 +1,675 @@
+# Terminal workspace
+
+[中文](tui_zh.md) | English
+
+The terminal workspace is a client of the same relay and wrapper as Web.
+It does not launch another Codex/Claude process or take over a native writer.
+Closing the client does not stop wrapper-owned tasks or deferred queries.
+
+Only the transcript viewport position controls auto-follow: exactly at the
+bottom before new output, it follows; anywhere above it, it stays put. Focus
+in the draft or transcript, Vim mode, and selections do not change this rule.
+Following preserves the reading cursor and selection. Sending a message does
+not jump a scrolled-up viewport. Scroll to the bottom or press `G` to resume;
+terminal reflow preserves the bottom or the source position being read.
+Each turn has a **Turn details** section, expanded by default. Progress prose
+stays visible; consecutive tools/thinking under each progress message form a
+collapsed summary, such as `3 个工具调用 · 修改 2 个文件 · Bash ×2 · Edit ×1`.
+Enter toggles either level. Enter/Esc inside a tool body closes its tool group;
+inside progress prose it closes the outer section. The final answer remains
+outside the fold, and Enter on it toggles its own turn's section. New output
+and completion preserve manually chosen folds. History details load on demand.
+
+The compact bottom status keeps mode, execution state and elapsed time visible;
+long activity/notice text is truncated to the terminal width. Shortcut lists
+live in `Space h` rather than permanently occupying the bottom of the screen.
+
+## Run
+
+To try reading, selection and independent drafts without connecting to a
+server, run `.venv/bin/python -m cc_remote.tui --demo`. The demo never logs in,
+sends commands or starts a model. Sending is deliberately disabled.
+
+Use the same source/protocol version as your relay and wrapper. A mismatched
+server is reported explicitly; the client does not downgrade the wire schema.
+Installing/running this client does not upgrade or restart either service.
+
+```sh
+uv venv .venv
+uv pip install --python .venv/bin/python \
+ -r requirements.txt -r requirements-tui.txt
+.venv/bin/python -m cc_remote.tui --engine codex
+```
+
+For a stable command independent of the current directory, run
+`scripts/cc-remote-tui` from this checkout, or install a user-local link:
+
+```sh
+mkdir -p "$HOME/.local/bin"
+ln -s "$PWD/scripts/cc-remote-tui" "$HOME/.local/bin/cc-remote-tui"
+cc-remote-tui
+```
+
+Keep `~/.local/bin` on PATH. The launcher resolves symlinks and always uses its
+own checkout's virtualenv and module. In contrast, `.venv/bin/python -m ...`
+inside another checkout imports that checkout, which may still contain the
+legacy line-mode TUI and different login handling. This launcher changes no
+credentials or running services.
+
+With a Linux user-level `cc-remote-relay.service`, local startup discovers the
+actual port and reuses the same user's running relay configuration without a
+password prompt. It does not persist another password, disable Web authentication,
+or exempt loopback from authentication. Automatic credentials are never used for
+an explicitly selected unrelated address or port. The relay must already allow
+loopback access (for example, `ALLOW_PRIVATE_ORIGINS=1`); the TUI does not change
+that security configuration.
+
+For a remote relay, pass `--url wss://your-domain/ws`; remote login still prompts
+without echo when needed. Existing `LOGIN_PASSWORD`, `LOGIN_USERNAME`,
+`PUBLIC_ORIGIN`, `RELAY_URL`, and `ENGINE` configuration remains supported.
+Non-systemd installations, SSH tunnels and multi-user logins need explicit
+credentials; unavailable local configuration fails clearly without prompting.
+Do not put credentials in command arguments or URLs, or send them over plain LAN
+HTTP.
+
+Pass an optional session ID to attach directly. `--machine ` selects an
+authorized relay device. `--line-mode` retains the original line-oriented
+terminal client without requiring Textual.
+
+## Interaction
+
+Opened sessions appear as tabs above the current-session title. In Normal mode:
+
+- `H` / `L`: previous / next opened session, wrapping at either end.
+- `Space ,`: search opened sessions across all engines and Code/Work. Type
+ immediately; Ctrl+j/k or arrows select; Enter opens the result.
+- `Space b d`: close the current **local tab**, choosing its neighbor. Closing
+ the last tab leaves an empty workspace. This never deletes a server session,
+ interrupts a task, or cancels a queued message. Reopen from `Space e`.
+
+Tabs show a cyan circle while running, a green circle for an unread completed
+turn, or a red circle for an unread interrupted/failed turn. Opening the tab in
+a focused TUI clears its result marker, even when reading older messages.
+Successful completion also respects the server's cross-client read receipts;
+failure markers are local to this TUI process (the protocol has no failure
+receipt). Merely becoming idle does not invent a completed turn.
+
+The tab working set and selected tab are saved automatically when changed and
+on exit, under `$XDG_STATE_HOME/cc-remote/tui-tabs` (default:
+`~/.local/state/cc-remote/tui-tabs`). State is private and scoped to relay URL,
+device and login; it contains no prompts or credentials. Concurrent terminals
+merge tab additions/removals instead of overwriting each other's working set.
+Startup restores these tabs and checks the catalog before attaching the selected
+one; it does not resume every saved session or start a model turn. Closing all
+tabs deliberately restores an empty workspace. An explicit startup session ID
+overrides saved focus. Temporary and ephemeral side-chat IDs are not persisted.
+The offline demo does not read or write this state.
+
+Tabs retain loaded messages/details, drafts and reading positions in this TUI
+process. Ordinary switches do not fetch history again; reconnects, replay gaps
+and server history invalidation revalidate the cache. Sessions evicted from the
+Wrapper pool also revalidate when reopened, since they lack a continuous live
+stream. Tabs are not saved across
+TUI exits. Background reports now use `Space b b` (previously `Space b`), leaving
+the `Space b` prefix available for buffer commands. Update that binding in any
+existing custom config to avoid a prefix conflict.
+
+The conversation uses the full terminal width, with a one-line current-session
+title at the top. Long titles are ellipsized. There is no persistent session
+list until `Space e` opens the left-hand session tree. It groups sessions by
+current directory using the catalog, not a disk scan; migrated sessions appear
+only under their new directory. Labels show folder basenames; grouping and
+search retain full paths. Use j/k or arrows to move, h/l to fold/unfold,
+and Enter to open a session. The tree stays open while focus returns to chat.
+Tree navigation also supports `gg` / `G` (first / last visible row),
+`50j` / `50k` (counted movement), and `50G` / `50gg` (visible row 50).
+`H` collapses all folders; `L` expands all folders. These tree-local keys do
+not switch session tabs. `r` renames the highlighted session, not the attached
+session: press `i` to edit, Esc for Normal, Enter to submit. Folder rows cannot
+be renamed. The `[tree]` key layer is configurable; search keeps these letters
+as ordinary text. See `Space h` for the active bindings.
+Press `/` in the tree to search titles, directories or IDs (all search terms
+must match). Ctrl+j/k or arrows select results; Enter opens. Esc clears search
+and returns to the tree; another Esc closes it. Then Ctrl+j focuses the draft.
+Ctrl+p has no default binding and no longer opens a picker.
+Each session retains a separate draft, input cursor and reading position in
+this process. Background output never switches the focused session.
+Startup selects the newest session after the current surface's catalog arrives,
+matching Web; an explicit session ID takes precedence. Use `--engine codex
+--space work` to choose the initial surface. The header shows engine and space.
+The default engine is Codex; --engine or ENGINE can explicitly override it.
+In Normal mode, `Space c` toggles Claude/Codex and `Space w` toggles Code/Work.
+The tree lists only that engine and space. Each of the four
+surfaces remembers its last focused session during this TUI process; a missing
+bookmark falls back to the newest remaining session. An empty surface never
+continues displaying or submitting to the old session.
+For an unarchived Codex Code session, confirmed deletion first requests native
+conversation-tree archival, then waits for the matching archived catalog result
+and command ACK before deleting. Rejection or an unconfirmed archive stops the
+workflow; ACK alone never proves success. Running/queued ownership checks remain
+server-owned. A local tab is removed only after confirmed deletion.
+
+Archived sessions are separated under a collapsed virtual `Archived` folder,
+then grouped by their original directory: `Archived → abc → session`.
+Normal and archived rows never share a folder, even with the same cwd.
+Search includes archives and expands matching ancestors; `H` / `L` also work
+through this nested tree. Rename/delete still target the session leaf, never
+the virtual folder. Use the archive action with `archived: false` to restore
+one to its normal directory group.
+
+On a highlighted session, `d` opens a Yes/No deletion confirmation. No is
+selected by default: Enter, `n` or Esc cancels; `y` deletes, or select Yes
+with `j` and press Enter. `D` requests deletion without that local dialog.
+This is permanent server-side session deletion, unlike `Space b d`, which
+only closes a local tab. Folder rows never delete a group. The server still
+enforces its running/queued-session rules; a row disappears only after a
+refreshed server catalog confirms removal. Renames use the same confirmed
+catalog refresh, so a submitted command is not mistaken for a saved title.
+Configure `[tree].delete`, `[tree].delete_direct` and `[confirmation]` to
+change these keys. Search input keeps `r`, `d` and `D` as literal text.
+
+### Configure shortcuts
+
+All application shortcuts use one registry for dispatch, help and inspection.
+Help groups effective bindings by scope and shows their stable configuration
+IDs. Empty optional global aliases are omitted: `keys.sessions = []` means
+there is no global tree alias, while `normal.tree = ["space e"]` still opens
+the tree in Normal mode. It does not mean that the tree has no shortcut.
+In Help, `/` opens the shortcut index ready to search by action, key or scope.
+Choosing a search result only inspects its ID; it never executes that action.
+
+Inspect without authentication or connecting to a server:
+
+```sh
+.venv/bin/python -m cc_remote.tui --list-keys
+.venv/bin/python -m cc_remote.tui --list-keys 'session tree' --json
+.venv/bin/python -m cc_remote.tui --config /path/to/tui.toml --list-keys
+```
+
+The JSON index includes ID, layer, scope, action, keys and enabled state,
+including disabled aliases. Python callers can use `KeyConfig.index(query)`
+or `KeyConfig.lookup(key, layer=...)` for reverse lookup.
+
+`[draft].send` configures Normal-mode submission; `[keys].send` is an optional
+global alias. `[reader]` owns latest-message jumps, follow, detail toggling,
+older/newer detail-page loading and the command editor. `[tree].choose`,
+`[tree_search].choose`, `[picker].choose` and `[form].confirm` are independent.
+`[file_hints].select_1` through `select_9` configure numbered file selection.
+Each dialog layer also exposes `next_field` and `previous_field`.
+An empty array disables that application's shortcut, without a hidden fallback
+to the old key. Standard Vim editing, Insert newlines and Esc-to-Normal remain
+editing grammar, not configurable application actions. Help and inline hints
+show the effective keys after overrides.
+
+Yanks briefly highlight the copied text in every Vim text surface, including
+chat, forms, question details and Markdown previews. Text objects such as
+`yi(` land at the range's start; line motions such as `yy` keep the column.
+The flash does not create a Visual selection or move focus. A character count
+confirms the local copy; terminal clipboard forwarding still depends on OSC 52
+support. Configure `[vim] yank_highlight_ms = 200` in `tui.toml` (0 disables
+the flash, maximum 5000). Document changes and session switches clear it.
+
+```toml
+[draft]
+send = ["ctrl+y"]
+
+[reader]
+details = ["x"]
+latest_assistant = ["g A"]
+```
+
+Panel shortcuts use letter-area `Space + letter` sequences instead of function
+keys. They work in main-pane Normal/Visual mode, not Insert or search inputs.
+`Space h` displays the effective bindings. Defaults are listed in
+[tui-keys.example.toml](tui-keys.example.toml).
+
+Load overrides from `~/.config/cc-remote/tui.toml` (respecting `XDG_CONFIG_HOME`),
+`CC_REMOTE_TUI_CONFIG`, or `--config PATH`, in increasing precedence. Relaunch
+only the TUI after editing; no service restart is necessary.
+
+```toml
+[keys]
+sessions = ["ctrl+y"]
+focus_draft = ["ctrl+n"] # Also next picker result
+focus_read = ["ctrl+b"] # Also previous picker result
+
+[normal]
+tree = ["space e"]
+engine = ["space c"]
+space = ["space x"]
+help = ["space h"]
+```
+
+Omitted actions keep their defaults; `[]` disables an action's binding.
+Overrides replace the old keys. Unknown actions, invalid keys, duplicates and
+prefix conflicts fail clearly rather than silently overriding each other.
+Help, footer bindings and picker hints reflect the loaded configuration.
+`[keys]` configures global workspace actions; `[normal]` configures Normal-mode
+panels, scope switching and quoting. Standard Vim editing grammar (`hjkl`,
+`ci(`, `daw`), Insert newlines and Esc-to-Normal retain their usual behavior.
+Application confirmation and navigation keys use their respective layers above.
+
+| Mode | Keys | Action |
+| --- | --- | --- |
+| Normal | `j/k`, `h/l` | Move the reading cursor |
+| Normal | `Ctrl+d/u` | Move half a screen |
+| Normal | `gg`, `G` | Loaded history start / newest output and follow |
+| Normal | `[m`, `]m` | Previous / next message |
+| Normal | `gu`, `ga` | Latest user message / latest assistant answer |
+| Normal | `o` | Load four older turns |
+| Normal | Enter | Expand a tool or fetch details of the current turn |
+| Normal | `v`, `V` | Character / line selection |
+| Visual | `y` | Copy, stay at the reading position |
+| Visual | `Space q` | Quote the selection into the draft; do not send |
+| Insert | Enter | Insert newline |
+| Draft Insert | Esc | Stay in the draft, switch to Draft Normal |
+| Reader Normal | Ctrl+o / Ctrl+i (Tab) | Previous / next jump-list position |
+| Any workspace pane | `Ctrl+j`, `Ctrl+k` | Focus draft / reading pane |
+| Draft Normal | `i/a`, `I/A` | Insert here / after cursor, or at line start / end |
+| Draft Normal | `h/j/k/l`, `w/b`, `0/$`, `gg/G` | Move within the draft |
+| Draft Normal | `o/O` | Open a line below / above and insert |
+| Draft Normal | `x`, `dd/dw/d$`, `cc/cw/c$` | Delete or change text |
+| Draft Normal | `yy`, `p`, `u`, `Ctrl+r` | Copy line, paste, undo, redo |
+| Draft Normal | `v/V`, then `d/c/y` | Select text / line, delete/change/copy |
+| Draft Normal | `ci(`, `diw`, `daw`, `yi"`, etc. | Change/delete/copy a text object |
+| Draft Normal | Enter | Submit draft / command / answer |
+| Draft Insert | Enter | Insert a newline, never send |
+| Any | `Ctrl+e` | Submit draft to the wrapper-owned deferred queue |
+| Main chat / draft | `Ctrl+x` | Stop the current turn; preserve draft and queue |
+| Any | `Ctrl+t` | Answer the visible pending question |
+| Draft | `Ctrl+Space` | Complete `/` actions or cached `$skill` names |
+| Any | `Ctrl+q` | Exit the terminal client, not the task |
+
+The stop shortcut is `[keys].stop` in `~/.config/cc-remote/tui.toml`:
+`stop = ["ctrl+x"]` selects the default; `stop = []` disables it. It works
+in main-pane Normal/Insert mode, not in the session tree or modal dialogs.
+The server confirms the terminal state; queued messages may then start.
+All application shortcuts share the configurable help/index registry; Vim
+editing motions, text objects, and mode transitions keep their Vim semantics.
+
+Focus and Vim mode are independent: Ctrl+j focuses the draft in Normal mode;
+press i to type. Esc stays in the draft so editing motions work there. Ctrl+k
+returns to the saved reading position. Reading-pane i does not change focus.
+`Space q` still quotes selected history without moving the reading cursor.
+Ctrl+j focuses the draft; Ctrl+k returns to reading. Repeating the key for the
+already-focused pane does not change its cursor or mode. In the session picker,
+these keys instead select the next/previous result, just like the arrow keys.
+Enter still inserts a newline in Draft Insert; Ctrl+j is reserved for navigation.
+
+Chat, expanded details, read-only reports, drafts, forms and modal searches
+share one Vim operator/text-object implementation. Readers allow selection and
+copying only, retaining their reading cursor. Draft `p` uses the latest shared
+register, including text just copied in chat.
+
+Text objects combine `d/c/y` (delete/change/copy) with `i/a` (inside/around).
+Supported objects are `w/W` (word/whitespace-delimited WORD), `()`, `[]`, `{}`,
+`<>`, double/single quotes and backticks; `b/B` alias parentheses/braces, and
+`p` selects a blank-line-separated paragraph.
+For example, `ci(` replaces text inside the nearest enclosing parentheses;
+`daw` deletes the current word with surrounding whitespace. `viw` selects a
+word. Nested and multiline bracket pairs work; quoted strings stay on the
+current line and honor escaped quotes. Bracket matching is textual, not a
+language-aware syntax parser. Missing/unmatched objects leave the draft intact.
+
+Use `yi(`, `ya(`, `yiw`, `yaw` directly in chat, or `vi(`/`va(` followed by
+`Space q` to quote. Counts compose: `2yaw`, `y2aw`, `2y3w`, `3yy`; `2yi(`
+selects the next enclosing bracket pair. Motions include `w/W`, `b/B`, `e/E`,
+`ge/gE`, `0/^/$`, `gg/G`, `%`, and line-local `f/F/t/T` character search with
+`;`/`,` repeat/reverse. `yf:` includes the colon; `yt:` excludes it.
+`2gg` goes to line two. Commands act only on loaded text, without model calls.
+
+A change such as `c2w` plus its Insert input is one undo transaction (`u`,
+`Ctrl+r`). Esc cancels pending operators; half commands do not cross panes,
+sessions or panels. Search pickers are the exception: type immediately,
+Ctrl+j/k or arrows select results, and one Esc closes the picker. Forms and
+the draft retain Vim Insert-to-Normal editing.
+
+This is not embedded Neovim: macros, named registers, dot-repeat changes, Ex
+commands and plugins are not implemented. Tree nodes remain list navigation;
+masked secret fields never copy their contents to the shared register.
+
+Ctrl+t opens a scoped dialog for a pending model question (a choice or a
+request for clarification). Type option numbers or permitted free text, then
+Enter to submit. Secret input is masked and never copied into the normal draft
+or transcript. It is not an ordinary new prompt. With no pending question it
+only shows a notice. Non-blocking questions also have a dedicated answer panel;
+their replies use ordinary user input through steer while the task is running.
+See "Answering questions" below. The offline demo never sends an answer.
+
+From reading Normal, `:` opens a command editor without overwriting the draft. Enter a
+command such as `new /absolute/project/path`, `stop`, `sessions`, `model `,
+`effort high`, or `engine claude`, then Normal Enter. Esc first enters editor Normal;
+another Esc cancels and restores the draft, as does Ctrl+k back to reading.
+Known local `/` commands open their panel or action form; unrecognized slash
+prefixes remain literal prompt text. `/goal resume` and `/goal pause` change
+the existing Goal status; `/goal clear` requires confirmation. `/goal text`
+sets an objective. Skills are prefetched for the engine, directory and profile;
+typing `$` reads that cache instead of starting a directory scan. Space r refreshes
+the full capability catalog if skills were changed outside this client.
+
+Copy uses Textual's OSC52 clipboard support; the terminal and tmux must permit
+it. The application clipboard is still available if the outer terminal blocks
+OSC52. Quote-to-draft is entirely internal and does not depend on clipboard
+configuration. Ctrl+s is unbound by default. Tab and Ctrl+i are the same key
+in many terminals, so both navigate forward in the reader's per-session jump
+list. Ctrl+o goes back; gg, G and message jumps record stable positions.
+New jumps after going back discard the forward branch.
+
+## Panels and shared controls
+
+| Key | Panel | Contents / controls |
+| --- | --- | --- |
+| Space h | Help | Reading, editing and panel shortcuts |
+| Space g | Goal / Plan | Goal status, budget, elapsed time, plan steps; edit/hide |
+| Space u | Usage / Context | Context, account quotas, relative daily token activity |
+| Space l | Queue | Server queue, full prompt, edit and confirmed cancellation |
+| Space a | Actions | Search every public wrapper action; validated parameter forms |
+| Space s | Settings | Effective model/permissions/search/fast mode; available models |
+| Space r | Reports | Capabilities, skills/MCP/hooks/plugins, artifacts and read results |
+| Space b b | Background | Authoritative background processes and their state |
+| Space t | Status | Native status, runtime, account and partial-read errors |
+| Space n | Notices | Retry, compatibility and other server notices |
+
+The fixed progress strip shows the Goal when present, with its Plan in Space g.
+Without a Goal it shows the current Plan. Finished progress retires at the next
+ordinary user-message boundary; an in-flight clarification does not discard an
+unfinished plan. Switching sessions and loading older history cannot replace
+a newer live plan with a stale one.
+
+Transcript headers distinguish progress, final answers, thinking summaries,
+tools and process activity. Tools/thinking collapse by default and expand with
+Enter. Enter or Esc folds an expanded block from any body line, returning to
+its header. `o` loads older turn-detail pages; `O` returns toward newer pages.
+Both directions show their configured shortcut when available. Native timestamps,
+durations, exit codes, errors and completion state
+are preserved. The footer shows the current activity and elapsed time, not an
+estimated completion time. It cannot expose reasoning the engine did not send.
+
+In a panel, Tab / Shift+Tab moves between fields; Enter selects an option.
+Esc returns to Normal before closing that panel. Searchable palettes also
+accept arrows and Ctrl+j/k. Panels stay pinned to the session they were opened
+for. Returning leaves the reading cursor and unsent draft intact.
+
+Space a uses the existing Python protocol schema, not a separate RPC implementation.
+The v66 actions include `set_codex_context` in Settings / Usage, plus
+`browse_files` and `get_turn_file_changes` in Reports. Directory and turn-file
+pages remain read-only; context settings apply on Enter.
+Their responses appear in the same session's panels, including pending context
+settings and application errors. Archived turn-file reads need the native turn
+ID and revision from `turn_file_changes`; they never start a model turn.
+Search an action, use Tab/Shift+Tab to select a named field, and press i to edit.
+Esc returns to Normal; Enter applies ordinary settings once. Destructive actions
+still lock their parameters for a second confirmation. Structured fields show
+a readable summary; explicitly press Ctrl+r (`[form].advanced`) to edit their
+raw JSON when needed. **Field help** shows the same schema's allowed
+types and enum values. Session identity is pinned; select a different session
+before editing that session. Submitted does not mean completed: the wrapper's
+reply in Reports or the session state is authoritative.
+
+Examples include rename/archive/pin/fork/worktree/migrate/rollback, interrupt,
+explicit takeover, model/effort/search/permission changes, Goal controls,
+plugin/skill/hook management and Work projects/sources/schedules. Engine and
+ownership restrictions are still enforced by the same wrapper as Web. Space s
+shows model/profile catalogs; Space r keeps file previews, diffs and other reports.
+Message-level fork/rewind forms seed the native fork/checkpoint identity from
+the turn under the reading cursor, not an invented UI row ID. Review the target
+before confirming. BTW side conversations are listed alongside main sessions;
+they recover through their private replay ring, not native history/resume.
+When a side conversation is closed elsewhere its retained text stays readable
+but its input is disabled.
+
+Normal Enter submits immediately (steers an already-running Codex turn).
+Without inventing a running native turn, ordinary sends appear as local
+`awaiting confirmation` receipts, then merge with the server echo/history by
+message ID. Rejected messages keep their text with a failed status. Ctrl+e
+transfers a deferred prompt to the wrapper immediately. A queue item starts at
+the real terminal boundary even after the TUI exits. Space l fetches full prompts
+privately instead of editing their shortened queue previews; edits preserve
+attachments and cannot change an item that already started.
+
+Use `:file /absolute/path` or `:image /absolute/path` to attach a local file to
+the current draft. `:attachments` lists them; `:detach 1` or `:detach all`
+removes unsent attachments. Limits and content validation reuse the wrapper's
+shared validator. Existing image messages show attachment markers. File
+reading requires an explicit path and rejects directories and special files.
+
+In the main draft, `Ctrl+v` attaches a PNG/JPEG/WebP image from the Linux desktop
+clipboard without sending it. Wayland uses `wl-paste` (wl-clipboard); X11 uses
+`xclip`. The TUI inherits the desktop environment; it does not guess another
+display or poll the clipboard. A plain SSH session cannot read the clipboard on
+your SSH client computer: save/transfer the image and use `:image /path` instead.
+Terminal text paste (usually Ctrl+Shift+v) works in both Normal and Insert
+mode in editable fields, retaining the mode. Normal-mode paste is one undo
+step; pasted newlines never submit the draft. Transcript readers stay locked.
+Masked secret inputs still require Insert mode.
+`Ctrl+v` in modal forms retains Textual's internal text-clipboard behavior.
+The configurable `[keys].paste_image` shortcut only stages an attachment. The
+original draft owns it even if you switch sessions before the read completes.
+
+Context and quota percentages display whole numbers. The footer uses neutral
+gray text with a muted blue accent for the model/current mode and shortcuts;
+unrestricted permissions use a muted amber warning. Separators, idle state,
+elapsed time and hint descriptions stay subdued rather than rainbow-colored.
+
+Focused, followed completions send the same exact completion receipt as Web.
+Goal dismissal is also server-owned and synchronized with other clients. A
+background session is never marked read just because this terminal is open.
+
+## Terminal boundaries
+
+Keyboard-native controls: `Space Enter` creates a session with directory,
+model, effort and allowed permission choices in one `NewSession` command.
+`Space m` selects a model, `Space p` permissions, and `Space s` all settings.
+Use j/k or arrows, Enter to open a field, i to edit, Esc for Normal and Enter to
+save text. Existing settings apply when a value is selected with Enter, without
+a JSON confirmation page. The displayed value follows the server response.
+In a new-session form, select the final “Create session” row and press Enter.
+Work keeps its wrapper-owned cwd/safe policy.
+Model catalogs are engine/account scoped; permission catalogs are also cwd
+scoped and never offer disallowed profiles. Switching cwd clears old choices.
+
+New sessions default to `~`. Enter/i on cwd opens a directory picker powered
+by the system `fzf` binary (required on the TUI machine). Type immediately;
+Ctrl+j/k or arrows select, Ctrl+Left/Right browse parent/child roots, Enter
+chooses a directory, and Ctrl+r refreshes. Listings come from the wrapper,
+not the TUI machine. Recursive results appear progressively and refresh every
+five seconds while the picker is visible, retaining the search and selection.
+Each scan allows four concurrent reads, at most 256 listings and 8192 paths;
+a limit notice asks you to narrow the root. Hidden directories are omitted
+by the wrapper, and recursion never follows symlinks outside the root.
+Selections are revalidated remotely. Esc closes the picker immediately.
+
+Panels contain hints, not buttons: r refresh, a related actions, i edit Goal, x hide
+Goal. Pickers start in Insert; Ctrl+j/k navigates results while typing.
+Only the main Space a palette lists all actions. i on Goal details enters
+Insert directly; returning from a nested screen still restores Normal.
+Esc closes search directly. Forms reuse draft Vim text objects and Esc-to-Normal.
+The `[panel]`, `[picker]` and `[form]` key layers are configurable separately
+from `[normal]` and global `[keys]`. Global send targets only the top form.
+
+`[u`/`]u` navigate user messages; `[a`/`]a` navigate AI answers (one preferred
+final answer per turn); gu/ga go to the latest. Previous-message navigation
+fetches another page at the boundary; o also loads older pages manually.
+User text has a separate background. Progress prose and final answers share
+normal text colors, including inside expanded details; thinking and tools
+remain subdued. Styles do not change copied text.
+Context shows percent used; 5h/Week show percent remaining. Missing quota
+windows are hidden rather than rendered as question marks.
+
+- This is a Vim-style subset, not Vim: no macros, named registers,
+ sentence/tag objects or full Vim operator/motion semantics yet.
+- History starts with four summary turns. Process details are fetched on
+ demand. The terminal projection keeps at most 160 blocks, with 64 KiB per
+ block; oversized blocks are explicitly marked. It is not a transcript export.
+- PDF/SVG/Mermaid and interactive Viewer rendering need a graphical Web
+ surface. `:web` gives a manual handoff address and the selected session ID;
+ it never opens a browser automatically or places a credential in the URL.
+ Markdown has a read-only preview; diffs remain source text.
+
+### Chat Markdown
+
+Assistant replies and progress prose render Markdown directly in the chat:
+bold, italic, strikethrough, headings, lists, quotes, code and tables. User
+prompts, thinking and tool logs retain their literal text. Expanded turn
+details preserve this distinction.
+
+Links show `label (complete URL)`, not a hidden URL behind a label. Tables
+also list their link targets below the grid, so Kitty URL hints can find
+complete URLs rather than column-wrapped fragments. Narrow tables switch to
+label/value rows rather than hiding columns. URLs are not fetched or opened
+automatically.
+
+Vim selection, yank and quote use the visible text, including displayed URLs,
+but skip image-only spacing. Stored messages retain their original Markdown.
+Source anchors preserve navigation across rendering and reflow. Render caches
+are bounded; excessive link expansion falls back to literal Markdown.
+
+### Mermaid terminal diagrams
+
+Mermaid remains selectable terminal text, not a raster image. Chat and local
+Markdown previews share the projection and its bounded render cache. Bare
+diagram headers and `mermaid` fences are recognized; ordinary prose/code is
+not reinterpreted. Unicode labels and `
` wrap to the available width.
+
+The projection catalog covers the families registered by Web's pinned
+Mermaid dependency, plus ZenUML text: flowcharts, sequence, class, state, ER,
+requirements, C4, architecture, blocks, swimlanes, event modeling, git graphs,
+journeys, timelines, Gantt, pie, XY, radar, quadrants, Sankey, packets,
+mindmaps, Kanban, tree views, treemaps, Ishikawa, Venn, Wardley, Cynefin and
+railroad/EBNF/ABNF/PEG. Their terminal representations differ:
+
+- Flowcharts use boxes, subgraph membership and annotated edges, including
+ bidirectional arrows, edge labels and grouped/chained endpoints.
+- Sequence diagrams use horizontal participant columns, vertical lifelines,
+ solid/dashed message arrows, self-call loops, notes and nested fragments.
+ Labels wrap by terminal cell width, including CJK text. Activation is a
+ heavy lifeline; creation/destruction bounds its lifetime. Message numbers
+ appear only with `autonumber`. If even four cells per participant cannot
+ fit, an explicit narrow-terminal record view preserves the messages.
+- UML/ER/architecture retain member blocks, relationships and cardinalities.
+- Pie/XY data use numbers and relative bars; radar/quadrants preserve values
+ and coordinates, Sankey preserves weighted edges, packets show bit ranges.
+- Hierarchies use indented trees. Gantt displays exact scheduling expressions
+ without pretending to resolve calendars/exclusions into a graphical axis.
+- Git graphs show branch/commit events; railroad views retain productions.
+
+This is not a complete Mermaid grammar validator or a pixel-equivalent port.
+Some advanced constructs still display marked `Source` rows with a Web hint;
+unknown families, malformed flowcharts and oversized input retain source.
+Family recognition alone must not be interpreted as full syntax support.
+Limits are 32 KiB source, 500 source lines/statements, 128 KiB rendered text,
+and (for flowcharts) 64 nodes / 128 edges. Shared targets/cycles use references.
+
+Click handlers, animation, CSS and graphical configuration never execute in
+the terminal. Detected directives show the configured browser shortcut next
+to the diagram: **Space B** in main Normal/Visual mode, **Alt+b** in file
+previews. They open the current session in authenticated cc-remote Web using
+its device/engine/space route, not a third-party Mermaid site. No diagram text
+or credentials enter the URL. Browser launch requires an explicit key press;
+if no browser is available, the URL is shown for manual opening. Web retains
+its existing strict security policy, so arbitrary callbacks are not enabled.
+The handoff opens the session, not an exact diagram anchor.
+
+Configure `normal.diagram_browser`, `preview.browser` and `image.browser` in
+the shortcut file; Help and inline hints read the same registry. No headless
+browser, JavaScript runtime or external renderer is needed for terminal text.
+
+### Managing the server queue
+
+Open `Space l`: `j/k` select, `Enter` reads the full prompt, and `i`
+opens it for editing. Press `Esc` for Normal, then `Enter` to save.
+Attachments are retained; rejected edits keep your text available to retry.
+`d` asks for cancellation (`y` confirms, `n`/`Esc` returns).
+`K` moves the selected message earlier; `J` moves it later.
+All these keys are configurable under `[queue]` and listed in Help.
+
+The wrapper owns the order and broadcasts changes to Web and other TUIs.
+Reordering compares the displayed queue against the server queue atomically;
+if another client changes it or a message is starting, refresh and retry.
+This requires coordinated protocol v67 Relay/Web/Wrapper/TUI deployment.
+
+### Markdown and image previews
+
+When choosing from multiple files, `Esc` in the preview returns to the
+same file-list page and selection. A second `Esc` returns to chat.
+
+`Space v` discovers local Markdown/image paths in loaded assistant messages
+(links, inline code and plain paths). One file opens directly; multiple files
+show numbered hints. Press `1–9` to open immediately, `h/l` to page, or `j/k`
+and Enter. Esc cancels. Discovery is capped at 64 paths and never scans disk.
+Reads use the existing wrapper preview API, including remote cwd resolution.
+
+Markdown is rendered as terminal text with headings, tables and code blocks.
+The reader shares Vim navigation/text objects/yanking (`yiw`, `yi(`, `v`, `y`),
+with Esc to close and `r` to reload. There are no edit/save actions or automatic
+network link/image requests. External files require an explicit `a` gesture
+in the preview to grant the wrapper's exact-file read authorization.
+
+Graphics support is probed once before Textual owns stdin. A compatible
+terminal renders images below the referring assistant paragraph, inside the
+scrolling transcript. Images are clipped at the reader boundary, never cover
+the draft, and disappear with their session or collapsed message. Original
+paths remain selectable; copying/yanking skips display-only image spacing.
+Only visible images are fetched, with a bounded cache and two concurrent reads.
+Previews fit the available width and at most 12 terminal rows, preserving aspect
+ratio. `Space v` opens the large image preview (choose a numbered file if
+there are several). It retains the original resolution: `zi` zooms in, `zo`
+zooms out, `h/j/k/l` or arrows pan, `zf` fits the whole image, and `Esc` closes.
+Zoom is relative to fit size (100%–3200%). Kitty receives the original image
+once; panning/zooming only updates its crop and placement, without resampling,
+PNG encoding, or pixel uploads per keypress. Other renderers resample the
+visible region locally. Scrolling out and back reuses up to eight parked
+inline images; eviction/session changes release their terminal resources.
+Removing one image never clears other images.
+Under tmux, the TUI probes the end-to-end Kitty graphics transport rather than
+trusting tmux's SIXEL advertisement (which can produce `SIXEL IMAGE` / `+`
+placeholders). If that transport is unavailable, a colored half-cell preview
+renders the image at reduced resolution without changing tmux settings.
+Other unsupported terminals keep paths as text without automatic file reads.
+Supported raster formats are PNG/JPEG/WebP/GIF/AVIF (first frame only), bounded
+to 5 MiB/16 million pixels and downscaled to 1024×768. See the
+[terminal compatibility notes](https://github.com/lnqs/textual-image#supported-terminals)
+for SSH/tmux protocol forwarding; `$TERM` alone is not sufficient.
+
+Configure `[normal].preview`, `[file_hints]`, `[preview]` (Markdown), and
+`[image]` independently. Image zoom chords are written as `"z i"` in TOML.
+Install the updated `requirements-tui.txt` for the optional graphics widget.
+- Browser/device administration (enrollment, push notifications, PWA install)
+ remains a Web/CLI administration concern, not a terminal session action.
+- Drafts survive session switches and socket reconnects, not process exit.
+ The reliable outbox is bounded process memory, just like the original TUI.
+ Accepted deferred queries belong to the wrapper, not this terminal.
+
+## Answering questions
+
+The strip above the draft announces pending questions. `Ctrl+t` opens a compact
+bottom panel. Blocking questions return an answer to their original request.
+Non-blocking questions use steer while Codex is running, never interrupt; an
+answer starts a new turn if the session has already become idle.
+
+Questions wrap in a scrollable reading area above the reply. Ctrl+k focuses that
+area; j/k, gg/G and Vim paging navigate it without submitting an answer. Ctrl+j
+returns to the options/reply. The reading position is shown, and switching
+questions retains each question's cursor and draft. The panel adapts to terminal
+resizes, keeping the answer area available.
+
+For async questions, select with `j/k`, arrows, or `Ctrl+j`, then confirm with
+Normal `Enter`. Use `i` for a free-text answer, `Esc` for Normal, then `Enter`.
+All questions must be confirmed before sending. `Ctrl+Left/Right` revisits
+questions;
+navigation alone does not commit a draft. The ordinary chat draft and its
+attachments remain independent. Normal `Esc` closes the panel without stopping
+the task; `Ctrl+t` reopens it. Configure the local keys in `[question]` and the
+global entry in `[keys].answer`.
+
+Async questions stay outside activity folds and are not final answers.
+Neither questions nor turn completion reset the chosen fold state. Question UI
+changes preserve the same bottom-only follow policy as streaming content.
+
+## Tests
+
+Install `requirements-dev.txt`, which includes the optional TUI dependency.
+`python -m pytest tests/test_tui*.py` runs model-free state, transport and
+headless keyboard/resize regressions, including public-event parity and an
+inventory test for all protocol commands. No live model is needed.
+The repository's complete local gate still applies before a PR.
diff --git a/docs/tui_zh.md b/docs/tui_zh.md
new file mode 100644
index 00000000..6f8ced66
--- /dev/null
+++ b/docs/tui_zh.md
@@ -0,0 +1,714 @@
+# 终端工作台操作指南
+
+中文 | [English](tui.md)
+
+终端工作台(TUI)与网页版连接同一套 relay 和 wrapper,共享会话和运行状态。
+它不会另起一个 Codex/Claude 进程,也不会抢占原生客户端的写入权。
+关闭 TUI 不会停止 wrapper 正在执行的任务或已经接收的排队消息。
+
+自动跟随只看聊天区的滚动位置:新输出到来前恰好在最底部就跟随,离开底部
+哪怕一行也不跟随。焦点在输入框还是聊天框、Vim 模式和文字选择都不改变此规则。
+跟随只滚动视窗,保留阅读光标和已选文字;发送消息也不会强行拉回底部。
+滚回底部或按 `G` 恢复跟随;缩放终端会保持底部或正在阅读的原文位置。
+每轮有一个默认展开的 **Turn details** 外层,保留 progress 正文。
+每段 progress 下连续的工具/思考活动合并成默认关闭的内层,例如
+`3 个工具调用 · 修改 2 个文件 · Bash ×2 · Edit ×1`。
+Enter 切换当前层;在工具正文任意行按 Enter/Esc 只收起内层,在 progress
+正文按则收起外层。最终回答始终留在外面,在它上面按 Enter 切换对应外层。
+新输出和回合完成不会重置手动折叠状态;旧历史的重型详情仍按需读取。
+
+底栏只保留简短模式、运行状态和耗时,过长的活动与通知按窗口宽度截断。
+完整快捷键列表统一放在 `Space h`,不再常驻占用底部空间。
+
+## 启动
+
+只想体验阅读、选择文字和独立草稿,可以运行离线演示:
+
+```sh
+.venv/bin/python -m cc_remote.tui --demo
+```
+
+演示不会登录服务器、发送命令或启动模型,发送功能是有意禁用的。
+
+实际连接时,客户端必须与 relay、wrapper 使用相同的源码/协议版本。
+版本不匹配会明确报错,不会自动降级协议。安装或运行 TUI 本身不会升级或
+重启这两个服务。
+
+在项目目录安装依赖并启动:
+
+```sh
+uv venv .venv
+uv pip install --python .venv/bin/python \
+ -r requirements.txt -r requirements-tui.txt
+.venv/bin/python -m cc_remote.tui --engine codex
+```
+
+本机使用 Linux 用户级 `cc-remote-relay.service` 时,TUI 自动识别实际端口,
+复用同一用户运行的 relay 认证配置,**不提示输入密码**。不会另存密码,
+也不会关闭网页认证或让回环地址免鉴权。只有匹配该服务的本机地址才会
+使用自动认证;显式指定其他地址或端口不会带上本机密码。服务需已允许
+回环访问(如 `ALLOW_PRIVATE_ORIGINS=1`);TUI 不会修改该安全配置。
+
+远程连接请显式传入 `--url wss://你的域名/ws`,仍按需提示输入远程密码,
+输入不回显。现有的 `LOGIN_PASSWORD`、`LOGIN_USERNAME`、`PUBLIC_ORIGIN`、
+`RELAY_URL` 和 `ENGINE` 配置继续有效。非 systemd 部署、SSH 隧道或多用户
+登录需要显式配置凭据;本地配置不可用时会给出错误,不会停在密码提示。
+不要把凭据写进命令行参数或 URL,不要通过局域网明文 HTTP 传送密码。
+
+可以额外传入一个会话 ID,直接连接该会话;`--machine ` 用于选择已获
+授权的 relay 设备。`--line-mode` 启动原来的逐行终端客户端,不依赖 Textual。
+
+## 最常用的一套操作
+
+最顶端的标签栏记录本次 TUI 中打开过的会话。在 Normal 模式下:
+
+- `H` / `L`:切到上一个/下一个已打开会话,首尾循环。
+- `Space ,`:直接输入搜索,只查已打开的会话,跨 Claude/Codex 和
+ Code/Work;`Ctrl+j/k` 或方向键选择,回车确认。
+- `Space b d`:仅关闭当前**本地标签**,切到邻近标签;最后一个关闭后
+ 显示空工作区。不会删除会话、终止任务或取消排队,可用 `Space e` 再打开。
+
+标签运行时显示青色圆圈;完成未读显示绿色圆圈,中断或失败未读显示红色圆圈。
+在获得焦点的 TUI 中打开该标签读取后,红绿标记消失,不要求滚动到最底部。
+成功完成同时遵循服务端的跨客户端已读回执;失败标记仅保存在本次 TUI 进程中,
+因为现有协议没有失败回执。只有 idle 状态变化不会被误判为完成。
+
+已加载的消息、详情、草稿和阅读位置保存在当前 TUI 内存中,普通切换不再
+重复拉取历史。断线重连、回放缺口或服务端历史失效时会重新验证缓存。
+服务端已释放的会话没有持续事件流,再打开时也会验证,避免显示过期内容。
+退出 TUI 后不保留标签列表。后台状态入口由 `Space b` 改为 `Space b b`,
+为关闭标签保留前缀;已有自定义配置也需要相应修改,避免前缀冲突。
+
+1. 按 `Space e` 展开左侧会话树,按文件夹选择会话;按 `/` 可搜索。
+2. 按 `Ctrl+j` 切到输入框,在普通模式下按 `i` 开始输入。
+3. 按 `Normal Enter` 立即发送;想排队则按 `Ctrl+e`。
+ 按 `Ctrl+x` 停止当前轮,阅读区和输入框的 Normal/Insert 模式都可用。
+ 草稿、附件和队列不会被清空;队列仍可能在当前轮停止后继续执行。
+ 会话树和弹窗内不触发停止,避免误停其他上下文的任务。
+4. 按 `Ctrl+k` 回到消息区,使用 `j/k` 阅读,`gu` 跳到最近一条自己的消息,
+ `ga` 跳到最近一条助手回复。
+5. 想引用内容时,按 `v` 选择文字,再依次按空格、`q`,将选中文本放入草稿。
+ 阅读位置不变,也不会自动发送。
+6. 按 `Ctrl+j` 到输入框补充问题,再按 `Ctrl+k` 回到原来的阅读位置,继续
+ 选择、复制或引用其他内容。
+
+`Normal` 是普通模式,用于移动、选择和编辑;`Insert` 是插入模式,用于打字;
+`Visual` 是选择模式。**焦点在哪个区域**与**该区域处于什么模式**是两回事。
+
+标签列表和当前标签在变化时、退出时自动保存,下次启动恢复。
+保存位置为 `$XDG_STATE_HOME/cc-remote/tui-tabs`,默认
+`~/.local/state/cc-remote/tui-tabs`;按 relay、设备和登录用户隔离,
+只存会话标识,不存对话内容、草稿或密码。多个终端合并各自的打开/关闭操作,
+不会因旧窗口退出而重新添加另一窗口已关闭的标签。
+恢复时先校验服务端会话目录,只连接选中的标签,不会启动所有会话的任务。
+全部关闭后下次仍为空工作区;命令行显式指定会话 ID 优先于保存的焦点。
+临时 ID、临时旁聊不持久化;离线 demo 不读写此状态。
+
+### 不依赖当前目录的启动命令
+
+推荐使用本分支的 `scripts/cc-remote-tui`,或在当前源码目录创建用户级入口:
+
+```sh
+mkdir -p "$HOME/.local/bin"
+ln -s "$PWD/scripts/cc-remote-tui" "$HOME/.local/bin/cc-remote-tui"
+cc-remote-tui
+```
+
+确保 `~/.local/bin` 在 PATH 中。入口解析符号链接,固定使用其所属源码目录和
+虚拟环境。在另一个 checkout 执行 `.venv/bin/python -m cc_remote.tui`,
+导入的是那个目录的代码,可能仍是旧的行式 TUI,登录处理也不同。
+此入口不会修改密码、认证规则或后台服务。
+
+## 会话搜索与切换
+
+对话区占满终端宽度,顶部只用一行显示当前会话标题,过长时省略显示。
+默认收起左侧会话树;`Space e` 展开/收起。树按会话的当前目录分组,
+不是磁盘文件浏览器,迁移目录后的会话只显示在新目录下。
+目录标签只显示最后一级文件夹名;同名的不同目录仍是独立组,搜索可用完整路径区分。
+`j/k` 或方向键上下移动,`h/l` 折叠/展开文件夹,Enter 打开会话。
+`gg/G` 跳到第一/最后一条可见行;`50j/50k` 移动 50 行,`50G/50gg` 跳到
+第 50 条可见行。`H` 收起所有目录,`L` 展开所有目录;树内不会切换会话标签。
+高亮会话后按 `r` 重命名:`i` 编辑,Esc 回 Normal,Enter 提交。针对的是
+高亮会话而非当前正在阅读的会话,目录不可重命名。这些键可通过 `[tree]`
+配置;搜索输入不触发树命令。`Space h` 可查看当前实际绑定。
+按 `/` 进入搜索框,可以输入标题、目录或 ID;多个词必须全部匹配。
+搜索时 `Ctrl+j/k` 或上下方向键移动,Enter 打开结果。Esc 先清除搜索回到树,
+再按 Esc 收起树,然后 Ctrl+j 转到输入框。打开会话后树保持展开,焦点回到对话区。
+默认 `Ctrl+p` 已取消,不再弹出选择窗口。
+
+高亮会话后按 `d` 弹出 Yes/No 删除确认框,默认选 No。
+直接 Enter、`n` 或 Esc 取消;按 `y` 删除,也可 `j` 选 Yes 后 Enter。
+大写 `D` 跳过本地确认框,直接请求删除该会话。这是服务端永久删除,
+不是 `Space b d` 那种仅关闭本地标签。目录行不会批量删除会话;服务端
+仍会检查运行中、排队等限制。收到服务端刷新后的列表才移除条目,
+不会发送命令就假装删除成功。重命名也会在命令完成后刷新列表。
+可通过 `[tree].delete`、`[tree].delete_direct` 和 `[confirmation]` 改键。
+搜索输入框里的 `r`、`d`、`D` 仍是普通文字,不会触发修改或删除。
+
+未归档的 Codex Code 会话会先请求原生会话树归档,等待对应请求的归档列表
+和 ACK 确认成功后,才发送永久删除。归档失败或无法确认就停止,不会跳过
+服务端检查;仅有 ACK 不代表成功。删除确认后才关闭对应本地标签。
+确认框会说明这个前置步骤;`D` 跳过本地确认,但遵循同样的服务端流程。
+
+默认引擎为 Codex;显式 `--engine claude` 或 `ENGINE=claude` 仍可覆盖。
+
+启动后收到当前范围的列表,就自动打开最近活动的会话,与网页相同;显式
+传入的会话 ID 优先。可用 `--engine codex --space work` 指定启动范围。
+
+顶部显示当前引擎、Code/Work 和会话标题。在阅读区或输入框的 Normal 模式:
+
+- `Space e`:展开/收起左侧会话树。
+- `Space c`:切换 Claude / Codex。
+- `Space w`:切换 Code / Work。
+- 树中 `/`:只搜索当前引擎、当前 Code/Work 范围内的会话。
+
+例如,Codex / Code 按 `Space w` 进入 Codex / Work,再按 `Space c`
+进入 Claude / Work。四个范围独立记住本次启动中上次看的会话;切回时恢复,
+若会话已删除则选择该范围内最近的会话。空列表不会继续显示或发送到旧范围。
+每个会话在当前 TUI 进程内都有独立的草稿、输入光标和阅读位置。后台会话
+输出、迟到的列表和其他客户端的切换通知都不会把焦点抢过去。
+
+已归档会话集中在默认收起的虚拟 `Archived` 文件夹下,再按原目录分组:
+`Archived → abc → 会话`,不再混入普通目录。同名但完整路径不同的目录
+仍独立分组。搜索包含归档会话并展开匹配路径,`H`/`L` 支持嵌套收起/展开。
+重命名、删除仍只作用于会话叶子,不能批量删除虚拟文件夹。
+通过归档操作设置 `archived: false` 后,会话回到普通目录分组。
+
+## 快捷键
+
+表中 `Ctrl+j/k` 表示 `Ctrl+j` 和 `Ctrl+k` 两个快捷键;`gg`、`gu`、
+`Space q` 等表示依次按下这些键,不是同时按。
+
+功能面板默认不再使用 F1–F10,统一使用 `Space + 字母`。这组快捷键仅在
+主界面的 Normal/Visual 模式生效,不会拦截 Insert 模式或搜索框中的文字。
+`Space h` 打开帮助,显示**实际加载的快捷键**。
+
+### 自定义快捷键
+
+所有应用功能键现在使用同一份注册表:执行、帮助、提示和查询索引保持一致。
+帮助按作用层显示实际绑定及稳定配置 ID,省略空的全局别名。
+例如 `keys.sessions = []` 只是没有全局树快捷键;
+`normal.tree = ["space e"]` 仍可在 Normal 模式打开会话树,并非没绑定。
+在帮助页按 `/` 打开快捷键索引,直接输入即可搜索功能、按键或作用层。
+确认搜索项仅显示配置 ID,不会执行该功能。
+
+不登录、不连接服务即可查看当前配置:
+
+```sh
+.venv/bin/python -m cc_remote.tui --list-keys
+.venv/bin/python -m cc_remote.tui --list-keys 'session tree' --json
+.venv/bin/python -m cc_remote.tui --config /path/to/tui.toml --list-keys
+```
+
+JSON 含 ID、作用层、动作、实际按键及是否启用,也保留已禁用的别名。
+Python 可用 `KeyConfig.index(query)` 搜索,或用
+`KeyConfig.lookup(key, layer=...)` 从按键反查对应动作。
+
+`[draft].send` 配置输入框 Normal 发送;`[keys].send` 仅是可选全局别名。
+`[reader]` 配置最新消息跳转、跟随、详情展开、新旧详情页加载和命令编辑器。
+`[tree].choose`、`[tree_search].choose`、`[picker].choose`、
+`[form].confirm` 分别控制各层确认,不互相影响。
+`[file_hints].select_1` 到 `select_9` 控制文件编号选择;
+各弹层还可以配置 `next_field`、`previous_field`。
+设为空数组就禁用对应功能键,不再有旧键的隐式回退。
+Vim 编辑操作、Insert 换行和 Esc 回 Normal 保留为编辑语法,不受这些配置影响。
+
+```toml
+[draft]
+send = ["ctrl+y"]
+
+[reader]
+details = ["x"]
+latest_assistant = ["g A"]
+```
+
+默认读取 `~/.config/cc-remote/tui.toml`(遵循 `XDG_CONFIG_HOME`)。
+也可以用 `CC_REMOTE_TUI_CONFIG` 或 `--config /path/to/tui.toml` 指定,
+优先级为命令行、环境变量、默认路径。修改后只需重新打开 TUI,不需重启服务。
+
+```toml
+[keys]
+sessions = ["ctrl+y"] # 可选:为树增加一个全局快捷键
+focus_draft = ["ctrl+n"] # 输入框 / 选择器下一项
+focus_read = ["ctrl+b"] # 阅读区 / 选择器上一项
+stop = ["ctrl+x"] # 停止当前轮;设为 [] 可禁用
+
+[normal]
+tree = ["space e"]
+engine = ["space c"]
+space = ["space x"] # 替换 Space w,避免与模型选择的 Space m 冲突
+help = ["space h"]
+```
+
+未列出的动作保留默认值,`[]` 可禁用某个快捷键;重新绑定后旧按键不再触发
+该动作。未知动作、非法按键和重复/前缀冲突会报错,不会静默覆盖。面板帮助、
+底部提示和选择器提示都跟随配置更新。完整默认配置见
+[tui-keys.example.toml](tui-keys.example.toml)。
+
+`[keys]` 配置全局工作台操作,`[normal]` 配置普通模式的功能入口和引用。
+`hjkl`、`ci(`、`daw` 等 Vim 编辑语法、Insert 换行和 Esc 回 Normal
+保持标准行为;应用确认和导航键则通过上面的对应作用层配置。
+
+所有 Vim 文本界面复制后会短暂高亮复制范围,并提示字符数,覆盖聊天、输入框、
+表单、问题详情和 Markdown 预览。`yi(` 等文本对象复制后落到范围起点;
+`yy` 等按行操作保留光标列。高亮不进入 Visual、不抢焦点,也不额外居中滚动。
+提示表示本地复制完成;终端系统剪贴板仍依赖 OSC 52 支持。
+在 `tui.toml` 的 `[vim]` 节设置 `yank_highlight_ms = 200` 调整时长,
+范围 0–5000 毫秒,0 关闭高亮。内容变化或切换会话时清除旧高亮。
+
+### 消息阅读区
+
+| 模式 | 按键 | 功能 |
+| --- | --- | --- |
+| 普通 | `j/k`、`h/l` | 移动阅读光标 |
+| 普通 | `Ctrl+d/u` | 向下/向上移动半屏 |
+| 普通 | `gg` | 跳到已加载历史的开头 |
+| 普通 | `G` | 跳到最新输出并跟随新内容 |
+| 普通 | `[m`、`]m` | 上一条/下一条消息 |
+| 普通 | `gu`、`ga` | 最近一条用户消息/助手回复 |
+| 普通 | `o` | 加载更早的四轮对话 |
+| 普通 | Enter | 展开/收起工具或整轮详情;详情内任意行均可收起 |
+| 普通 | `v`、`V` | 按字符/按行选择 |
+| 选择 | `y` | 复制,保留当前阅读位置 |
+| 选择 | `Space q` | 将选中内容引用到草稿,不发送 |
+
+### 输入框
+
+| 模式 | 按键 | 功能 |
+| --- | --- | --- |
+| 插入 | Enter | 换行,不发送 |
+| 插入 | Esc | 留在输入框,切回普通模式 |
+| 普通 | `i/a` | 在光标处/光标后进入插入模式 |
+| 普通 | `I/A` | 在行首/行尾进入插入模式 |
+| 普通 | `h/j/k/l`、`w/b`、`0/$`、`gg/G` | 在草稿内移动 |
+| 普通 | `o/O` | 在下方/上方新建一行并进入插入模式 |
+| 普通 | `x`、`dd/dw/d$`、`cc/cw/c$` | 删除或修改文字 |
+| 普通 | `yy`、`p`、`u`、`Ctrl+r` | 复制整行、粘贴、撤销、重做 |
+| 普通 | `v/V`,然后 `d/c/y` | 选择文字/整行,再删除、修改或复制 |
+| 普通 | `ci(`、`diw`、`daw`、`yi"` 等 | 修改、删除或复制文本对象 |
+| 草稿 | `Ctrl+Space` | 补全 `/` 操作或缓存中的 `$skill` 名称 |
+
+### 区域切换与提交
+
+| 按键 | 功能 |
+| --- | --- |
+| `Ctrl+o / Ctrl+i`(Tab) | 阅读区:返回/前进到跳转历史位置 |
+| `Ctrl+j` | 聚焦输入框 |
+| `Ctrl+k` | 聚焦阅读区 |
+| 输入框 Normal 下 Enter | 提交草稿、命令或问题回答 |
+| 输入框 Insert 下 Enter | 仅换行,不发送 |
+| `Ctrl+e` | 把草稿提交到 wrapper 管理的队列 |
+| `Ctrl+t` | 回答当前等待处理的问题 |
+| `Ctrl+q` | 退出 TUI,不停止后台任务 |
+
+Ctrl+j 聚焦草稿时进入普通模式,按 `i` 才开始打字。输入框内按 Esc 不会跳回
+阅读区,因此可以继续使用 Vim 编辑快捷键。Ctrl+k 返回阅读区时恢复原阅读位置。
+在阅读区按 `i` 不会切换焦点;要去输入框请用 `Ctrl+j`。
+
+多数终端将 Tab 和 Ctrl+i 视为同一个键,因此 Tab 不再默认切换区域。
+`gg`、`G`、上/下一条消息和最新回答跳转会记录位置,每个会话独立保存最多
+100 个跳转位置;Ctrl+o 返回,Ctrl+i 前进。返回后进行新跳转会清空前进分支。
+
+对已经聚焦的区域重复按 `Ctrl+j` 或 `Ctrl+k`,不会改变其光标或模式。
+但在会话搜索框内,这两个键分别选择下一项/上一项,与方向键一致。
+输入框插入模式仍用 Enter 换行,`Ctrl+j` 专门用于区域切换。
+
+### Vim 文本对象
+
+聊天记录、展开详情、Goal/报告阅读区、草稿、普通表单和弹层搜索框使用同一套
+文本对象及操作符逻辑。只读区支持选择和复制,不允许修改记录;复制后保持
+阅读位置。切到输入框按 `p` 可以粘贴刚复制的内容,不会使用旧草稿的寄存器。
+
+文本对象由操作符 `d/c/y`(删除/修改/复制)、范围 `i/a`(内部/包含周围)
+和对象组成。支持:
+
+- `w/W`:单词/以空白分隔的 WORD。
+- `()`、`[]`、`{}`、`<>`:括号配对。
+- 双引号、单引号和反引号。
+- `b/B`:分别是圆括号/花括号的别名。
+- `p`:空行分隔的段落。
+
+例如,`ci(` 修改最近一层包围光标的圆括号内部文字;`daw` 删除当前单词及
+周围空白;`viw` 选择当前单词。
+
+聊天区可直接按 `yi(`、`ya(`、`yiw`、`yaw`,不必先进入 Visual。
+`vi(`/`va(` 选择范围后仍可用 `Space q` 引用到草稿。
+操作符和移动支持计数:`2yaw`、`y2aw`、`2y3w`、`3yy`;`2yi(` 选择外一层
+括号。`w/W`、`b/B`、`e/E`、`ge/gE`、`0/^/$`、`gg/G` 和 `%` 用于移动;
+`f/F` 查找行内字符,`t/T` 停在字符旁边,`;` 重复、`,` 反向重复。
+例如 `yf:` 复制到冒号(包含冒号),`yt:` 不包含冒号,`2gg` 跳到第二行。
+这些命令只作用于当前已加载的文本,不会自动拉取完整历史或调用模型。
+
+编辑区的 `c2w`、`ci(` 等修改及后续输入合为一次撤销,`u` 撤销,`Ctrl+r`
+重做。Esc 取消未完成的操作符;切换区域、会话或弹层也不会携带半条命令。
+搜索弹层打开就能输入,Ctrl+j/k 或方向键选结果,按一次 Esc 直接关闭。
+普通表单和草稿仍保留 Vim 编辑:Esc 从 Insert 回到 Normal。
+
+这仍是内嵌 Vim 风格编辑器,不是完整 Neovim:宏、命名寄存器、`.` 重复修改、
+Ex 命令和插件尚不支持。树节点是列表导航,不是可编辑文本;遮蔽的密码字段
+保留隔离输入,不支持把秘密复制到共享寄存器。
+
+括号支持嵌套和跨行,引号对象只在当前行查找并识别转义引号。括号匹配是纯
+文本匹配,不是编程语言语法分析。找不到对应对象或括号不成对时,不修改草稿。
+
+### `Ctrl+t`:回答模型的问题
+
+`Ctrl+t` 打开当前会话待回答问题的专用窗口,例如选择题或澄清问题。输入
+选项编号或允许的自由文本,再按 Enter 提交。这不是发送一条普通新消息。
+
+秘密信息输入会隐藏内容,不会复制到普通草稿或对话记录中。没有待回答问题
+时,只显示提示。非阻塞问题同样提供专用回答面板,运行中的回答通过 steer
+补充输入;详见后面的“回答模型的问题”。离线演示不会发送回答。
+
+## 命令、技能补全与附件
+
+在阅读区普通模式按 `:`,打开独立命令编辑器,不覆盖原草稿。输入命令后
+按 `Normal Enter` 执行,例如:
+
+```text
+new /absolute/project/path
+stop
+sessions
+model
+effort high
+engine claude
+```
+
+命令编辑器内,第一次 Esc 回到编辑器普通模式,再按一次 Esc 取消并恢复
+草稿;按 Ctrl+k 返回阅读区也会取消。
+
+已识别的本地 `/` 命令打开对应面板或操作表单;未识别的斜杠前缀保留为普通
+提示词,不会被擅自当作命令处理。
+
+- `/goal resume`:恢复已有 Goal。
+- `/goal pause`:暂停已有 Goal。
+- `/goal clear`:清除 Goal,需要确认。
+- `/goal text`:把 `text` 替换为目标内容,设置 Goal。
+
+技能列表按引擎、目录和配置档案预加载;输入 `$` 时读取缓存,不临时扫描
+目录。若在客户端之外修改了技能,可用 Space r 刷新完整能力目录。
+
+附件命令:
+
+| 命令 | 功能 |
+| --- | --- |
+| `:file /absolute/path` | 给当前草稿添加本地文件 |
+| `:image /absolute/path` | 给当前草稿添加本地图片 |
+| `:attachments` | 列出当前附件 |
+| `:detach 1` | 移除第一个尚未发送的附件 |
+| `:detach all` | 移除所有尚未发送的附件 |
+
+文件大小和内容检查复用 wrapper 的共享校验器。读取文件需要明确路径,拒绝
+目录和特殊文件。传入图片显示附件标记;AI 消息中的图片路径预览见文末。
+
+在主输入框按 `Ctrl+v`,从 Linux 桌面剪贴板添加 PNG/JPEG/WebP 图片,
+**只加入草稿附件,不发送**。Wayland 需要 `wl-paste`(wl-clipboard),
+X11 需要 `xclip`;TUI 使用启动时继承的桌面环境,不猜测其他显示器,
+也不轮询剪贴板。普通 SSH 无法读取 SSH 客户端电脑的剪贴板:请先保存/
+传输图片,再用 `:image /路径` 添加。读取时切换会话,图片仍归原会话草稿。
+
+终端的文字粘贴(通常为 `Ctrl+Shift+v`)在可编辑输入框的 Normal 和 Insert
+模式都能使用,粘贴后保持原模式。Normal 模式下按 `u` 一次撤销整次粘贴;
+粘贴中的换行不会发送消息,聊天正文等只读区域仍不可修改。
+隐藏内容的敏感信息输入框仍要求先进入 Insert 模式。
+弹层表单中的 `Ctrl+v` 仍粘贴 Textual 内部文字剪贴板。
+可通过 `[keys].paste_image` 自定义图片粘贴快捷键。
+
+Context 和额度百分比统一显示整数。底栏以灰阶为主:模型、当前模式和
+快捷键用低饱和蓝色强调,完整权限用柔和琥珀色提醒;分隔符、空闲状态、
+耗时和说明文字弱化显示,不使用彩虹配色。
+
+### 复制与 tmux
+
+复制使用 Textual 的 OSC52 剪贴板功能,需要终端及 tmux 允许。外层终端
+阻止 OSC52 时,应用内部剪贴板仍然可用。`Space q` 引用到草稿是内部操作,
+不依赖系统剪贴板配置。
+
+`Ctrl+s` 默认已取消。发送使用输入框 Normal 模式下的 Enter,不依赖软件流控。
+如果自行配置额外的发送/排队快捷键,注意外层终端或 tmux 是否会截获。
+
+### 常用设置与新建会话
+
+- `Space Enter`:新建会话,`j/k` 或方向键选择字段,`Enter` 打开字段。
+ Code 可填写 wrapper 上的目录,选择模型、推理强度、权限档案、审批策略等。
+ Work 的目录和安全权限由 wrapper 管理,不提供绕过选项。
+- `Space m`:模型设置;`Space p`:权限设置;`Space s`:全部常用设置。
+ 再按 `Enter` 打开选项,`Ctrl+j/k` 或方向键选择,`Enter` 确认选择。
+- 新建会话默认目录是 `~`,目录字段按 `Enter` 或 `i` 打开 fzf 模糊选择。
+ 需在运行 TUI 的电脑安装系统 `fzf`。打开后直接输入搜索词,
+ `Ctrl+j/k` 或方向键选择,`Ctrl+Left/Right` 浏览父目录/选中的目录,
+ `Enter` 选定,`Ctrl+r` 刷新,按一次 Esc 关闭。
+ 递归读取 wrapper 端目录,逐步显示结果;弹层可见期间每五秒重新扫描,
+ 保留搜索词和选中项,能发现新建目录。每轮最多四个并行请求、
+ 256 个目录列表和 8192 条路径;达到上限会提示缩小搜索根目录。
+ 遵循 wrapper 隐藏目录规则,不递归跟随跳出根目录的符号链接,
+ 不扫描 TUI 所在电脑。选定时再次验证目录仍存在。
+- 文本字段默认 Normal,`i` 或 `ci(` 等进入编辑;`Esc` 回 Normal,
+ Normal 下 Enter 保存字段。新建表单选择最后的“Create session”项,
+ 按一次 Enter 创建,不再出现 JSON 确认页。
+- 权限选项使用该目录和账号的服务端目录,只提供允许的档案。目录变化会
+ 清空旧的权限选择并重新获取;不会默认替你开启 Full Access。
+- 已有会话选好设置后按一次 Enter 提交,显示值以服务端事件为准。
+
+### 分层键盘交互
+
+弹层底部是不可点击的按键提示,不再有按钮。`[keys]` 是全局层;
+主界面的 `[normal]` 不会穿透弹层。弹层分别使用 `[panel]`、`[picker]`、
+`[form]` 配置,`Space h` 显示实际按键。
+
+- 详情页:`r` 刷新,`a` 仅列本页相关操作;完整操作列表在主界面 `Space a`。
+ Goal 页 `i` 直接进入编辑,`x` 隐藏。搜索选项页则直接输入。
+- 选项页:打开即可输入搜索,`Ctrl+j/k` 或方向键选结果。
+ 搜索框中按一次 `Esc` 直接回上一层,不经过 Normal 和结果列表。
+- 编辑页:沿用草稿的 Vim 操作,包括 `ci(`、`daw`。`Normal Enter` 检查/提交;
+ 破坏性操作锁定参数后再按 `Enter` 确认。`?` 查看字段帮助。
+- 编辑表单的 `Esc`:Insert → Normal → 上一页;搜索页一次 Esc 返回。
+ 返回主聊天界面后是 Normal。
+ 弹层内发送快捷键只操作当前表单,不能发送背后的草稿。
+
+### 消息定位与显示
+
+v66 接口可在 `Space a` 中搜索:`set_codex_context` 设置会话上下文容量,
+也位于 Settings/Usage 面板;`browse_files`、`get_turn_file_changes`
+位于 Reports,分别只读查询目录与指定回合的文件改动页。修改容量仍需确认,
+结果在所属会话面板显示,包含待应用状态和错误。读取归档文件改动时,使用
+`turn_file_changes` 返回的原生 turn ID 和 revision;不会启动模型回合。
+
+`[u` / `]u` 跳上/下条用户消息,`[a` / `]a` 跳上/下个 AI 回答;
+`gu` / `ga` 跳最新用户消息/AI 回答。回答跳转优先每轮最终回答,不逐条跳
+进度。`[m` / `]m` 保留逐消息定位。向前跳到当前页边界时自动再读一页,
+也可用 `o` 手动加载;等待期间继续移动阅读光标会取消待执行的跳转。
+
+用户消息使用独立底色,文字进度说明与最终回答使用相同的正常正文颜色,
+展开详情后也保留该颜色;思考与工具详情仍置灰。
+光标位于最终回答时按 Enter,可直接打开同一轮折叠的详情。
+展开内容后可在其中任意位置按 `Enter` 或 `Esc` 收起并回到该块标题,
+`o` 单独读取更早的整轮详情页,`O` 向更新的详情页返回。
+存在对应分页时会显示配置中的快捷键;较迟的响应不会自动展开已收起的详情。
+滚动和选区仍属于独立阅读光标;Markdown 的复制规则见下文。
+Context 显示**已使用百分比**,5h / Week 显示**剩余百分比**;
+没有可用配额数据的窗口不显示,未知上下文显示 unavailable,不伪造 0%。
+
+## 功能面板与共享控制
+
+| 按键 | 面板 | 内容与操作 |
+| --- | --- | --- |
+| Space h | Help:帮助 | 阅读、编辑及面板快捷键 |
+| Space g | Goal / Plan:目标与计划 | Goal 状态、预算、已用时间、计划步骤;修改/隐藏 |
+| Space u | Usage / Context:用量与上下文 | 上下文、账号额度、相对每日 Token 活动 |
+| Space l | Queue:队列 | 服务端队列、完整提示词、编辑及确认取消 |
+| Space a | Actions:操作 | 搜索 wrapper 公开操作,填写并校验参数 |
+| Space s | Settings:设置 | 生效的模型、权限、搜索、快速模式及可用模型 |
+| Space r | Reports:报告 | 能力、技能、MCP、hooks、插件、产物及读取结果 |
+| Space b b | Background:后台 | 服务端报告的后台进程及状态 |
+| Space t | Status:状态 | 原生状态、运行时、账号及部分读取失败的信息 |
+| Space n | Notices:通知 | 重试、兼容性及其他服务端通知 |
+
+### 进度、思考与工具输出
+
+有 Goal 时,固定进度条显示 Goal,关联 Plan 在 Space g 中查看;没有 Goal 时,
+独立显示当前 Plan。已完成的进度在下一条普通用户消息开始时退出显示;任务
+进行中发送澄清消息,不会丢弃尚未完成的计划。切换会话或加载更早历史,不会
+用旧计划覆盖更新的实时计划。
+
+对话标题区区分进度、最终回答、思考摘要、工具和进程活动。工具和思考默认
+折叠,按 Enter 展开。原生时间戳、持续时间、退出码、错误和完成状态会保留。
+底部显示当前正在做什么以及已用时间,**不是预计还需多久完成**。
+只能展示引擎实际提供的思考摘要,不能显示引擎没有发送的推理内容。
+
+### 面板操作规则
+
+面板内用 Tab/Shift+Tab 在字段之间移动,Enter 选择选项。Esc 先退出编辑,
+再关闭当前面板。可搜索列表同时支持方向键与 `Ctrl+j/k`。
+
+面板绑定打开它时的会话,不会跟随其他焦点变化而操作错会话。关闭后保留
+阅读光标和未发送的草稿。
+
+### Space a:统一操作入口
+
+Space a 直接复用现有 Python 协议定义,不是另一套独立 RPC。搜索操作后,
+使用 Tab/Shift+Tab 选择具名字段,`i` 编辑,`Esc` 回 Normal,Enter 提交。
+普通设置不需要二次确认;破坏性操作仍会锁定参数,再按 Enter 确认。
+复杂结构默认显示可读摘要,只有主动按 Ctrl+r(`[form].advanced`)时
+才进入原始 JSON 高级编辑。详情页显示具名字段,不再默认输出协议 JSON。
+
+**Field help(字段帮助)** 展示协议允许的类型和枚举值。会话身份固定,
+不能通过修改参数切换目标;要操作另一个会话,先切换会话再打开面板。
+“已提交”不代表“已完成”,以 Reports 中 wrapper 的回复或会话实际状态为准。
+
+支持的操作包括:
+
+- 重命名、归档、置顶、派生、工作树、迁移目录、回退、中断及明确接管。
+- 模型、推理强度、搜索和权限调整。
+- Goal 管理,插件、技能及 hook 管理。
+- Work 项目、资料源和计划任务。
+
+引擎能力和会话控制权限制与网页版相同,由同一个 wrapper 执行校验。
+Space s 显示模型/配置档案目录,Space r 保留文件预览、差异和其他操作结果。
+
+按消息派生或回退时,表单从阅读光标所在轮次取得原生派生/检查点标识,
+不使用虚构的 UI 行 ID。确认前请检查目标。
+
+BTW 旁支对话与主会话一起列出,通过自身的私有回放缓冲恢复,而非原生
+历史/会话恢复接口。如果旁支对话在其他客户端被关闭,已保留文字仍可阅读,
+但不能继续输入。
+
+### 排队与编辑
+
+`Normal Enter` 立即提交;Codex 已经运行时,用于向当前轮补充指令(Steer)。
+发送后先立即显示标记为 `awaiting confirmation`(待确认)的本地消息,
+服务端回传或历史确认后按消息 ID 合并,不重复显示,也不提前假定任务已运行。
+被拒绝的消息保留原文并标记失败。
+`Ctrl+e` 则立即把待执行提示词交给 wrapper 排队,不是暂存在终端里。
+当前任务真正结束后,服务端自行启动下一项,即使 TUI 已经退出也一样。
+
+Space l 私下读取完整排队提示词供查看和编辑,不是编辑队列中的截断预览。
+编辑保留附件,已经开始执行的项目不能再修改。
+
+### 多端同步
+
+当前聚焦且处于跟随状态的会话完成后,会发送与网页版相同、精确对应此次
+完成的已读回执。Goal 的关闭状态也由服务端保存,并同步到其他客户端。
+不会因为终端开着,就把后台会话自动标为已读。
+
+## 当前终端版本的边界
+
+- 这是 Vim 风格子集,不是完整 Vim。目前不支持宏、命名寄存器、
+ 句子/标签文本对象,以及完整 Vim 操作符与移动组合语义。
+- 历史初始加载四轮摘要,进程详情按需读取。终端最多保留 160 个内容块,
+ 每块最多 64 KiB;超出限制会明确标记。这不是完整对话导出工具。
+- PDF、SVG、Mermaid 图及交互式 Viewer 需要图形化网页。`:web` 提供手动
+ 打开的地址与当前会话 ID,不会自动启动浏览器,也不会把凭据放入 URL。
+ Markdown 可用下面的只读预览;差异保留源码,不复刻网页排版。
+- 浏览器/设备管理,如设备配对、推送通知、PWA 安装,仍由网页或管理 CLI
+ 完成,不属于终端会话操作。
+- 草稿在会话切换和 WebSocket 重连后保留,但退出进程后不保留。可靠发送
+ 缓冲与原逐行 TUI 一样,是有容量限制的进程内存;已经被服务端接收的排队
+ 消息则属于 wrapper,不依赖终端存活。
+
+## 聊天正文的 Markdown
+
+AI 回答和进度说明直接渲染加粗、斜体、删除线、标题、列表、引用、代码和
+表格。用户输入、思考和工具日志保留字面原文;展开整轮详情后也保留这一区分。
+
+链接显示为「标题(完整 URL)」,不会只显示标题把地址藏起来。表格内的链接
+还会在表格下方单独列出完整地址,避免 Kitty 只能识别被列宽切断的片段。
+窗口过窄时,表格改为字段/值逐行展示,不隐藏列。不会自动访问或打开链接。
+
+Vim 选择、复制和引用使用可见文字,包含显示的 URL,但跳过图片占位空行;
+原始会话的 Markdown 不被改写。文字位置通过原文锚点关联,切换、缩放仍能
+恢复阅读位置。渲染缓存有容量限制;重复链接造成过度膨胀时回退显示字面原文。
+
+## Mermaid 终端图表
+
+继续使用可选择、复制的文字,不改成图片。聊天和 Markdown 预览共用渲染器和
+有限缓存,支持 `mermaid` 代码块及独立图表声明,中文和 `
` 随宽度换行。
+
+图种目录覆盖网页所锁定 Mermaid 版本注册的各类图,另含 ZenUML 文字入口:
+流程图、时序、类、状态、ER、需求、C4、架构、块、泳道、事件建模、Git、
+用户旅程、时间线、甘特、饼图、XY、雷达、象限、桑基、数据包、思维导图、
+看板、树、矩形树图、鱼骨、Venn、Wardley、Cynefin 和铁路语法图。
+
+- 流程图用节点框、分组归属和连线,支持双向箭头、边标签与成组端点。
+- 时序图横排参与者,显示纵向生命线、实线/虚线消息箭头、自调用回环、
+ 注释和嵌套片段。中文按终端显示宽度换行;激活状态用粗生命线表示,
+ 创建/销毁会限制生命线范围。只有 `autonumber` 才显示消息编号。
+ 如果每个参与者连四列都放不下,会明确提示窄屏记录模式,保留消息内容。
+ UML 等保留成员和关系记号。
+- 数值类以条形、数值或坐标显示;数据包显示位范围;层级类用树。
+- 甘特保留日期和依赖表达式,不伪造日历运算后的图形坐标;Git 显示事件序列。
+
+这不是完整 Mermaid 语法验证器,也不与网页图形像素等价。部分高级语句仍以
+明确标注的 `Source` 行保留并提示 Web 入口,不能把识别图种算作全量语法支持。
+未知图种、无法解析的流程图或超限内容回退源码。限制为 32 KiB 源码、500 行/
+语句、128 KiB 输出;流程图额外限制 64 个节点和 128 条边,循环用节点引用。
+
+检测到点击、动画、CSS 或图形配置时,图旁提示浏览器快捷键:聊天 Normal/
+Visual 默认 **Space B**,文件预览默认 **Alt+b**。只在主动按键后打开当前
+cc-remote 会话(包含设备、引擎和 Code/Work 路由),不会上传到外部 Mermaid
+网站,不在 URL 中放源码或凭证;没有可用浏览器时提供手动打开的地址。
+入口定位到会话,不是具体某张图。网页现有严格安全策略仍有效,不放开任意回调。
+
+可配置 `normal.diagram_browser`、`preview.browser`、`image.browser`,帮助和
+图旁提示读取同一注册表。终端文字渲染不启动无头浏览器,不运行 JavaScript。
+
+## 管理服务端队列
+
+`Space l` 打开队列:`j/k` 选择,`Enter` 查看完整内容,`i` 直接编辑。
+编辑后 `Esc` 回到 Normal,再按 `Enter` 保存;附件保留,保存被拒绝时
+保留修改后的文字。`d` 请求取消选中消息,`y` 确认,`n`/`Esc` 返回。
+`K` 上移、`J` 下移。操作键都可在 `[queue]` 配置,并可在帮助中查询。
+
+队列顺序由服务端维护并同步到网页及其他 TUI,不通过取消再发送来排序。
+如果其他客户端改变队列,或消息正在启动,服务端拒绝旧列表的排序请求,
+请按最新列表重试。需要将 Relay、Web、Wrapper、TUI 一起部署为协议 v67。
+
+## Markdown 与图片预览
+
+从多文件选择页打开预览后,`Esc` 返回原页码和选中项,再按一次 `Esc`
+才返回对话;只有一个文件、直接打开预览时仍直接返回对话。
+
+- Normal 模式按 `Space v`,识别当前已加载的 AI 消息里的 Markdown/图片路径。
+ 支持 Markdown 链接、反引号内路径和普通路径;相对路径由 wrapper 按会话目录
+ 读取,不会误读 TUI 所在电脑的同名文件,也不扫描磁盘。
+- 只有一个文件直接打开。多个文件显示 `1–9` 编号,按数字立即打开;超过九个
+ 用 `h/l` 翻页。也可 `j/k` 选择、Enter 打开;Esc 取消。最多列出 64 个路径。
+- Markdown 按标题、列表、代码和表格排版。沿用聊天区的只读 Vim 操作,
+ 包括 `j/k`、`gg/G`、`yiw`、`yi(`、`v`、`y`;Esc 关闭、`r` 重新读取。
+ 没有编辑或保存入口,不会访问文档里的网络链接或下载网络图片。
+- 启动时在 Textual 接管终端输入前检测图形协议。图片内嵌在 AI 引用它的段落
+ 下方,随消息滚动并在聊天区边缘裁剪,不再固定在底部,不覆盖输入框。
+ 切换会话、折叠消息后对应图片隐藏。原路径可复制;复制/引用会跳过图片
+ 的显示占位行。仅加载可见图片,使用有界缓存,同时最多读取两张图片。
+ 根据窗口宽度等比缩放,最高 12 行;`Space v` 仍可单独打开文件预览。
+ PNG/JPEG/WebP/GIF/AVIF 由 Pillow 解码,动画只显示首帧;每图最多 5 MiB、
+ 1600 万像素,缩略图缩至 1024×768 以内。
+- `Space v` 打开大图预览,多图时先按编号选择。预览保留原始分辨率,
+ `zi` 放大、`zo` 缩小,`h/j/k/l` 或方向键平移,`zf` 恢复整图适配,
+ `Esc` 一次关闭。缩放比例相对适配窗口大小为 100%–3200%。
+ Kitty 只上传一次原图;移动和缩放由终端处理,仅更新裁剪和显示参数,
+ 不逐键重采样、编码 PNG 或上传像素。其他图形后端在本地重采样可见区域。
+ 图片滚出再滚回时复用最多八张离屏缓存;淘汰或切换会话时释放终端资源,
+ 清理单图不会清掉其他图片。
+- tmux 下优先实测端到端 Kitty 图形链路,不再仅信任 tmux 的 SIXEL 声明,
+ 避免 `SIXEL IMAGE`/`+` 占位符。链路不可用时使用低分辨率彩色半字符
+ 图片预览,不修改 tmux 配置。其他不支持的终端保留路径,不自动读取图片;
+ Markdown 预览照常可用。
+ SSH/tmux 下依赖实际链路能否传递终端图形协议,不仅判断 `$TERM`。
+ 参见 [textual-image 终端兼容说明](https://github.com/lnqs/textual-image#supported-terminals)。
+- 目录外文件继续走 wrapper 的逐文件授权。自动图片预览不会自动批准;
+ `Space v` 打开后检查完整路径,按 `a` 才批准读取该文件。
+- `Space v` 可在 `[normal].preview` 改键;编号列表使用 `[file_hints]`,
+ Markdown 预览使用 `[preview]`,大图预览使用 `[image]`;缩放组合键在
+ TOML 里写作 `"z i"`。图形显示需要安装更新后的 `requirements-tui.txt`。
+
+## 回答模型的问题
+
+输入区上方会提示待回答的问题,按 `Ctrl+t` 打开底部问题面板。
+阻塞问题使用原来的问答请求返回答案;异步问题在任务运行中通过 steer
+补充输入,不发送中断。若任务已经空闲,回答会开始下一轮。
+
+长问题自动换行,正文可独立滚动,回答区保持可见。Ctrl+k 进入正文阅读区,
+支持 j/k、gg/G 和 Vim 翻页,阅读时按 Enter 不会提交答案。Ctrl+j 回到选项/
+回答区。显示阅读位置;切换题目保留各题光标和草稿,终端缩放不丢正文。
+
+异步问题面板中,`j/k`、方向键或 `Ctrl+j` 选择选项,Normal 模式
+`Enter` 确认;`i` 输入自由回答,`Esc` 回到 Normal,再按 `Enter` 确认。
+多问题逐个确认,全部确认后才发送;`Ctrl+左/右` 可切换问题查看或修改,
+仅切换不会提交未确认的草稿。原聊天草稿和附件不会被回答占用。
+Normal 模式 `Esc` 收起面板,不停止任务;可以再次 `Ctrl+t` 打开。
+专用按键在 `[question]` 配置,全局的 `Ctrl+t` 在 `[keys].answer` 配置。
+
+异步提问不再标为最终结果,且独立于两层活动折叠。提问或回合完成都不会
+重置折叠状态;问题出现或收起仍遵循“在底部才跟随”的阅读规则。
+
+## 测试
+
+安装 `requirements-dev.txt`,其中已包含可选的 TUI 依赖。运行:
+
+```sh
+python -m pytest tests/test_tui*.py
+```
+
+这些测试不调用模型,覆盖状态、传输、无界面键盘操作、终端尺寸变化、
+公开事件对齐以及协议命令清单。提交 PR 前仍须通过仓库要求的完整本地检查。
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 5fb84536..57850b28 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,5 @@
-r requirements.txt
+-r requirements-tui.txt
pytest==9.1.1
pytest-asyncio==1.4.0
httpx2==2.5.0
diff --git a/requirements-tui.txt b/requirements-tui.txt
new file mode 100644
index 00000000..f6507388
--- /dev/null
+++ b/requirements-tui.txt
@@ -0,0 +1,3 @@
+# Optional full-screen terminal UI (relay/wrapper do not need Textual).
+textual==6.12.0
+textual-image==0.13.2
diff --git a/scripts/cc-remote-tui b/scripts/cc-remote-tui
new file mode 100755
index 00000000..6e09afad
--- /dev/null
+++ b/scripts/cc-remote-tui
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Resolve the checkout owning this launcher, including when installed by symlink.
+set -euo pipefail
+launcher=${BASH_SOURCE[0]}
+[[ $launcher = /* ]] || launcher="$PWD/$launcher"
+for ((depth = 0; depth < 40; depth++)); do
+ source_dir=$(CDPATH='' cd -P -- "${launcher%/*}" && pwd)
+ launcher="$source_dir/${launcher##*/}"
+ [[ -L $launcher ]] || break
+ target=$(readlink "$launcher")
+ case $target in
+ /*) launcher=$target ;;
+ *) launcher="$source_dir/$target" ;;
+ esac
+done
+if [[ -L $launcher ]]; then
+ echo "Too many launcher symlinks." >&2
+ exit 1
+fi
+source_root=$(CDPATH='' cd -P -- "${launcher%/*}/.." && pwd)
+cd -- "$source_root"
+if [[ ! -x .venv/bin/python ]]; then
+ echo "Missing checkout virtualenv; see docs/tui.md." >&2
+ exit 1
+fi
+exec .venv/bin/python -m cc_remote.tui "$@"
diff --git a/tests/test_codex_session_delete.py b/tests/test_codex_session_delete.py
index 6d3ae1b1..ea831a59 100644
--- a/tests/test_codex_session_delete.py
+++ b/tests/test_codex_session_delete.py
@@ -1348,6 +1348,7 @@ async def run():
result = await machine._handle_delete_work_session(command)
assert isinstance(result, Error)
+ assert result.request_id == command.cmd_id
assert handle.calls == [("delete", "codex-thread")]
assert machine.sessions == {ctx.key: ctx}
assert store.get_by_session(
@@ -1358,6 +1359,48 @@ async def run():
asyncio.run(run())
+@pytest.mark.parametrize("failure", ["missing", "running", "queued", "rpc"])
+def test_work_delete_errors_are_correlated_to_request(monkeypatch, failure):
+ async def run():
+ machine, transport = _mk_machine()
+ store = machine._work.for_engine("codex")
+ profile_id = machine._codex_profiles.default.id
+ if failure != "missing":
+ record = store.create_session(codex_profile_id=profile_id)
+ store.bind_session(record.work_id, "codex-thread",
+ codex_profile_id=profile_id)
+ if failure in {"running", "queued"}:
+ ctx = _mk_ctx("codex-thread", "codex-thread")
+ ctx.engine, ctx.space = "codex", "work"
+ ctx.work_id, ctx.codex_profile_id = record.work_id, profile_id
+ ctx.sdk = _DeleteHandle()
+ if failure == "running":
+ ctx.state = "running"
+ else:
+ ctx.queued_query_starting_msg_id = "queued"
+ machine.sessions[ctx.key] = ctx
+
+ async def reject(*args, **kwargs):
+ raise OSError("delete unavailable")
+
+ monkeypatch.setattr(machine, "_codex_rpc_for_wire", reject)
+ command = DeleteWorkSession(
+ session_id="codex-thread", engine="codex",
+ cmd_id="delete-work-failed", client_id="client-1",
+ )
+ result = await machine._handle_delete_work_session(command)
+ assert isinstance(result, Error)
+ assert result.request_id == command.cmd_id
+ assert result.to == command.client_id
+ assert result.sid == command.session_id
+ assert result in transport.sent
+ if failure != "missing":
+ assert store.get_by_session("codex-thread",
+ codex_profile_id=profile_id) is not None
+
+ asyncio.run(run())
+
+
def test_cold_codex_delete_bypasses_full_resident_pool(
monkeypatch,
tmp_path: Path,
diff --git a/tests/test_codex_session_migration.py b/tests/test_codex_session_migration.py
index 69817d93..af836422 100644
--- a/tests/test_codex_session_migration.py
+++ b/tests/test_codex_session_migration.py
@@ -91,7 +91,7 @@ async def list_sessions(_cmd):
def test_session_migration_protocol_roundtrips_as_control_frames():
- assert PROTOCOL_VERSION == 66
+ assert PROTOCOL_VERSION == 67
command = deserialize(serialize(_command("/tmp/new-cwd")))
assert command.type == "migrate_session"
assert command.session_id == "thread-1"
diff --git a/tests/test_command_router.py b/tests/test_command_router.py
index 5d7e2b5a..23e4169a 100644
--- a/tests/test_command_router.py
+++ b/tests/test_command_router.py
@@ -19,6 +19,7 @@
EXPECTED_COMMAND_HANDLERS = {
"query": "_handle_query",
"cancel_queued_query": "_handle_cancel_queued_query",
+ "reorder_queued_queries": "_handle_reorder_queued_queries",
"get_queued_query": "_handle_get_queued_query",
"update_queued_query": "_handle_update_queued_query",
"steer": "_handle_steer",
diff --git a/tests/test_queue_reorder.py b/tests/test_queue_reorder.py
new file mode 100644
index 00000000..c93b996c
--- /dev/null
+++ b/tests/test_queue_reorder.py
@@ -0,0 +1,117 @@
+"""Queue ordering changes server ownership, never prompt identity or payload."""
+
+import asyncio
+
+import pytest
+from pydantic import ValidationError
+
+from cc_remote.protocol import (
+ Error, QueryQueueState, ReorderQueuedQueries, deserialize, serialize,
+)
+from tests.test_multisession import _mk_ctx, _mk_machine
+from tests.test_query_queue import _deferred
+
+
+def command(**updates):
+ return ReorderQueuedQueries(**{
+ "sid": "session-queue", "cmd_id": "move", "client_id": "editor",
+ "expected": ["a", "b", "c"], "order": ["b", "a", "c"],
+ **updates,
+ })
+
+
+def test_reorder_schema_is_bounded_unique_permutation():
+ cmd = command()
+ assert deserialize(serialize(cmd)) == cmd
+ for update in (
+ {"order": ["a", "a", "c"]}, {"order": ["a", "b"]},
+ {"order": ["a", "b", "other"]}, {"expected": ["a", "a", "c"]},
+ {"expected": [str(i) for i in range(1000)]},
+ ):
+ with pytest.raises(ValidationError):
+ command(**update)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("rejection", [None, "stale", "starting", "missing"])
+async def test_reorder_atomic_broadcast_and_replay(monkeypatch, rejection):
+ machine, transport = _mk_machine()
+ ctx = _mk_ctx("session-queue", "session-queue")
+ machine.sessions[ctx.key] = ctx
+ monkeypatch.setattr(machine, "_schedule_query_queue_drain", lambda _: None)
+ queries = [_deferred(mid) for mid in "abc"]
+ queries[1] = queries[1].model_copy(update={
+ "images": [{"media_type": "image/png", "data": "aGVsbG8="}],
+ "delivery": "replace",
+ })
+ ctx.queued_queries[:] = queries
+ ctx.queued_query_errors["b"] = "retryable"
+ ctx.queued_query_bytes = sum(machine._queued_query_size(q) for q in queries)
+ machine._queued_query_count = 3
+ machine._queued_query_bytes = ctx.queued_query_bytes
+ size = ctx.queued_query_bytes
+ if rejection == "starting":
+ ctx.queued_query_starting_msg_id = "a"
+ if rejection == "missing":
+ machine.sessions.clear()
+ cmd = command(expected=["b", "a", "c"] if rejection == "stale"
+ else ["a", "b", "c"])
+ await machine._process_command(cmd)
+ assert [q.msg_id for q in ctx.queued_queries] == (
+ list("abc") if rejection else list("bac")
+ )
+ assert all(any(q is original for original in queries)
+ for q in ctx.queued_queries)
+ assert ctx.queued_query_errors == {"b": "retryable"}
+ assert ctx.queued_query_bytes == machine._queued_query_bytes == size
+ assert machine._queued_query_count == 3
+ if rejection:
+ assert any(isinstance(r, Error) and r.request_id == cmd.cmd_id
+ and r.to == "editor" for r in transport.sent)
+ else:
+ state = next(r for r in transport.sent if isinstance(r, QueryQueueState))
+ assert [q.msg_id for q in state.items] == list("bac")
+ assert not state.to # Every connected Web/TUI gets the same order.
+ transport.sent.clear()
+ await machine._process_command(cmd)
+ assert not any(isinstance(r, QueryQueueState) for r in transport.sent)
+ assert [q.msg_id for q in ctx.queued_queries] == (
+ list("abc") if rejection else list("bac")
+ )
+
+
+@pytest.mark.asyncio
+async def test_reordered_queue_drains_without_client_after_current_turn():
+ machine, _ = _mk_machine()
+ ctx = _mk_ctx("session-queue", "session-queue")
+ ctx.state = "running"
+ machine.sessions[ctx.key] = ctx
+ finish = asyncio.Event()
+ launched = []
+ started = asyncio.Event()
+
+ async def turn():
+ await finish.wait()
+ await machine._set_state(ctx, "idle")
+
+ async def launch(context, query, *, launch_receipt=None):
+ launched.append(query.msg_id)
+ context.state = "running"
+ launch_receipt.set_result(True)
+ started.set()
+
+ ctx.turn_task = asyncio.create_task(turn())
+ machine._handle_immediate_query = launch
+ try:
+ for mid in "abc":
+ await machine._process_command(_deferred(mid))
+ await machine._process_command(command(order=list("cab")))
+ assert not launched
+ finish.set()
+ await asyncio.wait_for(started.wait(), 2)
+ assert launched == ["c"]
+ assert [q.msg_id for q in ctx.queued_queries] == list("ab")
+ finally:
+ finish.set()
+ await ctx.turn_task
+ await machine._discard_query_queue(ctx)
diff --git a/tests/test_tui_activity.py b/tests/test_tui_activity.py
new file mode 100644
index 00000000..76fdd8b5
--- /dev/null
+++ b/tests/test_tui_activity.py
@@ -0,0 +1,468 @@
+"""Progress-scoped tool groups and independent outer activity folds."""
+
+import json
+
+import pytest
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp, location
+from cc_remote.tui_state import (
+ Block,
+ MAX_BLOCK_CHARS,
+ SessionView,
+ TRUNCATED,
+ WorkspaceState,
+)
+from tests.test_tui_send_jumps import client
+
+
+def progress(view, identity, text):
+ view.event(
+ dict(
+ type="delta",
+ message_id=identity,
+ channel="commentary",
+ text=text,
+ turn_id="t",
+ )
+ )
+
+
+def tool(view, identity, name="Bash", inputs=None):
+ view.event(
+ dict(
+ type="tool_use",
+ tool_use_id=identity,
+ tool=name,
+ input=inputs or {"command": "private payload"},
+ turn_id="t",
+ )
+ )
+ view.event(
+ dict(
+ type="tool_result",
+ tool_use_id=identity,
+ content="private output",
+ turn_id="t",
+ )
+ )
+
+
+def seed(view):
+ view.event(dict(type="user_msg", msg_id="t", prompt="task"))
+ progress(view, "p1", "First progress")
+ tool(view, "a")
+ tool(view, "b", "Edit", {"file_path": "a.py"})
+ tool(view, "c", "Write", {"file_path": "b.py"})
+ progress(view, "p2", "Second progress")
+ tool(view, "d")
+
+
+def test_progress_groups_default_closed_and_outer_stays_open_on_completion():
+ view = SessionView()
+ seed(view)
+ text, starts = view.render()
+ groups = [b for _, b in starts if b.role == "tool_group"]
+ assert len(groups) == 2 and not any(g.expanded for g in groups)
+ assert "3 个工具调用 · 修改 2 个文件" in text
+ assert "1 个工具调用" in text
+ assert "private payload" not in text and "private output" not in text
+ assert text.index("First progress") < text.index("3 个工具调用")
+ assert text.index("3 个工具调用") < text.index("Second progress")
+ assert text.index("Second progress") < text.index("1 个工具调用")
+ groups[0].expanded = True
+ view.event(
+ dict(
+ type="delta",
+ message_id="answer",
+ channel="final",
+ text="Final",
+ turn_id="t",
+ )
+ )
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ text, _ = view.render()
+ assert view.local_details["t"].expanded and groups[0].expanded
+ assert "First progress" in text and "private payload" in text
+ assert not groups[1].expanded
+
+
+def test_folds_keep_independent_state_and_resolve_hidden_child_anchors():
+ view = SessionView()
+ seed(view)
+ view.render()
+ inner = view.tool_groups["tools:a"]
+ inner.expanded = True
+ text, starts = view.render()
+ assert "private output" in text
+ outer = view.local_details["t"]
+ outer.expanded = False
+ text, starts = view.render()
+ assert "First progress" not in text
+ assert view.resolve(("b", 12), starts, len(text)) == next(
+ start for start, b in starts if b.id == outer.id
+ )
+ tool(view, "late")
+ view.render()
+ assert inner.expanded and not outer.expanded
+ outer.expanded = True
+ text, starts = view.render()
+ assert inner.expanded and "private output" in text
+ inner.expanded = False
+ text, starts = view.render()
+ assert view.resolve(("a", 12), starts, len(text)) == next(
+ start for start, b in starts if b.id == inner.id
+ )
+
+
+def test_file_change_paths_are_deduplicated_and_hooks_are_not_tool_calls():
+ view = SessionView()
+ view.event(dict(type="user_msg", msg_id="t", prompt="task"))
+ for identity in ("f1", "f2"):
+ view.event(
+ dict(
+ type="process",
+ item_id=identity,
+ turn_id="t",
+ kind="file_change",
+ tool="apply_patch",
+ status="succeeded",
+ input={"file_paths": ["a", "b", "a"]},
+ )
+ )
+ view.event(
+ dict(
+ type="process",
+ item_id="h",
+ turn_id="t",
+ kind="hook",
+ status="succeeded",
+ title="postToolUse",
+ )
+ )
+ text, _ = view.render()
+ assert "2 个工具调用 · 修改 2 个文件 · 1 项活动" in text
+
+
+def test_partial_canonical_detail_keeps_progress_order_and_deduplicates():
+ state = WorkspaceState()
+ view = state.view("s")
+ seed(view)
+ view.revision = "r"
+ state.event(
+ dict(
+ type="turn_detail",
+ session_id="s",
+ revision="r",
+ turn_id="t",
+ events=[
+ dict(
+ type="tool_use",
+ tool_use_id="a",
+ tool="Bash",
+ input={"command": "hydrated"},
+ )
+ ],
+ )
+ )
+ text, starts = view.render()
+ assert text.index("First progress") < text.index("3 个工具调用")
+ view.tool_groups["tools:a"].expanded = True
+ text, starts = view.render()
+ assert text.count("hydrated") == 1
+ assert sum(b.id == "a" for _, b in starts) == 1
+ state.event(
+ dict(type="history_invalidated", session_id="s", revision="new")
+ )
+ assert not view.detail_blocks and not view.tool_groups
+ assert not view.local_details and not view.render()[0]
+
+
+@pytest.mark.asyncio
+async def test_cold_history_loads_only_on_demand_and_keeps_final_outside():
+ c = client()
+ view = c.workspace.view("s")
+ view.blocks = [
+ Block("earlier", "user", "Previous turn", "older"),
+ Block("answer", "assistant", "Short final", "t", "final"),
+ Block("detail:t", "detail", turn="t"),
+ ]
+ view.revision = "r"
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.pause()
+ assert not c._outbox
+ start = next(i for i, b in app.starts if b.role == "tool_group")
+ assert start < reader.text.index("Short final")
+ reader.move_cursor(location(reader.text, start))
+ await pilot.press("enter")
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert len(frames) == 1 and frames[0]["type"] == "get_turn_detail"
+ c.workspace.event(
+ dict(
+ type="turn_detail",
+ session_id="s",
+ turn_id="t",
+ revision="r",
+ events=[
+ dict(
+ type="delta",
+ message_id="p",
+ channel="commentary",
+ text="Historical progress",
+ ),
+ dict(
+ type="tool_use",
+ tool_use_id="a",
+ tool="Bash",
+ input={"command": "historical command"},
+ ),
+ dict(
+ type="delta",
+ message_id="answer",
+ channel="final",
+ text="Complete final",
+ ),
+ ],
+ )
+ )
+ app.paint()
+ await pilot.pause()
+ assert "Historical progress" in reader.text
+ assert "historical command" not in reader.text
+ assert "Short final" not in reader.text
+ assert reader.text.count("Complete final") == 1
+ assert view.local_details["t"].expanded
+ assert not view.tool_groups["tools:a"].expanded
+ text, starts = view.render()
+ assert view.resolve(("tools:history:t", 0), starts, len(text)) == next(
+ i for i, b in starts if b.id == "detail:t"
+ )
+ view.local_details["t"].expanded = False
+ text, _ = view.render()
+ assert "Historical progress" not in text
+ assert "Complete final" in text
+
+
+def test_detail_cache_cannot_resurrect_evicted_or_btw_rebuilt_turns():
+ view = SessionView()
+ seed(view)
+ view.detail_blocks["t"] = [
+ Block("old", "assistant", "Stale", "t", "commentary"),
+ ]
+ view.render()
+ view.blocks.clear()
+ assert not view.render()[0] and not view.detail_blocks
+ seed(view)
+ view.render()
+ view.event(dict(type="replay_start", sid="btw-test", rebuild=True))
+ assert not view.render()[0] and not view.tool_groups
+ assert not view.local_details
+
+
+def test_native_turn_alias_keeps_outer_and_inner_fold_choices():
+ view = SessionView()
+ seed(view)
+ for block in view.blocks:
+ block.seq = 2
+ view.render()
+ view.local_details["t"].expanded = False
+ view.collapsed_details.add("t")
+ view.tool_groups["tools:a"].expanded = True
+ view.history(
+ dict(
+ type="history",
+ revision="r",
+ live_seq=1,
+ turns=[
+ dict(
+ id="native",
+ clientMsgId="t",
+ prompt="task",
+ blocks=[],
+ )
+ ],
+ )
+ )
+ view.render()
+ assert not view.local_details["native"].expanded
+ assert "native" in view.collapsed_details
+ assert view.tool_groups["tools:a"].expanded
+ assert view.tool_groups["tools:a"].turn == "native"
+ assert "t" not in view.local_details
+ text, starts = view.render()
+ assert view.resolve(("detail:t", 0), starts, len(text)) == next(
+ i for i, b in starts if b.id == "detail:native"
+ )
+
+
+def test_typed_detail_cache_is_bounded_and_does_not_overwrite_newer_live():
+ state = WorkspaceState()
+ view = state.view("s")
+ view.revision = "r"
+ view.put(Block("answer", "assistant", "Live final", "t", "final", seq=99))
+ state.event(
+ dict(
+ type="turn_detail",
+ session_id="s",
+ turn_id="t",
+ revision="r",
+ events=[
+ dict(
+ type="delta",
+ message_id="answer",
+ channel="final",
+ text="Stale final",
+ seq=1,
+ ),
+ dict(
+ type="delta",
+ message_id="p",
+ channel="commentary",
+ text="x" * (MAX_BLOCK_CHARS * 2),
+ ),
+ ],
+ )
+ )
+ size = sum(
+ len(b.text) + len(json.dumps(b.data, ensure_ascii=False))
+ for b in view.detail_blocks["t"]
+ )
+ assert size <= MAX_BLOCK_CHARS
+ text, _ = view.render()
+ assert "Live final" in text and "Stale final" not in text
+ assert TRUNCATED.strip() in text
+ state.event(
+ dict(
+ type="turn_detail",
+ session_id="s",
+ turn_id="t",
+ revision="r",
+ reset_required=True,
+ )
+ )
+ assert "t" not in view.detail_blocks
+
+
+def test_tool_summary_does_not_retain_oversized_inputs():
+ view = SessionView(active_turn="t")
+ tool(
+ view,
+ "huge",
+ inputs={
+ "first": "x" * MAX_BLOCK_CHARS,
+ "second": "y" * MAX_BLOCK_CHARS,
+ },
+ )
+ assert view.blocks[0].data["input"] == {}
+ assert len(view.blocks[0].text) <= MAX_BLOCK_CHARS
+ assert "1 个工具调用" in view.render()[0]
+
+
+def test_async_question_is_not_hidden_with_its_turn_activity():
+ view = SessionView()
+ seed(view)
+ view.put(
+ Block(
+ "question",
+ "assistant",
+ "Where?",
+ "t",
+ "commentary",
+ data={"delivery": "async", "questions": [{"title": "Where?"}]},
+ )
+ )
+ view.render()
+ view.local_details["t"].expanded = False
+ text, _ = view.render()
+ assert "First progress" not in text and "Where?" in text
+
+
+@pytest.mark.asyncio
+async def test_two_enter_layers_close_from_any_tool_body_and_final():
+ c = client()
+ view = c.workspace.view("s")
+ view.blocks.clear()
+ seed(view)
+ view.event(
+ dict(
+ type="delta",
+ message_id="answer",
+ channel="final",
+ text="Final answer",
+ turn_id="t",
+ )
+ )
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.pause()
+ start = next(i for i, b in app.starts if b.id == "tools:a")
+ reader.move_cursor(location(reader.text, start))
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "private payload" in reader.text
+ reader.move_cursor(
+ location(reader.text, reader.text.index("private output"))
+ )
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "private payload" not in reader.text
+ assert "First progress" in reader.text
+ reader.move_cursor(
+ location(reader.text, reader.text.index("Final answer"))
+ )
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "First progress" not in reader.text
+ assert "Final answer" in reader.text
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "First progress" in reader.text
+ assert "private output" not in reader.text
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("bottom", [True, False])
+async def test_streaming_collapsed_groups_preserves_every_reader_frame(
+ monkeypatch,
+ bottom,
+):
+ c = client()
+ view = c.workspace.view("s")
+ seed(view)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(70, 26)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.move_cursor((10, 0))
+ app.query_one(Composer).focus()
+ reader.scroll_to(
+ y=reader.max_scroll_y if bottom else 12,
+ immediate=True,
+ animate=False,
+ )
+ await pilot.pause()
+ frames = []
+ original = app._display
+
+ def frame(screen, renderable):
+ if renderable is not None:
+ frames.append((reader.scroll_y, reader.max_scroll_y))
+ original(screen, renderable)
+
+ monkeypatch.setattr(app, "_display", frame)
+ for n in range(3):
+ tool(view, f"new-{n}")
+ progress(view, f"p-{n}", "More progress\n" * 5)
+ app.paint()
+ await pilot.pause()
+ assert frames
+ assert all(y == (end if bottom else 12) for y, end in frames)
+ assert "private output" not in reader.text
diff --git a/tests/test_tui_anchor_fallback.py b/tests/test_tui_anchor_fallback.py
new file mode 100644
index 00000000..d74a4fb7
--- /dev/null
+++ b/tests/test_tui_anchor_fallback.py
@@ -0,0 +1,16 @@
+"""Missing history anchors retain a caller-provided, bounded position."""
+
+from cc_remote.tui_state import Block, SessionView
+
+
+def test_missing_anchor_fallback_is_bounded_and_valid_anchor_wins():
+ view = SessionView()
+ starts = [
+ (0, Block("first", "user", "hello")),
+ (20, Block("second", "assistant", "answer")),
+ ]
+ assert view.resolve(("gone", 0), starts, 50, fallback=30) == 30
+ assert view.resolve(("gone", 0), starts, 50, fallback=90) == 50
+ assert view.resolve(("gone", 0), starts, 50, fallback=-9) == 0
+ assert view.resolve(("gone", 0), [], 0, fallback=90) == 0
+ assert view.resolve(("first", 2), starts, 50, fallback=30) == 2
diff --git a/tests/test_tui_archive_tree.py b/tests/test_tui_archive_tree.py
new file mode 100644
index 00000000..d9ffd700
--- /dev/null
+++ b/tests/test_tui_archive_tree.py
@@ -0,0 +1,118 @@
+"""Archived sessions live under a virtual root without losing cwd identity."""
+
+import pytest
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient
+from cc_remote.tui_tree import SessionExplorer, SessionTree, TreeSearch, tree_nodes
+
+
+def setup():
+ c = WorkspaceClient("ws://localhost/ws", "", "", "codex", "live")
+ for sid, cwd, archived in [
+ ("live", "/one/abc", False),
+ ("a", "/one/abc", True),
+ ("b", "/two/abc", True),
+ ]:
+ c.workspace.catalog[sid] = dict(
+ session_id=sid, cwd=cwd, tag="archived" if archived else None,
+ engine="codex", space="code", summary=sid,
+ )
+ return WorkspaceApp(c, connect=False), c
+
+
+def node(tree, identity):
+ return next(n for n in tree_nodes(tree.root) if n.data == identity)
+
+
+@pytest.mark.asyncio
+async def test_archive_is_collapsed_root_with_distinct_original_folders():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e")
+ tree = app.query_one(SessionTree)
+ assert [n.label.plain for n in tree.root.children] == ["abc", "Archived"]
+ archive = node(tree, ("archive", ""))
+ assert not archive.is_expanded
+ assert [n.label.plain for n in archive.children] == ["abc", "abc"]
+ assert {n.data for n in archive.children} == {
+ ("archived_folder", "/one/abc"), ("archived_folder", "/two/abc")}
+ assert node(tree, ("session", "a")).parent.parent is archive
+ assert node(tree, ("session", "live")).parent.parent is tree.root
+ tree.move_cursor(archive)
+ await pilot.press("r", "d", "D")
+ assert len(app.screen_stack) == 1 and not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_archive_search_opens_all_ancestors_and_clearing_restores_folds():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "slash")
+ search = app.query_one(TreeSearch)
+ search.value = "archived /two/"
+ await pilot.pause()
+ tree = app.query_one(SessionTree)
+ target = node(tree, ("session", "b"))
+ assert target.parent.is_expanded and target.parent.parent.is_expanded
+ assert tree.cursor_node is target
+ assert len(tree.root.children) == 1
+ await pilot.press("escape")
+ assert not node(tree, ("archive", "")).is_expanded
+ assert c.attached_sid == "live"
+
+
+@pytest.mark.asyncio
+async def test_nested_folds_survive_catalog_refresh_and_H_has_visible_cursor():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ tree.move_cursor(node(tree, ("session", "b")))
+ c.workspace.catalog["b"]["summary"] = "Renamed"
+ app.query_one(SessionExplorer).refresh_catalog()
+ target = node(tree, ("session", "b"))
+ assert target.label.plain == "Renamed"
+ assert target.parent.is_expanded and target.parent.parent.is_expanded
+ await pilot.press("H")
+ assert tree.cursor_node.data == ("archive", "")
+ assert tree.cursor_line >= 0
+ assert all(not n.is_expanded for n in tree.root.children)
+ await pilot.press("L", "G")
+ assert tree.cursor_node.data == ("session", "b")
+ assert c.attached_sid == "live" and not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_archive_move_and_unarchive_preserve_native_identity():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ tree.move_cursor(node(tree, ("session", "a")))
+ c.workspace.catalog["a"]["cwd"] = "/new/location"
+ app.query_one(SessionExplorer).refresh_catalog()
+ assert node(tree, ("session", "a")).parent.data == (
+ "archived_folder", "/new/location")
+ assert not any(n.data == ("archived_folder", "/one/abc")
+ for n in tree_nodes(tree.root))
+ c.workspace.catalog["a"]["tag"] = None
+ app.query_one(SessionExplorer).refresh_catalog()
+ assert node(tree, ("session", "a")).parent.data == (
+ "folder", "/new/location")
+ assert len([n for n in tree_nodes(tree.root)
+ if n.data == ("session", "a")]) == 1
+
+
+@pytest.mark.asyncio
+async def test_archive_folder_rename_and_delete_target_the_leaf():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ tree.move_cursor(node(tree, ("session", "a")))
+ await pilot.press("r")
+ assert app.screen.value == "a"
+ await pilot.press("escape", "d")
+ assert app.screen.sid == "a" and not app.screen.archive
+ await pilot.press("n")
+ assert not c._outbox
diff --git a/tests/test_tui_buffers.py b/tests/test_tui_buffers.py
new file mode 100644
index 00000000..05d30b9c
--- /dev/null
+++ b/tests/test_tui_buffers.py
@@ -0,0 +1,336 @@
+"""Local tab navigation, picker keys and history cache regression tests."""
+
+import json
+
+import pytest
+from textual.widgets import OptionList, Static
+
+from cc_remote.protocol import PROTOCOL_VERSION
+from cc_remote.tui_app import (
+ Composer,
+ Transcript,
+ WorkspaceApp,
+ WorkspaceClient,
+ seed_demo,
+)
+from cc_remote.tui_buffers import BufferPicker, SessionBuffers, tab_line
+from cc_remote.tui_modal import ModalEditor
+
+
+def client():
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", None)
+ frames = []
+
+ async def send(raw):
+ frames.append(json.loads(raw))
+ return True
+
+ c._send_raw = send
+ return c, frames
+
+
+def event(c, kind, **data):
+ c._handle({"v": PROTOCOL_VERSION, "type": kind, **data})
+
+
+def history(c, sid, **data):
+ event(c, "history", session_id=sid, turns=[], revision="r", **data)
+
+
+def reads(frames):
+ return [f["session_id"] for f in frames if f["type"] == "get_history"]
+
+
+def test_order_rekey_dedup_wrap_and_local_close():
+ tabs = SessionBuffers()
+ for sid in ("a", "tmp-b", "a", "c", "b"):
+ tabs.open(sid)
+ tabs.rekey("tmp-b", "b")
+ assert tabs.ids == ["a", "b", "c"]
+ assert tabs.neighbor("a", -1) == "c"
+ assert tabs.neighbor("c", 1) == "a"
+ assert tabs.close("b") == "c"
+ assert tabs.close("c") == "a"
+ assert tabs.close("a") is None
+ assert tabs.neighbor(None, 1) is None
+
+
+@pytest.mark.asyncio
+async def test_cached_switch_keeps_projection_and_draft_without_history_read():
+ c, frames = client()
+ await c._attach("a")
+ history(c, "a")
+ view = c.workspace.view("a")
+ view.draft = "keep draft"
+ view.anchor = ("answer", 2)
+ await c._attach("b")
+ history(c, "b")
+ event(c, "delta", sid="a", message_id="answer", text="background")
+ await c._attach("a")
+ assert reads(frames) == ["a", "b"]
+ assert c.workspace.view("a") is view
+ assert view.draft == "keep draft" and view.anchor == ("answer", 2)
+ assert any(b.text == "background" for b in view.blocks)
+ assert not any(f["type"] in {"query", "interrupt"} for f in frames)
+
+
+@pytest.mark.asyncio
+async def test_error_and_stale_history_do_not_cache():
+ c, frames = client()
+ await c._attach("a")
+ history(c, "a", error="unavailable", authoritative=False)
+ await c._attach("a")
+ assert reads(frames) == ["a", "a"]
+ event(c, "history_invalidated", session_id="a", revision="new")
+ history(c, "a") # Old page must not undo the invalidation barrier.
+ assert "a" not in c.cached_history
+ await c._attach("a")
+ assert reads(frames) == ["a", "a", "a"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reset", ["rebuild", "truncated", "generation"])
+async def test_replay_gaps_and_new_wrapper_invalidate_cached_tabs(reset):
+ c, frames = client()
+ await c._attach("a")
+ history(c, "a", generation="old")
+ await c._attach("b")
+ history(c, "b", generation="old")
+ if reset == "generation":
+ event(c, "snapshot", sid="b", generation="new")
+ assert not c.cached_history
+ else:
+ event(c, "replay_start", sid="a", **{reset: True})
+ assert "a" not in c.cached_history
+ event(c, "replay_end", sid="a", to_seq=0)
+ await c._attach("a")
+ assert reads(frames) == ["a", "b", "a"]
+
+
+@pytest.mark.asyncio
+async def test_reconnect_retains_views_but_revalidates_background_tab():
+ c, frames = client()
+ await c._attach("a")
+ history(c, "a")
+ view = c.workspace.view("a")
+ await c._attach("b")
+ history(c, "b")
+ # Commands were acknowledged before the connection dropped.
+ c._outbox.clear()
+ c._outbox_bytes = 0
+ await c._recovery_preamble()
+ assert not c.cached_history
+ assert c.workspace.view("a") is view
+ await c._attach("a")
+ assert reads(frames)[-1] == "a" and reads(frames).count("a") == 2
+
+
+def test_rekey_updates_tab_and_cache_identity_without_duplicates():
+ c, _ = client()
+ c.buffers.open("tmp-a")
+ c.buffers.open("a")
+ c.cached_history.add("tmp-a")
+ c.attached_sid = "tmp-a"
+ c.workspace.view("tmp-a").draft = "draft"
+ event(c, "session_rekey", old_key="tmp-a", session_id="a")
+ assert c.buffers.ids == ["a"]
+ assert c.attached_sid == "a"
+ assert c.cached_history == {"a"}
+ assert c.workspace.view("a").draft == "draft"
+
+
+@pytest.mark.asyncio
+async def test_catalog_eviction_revalidates_but_resident_tabs_stay_cached():
+ c, frames = client()
+ for sid in ("a", "b"):
+ await c._attach(sid)
+ history(c, sid)
+ event(
+ c,
+ "session_list",
+ engine="codex",
+ sessions=[
+ {"session_id": "a", "state": None},
+ {"session_id": "b", "state": "idle"},
+ ],
+ )
+ assert c.cached_history == {"b"}
+ await c._attach("b")
+ assert reads(frames) == ["a", "b"]
+ await c._attach("a")
+ assert reads(frames) == ["a", "b", "a"]
+
+
+@pytest.mark.asyncio
+async def test_tab_keys_picker_search_and_close_preserve_session_and_draft():
+ c, frames = client()
+ seed_demo(c)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(100, 32)) as pilot:
+ assert c.buffers.ids == ["demo-review"]
+ assert app.query_one("#session-tabs", Static).size.height == 1
+ await pilot.press("ctrl+j", "i", "H", "L", "escape")
+ assert app.query_one(Composer).text == "HL" # Insert is not navigation.
+ await pilot.press("ctrl+k", "g", "g", "j", "j")
+ reading_cursor = app.query_one(Transcript).cursor_location
+ await app.attach_session("demo-codex-work")
+ await pilot.press("H")
+ assert c.attached_sid == "demo-review"
+ assert app.query_one(Composer).text == "HL"
+ assert app.query_one(Transcript).cursor_location == reading_cursor
+ await pilot.press("L")
+ assert c.attached_sid == "demo-codex-work"
+ await pilot.press("space", "comma")
+ assert isinstance(app.screen, BufferPicker)
+ search = app.screen.query_one(ModalEditor)
+ assert search.vim_mode == "INSERT" and app.focused is search
+ listing = app.screen.query_one(OptionList)
+ assert listing.option_count == 2 # Not all demo catalog sessions.
+ await pilot.press("ctrl+j")
+ assert listing.highlighted == 1
+ await pilot.press("ctrl+k")
+ assert listing.highlighted == 0
+ await pilot.press("down", "up")
+ assert listing.highlighted == 0
+ await pilot.press(*"demo-review", "enter")
+ assert len(app.screen_stack) == 1
+ assert c.attached_sid == "demo-review"
+ assert app.query_one(Transcript).vim_mode == "NORMAL"
+ await pilot.press("space", "b", "d")
+ assert c.buffers.ids == ["demo-codex-work"]
+ assert c.attached_sid == "demo-codex-work"
+ assert c.workspace.view("demo-review").draft == "HL"
+ assert c.workspace.view("demo-review").state == "running"
+ await pilot.press("space", "b", "d")
+ assert c.attached_sid is None and c.buffers.ids == []
+ app.paint()
+ assert not c.buffers.ids
+ await pilot.press("space", "comma", *"missing", "enter")
+ assert isinstance(app.screen, BufferPicker)
+ assert app.screen.query_one(OptionList).option_count == 0
+ assert not frames and not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_close_running_tab_only_sends_neighbor_focus_not_mutations():
+ c, frames = client()
+ seed_demo(c)
+ c.demo = False
+ c.buffers.open("demo-codex-work")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ current = c.workspace.view("demo-review")
+ current.queue = [{"msg_id": "queued", "preview": "keep queued"}]
+ await pilot.press("space", "b", "d")
+ assert c.attached_sid == "demo-codex-work"
+ assert current.state == "running"
+ assert current.queue[0]["msg_id"] == "queued"
+ assert {f["type"] for f in frames} == {
+ "switch_session",
+ "get_history",
+ }
+ frames.clear()
+ await pilot.press("space", "b", "d")
+ assert not frames # Last-tab close is entirely local.
+
+
+@pytest.mark.asyncio
+async def test_rekey_keeps_visible_draft_without_reopening_temporary_tab():
+ c, _ = client()
+ seed_demo(c)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("draft not yet painted")
+ event(c, "session_rekey", old_key="demo-review", session_id="new-id")
+ app.paint()
+ await pilot.pause()
+ assert c.buffers.ids == ["new-id"]
+ assert "demo-review" not in c.workspace.views
+ assert editor.text == "draft not yet painted"
+ assert c.workspace.view("new-id").draft == editor.text
+
+
+def test_many_tabs_keep_active_visible_and_titles_safe():
+ c, _ = client()
+ for n in range(50):
+ sid = f"s{n}"
+ c.buffers.open(sid)
+ c.workspace.catalog[sid] = {"summary": f"项目{n}\n[bold]"}
+ c.attached_sid = "s49"
+ line = tab_line(c, 80)
+ assert "项目49" in line.plain and "\n" not in line.plain
+ assert any(span.style == "bold white on #334466" for span in line.spans)
+
+
+def tab_client(titles, active=0):
+ c, _ = client()
+ for index, title in enumerate(titles):
+ sid = f"tab-{index}"
+ c.buffers.open(sid)
+ c.workspace.catalog[sid] = {"summary": title}
+ c.attached_sid = f"tab-{active}"
+ return c
+
+
+def test_short_tabs_use_available_cells_not_a_fixed_slot_count():
+ titles = ["cc-remote", "airmux", "opensbi ipi", "mail", "codeg", "notes"]
+ c = tab_client(titles, active=1)
+ line = tab_line(c, 100)
+ assert all(title in line.plain for title in titles)
+ assert line.cell_len <= 100
+ assert "‹" not in line.plain and "›" not in line.plain
+
+
+@pytest.mark.parametrize("width", [0, 1, 5, 8, 20, 40, 80, 120, 200])
+@pytest.mark.parametrize("active", [0, 7, 11])
+def test_tab_width_accounts_for_unicode_indexes_status_and_overflow(width, active):
+ c = tab_client([f"项目{i} · cafe\u0301 · 🐟" for i in range(12)], active)
+ for sid in c.buffers.ids:
+ c.workspace.view(sid).state = "running"
+ line = tab_line(c, width)
+ assert line.cell_len <= width
+ if width:
+ assert (line.style == "bold white on #334466"
+ or any(s.style == "bold white on #334466" for s in line.spans))
+ if width >= 40:
+ assert f" {active + 1} 项目{active}" in line.plain
+ assert " ●" in line.plain
+
+
+def test_long_titles_are_ellipsized_and_neighbor_windows_stay_contiguous():
+ c = tab_client([f"session-{i}-" + "long" * 50 for i in range(8)], active=4)
+ line = tab_line(c, 100)
+ assert "…" in line.plain and " 5 session-4-" in line.plain
+ assert line.plain.startswith("‹ ") and line.plain.endswith(" ›")
+ assert line.cell_len <= 100
+ narrow = tab_line(c, 16)
+ assert " 5 " in narrow.plain and narrow.cell_len <= 16
+ assert narrow.plain.startswith("‹ ") and narrow.plain.endswith(" ›")
+
+
+@pytest.mark.asyncio
+async def test_tab_bar_reflows_after_terminal_resize_without_navigation():
+ c, frames = client()
+ seed_demo(c)
+ for i in range(10):
+ sid = f"short-{i}"
+ c.buffers.open(sid)
+ c.workspace.catalog[sid] = {"summary": f"tab{i}"}
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(60, 30)) as pilot:
+ tabs = app.query_one("#session-tabs", Static)
+ narrow = tabs.content.plain
+ await pilot.resize_terminal(160, 30)
+ await pilot.pause()
+ app.paint()
+ wide = tabs.content.plain
+ assert wide.count("│") > narrow.count("│")
+ assert all(f"tab{i}" in wide for i in range(10))
+ await pilot.resize_terminal(40, 30)
+ await pilot.pause()
+ app.paint()
+ assert tabs.content.cell_len <= 40
+ assert any(s.style == "bold white on #334466" for s in tabs.content.spans)
+ assert c.attached_sid == "demo-review"
+ assert not frames and not c._outbox
diff --git a/tests/test_tui_catalog_actions.py b/tests/test_tui_catalog_actions.py
new file mode 100644
index 00000000..b74ef59e
--- /dev/null
+++ b/tests/test_tui_catalog_actions.py
@@ -0,0 +1,70 @@
+"""Catalog mutations must refresh the projected tree after their real ACK."""
+
+import asyncio
+import json
+
+import pytest
+
+from cc_remote import protocol as p
+from cc_remote.tui_app import WorkspaceClient
+
+
+def frames(client):
+ return [json.loads(raw) for raw, _ in client._outbox.values()]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("engine,space", [
+ ("codex", "code"), ("claude", "code"),
+ ("codex", "work"), ("claude", "work"),
+])
+@pytest.mark.parametrize("kind,extra", [
+ (p.RenameSession, {"title": "New title"}),
+ (p.DeleteSession, {}),
+ (p.ArchiveSession, {"archived": True}),
+ (p.PinSession, {"pinned": True}),
+])
+async def test_mutation_ack_refreshes_catalog_without_accepting_stale_lists(
+ engine, space, kind, extra,
+):
+ client = WorkspaceClient("ws://localhost/ws", "", "", engine, None,
+ space=space)
+ client.restore_pending = False
+ await client._send(p.ListSessions(engine=engine, space=space))
+ initial = client.session_catalog_requests[engine, space]
+ row = dict(session_id="target", summary="Old title", cwd="/project")
+ if kind is p.DeleteSession:
+ row["tag"] = "archived" # Exercise the deletion phase, not archival.
+ listing = dict(type="session_list", engine=engine, space=space,
+ request_id=initial, sessions=[row])
+ client._handle(listing)
+ await client.execute_action(kind(
+ session_id="target", engine=engine, space=space, **extra,
+ ))
+ await asyncio.sleep(0) # Mutations may run as non-blocking workflows.
+ mutation = frames(client)[-1]
+ updated = dict(row, summary="New title")
+ result = [] if kind is p.DeleteSession else [updated]
+ client._handle(dict(listing, request_id=mutation["cmd_id"],
+ sessions=result))
+ assert client.workspace.catalog["target"]["summary"] == "Old title"
+ ack = dict(type="command_ack", cmd_id=mutation["cmd_id"],
+ client_id=client.client_id, to=client.client_id)
+ client._handle(dict(ack, client_id="other", to="other"))
+ assert not client.catalog_dirty
+ client._handle(ack)
+ assert client.catalog_dirty == {(engine, space)}
+ client._handle(dict(listing, sessions=[]))
+ assert "target" in client.workspace.catalog
+ await client._flush_history_refreshes()
+ refresh = client.session_catalog_requests[engine, space]
+ assert refresh != initial
+ client._handle(dict(listing, request_id=refresh, sessions=result))
+ expected = {item["session_id"]: item["summary"] for item in result}
+ assert {sid: item["summary"]
+ for sid, item in client.workspace.catalog.items()} == expected
+ client._handle(listing)
+ client._handle(ack)
+ assert not client.catalog_dirty # Duplicate ACK cannot refetch forever.
+ assert {sid: item["summary"]
+ for sid, item in client.workspace.catalog.items()} == expected
diff --git a/tests/test_tui_clipboard_chrome.py b/tests/test_tui_clipboard_chrome.py
new file mode 100644
index 00000000..125d5256
--- /dev/null
+++ b/tests/test_tui_clipboard_chrome.py
@@ -0,0 +1,264 @@
+"""Clipboard isolation and low-noise terminal status presentation."""
+
+import asyncio
+import base64
+import io
+import sys
+
+import pytest
+from PIL import Image
+from rich.console import Console
+from textual import events
+from textual.widgets import Static
+
+from cc_remote import tui_clipboard as cb
+from cc_remote.tui_app import Composer, WorkspaceApp, WorkspaceClient
+from cc_remote.tui_chrome import ACCENT, WARNING, settings_text, status_text
+from cc_remote.tui_presentation import SessionPresentation
+from cc_remote.tui_settings import TextValue
+
+
+def png():
+ stream = io.BytesIO()
+ Image.new("RGB", (4, 4)).save(stream, format="PNG")
+ return stream.getvalue()
+
+
+def attachment():
+ return {
+ "name": "clipboard.png",
+ "image": True,
+ "content": {
+ "media_type": "image/png",
+ "data": base64.b64encode(png()).decode(),
+ },
+ }
+
+
+def app():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ return WorkspaceApp(client, connect=False)
+
+
+@pytest.mark.parametrize("value, expected", [(43.2055, "43%"), (99.8, "100%")])
+def test_percentages_are_whole_numbers(value, expected):
+ p = SessionPresentation()
+ p.context = {"percentage": value}
+ p.rates = {
+ "codex": {
+ "secondary": {
+ "used_percent": 100 - value,
+ "window_duration_mins": 10080,
+ }
+ }
+ }
+ label = p.usage_label()
+ assert f"{expected} used" in label
+ assert f"{expected} remaining" in label
+ assert "." not in label
+
+
+def test_settings_and_status_have_semantic_not_rainbow_styles():
+ p = SessionPresentation()
+ p.settings = {
+ "model": {"model": "gpt-6-astra"},
+ "effort": {"effort": "high"},
+ "perm": {"mode": "never"},
+ "permission_profile": {"profile": ":danger-full-access"},
+ "web_search": {"mode": "live"},
+ "collaboration_mode": {"mode": "default"},
+ "fast": {"on": True},
+ }
+ rendered = settings_text(p)
+ console = Console()
+ assert rendered.plain == p.settings_label()
+ assert rendered.get_style_at_offset(console, 0).bold
+ assert (
+ rendered.get_style_at_offset(
+ console, rendered.plain.index("never")
+ ).color.triplet.hex
+ == WARNING
+ )
+ status = status_text(
+ "DRAFT INSERT · idle · writable · queued 0 · completed · 11m 35s\n"
+ "Space h: help · Shortcut: space …"
+ )
+ assert status.get_style_at_offset(console, 0).bold
+ assert status.get_style_at_offset(console, status.plain.index("idle")).dim
+ colors = {s.style for s in status.spans}
+ assert colors <= {f"bold {ACCENT}", ACCENT, "dim"}
+
+
+def test_provider_selection_is_display_scoped(monkeypatch):
+ monkeypatch.delenv("DISPLAY", raising=False)
+ monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
+ monkeypatch.setattr(cb.shutil, "which", lambda exe: "/bin/" + exe)
+ assert cb.providers() == []
+ monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-test")
+ monkeypatch.setenv("DISPLAY", ":9")
+ providers = cb.providers()
+ assert providers[0][0] == ["/bin/wl-paste", "--list-types"]
+ assert providers[1][0][-1] == "TARGETS"
+
+
+@pytest.mark.asyncio
+async def test_clipboard_reads_only_supported_mime_and_validates(monkeypatch):
+ monkeypatch.setattr(cb, "providers", lambda: [(["list"], ["read"])])
+ calls = []
+
+ async def read(args, limit):
+ calls.append(args)
+ return b"text/plain\nimage/png\n" if args == ["list"] else png()
+
+ monkeypatch.setattr(cb, "read_command", read)
+ result = await cb.read_clipboard_image()
+ assert result == attachment()
+ assert calls == [["list"], ["read", "image/png"]]
+
+
+@pytest.mark.asyncio
+async def test_non_image_does_not_fall_back_to_another_desktop(monkeypatch):
+ monkeypatch.setattr(cb, "providers", lambda: [(["one"], []), (["two"], [])])
+ calls = []
+
+ async def read(args, limit):
+ calls.append(args)
+ return b"text/plain\n"
+
+ monkeypatch.setattr(cb, "read_command", read)
+ with pytest.raises(cb.NoClipboardImage):
+ await cb.read_clipboard_image()
+ assert calls == [["one"]]
+
+
+@pytest.mark.asyncio
+async def test_clipboard_rejects_invalid_and_empty_images(monkeypatch):
+ monkeypatch.setattr(cb, "providers", lambda: [(["list"], ["read"])])
+ for content in (b"", b"not a PNG"):
+
+ async def read(args, limit):
+ return b"image/png" if args == ["list"] else content
+
+ monkeypatch.setattr(cb, "read_command", read)
+ with pytest.raises(ValueError):
+ await cb.read_clipboard_image()
+
+
+@pytest.mark.asyncio
+async def test_clipboard_helper_output_exit_timeout_and_cancellation(
+ monkeypatch,
+):
+ assert (
+ await cb.read_command([sys.executable, "-c", "print('ok')"], 10)
+ == b"ok\n"
+ )
+ with pytest.raises(ValueError, match="size limit"):
+ await cb.read_command([sys.executable, "-c", "print('x' * 10000)"], 100)
+ with pytest.raises(ValueError, match="unavailable"):
+ await cb.read_command([sys.executable, "-c", "exit(1)"], 10)
+ monkeypatch.setattr(cb, "TIMEOUT", 0.05)
+ with pytest.raises(ValueError, match="timed out"):
+ await cb.read_command(
+ [sys.executable, "-c", "import time; time.sleep(5)"], 10
+ )
+ monkeypatch.setattr(cb, "TIMEOUT", 3)
+ task = asyncio.create_task(
+ cb.read_command(
+ [sys.executable, "-c", "import time; time.sleep(5)"],
+ 10,
+ )
+ )
+ await asyncio.sleep(0.05)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+
+@pytest.mark.asyncio
+async def test_image_paste_stages_only_and_keeps_text_paste(monkeypatch):
+ application = app()
+
+ async def image():
+ return attachment()
+
+ monkeypatch.setattr("cc_remote.tui_app.read_clipboard_image", image)
+ async with application.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "ctrl+v")
+ await pilot.pause()
+ view = application.client.workspace.view("s")
+ assert len(view.attachments) == 1
+ assert not application.client._outbox
+ editor = application.query_one("#composer", Composer)
+ application.post_message(events.Paste("ordinary pasted text"))
+ await pilot.pause()
+ assert editor.text == "ordinary pasted text"
+ assert application.query_one("#settings", Static).render() is not None
+ assert not application.clipboard_busy
+
+
+@pytest.mark.asyncio
+async def test_paste_stays_on_original_session_after_switch_and_rekey(
+ monkeypatch,
+):
+ application = app()
+ gate = asyncio.Event()
+
+ async def image():
+ await gate.wait()
+ return attachment()
+
+ monkeypatch.setattr("cc_remote.tui_app.read_clipboard_image", image)
+ async with application.run_test() as pilot:
+ await pilot.press("ctrl+j", "ctrl+v", "ctrl+v")
+ application.client._handle(
+ {
+ "type": "session_rekey",
+ "old_key": "s",
+ "session_id": "renamed",
+ }
+ )
+ application.client.attached_sid = "other"
+ application.paint()
+ gate.set()
+ await pilot.pause()
+ assert (
+ len(application.client.workspace.view("renamed").attachments) == 1
+ )
+ assert not application.client.workspace.view("other").attachments
+ assert not application.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_clipboard_limits_and_missing_display_leave_draft_intact(
+ monkeypatch,
+):
+ application = app()
+ view = application.client.workspace.view("s")
+ view.attachments = [attachment() for _ in range(8)]
+
+ async def image():
+ return attachment()
+
+ monkeypatch.setattr("cc_remote.tui_app.read_clipboard_image", image)
+ await application.paste_clipboard_image("s")
+ assert len(view.attachments) == 8
+ assert "too many" in application.client.notice
+ monkeypatch.setattr(cb, "providers", lambda: [])
+ with pytest.raises(ValueError, match="No desktop clipboard"):
+ await cb.read_clipboard_image()
+
+
+@pytest.mark.asyncio
+async def test_modal_ctrl_v_keeps_textual_text_paste(monkeypatch):
+ application = app()
+
+ async def forbidden():
+ raise AssertionError("A modal must not read the desktop clipboard")
+
+ monkeypatch.setattr("cc_remote.tui_app.read_clipboard_image", forbidden)
+ async with application.run_test() as pilot:
+ application.push_screen(TextValue("Value", ""))
+ await pilot.pause()
+ application.copy_to_clipboard("local text")
+ await pilot.press("i", "ctrl+v")
+ assert application.screen.query_one(Composer).text == "local text"
diff --git a/tests/test_tui_clocks.py b/tests/test_tui_clocks.py
new file mode 100644
index 00000000..52b253c7
--- /dev/null
+++ b/tests/test_tui_clocks.py
@@ -0,0 +1,111 @@
+"""Elapsed time must advance without detail fetches or transcript reloads."""
+
+import pytest
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, Transcript
+from cc_remote.tui_state import Block
+from cc_remote.tui_presentation import TurnDisplay
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("expanded", [False, True])
+async def test_elapsed_ticks_without_events_fetches_or_document_reload(
+ monkeypatch, expanded
+):
+ now = [1009.0]
+ monkeypatch.setattr("cc_remote.tui_app.time.time", lambda: now[0])
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ view = client.workspace.view("s")
+ for tid in ("first", "second"):
+ view.presentation.turns[tid] = TurnDisplay(started=1000)
+ view.put(
+ Block(tid, "detail", "body text\nmore text", tid, expanded=expanded)
+ )
+ app = WorkspaceApp(client, connect=False)
+ async with app.run_test():
+ reader = app.query_one(Transcript)
+ assert reader.text.count("Processing · 9s") == 2
+ app.stop_following()
+ row, _ = reader.document.get_location_from_index(app.starts[1][0])
+ reader.selection = Selection((row + 1, 1), (row + 1, 5))
+ chosen, selection = reader.selected_text, reader.selection
+ app.remember()
+ original_render = view.render
+ reloads = []
+ original_load = reader.load_text
+
+ def load(text):
+ reloads.append(text)
+ original_load(text)
+
+ monkeypatch.setattr(reader, "load_text", load)
+ for value in (1010.0, 1011.0, 1060.0, 1121.0):
+ now[0] = value
+ app.paint()
+ expected, starts = original_render()
+ assert reader.text == expected
+ assert app.starts == starts
+ assert reader.selection == selection
+ assert reader.selected_text == chosen
+ assert "Processing · 2m 1s" in reader.text
+ assert not reloads and not client._outbox
+ assert not reader.history.undo_stack
+ # The next real data update uses the rebased block anchors, too.
+ view.version += 1
+ app.paint()
+ assert reader.selected_text == chosen
+
+
+@pytest.mark.asyncio
+async def test_clock_stops_at_terminal_and_only_ticks_once_per_second(
+ monkeypatch,
+):
+ now = [1010.0]
+ monkeypatch.setattr("cc_remote.tui_app.time.time", lambda: now[0])
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ view = client.workspace.view("s")
+ turn = TurnDisplay(started=1000)
+ view.presentation.turns["turn"] = turn
+ view.put(Block("d", "detail", "body", "turn"))
+ app = WorkspaceApp(client, connect=False)
+ async with app.run_test():
+ reader = app.query_one(Transcript)
+ calls = []
+ original = view.block_header
+
+ def header(*args, **kwargs):
+ calls.append(kwargs.get("now"))
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(view, "block_header", header)
+ for value in (1011.0, 1011.1, 1011.9):
+ now[0] = value
+ app.paint()
+ assert len(calls) == 1
+ assert "Processing · 11s" in reader.text
+ turn.status, turn.ended = "completed", 1012.0
+ view.version += 1
+ app.paint()
+ assert "completed · 12s" in reader.text
+ calls.clear()
+ now[0] = 9999.0
+ app.paint()
+ assert "completed · 12s" in reader.text
+ assert not calls
+
+
+@pytest.mark.asyncio
+async def test_timer_callback_refreshes_without_manual_paint(monkeypatch):
+ now = [1001.0]
+ monkeypatch.setattr("cc_remote.tui_app.time.time", lambda: now[0])
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ view = client.workspace.view("s")
+ view.presentation.turns["turn"] = TurnDisplay(started=1000)
+ view.put(Block("d", "detail", "body", "turn"))
+ app = WorkspaceApp(client, connect=False)
+ async with app.run_test() as pilot:
+ now[0] = 1012.0
+ await pilot.pause(0.2)
+ assert "Processing · 12s" in app.query_one(Transcript).text
+ assert not client._outbox
diff --git a/tests/test_tui_completion_retry.py b/tests/test_tui_completion_retry.py
new file mode 100644
index 00000000..ba70dba1
--- /dev/null
+++ b/tests/test_tui_completion_retry.py
@@ -0,0 +1,26 @@
+"""Outbox backpressure must not permanently suppress read receipts."""
+
+import pytest
+
+from cc_remote.tui_app import WorkspaceApp
+from tests.test_tui_workspace import client
+
+
+@pytest.mark.asyncio
+async def test_failed_completion_ack_can_be_retried():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ key = ("s", "completion")
+ outcomes = iter([False, True])
+
+ async def send(command):
+ assert command.type == "acknowledge_completion"
+ return next(outcomes)
+
+ c._send = send
+ app.acknowledged.add(key)
+ await app.acknowledge_completion(*key)
+ assert key not in app.acknowledged
+ app.acknowledged.add(key)
+ await app.acknowledge_completion(*key)
+ assert key in app.acknowledged
diff --git a/tests/test_tui_controls.py b/tests/test_tui_controls.py
new file mode 100644
index 00000000..81aae50c
--- /dev/null
+++ b/tests/test_tui_controls.py
@@ -0,0 +1,433 @@
+"""Real key dispatch, scoped settings and readable transcript regressions."""
+
+import json
+
+import pytest
+from textual.widgets import Button, OptionList, Static
+
+from cc_remote import protocol as p
+from cc_remote.tui_app import (
+ WorkspaceApp,
+ WorkspaceClient,
+ Transcript,
+ Composer,
+)
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_panels import ActionForm, DetailPanel, ActionPicker
+from cc_remote.tui_presentation import SessionPresentation
+from cc_remote.tui_settings import SettingsForm, ValuePicker, TextValue
+
+
+def setup():
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ c.workspace.catalog["s"] = {
+ "session_id": "s",
+ "engine": "codex",
+ "space": "code",
+ "cwd": "/repo",
+ }
+ view = c.workspace.view("s")
+ view.write_state = "writable"
+ return WorkspaceApp(c, connect=False), c
+
+
+def catalogs(form):
+ for kind, data in {
+ "models": {
+ "default_model": "alpha",
+ "models": [
+ {"id": "alpha", "efforts": ["low", "high"]},
+ {"id": "beta", "efforts": []},
+ ],
+ },
+ "permission_profiles": {
+ "profiles": [
+ {"id": ":workspace", "allowed": True},
+ {"id": ":danger-full-access", "allowed": True},
+ {"id": "forbidden", "allowed": False},
+ ]
+ },
+ }.items():
+ key = (kind, *form.client.capability_key(form.args(kind)))
+ form.client.settings_catalogs[key] = data
+
+
+def sent(c, kind):
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ return [frame for frame in frames if frame["type"] == kind]
+
+
+def test_quota_missing_short_window_hidden_and_percent_semantics():
+ presentation = SessionPresentation()
+ presentation.context = {"percentage": 12.5}
+ presentation.rates = {
+ "codex": {
+ "secondary": {
+ "window_duration_mins": 10080,
+ "used_percent": 20,
+ }
+ }
+ }
+ label = presentation.usage_label()
+ assert "5h" not in label and "?" not in label
+ assert "12% used" in label and "80% remaining" in label
+ presentation.rates["codex"]["primary"] = {
+ "window_duration_mins": 300,
+ "used_percent": 0,
+ }
+ assert "5h [██████████] 100% remaining" in presentation.usage_label()
+ presentation.rates["codex"]["primary"]["used_percent"] = None
+ assert "5h" not in presentation.usage_label()
+
+
+@pytest.mark.asyncio
+async def test_all_message_directions_skip_progress_and_preserve_draft():
+ app, c = setup()
+ view = c.workspace.view("s")
+ for n in range(3):
+ view.event(
+ {"type": "user_msg", "msg_id": f"u{n}", "prompt": f"user {n}"}
+ )
+ view.event(
+ {
+ "type": "delta",
+ "message_id": f"p{n}",
+ "text": "progress",
+ "channel": "commentary",
+ }
+ )
+ view.event(
+ {
+ "type": "delta",
+ "message_id": f"a{n}",
+ "text": f"answer {n}",
+ "channel": "final",
+ }
+ )
+ view.event({"type": "turn_end", "turn_id": f"u{n}",
+ "result": {"subtype": "success"}})
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("g", "a")
+ latest = reader.cursor_location
+ await pilot.press("left_square_bracket", "a")
+ previous = reader.cursor_location
+ assert previous < latest
+ await pilot.press("right_square_bracket", "a")
+ assert reader.cursor_location == latest
+ await pilot.press("g", "u")
+ latest_user = reader.cursor_location
+ await pilot.press("left_square_bracket", "u")
+ assert reader.cursor_location < latest_user
+ await pilot.press("right_square_bracket", "u")
+ assert reader.cursor_location == latest_user
+ assert not view.follow and not app.query_one(Composer).text
+ assert "on #202a36" in reader.line_styles.values()
+ assert "bright_black" in reader.line_styles.values()
+ assert "bold green" in reader.line_styles.values()
+
+
+@pytest.mark.asyncio
+async def test_goal_nested_form_insert_escape_and_no_button_or_main_send():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ app.query_one(Composer).load_text("keep draft")
+ await pilot.press("space", "g")
+ assert isinstance(app.screen, DetailPanel)
+ assert not app.screen.query(Button)
+ await pilot.press("i")
+ assert isinstance(app.screen, ActionForm)
+ editor = app.screen.query_one(ModalEditor)
+ assert editor.vim_mode == "INSERT"
+ await pilot.press("escape")
+ editor.load_text('{"objective":"old", "status":"active"}')
+ editor.move_cursor((0, 14))
+ await pilot.press("c", "i", "quotation_mark")
+ assert editor.vim_mode == "INSERT"
+ await pilot.press("n", "e", "w", "escape")
+ assert '"objective":"new"' in editor.text
+ assert editor.vim_mode == "NORMAL"
+ await pilot.press("escape")
+ assert isinstance(app.screen, DetailPanel)
+ await pilot.press("ctrl+s", "ctrl+e")
+ assert not sent(c, "query") and not sent(c, "set_goal")
+ await pilot.press("escape")
+ assert app.query_one(Composer).text == "keep draft"
+ assert app.query_one(Composer).vim_mode == "NORMAL"
+
+
+@pytest.mark.asyncio
+async def test_picker_search_ctrl_j_k_and_normal_mode_return():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "a")
+ assert isinstance(app.screen, ActionPicker)
+ editor = app.screen.query_one(ModalEditor)
+ assert editor.vim_mode == "INSERT"
+ await pilot.press(*"goal")
+ listing = app.screen.query_one(OptionList)
+ assert listing.option_count >= 2
+ await pilot.press("ctrl+j")
+ assert listing.highlighted == 1
+ await pilot.press("ctrl+k", "escape")
+ assert len(app.screen_stack) == 1
+
+
+@pytest.mark.asyncio
+async def test_model_change_uses_catalog_and_one_confirmation():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "m")
+ form = app.screen
+ assert isinstance(form, SettingsForm)
+ catalogs(form)
+ await pilot.press("enter")
+ assert isinstance(app.screen, ValuePicker)
+ await pilot.press("ctrl+j", "enter")
+ assert app.screen is form
+ assert len(sent(c, "set_model")) == 1
+ assert sent(c, "set_model")[-1]["model"] == "beta"
+ assert sent(c, "set_model")[-1]["sid"] == "s"
+
+
+@pytest.mark.asyncio
+async def test_new_session_collects_cwd_model_permissions_atomically(
+ monkeypatch,
+):
+ from cc_remote.tui_directories import DirectoryPicker
+
+ app, c = setup()
+
+ async def directories(path, **kwargs):
+ return {
+ "path": "/home/example" if path == "~" else path,
+ "parent": "/",
+ "dirs": [{"path": "/new"}],
+ }
+
+ async def rank(paths, query):
+ return [path for path in paths if query in path]
+
+ monkeypatch.setattr(c, "list_directories", directories)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ async with app.run_test(size=(80, 28)) as pilot:
+ await pilot.press("space", "enter")
+ form = app.screen
+ assert isinstance(form, SettingsForm) and form.new
+ assert form.values["cwd"] == "~"
+ await pilot.press("enter")
+ assert isinstance(app.screen, DirectoryPicker)
+ await pilot.pause(0.1)
+ await pilot.press(*"/new")
+ await pilot.pause(0.1)
+ await pilot.press("enter")
+ await pilot.pause()
+ assert app.screen is form and form.values["cwd"] == "/new"
+ catalogs(form)
+ form.query_one(OptionList).highlighted = form.fields().index("model")
+ await pilot.press("enter", "ctrl+j", "enter")
+ assert form.values["model"] == "alpha"
+ form.query_one(OptionList).highlighted = form.fields().index(
+ "permission_profile"
+ )
+ await pilot.press("enter")
+ assert all(value != "forbidden" for _, value in app.screen.choices)
+ await pilot.press("ctrl+j", "ctrl+j", "enter")
+ assert form.values["permission_profile"] == ":danger-full-access"
+ form.values["permission_mode"] = "never"
+ await pilot.press("end", "enter")
+ assert len(sent(c, "new_session")) == 1
+ request = sent(c, "new_session")[-1]
+ assert (
+ request["cwd"],
+ request["model"],
+ request["permission_profile"],
+ request["permission_mode"],
+ ) == ("/new", "alpha", ":danger-full-access", "never")
+ assert request["space"] == "code" and request["engine"] == "codex"
+ assert not sent(c, "query") and not sent(c, "set_perm")
+
+
+def test_new_work_and_cwd_scoped_catalog_never_reuse_foreign_permissions():
+ _, c = setup()
+ form = SettingsForm(c, None, new=True)
+ catalogs(form)
+ assert len(form.choices("permission_profile")) == 3
+ form.values["cwd"] = "/other"
+ assert form.choices("permission_profile") == [
+ ("Engine default (no override)", None)
+ ]
+ c.space = "work"
+ work = SettingsForm(c, None, new=True)
+ assert (
+ "cwd" not in work.fields() and "permission_profile" not in work.fields()
+ )
+ c.engine = "claude"
+ claude = SettingsForm(c, None, new=True)
+ assert "permission_mode" not in claude.fields()
+ assert len(claude.choices("model")) > 1
+
+
+def test_modal_key_configuration_is_scoped_and_rejects_global_conflicts():
+ keys = KeyConfig({"panel": {"refresh": ["z"]}, "picker": {"down": ["n"]}})
+ assert "z: Refresh" in keys.layer_help("panel")
+ assert keys.normal_keys["goal"] == ("space g",)
+ with pytest.raises(ValueError, match="global"):
+ KeyConfig({"panel": {"refresh": ["ctrl+e"]}})
+
+
+@pytest.mark.asyncio
+async def test_catalog_responses_bound_to_requested_directory_and_profile():
+ _, c = setup()
+ for directory in ("/old", "/new"):
+ await c._send(
+ p.GetPermissionProfiles(cwd=directory, codex_profile_id="account")
+ )
+ request, source = next(iter(c.catalog_reads.items()))
+ c._on_event(
+ {
+ "type": "permission_profiles",
+ "request_id": request,
+ "cwd": "/old",
+ "profiles": [{"id": "old", "allowed": True}],
+ }
+ )
+ key = ("permission_profiles", *c.capability_key(source))
+ assert c.settings_catalogs[key]["profiles"][0]["id"] == "old"
+ form = SettingsForm(c, None, new=True)
+ form.values["cwd"] = "/new"
+ form.profiles["codex_profile_id"] = "account"
+ assert not form.catalog("permission_profiles")
+
+
+@pytest.mark.asyncio
+async def test_configured_panel_refresh_and_main_keys_do_not_leak():
+ app, c = setup()
+ c.keys = KeyConfig({"panel": {"refresh": ["z"]}})
+ async with app.run_test() as pilot:
+ await pilot.press("space", "g")
+ before = len(sent(c, "get_goal"))
+ await pilot.press("z")
+ assert len(sent(c, "get_goal")) == before + 1
+ assert "z: Refresh" in str(
+ app.screen.query_one(".key-hints", Static).render()
+ )
+ await pilot.press("space", "enter")
+ assert isinstance(app.screen, DetailPanel) and not sent(
+ c, "new_session"
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "field,command,attribute,value",
+ [
+ (
+ "permission_profile",
+ "set_permission_profile",
+ "profile",
+ ":workspace",
+ ),
+ ("permission_mode", "set_perm", "mode", "never"),
+ ("web_search", "set_web_search", "mode", "cached"),
+ ("collaboration_mode", "set_collaboration_mode", "mode", "default"),
+ ("service_tier", "set_service_tier", "service_tier", "default"),
+ ],
+)
+async def test_settings_apply_one_scoped_command_after_selection(
+ field,
+ command,
+ attribute,
+ value,
+):
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "s")
+ form = app.screen
+ catalogs(form)
+ form.query_one(OptionList).highlighted = form.fields().index(field)
+ await pilot.press("enter", "enter")
+ assert app.screen is form and len(sent(c, command)) == 1
+ assert sent(c, command)[-1][attribute] == value
+ assert sent(c, command)[-1]["sid"] == "s"
+ assert form.values[field] is None # Wait for effective server settings.
+
+
+@pytest.mark.asyncio
+async def test_custom_close_key_does_not_eat_insert_text():
+ app, c = setup()
+ c.keys = KeyConfig({"form": {"close": ["q"]}})
+ async with app.run_test() as pilot:
+ app.push_screen(TextValue("Directory", ""))
+ await pilot.pause()
+ await pilot.press("i", "q")
+ editor = app.screen.query_one(ModalEditor)
+ assert editor.text == "q"
+ await pilot.press("escape")
+ assert editor.vim_mode == "NORMAL"
+ await pilot.press("q")
+ assert len(app.screen_stack) == 1
+
+
+@pytest.mark.asyncio
+async def test_previous_user_fetches_older_page_without_losing_anchor():
+ from cc_remote.tui_state import Block
+
+ app, c = setup()
+ view = c.workspace.view("s")
+ view.event({"type": "user_msg", "msg_id": "u", "prompt": "current"})
+ view.has_more, view.oldest = True, "u"
+ async with app.run_test() as pilot:
+ await pilot.press("g", "u", "left_square_bracket", "u")
+ assert sent(c, "get_history")[-1]["before"] == "u"
+ assert app.pending_jump and view.loading
+ view.blocks.insert(
+ 0, Block(id="older", role="user", text="older", turn="older")
+ )
+ view.loading = False
+ view.version += 1
+ app.paint()
+ await pilot.pause()
+ assert app.query_one(Transcript).cursor_location == (0, 0)
+ assert app.pending_jump is None
+
+
+def test_claude_model_fallback_matches_web_curated_choices():
+ from pathlib import Path
+ from cc_remote.tui_settings import CLAUDE_MODELS
+
+ web = Path("web/src/data.ts").read_text()
+ table = web.split("export const MODELS:", 1)[1].split("];")[0]
+ assert all(f'id: "{model}"' in table for model in CLAUDE_MODELS)
+
+
+@pytest.mark.asyncio
+async def test_secret_input_paste_requires_insert_and_remains_masked():
+ from textual.events import Paste
+ from cc_remote.tui_modal import ModalInput
+ from cc_remote.tui_panels import QuestionDialog
+
+ app, c = setup()
+ async with app.run_test() as pilot:
+ app.push_screen(
+ QuestionDialog(
+ c,
+ {
+ "sid": "s",
+ "ask_id": "private",
+ "question": "Secret?",
+ "secret": True,
+ "allow_text": True,
+ },
+ )
+ )
+ await pilot.pause()
+ editor = app.screen.query_one(ModalInput)
+ editor._on_paste(Paste("secret"))
+ assert editor.value == ""
+ await pilot.press("i")
+ editor._on_paste(Paste("secret"))
+ assert editor.value == "secret" and editor.password
+ await pilot.press("escape", "escape")
+ assert len(app.screen_stack) == 1 and app.clipboard == ""
diff --git a/tests/test_tui_deletion.py b/tests/test_tui_deletion.py
new file mode 100644
index 00000000..b78698d7
--- /dev/null
+++ b/tests/test_tui_deletion.py
@@ -0,0 +1,189 @@
+"""Deleting unarchived Code sessions never skips authoritative confirmation."""
+
+import asyncio
+import json
+
+import pytest
+
+from cc_remote.protocol import DeleteSession, DeleteWorkSession
+from cc_remote.tui_app import WorkspaceClient
+
+
+def setup(engine="codex", space="code", archived=False):
+ c = WorkspaceClient("ws://localhost/ws", "", "", engine, "other")
+ c.space = space
+ c.session_catalog_requests[c.scope] = "catalog-read"
+ c.workspace.catalog["target"] = dict(
+ session_id="target", engine=engine, space=space,
+ tag="archived" if archived else None,
+ )
+ c.buffers.open("target")
+ return c, DeleteSession(session_id="target", engine=engine, space=space)
+
+
+def sent(c, kind):
+ return [frame for raw, _ in c._outbox.values()
+ if (frame := json.loads(raw))["type"] == kind]
+
+
+def ack(c, command):
+ c._handle(dict(type="command_ack", cmd_id=command["cmd_id"],
+ to=c.client_id, client_id=c.client_id))
+
+
+def listing(c, command, *, archived=None, **overrides):
+ event = dict(type="session_list", request_id=command["cmd_id"],
+ to=c.client_id, engine=command["engine"], space=command["space"],
+ sessions=[] if archived is None else [dict(
+ c.workspace.catalog["target"],
+ tag="archived" if archived else None)])
+ c._handle(dict(event, **overrides))
+
+
+async def tick():
+ for _ in range(5):
+ await asyncio.sleep(0)
+
+
+@pytest.mark.asyncio
+async def test_archive_proof_and_ack_then_delete_proof_retire_only_target():
+ c, cmd = setup()
+ c.session_catalog_requests[c.scope] = "unrelated-list"
+ assert await c.execute_action(cmd)
+ await tick()
+ archive = sent(c, "archive_session")[0]
+ assert archive["session_id"] == "target" and archive["archived"]
+ listing(c, archive, archived=True)
+ await tick()
+ assert not sent(c, "delete_session")
+ ack(c, archive)
+ await tick()
+ deletion = sent(c, "delete_session")[0]
+ assert deletion["session_id"] == "target"
+ listing(c, deletion)
+ ack(c, deletion)
+ await tick()
+ assert "target" not in c.buffers.ids
+ assert "target" not in c.workspace.catalog
+ assert c.attached_sid == "other" and c.notice == "Session deleted"
+ assert not c.deletions.tasks and not c.deletions.pending
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("bad", ["error", "ack-only", "wrong-client",
+ "wrong-scope", "wrong-request", "active"])
+async def test_failed_or_unconfirmed_archive_never_sends_delete(bad):
+ c, cmd = setup()
+ c.deletions.TIMEOUT = 0.03
+ await c.execute_action(cmd)
+ await tick()
+ archive = sent(c, "archive_session")[0]
+ if bad == "error":
+ c._handle(dict(type="error", request_id=archive["cmd_id"],
+ to=c.client_id, sid="target", message="Session busy"))
+ listing(c, archive, archived=True)
+ elif bad != "ack-only":
+ override = {"wrong-client": {"to": "foreign"},
+ "wrong-scope": {"space": "work"},
+ "wrong-request": {"request_id": "old"}}.get(bad, {})
+ listing(c, archive, archived=bad != "active", **override)
+ ack(c, archive)
+ await asyncio.sleep(0.06)
+ assert not sent(c, "delete_session")
+ assert "target" in c.buffers.ids
+ assert "target" in c.workspace.catalog
+ assert not c.deletions.pending and not c.deletions.tasks
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("engine,space,archived", [
+ ("claude", "code", False), ("codex", "work", False),
+ ("codex", "code", True),
+])
+async def test_other_delete_paths_do_not_need_archive(engine, space, archived):
+ c, cmd = setup(engine, space, archived)
+ await c.execute_action(cmd)
+ await tick()
+ assert not sent(c, "archive_session")
+ deletion = sent(c, "delete_session")[0]
+ listing(c, deletion)
+ ack(c, deletion)
+ await tick()
+ assert c.notice == "Session deleted"
+
+
+@pytest.mark.asyncio
+async def test_rejection_does_not_optimistically_remove_tab_or_repeat_delete():
+ c, cmd = setup(archived=True)
+ await c.execute_action(cmd)
+ assert not await c.execute_action(cmd)
+ await tick()
+ deletion = sent(c, "delete_session")[0]
+ c._handle(dict(type="error", request_id=deletion["cmd_id"],
+ message="External owner is busy", to=c.client_id))
+ listing(c, deletion)
+ ack(c, deletion)
+ await tick()
+ assert "target" in c.buffers.ids and "target" in c.workspace.catalog
+ assert "External owner is busy" in c.notice
+
+
+@pytest.mark.asyncio
+async def test_work_rejection_finishes_waiter_without_timeout():
+ c, _ = setup(space="work")
+ await c.execute_action(DeleteWorkSession(
+ session_id="target", engine="codex",
+ ))
+ await tick()
+ deletion = sent(c, "delete_work_session")[0]
+ assert deletion["cmd_id"] in c.deletions.pending
+ c._handle(dict(type="error", request_id=deletion["cmd_id"],
+ sid="target", to=c.client_id, message="Work session busy"))
+ await tick()
+ assert not c.deletions.pending and not c.deletions.tasks
+ assert "Work session busy" in c.notice
+ assert "target" in c.buffers.ids and "target" in c.workspace.catalog
+
+
+@pytest.mark.asyncio
+async def test_exit_during_archive_does_not_schedule_delete_later():
+ c, cmd = setup()
+ await c.execute_action(cmd)
+ await tick()
+ archive = sent(c, "archive_session")[0]
+ c.deletions.close()
+ await tick()
+ listing(c, archive, archived=True)
+ ack(c, archive)
+ await tick()
+ assert not sent(c, "delete_session") and not c.deletions.tasks
+
+
+@pytest.mark.asyncio
+async def test_partial_profile_catalog_does_not_confirm_deletion():
+ c, cmd = setup(archived=True)
+ c.deletions.TIMEOUT = 0.03
+ await c.execute_action(cmd)
+ await tick()
+ deletion = sent(c, "delete_session")[0]
+ listing(c, deletion) # An earlier cached page is not enough either.
+ listing(c, deletion, codex_profiles=[{"id": "default", "error": "offline"}])
+ ack(c, deletion)
+ await asyncio.sleep(0.06)
+ assert "target" in c.buffers.ids
+ assert "not confirmed" in c.notice
+
+
+@pytest.mark.asyncio
+async def test_rekeyed_request_deletes_real_identity_and_clears_current_focus():
+ c, _ = setup(archived=True)
+ c.workspace.rekeys["old"] = "target"
+ c.attached_sid = "target"
+ await c.execute_action(DeleteSession(session_id="old", engine="codex"))
+ await tick()
+ deletion = sent(c, "delete_session")[0]
+ assert deletion["session_id"] == "target"
+ listing(c, deletion)
+ ack(c, deletion)
+ await tick()
+ assert c.attached_sid is None and not c.buffers.ids
diff --git a/tests/test_tui_detail_directories.py b/tests/test_tui_detail_directories.py
new file mode 100644
index 00000000..2e5cc9cb
--- /dev/null
+++ b/tests/test_tui_detail_directories.py
@@ -0,0 +1,366 @@
+"""Scoped menus, fold ownership, fzf directory picking and folder labels."""
+
+import asyncio
+import json
+import shutil
+
+import pytest
+from textual.widgets import OptionList
+
+from cc_remote.tui_app import (
+ WorkspaceApp,
+ WorkspaceClient,
+ Transcript,
+ location,
+)
+from cc_remote.tui_panels import ActionPicker, PANEL_ACTIONS, ActionForm
+from cc_remote.tui_directories import DirectoryPicker, fuzzy_directories
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_state import Block
+from cc_remote.tui_settings import SettingsForm
+from cc_remote.tui_tree import SessionTree, SessionExplorer, TreeSearch
+
+
+def setup():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ client.workspace.catalog["s"] = {
+ "session_id": "s",
+ "cwd": "/a/project",
+ "engine": "codex",
+ "space": "code",
+ }
+ return WorkspaceApp(client, connect=False), client
+
+
+def detail_event():
+ return {
+ "type": "turn_detail",
+ "session_id": "s",
+ "turn_id": "u",
+ "revision": "r",
+ "has_more": True,
+ "oldest_cursor": "cursor",
+ "events": [
+ {
+ "type": "delta",
+ "message_id": "a",
+ "channel": "commentary",
+ "text": "\n".join(f"detail line {n}" for n in range(40)),
+ }
+ ],
+ }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("key", ["enter", "escape"])
+@pytest.mark.parametrize("role", ["detail", "process", "tool", "thinking"])
+async def test_fold_from_any_body_line_keeps_owner_header(key, role):
+ app, c = setup()
+ view = c.workspace.view("s")
+ view.revision = "r"
+ if role == "detail":
+ c.workspace.event(detail_event())
+ view.render()
+ block = view.local_details["u"]
+ else:
+ block = Block(
+ "b",
+ "assistant" if role == "thinking" else role,
+ "\n".join(f"detail line {n}" for n in range(40)),
+ "u",
+ channel="thinking" if role == "thinking" else "unknown",
+ expanded=True,
+ )
+ view.put(block)
+ view.render()
+ block = view.tool_groups["tools:b"]
+ block.expanded = True
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ reader.move_cursor(
+ location(reader.text, reader.text.index("detail line 25"))
+ )
+ await pilot.press(key)
+ app.paint()
+ assert not block.expanded
+ owner = next(i for i, b in app.starts if b.id == block.id)
+ assert reader.cursor_location == location(reader.text, owner)
+ assert "detail line 25" not in reader.text
+ assert not c._outbox
+ if role == "detail":
+ # A page already in flight cannot reopen a deliberately folded block.
+ c.workspace.event(detail_event())
+ assert not block.expanded
+ await pilot.press("enter")
+ app.paint()
+ assert block.expanded and "detail line 25" in reader.text
+
+
+@pytest.mark.asyncio
+async def test_detail_older_page_is_separate_from_enter_and_escape():
+ app, c = setup()
+ view = c.workspace.view("s")
+ view.revision = "r"
+ c.workspace.event(detail_event())
+ async with app.run_test() as pilot:
+ await pilot.press("o")
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert frames[-1]["type"] == "get_turn_detail"
+ assert frames[-1]["before"] == "cursor"
+ await pilot.press("escape")
+ assert not view.local_details["u"].expanded
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("newer_key", ["O", "ctrl+y"])
+async def test_detail_paging_returns_to_newest_without_reopening_fold(newer_key):
+ app, c = setup()
+ c.keys = KeyConfig({"reader": {"newer": [newer_key]}})
+ view = c.workspace.view("s")
+ view.revision = "r"
+ c.workspace.event(detail_event())
+ async with app.run_test() as pilot:
+ await pilot.press("o")
+ older = detail_event() | {
+ "has_more": False, "oldest_cursor": None,
+ "has_newer": True, "newer_cursor": "toward-newest",
+ "events": [{"type": "delta", "message_id": "old",
+ "channel": "commentary", "text": "older body"}],
+ }
+ c.workspace.event(older)
+ app.paint()
+ reader = app.query_one(Transcript)
+ reader.move_cursor(location(reader.text, reader.text.index("older body")))
+ assert c.keys.layer_label("reader", "newer") + ": load newer" in reader.text
+ await pilot.press(newer_key)
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert frames[-1]["type"] == "get_turn_detail"
+ assert frames[-1]["before"] == "toward-newest"
+ c.workspace.event(detail_event())
+ app.paint()
+ assert "detail line 39" in reader.text
+ assert "older body" not in reader.text
+ assert not view.details_newer["u"]
+
+
+@pytest.mark.parametrize("invalidate", ["turn_detail", "history_invalidated"])
+def test_detail_newer_cursor_is_invalidated_with_its_page(invalidate):
+ _, c = setup()
+ view = c.workspace.view("s")
+ view.revision = "r"
+ c.workspace.event(detail_event() | {
+ "has_newer": True, "newer_cursor": "newer",
+ })
+ c.workspace.event({
+ "type": invalidate, "sid": "s", "session_id": "s",
+ "turn_id": "u", "revision": "r", "reset_required": True,
+ })
+ assert not view.details_newer
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "panel",
+ ["Goal / Plan", "Usage / Context", "Reports", "Background", "Status"],
+)
+async def test_panel_actions_stay_scoped_even_when_searching(panel):
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await app.action_panel(panel)
+ await pilot.press("a")
+ picker = app.screen
+ assert isinstance(picker, ActionPicker)
+ assert picker.allowed_actions == PANEL_ACTIONS[panel]
+ names = {option.id for option in picker.query_one(OptionList)._options}
+ assert names == PANEL_ACTIONS[panel]
+ await pilot.press(*"new session")
+ assert not picker.query_one(OptionList).option_count
+ picker.open_form("new_session")
+ assert app.screen is picker
+
+
+@pytest.mark.asyncio
+async def test_goal_edit_i_enters_insert_but_old_e_is_not_an_action():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "g", "e")
+ assert not isinstance(app.screen, ActionForm)
+ await pilot.press("i")
+ assert isinstance(app.screen, ActionForm)
+ assert app.screen.query_one(ModalEditor).vim_mode == "INSERT"
+ await pilot.press("escape")
+ assert app.screen.query_one(ModalEditor).vim_mode == "NORMAL"
+
+
+@pytest.mark.asyncio
+async def test_tree_basename_labels_keep_distinct_paths_and_search():
+ app, c = setup()
+ c.workspace.catalog["t"] = {
+ "session_id": "t",
+ "cwd": "/b/project",
+ "engine": "codex",
+ "space": "code",
+ }
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e")
+ tree = app.query_one(SessionTree)
+ assert [node.label.plain for node in tree.root.children] == [
+ "project",
+ "project",
+ ]
+ assert {node.data[1] for node in tree.root.children} == {
+ "/a/project",
+ "/b/project",
+ }
+ await pilot.press("slash")
+ app.query_one(TreeSearch).value = "/b/"
+ app.query_one(SessionExplorer).refresh_catalog()
+ assert len(tree.root.children) == 1
+ assert tree.root.children[0].data == ("folder", "/b/project")
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(
+ shutil.which("fzf") is None, reason="optional fzf CLI not installed"
+)
+async def test_real_fzf_unicode_spaces_and_shell_options_are_not_executed(
+ monkeypatch, tmp_path
+):
+ marker = tmp_path / "unexpected"
+ monkeypatch.setenv(
+ "FZF_DEFAULT_OPTS", f"--bind=start:execute(touch {marker})"
+ )
+ monkeypatch.setenv("FZF_DEFAULT_COMMAND", f"touch {marker}")
+ paths = ["/projects/cc-remote", "/projects/kernel", "/目录/hello world"]
+ assert await fuzzy_directories(paths, "ccrmt") == [paths[0]]
+ assert await fuzzy_directories(paths, "目录 world") == [paths[2]]
+ assert (
+ await fuzzy_directories(paths, "--bind=start:execute(echo bad)") == []
+ )
+ assert not marker.exists()
+
+
+@pytest.mark.asyncio
+async def test_directory_rpc_correlation_cache_and_cancellation():
+ _, c = setup()
+ task = asyncio.create_task(c.list_directories("~"))
+ await asyncio.sleep(0)
+ request = next(iter(c.directory_waiters))
+ c._on_event(
+ {
+ "type": "dir_list",
+ "request_id": "foreign",
+ "path": "/wrong",
+ "dirs": [],
+ }
+ )
+ assert not task.done()
+ c._on_event(
+ {
+ "type": "dir_list",
+ "request_id": request,
+ "path": "/home/example",
+ "dirs": [],
+ }
+ )
+ assert (await task)["path"] == "/home/example"
+ count = len(c._outbox)
+ assert (await c.list_directories("~"))["path"] == "/home/example"
+ assert len(c._outbox) == count
+ task = asyncio.create_task(c.list_directories("/other"))
+ await asyncio.sleep(0)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert not c.directory_waiters
+
+
+@pytest.mark.asyncio
+async def test_directory_picker_search_browse_select_and_no_model_turn(
+ monkeypatch,
+):
+ app, c = setup()
+ reads = []
+
+ async def directories(path, **kwargs):
+ reads.append(path)
+ base = "/home/test" if path == "~" else path
+ return {
+ "path": base,
+ "parent": "/home/test" if path != "~" else "/home",
+ "dirs": ([{"path": base + "/repo"}, {"path": base + "/code"}]
+ if base == "/home/test" else []),
+ }
+
+ async def rank(paths, query):
+ return [path for path in paths if query in path]
+
+ monkeypatch.setattr(c, "list_directories", directories)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "enter")
+ form = app.screen
+ assert isinstance(form, SettingsForm) and form.values["cwd"] == "~"
+ await pilot.press("i")
+ assert isinstance(app.screen, DirectoryPicker)
+ await pilot.pause(0.12)
+ assert app.screen.query_one(ModalEditor).vim_mode == "INSERT"
+ assert app.focused is app.screen.query_one(ModalEditor)
+ await pilot.press(*"repo")
+ await pilot.pause(0.12)
+ assert app.screen.query_one(ModalEditor).text == "repo"
+ await pilot.press("ctrl+right")
+ await pilot.pause(0.12)
+ assert app.screen.path == "/home/test/repo"
+ await pilot.press("ctrl+left")
+ await pilot.pause(0.12)
+ assert app.screen.path == "/home/test"
+ await pilot.press(*"code")
+ await pilot.pause(0.12)
+ await pilot.press("enter")
+ await pilot.pause()
+ assert app.screen is form and form.values["cwd"] == "/home/test/code"
+ assert (
+ reads[-1] == "/home/test/code"
+ ) # Verify even a known-directory shortcut.
+ assert all(
+ json.loads(raw)["type"] in {"get_models", "get_permission_profiles"}
+ for raw, _ in c._outbox.values()
+ )
+
+
+@pytest.mark.asyncio
+async def test_directory_filter_never_selects_stale_match_or_starts_command(
+ monkeypatch,
+):
+ app, c = setup()
+
+ async def directories(path, **kwargs):
+ return {"path": "/home/test", "dirs": []}
+
+ async def rank(paths, query):
+ if query:
+ await asyncio.sleep(0.5)
+ return paths
+
+ monkeypatch.setattr(c, "list_directories", directories)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ async with app.run_test() as pilot:
+ app.push_screen(DirectoryPicker(c, "~"))
+ await pilot.pause(0.12)
+ screen = app.screen
+ screen.query_one(ModalEditor).load_text("changed")
+ assert screen.selected_path() is None
+ await pilot.press("enter")
+ assert app.screen is screen
+ await pilot.press("escape")
+ assert len(app.screen_stack) == 1
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_missing_fzf_is_an_explicit_non_mutating_error(monkeypatch):
+ monkeypatch.setattr(shutil, "which", lambda _: None)
+ with pytest.raises(ValueError, match="install fzf"):
+ await fuzzy_directories(["/tmp"], "tmp")
diff --git a/tests/test_tui_diagram_families.py b/tests/test_tui_diagram_families.py
new file mode 100644
index 00000000..8272d979
--- /dev/null
+++ b/tests/test_tui_diagram_families.py
@@ -0,0 +1,280 @@
+"""Terminal diagram projections retain data without executing directives."""
+
+import json
+from urllib.parse import unquote, urlsplit
+
+import pytest
+from rich.cells import cell_len
+
+from cc_remote.tui_diagram_text import KINDS, diagram_source
+from cc_remote.tui_mermaid import parse_flowchart, render_diagram
+from cc_remote.tui_markdown import render_markdown
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_diagram_browser import (
+ session_browser_url,
+ open_session_browser,
+)
+from cc_remote.tui_app import WorkspaceApp, Transcript
+from cc_remote.tui_preview import PreviewRef
+from cc_remote.tui_preview_views import FilePreviewScreen, MarkdownReader
+from tests.test_tui_workspace import client
+
+GPU = """flowchart TB
+ H["主机:框架、backend、CUDA 与驱动"]
+ subgraph GPU["GPU 卡端"]
+ G["GSP 管理处理器
运行管理固件"]
+ Q["计算任务入口"]
+ S["多个 SM 计算单元
执行 kernel 机器指令"]
+ M["显存
计算程序、权重、中间结果"]
+ Q --> S
+ S <-->|读取与写入| M
+ G -. 初始化与设备管理 .-> Q
+ end
+ H -->|提交计算任务| Q
+ H -. 管理通信 .-> G
+"""
+
+EXAMPLES = {
+ "sequenceDiagram": "participant A as Alice\nparticipant B as Bob\nloop retry\nA->>B: hello\nB-->>A: reply\nend",
+ "classDiagram": "class Animal {\n+name: string\n+eat()\n}\nAnimal <|-- Duck",
+ "stateDiagram": "[*] --> Idle\nIdle --> Busy: start\nBusy --> [*]",
+ "erDiagram": "USER ||--o{ ORDER : places\nUSER {\nstring name\n}",
+ "requirementDiagram": 'requirement test {\nid: 1\ntext: "Safe"\n}\nA - satisfies -> test',
+ "gitGraph": 'commit id: "a"\nbranch dev\ncommit id: "b"\ncheckout main\nmerge dev',
+ "gantt": "dateFormat YYYY-MM-DD\nsection Build\nFirst :a, 2026-01-01, 2d\nNext :after a, 3d",
+ "pie": '"A": 30\n"B": 70',
+ "journey": "section Work\nWrite: 5: Me, You",
+ "timeline": "section Past\n2025: First\n2026: Next",
+ "mindmap": " root((Root))\n A[First]\n B[Child]\n C[Second]",
+ "kanban": " Todo[Todo]\n task[Implement]@{ assigned: 'me' }",
+ "quadrantChart": "x-axis Low --> High\ny-axis Low --> High\nquadrant-1 Best\nA: [0.2, 0.8]",
+ "xychart": 'x-axis [jan, feb]\ny-axis "Count" 0 --> 20\nbar [10, 20]\nline [12, 15]',
+ "sankey": '"A, one",B,10\nB,C,8',
+ "packet": '0-7: "Header"\n+8: "Body"',
+ "radar-beta": 'axis a["A"], b["B"]\ncurve one["One"]{1,2}\nmax 10',
+ "block": 'columns 2\nA["One"] B["Two"]\nA --> B',
+ "architecture": "group cloud(cloud)[Cloud]\nservice api(server)[API] in cloud\nservice db(database)[DB]\napi:R --> L:db",
+ "C4Context": 'Person(user, "User", "Person")\nSystem(app, "App")\nRel(user, app, "Uses")',
+ "C4Container": 'Container(api, "API", "Python", "Backend")',
+ "C4Component": 'Component(db, "DB", "SQL")',
+ "C4Dynamic": 'Rel(user, api, "Request", "HTTPS")',
+ "C4Deployment": 'Deployment_Node(host, "Host") {\nContainer(api, "API")\n}',
+ "treeView-beta": "Root\n Child\n Leaf",
+ "treemap": '"Root"\n "A": 30\n "B": 70',
+ "swimlane-beta": "pool Main {\nlane Work {\ntask A\n}\n}",
+ "eventmodeling": "swimlane User\nevent Created\ncommand Create",
+ "ishikawa": "Problem\n People\n Training",
+ "venn-beta": 'set A["First"]\nset B["Second"]\nA & B: 10',
+ "wardley-beta": "component App [0.8, 0.3]\ncomponent DB [0.5, 0.8]\nApp -> DB",
+ "cynefin-beta": "complex\n Investigate\nclear\n Repeat",
+ "railroad-beta": 'rule = "a" | "b"',
+ "railroad-ebnf-beta": 'rule = "a", { "b" };',
+ "railroad-abnf-beta": "rule = 1*DIGIT",
+ "railroad-peg-beta": 'rule <- "a" / "b"',
+ "zenuml": "A->B: hello",
+ "info": "",
+}
+ALIASES = {
+ "classDiagram-v2": "classDiagram",
+ "stateDiagram-v2": "stateDiagram",
+ "requirement": "requirementDiagram",
+}
+for alias in KINDS:
+ if alias.endswith("-beta") and alias[:-5] in EXAMPLES:
+ ALIASES[alias] = alias[:-5]
+for alias, target in ALIASES.items():
+ EXAMPLES[alias] = EXAMPLES[target]
+for kind in ("flowchart", "flowchart-elk", "graph"):
+ EXAMPLES[kind] = 'TB\nA["Start"] --> B["End"]'
+
+
+def test_every_pinned_family_has_a_projection_fixture():
+ assert set(KINDS) == set(EXAMPLES)
+
+
+@pytest.mark.parametrize("kind", KINDS)
+@pytest.mark.parametrize("width", [12, 40, 100])
+def test_family_projection_is_bounded_and_responsive(kind, width):
+ source = kind + "\n" + EXAMPLES[kind]
+ text, responsive, _ = render_markdown(
+ "```mermaid\n" + source + "\n```", width
+ )
+ assert responsive
+ assert "Unsupported diagram" not in text.plain
+ assert "Source ·" not in text.plain
+ assert all(cell_len(line) <= width for line in text.plain.splitlines())
+ assert len(text) < 128 * 1024
+
+
+@pytest.mark.parametrize("width", [12, 40, 100])
+def test_gpu_graph_keeps_groups_bidirectional_edges_and_inline_labels(width):
+ graph = parse_flowchart(GPU)
+ assert graph.groups["GPU"] == ("GPU 卡端", None)
+ assert graph.membership["S"] == "GPU"
+ assert len(graph.edges) == 5
+ assert any(
+ a == "S" and b == "M" and "↔" in text for a, b, text in graph.edges
+ )
+ assert any(
+ a == "G" and b == "Q" and "初始化与设备管理" in text
+ for a, b, text in graph.edges
+ )
+ result = render_diagram(GPU, width)
+ assert result is not None and "Source ·" not in result.plain
+
+
+def test_flowchart_chain_sets_shapes_and_nested_groups():
+ source = """graph LR
+subgraph outer["Outer"]
+subgraph inner["Inner"]
+A(("Circle")) & B{"Diamond"} --> C@{shape: rect, label: "Final"}
+end
+end
+C --> D["End"]
+"""
+ graph = parse_flowchart(source)
+ assert graph.groups["inner"] == ("Inner", "outer")
+ assert graph.nodes["C"] == "Final"
+ assert {(a, b) for a, b, _ in graph.edges} == {
+ ("A", "C"),
+ ("B", "C"),
+ ("C", "D"),
+ }
+
+
+def test_directives_are_inert_and_hint_tracks_configured_key():
+ source = """---
+title: Diagram
+config:
+ theme: dark
+---
+%%{init: {"securityLevel": "loose"}}%%
+flowchart TB
+A["[bold]literal"] --> B
+click A "https://example.com"
+classDef red fill:red
+class A red
+"""
+ result = render_diagram(source, 100, "Space z")
+ assert (
+ result is not None
+ and "Space z: open session in browser" in result.plain
+ )
+ assert "Web-only" in result.plain and "[bold]literal" in result.plain
+ assert "https://example.com" not in result.plain
+
+
+def test_unknown_extension_is_not_silently_accepted_or_lost():
+ result = render_diagram("sequenceDiagram\nfuture syntax", 100)
+ assert "Source · future syntax" in result.plain
+ assert "unprojected syntax" in result.plain
+ assert render_diagram("futureDiagram\nA", 80) is None
+
+
+def test_pie_values_and_packet_offsets_are_not_just_source():
+ text = render_diagram('pie\n"A": 30\n"B": 70', 80).plain
+ assert "30.0%" in text and "70.0%" in text and "█" in text
+ text = render_diagram('packet\n0-7: "Head"\n+8: "Body"', 80).plain
+ assert "Bits 8–15 (8)" in text and "Body" in text
+ text = render_diagram('pie\n"A": 1e308\n"B": 1e308', 80).plain
+ assert text.count("50.0%") == 2
+
+
+def test_browser_route_has_no_source_or_secret_and_preserves_scope():
+ c = client()
+ c.workspace.catalog["s"] = {"engine": "claude", "space": "work"}
+ target = session_browser_url(c, "s")
+ route = json.loads(unquote(urlsplit(target).fragment.split("=", 1)[1]))
+ assert route == {
+ "machine_id": c.machine_id,
+ "session_id": "s",
+ "engine": "claude",
+ "space": "work",
+ }
+ assert not urlsplit(target).query
+ assert KeyConfig().lookup("space B")[0]["id"] == "normal.diagram_browser"
+
+
+@pytest.mark.asyncio
+async def test_browser_opens_only_when_explicitly_requested(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "cc_remote.tui_diagram_browser.webbrowser.open",
+ lambda url, **kw: calls.append(url) or True,
+ )
+ render_diagram('graph TB\nA-->B\nclick A "javascript:evil()"', 80)
+ assert not calls
+ assert (
+ await open_session_browser(client(), "s") == "Opened session in browser"
+ )
+ assert len(calls) == 1 and "evil" not in calls[0]
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ "graph TB\nA" + "x" * 33000,
+ "sequenceDiagram\n" + "A->>B: hello\n" * 501,
+ ],
+)
+def test_limits_fail_closed(source):
+ assert render_diagram(source, 80) is None
+
+
+def test_comment_frontmatter_and_non_mermaid_code():
+ assert diagram_source('%% note\npie\n"A": 1').kind == "pie"
+ text, responsive, _ = render_markdown('```text\npie\n"A": 1\n```', 80)
+ assert text.plain == 'pie\n"A": 1' and not responsive
+ for prose in ("info about the server", "graph of our work", "pie is food"):
+ assert render_markdown(prose, 80)[0].plain == prose
+
+
+@pytest.mark.asyncio
+async def test_configured_browser_key_is_live_in_chat_and_preview(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "cc_remote.tui_diagram_browser.webbrowser.open",
+ lambda url, **kw: calls.append(url) or True,
+ )
+
+ async def request(*args, **kwargs):
+ return {
+ "type": "file_preview",
+ "format": "markdown",
+ "content": '```mermaid\ngraph TB\nA-->B\nclick A "https://example.com"\n```',
+ }
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ c = client()
+ c.keys = KeyConfig(
+ {
+ "normal": {"diagram_browser": ["space z"]},
+ "preview": {"browser": ["alt+m"]},
+ }
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(100, 30)) as pilot:
+ app.query_one(Transcript).focus()
+ assert not calls
+ await pilot.press("space", "z")
+ assert len(calls) == 1
+ await app.push_screen(
+ FilePreviewScreen(
+ c,
+ "s",
+ PreviewRef(path="/diagram.md", kind="markdown"),
+ )
+ )
+ await pilot.pause()
+ reader = app.screen.query_one(MarkdownReader)
+ assert "alt+m" in reader.text.lower()
+ await pilot.press("alt+m")
+ assert len(calls) == 2
+
+
+def test_large_numeric_literals_and_terminal_controls_stay_inert():
+ result = render_diagram("packet\n" + "1" * 5000 + ': "Bad"', 40)
+ assert "Source ·" in result.plain
+ for kind in ("pie", "sequenceDiagram", "mindmap"):
+ result = render_diagram(kind + '\n"[31m": 1', 80)
+ assert "\x1b" not in result.plain
diff --git a/tests/test_tui_diagram_sequence.py b/tests/test_tui_diagram_sequence.py
new file mode 100644
index 00000000..549045d1
--- /dev/null
+++ b/tests/test_tui_diagram_sequence.py
@@ -0,0 +1,224 @@
+"""Sequence layout assertions inspect cells, not just presence of source text."""
+
+import pytest
+from rich.cells import cell_len
+
+from cc_remote.tui_mermaid import render_diagram
+from cc_remote.tui_markdown import render_markdown
+
+
+SOURCE = """sequenceDiagram
+participant H as 主机 llama / backend
+participant C as 卡端 runtime / 执行器
+participant K as 卡端 kernel 库
+Note over H,K: 加载与准备阶段
+H->>C: 上传权重,分配卡端 buffer
+H->>C: 提交可复用的计算图/执行计划
+C-->>H: 返回计划句柄
+Note over H,K: 执行阶段
+H->>C: 更新输入,执行指定计划
+C->>K: 矩阵乘
+K-->>C: 完成,中间数据留在卡上
+C->>K: 加法、激活及后续计算
+K-->>C: 完成
+C-->>H: 报告计划完成
+H->>C: 读取需要的输出
+"""
+
+
+def render(body, width=80):
+ result = render_diagram("sequenceDiagram\n" + body, width)
+ assert result is not None
+ return result.plain
+
+
+def test_user_diagram_has_parallel_lifelines_and_ten_directional_arrows():
+ text = render_diagram(SOURCE, 100).plain
+ lines = text.splitlines()
+ header = next(line for line in lines if "主机" in line)
+ assert "执行器" in header and "kernel" in header
+ lifelines = next(line for line in lines if line.count("│") == 3)
+ positions = [i for i, char in enumerate(lifelines) if char == "│"]
+ h, c, k = positions
+ arrows = [line for line in lines if "▶" in line or "◀" in line]
+ assert len(arrows) == 10
+ for line, sender, receiver, dashed in zip(
+ arrows,
+ [h, h, c, h, c, k, c, k, c, h],
+ [c, c, h, c, k, c, k, c, h, c],
+ [False, False, True, False, False, True, False, True, True, False],
+ ):
+ assert line[receiver] == ("▶" if receiver > sender else "◀")
+ assert line[sender] in "├┤"
+ assert ("┄" if dashed else "─") in line
+ other = ({h, c, k} - {sender, receiver}).pop()
+ assert line[other] == "│"
+ assert "[->>" not in text
+ assert "1." not in text # Mermaid does not number without autonumber.
+ assert "Note: 加载与准备阶段" in text
+
+
+@pytest.mark.parametrize("width", [8, 12, 16, 24, 40, 80, 100, 240])
+def test_chinese_reflow_is_bounded_and_preserves_every_message(width):
+ text, responsive, _ = render_markdown(
+ "```mermaid\n" + SOURCE + "```",
+ width,
+ )
+ assert responsive
+ assert all(cell_len(line) <= width for line in text.plain.splitlines())
+ if width >= 16:
+ assert text.plain.count("▶") + text.plain.count("◀") == 10
+ assert "Narrow terminal" not in text.plain
+ # Wrapping can insert whitespace and lifelines, but cannot drop content.
+ flattened = "".join(c for c in text.plain if not c.isspace() and c != "│")
+ assert "提交可复用的计算图/执行计划" in flattened
+
+
+@pytest.mark.parametrize(
+ "arrow,head",
+ [
+ ("->>", "▶"),
+ ("-->>", "▶"),
+ ("->", "┤"),
+ ("-->", "┤"),
+ ("-)", "▷"),
+ ("--)", "▷"),
+ ("-x", "×"),
+ ("--x", "×"),
+ ("<<->>", "▶"),
+ ("<<-->>", "▶"),
+ ],
+)
+def test_arrow_styles_and_heads(arrow, head):
+ lines = render(f"A{arrow}B: Request").splitlines()
+ edge = next(line for line in lines if "─" in line or "┄" in line)
+ assert head in edge
+ assert ("┄" if "--" in arrow else "─") in edge
+ assert ("◀" in edge) == arrow.startswith("<<")
+
+
+def test_self_message_has_return_loop_not_a_one_character_arrow():
+ text = render("A->>A: first\nA-->>A: return\nA-)A: async")
+ assert text.count("╮") == text.count("╯") == 3
+ assert "◀────╯" in text and "◀┄┄┄┄╯" in text
+ assert "◁────╯" in text
+
+
+def test_blocks_notes_and_activation_are_drawn_in_order():
+ text = render("""actor A as User
+participant B as Server
+alt success
+A->>+B: request
+Note right of B: working
+loop retry
+B->>B: step
+end
+B-->>-A: result
+else error
+Note over A,B: failure
+B-xA: failed
+end""")
+ assert "actor · User" in text
+ assert "┃" in text
+ assert "┌" in text and "└" in text
+ assert text.index("alt success") < text.index("request")
+ assert text.index("loop retry") < text.index("step")
+ assert text.index("else error") < text.index("failure")
+ assert "Note: working" in text
+ last_line = text.splitlines()[-1]
+ assert "┃" not in last_line
+
+
+def test_autonumber_off_and_resume_do_not_reset_counter():
+ text = render("""autonumber 10 5
+A->>B: one
+autonumber off
+A->>B: two
+autonumber resume
+A->>B: three""")
+ assert "10. one" in text and "15. three" in text
+ assert ". two" not in text
+
+
+def test_creation_and_destruction_bound_lifetimes():
+ text = render("""participant A
+A->>A: prepare
+create participant B as Worker
+A->>B: create
+destroy B
+B-->>A: finish
+A->>A: alone""")
+ before, after = text.split("create B")
+ live = next(line for line in after.splitlines() if line.count("│") == 2)
+ worker_column = live.rindex("│")
+ assert all(
+ line.ljust(80)[worker_column] != "│" for line in before.splitlines()
+ )
+ assert "×" in after
+ assert after.splitlines()[-1].count("│") == 1
+
+
+def test_excess_participants_and_numeric_extensions_do_not_crash():
+ assert (
+ render_diagram(
+ "sequenceDiagram\n"
+ + "\n".join(f"participant P{i}" for i in range(65)),
+ 100,
+ )
+ is None
+ )
+ assert "Source ·" in render("autonumber " + "9" * 5000)
+
+
+def test_unknown_syntax_and_browser_hint_survive_narrow_fallback():
+ text = render(
+ "participant A\nparticipant B\nparticipant C\nfuture syntax", 8
+ )
+ assert "Source" in text
+ assert "browser" in text
+
+
+def test_deep_blocks_keep_outer_frame_after_inner_frames_end():
+ text = render("""loop outer
+loop middle
+loop inner
+A->>B: inner
+end
+end
+A->>B: outer
+end""")
+ arrow = [line for line in text.splitlines() if "▶" in line][-1]
+ assert arrow[0] == arrow[-1] == "│"
+
+
+def test_alias_entities_are_decoded_once():
+ assert "<" in render('participant A as "<"\nA->>A: hello')
+
+
+def test_message_entities_do_not_split_into_separate_statements():
+ text = render("A->>B: A#59;B & C;B-->>A: done")
+ assert "A;B & C" in text and "done" in text
+ assert "Source ·" not in text
+
+
+@pytest.mark.parametrize("width", range(8, 85))
+def test_notes_self_calls_and_nested_frames_fit_all_terminal_widths(width):
+ text = render(
+ """participant A as 中文
é 👩💻
+participant B as Server
+alt first
+loop retry
+Note left of A: 左边
第二行
+A->>A: 本地处理
+Note right of B: 右边
+B-->>B: 本地返回
+Note over A: 自己
+Note over B,A: 两者
+end
+else second
+A->>B: remote
+end""",
+ width,
+ )
+ assert all(cell_len(line) <= width for line in text.splitlines())
+ assert "Source ·" not in text
diff --git a/tests/test_tui_directory_lifecycle.py b/tests/test_tui_directory_lifecycle.py
new file mode 100644
index 00000000..4c2c12b6
--- /dev/null
+++ b/tests/test_tui_directory_lifecycle.py
@@ -0,0 +1,65 @@
+"""Late directory results cannot address controls removed during teardown."""
+
+import asyncio
+
+import pytest
+
+from cc_remote.tui_directories import DirectoryPicker
+from tests.test_tui_parity import app_client
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["filter", "scan", "select"])
+@pytest.mark.parametrize("fails", [False, True])
+async def test_directory_reply_during_teardown_is_discarded(
+ monkeypatch, operation, fails,
+):
+ app, client = app_client()
+
+ async def listing(*args, **kwargs):
+ return {"path": "/root", "dirs": []}
+
+ async def rank(paths, query):
+ return paths
+
+ monkeypatch.setattr(client, "list_directories", listing)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ async with app.run_test() as pilot:
+ picker = DirectoryPicker(client, "/root")
+ app.push_screen(picker)
+ await pilot.pause(0.2)
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def delayed(*args, **kwargs):
+ started.set()
+ await release.wait()
+ if fails:
+ raise OSError("late failure")
+ return ["/root"] if operation == "filter" else {
+ "path": "/root", "dirs": [],
+ }
+
+ if operation == "filter":
+ monkeypatch.setattr(
+ "cc_remote.tui_directories.fuzzy_directories", delayed,
+ )
+ coroutine = picker.apply_filter("", picker.filter_revision)
+ else:
+ monkeypatch.setattr(client, "list_directories", delayed)
+ coroutine = (picker.scan("/root", picker.load_revision, True)
+ if operation == "scan" else picker.select("/root"))
+ task = asyncio.create_task(coroutine)
+ try:
+ await asyncio.wait_for(started.wait(), 1)
+ # Textual marks pruning nodes non-displayed before removing their
+ # children; the parent's Unmount/cancellation can arrive later.
+ picker._pruning = True
+ await picker.query_one(".tui-panel").remove()
+ release.set()
+ await asyncio.wait_for(task, 1)
+ assert app.screen is picker
+ finally:
+ picker._pruning = False
+ release.set()
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
diff --git a/tests/test_tui_docs.py b/tests/test_tui_docs.py
new file mode 100644
index 00000000..42292d54
--- /dev/null
+++ b/tests/test_tui_docs.py
@@ -0,0 +1,32 @@
+"""Keep translated setup examples usable with the actual shortcut parser."""
+
+from pathlib import Path
+import re
+import tomllib
+
+import pytest
+
+from cc_remote.tui_keys import KeyConfig
+
+
+@pytest.mark.parametrize("name", ["tui.md", "tui_zh.md"])
+def test_guide_key_examples_have_no_binding_conflicts(name):
+ root = Path(__file__).resolve().parents[1]
+ source = (root / "docs" / name).read_text()
+ examples = re.findall(r"```toml\n(.*?)\n```", source, re.DOTALL)
+ assert examples
+ for example in examples:
+ assert KeyConfig(tomllib.loads(example)).help()
+
+
+@pytest.mark.parametrize(
+ ("readme", "guide"),
+ [("README.md", "tui_zh.md"), ("README_en.md", "tui.md")],
+)
+def test_readme_points_to_the_current_session_tree(readme, guide):
+ root = Path(__file__).resolve().parents[1]
+ source = (root / readme).read_text()
+ assert f"(docs/{guide})" in source
+ assert (root / "docs" / guide).is_file()
+ assert "Space e" in source
+ assert "Ctrl+p" not in source
diff --git a/tests/test_tui_editing.py b/tests/test_tui_editing.py
new file mode 100644
index 00000000..580f7fbe
--- /dev/null
+++ b/tests/test_tui_editing.py
@@ -0,0 +1,298 @@
+"""Draft Vim modes and searchable session-picker regressions."""
+
+import pytest
+
+from textual.keys import _character_to_key
+
+from cc_remote.tui_app import (
+ Composer,
+ Transcript,
+ WorkspaceApp,
+ WorkspaceClient,
+)
+from cc_remote.tui_tree import TreeSearch as SearchInput, SessionTree, SessionExplorer
+
+
+def matches(app):
+ return [
+ n
+ for folder in app.query_one(SessionTree).root.children
+ for n in folder.children
+ ]
+
+
+def make_app():
+ client = WorkspaceClient("ws://localhost:8765/ws", "", "", "codex", "s")
+ client.workspace.view("s").write_state = "writable"
+ client.workspace.view("s").event(
+ {
+ "type": "user_msg",
+ "msg_id": "m",
+ "prompt": "reading position\nsecond line",
+ }
+ )
+ client.workspace.catalog = {
+ "s": {"summary": "Review changes", "cwd": "/work/alpha"},
+ "target-123": {"summary": "Kernel TESTS", "cwd": "/work/linux"},
+ "chinese-session": {"summary": "驱动检查", "cwd": "/work/中文"},
+ }
+ for order, (sid, row) in enumerate(client.workspace.catalog.items()):
+ row.update(session_id=sid, engine="codex", last_modified=str(3 - order))
+ return WorkspaceApp(client, connect=False)
+
+
+@pytest.mark.asyncio
+async def test_pane_switch_and_insert_are_independent():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ editor = app.query_one(Composer)
+ await pilot.press("g", "u", "j", "i")
+ position = reader.cursor_location
+ assert app.focused is reader # i no longer switches panes.
+ await pilot.press("ctrl+j")
+ assert app.focused is editor and editor.vim_mode == "NORMAL"
+ await pilot.press("i", "a", "b", "c", "escape")
+ assert app.focused is editor and editor.vim_mode == "NORMAL"
+ assert editor.text == "abc"
+ await pilot.press("h", "x")
+ assert editor.text == "ac"
+ await pilot.press("u")
+ assert editor.text == "abc"
+ await pilot.press("ctrl+r")
+ assert editor.text == "ac"
+ await pilot.press("ctrl+k")
+ assert app.focused is reader and reader.cursor_location == position
+
+
+@pytest.mark.asyncio
+async def test_draft_word_motion_change_delete_and_paste():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("one two three\nnext line")
+ await pilot.press("ctrl+j", "g", "g", "w", "d", "w")
+ assert editor.text == "one three\nnext line"
+ await pilot.press("0", "c", "w", "N", "E", "W", "escape")
+ assert editor.text == "NEW three\nnext line"
+ assert editor.vim_mode == "NORMAL"
+ await pilot.press("j", "y", "y", "p")
+ assert editor.text.endswith("next line\nnext line")
+ assert app.focused is editor
+
+
+@pytest.mark.asyncio
+async def test_draft_visual_delete_and_open_line():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("abc\ndef")
+ await pilot.press("ctrl+j", "g", "g", "v", "l", "l", "d")
+ assert editor.text == "\ndef"
+ await pilot.press("o", "x", "escape")
+ assert editor.text == "\nx\ndef"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("keys", "expected"),
+ [
+ (("d", "d"), "def"),
+ (("G", "d", "d"), "abc"),
+ (("c", "c", "x", "escape"), "x\ndef"),
+ (("w", "c", "w", "x", "escape"), "abc\nx"),
+ (("A", "x", "escape"), "abcx\ndef"),
+ (("G", "I", "x", "escape"), "abc\nxdef"),
+ (("O", "x", "escape"), "x\nabc\ndef"),
+ (("l", "d", "dollar_sign"), "a\ndef"),
+ ],
+)
+async def test_draft_line_editing_boundaries(keys, expected):
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("abc\ndef")
+ await pilot.press("ctrl+j", "g", "g", *keys)
+ assert editor.text == expected
+ assert app.focused is editor and editor.vim_mode == "NORMAL"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("search", "expected"),
+ [
+ ("TESTS", "target-123"),
+ ("/work/linux", "target-123"),
+ ("123", "target-123"),
+ ("kernel linux", "target-123"),
+ ("中文", "chinese-session"),
+ ],
+)
+async def test_session_picker_filters_title_cwd_id_and_unicode(search, expected):
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "slash")
+ assert app.query_one(SessionExplorer).display
+ assert isinstance(app.focused, SearchInput)
+ app.screen.query_one(SearchInput).value = search
+ await pilot.pause()
+ assert len(matches(app)) == 1
+ assert matches(app)[0].data == ("session", expected)
+ await pilot.press("enter")
+ assert app.client.attached_sid == expected
+ assert app.focused is app.query_one(Transcript)
+
+
+@pytest.mark.asyncio
+async def test_empty_search_results_cannot_attach_and_cancel_preserves_editor():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ await pilot.press("ctrl+j", "i", "a", "b", "c", "escape", "h")
+ position = editor.cursor_location
+ await pilot.press("space", "e", "slash", "z", "z", "z", "enter")
+ assert app.query_one(SessionExplorer).display
+ assert app.client.attached_sid == "s"
+ await pilot.press("ctrl+s", "ctrl+e", "ctrl+t")
+ assert not app.client._outbox # Search never submits a draft or answer.
+ await pilot.press("escape", "escape")
+ assert app.focused is editor
+ assert editor.text == "abc" and editor.cursor_location == position
+ assert editor.vim_mode == "NORMAL"
+
+
+@pytest.mark.asyncio
+async def test_search_accepts_j_as_text_and_arrows_move_selection():
+ app = make_app()
+ app.client.workspace.catalog["j"] = {
+ "session_id": "j",
+ "summary": "job",
+ "cwd": "/j",
+ "engine": "codex",
+ }
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "slash", "j")
+ assert app.screen.query_one(SearchInput).value == "j"
+ assert len(matches(app)) == 1
+ app.query_one(SearchInput).value = "target-123"
+ await pilot.pause()
+ await pilot.press("enter")
+ assert app.client.attached_sid == "target-123"
+
+
+@pytest.mark.asyncio
+async def test_picker_cancels_pending_vim_operator():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("one two")
+ await pilot.press("ctrl+j", "d", "escape", "space", "e", "escape", "w")
+ assert editor.text == "one two"
+ assert editor.cursor_location == (0, 4)
+
+
+@pytest.mark.asyncio
+async def test_quote_then_vim_edit_then_read_keeps_reading_anchor():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("g", "u", "j", "V", "space", "q")
+ position = reader.cursor_location
+ await pilot.press("ctrl+j", "i", "w", "h", "y", "escape", "h", "ctrl+k")
+ assert "why" in app.query_one(Composer).text
+ assert reader.cursor_location == position
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("text", "cursor", "keys", "expected"),
+ [
+ ("call(old)", 6, "ci(new", "call(new)"),
+ ("one two three", 5, "daw", "one three"),
+ ("one two three", 5, "ciwNEW", "one NEW three"),
+ ('say "old"', 6, 'ci"new', 'say "new"'),
+ ("one [old] rest", 6, "da[", "one rest"),
+ ("{outer(inner)}", 8, "cibX", "{outer(X)}"),
+ ("one two", 5, "viwd", "one "),
+ ("call()", 4, "ci(x", "call(x)"),
+ ("one ", 6, "da<", "one "),
+ ("one two", 0, "di(", "one two"),
+ ],
+)
+async def test_text_object_keyboard_commands(text, cursor, keys, expected):
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text(text)
+ editor.move_cursor((0, cursor))
+ await pilot.press("ctrl+j", *(_character_to_key(k) for k in keys))
+ assert editor.text == expected
+ assert app.focused is editor
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_text_object_yank_cancel_and_undo():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("one two")
+ await pilot.press("ctrl+j", "y", "i", "w")
+ assert editor.register == app.clipboard == "one"
+ assert editor.text == "one two"
+ await pilot.press("c", "i", "escape", "w")
+ assert editor.text == "one two" and editor.cursor_location == (0, 4)
+ await pilot.press("d", "a", "w", "u")
+ assert editor.text == "one two"
+
+
+@pytest.mark.asyncio
+async def test_directional_focus_keys_are_idempotent_and_preserve_text():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ editor = app.query_one(Composer)
+ await pilot.press("g", "u", "j")
+ position = reader.cursor_location
+ await pilot.press("ctrl+j", "i", "a", "b", "ctrl+j", "enter", "c")
+ assert editor.text == "ab\nc" and editor.vim_mode == "INSERT"
+ await pilot.press("ctrl+k", "ctrl+k")
+ assert app.focused is reader and reader.cursor_location == position
+ assert editor.text == "ab\nc"
+ await pilot.press("ctrl+j")
+ assert app.focused is editor and editor.vim_mode == "NORMAL"
+ assert editor.cursor_location == (1, 1)
+
+
+@pytest.mark.asyncio
+async def test_search_ctrl_j_k_and_arrows_share_selection():
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "slash")
+ listing = app.query_one(SessionTree)
+ start = listing.cursor_line
+ await pilot.press("ctrl+j")
+ assert listing.cursor_line == start + 1
+ await pilot.press("down")
+ assert listing.cursor_line == start + 2
+ await pilot.press("ctrl+k", "up")
+ assert listing.cursor_line == start
+ assert isinstance(app.focused, SearchInput)
+ assert app.screen.query_one(SearchInput).value == ""
+ app.query_one(SearchInput).value = "target-123"
+ await pilot.pause()
+ await pilot.press("enter")
+ assert app.client.attached_sid == "target-123"
+
+
+@pytest.mark.asyncio
+async def test_empty_search_ctrl_j_k_never_leaves_picker_or_sends():
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press(
+ "space", "e", "slash", "z", "z", "z", "ctrl+j", "ctrl+k", "enter"
+ )
+ assert app.query_one(SessionExplorer).display
+ assert not matches(app)
+ assert not app.client._outbox
diff --git a/tests/test_tui_engine_validation.py b/tests/test_tui_engine_validation.py
new file mode 100644
index 00000000..5a8c51a6
--- /dev/null
+++ b/tests/test_tui_engine_validation.py
@@ -0,0 +1,25 @@
+"""Invalid ENGINE configuration fails before connecting or opening the UI."""
+
+import pytest
+
+from cc_remote import tui, tui_app
+
+
+@pytest.mark.parametrize("engine", ["CODEX", "foo", "codex "])
+@pytest.mark.parametrize("line", [False, True])
+def test_invalid_engine_fails_before_client_start(monkeypatch, capsys, engine, line):
+ monkeypatch.setenv("ENGINE", engine)
+ monkeypatch.setattr(tui.sys, "argv", ["tui", *(["--line-mode"] if line else [])])
+ with pytest.raises(SystemExit) as error:
+ tui.main()
+ assert error.value.code == 2
+ assert "ENGINE must be" in capsys.readouterr().err
+
+
+def test_explicit_engine_overrides_invalid_environment(monkeypatch):
+ monkeypatch.setenv("ENGINE", "typo")
+ monkeypatch.setattr(tui.sys, "argv", ["tui", "--demo", "--engine", "codex"])
+ clients = []
+ monkeypatch.setattr(tui_app, "run_workspace", lambda c, **kw: clients.append(c))
+ tui.main()
+ assert clients[0].engine == "codex"
diff --git a/tests/test_tui_follow_recovery.py b/tests/test_tui_follow_recovery.py
new file mode 100644
index 00000000..3469646f
--- /dev/null
+++ b/tests/test_tui_follow_recovery.py
@@ -0,0 +1,96 @@
+"""Follow intent survives asynchronous wheel scrolling and transcript reflow."""
+
+import pytest
+
+from cc_remote.tui_app import Transcript, WorkspaceApp
+from cc_remote.tui_state import Block
+from tests.test_tui_send_jumps import client
+
+
+@pytest.mark.asyncio
+async def test_wheel_arrives_at_bottom_after_initial_callback():
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("k")
+ reader.scroll_to(y=reader.max_scroll_y - 5, animate=False, immediate=True)
+ reader.on_mouse_scroll_down()
+ await pilot.pause()
+ assert not view.follow # Scroll animation has not reached its target.
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ assert view.follow
+ view.put(Block("new", "assistant", "new output\n" * 30))
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == reader.max_scroll_y
+
+
+@pytest.mark.asyncio
+async def test_downward_navigation_resumes_at_viewport_bottom_not_last_line():
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("k", "k", "k")
+ app.paint()
+ assert view.follow # The reading cursor is not the scroll position.
+ assert reader.cursor_location != reader.document.end
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.press("j")
+ assert view.follow
+
+
+@pytest.mark.asyncio
+async def test_following_reflow_overrides_transient_middle_bookmark():
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ app.remember()
+ # Text/image re-projection can temporarily reset the scroll offset
+ # before a resize captures it. Follow intent remains authoritative.
+ bookmark = ("s", view.anchor, view.selection, ("user:t", 0),
+ False, True, True)
+ reader.resize_bookmark = bookmark
+ view.put(Block("new", "assistant", "new output\n" * 30))
+ app.paint()
+ reader.restore_resize(bookmark)
+ await pilot.pause()
+ assert view.follow
+ assert reader.scroll_y == reader.max_scroll_y
+
+
+@pytest.mark.asyncio
+async def test_ignored_read_key_does_not_disable_stream_follow():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+f8")
+ assert c.workspace.view("s").follow
+
+
+@pytest.mark.asyncio
+async def test_stale_wheel_callback_is_ignored_but_bottom_selection_follows():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ view = c.workspace.view("s")
+ app.stop_following()
+ reader.scroll_end(animate=False, immediate=True)
+ app.resume_following_at_bottom(False, "another-session")
+ assert not view.follow
+ old_revision = reader.follow_revision
+ app.stop_following() # A new upward gesture cancels an older callback.
+ app.resume_following_at_bottom(False, "s", old_revision)
+ assert not view.follow
+ await pilot.press("v", "k")
+ reader.on_mouse_scroll_down()
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ assert view.follow and reader.selected_text
diff --git a/tests/test_tui_generic_authorization.py b/tests/test_tui_generic_authorization.py
new file mode 100644
index 00000000..51838027
--- /dev/null
+++ b/tests/test_tui_generic_authorization.py
@@ -0,0 +1,28 @@
+"""Generic preview authorization preserves its original challenge identity."""
+
+import json
+
+import pytest
+
+from cc_remote.tui_actions import build_action, defaults
+from cc_remote.tui_presentation import SessionPresentation
+
+
+def test_generic_authorization_uses_reported_request_identity():
+ presentation = SessionPresentation()
+ presentation.reports["preview_authorization_required"] = {
+ "authorization_id": "challenge", "request_id": "original-read",
+ }
+ payload = defaults("authorize_preview", "s", "codex", {}, presentation)
+ payload["decision"] = "allow"
+ command = build_action("authorize_preview", json.dumps(payload), "s", "c")
+ assert command.request_id == "original-read"
+ assert command.authorization_id == "challenge"
+ assert command.cmd_id != command.request_id
+
+
+def test_other_actions_cannot_override_request_identity():
+ with pytest.raises(ValueError, match="cannot be edited"):
+ build_action("get_file_preview", json.dumps({
+ "path": "a.md", "request_id": "injected",
+ }), "s", "c")
diff --git a/tests/test_tui_goal_confirmations.py b/tests/test_tui_goal_confirmations.py
new file mode 100644
index 00000000..827ccca5
--- /dev/null
+++ b/tests/test_tui_goal_confirmations.py
@@ -0,0 +1,58 @@
+"""Goal reads must not fence later authoritative mutations."""
+
+import pytest
+
+from cc_remote import protocol as p, tui
+from tests.test_tui_workspace import client, emit
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("read_first", [False, True])
+@pytest.mark.parametrize("mutation", [
+ p.SetGoal(sid="s", cmd_id="mutate", objective="new"),
+ p.ClearGoal(sid="s", cmd_id="mutate"),
+ p.DismissGoal(sid="s", cmd_id="mutate", goal_id="g"),
+])
+async def test_goal_mutation_survives_attach_read(mutation, read_first):
+ c = client()
+ await c._send(p.GetGoal(sid="s", cmd_id="read"))
+
+ def old_read():
+ emit(c, "goal_state", request_id="read", goal_id="old",
+ goal={"status": "complete"})
+
+ if read_first:
+ old_read()
+ assert ("s", "goal_state") not in c.read_tickets
+ await c._send(mutation)
+ goal = None if isinstance(mutation, p.ClearGoal) else {"status": "active"}
+ dismissed = isinstance(mutation, p.DismissGoal)
+ emit(c, "goal_state", request_id="mutate", goal_id="g",
+ goal=goal, dismissed=dismissed)
+ old_read() # Delayed or duplicate read cannot replace the mutation.
+ view = c.workspace.view("s").presentation
+ assert view.goal_id == "g" and view.goal == goal
+ assert view.goal_dismissed == dismissed
+ assert ("s", "goal_state") not in c.read_tickets
+
+
+@pytest.mark.asyncio
+async def test_superseded_goal_reads_stay_fenced_after_ticket_retirement():
+ c = client()
+ for request in ("older", "newer"):
+ await c._send(p.GetGoal(sid="s", cmd_id=request))
+ emit(c, "goal_state", request_id="newer", goal_id="new")
+ emit(c, "goal_state", request_id="older", goal_id="old")
+ assert c.workspace.view("s").presentation.goal_id == "new"
+ assert ("s", "goal_state") not in c.read_tickets
+
+
+@pytest.mark.asyncio
+async def test_rejected_goal_read_preserves_admitted_ticket(monkeypatch):
+ c = client()
+ await c._send(p.GetGoal(sid="s", cmd_id="admitted"))
+ monkeypatch.setattr(tui, "TUI_OUTBOX_CAP", 0)
+ assert not await c._send(p.GetGoal(sid="s", cmd_id="rejected"))
+ assert c.read_tickets["s", "goal_state"][0] == "admitted"
+ emit(c, "goal_state", request_id="admitted", goal_id="g")
+ assert c.workspace.view("s").presentation.goal_id == "g"
diff --git a/tests/test_tui_graphics.py b/tests/test_tui_graphics.py
new file mode 100644
index 00000000..5d98339d
--- /dev/null
+++ b/tests/test_tui_graphics.py
@@ -0,0 +1,73 @@
+"""Assert the real Kitty protocol, not just the presence of an image widget."""
+
+from PIL import Image
+from rich.console import Console
+
+from cc_remote.tui_graphics import OwnedTGP, StableTGPImage
+
+
+def test_repaint_does_not_reupload_or_delete_other_images(monkeypatch):
+ sent = []
+ monkeypatch.setattr(
+ "textual_image.renderable.tgp._send_tgp_message",
+ lambda **kw: sent.append(kw),
+ )
+ image = Image.new("RGB", (40, 20), "green")
+ first, second = StableTGPImage(image), StableTGPImage(image)
+ console = Console(width=80, height=25)
+ for widget in (first, second):
+ list(console.render(widget.render()))
+ ids = [first._renderable.terminal_image_id,
+ second._renderable.terminal_image_id]
+ assert ids[0] != ids[1]
+ uploads = sum("payload" in m for m in sent)
+ sent.clear()
+ for _ in range(30):
+ list(console.render(first.render()))
+ list(console.render(second.render()))
+ assert uploads == 2 and not sent
+ first.image = None
+ assert sent == [dict(a="d", d="I", i=ids[0], q=2)]
+ assert second._renderable.terminal_image_id == ids[1]
+ second.on_unmount()
+ assert sent[-1] == dict(a="d", d="I", i=ids[1], q=2)
+ image.close()
+
+
+def test_replacing_pixels_releases_only_previous_upload(monkeypatch):
+ sent = []
+ monkeypatch.setattr(
+ "textual_image.renderable.tgp._send_tgp_message",
+ lambda **kw: sent.append(kw),
+ )
+ image = Image.new("RGB", (30, 20), "red")
+ widget = StableTGPImage(image)
+ console = Console(width=80)
+ list(console.render(widget.render()))
+ old = widget._renderable.terminal_image_id
+ widget.image = image
+ list(console.render(widget.render()))
+ assert widget._renderable.terminal_image_id != old
+ assert [m for m in sent if m.get("a") == "d"] == [
+ dict(a="d", d="I", i=old, q=2)
+ ]
+ widget.image = None
+ image.close()
+
+
+def test_resize_updates_placement_without_reupload(monkeypatch):
+ sent = []
+ monkeypatch.setattr(
+ "textual_image.renderable.tgp._send_tgp_message",
+ lambda **kw: sent.append(kw),
+ )
+ with Image.new("RGB", (1200, 800), "red") as image:
+ renderable = OwnedTGP(image, "auto", "auto")
+ list(Console(width=80, height=25).render(renderable))
+ old = renderable.terminal_image_id
+ sent.clear()
+ list(Console(width=30, height=15).render(renderable))
+ assert renderable.terminal_image_id == old
+ assert len(sent) == 1 and sent[0]["a"] == "p"
+ assert sent[0]["p"] == 1 and sent[0]["i"] == old
+ renderable.cleanup()
diff --git a/tests/test_tui_image_performance.py b/tests/test_tui_image_performance.py
new file mode 100644
index 00000000..efddf386
--- /dev/null
+++ b/tests/test_tui_image_performance.py
@@ -0,0 +1,147 @@
+"""Structural performance budgets: scrolling/panning must not upload pixels."""
+
+from types import SimpleNamespace
+
+import pytest
+from PIL import Image
+from rich.console import Console
+
+from cc_remote.tui_app import Transcript
+from cc_remote.tui_graphics import StableTGPImage
+from cc_remote.tui_image_viewer import ImageCanvas
+from cc_remote.tui_inline_images import TranscriptViewport
+from cc_remote.tui_preview import PreviewRef
+from cc_remote.tui_preview_views import FilePreviewScreen
+from cc_remote.tui_state import Block
+from tests.test_tui_inline_images import setup
+from tests.test_tui_image_viewer import large_reply
+from tests.test_tui_preview import make_app
+
+
+@pytest.mark.asyncio
+async def test_pan_and_zoom_send_only_placement_metadata(monkeypatch):
+ sent = []
+ monkeypatch.setattr("textual_image.renderable.tgp._send_tgp_message",
+ lambda **kw: sent.append(kw))
+ app = make_app()
+ source = Image.new("RGB", (1980, 2052), "green")
+ async with app.run_test(size=(100, 40)) as pilot:
+ canvas = ImageCanvas(StableTGPImage, source)
+ await app.mount(canvas)
+ # Keep the production pan/zoom path, with deterministic viewport size.
+ canvas.styles.width = 80
+ canvas.styles.height = 20
+ await pilot.pause()
+ canvas.draw_frame()
+ console = Console(width=80, height=20)
+ list(console.render(canvas.picture.render()))
+ image_id = canvas.picture._renderable.terminal_image_id
+ sent.clear()
+
+ def no_resampling(*args, **kwargs):
+ pytest.fail("Pan/zoom must not resample or re-encode source pixels")
+
+ monkeypatch.setattr(Image.Image, "resize", no_resampling)
+ for n in range(60):
+ canvas.zoom(1 if n % 10 < 5 else -1)
+ canvas.pan(1 if n % 2 else -1, 1 if n % 2 else -1)
+ canvas.draw_frame()
+ list(console.render(canvas.picture.render()))
+ assert canvas.frame is None
+ assert sent and all(m["a"] == "p" and "payload" not in m for m in sent)
+ assert all(m["i"] == image_id and m["p"] == 1 for m in sent)
+ assert all(m["w"] > 0 and m["h"] > 0 for m in sent)
+ assert len(sent) <= 60
+ assert sum(len(str(m)) for m in sent) < 15000
+
+
+@pytest.mark.asyncio
+async def test_scroll_out_and_back_keeps_the_same_uploaded_image(monkeypatch):
+ app, view, calls = setup(monkeypatch)
+ app.graphics = StableTGPImage
+ sent = []
+ monkeypatch.setattr("textual_image.renderable.tgp._send_tgp_message",
+ lambda **kw: sent.append(kw))
+ view.put(Block("b", "assistant", "\n".join(
+ f"line {n}" for n in range(70)
+ ), "t2"))
+ async with app.run_test(size=(80, 28)) as pilot:
+ app.stop_following()
+ reader = app.query_one(Transcript)
+ viewport = app.query_one(TranscriptViewport)
+ reader.scroll_home(animate=False, immediate=True)
+ await pilot.pause()
+ await pilot.pause()
+ picture = next(iter(viewport.pictures.values()))[1]
+ console = Console(width=80, height=28)
+ list(console.render(picture.picture.render()))
+ renderable = picture.picture._renderable
+ sent.clear()
+ for _ in range(5):
+ reader.scroll_end(animate=False, immediate=True)
+ viewport.sync()
+ assert not viewport.pictures and viewport.parked
+ assert not picture.display
+ reader.scroll_home(animate=False, immediate=True)
+ viewport.sync()
+ assert next(iter(viewport.pictures.values()))[1] is picture
+ list(console.render(picture.picture.render()))
+ assert picture.picture._renderable is renderable
+ assert not sent and len(calls) == 1
+ # Real removal must still release the parked terminal image.
+ reader.scroll_end(animate=False, immediate=True)
+ viewport.sync()
+ view.blocks.clear()
+ view.version += 1
+ app.paint()
+ await pilot.pause()
+ assert not viewport.parked and not viewport.pictures
+ assert any(m.get("a") == "d" for m in sent)
+
+
+def test_offscreen_cache_eviction_releases_widgets_and_pixels():
+ viewport = TranscriptViewport(Transcript())
+ images = {n: Image.new("RGB", (20, 10)) for n in range(12)}
+ viewport.cache = images.copy()
+ removed = []
+ viewport.pictures = {10: None, 11: None}
+ viewport.parked = {
+ n: (None, SimpleNamespace(remove=lambda n=n: removed.append(n)))
+ for n in range(10)
+ }
+ viewport.trim_cache()
+ assert len(viewport.cache) == viewport.MAX_CACHE
+ assert removed == [0, 1, 2, 3]
+ assert 10 in viewport.cache and 11 in viewport.cache
+ for n in removed:
+ with pytest.raises(ValueError):
+ images[n].getpixel((0, 0))
+ for image in viewport.cache.values():
+ image.close()
+
+
+@pytest.mark.asyncio
+async def test_keyboard_pan_reaches_kitty_without_manual_render(monkeypatch):
+ sent = []
+ monkeypatch.setattr("textual_image.renderable.tgp._send_tgp_message",
+ lambda **kw: sent.append(kw))
+
+ async def request(*args, **kwargs):
+ return large_reply()
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ app = make_app()
+ async with app.run_test(size=(100, 40)) as pilot:
+ await app.push_screen(FilePreviewScreen(
+ app.client, "s", PreviewRef("board.png", "image"), StableTGPImage,
+ ))
+ await pilot.pause()
+ await pilot.press("z", "i")
+ await pilot.pause()
+ before = [m for m in sent if m.get("a") == "p"][-1]
+ sent.clear()
+ await pilot.press("j")
+ await pilot.pause()
+ updates = [m for m in sent if m.get("a") == "p"]
+ assert updates and updates[-1]["y"] > before["y"]
+ assert not any("payload" in m for m in sent)
diff --git a/tests/test_tui_image_viewer.py b/tests/test_tui_image_viewer.py
new file mode 100644
index 00000000..b2a70d2d
--- /dev/null
+++ b/tests/test_tui_image_viewer.py
@@ -0,0 +1,130 @@
+"""Full-resolution image viewing, scoped chords, and bounded local rendering."""
+
+import base64
+import io
+
+import pytest
+from PIL import Image
+
+from cc_remote.tui_image_viewer import ImageCanvas, ImageView
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_preview import PreviewRef, decode_image
+from cc_remote.tui_preview_views import FilePreviewScreen
+from tests.test_tui_preview import FakeImage, make_app
+
+
+def large_reply():
+ stream = io.BytesIO()
+ with Image.new("RGB", (1980, 2052), "green") as image:
+ image.save(stream, format="PNG")
+ return dict(type="file_preview", format="image", media_type="image/png",
+ data=base64.b64encode(stream.getvalue()).decode())
+
+
+def test_original_resolution_and_thumbnail_limits():
+ reply = large_reply()
+ with decode_image(reply) as thumbnail:
+ assert thumbnail.width <= 1024 and thumbnail.height <= 768
+ with decode_image(reply, thumbnail=False) as original:
+ assert original.size == (1980, 2052)
+
+
+def test_pan_bounds_keep_source_visible_at_every_zoom():
+ view = ImageView(1980, 2052)
+ assert view.bounds((800, 600))[0] == (0, 0, 1980, 2052)
+ view.zoom = 4
+ for dx, dy in ((-100, -100), (100, 100), (1, -1)):
+ view.pan(dx, dy, (800, 600))
+ box, _ = view.bounds((800, 600))
+ assert 0 <= box[0] < box[2] <= view.width
+ assert 0 <= box[1] < box[3] <= view.height
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("custom", [False, True])
+async def test_image_zoom_pan_fit_and_close_are_local(monkeypatch, custom):
+ app = make_app()
+ if custom:
+ app.client.keys = KeyConfig({"image": {
+ "zoom_in": ["x l"], "zoom_out": ["x o"], "fit": ["x f"],
+ }})
+ calls = []
+
+ async def request(*args, **kwargs):
+ calls.append(args)
+ return large_reply()
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ async with app.run_test(size=(100, 40)) as pilot:
+ await app.push_screen(FilePreviewScreen(
+ app.client, "s", PreviewRef("board.png", "image"), FakeImage,
+ ))
+ await pilot.pause()
+ canvas = app.screen.query_one(ImageCanvas)
+ assert canvas.source.size == (1980, 2052)
+ assert canvas.size.height > 9
+ chord = "x" if custom else "z"
+ zoom_key = "l" if custom else "i"
+ await pilot.press(chord, zoom_key, chord, zoom_key, chord, zoom_key)
+ assert canvas.view.zoom == 1.5 ** 3
+ center = (canvas.view.x, canvas.view.y)
+ await pilot.press("h", "j")
+ assert canvas.view.x < center[0] and canvas.view.y > center[1]
+ await pilot.press("l", "k")
+ assert canvas.view.x == pytest.approx(center[0])
+ assert canvas.view.y == pytest.approx(center[1])
+ await pilot.press(chord, "o")
+ assert canvas.view.zoom == 1.5 ** 2
+ await pilot.resize_terminal(55, 24)
+ await pilot.pause()
+ assert canvas.view.zoom == 1.5 ** 2
+ assert canvas.frame.width <= 1600 and canvas.frame.height <= 1200
+ await pilot.press(chord, "f")
+ assert canvas.view.zoom == 1 and canvas.view.x == 0.5
+ assert len(calls) == 1 and not app.client._outbox
+ # One Esc closes even while a zoom chord is incomplete.
+ await pilot.press(chord, "escape")
+ assert len(app.screen_stack) == 1
+ assert canvas.picture.image is None
+ with pytest.raises(ValueError):
+ canvas.source.getpixel((0, 0))
+
+
+def test_image_chords_are_indexed_and_conflict_checked():
+ keys = KeyConfig()
+ assert "Image preview" in keys.help()
+ assert "zoom_in" in keys.help()
+ with pytest.raises(ValueError, match="prefix"):
+ KeyConfig({"image": {"fit": ["z"]}})
+
+
+@pytest.mark.asyncio
+async def test_image_authorization_is_explicit(monkeypatch):
+ app = make_app()
+ calls = []
+
+ async def request(*args, **kwargs):
+ calls.append(kwargs)
+ if kwargs.get("challenge"):
+ return dict(status="granted")
+ if len(calls) == 1:
+ return dict(type="preview_authorization_required",
+ authorization_id="exact-file", resolved_path="/x.png",
+ request_id="tui-preview-image")
+ return large_reply()
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ async with app.run_test() as pilot:
+ await app.push_screen(FilePreviewScreen(
+ app.client, "s", PreviewRef("/x.png", "image"), FakeImage,
+ ))
+ await pilot.pause()
+ await pilot.press("z", "i")
+ assert len(calls) == 1 and not app.screen.query(ImageCanvas)
+ await pilot.press("a")
+ await pilot.pause()
+ assert calls == [{}, {"challenge": {
+ "authorization_id": "exact-file",
+ "request_id": "tui-preview-image",
+ }}, {}]
+ assert app.screen.query(ImageCanvas)
diff --git a/tests/test_tui_inline_images.py b/tests/test_tui_inline_images.py
new file mode 100644
index 00000000..50473755
--- /dev/null
+++ b/tests/test_tui_inline_images.py
@@ -0,0 +1,231 @@
+"""Inline pixels must share the transcript viewport, not persistent chrome."""
+
+import asyncio
+
+import pytest
+from textual.events import MouseScrollDown
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_app import Transcript
+from cc_remote.tui_inline_images import TranscriptViewport, InlinePicture
+from cc_remote.tui_preview import detect_graphics
+from cc_remote.tui_state import Block
+from tests.test_tui_preview import make_app, image_reply, FakeImage
+
+
+def setup(monkeypatch, text="\n\nAfter image."):
+ app = make_app()
+ app.graphics = FakeImage
+ calls = []
+
+ async def request(client, sid, path, **kwargs):
+ calls.append((sid, path, kwargs))
+ return image_reply()
+
+ monkeypatch.setattr("cc_remote.tui_inline_images.preview_request", request)
+ view = app.client.workspace.view("s")
+ view.put(Block("a", "assistant", text, "turn"))
+ return app, view, calls
+
+
+@pytest.mark.parametrize("supported", [True, False])
+def test_tmux_never_chooses_its_sixel_placeholder(monkeypatch, supported):
+ from textual_image.widget import HalfcellImage
+ from cc_remote.tui_graphics import StableTGPImage
+
+ class Tty:
+ def isatty(self):
+ return True
+
+ monkeypatch.setattr("cc_remote.tui_preview.sys.__stdin__", Tty())
+ monkeypatch.setattr("cc_remote.tui_preview.sys.__stdout__", Tty())
+ monkeypatch.setenv("TMUX", "/test/tmux")
+ monkeypatch.setattr(
+ "textual_image.renderable.tgp.query_terminal_support", lambda: supported
+ )
+ assert detect_graphics() is (StableTGPImage if supported else HalfcellImage)
+
+
+@pytest.mark.asyncio
+async def test_inline_paragraph_positions_and_source_copy(monkeypatch):
+ app, view, calls = setup(monkeypatch,
+ "First .\n\nSecond `b.png`.\n\nEnd.")
+ async with app.run_test(size=(100, 55)) as pilot:
+ await pilot.pause()
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ viewport = app.query_one(TranscriptViewport)
+ slots = viewport.projection.slots
+ assert [s.path for s in slots] == ["a.png", "b.png"]
+ first, second, end = [reader.text.index(w)
+ for w in ("First", "Second", "End.")]
+ assert first < slots[0].start < second < slots[1].start < end
+ assert len(viewport.query(InlinePicture)) == 2
+ for _ in range(3):
+ await pilot.pause(0.12)
+ assert reader.size.height == viewport.content_size.height
+ assert len(viewport.query(InlinePicture)) == 2
+ assert not app.query("#image-shelf")
+ assert [c[1] for c in calls] == ["a.png", "b.png"]
+ source, source_starts = view.render()
+ visible = source.replace("", "A (a.png)").replace(
+ "`b.png`", "b.png"
+ )
+ reader.selection = Selection((0, 0), reader.document.end)
+ assert reader.selected_text == visible
+ reader.operate("y", (0, 0), reader.document.end)
+ assert app.clipboard == visible
+ assert view.render()[0] == source
+ for word in ("First", "Second", "End."):
+ pos = reader.text.index(word)
+ anchor = reader.projection.locate(view, pos, app.starts)
+ assert anchor == view.locate(source.index(word), source_starts)
+ assert reader.projection.resolve(
+ view, anchor, app.starts, len(reader.text)
+ ) == pos
+ assert reader.projection.resolve(
+ view, ("missing", 0), app.starts, len(reader.text),
+ fallback=pos,
+ ) == pos
+
+
+@pytest.mark.asyncio
+async def test_reference_image_starts_below_the_complete_rendered_paragraph(
+ monkeypatch,
+):
+ app, view, calls = setup(
+ monkeypatch, "![photo][ref]\n\n[ref]: photo.png"
+ )
+ async with app.run_test(size=(90, 40)) as pilot:
+ await pilot.pause()
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ viewport = app.query_one(TranscriptViewport)
+ assert "photo (photo.png)\n" in reader.text
+ slot, = viewport.projection.slots
+ assert slot.start > reader.text.index("photo.png)") + len("photo.png)")
+ assert reader.text[slot.start - 1] == "\n"
+ assert [call[1] for call in calls] == ["photo.png"]
+
+
+@pytest.mark.asyncio
+async def test_scroll_clips_images_and_never_covers_input(monkeypatch):
+ app, view, _ = setup(monkeypatch)
+ view.put(Block("b", "assistant", "\n".join(
+ f"line {n}" for n in range(60)
+ ), "t2"))
+ async with app.run_test(size=(80, 28)) as pilot:
+ await pilot.pause()
+ viewport = app.query_one(TranscriptViewport)
+ reader = app.query_one(Transcript)
+ # History offscreen is not read merely to paint the latest turn.
+ assert not viewport.cache
+ app.stop_following()
+ reader.scroll_home(animate=False, immediate=True)
+ await pilot.pause()
+ await pilot.pause()
+ assert viewport.cache and viewport.pictures
+ picture = viewport.query_one(InlinePicture)
+ region = app.screen.find_widget(picture).visible_region
+ assert region.y >= viewport.region.y
+ assert region.bottom <= viewport.region.bottom
+ before = reader.scroll_y
+ picture.picture.post_message(MouseScrollDown(
+ picture.picture, 1, 1, 0, 0, 0, False, False, False
+ ))
+ await pilot.pause()
+ assert reader.scroll_y > before
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ assert not viewport.pictures
+ reader.scroll_home(animate=False, immediate=True)
+ await pilot.pause()
+ assert viewport.pictures # cached image reappears in its original spot
+
+
+@pytest.mark.asyncio
+async def test_image_resize_preserves_reader_anchor_and_draft(monkeypatch):
+ app, view, _ = setup(monkeypatch)
+ async with app.run_test(size=(110, 40)) as pilot:
+ await pilot.pause()
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ pos = reader.text.index("After")
+ reader.move_cursor(reader.document.get_location_from_index(pos))
+ app.remember()
+ anchor = view.anchor
+ await pilot.press("ctrl+j", "i", "x")
+ await pilot.resize_terminal(32, 30)
+ await pilot.pause()
+ assert view.anchor == anchor
+ assert reader.document.get_text_range(
+ reader.cursor_location,
+ (reader.cursor_location[0], reader.cursor_location[1] + 5)
+ ) == "After"
+ assert app.focused.id == "composer" and app.focused.text == "x"
+ picture = app.query_one(InlinePicture)
+ assert picture.size.width <= reader.content_size.width
+
+
+@pytest.mark.asyncio
+async def test_switch_and_cwd_change_discard_inflight_image(monkeypatch):
+ app, view, _ = setup(monkeypatch)
+ waiting = asyncio.Event()
+ cancelled = []
+
+ async def request(*args):
+ try:
+ await waiting.wait()
+ return image_reply()
+ except asyncio.CancelledError:
+ cancelled.append(True)
+ raise
+
+ monkeypatch.setattr("cc_remote.tui_inline_images.preview_request", request)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ viewport = app.query_one(TranscriptViewport)
+ assert viewport.pending
+ app.client.workspace.catalog["s"] = {"cwd": "/new"}
+ app.paint()
+ await pilot.pause()
+ assert cancelled and viewport.identity == ("s", "/new", 0)
+ app.client.attached_sid = "other"
+ app.paint()
+ await pilot.pause()
+ waiting.set()
+ await pilot.pause()
+ assert not viewport.cache and not viewport.pictures
+
+
+@pytest.mark.asyncio
+async def test_no_auto_read_from_tool_code_or_remote_link(monkeypatch):
+ app, view, calls = setup(monkeypatch,
+ "```\na.png\n```\n\n")
+ view.put(Block("tool", "tool", "c.png"))
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ assert not calls
+ assert not app.query_one(TranscriptViewport).projection.slots
+
+
+@pytest.mark.asyncio
+async def test_folded_history_and_modal_remove_pixels(monkeypatch):
+ app, view, _ = setup(monkeypatch)
+ async with app.run_test(size=(90, 40)) as pilot:
+ await pilot.pause()
+ await pilot.pause()
+ viewport = app.query_one(TranscriptViewport)
+ assert viewport.pictures
+ await pilot.press("space", "h")
+ await pilot.pause(0.15)
+ assert not viewport.pictures
+ await pilot.press("escape")
+ await pilot.pause()
+ assert viewport.pictures
+ view.blocks.clear()
+ view.version += 1
+ app.paint()
+ await pilot.pause()
+ assert not viewport.projection.slots and not viewport.pictures
diff --git a/tests/test_tui_key_registry.py b/tests/test_tui_key_registry.py
new file mode 100644
index 00000000..d8412d56
--- /dev/null
+++ b/tests/test_tui_key_registry.py
@@ -0,0 +1,232 @@
+"""Configuration, dispatch, help and inspection share one shortcut registry."""
+
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tomllib
+
+import pytest
+from textual.widgets import OptionList, TextArea
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp, WorkspaceClient
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_panels import ShortcutPicker
+from cc_remote.tui_preview import PreviewRef
+from cc_remote.tui_preview_views import FileHints
+from cc_remote.tui_settings import TextValue, ValuePicker
+from cc_remote.tui_state import Block
+from cc_remote.tui_tree import SessionExplorer, SessionTree, TreeSearch
+
+
+def make_app(config=None):
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ c.keys = KeyConfig(config)
+ c.workspace.catalog["s"] = dict(
+ session_id="s", engine="codex", space="code", cwd="/project",
+ summary="Current session",
+ )
+ v = c.workspace.view("s")
+ v.write_state = "writable"
+ v.put(Block("u", "user", "question\n" * 20, "t"))
+ v.put(Block("a", "assistant", "answer\n" * 20, "t", "final"))
+ return WorkspaceApp(c, connect=False)
+
+
+def test_index_preserves_layers_and_disabled_aliases_without_misleading_help():
+ keys = KeyConfig()
+ rows = {r["id"]: r for r in keys.index()}
+ assert rows["keys.sessions"]["keys"] == []
+ assert rows["normal.tree"]["keys"] == ["space e"]
+ assert keys.label("sessions") == keys.label("tree") == "Space e"
+ assert "[keys.sessions]" not in keys.help()
+ assert "[normal.tree]" in keys.help()
+ assert "unbound" not in keys.help()
+ assert {r["id"] for r in keys.index("session tree")} >= {
+ "keys.sessions", "normal.tree",
+ }
+ for layer in ("reader", "draft", "tree_search", "picker", "file_hints"):
+ assert any(r["layer"] == layer for r in rows.values())
+ assert json.loads(json.dumps(keys.index())) == keys.index()
+ assert [r["id"] for r in keys.lookup("space e")] == ["normal.tree"]
+ assert [r["id"] for r in keys.lookup("enter", layer="draft")] == ["draft.send"]
+
+
+@pytest.mark.parametrize("config", [
+ {"normal": {"help": ["g"]}},
+ {"normal": {"help": ["enter"]}},
+ {"tree": {"rename": ["space"]}},
+])
+def test_cross_layer_prefixes_cannot_hide_registered_actions(config):
+ with pytest.raises(ValueError, match="Conflicting"):
+ KeyConfig(config)
+
+
+def test_index_cli_reads_config_without_login_or_connection(tmp_path):
+ config = tmp_path / "keys.toml"
+ config.write_text('[normal]\ntree = ["space z"]\n')
+ result = subprocess.run(
+ [sys.executable, "-m", "cc_remote.tui", "--config", str(config),
+ "--list-keys", "normal.tree", "--json"],
+ capture_output=True, text=True, check=True, timeout=10,
+ )
+ assert json.loads(result.stdout)[0]["keys"] == ["space z"]
+ assert "password" not in result.stdout.lower()
+
+
+def test_example_covers_every_registered_action_with_current_defaults():
+ path = Path(__file__).resolve().parents[1] / "docs/tui-keys.example.toml"
+ data = tomllib.loads(path.read_text())
+ defaults = KeyConfig().index()
+ assert {f"{layer}.{name}" for layer, section in data.items()
+ for name in section if layer != "vim"} == {
+ row["id"] for row in defaults
+ }
+ assert KeyConfig(data).index() == defaults
+ assert KeyConfig(data).yank_highlight_ms == KeyConfig().yank_highlight_ms
+
+
+@pytest.mark.parametrize("row", KeyConfig().index(), ids=lambda row: row["id"])
+def test_every_function_shortcut_can_be_remapped_disabled_and_indexed(row):
+ layer, name = row["id"].split(".")
+ remapped = KeyConfig({layer: {name: ["f20"]}})
+ match = next(r for r in remapped.index() if r["id"] == row["id"])
+ assert match["keys"] == ["f20"] and match["enabled"]
+ assert row["id"] in {r["id"] for r in remapped.lookup("f20", layer=layer)}
+ for old in row["keys"]:
+ assert row["id"] not in {
+ r["id"] for r in remapped.lookup(old, layer=layer)
+ }
+ disabled = KeyConfig({layer: {name: []}})
+ match = next(r for r in disabled.index() if r["id"] == row["id"])
+ assert match["keys"] == [] and not match["enabled"]
+
+
+@pytest.mark.asyncio
+async def test_picker_boundary_navigation_uses_only_configured_keys():
+ app = make_app({"picker": {"first": ["ctrl+b"], "last": ["ctrl+f"]}})
+ async with app.run_test() as pilot:
+ app.push_screen(ValuePicker("Choose", [("a", "a"), ("b", "b")]))
+ await pilot.pause()
+ listing = app.screen.query_one(OptionList)
+ assert listing.highlighted == 0
+ await pilot.press("end")
+ assert listing.highlighted == 0
+ await pilot.press("ctrl+f")
+ assert listing.highlighted == 1
+ await pilot.press("home")
+ assert listing.highlighted == 1
+ await pilot.press("ctrl+b")
+ assert listing.highlighted == 0
+
+
+@pytest.mark.asyncio
+async def test_draft_send_rebind_removes_enter_and_preserves_insert_newlines():
+ app = make_app({"draft": {"send": ["ctrl+y"]}})
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "x", "enter", "y", "escape", "enter")
+ editor = app.query_one(Composer)
+ assert editor.text == "x\ny" and not app.client._outbox
+ await pilot.press("ctrl+y")
+ assert editor.text == "" and len(app.client._outbox) == 1
+ await pilot.press("space", "enter")
+ assert len(app.screen_stack) == 2 # Leader is not draft confirmation.
+
+
+@pytest.mark.asyncio
+async def test_reader_custom_jumps_details_and_older_have_no_old_key_fallback():
+ app = make_app({"reader": {
+ "latest_user": ["g U"], "latest_assistant": ["g A"],
+ "details": ["x"], "older": ["z"], "command": ["semicolon"],
+ }})
+ v = app.client.workspace.view("s")
+ v.put(Block("d", "tool", "command\nprivate details", "t"))
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("g", "g", "g", "a")
+ assert reader.cursor_location == (0, 0)
+ await pilot.press("g", "A")
+ assert app.current_block().id == "a"
+ await pilot.press("colon")
+ assert not app.command_mode
+ await pilot.press("semicolon")
+ assert app.command_mode
+ await pilot.press("escape", "ctrl+k", "G", "enter")
+ assert not v.blocks[-1].expanded
+ await pilot.press("x")
+ app.paint()
+ assert "private details" in reader.text
+ assert "[x: details]" not in reader.text
+
+
+@pytest.mark.asyncio
+async def test_tree_and_search_choose_can_be_rebound_independently():
+ app = make_app({"tree": {"choose": ["x"]},
+ "tree_search": {"choose": ["ctrl+y"], "close": ["ctrl+z"]}})
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "H", "enter")
+ tree = app.query_one(SessionTree)
+ assert not tree.cursor_node.is_expanded
+ await pilot.press("x")
+ assert tree.cursor_node.is_expanded
+ await pilot.press("slash", *"Current", "enter")
+ assert isinstance(app.focused, TreeSearch)
+ await pilot.press("ctrl+y")
+ assert isinstance(app.focused, Transcript)
+ await pilot.press("space", "e", "space", "e", "slash", "ctrl+z")
+ assert isinstance(app.focused, SessionTree)
+ assert not app.query_one(SessionExplorer).query_one(TreeSearch).display
+
+
+@pytest.mark.asyncio
+async def test_picker_rebinding_works_in_search_without_inherited_enter():
+ app = make_app({"picker": {"choose": ["ctrl+y"], "down": ["n", "down"]}})
+ picked = []
+ async with app.run_test() as pilot:
+ app.push_screen(ValuePicker("Pick", [("one", 1), ("two", 2)]), picked.append)
+ await pilot.pause()
+ await pilot.press("n")
+ assert app.screen.query_one(ModalEditor).text == "n"
+ await pilot.press("enter")
+ assert not picked
+ await pilot.press("ctrl+y")
+ assert picked == [(1,)]
+
+
+@pytest.mark.asyncio
+async def test_forms_and_file_numbers_use_the_same_indexed_configuration():
+ app = make_app({"form": {"confirm": ["ctrl+y"]},
+ "file_hints": {"select_1": ["x"]}})
+ picked = []
+ async with app.run_test() as pilot:
+ app.push_screen(TextValue("Name", "value"), picked.append)
+ await pilot.pause()
+ await pilot.press("enter")
+ assert not picked
+ await pilot.press("ctrl+y")
+ assert picked == [("value",)]
+ app.push_screen(FileHints([PreviewRef("/a.md", "markdown")]), picked.append)
+ await pilot.pause()
+ assert "x markdown" in app.screen.query_one(OptionList).get_option_at_index(0).prompt.plain
+ await pilot.press("1")
+ assert len(picked) == 1
+ await pilot.press("x")
+ assert picked[-1].path == "/a.md"
+
+
+@pytest.mark.asyncio
+async def test_help_search_is_inspection_only_and_uses_remapped_keys():
+ app = make_app({"normal": {"tree": ["space z"]}})
+ async with app.run_test() as pilot:
+ await pilot.press("space", "h")
+ help_text = app.screen.query_one(TextArea).text
+ assert "Space z" in help_text and "Space e" not in help_text
+ await pilot.press("slash", *"normal.tree")
+ assert isinstance(app.screen, ShortcutPicker)
+ assert app.screen.query_one(ModalEditor).text == "normal.tree"
+ listing = app.screen.query_one(OptionList)
+ assert listing.option_count == 1
+ assert "Space z" in listing.get_option_at_index(0).prompt.plain
+ await pilot.press("enter")
+ assert not app.client._outbox # Search never executes indexed actions.
diff --git a/tests/test_tui_launcher.py b/tests/test_tui_launcher.py
new file mode 100644
index 00000000..affcd7e0
--- /dev/null
+++ b/tests/test_tui_launcher.py
@@ -0,0 +1,49 @@
+"""A linked launcher always imports its own checkout, not the caller's cwd."""
+
+import json
+import os
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+
+import pytest
+
+
+def test_launcher_uses_own_checkout_from_unrelated_cwd(tmp_path):
+ root = Path(__file__).resolve().parents[1]
+ link = tmp_path / "tui"
+ link.symlink_to(root / "scripts/cc-remote-tui")
+ result = subprocess.run(
+ ["bash", str(link), "--list-keys", "normal.tree", "--json"],
+ cwd=tmp_path, capture_output=True, text=True, timeout=10, check=True,
+ )
+ assert json.loads(result.stdout)[0]["id"] == "normal.tree"
+ assert "password" not in result.stdout.lower()
+
+
+@pytest.mark.parametrize("relative", [False, True])
+def test_launcher_with_bsd_readlink_and_chained_symlinks(tmp_path, relative):
+ root = Path(__file__).resolve().parents[1]
+ bindir = tmp_path / "bin with spaces"
+ bindir.mkdir()
+ # Emulate BSD readlink: only the single path operand is accepted.
+ readlink = bindir / "readlink"
+ readlink.write_text(
+ '#!/bin/sh\n[ "$#" -eq 1 ] || exit 64\n'
+ f'exec {shlex.quote(sys.executable)} -c '
+ "'import os, sys; print(os.readlink(sys.argv[1]))' \"$1\"\n"
+ )
+ readlink.chmod(0o755)
+ inner = bindir / "inner"
+ inner.symlink_to(root / "scripts/cc-remote-tui")
+ outer = tmp_path / "linked tui"
+ outer.symlink_to("bin with spaces/inner" if relative else inner)
+ cwd = tmp_path / "unrelated"
+ cwd.mkdir()
+ result = subprocess.run(
+ ["bash", str(outer), "--list-keys", "normal.tree", "--json"],
+ cwd=cwd, env={**os.environ, "PATH": f"{bindir}:{os.environ['PATH']}"},
+ capture_output=True, text=True, timeout=10, check=True,
+ )
+ assert json.loads(result.stdout)[0]["id"] == "normal.tree"
diff --git a/tests/test_tui_live_layout.py b/tests/test_tui_live_layout.py
new file mode 100644
index 00000000..2a4b80c9
--- /dev/null
+++ b/tests/test_tui_live_layout.py
@@ -0,0 +1,213 @@
+"""Live tail, terminal folding and narrow terminal regressions (no model)."""
+
+import pytest
+from textual.widgets import Static
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, Transcript, location
+from cc_remote.tui_chrome import status_text
+from cc_remote.tui_presentation import TurnDisplay
+from cc_remote.tui_state import Block, SessionView, WorkspaceState
+
+
+def client():
+ return WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+
+
+def turn(view):
+ view.event(dict(type="user_msg", msg_id="t", prompt="question", ts=1000))
+ view.event(dict(type="delta", message_id="p", channel="commentary",
+ text="progress explanation"))
+ view.event(dict(type="tool_use", tool_use_id="tool", tool="command",
+ input={"command": "echo test"}))
+ view.event(dict(type="tool_result", tool_use_id="tool", content="result"))
+
+
+@pytest.mark.parametrize("terminal", ["final", "success", "error_during_execution"])
+def test_live_processes_fold_without_losing_data(terminal):
+ view = SessionView()
+ turn(view)
+ assert "progress explanation" in view.render()[0]
+ if terminal == "final":
+ view.event(dict(type="delta", message_id="answer", channel="final",
+ text="final answer"))
+ # Async questions also use final; the phase is not a terminal.
+ assert "progress explanation" in view.render()[0]
+ assert "── Turn details" in view.render()[0]
+ view.event(dict(type="turn_end", turn_id="t",
+ result={"subtype": "success"}))
+ else:
+ view.event(dict(type="turn_end", turn_id="t",
+ result={"subtype": terminal}, ts=1010))
+ text, starts = view.render()
+ assert text.count("── Turn details") == 1
+ assert "progress explanation" in text
+ assert "echo test" not in text
+ detail = next(b for _, b in starts if b.role == "detail")
+ assert detail.expanded
+ group = next(b for _, b in starts if b.role == "tool_group")
+ assert not group.expanded
+ group.expanded = True
+ text, _ = view.render()
+ assert "progress explanation" in text and "echo test" in text
+ view.event(dict(type="tool_delta", tool_use_id="tool", delta="late tail"))
+ assert "late tail" in view.render()[0]
+ if terminal == "final":
+ assert "final answer" in text
+ detail.expanded = False
+ text, starts = view.render()
+ assert view.resolve(("p", 30), starts, len(text)) == next(
+ start for start, b in starts if b.role == "detail"
+ )
+
+
+def test_fetched_details_do_not_fold_recursively_or_duplicate_live_tools():
+ state = WorkspaceState()
+ view = state.view("s")
+ turn(view)
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ view.revision = "r"
+ state.event(dict(type="turn_detail", session_id="s", revision="r", turn_id="t",
+ events=[dict(type="tool_use", tool_use_id="tool",
+ tool="command", input={}),
+ dict(type="turn_end", turn_id="t", result={})]))
+ text, starts = view.render()
+ assert sum(b.role == "detail" for _, b in starts) == 1
+ assert "1 个工具调用" in text
+ group = next(b for _, b in starts if b.role == "tool_group")
+ group.expanded = True
+ assert view.render()[0].count("── Tool") == 1
+ assert "Enter: show this turn's details" not in text
+
+
+@pytest.mark.asyncio
+async def test_follow_tail_after_append_resize_and_return_from_reading():
+ c = client()
+ view = c.workspace.view("s")
+ view.put(Block("p", "assistant", "line\n" * 100))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(90, 30)) as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.pause()
+ assert reader.scroll_y == reader.max_scroll_y
+ view.put(Block("p", "assistant", "new output\n" * 30), append=True)
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == reader.max_scroll_y
+ await pilot.resize_terminal(42, 24)
+ await pilot.pause()
+ assert reader.scroll_y == reader.max_scroll_y
+ await pilot.press("g", "g")
+ top = reader.scroll_y
+ assert not view.follow
+ view.put(Block("p", "assistant", "more\n" * 30), append=True)
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == top
+ reader.scroll_end(animate=False, force=True)
+ reader.on_mouse_scroll_down()
+ await pilot.pause()
+ assert view.follow
+ view.put(Block("p", "assistant", "last\n" * 30), append=True)
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == reader.max_scroll_y
+
+
+@pytest.mark.asyncio
+async def test_selection_at_bottom_follows_without_clearing_selection():
+ c = client()
+ view = c.workspace.view("s")
+ view.put(Block("p", "assistant", "line\n" * 100))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ reader.selection = Selection((95, 0), (95, 4))
+ reader.scroll_end(animate=False, force=True)
+ reader.on_mouse_up()
+ await pilot.pause()
+ assert view.follow and reader.selected_text == "line"
+
+
+@pytest.mark.asyncio
+async def test_keyboard_bottom_resumes_and_stale_buffer_callback_is_ignored():
+ c = client()
+ view = c.workspace.view("s")
+ view.put(Block("p", "assistant", "line\n" * 60))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("k")
+ app.paint()
+ assert view.follow # Cursor moved, but the viewport remains at bottom.
+ await pilot.press("j")
+ await pilot.pause()
+ assert view.follow
+ app.stop_following()
+ reader.move_cursor((0, 0))
+ app.follow_tail("another-buffer")
+ assert reader.cursor_location == (0, 0)
+
+
+@pytest.mark.asyncio
+async def test_local_fold_opens_and_closes_from_body_without_network():
+ c = client()
+ view = c.workspace.view("s")
+ turn(view)
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ start = next(i for i, b in app.starts if b.role == "detail")
+ app.stop_following() # As a user cursor/mouse navigation would do.
+ reader.move_cursor(location(reader.text, start))
+ await pilot.press("enter")
+ assert not view.local_details["t"].expanded
+ app.paint() # Drive the periodic renderer without depending on its tick.
+ await pilot.pause()
+ assert "progress explanation" not in reader.text
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert view.local_details["t"].expanded
+ assert "progress explanation" in reader.text
+ await pilot.press("j", "j", "enter")
+ assert not view.local_details["t"].expanded
+ app.paint()
+ await pilot.pause()
+ assert "progress explanation" not in reader.text
+ assert not c._outbox
+
+
+def test_footer_strips_ansi_and_preserves_two_bounded_lines():
+ c = client()
+ c._line("\x1b[2mtest\x1b[0m")
+ assert c.notice == "test"
+ text = status_text("READ NORMAL · running · " + "long " * 100
+ + "\nSpace h: help · message\nextra line", width=32)
+ lines = text.split("\n")
+ assert len(lines) == 2
+ assert all(line.cell_len <= 32 for line in lines)
+
+
+@pytest.mark.asyncio
+async def test_narrow_footer_keeps_time_and_moves_shortcuts_to_help():
+ c = client()
+ view = c.workspace.view("s")
+ view.state = "running"
+ view.presentation.active = "t"
+ view.presentation.turns["t"] = TurnDisplay(
+ duration_ms=573000, activity="Hook · postToolUse · " + "command " * 60
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(44, 26)) as pilot:
+ assert not list(app.query("#key-hints"))
+ text = app.query_one("#status", Static).content
+ assert "9m 33s" in text.plain
+ assert "queued 0" not in text.plain
+ assert all(line.cell_len <= 44 for line in text.split("\n"))
+ await pilot.press("space", "h")
+ help_text = app.screen.query_one("TextArea").text
+ for key in ("Space e", "Space m", "Space p", "Ctrl+v"):
+ assert key in help_text
diff --git a/tests/test_tui_local.py b/tests/test_tui_local.py
new file mode 100644
index 00000000..03c6f01a
--- /dev/null
+++ b/tests/test_tui_local.py
@@ -0,0 +1,172 @@
+"""Local TUI login is automatic and cannot export service credentials."""
+
+import asyncio
+import builtins
+from types import SimpleNamespace
+
+import pytest
+
+from cc_remote import tui, tui_local
+from cc_remote.tui_local import LocalRelay
+
+
+@pytest.mark.parametrize("module", sorted(tui.OPTIONAL_TUI_MODULES))
+def test_missing_optional_dependency_has_install_guidance(monkeypatch, capsys, module):
+ original = builtins.__import__
+
+ def importing(name, *args, **kwargs):
+ if name == "cc_remote.tui_app":
+ raise ModuleNotFoundError(f"No module named {module}", name=module)
+ return original(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", importing)
+ monkeypatch.setattr(tui.sys, "argv", ["tui", "--demo"])
+ with pytest.raises(SystemExit) as exc:
+ tui.main()
+ assert exc.value.code == 1
+ assert "Install requirements-tui.txt, or use --line-mode" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize("module", ["cc_remote.typo", "unrelated", "textual.missing"])
+def test_unrelated_import_error_is_not_hidden(monkeypatch, module):
+ original = builtins.__import__
+
+ def importing(name, *args, **kwargs):
+ if name == "cc_remote.tui_app":
+ raise ModuleNotFoundError(f"No module named {module}", name=module)
+ return original(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", importing)
+ monkeypatch.setattr(tui.sys, "argv", ["tui", "--demo"])
+ with pytest.raises(ModuleNotFoundError) as exc:
+ tui.main()
+ assert exc.value.name == module
+
+
+@pytest.mark.parametrize("args,env,scope,explicit", [
+ (["--engine", "claude", "--space", "work"], None,
+ ("claude", "work"), (True, True)),
+ (["--space", "code"], "claude", ("claude", "code"), (True, True)),
+ ([], "claude", ("claude", "code"), (True, False)),
+ ([], None, ("codex", "code"), (False, False)),
+])
+def test_main_marks_only_explicit_startup_scope(
+ monkeypatch, args, env, scope, explicit,
+):
+ from cc_remote import tui_app
+
+ monkeypatch.delenv("ENGINE", raising=False)
+ if env:
+ monkeypatch.setenv("ENGINE", env)
+ monkeypatch.setattr(tui, "ENGINE", "codex")
+ monkeypatch.setattr(tui.sys, "argv", ["tui", "--demo", *args])
+ clients = []
+ monkeypatch.setattr(tui_app, "run_workspace", lambda c, **kw: clients.append(c))
+ tui.main()
+ c = clients[0]
+ assert c.scope == scope
+ assert (c.explicit_engine, c.explicit_space) == explicit
+
+
+@pytest.mark.parametrize("host,authority", [
+ ("0.0.0.0", "127.0.0.1"), ("127.0.0.1", "127.0.0.1"),
+ ("localhost", "127.0.0.1"), ("::", "[::1]"),
+])
+def test_discovery_uses_real_port_without_exposing_password(monkeypatch, host, authority):
+ monkeypatch.setattr(tui_local, "_service_environment", lambda: {
+ "RELAY_HOST": host, "RELAY_PORT": "9876", "LOGIN_PASSWORD": "private-value",
+ "ALLOW_PRIVATE_ORIGINS": "1", "PUBLIC_ORIGIN": "https://relay.example",
+ })
+ local = tui_local.discover_local_relay()
+ assert local.url == f"ws://{authority}:9876/ws"
+ assert local.password == "private-value"
+ assert local.origin == f"http://{authority}:9876"
+ assert "private-value" not in repr(local)
+
+
+@pytest.mark.parametrize("overrides", [
+ {"RELAY_HOST": "10.0.0.2"}, {"RELAY_HOST": "untrusted.example"},
+ {"RELAY_PORT": "0"}, {"RELAY_PORT": "65536"}, {"RELAY_PORT": "bad"},
+ {"LOGIN_PASSWORD": ""}, {"LOGIN_USERS_JSON": '{"accounts":[]}'},
+])
+def test_discovery_fails_closed(monkeypatch, overrides):
+ monkeypatch.setattr(tui_local, "_service_environment", lambda: {
+ "LOGIN_PASSWORD": "private-value", "ALLOW_PRIVATE_ORIGINS": "1", **overrides,
+ })
+ assert tui_local.discover_local_relay() is None
+
+
+@pytest.mark.parametrize("target", [
+ "wss://remote.example/ws", "ws://127.0.0.1:8765/ws",
+ "ws://127.0.0.2:9876/ws", "ws://127.0.0.1:9876/other",
+ "ws://user@127.0.0.1:9876/ws", "ws://127.0.0.1:9876/ws?token=x",
+ "ws://127.0.0.1:9876/ws#x", "wss://127.0.0.1:9876/ws",
+])
+def test_local_secret_is_not_reused_for_other_targets(target):
+ assert not tui_local.same_local_endpoint("ws://127.0.0.1:9876/ws", target)
+
+
+def test_unavailable_service_does_not_read_process_environment(monkeypatch):
+ monkeypatch.setattr(tui_local.subprocess, "run", lambda *a, **k: SimpleNamespace(
+ stdout="MainPID=0\nActiveState=inactive\n"))
+ monkeypatch.setattr(tui_local.os, "open", lambda *a, **k: pytest.fail("opened proc"))
+ assert tui_local._service_environment() == {}
+
+
+def test_different_user_service_is_not_read(monkeypatch):
+ monkeypatch.setattr(tui_local.subprocess, "run", lambda *a, **k: SimpleNamespace(
+ stdout="MainPID=123\nActiveState=active\n"))
+ opened = []
+ monkeypatch.setattr(tui_local.os, "open", lambda *a, **k: opened.append(a) or 42)
+ monkeypatch.setattr(tui_local.os, "fstat", lambda fd: SimpleNamespace(st_uid=-1))
+ monkeypatch.setattr(tui_local.os, "close", lambda fd: None)
+ assert tui_local._service_environment() == {}
+ assert len(opened) == 1
+
+
+def test_local_login_never_prompts_and_refreshes_on_reauthentication(monkeypatch):
+ local = LocalRelay("ws://127.0.0.1:9876/ws", "first", "https://relay.example")
+ monkeypatch.setattr(tui, "discover_local_relay", lambda: local)
+ monkeypatch.setattr(tui.getpass, "getpass", lambda *a: pytest.fail("prompted"))
+ calls = []
+ monkeypatch.setattr(tui, "_login_cookie", lambda *a: calls.append(a) or "cookie")
+ client = tui.Tui(local.url, "", "", "codex", None)
+ asyncio.run(client._authenticate())
+ assert client.local_login and client.origin == local.origin
+ local = LocalRelay(local.url, "rotated", local.origin)
+ asyncio.run(client._authenticate())
+ assert [call[1] for call in calls] == ["first", "rotated"]
+
+
+def test_missing_local_service_fails_without_password_prompt(monkeypatch):
+ monkeypatch.setattr(tui, "discover_local_relay", lambda: None)
+ monkeypatch.setattr(tui.getpass, "getpass", lambda *a: pytest.fail("prompted"))
+ client = tui.Tui("ws://127.0.0.1:9876/ws", "", "", "codex", None)
+ with pytest.raises(ValueError, match="Local relay authentication is unavailable"):
+ asyncio.run(client._authenticate())
+
+
+def test_remote_login_never_discovers_local_credentials(monkeypatch):
+ monkeypatch.setattr(tui, "discover_local_relay", lambda: pytest.fail("discovered"))
+ monkeypatch.setattr(tui.getpass, "getpass", lambda *a: "remote-password")
+ calls = []
+ monkeypatch.setattr(tui, "_login_cookie", lambda *a: calls.append(a) or "cookie")
+ client = tui.Tui("wss://remote.example/ws", "", "", "codex", None)
+ asyncio.run(client._authenticate())
+ assert calls == [(client.url, "remote-password", "")]
+
+
+def test_bare_command_discovers_local_port_before_starting_ui(monkeypatch):
+ from cc_remote import tui_app
+ local = LocalRelay("ws://127.0.0.1:9876/ws", "local-password")
+ monkeypatch.delenv("RELAY_URL", raising=False)
+ for name in ("LOGIN_PASSWORD", "LOGIN_USERNAME", "PUBLIC_ORIGIN"):
+ monkeypatch.setattr(tui, name, "")
+ monkeypatch.setattr(tui, "discover_local_relay", lambda: local)
+ monkeypatch.setattr(tui.sys, "argv", ["cc_remote.tui"])
+ clients = []
+ monkeypatch.setattr(tui_app, "run_workspace", lambda client, **kw: clients.append(client))
+ tui.main()
+ assert clients[0].url == local.url
+ assert clients[0].password == local.password
+ assert clients[0].local_login
diff --git a/tests/test_tui_markdown.py b/tests/test_tui_markdown.py
new file mode 100644
index 00000000..21e19d40
--- /dev/null
+++ b/tests/test_tui_markdown.py
@@ -0,0 +1,407 @@
+"""Markdown is presentation, never a rewrite of session content or URLs."""
+
+import pytest
+from rich.cells import cell_len
+from rich.console import Console
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp, location
+from cc_remote.tui_markdown import (
+ display_url,
+ MarkdownProjection,
+ matching_runs,
+ render_markdown,
+)
+from cc_remote.tui_state import Block
+from tests.test_tui_send_jumps import client
+from tests.test_tui_scroll_frames import watch_frames
+
+
+def style(text, word):
+ return text.get_style_at_offset(Console(), text.plain.index(word))
+
+
+def test_inline_markdown_styles_without_delimiters():
+ text, _, _ = render_markdown("**bold** *italic* ~~gone~~ `call(x)`", 80)
+ assert text.plain == "bold italic gone call(x)"
+ assert style(text, "bold").bold
+ assert style(text, "italic").italic
+ assert style(text, "gone").strike
+ assert style(text, "call").color
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ "[title](https://example.com/a?q=1#anchor)",
+ "[title][ref]\n\n[ref]: https://example.com/a?q=1#anchor",
+ "",
+ ],
+)
+def test_links_keep_a_contiguous_complete_url(source):
+ text, _, _ = render_markdown(source, 20)
+ assert "https://example.com/a?q=1#anchor" in text.plain
+ assert "[title]" not in text.plain and "[ref]" not in text.plain
+ assert text.plain.count("https://example.com/a?q=1#anchor") == 1
+
+
+@pytest.mark.parametrize("source", [
+ "[讲稿](/docs/%E6%BC%94%E8%AE%B2.md)",
+ "[讲稿](/docs/演讲.md)",
+ "",
+ "| File |\n| --- |\n| [讲稿](/docs/演讲.md) |",
+])
+def test_unicode_destinations_are_readable(source):
+ text, _, _ = render_markdown(source, 80)
+ assert "/docs/演讲.md" in text.plain
+ assert "%E6%BC%94" not in text.plain
+
+
+def test_unicode_autolink_is_not_duplicated():
+ text, _, _ = render_markdown(
+ "", 80
+ )
+ assert text.plain == "https://example.com/演讲"
+
+
+def test_unicode_link_preserves_surrounding_emphasis():
+ text, _, _ = render_markdown(
+ "****", 80
+ )
+ assert text.plain == "https://example.com/演讲"
+ assert style(text, "https://").bold
+
+
+@pytest.mark.parametrize("encoded", [
+ "%20", "%2F", "%23", "%3F", "%25", "%1B", "%0A",
+ "%FF", "%E6%BC", "%C2%85", "%E2%80%AE", "%E3%80%80",
+])
+def test_url_escapes_cannot_change_structure_or_inject_controls(encoded):
+ assert display_url("/docs/" + encoded) == "/docs/" + encoded
+
+
+def test_literal_code_keeps_percent_encoding():
+ text, _, _ = render_markdown("`/docs/%E6%BC%94.md`", 80)
+ assert text.plain == "/docs/%E6%BC%94.md"
+
+
+def test_headings_lists_quotes_and_literal_markup():
+ text, _, _ = render_markdown(
+ "# Heading\n\nSetext\n======\n\n- **first**\n - nested\n"
+ "\n> quoted\n> continuation\n\n\\*literal\\* and &",
+ 60,
+ )
+ assert "# Heading" not in text.plain and "======" not in text.plain
+ assert style(text, "Heading").bold and style(text, "Setext").bold
+ assert "• first" in text.plain and "• nested" in text.plain
+ assert "│ quoted\n│ continuation" in text.plain
+ assert "*literal* and &" in text.plain
+ assert not style(text, "literal").italic
+
+
+@pytest.mark.parametrize("width", [18, 70])
+def test_tables_keep_all_columns_values_and_link_targets(width):
+ url = "https://example.com/" + "long-path/" * 8
+ text, responsive, _ = render_markdown(
+ "| Name | Value |\n| --- | ---: |\n| **鱼缸** | 123 |\n"
+ f"| [docs]({url}) | 456 |\n",
+ width,
+ )
+ assert responsive
+ assert "鱼缸" in text.plain and "123" in text.plain and "456" in text.plain
+ assert "---" not in text.plain and "**" not in text.plain
+ assert url in text.plain # Not only wrapped fragments in a cell.
+ if width == 70:
+ assert "╭" in text.plain and "╰" in text.plain
+ assert all(
+ cell_len(line) <= width
+ for line in text.plain.splitlines()
+ if not line.startswith("https://")
+ )
+
+
+def test_code_and_control_sequences_are_inert():
+ text, _, _ = render_markdown(
+ '```python\nprint("**literal**")\n```\n\n'
+ "\x1b]52;c;secret\x07[bold red]not Rich markup[/]",
+ 80,
+ )
+ assert "```" not in text.plain and 'print("**literal**")' in text.plain
+ assert "\x1b" not in text.plain and "\x07" not in text.plain
+ assert "[bold red]not Rich markup[/]" in text.plain
+ assert not style(text, "literal").bold
+
+
+def test_reference_expansion_is_bounded_and_empty_table_keeps_headers():
+ source = (
+ "[link][r] " * 800
+ + "\n\n[r]: https://example.com/"
+ + "long-path/" * 100
+ )
+ text, _, _ = render_markdown(source, 80)
+ assert text.plain == source # Safe raw fallback, not an unbounded layout.
+ text, _, _ = render_markdown("| One | Two |\n| --- | --- |\n", 10)
+ assert "One" in text.plain and "Two" in text.plain
+
+
+def test_repetitive_prose_has_exact_coordinates_and_bounded_diff(monkeypatch):
+ from difflib import SequenceMatcher
+
+ matching_runs.cache_clear()
+ lengths = []
+
+ def matcher(junk, a, b, **kwargs):
+ lengths.append(max(len(a), len(b)))
+ return SequenceMatcher(junk, a, b, **kwargs)
+
+ monkeypatch.setattr("cc_remote.tui_markdown.SequenceMatcher", matcher)
+ c = client()
+ view = c.workspace.view("s")
+ view.blocks = [
+ Block("a", "assistant", "**" + "same word " * 4000 + "end**")
+ ]
+ source, starts = view.render()
+ projection = MarkdownProjection(source)
+ rendered, _ = projection.project(starts, 50)
+ for word in ("same", "end"):
+ position = source.index(word)
+ assert projection.source(projection.display(position)) == position
+ assert rendered.plain[projection.display(position) :].startswith(word)
+ assert lengths and max(lengths) <= 256
+
+
+@pytest.mark.asyncio
+async def test_chat_renders_prose_and_copies_visible_text():
+ c = client()
+ view = c.workspace.view("s")
+ view.blocks = [
+ Block("u", "user", "**literal prompt**", "turn"),
+ Block(
+ "a",
+ "assistant",
+ "**bold answer** and [docs](https://example.com)",
+ "turn",
+ "final",
+ ),
+ Block("t", "tool", "**literal tool**", expanded=True),
+ ]
+ view.render()
+ view.tool_groups["tools:t"].expanded = True
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ assert "**bold answer**" not in reader.text
+ assert (
+ "**literal prompt**" in reader.text
+ and "**literal tool**" in reader.text
+ )
+ assert "docs (https://example.com)" in reader.text
+ row = reader.text[: reader.text.index("bold answer")].count("\n")
+ assert style(reader.get_line(row), "bold answer").bold
+ reader.selection = Selection((0, 0), reader.document.end)
+ assert reader.selected_text == reader.text
+ assert "**bold answer**" in view.render()[0]
+ start = reader.text.index("bold answer")
+ reader.selection = Selection(
+ location(reader.text, start),
+ location(reader.text, start + len("bold answer")),
+ )
+ assert reader.selected_text == "bold answer"
+ app.quote()
+ assert "> bold answer" in app.query_one(Composer).text
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_rendered_table_resizes_without_losing_source_cursor():
+ c = client()
+ view = c.workspace.view("s")
+ view.put(
+ Block(
+ "table",
+ "assistant",
+ "| Name | Value |\n| --- | --- |\n| fish | 123 |\n\n"
+ "after-table marker",
+ )
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(90, 32)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ reader.move_cursor(
+ location(reader.text, reader.text.index("after-table"))
+ )
+ await pilot.pause()
+ app.remember()
+ anchor = view.anchor
+ for width in (20, 100, 35):
+ await pilot.resize_terminal(width, 30)
+ await pilot.pause()
+ assert view.anchor == anchor
+ assert reader.document.get_line(
+ reader.cursor_location[0]
+ ).startswith("after-table")
+ assert "fish" in reader.text and "123" in reader.text
+
+
+@pytest.mark.asyncio
+async def test_markdown_streaming_and_table_reflow_keep_bottom_frames(
+ monkeypatch,
+):
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(80, 30)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.move_cursor((20, 0))
+ await pilot.press("ctrl+j")
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ frames = watch_frames(monkeypatch, app)
+ for text in (
+ "**stream",
+ "ed**\n\n",
+ "| key | value |\n",
+ "| --- | --- |\n",
+ "| bold | **yes** |\n",
+ ):
+ view.put(Block("stream", "assistant", text), append=True)
+ app.paint()
+ await pilot.pause()
+ for size in ((45, 22), (95, 35)):
+ await pilot.resize_terminal(*size)
+ await pilot.pause()
+ assert frames and all(y == bottom for y, bottom in frames), frames
+
+
+@pytest.mark.asyncio
+async def test_unchanged_bodies_use_render_cache():
+ render_markdown.cache_clear()
+ c = client()
+ c.workspace.view("s").put(Block("bold", "assistant", "**cached**"))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ app.rendered_version = -1
+ app.paint() # Initial mount now has the actual scrollbar/content width.
+ await pilot.pause()
+ before = render_markdown.cache_info()
+ for _ in range(3):
+ c.workspace.view("s").version += 1
+ app.paint()
+ await pilot.pause()
+ after = render_markdown.cache_info()
+ assert after.misses == before.misses
+ assert after.hits > before.hits
+
+
+@pytest.mark.asyncio
+async def test_folded_markdown_progress_keeps_tool_styles_and_navigation():
+ from tests.test_tui_live_layout import turn
+ from tests.test_tui_progress_details import body_style
+
+ c = client()
+ view = c.workspace.view("s")
+ view.blocks = []
+ turn(view)
+ progress = next(b for b in view.blocks if b.channel == "commentary")
+ progress.text = (
+ "**progress explanation**\n\n| Name | Value |\n"
+ "| --- | --- |\n| test | 12 |\n"
+ )
+ view.event(
+ dict(
+ type="delta",
+ message_id="answer",
+ channel="final",
+ text="**final answer**",
+ )
+ )
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.pause()
+ assert "progress explanation" in reader.text
+ reader.move_cursor(
+ location(reader.text, reader.text.index("final answer"))
+ )
+ await pilot.press("enter")
+ assert not view.local_details["t"].expanded
+ app.paint()
+ await pilot.press("enter")
+ assert view.local_details["t"].expanded
+ app.paint() # Do not race the workspace's 100 ms presentation timer.
+ await pilot.pause()
+ assert "**progress explanation**" not in reader.text
+ assert "progress explanation" in reader.text and "╭" in reader.text
+ assert body_style(reader, "progress explanation") == ""
+ assert "echo test" not in reader.text
+ start = next(i for i, b in app.starts if b.role == "tool_group")
+ reader.move_cursor(location(reader.text, start))
+ await pilot.press("enter")
+ app.paint()
+ assert body_style(reader, "echo test") == "bright_black"
+ assert body_style(reader, "final answer") == ""
+ reader.move_cursor(
+ location(reader.text, reader.text.index("echo test"))
+ )
+ await pilot.press("enter")
+ assert view.local_details["t"].expanded
+ app.paint()
+ await pilot.pause()
+ assert "echo test" not in reader.text
+
+
+def test_source_position_after_a_wrapped_link_table_remains_resolvable():
+ c = client()
+ view = c.workspace.view("s")
+ url = "https://example.com/" + "very-long-path/" * 100
+ view.blocks = [
+ Block(
+ "a",
+ "assistant",
+ "| Link | Value |\n| --- | --- |\n"
+ f"| [link]({url}) | 123 |\n\nafter-table marker",
+ )
+ ]
+ source, starts = view.render()
+ for width in (18, 80):
+ projection = MarkdownProjection(source)
+ text, _ = projection.project(starts, width)
+ original = source.index("after-table")
+ displayed = text.plain.index("after-table")
+ assert projection.display(original) == displayed
+ assert projection.source(displayed) == original
+
+
+@pytest.mark.asyncio
+async def test_style_only_change_invalidates_rendered_line_cache():
+ c = client()
+ view = c.workspace.view("s")
+ view.blocks = [Block("a", "assistant", "marker")]
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j")
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ row = reader.text[: reader.text.index("marker")].count("\n")
+
+ def rendered_bold():
+ y = reader.wrapped_document.location_to_offset((row, 0)).y
+ line = reader.render_line(y - int(reader.scroll_y))
+ return any(
+ "marker" in segment.text and segment.style.bold
+ for segment in line
+ if segment.style
+ )
+
+ assert not rendered_bold()
+ view.blocks[0].text = "**marker**"
+ view.version += 1
+ app.paint()
+ await pilot.pause()
+ assert rendered_bold()
diff --git a/tests/test_tui_mermaid.py b/tests/test_tui_mermaid.py
new file mode 100644
index 00000000..976c67a4
--- /dev/null
+++ b/tests/test_tui_mermaid.py
@@ -0,0 +1,71 @@
+"""Flowcharts are readable terminal text, never executable Mermaid scripts."""
+
+import pytest
+from rich.cells import cell_len
+
+from cc_remote.tui_markdown import render_markdown
+from cc_remote.tui_mermaid import parse_flowchart, render_flowchart
+
+EXAMPLE = '''flowchart TB
+ A["ggml 计算节点
矩阵乘:输入、输出、形状、类型"]
+ A --> B["K3:CPU backend 的 SpacemiT 优化路径"]
+ B --> C["选择并调用已编译的矩阵乘函数"]
+ C --> D["执行 RISC-V / RVV / IME 指令"]
+ A --> E["NVIDIA:CUDA backend"]
+ E --> F["选择自有 CUDA kernel 或 cuBLAS 实现"]
+ F --> G["通过 CUDA 提交到 GPU
由 SM 执行计算程序"]
+'''
+
+
+@pytest.mark.parametrize("width", [20, 48, 100])
+@pytest.mark.parametrize("fenced", [False, True])
+def test_requested_flowchart_is_rendered_with_all_nodes(width, fenced):
+ source = "```mermaid\n" + EXAMPLE + "```" if fenced else EXAMPLE
+ text, responsive, _ = render_markdown(source, width)
+ assert responsive and "╭" in text.plain and "─▶" in text.plain
+ assert '
' not in text.plain and 'A["' not in text.plain
+ assert all(cell_len(line) <= width for line in text.plain.splitlines())
+ graph = parse_flowchart(EXAMPLE)
+ compact = "".join(text.plain.split())
+ # Labels may wrap; every node ID is retained and all six edges are parsed.
+ assert all(node + "·" in compact for node in graph.nodes)
+ assert len(graph.edges) == 6
+
+
+def test_shared_nodes_cycles_and_edge_labels_are_bounded():
+ source = 'graph LR;A["start"]-->B;A-->|other|C;B-->D;C-->D;D-->A'
+ graph = parse_flowchart(source)
+ assert len(graph.edges) == 5
+ text = render_flowchart(source, 80).plain
+ assert text.count("D · D") == 1 and "↪ D" in text and "↪ A" in text
+ assert "other" in text and len(text) < 2000
+
+
+@pytest.mark.parametrize("source", [
+ 'flowchart TB\nA["unfinished',
+ "graph TB\n" + ";".join(f"N{i}" for i in range(65)),
+ 'graph TB\nA["' + "x" * 33000 + '"]',
+ 'graph TB\nA["' + "中" * 12000 + '"]',
+])
+def test_unsupported_or_oversized_diagrams_keep_source(source):
+ assert render_flowchart(source, 80) is None
+ text, _, _ = render_markdown("```mermaid\n" + source + "\n```", 80)
+ assert "Unsupported diagram" in text.plain and source in text.plain
+
+
+@pytest.mark.parametrize("ending", ["", "\n"])
+def test_flowcharts_do_not_change_other_code_blocks_or_prose(ending):
+ text, responsive, _ = render_markdown(
+ "```text\n" + EXAMPLE + "```" + ending, 80,
+ )
+ # Streaming Markdown drops the final display newline when its source has
+ # none. Ordinary code must retain all content under that existing policy.
+ expected = EXAMPLE if ending else EXAMPLE.removesuffix("\n")
+ assert text.plain == expected and not responsive
+ source = "Mention flowchart TB in a sentence."
+ assert render_markdown(source, 80)[0].plain == source
+
+
+def test_labels_cannot_inject_terminal_controls_or_rich_markup():
+ text = render_flowchart('graph TB;A["[bold]literal & [31m"]', 80)
+ assert "\x1b" not in text.plain and "[bold]literal &" in text.plain
diff --git a/tests/test_tui_navigation.py b/tests/test_tui_navigation.py
new file mode 100644
index 00000000..7d02b254
--- /dev/null
+++ b/tests/test_tui_navigation.py
@@ -0,0 +1,316 @@
+"""Scoped startup, navigation races and configurable keyboard regressions."""
+
+import asyncio
+import json
+
+import pytest
+from textual.widgets import TextArea
+
+from cc_remote.protocol import ListSessions, PROTOCOL_VERSION
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, seed_demo, Composer
+from cc_remote.tui_keys import KeyConfig, load_keys
+from cc_remote.tui_navigation import activity, scoped_catalog, select_session
+from cc_remote.tui_tree import SessionExplorer, SessionTree
+
+
+def client(sid=None):
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", sid)
+ frames = []
+
+ async def send(raw):
+ frames.append(json.loads(raw))
+ return True
+
+ c._send_raw = send
+ return c, frames
+
+
+async def catalog(c, rows, engine="codex", space="code", *, request=True):
+ if request:
+ await c._send(ListSessions(engine=engine, space=space))
+ c._handle(
+ {
+ "v": PROTOCOL_VERSION,
+ "type": "session_list",
+ "engine": engine,
+ "space": space,
+ "request_id": c.session_catalog_requests.get((engine, space)),
+ "sessions": rows,
+ }
+ )
+ await c._flush_history_refreshes()
+
+
+def row(sid, modified="1", **extra):
+ return {"session_id": sid, "last_modified": modified, **extra}
+
+
+def test_activity_matches_web_seconds_milliseconds_iso_and_invalid():
+ expected = 1_700_000_000_000
+ for value in ("1700000000", "1700000000000", "2023-11-14T22:13:20Z"):
+ assert activity(row("s", value)) == expected
+ for value in (None, "invalid", "NaN", "Infinity", ""):
+ assert activity(row("s", value)) == -float("inf")
+
+
+def test_selection_matches_web_order_archives_and_remembered_focus():
+ rows = {
+ "old": row("old", "1"),
+ "new": row("new", "4"),
+ "archive": row("archive", "6", tag="archived"),
+ "other": row("other", "10", space="work"),
+ }
+ scoped = scoped_catalog(rows, "codex", "code")
+ assert list(scoped) == ["archive", "new", "old"]
+ assert select_session(scoped, None) == "new"
+ assert select_session(scoped, "old") == "old"
+ assert select_session(scoped, "archive") == "archive"
+ assert select_session(scoped, "deleted") == "new"
+ assert select_session({}, None) is None
+
+
+@pytest.mark.asyncio
+async def test_startup_waits_for_selected_scope_not_background_snapshot():
+ c, frames = client()
+ c._handle({"v": PROTOCOL_VERSION, "type": "snapshot", "sid": "background"})
+ await catalog(c, [row("claude-new", "99")], "claude")
+ await catalog(c, [row("work-new", "99")], space="work")
+ assert c.attached_sid is None
+ await catalog(
+ c, [row("old"), row("new", "3"), row("hidden", "9", tag="archived")]
+ )
+ assert c.attached_sid == "new"
+ assert [
+ f["session_id"] for f in frames if f["type"] == "switch_session"
+ ] == ["new"]
+ assert not any(
+ f["type"] in {"query", "steer", "new_session"} for f in frames
+ )
+ await catalog(c, [row("old"), row("new", "3"), row("newer", "10")])
+ assert c.attached_sid == "new" # Refresh never steals focus.
+
+
+@pytest.mark.asyncio
+async def test_explicit_session_wins_over_initial_auto_selection():
+ c, _ = client("explicit")
+ await catalog(c, [row("newest", "10")])
+ assert c.attached_sid == "explicit"
+
+
+@pytest.mark.asyncio
+async def test_all_four_scopes_restore_independent_focus_and_drafts():
+ c, frames = client()
+ await catalog(c, [row("code-old"), row("code-new", "3")])
+ await c._attach("code-old")
+ c.workspace.view("code-old").draft = "keep code draft"
+ for engine, space in (
+ ("codex", "work"),
+ ("claude", "work"),
+ ("claude", "code"),
+ ):
+ await c.switch_surface(engine, space)
+ assert c.attached_sid is None
+ sid = engine + space
+ await catalog(c, [row(sid)], engine, space)
+ assert c.attached_sid == sid
+ assert set(c.visible_catalog()) == {sid}
+ switch = [f for f in frames if f["type"] == "switch_session"][-1]
+ assert (switch["engine"], switch["space"]) == (engine, space)
+ await c.switch_surface("codex", "code")
+ await catalog(c, [row("code-old"), row("code-new", "10")])
+ assert c.attached_sid == "code-old"
+ assert c.workspace.view("code-old").draft == "keep code draft"
+
+
+@pytest.mark.asyncio
+async def test_missing_bookmark_and_empty_surface_are_safe():
+ c, frames = client()
+ c.last_focus["codex", "code"] = "deleted"
+ await catalog(c, [row("valid")])
+ assert c.attached_sid == "valid"
+ await c.switch_surface("codex", "work")
+ count = len([f for f in frames if f["type"] == "switch_session"])
+ await catalog(c, [], space="work")
+ assert c.attached_sid is None
+ assert "No Codex / Work sessions" in c.notice
+ assert len([f for f in frames if f["type"] == "switch_session"]) == count
+ assert not await c.submit("must not target old Code session")
+
+
+@pytest.mark.asyncio
+async def test_stale_catalog_and_switch_confirmation_cannot_steal_focus():
+ c, _ = client()
+ await c._send(ListSessions(engine="codex"))
+ old_request = c.session_catalog_requests["codex", "code"]
+ await c.switch_surface("codex", "work")
+ await c.switch_surface("codex", "code")
+ c._handle(
+ {
+ "type": "session_list",
+ "v": PROTOCOL_VERSION,
+ "engine": "codex",
+ "request_id": old_request,
+ "sessions": [row("stale")],
+ }
+ )
+ await c._flush_history_refreshes()
+ assert c.attached_sid is None
+ assert "stale" not in c.workspace.catalog
+ await catalog(c, [row("current")], request=False)
+ c._handle(
+ {"v": PROTOCOL_VERSION, "type": "session_focus", "session_id": "stale"}
+ )
+ assert c.attached_sid == "current"
+
+
+@pytest.mark.asyncio
+async def test_failed_old_attach_does_not_undo_newer_surface_choice():
+ c, _ = client("initial")
+ waiting, release = asyncio.Event(), asyncio.Event()
+
+ async def send(message):
+ if message.type == "switch_session":
+ waiting.set()
+ await release.wait()
+ return False
+ return True
+
+ c._send = send
+ task = asyncio.create_task(c._attach("old"))
+ await waiting.wait()
+ await c.switch_surface("claude", "work")
+ release.set()
+ await task
+ assert c.scope == ("claude", "work") and c.attached_sid is None
+
+
+@pytest.mark.asyncio
+async def test_new_session_command_uses_current_engine_and_space():
+ c, frames = client()
+ await c.switch_surface("claude", "work")
+ await c._command("/new")
+ new = [f for f in frames if f["type"] == "new_session"][-1]
+ assert (new["engine"], new["space"]) == ("claude", "work")
+ assert new["cwd"] is None
+ with pytest.raises(ValueError, match="without a path"):
+ await c._command("/new /example/project")
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {"unknown": {}},
+ {"keys": {"unknown": []}},
+ {"normal": []},
+ {"keys": {"sessions": "ctrl+p"}},
+ {"keys": {"sessions": ["p"]}},
+ {"keys": {"sessions": ["ctrl+q"]}},
+ {"normal": {"help": ["space"]}},
+ {"normal": {"help": ["made_up_key"]}},
+ ],
+)
+def test_bad_or_conflicting_config_fails_closed(data):
+ with pytest.raises(ValueError):
+ KeyConfig(data)
+
+
+def test_config_loading_precedence_and_disabled_bindings(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
+ monkeypatch.delenv("CC_REMOTE_TUI_CONFIG", raising=False)
+ assert load_keys().label("sessions") == "Space e"
+ default = tmp_path / "cc-remote" / "tui.toml"
+ default.parent.mkdir()
+ default.write_text('[keys]\nsessions = ["ctrl+o"]\n'
+ '[normal]\njump_back = []\n')
+ assert load_keys().label("sessions") == "Ctrl+o"
+ override = tmp_path / "override.toml"
+ override.write_text("[keys]\nsessions = []\n")
+ monkeypatch.setenv("CC_REMOTE_TUI_CONFIG", str(override))
+ assert load_keys().label("sessions") == "Space e"
+ assert load_keys().global_keys["sessions"] == ()
+ assert load_keys(str(default)).label("sessions") == "Ctrl+o"
+ with pytest.raises(ValueError, match="not found"):
+ load_keys(str(tmp_path / "absent"))
+
+
+def test_documented_example_loads_and_matches_defaults():
+ from pathlib import Path
+
+ path = Path(__file__).resolve().parents[1] / "docs/tui-keys.example.toml"
+ assert load_keys(str(path)).help() == KeyConfig().help()
+
+
+@pytest.mark.asyncio
+async def test_demo_scoped_picker_leaders_and_draft_preservation():
+ c, frames = client()
+ seed_demo(c)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "k", "e", "e", "p", "escape")
+ await pilot.press("space", "w") # Works in draft Normal, too.
+ assert c.scope == ("codex", "work")
+ assert c.attached_sid == "demo-codex-work"
+ assert app.query_one(Composer).text == ""
+ await pilot.press("space", "e")
+ assert app.query_one(SessionExplorer).display
+ assert len(app.query_one(SessionTree).root.children) == 1
+ await pilot.press("escape", "space", "c")
+ assert c.scope == ("claude", "work")
+ await pilot.press("space", "c", "space", "w")
+ assert c.attached_sid == "demo-review"
+ assert app.query_one(Composer).text == "keep"
+ assert not frames and not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_custom_shortcuts_apply_to_help_picker_and_not_insert_text():
+ c, _ = client()
+ c.keys = KeyConfig(
+ {
+ "keys": {
+ "sessions": ["ctrl+o"],
+ "focus_draft": ["ctrl+n"],
+ "focus_read": ["ctrl+b"],
+ },
+ "normal": {"help": ["space z"], "space": ["space m"], "model": [],
+ "jump_back": []},
+ }
+ )
+ seed_demo(c)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+p")
+ assert len(app.screen_stack) == 1
+ await pilot.press("ctrl+o")
+ tree = app.query_one(SessionTree)
+ start = tree.cursor_line
+ await pilot.press("ctrl+n")
+ assert tree.cursor_line == start + 1
+ await pilot.press("ctrl+b")
+ assert tree.cursor_line == start
+ await pilot.press("escape", "space", "h")
+ assert len(app.screen_stack) == 1 # Rebinding removed the old shortcut.
+ await pilot.press("space", "z")
+ help_text = app.screen.query_one(TextArea).text
+ assert "Ctrl+o" in help_text and "Space z" in help_text
+ assert "F1" not in help_text
+ await pilot.press("escape", "ctrl+n", "i", "space", "m")
+ assert app.query_one(Composer).text == " m"
+ assert c.space == "code"
+ await pilot.press("escape", "space", "m")
+ assert c.space == "work"
+ assert all(
+ not binding.key.startswith("f") for binding in KeyConfig().bindings()
+ )
+
+
+@pytest.mark.asyncio
+async def test_global_navigation_cancels_partial_leader_chord():
+ c, _ = client()
+ seed_demo(c)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "ctrl+j", "escape", "w")
+ assert c.space == "code"
+ await pilot.press("space", "ctrl+j", "w")
+ assert c.space == "code"
diff --git a/tests/test_tui_normal_paste.py b/tests/test_tui_normal_paste.py
new file mode 100644
index 00000000..aef51b84
--- /dev/null
+++ b/tests/test_tui_normal_paste.py
@@ -0,0 +1,78 @@
+"""Terminal paste is text, never a Normal-mode command or submission."""
+
+import pytest
+from textual import events
+
+from cc_remote.tui_app import Composer, Transcript
+from cc_remote.tui_settings import TextValue
+from tests.test_tui_editing import make_app
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("mode", ["NORMAL", "INSERT"])
+async def test_terminal_paste_preserves_mode_and_does_not_send(mode):
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ await pilot.press("ctrl+j")
+ editor.load_text("before after")
+ editor.move_cursor((0, 7))
+ editor.set_mode(mode)
+ app.post_message(events.Paste("hello\n/quit\ndd\n"))
+ await pilot.pause()
+ assert editor.text == "before hello\n/quit\ndd\nafter"
+ assert editor.vim_mode == mode
+ assert not app.client._outbox
+ if mode == "INSERT":
+ await pilot.press("escape")
+ await pilot.press("u")
+ assert editor.text == "before after"
+ await pilot.press("ctrl+r")
+ assert editor.text == "before hello\n/quit\ndd\nafter"
+
+
+@pytest.mark.asyncio
+async def test_paste_cancels_pending_operator_and_stays_a_separate_undo():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ await pilot.press("ctrl+j", "i", "a", "b", "escape", "d")
+ assert editor.prefix
+ app.post_message(events.Paste("XYZ"))
+ await pilot.pause()
+ assert editor.text == "aXYZb"
+ assert not editor.prefix
+ await pilot.press("u")
+ assert editor.text == "ab"
+ await pilot.press("u")
+ assert editor.text == ""
+
+
+@pytest.mark.asyncio
+async def test_locked_transcript_ignores_terminal_and_local_paste():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ reader.focus()
+ original = reader.text
+ app.post_message(events.Paste("do not edit history"))
+ await pilot.pause()
+ app.copy_to_clipboard("also blocked")
+ reader.action_paste()
+ assert reader.text == original
+
+
+@pytest.mark.asyncio
+async def test_modal_normal_paste_uses_same_editable_boundary():
+ app = make_app()
+ async with app.run_test() as pilot:
+ app.push_screen(TextValue("Value", ""))
+ await pilot.pause()
+ editor = app.screen.query_one(Composer)
+ assert editor.vim_mode == "NORMAL"
+ app.copy_to_clipboard("pasted value")
+ await pilot.press("ctrl+v")
+ assert editor.text == "pasted value"
+ assert editor.vim_mode == "NORMAL"
+ await pilot.press("u")
+ assert editor.text == ""
diff --git a/tests/test_tui_parity.py b/tests/test_tui_parity.py
new file mode 100644
index 00000000..93432446
--- /dev/null
+++ b/tests/test_tui_parity.py
@@ -0,0 +1,1561 @@
+"""Web/TUI public-event parity and keyboard/control regressions (no model)."""
+
+import json
+
+import pytest
+from textual.widgets import Input, Static, TextArea
+
+from cc_remote import protocol as p
+from cc_remote.tui_actions import ACTIONS, build_action, defaults
+from cc_remote.tui_app import (
+ Composer,
+ Transcript,
+ WorkspaceApp,
+ WorkspaceClient,
+ seed_demo,
+)
+from cc_remote.tui_attachments import read_attachment
+from cc_remote.tui_panels import (
+ ActionForm,
+ DetailPanel,
+ PanelReader,
+ QuestionDialog,
+ QueueEdit,
+ QueuePanel,
+)
+from cc_remote.tui_presentation import (
+ SessionPresentation,
+ TurnDisplay,
+ merge_rate_window,
+ quota_windows,
+ tokens,
+)
+from cc_remote.tui_state import SessionView, WorkspaceState
+
+
+def event(view, event_type, **kw):
+ view.event({"type": event_type, **kw})
+
+
+def app_client():
+ client = WorkspaceClient("ws://localhost:8765/ws", "", "", "codex", "s")
+ client.workspace.catalog = {
+ "s": {"session_id": "s", "cwd": "/repo", "engine": "codex"}
+ }
+ client.workspace.view("s").write_state = "writable"
+ return WorkspaceApp(client, connect=False), client
+
+
+def test_native_turn_binding_late_terminal_never_closes_new_turn():
+ view = SessionView()
+ event(view, "user_msg", msg_id="one", prompt="first", ts=100, seq=1)
+ event(view, "turn_binding", msg_id="one", turn_id="native-one")
+ event(view, "user_msg", msg_id="two", prompt="second", ts=120, seq=3)
+ event(view, "turn_binding", msg_id="two", turn_id="native-two")
+ event(
+ view,
+ "turn_end",
+ turn_id="native-one",
+ ts=130,
+ result={"subtype": "success", "is_error": False, "duration_ms": 2500},
+ )
+ assert view.presentation.turns["native-one"].label() == "completed · 2s"
+ assert view.presentation.turns["native-two"].status == "running"
+ assert view.presentation.active == "native-two"
+
+
+def test_claude_terminal_assistant_id_closes_unbound_user_owner():
+ view = SessionView()
+ event(view, "user_msg", msg_id="user", prompt="hi", ts=100)
+ event(
+ view,
+ "turn_end",
+ turn_id="assistant-id",
+ checkpoint_id="user",
+ ts=110,
+ result={
+ "subtype": "error_during_execution",
+ "is_error": True,
+ "duration_ms": 10000,
+ },
+ )
+ assert view.presentation.turns["user"].status == "interrupted"
+ assert "assistant-id" not in view.presentation.turns
+
+
+def test_current_activity_thinking_tool_exit_and_background_are_distinct():
+ view = SessionView()
+ event(view, "user_msg", msg_id="u", prompt="test", ts=100)
+ event(
+ view, "delta", message_id="a", channel="thinking", text="summary\nmore"
+ )
+ assert view.presentation.turns["u"].activity == "Thinking"
+ event(
+ view,
+ "process",
+ item_id="cmd",
+ kind="command",
+ title="Run pytest",
+ phase="begin",
+ status="running",
+ )
+ event(
+ view,
+ "process",
+ item_id="cmd",
+ kind="command",
+ title="Run pytest",
+ phase="delta",
+ append_to="output",
+ delta="passed ",
+ )
+ event(
+ view,
+ "process",
+ item_id="cmd",
+ kind="command",
+ title="Run pytest",
+ phase="delta",
+ append_to="output",
+ delta="42",
+ )
+ block = next(b for b in view.blocks if b.id == "cmd")
+ assert block.data["output"] == "passed 42"
+ event(view, "tool_use", tool_use_id="t", tool="exec", input={"cmd": "true"})
+ event(
+ view,
+ "tool_result",
+ tool_use_id="t",
+ content="ok",
+ is_error=False,
+ duration_ms=1200,
+ exit_code=0,
+ )
+ rendered = view.render()[0]
+ assert "2 个工具调用 · 1 项活动" in rendered
+ assert "Thinking" not in rendered
+ next(iter(view.tool_groups.values())).expanded = True
+ rendered = view.render()[0]
+ assert "Thinking" in rendered and "[Enter: expand]" not in rendered
+ assert "succeeded · 1s · exit 0" in rendered
+ event(
+ view,
+ "turn_end",
+ result={"subtype": "success", "is_error": False, "duration_ms": 1200},
+ )
+ event(
+ view,
+ "process",
+ item_id="bg",
+ kind="agent",
+ title="Background agent",
+ phase="begin",
+ status="running",
+ background=True,
+ )
+ assert view.presentation.turns["u"].status == "completed"
+ event(
+ view,
+ "background_process_sync",
+ items=[{"item_id": "bg", "title": "Agent"}],
+ )
+ event(view, "background_process_sync", items=[])
+ assert view.presentation.background == []
+
+
+def test_goal_new_user_boundary_and_plan_progress_do_not_stick_to_old_goal():
+ view = SessionView()
+ event(
+ view,
+ "goal_state",
+ goal_id="g",
+ goal={"status": "complete", "objective": "old"},
+ )
+ event(view, "user_msg", msg_id="new", prompt="new work")
+ event(
+ view,
+ "turn_plan",
+ item_id="p",
+ plan=[{"step": "new step", "status": "inProgress"}],
+ )
+ assert view.presentation.visible_goal() is None
+ assert view.presentation.progress_label().startswith("Plan 0/1")
+ event(
+ view,
+ "turn_plan",
+ item_id="p",
+ plan=[{"step": "new step", "status": "completed"}],
+ )
+ assert "Plan 1/1" in view.presentation.progress_label()
+ event(view, "user_msg", msg_id="next", prompt="more")
+ assert view.presentation.plan is None
+ event(
+ view,
+ "goal_state",
+ goal_id="g2",
+ goal={"status": "active", "objective": "replacement"},
+ )
+ assert view.presentation.visible_goal()["objective"] == "replacement"
+
+
+def test_unfinished_plan_survives_clarification_but_not_next_turn_after_terminal():
+ view = SessionView()
+ event(view, "user_msg", msg_id="u", prompt="work")
+ event(
+ view,
+ "turn_plan",
+ item_id="p",
+ plan=[{"step": "step", "status": "inProgress"}],
+ )
+ event(
+ view,
+ "turn_steered",
+ msg_id="clarify",
+ turn_id="u",
+ prompt="clarification",
+ )
+ assert view.presentation.plan is not None
+ event(
+ view,
+ "turn_end",
+ turn_id="u",
+ result={"subtype": "success", "duration_ms": 1},
+ )
+ assert "not updated" in view.presentation.progress_label()
+ event(view, "user_msg", msg_id="next", prompt="continue")
+ assert view.presentation.plan is None
+ event(
+ view,
+ "turn_plan",
+ item_id="p2",
+ plan=[{"step": "step", "status": "inProgress"}],
+ )
+ assert view.presentation.plan["turn_id"] == "next"
+
+
+def summary(**kw):
+ return {
+ "type": "history",
+ "revision": "r",
+ "generation": "g",
+ "detail": "summary",
+ "build_seq": 1,
+ "turns": [],
+ **kw,
+ }
+
+
+def test_cold_history_restores_latest_plan_tool_thinking_and_duration():
+ view = SessionView()
+ turns = []
+ for i in (1, 2):
+ turns.append(
+ {
+ "id": str(i),
+ "prompt": "work",
+ "ts": i * 100000,
+ "done": i == 1,
+ "durationMs": 9000 if i == 1 else None,
+ "blocks": [
+ {
+ "kind": "process",
+ "item_id": f"plan{i}",
+ "processKind": "plan",
+ "plan": [{"step": f"step{i}", "status": "inProgress"}],
+ }
+ ],
+ }
+ )
+ view.history(summary(turns=turns))
+ assert view.presentation.plan["turn_id"] == "2"
+ assert view.presentation.turns["1"].duration_ms == 9000
+ event(
+ view,
+ "turn_plan",
+ item_id="plan2",
+ turn_id="2",
+ seq=100,
+ plan=[{"step": "step2", "status": "completed"}],
+ )
+ view.history(summary(turns=turns, live_seq=90, build_seq=2))
+ assert view.presentation.plan["plan"][0]["status"] == "completed"
+
+
+def test_exact_terminal_fences_do_not_close_unrelated_rows():
+ view = SessionView()
+ view.history(
+ summary(
+ turns=[
+ {"id": "u", "forkPointId": "native", "prompt": "one"},
+ {"id": "other", "prompt": "two"},
+ ],
+ terminal_fences=[
+ {
+ "turn_id": "native",
+ "status": "completed",
+ "duration_ms": 12000,
+ }
+ ],
+ )
+ )
+ assert view.presentation.turns["u"].status == "completed"
+ assert view.presentation.turns["other"].status == "running"
+
+
+def test_sparse_quota_and_unknown_context_never_fabricate_zero():
+ view = SessionView()
+ event(view, "context_report", percentage=0, available=False)
+ assert view.presentation.usage_label() == "Context unavailable"
+ event(
+ view, "rate_limit_update", primary={"used_percent": 2, "resets_at": 100}
+ )
+ event(view, "rate_limit_update", primary={"resets_at": 101})
+ event(view, "status_report", rate_limits=[{"primary": {"used_percent": 0}}])
+ assert view.presentation.rates["default"]["primary"] == {
+ "used_percent": 2,
+ "resets_at": 101,
+ }
+
+
+def test_usage_human_units_and_relative_daily_scale():
+ assert tokens(2651000000) == "26.51亿"
+ assert tokens(1000000000000) == "1兆"
+ p = SessionPresentation(
+ status={
+ "usage": {
+ "lifetime_tokens": 20000,
+ "daily_usage_buckets": [
+ {"start_date": "2026-09-01", "tokens": 100},
+ {"start_date": "2026-09-02", "tokens": 200},
+ ],
+ }
+ }
+ )
+ content = p.panel("Usage / Context")
+ assert "2万" in content and "2026-09-02 [██████████]" in content
+
+
+def test_completion_revision_and_goal_hide_follow_session_rekey():
+ state = WorkspaceState()
+ state.event(
+ {
+ "type": "completion_state",
+ "sid": "tmp",
+ "completion_id": "c",
+ "unread": True,
+ "revision": 2,
+ }
+ )
+ state.event(
+ {
+ "type": "completion_state",
+ "sid": "tmp",
+ "unread": False,
+ "revision": 1,
+ }
+ )
+ state.event(
+ {
+ "type": "goal_state",
+ "sid": "tmp",
+ "goal_id": "g",
+ "dismissed": True,
+ "goal": {"status": "complete"},
+ }
+ )
+ state.event(
+ {"type": "session_rekey", "old_key": "tmp", "session_id": "real"}
+ )
+ assert state.view("real").presentation.completion["unread"]
+ assert state.view("real").presentation.visible_goal() is None
+
+
+@pytest.mark.asyncio
+async def test_late_goal_read_cannot_replace_new_broadcast_goal():
+ _, client = app_client()
+ await client._send(p.GetGoal(sid="s", cmd_id="read-old"))
+ client._on_event(
+ {
+ "type": "goal_state",
+ "sid": "s",
+ "goal_id": "new",
+ "goal": {"status": "active"},
+ }
+ )
+ client._on_event(
+ {
+ "type": "goal_state",
+ "sid": "s",
+ "request_id": "read-old",
+ "goal_id": "old",
+ "goal": {"status": "complete"},
+ }
+ )
+ assert client.workspace.view("s").presentation.goal_id == "new"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "name",
+ [
+ "Goal / Plan",
+ "Usage / Context",
+ "Background",
+ "Status",
+ "Notices",
+ "Reports",
+ "Web handoff",
+ ],
+)
+async def test_detail_panels_keep_reader_draft_and_session_scope(name):
+ app, client = app_client()
+ client.demo = True
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "x", "escape", "ctrl+k")
+ position = app.query_one(Transcript).cursor_location
+ await app.action_panel(name)
+ assert isinstance(app.screen, DetailPanel)
+ client.workspace.view("other").presentation.goal = {
+ "objective": "NOT THIS SESSION"
+ }
+ await pilot.pause()
+ assert "NOT THIS SESSION" not in app.screen.query_one(TextArea).text
+ await pilot.press("ctrl+s", "ctrl+e", "escape")
+ assert app.query_one(Composer).text == "x"
+ assert app.query_one(Transcript).cursor_location == position
+ assert not client._outbox
+
+
+@pytest.mark.asyncio
+async def test_named_action_submits_once_and_keeps_session_target():
+ app, client = app_client()
+ async with app.run_test() as pilot:
+ app.push_screen(ActionForm(client, "s", "rename_session"))
+ await pilot.pause()
+ from cc_remote.tui_fields import ParameterFields
+ form = app.screen.query_one(ParameterFields)
+ editor = next(row[-1] for row in form.rows if row[0] == "title")
+ editor.load_text("renamed")
+ await pilot.press("enter")
+ await pilot.pause()
+ messages = [json.loads(raw) for raw, _ in client._outbox.values()]
+ assert (
+ messages[-1]["type"] == "rename_session"
+ and messages[-1]["title"] == "renamed"
+ )
+ await pilot.press("enter")
+ await pilot.pause()
+ assert len(client._outbox) == 1
+
+
+def test_protocol_actions_are_validated_not_arbitrary_rpc_or_routing():
+ assert {
+ "fork_session",
+ "set_goal",
+ "manage_engine_hook",
+ "create_work_project",
+ } <= ACTIONS.keys()
+ with pytest.raises(ValueError, match="Transport"):
+ build_action("set_model", '{"sid":"other","model":"x"}', "s", "client")
+ with pytest.raises(ValueError):
+ build_action("set_effort", '{"effort":"bogus"}', "s", "client")
+ with pytest.raises(ValueError, match="target is pinned"):
+ build_action("delete_session", '{"session_id":"other"}', "s", "client")
+ msg = build_action(
+ "fork_session", '{"session_id":"s","last_turn_id":"t"}', "s", "client"
+ )
+ assert msg.request_id == msg.cmd_id and msg.sid == "s"
+ for name in ACTIONS:
+ values = defaults(
+ name, "s", "codex", {"cwd": "/repo"}, SessionPresentation()
+ )
+ assert "sid" not in values and "cmd_id" not in values
+
+
+@pytest.mark.asyncio
+async def test_secret_question_is_masked_scoped_and_never_put_in_draft():
+ app, client = app_client()
+ ask = {
+ "type": "ask_user",
+ "sid": "s",
+ "ask_id": "ask",
+ "question": "Password?",
+ "secret": True,
+ "allow_text": True,
+ "options": [],
+ }
+ client._handle(ask)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+t")
+ assert isinstance(app.screen, QuestionDialog)
+ input_ = app.screen.query_one(Input)
+ assert input_.password
+ input_.value = "secret answer"
+ await pilot.press("enter")
+ assert app.query_one(Composer).text == ""
+ assert "secret answer" not in client.workspace.view("s").render()[0]
+ sent = json.loads(next(iter(client._outbox.values()))[0])
+ assert sent["type"] == "answer_question" and sent["sid"] == "s"
+
+
+@pytest.mark.asyncio
+async def test_question_renders_wire_option_descriptions():
+ app, client = app_client()
+ ask = p.AskUser(
+ sid="s", ask_id="ask", question="Choose",
+ options=[{"label": "Safe", "ds": "Keeps existing files"},
+ {"label": "Replace", "ds": "Overwrites existing files"}],
+ ).model_dump()
+ client._handle(ask)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+t")
+ assert "Keeps existing files" in app.screen.query_one(PanelReader).text
+
+
+def test_attachment_validation_reuses_shared_limits(tmp_path):
+ f = tmp_path / "notes.txt"
+ f.write_text("notes")
+ attachment = read_attachment(str(f))
+ assert attachment["content"]["filename"] == "notes.txt"
+ with pytest.raises(ValueError):
+ read_attachment(str(f), image=True)
+ with pytest.raises(ValueError):
+ read_attachment(str(tmp_path))
+
+
+@pytest.mark.asyncio
+async def test_codex_running_uses_steer_and_queue_remains_server_owned():
+ _, client = app_client()
+ view = client.workspace.view("s")
+ view.state = "running"
+ assert await client.submit("clarify")
+ assert await client.submit("next", queue=True)
+ frames = [json.loads(raw) for raw, _ in client._outbox.values()]
+ assert frames[0]["type"] == "steer"
+ assert frames[1]["type"] == "query" and frames[1]["delivery"] == "queue"
+ assert not view.blocks and not view.queue
+
+
+@pytest.mark.asyncio
+async def test_queue_edit_uses_private_full_prompt_not_preview():
+ app, client = app_client()
+ async with app.run_test() as pilot:
+ app.push_screen(QueueEdit(client, "s", "queued"))
+ await pilot.pause()
+ screen = app.screen
+ client._on_event(
+ {
+ "type": "queued_query_detail",
+ "sid": "s",
+ "msg_id": "queued",
+ "request_id": screen.request_id,
+ "prompt": "full prompt beyond preview",
+ }
+ )
+ await pilot.pause(0.2)
+ assert screen.query_one(TextArea).text == "full prompt beyond preview"
+ screen.query_one(TextArea).load_text("edited")
+ await pilot.press("enter")
+ await pilot.pause()
+ frames = [json.loads(raw) for raw, _ in client._outbox.values()]
+ assert (
+ frames[-1]["type"] == "update_queued_query"
+ and frames[-1]["prompt"] == "edited"
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("outcome", ["accepted", "rejected", "error"])
+async def test_queue_edit_waits_for_exact_server_result(outcome):
+ app, client = app_client()
+ async with app.run_test() as pilot:
+ app.push_screen(QueueEdit(client, "s", "queued"))
+ await pilot.pause()
+ screen = app.screen
+ client._on_event({
+ "type": "queued_query_detail", "sid": "s", "msg_id": "queued",
+ "request_id": screen.request_id, "prompt": "original",
+ })
+ screen.receive_detail()
+ screen.query_one(TextArea).load_text("edited draft")
+ await screen.action_submit()
+ request = screen.update_request_id
+ assert request
+ result = screen.query_one("#queue-result", Static)
+ assert "not yet confirmed" in str(result.render())
+ count = len(client._outbox)
+ await screen.action_submit()
+ assert len(client._outbox) == count
+ response = {
+ "type": "queued_query_updated", "sid": "s", "msg_id": "queued",
+ "request_id": request, "updated": True, "to": client.client_id,
+ }
+ for mismatch in (
+ {"sid": "another"}, {"msg_id": "another"},
+ {"request_id": "another"}, {"to": "another"},
+ ):
+ client._handle(response | mismatch)
+ screen.receive_detail()
+ assert screen.update_request_id == request
+ client._handle({
+ "type": "command_ack", "client_id": client.client_id,
+ "cmd_id": request,
+ })
+ screen.receive_detail()
+ assert screen.update_request_id == request
+ if outcome == "rejected":
+ response.update(updated=False, error="Already started")
+ elif outcome == "error":
+ response = {
+ "type": "error", "sid": "s", "request_id": request,
+ "to": client.client_id, "message": "Already started",
+ }
+ client._handle(response)
+ screen.receive_detail()
+ assert screen.update_request_id is None
+ expected = ("saved by server" if outcome == "accepted"
+ else "rejected: Already started")
+ assert expected in str(result.render())
+ assert screen.query_one(TextArea).text == "edited draft"
+ assert not client.queue_updates and not client.queue_update_results
+
+
+@pytest.mark.asyncio
+async def test_queue_edit_closing_discards_pending_confirmation():
+ app, client = app_client()
+ async with app.run_test() as pilot:
+ app.push_screen(QueueEdit(client, "s", "queued"))
+ await pilot.pause()
+ screen = app.screen
+ screen.loaded = True
+ await screen.action_submit()
+ request = screen.update_request_id
+ assert request in client.queue_updates
+ app.pop_screen()
+ await pilot.pause()
+ client._handle({
+ "type": "queued_query_updated", "sid": "s", "msg_id": "queued",
+ "request_id": request, "updated": True, "to": client.client_id,
+ })
+ assert not client.queue_updates and not client.queue_update_results
+
+
+@pytest.mark.asyncio
+async def test_queue_edit_fast_reply_is_not_overwritten_by_send(monkeypatch):
+ app, client = app_client()
+ async with app.run_test() as pilot:
+ app.push_screen(QueueEdit(client, "s", "queued"))
+ await pilot.pause()
+ screen = app.screen
+ screen.loaded = True
+
+ async def immediate_reply(command):
+ client._handle({
+ "type": "queued_query_updated", "sid": "s",
+ "msg_id": "queued", "request_id": command.cmd_id,
+ "updated": False, "error": "Already started",
+ })
+ screen.receive_detail()
+ return True
+
+ monkeypatch.setattr(client, "_send", immediate_reply)
+ await screen.action_submit()
+ result = screen.query_one("#queue-result", Static)
+ assert "rejected: Already started" in str(result.render())
+
+
+@pytest.mark.asyncio
+async def test_demo_has_rich_content_and_no_writes_from_panels():
+ app, client = app_client()
+ seed_demo(client)
+ text = client.workspace.view("demo-review").render()[0]
+ assert "unbound switches" not in text
+ assert "Ctrl+j focuses the draft" in text
+ async with app.run_test(size=(90, 30)) as pilot:
+ await pilot.press("space", "g")
+ assert "Check regressions" in app.screen.query_one(TextArea).text
+ await pilot.press("escape", "space", "l")
+ assert isinstance(app.screen, QueuePanel)
+ await pilot.press("escape")
+ assert not client._outbox
+
+
+def test_missing_timestamp_is_explicit_not_fabricated():
+ assert TurnDisplay().label() == "Processing · duration unavailable"
+
+
+@pytest.mark.parametrize(
+ ("update", "expected"),
+ [
+ ({"used_percent": 0}, {"used_percent": 2, "resets_at": 100}),
+ (
+ {"resets_at": 101, "used_percent": None},
+ {"used_percent": 2, "resets_at": 101},
+ ),
+ ({"resets_at": 200}, {"resets_at": 200}),
+ (
+ {"resets_at": 200, "used_percent": 0},
+ {"resets_at": 200, "used_percent": 0},
+ ),
+ (
+ {"resets_at": 10, "used_percent": 90},
+ {"used_percent": 2, "resets_at": 100},
+ ),
+ ],
+)
+def test_rate_window_matches_web_period_and_monotonic_rules(update, expected):
+ assert (
+ merge_rate_window({"used_percent": 2, "resets_at": 100}, update)
+ == expected
+ )
+
+
+@pytest.mark.asyncio
+async def test_fresh_status_can_replace_quota_after_account_change_but_old_read_cannot():
+ _, c = app_client()
+ c._on_event(
+ {
+ "type": "rate_limit_update",
+ "sid": "s",
+ "primary": {"used_percent": 100},
+ }
+ )
+ await c._send(p.GetStatus(sid="s", cmd_id="old"))
+ c._on_event(
+ {
+ "type": "rate_limit_update",
+ "sid": "s",
+ "reached_type": "",
+ "primary": {"used_percent": 2},
+ }
+ )
+ c._on_event(
+ {
+ "type": "status_report",
+ "sid": "s",
+ "request_id": "old",
+ "rate_limits": [{"primary": {"used_percent": 0}}],
+ }
+ )
+ assert (
+ c.workspace.view("s").presentation.rates["default"]["primary"][
+ "used_percent"
+ ]
+ == 100
+ )
+ await c._send(p.GetStatus(sid="s", cmd_id="fresh"))
+ c._on_event(
+ {
+ "type": "status_report",
+ "sid": "s",
+ "request_id": "fresh",
+ "rate_limits": [{"primary": {"used_percent": 2}}],
+ }
+ )
+ assert (
+ c.workspace.view("s").presentation.rates["default"]["primary"][
+ "used_percent"
+ ]
+ == 2
+ )
+
+
+def test_restart_generation_accepts_fresh_completion_revision_and_distrusts_old_control():
+ view = SessionView(write_state="writable")
+ event(view, "snapshot", generation="old", state="running")
+ event(
+ view, "completion_state", completion_id="old", unread=True, revision=20
+ )
+ event(view, "snapshot", generation="new", state="idle")
+ event(
+ view, "completion_state", completion_id="new", unread=False, revision=1
+ )
+ assert view.presentation.completion["completion_id"] == "new"
+ assert view.write_state == "unknown"
+
+
+def test_old_user_replay_does_not_retire_completed_goal():
+ view = SessionView()
+ event(
+ view,
+ "goal_state",
+ goal_id="g",
+ goal={"status": "complete", "updatedAt": 200},
+ )
+ event(view, "user_msg", msg_id="old", prompt="old work", ts=100)
+ assert view.presentation.visible_goal() is not None
+
+
+def test_all_existing_command_surfaces_are_exposed_or_owned_by_core_ui():
+ from typing import get_args
+
+ commands = {
+ c.model_fields["type"].default
+ for c in get_args(p.AnyMessage)
+ if issubclass(c, p._Command)
+ }
+ core = {
+ "query",
+ "steer",
+ "switch_session",
+ "sync_btw",
+ "get_history",
+ "get_turn_detail",
+ "answer_question",
+ }
+ assert commands == set(ACTIONS) | core
+
+
+@pytest.mark.asyncio
+async def test_work_attach_and_reconnect_preserve_space():
+ _, c = app_client()
+ c.workspace.catalog["work-session"] = {
+ "session_id": "work-session",
+ "engine": "codex",
+ "space": "work",
+ }
+ await c._attach("work-session", "codex")
+ messages = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert (
+ messages[0]["type"] == "switch_session"
+ and messages[0]["space"] == "work"
+ )
+ sent = []
+
+ async def capture(raw):
+ sent.append(json.loads(raw))
+ return True
+
+ c._send_raw = capture
+ await c._recovery_preamble()
+ assert all(
+ m["space"] == "work" for m in sent if m["type"] == "switch_session"
+ )
+
+
+def test_btw_snapshot_empty_replaces_side_chats_without_losing_main_session():
+ state = WorkspaceState()
+ state.catalog["main"] = {"summary": "main", "engine": "codex"}
+ state.event(
+ {
+ "type": "btw_sync",
+ "generation": "g",
+ "revision": 1,
+ "sessions": [
+ {
+ "btw_sid": "btw-1",
+ "parent_sid": "main",
+ "engine": "codex",
+ "state": "idle",
+ }
+ ],
+ }
+ )
+ assert "btw-1" in state.catalog
+ state.event(
+ {"type": "btw_sync", "generation": "g", "revision": 2, "sessions": []}
+ )
+ assert "btw-1" not in state.catalog and "main" in state.catalog
+
+
+@pytest.mark.asyncio
+async def test_skill_completion_uses_profile_cwd_cache_without_input_time_request():
+ app, c = app_client()
+ c.capability_cache[
+ c.capability_key({"engine": "codex", "space": "code", "cwd": "/repo"})
+ ] = {"items": [{"kind": "skill", "name": "review", "enabled": True}]}
+ async with app.run_test() as pilot:
+ await pilot.press(
+ "ctrl+j", "i", "dollar_sign", "r", "ctrl+space", "enter"
+ )
+ assert app.query_one(Composer).text == "$review "
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_completion_read_receipt_only_for_visible_followed_session():
+ app, c = app_client()
+ c.workspace.view("other").presentation.completion = {
+ "completion_id": "other-c",
+ "unread": True,
+ }
+ c.workspace.view("s").presentation.completion = {
+ "completion_id": "mine",
+ "unread": True,
+ }
+ async with app.run_test() as pilot:
+ await pilot.pause(0.2)
+ messages = [json.loads(raw) for raw, _ in c._outbox.values()]
+ acknowledgements = [
+ m for m in messages if m["type"] == "acknowledge_completion"
+ ]
+ assert len(acknowledgements) == 1 and acknowledgements[0]["sid"] == "s"
+
+
+def test_private_responses_for_other_clients_are_never_projected():
+ _, c = app_client()
+ c._handle(
+ {
+ "type": "file_preview",
+ "sid": "s",
+ "to": "not-this-client",
+ "content": "secret",
+ }
+ )
+ assert not c.workspace.view("s").presentation.reports
+
+
+def test_quota_selects_account_bucket_and_native_window_durations():
+ rates = {
+ "model": {
+ "primary": {"used_percent": 100, "window_duration_mins": 300}
+ },
+ "codex": {
+ "primary": {"used_percent": 20, "window_duration_mins": 10080},
+ "secondary": {"used_percent": 2, "window_duration_mins": 300},
+ },
+ }
+ windows = dict(quota_windows(rates, "codex", 0))
+ assert windows["5h"]["used_percent"] == 2
+ assert windows["Week"]["used_percent"] == 20
+ assert dict(quota_windows({"model": rates["model"]}, "codex", 0)) == {
+ "5h": None,
+ "Week": None,
+ }
+
+
+def test_free_and_claude_quotas_do_not_get_mislabeled_as_paid_codex():
+ rates = {"codex": {"primary": {"used_percent": 22}}}
+ assert quota_windows(rates, "codex", 0) == [
+ ("Overall", {"used_percent": 22})
+ ]
+ rates = {
+ "claude-seven-day-opus": {
+ "primary": {
+ "used_percent": 12,
+ "resets_at": 1000,
+ "window_duration_mins": 10080,
+ }
+ }
+ }
+ assert (
+ dict(quota_windows(rates, "claude", 999))["Opus"]["used_percent"] == 12
+ )
+ assert "Opus" not in dict(quota_windows(rates, "claude", 1000))
+
+
+def test_cold_finished_plan_does_not_survive_a_newer_prompt():
+ view = SessionView()
+ view.history(
+ summary(
+ turns=[
+ {
+ "id": "old",
+ "prompt": "old",
+ "done": True,
+ "interrupted": True,
+ "blocks": [
+ {
+ "kind": "process",
+ "item_id": "p",
+ "processKind": "plan",
+ "plan": [
+ {"step": "unfinished", "status": "inProgress"}
+ ],
+ }
+ ],
+ },
+ {"id": "new", "prompt": "next"},
+ ]
+ )
+ )
+ assert view.presentation.plan is None
+
+
+def test_goal_resume_restores_previously_retired_same_objective():
+ view = SessionView()
+ event(view, "goal_state", goal_id="g", goal={"status": "complete"})
+ event(view, "user_msg", msg_id="u", prompt="next")
+ assert view.presentation.visible_goal() is None
+ event(view, "goal_state", goal_id="g", goal={"status": "active"})
+ assert view.presentation.visible_goal()["status"] == "active"
+
+
+def test_rebuild_does_not_reappend_process_output_over_retained_baseline():
+ _, c = app_client()
+ c._handle(
+ {
+ "type": "process",
+ "sid": "s",
+ "seq": 1,
+ "item_id": "p",
+ "output": "once",
+ "phase": "begin",
+ "kind": "command",
+ }
+ )
+ c._handle(
+ {
+ "type": "replay_start",
+ "sid": "s",
+ "rebuild": True,
+ "generation": "new",
+ "from_seq": 0,
+ "to_seq": 2,
+ }
+ )
+ c._handle(
+ {
+ "type": "process",
+ "sid": "s",
+ "seq": 2,
+ "item_id": "p",
+ "delta": "once",
+ "append_to": "output",
+ "phase": "delta",
+ "kind": "command",
+ }
+ )
+ assert c.workspace.view("s").blocks[0].data["output"] == "once"
+ assert c.cursors["s"] == 2
+
+
+@pytest.mark.asyncio
+async def test_modal_actions_are_reachable_and_confirmable_without_mouse():
+ app, c = app_client()
+ async with app.run_test(size=(60, 22)) as pilot:
+ app.push_screen(ActionForm(c, "s", "set_effort"))
+ await pilot.pause()
+ editor = app.screen.query_one(TextArea)
+ editor.load_text('high')
+ editor.focus()
+ await pilot.press("enter")
+ assert len(c._outbox) == 1
+ await pilot.press("escape")
+ assert app.query_one(Composer).text == ""
+
+
+@pytest.mark.asyncio
+async def test_slash_action_opens_form_without_sending_text_to_model():
+ app, c = app_client()
+ async with app.run_test() as pilot:
+ app.query_one(Composer).load_text("/set_effort")
+ await pilot.press("ctrl+j", "enter")
+ assert isinstance(app.screen, ActionForm)
+ assert app.screen.panel_name == "set_effort"
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_narrow_layout_hides_empty_bars_and_keeps_transcript_visible():
+ app, c = app_client()
+ async with app.run_test(size=(60, 22)) as pilot:
+ await pilot.pause()
+ for name in (
+ "#question",
+ "#progress",
+ "#attachments",
+ "#suggestions",
+ "#settings",
+ ):
+ assert not app.query_one(name).display
+ assert app.query_one(Transcript).size.height >= 8
+ await pilot.press("ctrl+j", "i", "dollar_sign")
+ c.capability_cache[
+ c.capability_key(
+ {"engine": "codex", "space": "code", "cwd": "/repo"}
+ )
+ ] = {"items": [{"kind": "skill", "name": "new-skill"}]}
+ app.paint()
+ assert app.query_one("#suggestions").display
+
+
+@pytest.mark.asyncio
+async def test_archived_session_remains_searchable_in_session_picker():
+ app, c = app_client()
+ c.workspace.event(
+ {
+ "type": "session_list",
+ "engine": "codex",
+ "sessions": [
+ {"session_id": "s", "engine": "codex", "summary": "active"},
+ {
+ "session_id": "archived",
+ "engine": "codex",
+ "summary": "old",
+ "tag": "archived",
+ },
+ ],
+ }
+ )
+ async with app.run_test() as pilot:
+ from textual.widgets import OptionList
+
+ assert not app.query(OptionList)
+ await pilot.press("space", "e", "slash")
+ app.screen.query_one(Input).value = "archived"
+ await pilot.pause()
+ from cc_remote.tui_tree import SessionTree
+
+ listing = app.query_one(SessionTree)
+ assert listing.cursor_node.data == ("session", "archived")
+
+
+@pytest.mark.asyncio
+async def test_rekeyed_queue_read_and_panel_action_keep_exact_session():
+ _, c = app_client()
+ c.workspace.view("tmp")
+ await c._send(
+ p.GetQueuedQuery(
+ sid="tmp", msg_id="q", cmd_id="read", client_id=c.client_id
+ )
+ )
+ c._handle({"type": "session_rekey", "old_key": "tmp", "session_id": "real"})
+ c._handle(
+ {
+ "type": "queued_query_detail",
+ "sid": "real",
+ "msg_id": "q",
+ "request_id": "read",
+ "prompt": "full",
+ }
+ )
+ assert c.queue_details["read"]["prompt"] == "full"
+ await c._send(p.RenameSession(sid="tmp", session_id="tmp", title="new"))
+ frame = json.loads(list(c._outbox.values())[-1][0])
+ assert frame["sid"] == frame["session_id"] == "real"
+
+
+@pytest.mark.asyncio
+async def test_recovery_loads_other_engine_and_work_catalogs():
+ _, c = app_client()
+ frames = []
+
+ async def capture(raw):
+ frames.append(json.loads(raw))
+ return True
+
+ c._send_raw = capture
+ await c._recovery_preamble()
+ assert {
+ (f["engine"], f["space"])
+ for f in frames
+ if f["type"] == "list_sessions"
+ } == {("claude", "code"), ("claude", "work"), ("codex", "work")}
+ assert not any(
+ f["type"] in {"query", "steer", "new_session"} for f in frames
+ )
+
+
+@pytest.mark.asyncio
+async def test_help_works_before_selecting_a_session():
+ app, c = app_client()
+ c.attached_sid = None
+ async with app.run_test() as pilot:
+ await pilot.press("space", "h")
+ assert isinstance(app.screen, DetailPanel)
+ assert "Ctrl+j" in app.screen.query_one(TextArea).text
+ assert not c._outbox
+
+
+@pytest.mark.parametrize(
+ "kind",
+ [
+ "command",
+ "hook",
+ "agent",
+ "reasoning",
+ "compaction",
+ "file_change",
+ "mcp",
+ "terminal",
+ "task",
+ "safety",
+ ],
+)
+def test_native_serialized_process_frames_preserve_sparse_output(kind):
+ """Use the real serializer, including null fields omitted by small fixtures."""
+ _, c = app_client()
+ frames = [
+ p.UserMsg(sid="s", msg_id="u", prompt="work"),
+ p.ProcessEvent(
+ sid="s",
+ item_id="p",
+ kind=kind,
+ phase="start",
+ status="running",
+ title="Activity",
+ command="command",
+ output="first",
+ ),
+ p.ProcessEvent(
+ sid="s",
+ item_id="p",
+ kind=kind,
+ phase="update",
+ title="Activity",
+ append_to="output",
+ delta=" second",
+ ),
+ p.ProcessEvent(
+ sid="s",
+ item_id="p",
+ kind=kind,
+ phase="end",
+ title="Activity",
+ status="succeeded",
+ exit_code=0,
+ duration_ms=1200,
+ ),
+ ]
+ for frame in frames:
+ c._handle(json.loads(p.serialize(frame)))
+ block = c.workspace.view("s").blocks[-1]
+ assert block.data["output"] == "first second"
+ assert block.data["command"] == "command"
+ assert block.data["status"] == "succeeded"
+ c.workspace.view("s").render()
+ c.workspace.view("s").tool_groups["tools:p"].expanded = True
+ assert "1s · exit 0" in c.workspace.view("s").render()[0]
+
+
+def test_native_serialized_tool_detail_and_async_questions_keep_wire_shapes():
+ view = SessionView()
+ frames = [
+ p.UserMsg(msg_id="u", prompt="work"),
+ p.ToolUse(
+ message_id="a",
+ tool_use_id="tool",
+ tool="exec",
+ input={"cmd": "true"},
+ ),
+ p.ToolDelta(tool_use_id="tool", stream="output", delta="progress"),
+ p.ToolResult(tool_use_id="tool", content="done", is_error=False),
+ p.AssistantMsgStart(message_id="a"),
+ p.Delta(message_id="a", text="answer"),
+ p.AssistantMsgEnd(
+ message_id="a",
+ channel="final",
+ delivery="async",
+ questions=[
+ p.AsyncQuestionSpec(title="Which?", options=["one", "two"])
+ ],
+ ),
+ p.TurnEnd(
+ result=p.TurnResult(
+ subtype="success", duration_ms=1000, is_error=False
+ )
+ ),
+ ]
+ for frame in frames:
+ view.event(json.loads(p.serialize(frame)))
+ assert view.presentation.turns["u"].status == "completed"
+ assert "Question · non-blocking" in view.render()[0]
+ assert "Which?" in view.render()[0]
+ assert "progress" in next(b.text for b in view.blocks if b.id == "tool")
+
+
+@pytest.mark.asyncio
+async def test_action_field_help_uses_shared_schema_without_changing_draft():
+ app, c = app_client()
+ async with app.run_test() as pilot:
+ app.push_screen(ActionForm(c, "s", "set_effort"))
+ await pilot.pause()
+ await pilot.press("question_mark")
+ await pilot.pause()
+ assert "high" in app.screen.query_one(TextArea).text
+ assert "cmd_id" not in app.screen.query_one(TextArea).text
+ await pilot.press("escape")
+ assert isinstance(app.screen, ActionForm)
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_work_catalog_invalidation_refreshes_work_not_default_code():
+ _, c = app_client()
+ c._handle(
+ json.loads(
+ p.serialize(p.SessionListInvalidated(engine="codex", space="work"))
+ )
+ )
+ await c._flush_history_refreshes()
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ lists = [
+ (m["engine"], m["space"])
+ for m in frames
+ if m["type"] == "list_sessions"
+ ]
+ assert lists == [("codex", "work")]
+
+
+def test_serialized_legacy_catalog_and_untitled_btw_are_normalized():
+ _, c = app_client()
+ c._handle(
+ json.loads(
+ p.serialize(
+ p.SessionList(
+ engine="claude",
+ sessions=[p.SessionInfo(session_id="parent")],
+ )
+ )
+ )
+ )
+ assert c.workspace.catalog["parent"]["engine"] == "claude"
+ assert c.session_engines["parent"] == "claude"
+ c.workspace.event(
+ {
+ "type": "btw_opened",
+ "btw_sid": "btw-side",
+ "parent_sid": "parent",
+ "engine": "claude",
+ }
+ )
+ assert c.workspace.catalog["btw-side"]["summary"] == "BTW · parent"
+
+
+def test_capability_completion_cache_retains_names_with_bounded_directories():
+ _, c = app_client()
+ for index in range(40):
+ c._on_event(
+ {
+ "type": "engine_capabilities",
+ "sid": "s",
+ "engine": "codex",
+ "space": "code",
+ "cwd": f"/repo/{index}",
+ "items": [
+ {
+ "kind": "skill",
+ "name": "useful",
+ "description": "large" * 10000,
+ }
+ ],
+ }
+ )
+ assert len(c.capability_cache) == 32
+ assert all(
+ row["items"] == [{"kind": "skill", "name": "useful"}]
+ for row in c.capability_cache.values()
+ )
+
+
+@pytest.mark.asyncio
+async def test_sidless_native_catalog_replies_reach_the_requested_panel_only():
+ _, c = app_client()
+ c.workspace.catalog["s"].update(codex_profile_id="account-one")
+ await c.refresh_panel("s", "Settings")
+ c._handle(
+ json.loads(
+ p.serialize(
+ p.Models(
+ engine="codex",
+ codex_profile_id="account-two",
+ models=[{"id": "other"}],
+ )
+ )
+ )
+ )
+ assert "models" not in c.workspace.view("s").presentation.reports
+ c._handle(
+ json.loads(
+ p.serialize(
+ p.Models(
+ engine="codex",
+ codex_profile_id="account-one",
+ models=[{"id": "wanted"}],
+ )
+ )
+ )
+ )
+ assert c.workspace.view("s").presentation.reports["models"]["models"] == [
+ {"id": "wanted"}
+ ]
+ await c.prefetch_capabilities("s")
+ request_id, request = next(
+ (key, value)
+ for key, value in c.catalog_reads.items()
+ if value["type"] == "get_engine_capabilities"
+ )
+ c._handle(
+ json.loads(
+ p.serialize(
+ p.EngineCapabilities(
+ engine="codex",
+ space="code",
+ cwd="/repo",
+ codex_profile_id="account-one",
+ request_id=request_id,
+ items=[
+ p.EngineCapabilityItem(
+ kind="skill", id="skill", name="my-skill"
+ )
+ ],
+ )
+ )
+ )
+ )
+ assert "engine_capabilities" in c.workspace.view("s").presentation.reports
+ assert (
+ c.capability_cache[c.capability_key(request)]["items"][0]["name"]
+ == "my-skill"
+ )
+
+
+def test_profile_scoped_actions_seed_the_selected_session_account():
+ row = {"cwd": "/repo", "codex_profile_id": "account-one"}
+ for name in (
+ "manage_engine_plugin",
+ "manage_engine_skill",
+ "get_models",
+ "get_engine_capabilities",
+ ):
+ assert (
+ defaults(name, "s", "codex", row, SessionPresentation())[
+ "codex_profile_id"
+ ]
+ == "account-one"
+ )
+
+
+@pytest.mark.asyncio
+async def test_btw_rebuild_uses_its_ring_not_native_history_and_keeps_draft():
+ _, c = app_client()
+ c.attached_sid = "btw-side"
+ view = c.workspace.view("btw-side")
+ view.draft = "kept"
+ view.event({"type": "user_msg", "msg_id": "old", "prompt": "old baseline"})
+ frames = [
+ p.ReplayStart(
+ sid="btw-side",
+ generation="g",
+ rebuild=True,
+ truncated=False,
+ from_seq=0,
+ to_seq=2,
+ ),
+ p.UserMsg(sid="btw-side", seq=1, msg_id="u", prompt="side question"),
+ p.Delta(sid="btw-side", seq=2, message_id="a", text="side answer"),
+ p.ReplayEnd(sid="btw-side", to_seq=2, truncated=False),
+ ]
+ for frame in frames:
+ c._handle(json.loads(p.serialize(frame)))
+ assert "old baseline" not in view.render()[0]
+ assert (
+ "side question" in view.render()[0]
+ and "side answer" in view.render()[0]
+ )
+ assert view.draft == "kept"
+ assert "btw-side" not in c._history_refresh_now
+ assert not await c._request_history("btw-side", force=True)
+ sent = []
+
+ async def capture(raw):
+ sent.append(json.loads(raw))
+ return True
+
+ c._send_raw = capture
+ await c._recovery_preamble()
+ sync = next(m for m in sent if m["type"] == "sync_btw")
+ assert sync["cursor"] == 2 and sync["generation"] == "g"
+ assert not any(
+ m["type"] in {"switch_session", "get_history", "new_session"}
+ for m in sent
+ )
+
+
+def test_btw_catalog_removal_disables_input_without_erasing_readable_text():
+ state = WorkspaceState()
+ state.event(
+ {
+ "type": "btw_opened",
+ "btw_sid": "btw-side",
+ "parent_sid": "s",
+ "engine": "codex",
+ }
+ )
+ view = state.view("btw-side")
+ view.write_state = "writable"
+ view.draft = "kept"
+ state.event(
+ {
+ "type": "user_msg",
+ "sid": "btw-side",
+ "msg_id": "u",
+ "prompt": "kept history",
+ }
+ )
+ state.event(
+ {"type": "btw_sync", "generation": "g", "revision": 1, "sessions": []}
+ )
+ assert view.write_state == "unavailable"
+ assert view.draft == "kept" and "kept history" in view.render()[0]
+
+
+def test_message_actions_seed_native_fork_and_checkpoint_not_ui_row_ids():
+ view = SessionView()
+ view.history(
+ summary(
+ turns=[
+ {
+ "id": "ui-old",
+ "forkPointId": "native-fork",
+ "checkpointId": "native-checkpoint",
+ "prompt": "old",
+ "done": True,
+ },
+ {"id": "ui-new", "prompt": "new"},
+ ]
+ )
+ )
+ view.presentation.selected_turn = "ui-old"
+ fork = defaults("fork_session", "s", "claude", {}, view.presentation)
+ rewind = defaults("rollback_session", "s", "claude", {}, view.presentation)
+ assert fork["last_turn_id"] == "native-fork"
+ assert rewind["checkpoint_id"] == "native-checkpoint"
+ rewind = defaults("rollback_session", "s", "codex", {}, view.presentation)
+ assert rewind["checkpoint_id"] is None
+
+
+def test_epoch_change_resets_cold_receipts_before_the_new_catalog():
+ state = WorkspaceState()
+ state.event(
+ {
+ "type": "snapshot",
+ "sid": "active",
+ "generation": "old",
+ "state": "idle",
+ }
+ )
+ state.event(
+ {
+ "type": "completion_state",
+ "sid": "cold",
+ "completion_id": "old",
+ "unread": True,
+ "revision": 100,
+ }
+ )
+ state.event(
+ {
+ "type": "snapshot",
+ "sid": "active",
+ "generation": "new",
+ "state": "idle",
+ }
+ )
+ state.event(
+ {
+ "type": "session_list",
+ "engine": "codex",
+ "sessions": [
+ {
+ "session_id": "cold",
+ "completion_id": "new",
+ "completion_unread": False,
+ "completion_revision": 1,
+ }
+ ],
+ }
+ )
+ assert state.view("cold").presentation.completion["completion_id"] == "new"
+ assert not state.view("cold").presentation.completion["unread"]
diff --git a/tests/test_tui_pending_messages.py b/tests/test_tui_pending_messages.py
new file mode 100644
index 00000000..5000f1e0
--- /dev/null
+++ b/tests/test_tui_pending_messages.py
@@ -0,0 +1,123 @@
+"""Immediate, non-authoritative send receipts across delayed echo/rebuild."""
+
+import asyncio
+import json
+
+import pytest
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp
+from tests.test_tui_send_jumps import client
+
+
+def frame(c):
+ return json.loads(next(iter(c._outbox.values()))[0])
+
+
+@pytest.mark.asyncio
+async def test_pending_message_is_visible_before_socket_send_finishes():
+ c = client()
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def slow_socket(raw):
+ started.set()
+ await release.wait()
+ return True
+
+ c._send_raw = slow_socket
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.focus()
+ editor.load_text("not swallowed")
+ task = asyncio.create_task(app.send(False))
+ try:
+ await asyncio.wait_for(started.wait(), 2)
+ app.paint()
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ assert "not swallowed" in reader.text
+ assert "awaiting confirmation" in reader.text
+ assert reader.scroll_y == reader.max_scroll_y
+ msg = frame(c)
+ c._handle(dict(type="user_msg", sid="s", msg_id=msg["msg_id"],
+ prompt=msg["prompt"]))
+ finally:
+ release.set()
+ await task
+ app.paint()
+ await pilot.pause()
+ assert reader.text.count("not swallowed") == 1
+ assert "awaiting confirmation" not in reader.text
+ assert editor.text == ""
+
+
+@pytest.mark.asyncio
+async def test_ack_does_not_confirm_delivery_but_echo_deduplicates():
+ c = client()
+ v = c.workspace.view("s")
+ before = v.active_turn
+ assert await c.submit("pending prompt")
+ msg = frame(c)
+ assert v.active_turn == before # No invented native task.
+ c._handle(dict(type="command_ack", client_id=c.client_id,
+ cmd_id=msg["cmd_id"]))
+ assert v.pending_messages
+ for _ in range(2):
+ c._handle(dict(type="user_msg", sid="s", msg_id=msg["msg_id"],
+ prompt=msg["prompt"]))
+ assert not v.pending_messages
+ assert v.render()[0].count("pending prompt") == 1
+
+
+@pytest.mark.asyncio
+async def test_history_first_rebuild_and_rekey_reconcile_pending_identity():
+ c = client()
+ assert await c.submit("pending prompt")
+ msg = frame(c)
+ c.workspace.event(dict(type="session_rekey", old_key="s", session_id="new"))
+ v = c.workspace.view("new")
+ v.history(dict(type="history", revision="r", generation="g", build_seq=1,
+ turns=[], reset=True))
+ assert "pending prompt" in v.render()[0]
+ v.history(dict(type="history", revision="r", generation="g", build_seq=2,
+ turns=[dict(id="native", clientMsgId=msg["msg_id"],
+ prompt="pending prompt", done=True, blocks=[])]))
+ v.event(dict(type="user_msg", msg_id=msg["msg_id"], prompt="pending prompt"))
+ assert not v.pending_messages
+ assert v.render()[0].count("pending prompt") == 1
+
+
+@pytest.mark.asyncio
+async def test_failed_send_retains_text_and_queue_is_not_projected_as_sent():
+ c = client()
+ v = c.workspace.view("s")
+ assert await c.submit("will be rejected")
+ msg = frame(c)
+ v.event(dict(type="error", msg_id=msg["msg_id"], code="busy", message="Busy"))
+ assert not v.pending_messages
+ failed = next(b for b in v.blocks if b.id == "user:" + msg["msg_id"])
+ assert failed.data["status"] == "failed"
+ assert "will be rejected" in v.render()[0]
+ assert await c.submit("queued only", queue=True)
+ assert "queued only" not in v.render()[0]
+ v.state = "running"
+ assert await c.submit("steering")
+ steering = json.loads(list(c._outbox.values())[-1][0])
+ assert steering["type"] == "steer"
+ v.event(dict(type="turn_steered", msg_id=steering["msg_id"],
+ turn_id="native", prompt="steering"))
+ assert v.render()[0].count("steering") == 1
+
+
+@pytest.mark.asyncio
+async def test_locally_rejected_send_removes_receipt_without_losing_draft():
+ c = client()
+ v = c.workspace.view("s")
+ v.draft = "keep draft"
+
+ async def reject(message):
+ return False
+
+ c._send = reject
+ assert not await c.submit(v.draft)
+ assert v.draft == "keep draft" and not v.pending_messages
diff --git a/tests/test_tui_preview.py b/tests/test_tui_preview.py
new file mode 100644
index 00000000..1328ee15
--- /dev/null
+++ b/tests/test_tui_preview.py
@@ -0,0 +1,397 @@
+"""Model-free preview discovery, file hints, Vim, RPC and image isolation."""
+
+import asyncio
+import base64
+import io
+
+import pytest
+from PIL import Image
+from textual.widgets import Static
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, Composer
+from cc_remote.tui_state import Block
+from cc_remote.tui_preview import (
+ PreviewRef,
+ references,
+ local_reference,
+ decode_image,
+ preview_request,
+ route_preview,
+ detect_graphics,
+)
+from cc_remote.tui_preview_views import (
+ FileHints,
+ FilePreviewScreen,
+ MarkdownReader,
+)
+from cc_remote.tui_inline_images import TranscriptViewport, InlinePicture
+
+
+def make_app():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ return WorkspaceApp(client, connect=False)
+
+
+def image_reply():
+ output = io.BytesIO()
+ Image.new("RGB", (20, 10), "red").save(output, format="PNG")
+ return {
+ "type": "file_preview",
+ "format": "image",
+ "media_type": "image/png",
+ "data": base64.b64encode(output.getvalue()).decode(),
+ }
+
+
+def test_detect_markdown_images_spaces_and_deduplicate():
+ refs = references(
+ "[Plan]() and `docs/b.markdown`\n"
+ "\n/work/a.md:12\n"
+ "[again](/work/image.png)"
+ )
+ assert [r.path for r in refs] == [
+ "/work/a plan.md",
+ "docs/b.markdown",
+ "/work/image.png",
+ "/work/a.md",
+ ]
+ assert len(references(" ".join(f"file{i}.md" for i in range(100)))) == 64
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "https://host/a.md",
+ "file:///a.md",
+ "//host/a.png",
+ "javascript:x.md",
+ "%2f%2fhost/a.md",
+ "a%00.md",
+ "a\x1b.md",
+ "foo.txt",
+ ],
+)
+def test_only_local_bounded_paths(path):
+ assert local_reference(path) is None
+
+
+def test_no_requests_for_remote_links():
+ assert not references("https://host/a.md ")
+ assert [r.path for r in references("[fake.md](real.md) (other.md)")] == [
+ "real.md",
+ "other.md",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_image_widget_integrates_with_textual(monkeypatch):
+ from textual_image.widget import HalfcellImage
+
+ monkeypatch.delenv("NO_COLOR", raising=False)
+ app = make_app()
+ app.graphics = HalfcellImage
+
+ async def request(*args, **kwargs):
+ return image_reply()
+
+ monkeypatch.setattr("cc_remote.tui_inline_images.preview_request", request)
+ app.client.workspace.view("s").put(
+ Block("image", "assistant", "result.png")
+ )
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ assert len(app.query(HalfcellImage)) == 1
+ assert app.query_one(TranscriptViewport).cache
+ picture = app.query_one(HalfcellImage)
+ strips = picture.render_lines(picture.size.region)
+ pixels = [segment for strip in strips for segment in strip
+ if "▀" in segment.text]
+ assert pixels and all(s.style.color.triplet.red == 255 for s in pixels)
+ assert not any("SIXEL IMAGE" in strip.text for strip in strips)
+ assert "▀" in app.export_screenshot()
+
+
+@pytest.mark.asyncio
+async def test_automatic_external_image_waits_for_user_authorization(
+ monkeypatch,
+):
+ app = make_app()
+ app.graphics = FakeImage
+ calls = []
+
+ async def request(*args, **kwargs):
+ calls.append(kwargs)
+ return {"type": "preview_authorization_required"}
+
+ monkeypatch.setattr("cc_remote.tui_inline_images.preview_request", request)
+ app.client.workspace.view("s").put(
+ Block("image", "assistant", "result.png")
+ )
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ assert calls == [{}]
+ viewport = app.query_one(TranscriptViewport)
+ assert not viewport.cache
+ assert "authorization" in next(iter(viewport.errors.values()))
+
+
+def test_decode_bounded_image_without_filesystem(monkeypatch):
+ decoded = decode_image(image_reply())
+ assert decoded.size == (20, 10)
+ decoded.close()
+ with pytest.raises(ValueError):
+ decode_image({"format": "image", "data": "invalid!"})
+ with pytest.raises(ValueError, match="SVG"):
+ decode_image({"format": "image", "media_type": "image/svg+xml"})
+ monkeypatch.setattr("cc_remote.tui_preview.MAX_IMAGE_BYTES", 1)
+ with pytest.raises(ValueError, match="limit"):
+ decode_image(image_reply())
+
+
+def test_no_terminal_probe_when_not_tty(monkeypatch):
+ class Pipe:
+ def isatty(self):
+ return False
+
+ monkeypatch.setattr("cc_remote.tui_preview.sys.__stdin__", Pipe())
+ assert detect_graphics() is None
+
+
+@pytest.mark.asyncio
+async def test_preview_rpc_rejects_other_sessions_and_cleans_cancelled_reads():
+ client = make_app().client
+ task = asyncio.create_task(preview_request(client, "s", "doc.md"))
+ await asyncio.sleep(0)
+ rid = next(iter(client.preview_waiters))
+ event = {
+ "type": "file_preview",
+ "request_id": rid,
+ "sid": "other",
+ "content": "private",
+ "format": "markdown",
+ }
+ assert route_preview(client, event) and not task.done()
+ client._on_event({**event, "sid": "s"})
+ assert (await task)["content"] == "private"
+ assert not client.preview_waiters
+ assert not client.workspace.view("s").presentation.reports
+ task = asyncio.create_task(preview_request(client, "s", "doc.md"))
+ await asyncio.sleep(0)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert not client.preview_waiters
+
+
+@pytest.mark.asyncio
+async def test_numbered_hints_immediate_digit_and_paging():
+ app = make_app()
+ refs = [PreviewRef(f"doc{i}.md", "markdown") for i in range(13)]
+ async with app.run_test() as pilot:
+ chosen = []
+ await app.push_screen(FileHints(refs), chosen.append)
+ await pilot.press("l", "4")
+ assert chosen == [refs[12]]
+ await app.push_screen(FileHints(refs), chosen.append)
+ await pilot.press("2")
+ assert chosen[-1] == refs[1]
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_multiple_docs_shortcut_uses_numbered_selection(monkeypatch):
+ app = make_app()
+ app.client.workspace.view("s").put(
+ Block("a", "assistant", "`a.md` and `b.md`")
+ )
+ calls = []
+
+ async def request(client, sid, path=None, **kwargs):
+ calls.append((sid, path))
+ return {
+ "type": "file_preview",
+ "format": "markdown",
+ "content": "# Hello",
+ }
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "v")
+ assert isinstance(app.screen, FileHints)
+ assert not calls
+ await pilot.press("2")
+ await pilot.pause()
+ assert isinstance(app.screen, FilePreviewScreen)
+ assert calls == [("s", "b.md")]
+ assert "Hello" in app.screen.query_one(MarkdownReader).text
+ await pilot.press("escape")
+ assert isinstance(app.screen, FileHints)
+ assert app.screen.page == 0
+ from textual.widgets import OptionList
+ assert app.screen.query_one(OptionList).highlighted == 1
+ await pilot.press("1")
+ await pilot.pause()
+ assert calls[-1] == ("s", "a.md")
+ await pilot.press("escape", "escape")
+ assert len(app.screen_stack) == 1
+
+
+@pytest.mark.asyncio
+async def test_markdown_readonly_vim_no_save_and_no_external_asset_fetch(
+ monkeypatch,
+):
+ app = make_app()
+
+ async def request(*args, **kwargs):
+ return {
+ "type": "file_preview",
+ "format": "markdown",
+ "content": "# Heading\n\ncall(one two)\n\n",
+ }
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ async with app.run_test() as pilot:
+ await app.push_screen(
+ FilePreviewScreen(app.client, "s", PreviewRef("a.md", "markdown"))
+ )
+ await pilot.pause()
+ reader = app.screen.query_one(MarkdownReader)
+ assert "# Heading" not in reader.text and "Heading" in reader.text
+ index = reader.text.index("one two")
+ reader.move_cursor(reader.document.get_location_from_index(index))
+ await pilot.press("y", "i", "left_parenthesis")
+ assert app.clipboard == "one two"
+ original = reader.text
+ await pilot.press("c", "i", "w", "ctrl+s", "ctrl+e")
+ assert reader.text == original and reader.read_only
+ assert not app.client._outbox
+ await pilot.press("escape")
+ assert len(app.screen_stack) == 1
+
+
+@pytest.mark.asyncio
+async def test_outside_file_requires_explicit_a_never_auto_grants(monkeypatch):
+ app = make_app()
+ calls = []
+
+ async def request(client, sid, path=None, **kwargs):
+ calls.append(kwargs)
+ if kwargs.get("challenge"):
+ return {"type": "preview_authorization_result", "status": "granted"}
+ if len(calls) == 1:
+ return {
+ "type": "preview_authorization_required",
+ "authorization_id": "auth",
+ "request_id": "tui-preview-original",
+ "resolved_path": "/outside/a.md",
+ }
+ return {
+ "type": "file_preview",
+ "format": "markdown",
+ "content": "allowed",
+ }
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ async with app.run_test() as pilot:
+ await app.push_screen(
+ FilePreviewScreen(
+ app.client, "s", PreviewRef("/outside/a.md", "markdown")
+ )
+ )
+ await pilot.pause()
+ assert len(calls) == 1
+ await pilot.press("a")
+ await pilot.pause()
+ assert calls == [{}, {"challenge": {
+ "authorization_id": "auth",
+ "request_id": "tui-preview-original",
+ }}, {}]
+
+
+@pytest.mark.asyncio
+async def test_authorization_reuses_original_correlated_read_id():
+ client = make_app().client
+ sent = []
+
+ async def send(command):
+ sent.append(command)
+ response = {
+ "sid": "s", "request_id": command.request_id,
+ "authorization_id": "challenge",
+ }
+ if command.type == "get_file_preview":
+ response["type"] = "preview_authorization_required"
+ else:
+ assert command.request_id == sent[0].request_id
+ assert command.authorization_id == "challenge"
+ route_preview(client, {
+ **response, "type": "preview_authorization_required",
+ })
+ assert not client.preview_waiters[command.request_id][1].done()
+ route_preview(client, {
+ **response, "type": "preview_authorization_result",
+ "authorization_id": "different", "status": "granted",
+ })
+ assert not client.preview_waiters[command.request_id][1].done()
+ response.update(type="preview_authorization_result",
+ status="granted")
+ route_preview(client, response)
+ return True
+
+ client._send = send
+ challenge = await preview_request(client, "s", "/outside/a.md")
+ assert not client.preview_waiters
+ result = await preview_request(client, "s", challenge=challenge)
+ assert result["status"] == "granted"
+ assert not client.preview_waiters
+
+
+class FakeImage(Static):
+ def __init__(self, image):
+ super().__init__("[image pixels]")
+ self.image = image
+
+
+@pytest.mark.asyncio
+async def test_auto_image_does_not_steal_focus_and_clears_on_session_switch(
+ monkeypatch,
+):
+ app = make_app()
+ app.graphics = FakeImage
+ calls = []
+
+ async def request(client, sid, path=None, **kwargs):
+ calls.append((sid, path))
+ return image_reply()
+
+ monkeypatch.setattr("cc_remote.tui_inline_images.preview_request", request)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "x")
+ editor = app.query_one(Composer)
+ app.client.workspace.view("s").put(
+ Block("image", "assistant", "")
+ )
+ app.paint()
+ await pilot.pause()
+ viewport = app.query_one(TranscriptViewport)
+ assert viewport.cache and len(viewport.query(InlinePicture)) == 1
+ assert app.focused is editor and editor.text == "x"
+ app.paint()
+ await pilot.pause()
+ assert calls == [("s", "result.png")]
+ app.client.attached_sid = "new"
+ app.paint()
+ await pilot.pause()
+ assert not viewport.cache and not viewport.query(InlinePicture)
+
+
+@pytest.mark.asyncio
+async def test_unsupported_terminal_never_automatically_reads_images():
+ app = make_app()
+ app.client.workspace.view("s").put(
+ Block("image", "assistant", "result.png")
+ )
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ assert not app.query_one(TranscriptViewport).projection.slots
+ assert not app.client._outbox
diff --git a/tests/test_tui_preview_truncation.py b/tests/test_tui_preview_truncation.py
new file mode 100644
index 00000000..68c19212
--- /dev/null
+++ b/tests/test_tui_preview_truncation.py
@@ -0,0 +1,43 @@
+"""Local Markdown limits are visible independently of wrapper truncation."""
+
+import pytest
+from rich.segment import Segment
+from types import SimpleNamespace
+from textual.widgets import Static
+
+from cc_remote.tui_preview import PreviewRef
+from cc_remote.tui_preview_views import FilePreviewScreen, MarkdownReader
+from tests.test_tui_preview import make_app
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("limit", ["source", "lines"])
+async def test_preview_marks_local_limits_and_clears_marker(monkeypatch, limit):
+ async def request(*args, **kwargs):
+ return dict(type="file_preview", format="markdown", truncated=False,
+ content="long" * (70000 if limit == "source" else 1))
+
+ line_count = 5001 if limit == "lines" else 2
+
+ def render(*args, **kwargs):
+ return [[Segment("line")] for _ in range(line_count)]
+
+ monkeypatch.setattr("cc_remote.tui_preview_views.preview_request", request)
+ monkeypatch.setattr("cc_remote.tui_preview_views.Console", lambda **kw:
+ SimpleNamespace(render_lines=render, options=None))
+ app = make_app()
+ async with app.run_test() as pilot:
+ await app.push_screen(FilePreviewScreen(
+ app.client, "s", PreviewRef(path="/file.md", kind="markdown"),
+ ))
+ await pilot.pause()
+ reader = app.screen.query_one(MarkdownReader)
+ assert reader.local_truncated
+ assert "truncated locally" in reader.text
+ assert "truncated locally" in str(
+ app.screen.query_one("#preview-status", Static).render()
+ )
+ line_count = 2
+ reader.show_markdown("short")
+ assert not reader.local_truncated
+ assert "truncated locally" not in reader.text
diff --git a/tests/test_tui_profile_catalog.py b/tests/test_tui_profile_catalog.py
new file mode 100644
index 00000000..20f1fae4
--- /dev/null
+++ b/tests/test_tui_profile_catalog.py
@@ -0,0 +1,43 @@
+"""Partial account failures must not look like native session deletion."""
+
+import pytest
+
+from cc_remote.tui_state import WorkspaceState
+
+
+@pytest.mark.parametrize("engine", ["claude", "codex"])
+@pytest.mark.parametrize("space", ["code", "work"])
+def test_failed_profile_keeps_sessions_and_control_revision(engine, space):
+ state = WorkspaceState()
+ profile_key, profiles_key = engine + "_profile_id", engine + "_profiles"
+
+ def listing(rows, profiles=None):
+ event = dict(type="session_list", engine=engine, space=space, sessions=rows)
+ if profiles is not None:
+ event[profiles_key] = profiles
+ state.event(event)
+
+ saved = dict(session_id="saved", **{profile_key: "offline"})
+ healthy = dict(session_id="healthy", **{profile_key: "ok"})
+ provisional = dict(session_id="tmp", provisional_fork=True,
+ **{profile_key: "offline"})
+ removed = dict(session_id="removed", **{profile_key: "removed-profile"})
+ listing([saved, healthy, provisional, removed])
+ control = dict(type="session_control", sid="saved", generation="g",
+ revision=3, write_state="writable")
+ state.event(control)
+ listing([], [{"id": "offline", "error": "unreachable"}, {"id": "ok"}])
+ assert set(state.catalog) == {"saved"}
+ assert state.view("saved").write_state == "writable"
+ assert state.view("healthy").write_state == "unavailable"
+ listing([]) # Legacy/partial response without refreshed profile metadata.
+ assert "saved" in state.catalog
+ state.event(control)
+ assert state.view("saved").write_state == "writable"
+ listing([{**saved, "summary": "fresh"}], [{"id": "offline"}, {"id": "ok"}])
+ assert state.catalog["saved"]["summary"] == "fresh"
+ state.event(control)
+ assert state.view("saved").write_state == "writable"
+ listing([], [{"id": "offline"}, {"id": "ok"}])
+ assert "saved" not in state.catalog
+ assert state.view("saved").write_state == "unavailable"
diff --git a/tests/test_tui_progress_details.py b/tests/test_tui_progress_details.py
new file mode 100644
index 00000000..d9ae9f87
--- /dev/null
+++ b/tests/test_tui_progress_details.py
@@ -0,0 +1,88 @@
+"""Prose stays readable and a final answer opens its own folded activity."""
+
+import pytest
+
+from cc_remote.tui_app import Transcript, WorkspaceApp, location
+from tests.test_tui_live_layout import client, turn
+
+
+def body_style(reader, text):
+ row = reader.text[:reader.text.rindex(text)].count("\n")
+ return reader.line_styles[row]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("canonical", [False, True])
+async def test_final_enter_opens_progress_and_tools_with_typed_colors(canonical):
+ c = client()
+ view = c.workspace.view("s")
+ turn(view)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ assert body_style(reader, "progress explanation") == ""
+ assert body_style(reader, "command") == "bright_black"
+ view.event(dict(type="delta", message_id="answer", channel="final",
+ text="final answer"))
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ if canonical:
+ view.revision = "r"
+ c.workspace.event(dict(
+ type="turn_detail", session_id="s", revision="r", turn_id="t",
+ events=[dict(type="delta", message_id="p", channel="commentary",
+ text="progress explanation"),
+ dict(type="tool_use", tool_use_id="tool", tool="command",
+ input={"command": "echo test"}),
+ dict(type="tool_result", tool_use_id="tool",
+ content="result")],
+ ))
+ app.paint()
+ await pilot.pause()
+ assert "progress explanation" in reader.text
+ assert "echo test" not in reader.text
+ reader.move_cursor(location(reader.text, reader.text.index("final answer")))
+ app.stop_following()
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "progress explanation" not in reader.text
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "progress explanation" in reader.text
+ assert "echo test" not in reader.text
+ start = next(i for i, b in app.starts if b.role == "tool_group")
+ reader.move_cursor(location(reader.text, start))
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "echo test" in reader.text
+ assert body_style(reader, "progress explanation") == ""
+ assert body_style(reader, "echo test") == "bright_black"
+ assert body_style(reader, "final answer") == ""
+ assert app.current_block().role == "tool_group"
+ reader.move_cursor(location(reader.text, reader.text.index("echo test")))
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "echo test" not in reader.text
+ assert "progress explanation" in reader.text
+ assert not c._outbox # Local/cached detail needs no history request.
+
+
+@pytest.mark.asyncio
+async def test_final_enter_never_opens_another_turns_detail():
+ c = client()
+ view = c.workspace.view("s")
+ turn(view)
+ view.event(dict(type="turn_end", turn_id="t", result={}))
+ view.event(dict(type="user_msg", msg_id="next", prompt="next question"))
+ view.event(dict(type="delta", message_id="next-answer", channel="final",
+ text="next answer"))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ reader.move_cursor(location(reader.text, reader.text.index("next answer")))
+ await pilot.press("enter")
+ assert view.local_details["t"].expanded # Unrelated turn is untouched.
diff --git a/tests/test_tui_projection.py b/tests/test_tui_projection.py
new file mode 100644
index 00000000..9f7f0b98
--- /dev/null
+++ b/tests/test_tui_projection.py
@@ -0,0 +1,52 @@
+"""Projection contracts stay testable without mounting the terminal UI."""
+
+import pytest
+
+from cc_remote.tui_actions import build_action
+from cc_remote.tui_presentation import merge_rate_window
+from cc_remote.tui_state import SessionView, WorkspaceState
+
+
+def test_projection_folds_completed_tools_without_losing_payload():
+ view = SessionView(active_turn="turn")
+ view.event(dict(type="tool_use", tool_use_id="tool", tool="command",
+ input={"command": "echo hello"}))
+ view.event(dict(type="turn_end", turn_id="turn", result={}))
+ text, starts = view.render()
+ assert "Turn details" in text and "echo hello" not in text
+ next(b for _, b in starts if b.role == "tool_group").expanded = True
+ assert "echo hello" in view.render()[0]
+
+
+def test_rekey_preserves_local_draft_and_read_position():
+ state = WorkspaceState()
+ view = state.view("temporary")
+ view.draft = "unfinished draft"
+ view.anchor = ("message", 12)
+ state.event(dict(type="session_rekey", old_key="temporary", session_id="real"))
+ assert state.view("real") is view
+ assert view.draft == "unfinished draft" and view.anchor == ("message", 12)
+
+
+def test_same_period_usage_does_not_regress_on_sparse_reads():
+ current = dict(used_percent=20, resets_at=1000, window_duration_mins=300)
+ assert merge_rate_window(current, dict(used_percent=0))["used_percent"] == 20
+ assert merge_rate_window(current, dict(resets_at=1020))["used_percent"] == 20
+ assert "used_percent" not in merge_rate_window(current, dict(resets_at=2000))
+
+
+@pytest.mark.parametrize("payload", [
+ '{"session_id":"other","title":"name"}',
+ '{"session_id":"s","title":"name","sid":"other"}',
+])
+def test_action_builder_rejects_target_override(payload):
+ with pytest.raises(ValueError):
+ build_action("rename_session", payload, "s", "client")
+
+
+def test_action_builder_uses_shared_protocol_validation():
+ message = build_action(
+ "rename_session", '{"session_id":"s","title":"name"}', "s", "client"
+ )
+ assert message.type == "rename_session" and message.client_id == "client"
+ assert message.sid == message.session_id == "s"
diff --git a/tests/test_tui_questions.py b/tests/test_tui_questions.py
new file mode 100644
index 00000000..0ab442d3
--- /dev/null
+++ b/tests/test_tui_questions.py
@@ -0,0 +1,299 @@
+"""Async questions are not final turns or interrupt requests."""
+
+import json
+
+import pytest
+from textual.widgets import Static
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_modal import ModalEditor, PickerList
+from cc_remote.tui_panels import AsyncQuestionDialog
+from cc_remote.tui_questions import pending_async, supplemental_answer_prompt
+from cc_remote.tui_state import Block, SessionView
+from tests.test_tui_send_jumps import client
+
+
+def ask(view, identity="question", options=None):
+ view.event(dict(
+ type="assistant_msg_end", message_id=identity,
+ turn_id="active", channel="final", text="Which directory?",
+ delivery="async",
+ questions=[dict(title="Which directory?", options=options)],
+ ))
+
+
+def setup():
+ c = client()
+ v = c.workspace.view("s")
+ v.event(dict(type="user_msg", msg_id="active", prompt="work"))
+ v.state = "running"
+ v.put(Block("progress", "assistant", "Working step\n" * 100,
+ "active", "commentary"))
+ return c, v
+
+
+def frames(c):
+ return [json.loads(raw) for raw, _ in c._outbox.values()]
+
+
+def test_async_question_preserves_running_progress_until_real_terminal():
+ _, v = setup()
+ # The phase can arrive before the delivery metadata; neither is a terminal.
+ v.event(dict(type="delta", message_id="question", channel="final",
+ text="Which directory?"))
+ assert "Working step" in v.render()[0]
+ ask(v)
+ assert v.presentation.turns["active"].status == "running"
+ assert "Working step" in v.render()[0]
+ assert "Question · non-blocking" in v.render()[0]
+ assert '"options"' not in v.render()[0]
+ v.event(dict(type="turn_end", turn_id="active", result={}))
+ assert "Working step" in v.render()[0] # Outer activity stays open.
+ assert "Which directory?" in v.render()[0]
+
+
+def test_history_restores_async_metadata_without_json_or_false_final():
+ v = SessionView()
+ v.history(dict(type="history", session_id="s", revision="r", turns=[dict(
+ id="active", prompt="work", done=False, blocks=[dict(
+ kind="text", message_id="q", channel="final", delivery="async",
+ text="Which?", questions=[dict(title="Which?", options=["a", "b"])],
+ )],
+ )]))
+ assert [b.id for b in pending_async(v)] == ["q"]
+ text = v.render()[0]
+ assert "1. a" in text and "2. b" in text
+ assert "Assistant · final" not in text and '"options"' not in text
+ v.event(dict(type="turn_steered", msg_id="other-client", turn_id="active",
+ prompt="use a"))
+ assert not pending_async(v)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("bottom", [False, True])
+async def test_question_arrival_and_answer_frames_preserve_viewport(
+ monkeypatch, bottom,
+):
+ c, v = setup()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(80, 28)) as pilot:
+ await pilot.pause()
+ r = app.query_one(Transcript)
+ # Keep cursor in an old message, including while the draft is focused.
+ r.move_cursor((10, 0))
+ app.query_one(Composer).focus()
+ r.scroll_to(y=r.max_scroll_y if bottom else 12,
+ animate=False, immediate=True)
+ await pilot.pause()
+ observed = []
+ original_display = app._display
+
+ def display(screen, renderable):
+ if renderable is not None and len(app.screen_stack) == 1:
+ observed.append((r.scroll_y, r.max_scroll_y))
+ original_display(screen, renderable)
+
+ monkeypatch.setattr(app, "_display", display)
+ ask(v)
+ app.paint()
+ await pilot.pause()
+ assert "Working step" in r.text
+ assert observed
+ assert all(y == (end if bottom else 12) for y, end in observed)
+ observed.clear()
+ app.action_answer()
+ await pilot.pause()
+ dialog = app.screen
+ assert isinstance(dialog, AsyncQuestionDialog)
+ dialog.query_one("#answer", ModalEditor).load_text("use a")
+ await pilot.press("enter")
+ await pilot.pause()
+ v.event(dict(type="turn_steered", msg_id=frames(c)[0]["msg_id"],
+ turn_id="active", prompt="use a"))
+ v.put(Block("next", "assistant", "Continuing\n" * 10,
+ "active", "commentary"))
+ app.paint()
+ await pilot.pause()
+ # Modal rendering is a different screen; inspect the restored reader.
+ assert r.scroll_y == (r.max_scroll_y if bottom else 12)
+ assert observed
+ assert all(y == (end if bottom else 12) for y, end in observed)
+ assert v.presentation.turns["active"].status == "running"
+ assert [f["type"] for f in frames(c)] == ["steer"]
+
+
+@pytest.mark.asyncio
+async def test_async_dialog_options_free_text_and_draft_are_independent():
+ c, v = setup()
+ ask(v, options=["a", "b"])
+ pending_async(v)[0].data["questions"].append(
+ dict(title="Second question?", options=[])
+ )
+ v.draft = "Unrelated draft"
+ v.attachments = [{"name": "keep", "image": False, "content": {}}]
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+t")
+ dialog = app.screen
+ assert isinstance(dialog, AsyncQuestionDialog)
+ assert isinstance(dialog.focused, PickerList)
+ await pilot.press("ctrl+j", "enter")
+ assert dialog.answers[0] == "b" and not frames(c)
+ await pilot.press("i", "o", "k", "escape")
+ assert app.screen is dialog and not frames(c)
+ await pilot.press("enter")
+ await pilot.pause()
+ sent = frames(c)
+ assert len(sent) == 1 and sent[0]["type"] == "steer"
+ assert sent[0]["prompt"] == (
+ "补充回答:\n\n问题:Which directory?\n回答:b"
+ "\n\n问题:Second question?\n回答:ok"
+ )
+ assert not sent[0].get("files") and not sent[0].get("images")
+ assert app.query_one(Composer).text == "Unrelated draft"
+ assert v.attachments and not pending_async(v)
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_stale_question_never_interrupt_or_send():
+ c, v = setup()
+ ask(v)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+t", "escape")
+ assert not isinstance(app.screen, AsyncQuestionDialog)
+ assert pending_async(v) and not frames(c)
+ await pilot.press("ctrl+t")
+ dialog = app.screen
+ dialog.query_one("#answer", ModalEditor).load_text("answer")
+ v.event(dict(type="turn_steered", msg_id="other", turn_id="active",
+ prompt="already answered elsewhere"))
+ await pilot.press("enter")
+ assert app.screen is dialog and not frames(c)
+ result = dialog.query_one("#answer-result", Static)
+ assert "no longer pending" in str(result.render())
+
+
+@pytest.mark.asyncio
+async def test_visiting_questions_does_not_confirm_unsubmitted_drafts():
+ c, v = setup()
+ ask(v)
+ pending_async(v)[0].data["questions"].append(
+ dict(title="Second question?", options=[])
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+t")
+ dialog = app.screen
+ dialog.query_one("#answer", ModalEditor).load_text("unconfirmed")
+ await pilot.press("ctrl+right")
+ dialog.query_one("#answer", ModalEditor).load_text("confirmed")
+ await pilot.press("enter")
+ assert dialog.index == 0 and not frames(c)
+ assert dialog.query_one("#answer", ModalEditor).text == "unconfirmed"
+ await pilot.press("enter")
+ assert len(frames(c)) == 1
+
+
+@pytest.mark.asyncio
+async def test_remapped_question_submit_and_vim_insert_do_not_send_early():
+ c, v = setup()
+ ask(v)
+ c.keys = KeyConfig({"question": {"confirm": ["ctrl+y"]}})
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+t", "i", "a", "enter", "b", "escape")
+ editor = app.screen.query_one("#answer", ModalEditor)
+ assert editor.text == "a\nb" and not frames(c)
+ await pilot.press("ctrl+y")
+ assert frames(c)[0]["type"] == "steer"
+
+
+@pytest.mark.asyncio
+async def test_question_answer_is_session_scoped_and_can_retry_rejection():
+ c, v = setup()
+ ask(v)
+ c.attached_sid = "other"
+ assert await c.answer_async("s", ["question"], "answer")
+ sent = frames(c)[0]
+ assert sent["sid"] == "s" and sent["type"] == "steer"
+ assert not await c.answer_async("s", ["question"], "duplicate")
+ v.event(dict(type="error", msg_id=sent["msg_id"], message="rejected"))
+ assert pending_async(v)
+ assert await c.answer_async("s", ["question"], "retry")
+ assert len(frames(c)) == 2
+
+
+@pytest.mark.asyncio
+async def test_idle_question_answer_starts_new_turn_without_interrupt():
+ c, v = setup()
+ ask(v)
+ v.state = "idle"
+ assert await c.answer_async("s", ["question"], "answer")
+ assert frames(c)[0]["type"] == "query"
+
+
+def test_question_keys_are_configurable_and_indexed():
+ keys = KeyConfig({"question": {"confirm": ["ctrl+y"]}})
+ assert list(keys.layers["question"]["confirm"]) == ["ctrl+y"]
+ assert "Async question" in keys.help()
+
+
+def test_canonical_reply_leaves_other_native_questions_pending():
+ _, v = setup()
+ ask(v)
+ ask(v, "second")
+ pending_async(v)[1].data["questions"][0]["title"] = "Other question?"
+ v.event(dict(
+ type="turn_steered", msg_id="reply", turn_id="active",
+ prompt=supplemental_answer_prompt([("Which directory?", "src")]),
+ ))
+ assert [b.id for b in pending_async(v)] == ["second"]
+
+
+def test_ambiguous_canonical_reply_preserves_all_candidates():
+ _, v = setup()
+ ask(v)
+ ask(v, "second")
+ v.event(dict(
+ type="turn_steered", msg_id="reply", turn_id="active",
+ prompt=supplemental_answer_prompt([("Which directory?", "src")]),
+ ))
+ assert [b.id for b in pending_async(v)] == ["question", "second"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("text", ["", " ", "\n\t"])
+async def test_blank_blocking_answer_keeps_question_pending(text):
+ from tests.test_tui_workspace import emit
+
+ c, _ = setup()
+ emit(c, "ask_user", ask_id="ask", question="Where?",
+ allow_text=True, options=[])
+ assert not await c.answer(text)
+ assert c._pending_ask_for_attached()["ask_id"] == "ask"
+ assert not frames(c)
+
+
+@pytest.mark.asyncio
+async def test_dialog_keeps_one_native_message_per_web_answer_envelope():
+ c, v = setup()
+ ask(v)
+ ask(v, "second")
+ pending_async(v)[1].data["questions"][0]["title"] = "Other question?"
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ for title in ("Which directory?", "Other question?"):
+ await pilot.press("ctrl+t")
+ dialog = app.screen
+ assert dialog.questions == [dict(title=title, options=[])]
+ dialog.query_one("#answer", ModalEditor).load_text("src")
+ await pilot.press("enter")
+ await pilot.pause()
+ sent = frames(c)[-1]
+ assert sent["prompt"] == supplemental_answer_prompt([(title, "src")])
+ v.event(dict(type="turn_steered", msg_id=sent["msg_id"],
+ turn_id="active", prompt=sent["prompt"]))
+ assert len(frames(c)) == 2
+ assert not pending_async(v)
diff --git a/tests/test_tui_queue_management.py b/tests/test_tui_queue_management.py
new file mode 100644
index 00000000..cfe929d4
--- /dev/null
+++ b/tests/test_tui_queue_management.py
@@ -0,0 +1,135 @@
+"""Drive the real queue keys, not only the action methods."""
+
+import json
+
+import pytest
+from textual.widgets import OptionList, Static
+
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_panels import QueueCancel, QueueEdit, QueuePanel
+from tests.test_tui_parity import app_client
+
+
+def queue(client, ids="abc"):
+ client._handle({"type": "query_queue", "sid": "s", "items": [
+ {"msg_id": mid, "kind": "queue", "prompt_preview": mid,
+ "image_count": 0, "file_count": 0, "retained_bytes": 5}
+ for mid in ids
+ ], "total_count": len(ids), "total_bytes": 5 * len(ids)})
+
+
+def frames(client):
+ return [json.loads(raw) for raw, _ in client._outbox.values()]
+
+
+@pytest.mark.asyncio
+async def test_queue_edit_failed_preflight_and_save_retains_attachments():
+ app, client = app_client()
+ queue(client)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "l", "j", "i")
+ assert isinstance(app.screen, QueueEdit)
+ screen = app.screen
+ client._handle({
+ "type": "queued_query_detail", "sid": "s", "msg_id": "b",
+ "request_id": screen.request_id, "prompt": "complete prompt",
+ "error": "preflight failed", "image_count": 2,
+ })
+ await pilot.pause(0.2)
+ editor = screen.query_one(ModalEditor)
+ assert editor.vim_mode == "INSERT" and not editor.locked
+ editor.load_text("updated prompt")
+ await pilot.press("escape", "enter")
+ sent = frames(client)[-1]
+ assert sent["type"] == "update_queued_query"
+ assert sent["msg_id"] == "b" and sent["prompt"] == "updated prompt"
+ assert "images" not in sent and "files" not in sent
+ client._handle({
+ "type": "queued_query_updated", "sid": "s", "msg_id": "b",
+ "request_id": sent["cmd_id"], "updated": False,
+ "error": "Already started",
+ })
+ await pilot.pause(0.2)
+ assert editor.text == "updated prompt"
+ assert "Already started" in str(screen.query_one("#queue-result", Static).render())
+ await pilot.press("escape")
+ assert isinstance(app.screen, QueuePanel)
+ assert app.screen.selected_id() == "b"
+
+
+@pytest.mark.asyncio
+async def test_queue_cancel_confirmation_is_bound_to_selected_message():
+ app, client = app_client()
+ queue(client)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "l", "j", "d")
+ assert isinstance(app.screen, QueueCancel)
+ await pilot.press("n")
+ assert not any(f["type"] == "cancel_queued_query" for f in frames(client))
+ await pilot.press("d")
+ queue(client, "ac") # The list changes while confirmation is open.
+ await pilot.press("y")
+ sent = frames(client)[-1]
+ assert sent["type"] == "cancel_queued_query" and sent["msg_id"] == "b"
+ assert isinstance(app.screen, QueuePanel)
+
+
+@pytest.mark.asyncio
+async def test_queue_reorder_uses_server_echo_and_stable_highlight():
+ app, client = app_client()
+ queue(client)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "l", "j", "K")
+ sent = frames(client)[-1]
+ assert sent["type"] == "reorder_queued_queries"
+ assert sent["expected"] == list("abc") and sent["order"] == list("bac")
+ assert [q["msg_id"] for q in client.workspace.view("s").queue] == list("abc")
+ queue(client, "bac")
+ await pilot.pause(0.3)
+ assert app.screen.selected_id() == "b"
+ assert app.screen.query_one(OptionList).highlighted == 0
+ await pilot.press("J")
+ assert frames(client)[-1]["order"] == list("abc")
+
+
+@pytest.mark.asyncio
+async def test_queue_keys_are_configurable_and_indexed():
+ app, client = app_client()
+ client.keys = KeyConfig({"queue": {"delete": ["x"], "move_up": ["u"]}})
+ assert "x" in client.keys.layer_help("queue", {"delete"})
+ queue(client)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "l", "j", "u")
+ assert frames(client)[-1]["order"] == list("bac")
+ await pilot.press("d")
+ assert isinstance(app.screen, QueuePanel)
+ await pilot.press("x")
+ assert isinstance(app.screen, QueueCancel)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reject", [True, False])
+async def test_fast_reorder_reply_is_not_overwritten(monkeypatch, reject):
+ app, client = app_client()
+ queue(client)
+ async with app.run_test() as pilot:
+ await pilot.press("space", "l", "j")
+ screen = app.screen
+
+ async def immediate(command):
+ if reject:
+ client._handle({
+ "type": "error", "sid": "s", "code": "queue_changed",
+ "request_id": command.cmd_id, "message": "Queue changed",
+ })
+ else:
+ queue(client, "bac")
+ screen.paint()
+ return True
+
+ monkeypatch.setattr(client, "_send", immediate)
+ await pilot.press("K")
+ status = str(screen.query_one("#queue-status", Static).render())
+ assert ("Queue changed" if reject else "Server confirmed") in status
+ assert "waiting" not in status
diff --git a/tests/test_tui_queue_privacy.py b/tests/test_tui_queue_privacy.py
new file mode 100644
index 00000000..0d864f5c
--- /dev/null
+++ b/tests/test_tui_queue_privacy.py
@@ -0,0 +1,29 @@
+"""Full queued prompts are one-shot editor payloads, never Reports."""
+
+from cc_remote.tui_panels import QueueEdit
+from cc_remote.tui_state import WorkspaceState
+from tests.test_tui_workspace import client, emit
+
+
+def test_queue_detail_is_private_and_discarded_after_editor_closes():
+ c = client()
+ editor = QueueEdit(c, "s", "m")
+ request = editor.request_id
+ c.queue_reads[request] = ("s", "m")
+ values = dict(request_id=request, msg_id="m", prompt="private-full-prompt")
+ emit(c, "queued_query_detail", **values)
+ assert c.queue_details[request]["prompt"] == values["prompt"]
+ assert "queued_query_detail" not in c.workspace.view("s").presentation.reports
+ assert "private-full-prompt" not in c.workspace.view("s").render()[0]
+ editor.on_unmount()
+ assert not c.queue_details and not c.queue_reads
+ emit(c, "queued_query_detail", **values)
+ assert not c.queue_details
+ assert "queued_query_detail" not in c.workspace.view("s").presentation.reports
+
+
+def test_unsolicited_queue_detail_cannot_enter_any_report_projection():
+ state = WorkspaceState()
+ for sid in ("s", None):
+ state.event(dict(type="queued_query_detail", sid=sid, prompt="private"))
+ assert not state.reports and not state.views
diff --git a/tests/test_tui_queue_receipts.py b/tests/test_tui_queue_receipts.py
new file mode 100644
index 00000000..b3b53189
--- /dev/null
+++ b/tests/test_tui_queue_receipts.py
@@ -0,0 +1,59 @@
+"""Rejected queued prompts survive transport ACK and newer drafts."""
+
+import json
+
+import pytest
+
+from cc_remote.tui_app import Composer, WorkspaceApp
+from tests.test_tui_workspace import client, emit
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("correlation", ["msg_id", "request_id"])
+async def test_queued_rejection_restores_full_text_after_ack(correlation):
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ prompt = "private queue text " * 5000
+ assert await c.submit(prompt, queue=True)
+ frame = json.loads(next(iter(c._outbox.values()))[0])
+ emit(c, "command_ack", client_id=c.client_id, cmd_id=frame["cmd_id"])
+ editor.load_text("newer draft")
+ emit(c, "error", message="queue full", **{
+ correlation: frame["msg_id" if correlation == "msg_id" else "cmd_id"],
+ })
+ app.paint()
+ assert editor.text == "newer draft\n\n" + prompt
+ assert not c.workspace.view("s").pending_queued_text
+ assert prompt not in c.workspace.view("s").render()[0]
+ await pilot.pause()
+ assert editor.text.count(prompt) == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["query_queue", "user_msg"])
+async def test_queue_receipt_released_only_on_authoritative_delivery(kind):
+ c = client()
+ assert await c.submit("private", queue=True)
+ frame = json.loads(next(iter(c._outbox.values()))[0])
+ view = c.workspace.view("s")
+ emit(c, "command_ack", client_id=c.client_id, cmd_id=frame["cmd_id"])
+ assert view.pending_queued_text
+ emit(c, kind, **(dict(items=[dict(msg_id=frame["msg_id"])])
+ if kind == "query_queue" else
+ dict(msg_id=frame["msg_id"], prompt="private")))
+ assert not view.pending_queued_text
+
+
+@pytest.mark.asyncio
+async def test_fast_queue_rejection_does_not_clear_the_original_draft():
+ c = client()
+
+ async def send(command):
+ emit(c, "error", request_id=command.cmd_id, message="queue full")
+ return True
+
+ c._send = send
+ assert not await c.submit("retain this", queue=True)
+ assert not c.workspace.view("s").pending_queued_text
diff --git a/tests/test_tui_read_focus.py b/tests/test_tui_read_focus.py
new file mode 100644
index 00000000..4bb6ccb6
--- /dev/null
+++ b/tests/test_tui_read_focus.py
@@ -0,0 +1,107 @@
+"""Focus-only navigation must not pull the viewport to an obsolete cursor."""
+
+import pytest
+from textual.geometry import Offset
+
+from cc_remote.tui_app import Transcript, WorkspaceApp, WorkspaceClient
+from cc_remote.tui_state import Block
+
+
+def make_app():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ client.workspace.view("s").put(
+ Block("long", "assistant", "a long answer line\n" * 300, "t", "final")
+ )
+ return WorkspaceApp(client, connect=False)
+
+
+def cursor_visible(reader):
+ position = reader.wrapped_document.location_to_offset(
+ reader.cursor_location
+ )
+ return (
+ reader.scroll_y
+ <= position.y
+ < (reader.scroll_y + reader.scrollable_content_region.height)
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("bottom", [False, True])
+@pytest.mark.parametrize("cursor_at_end", [False, True])
+async def test_ctrl_k_preserves_viewport_then_vim_moves_locally(
+ bottom, cursor_at_end
+):
+ app = make_app()
+ async with app.run_test(size=(80, 26)) as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press(*(["G"] if cursor_at_end else ["g", "g"]))
+ await pilot.press("ctrl+j", "i", "x")
+ target = reader.max_scroll_y if bottom else reader.max_scroll_y // 2
+ reader.scroll_to(y=target, animate=False, immediate=True, force=True)
+ await pilot.pause()
+ await pilot.press("ctrl+k")
+ await pilot.pause()
+ assert reader.scroll_y == target
+ assert cursor_visible(reader)
+ await pilot.press("k", "j")
+ await pilot.pause()
+ assert abs(reader.scroll_y - target) <= 2
+ if bottom:
+ assert app.client.workspace.view("s").follow
+
+
+@pytest.mark.asyncio
+async def test_already_focused_ctrl_k_reconciles_mouse_scrolled_cursor():
+ app = make_app()
+ async with app.run_test(size=(80, 26)) as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("g", "g")
+ reader.scroll_to(y=150, animate=False, immediate=True, force=True)
+ await pilot.pause()
+ await pilot.press("ctrl+k")
+ assert reader.scroll_y == 150 and cursor_visible(reader)
+
+
+@pytest.mark.asyncio
+async def test_focus_retains_visible_cursor_with_wrapped_lines():
+ app = make_app()
+ app.client.workspace.view("s").put(
+ Block("long", "assistant", "宽行 words " * 1000, "t", "final")
+ )
+ async with app.run_test(size=(42, 26)) as pilot:
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ target = reader.wrapped_document.offset_to_location(Offset(3, 60))
+ reader.move_cursor(target)
+ await pilot.pause()
+ top = reader.scroll_y
+ await pilot.press("ctrl+j", "i", "x", "ctrl+k", "ctrl+k")
+ assert reader.cursor_location == target
+ assert reader.scroll_y == top
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("draft", [False, True])
+async def test_replaced_history_anchor_keeps_reading_row(draft):
+ app = make_app()
+ async with app.run_test(size=(80, 26)) as pilot:
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ reader.move_cursor((180, 0))
+ await pilot.pause()
+ if draft:
+ await pilot.press("ctrl+j")
+ top = reader.scroll_y
+ view = app.client.workspace.view("s")
+ # Model an authoritative history page replacing an obsolete block ID.
+ view.blocks.clear()
+ view.put(
+ Block("canonical", "assistant", "new answer\n" * 300, "t", "final")
+ )
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == top
+ assert reader.cursor_location == (180, 0)
+ await pilot.press("ctrl+k", "k")
+ assert abs(reader.scroll_y - top) <= 1
diff --git a/tests/test_tui_readable_dialogs.py b/tests/test_tui_readable_dialogs.py
new file mode 100644
index 00000000..00336537
--- /dev/null
+++ b/tests/test_tui_readable_dialogs.py
@@ -0,0 +1,178 @@
+"""Human-readable controls and long questions retain full content and scope."""
+
+import json
+
+import pytest
+from textual.widgets import Static
+
+from cc_remote.tui_fields import ParameterFields
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_panels import ActionForm, PanelReader, QuestionDialog
+from tests.test_tui_controls import setup, sent, catalogs
+from tests.test_tui_questions import ask, pending_async, setup as question_setup
+from cc_remote.tui_app import WorkspaceApp
+
+
+@pytest.mark.asyncio
+async def test_named_goal_fields_send_without_json_confirmation():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await app.push_screen(ActionForm(c, "s", "set_goal"))
+ form = app.screen.query_one(ParameterFields)
+ fields = {row[0]: row[-1] for row in form.rows}
+ fields["objective"].load_text("A multi-line\n目标")
+ fields["token_budget"].load_text("12345")
+ await pilot.press("enter", "enter")
+ commands = sent(c, "set_goal")
+ assert len(commands) == 1
+ assert commands[0]["objective"] == "A multi-line\n目标"
+ assert commands[0]["token_budget"] == 12345
+
+
+@pytest.mark.asyncio
+async def test_destructive_named_form_still_requires_frozen_review():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await app.push_screen(ActionForm(c, "s", "clear_goal"))
+ await pilot.press("enter")
+ assert not sent(c, "clear_goal")
+ assert app.screen.prepared is not None
+ await pilot.press("enter", "enter")
+ assert len(sent(c, "clear_goal")) == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("size", [(48, 24), (100, 40)])
+@pytest.mark.parametrize("asynchronous", [False, True])
+async def test_long_questions_scroll_without_submitting_and_resize(
+ size, asynchronous
+):
+ c, view = question_setup()
+ question = "\n".join(
+ f"问题 {n}:请完整阅读这一行的详细内容" for n in range(100)
+ )
+ if asynchronous:
+ ask(view, options=["First", "Second"])
+ pending_async(view)[0].data["questions"][0]["title"] = question
+ pending_async(view)[0].data["questions"].append(
+ {"title": "Second question", "options": []}
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=size) as pilot:
+ if asynchronous:
+ await pilot.press("ctrl+t")
+ else:
+ await app.push_screen(
+ QuestionDialog(
+ c,
+ {
+ "question": question,
+ "sid": "s",
+ "ask_id": "ask",
+ },
+ )
+ )
+ dialog = app.screen
+ reader = dialog.query_one("#question-body", PanelReader)
+ assert reader.text == question or reader.text.startswith(question)
+ await pilot.press("ctrl+k", "G", "enter")
+ await pilot.pause()
+ assert dialog.focused is reader and not c._outbox
+ assert reader.cursor_location[0] >= 99 and reader.scroll_y > 0
+ assert dialog.query_one("#answer").region.bottom <= size[1]
+ cursor = reader.cursor_location
+ await pilot.resize_terminal(size[0] - 8, size[1])
+ assert reader.cursor_location == cursor
+ if asynchronous:
+ await pilot.press("ctrl+right", "ctrl+left", "ctrl+k")
+ assert reader.cursor_location == cursor
+ await pilot.press("ctrl+j")
+ if asynchronous:
+ from cc_remote.tui_modal import PickerList
+
+ listing = dialog.query_one(PickerList)
+ await pilot.press("j", "k")
+ assert listing.highlighted == 0 and dialog.focused is listing
+ await pilot.press("i")
+ editor = dialog.query_one("#answer", ModalEditor)
+ if not asynchronous:
+ assert dialog.focused is editor
+ assert not c._outbox
+ assert "Question line" in str(
+ dialog.query_one("#reading-position", Static).render()
+ )
+
+
+@pytest.mark.asyncio
+async def test_structured_parameters_only_show_json_after_explicit_request():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await app.push_screen(ActionForm(c, "s", "reorder_queued_queries"))
+ fields = app.screen.query_one(ParameterFields)
+ row = next(row for row in fields.rows if row[0] == "order")
+ row[-1].focus()
+ assert row[-1].locked and row[-1].text != "[]"
+ await pilot.press("ctrl+r")
+ assert row[-1].vim_mode == "INSERT"
+ assert json.loads(row[-1].text) == []
+
+
+@pytest.mark.asyncio
+async def test_rejected_setting_preserves_choice_and_effective_value():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "m")
+ form = app.screen
+ catalogs(form)
+ await pilot.press("enter", "ctrl+j", "enter")
+ command = sent(c, "set_model")[-1]
+ # While the request is pending, Enter must not duplicate it.
+ await pilot.press("enter", "enter")
+ assert len(sent(c, "set_model")) == 1
+ assert form.values["model"] is None
+ c._handle(
+ dict(
+ type="error",
+ sid="s",
+ to=c.client_id,
+ request_id=command["cmd_id"],
+ message="Cannot change model",
+ )
+ )
+ form.paint()
+ assert "Cannot change model" in str(
+ form.query_one("#form-result", Static).render()
+ )
+ await pilot.press("enter")
+ assert app.screen.selected == "beta"
+ await pilot.press("enter")
+ assert len(sent(c, "set_model")) == 2
+ assert sent(c, "set_model")[-1]["cmd_id"] != command["cmd_id"]
+ c._handle(dict(type="model", sid="s", model="beta"))
+ form.paint()
+ assert form.values["model"] == "beta" and form.pending_change is None
+
+
+@pytest.mark.asyncio
+async def test_rejected_named_action_is_editable_and_retry_has_new_id():
+ app, c = setup()
+ async with app.run_test() as pilot:
+ await app.push_screen(ActionForm(c, "s", "set_effort"))
+ form = app.screen
+ form.query_one(ModalEditor).load_text("high")
+ await pilot.press("enter")
+ command = sent(c, "set_effort")[-1]
+ c._handle(
+ dict(
+ type="error",
+ sid="s",
+ request_id=command["cmd_id"],
+ to=c.client_id,
+ message="Rejected",
+ )
+ )
+ form.check_result()
+ assert not form.submitted
+ assert form.query_one(ModalEditor).text == "high"
+ await pilot.press("enter")
+ assert sent(c, "set_effort")[-1]["cmd_id"] != command["cmd_id"]
diff --git a/tests/test_tui_readable_state.py b/tests/test_tui_readable_state.py
new file mode 100644
index 00000000..d15567be
--- /dev/null
+++ b/tests/test_tui_readable_state.py
@@ -0,0 +1,25 @@
+"""Readable detail formatting preserves values and terminal safety."""
+
+from cc_remote.tui_details import details
+from cc_remote.tui_presentation import SessionPresentation
+
+
+def test_state_details_are_readable_and_keep_unknown_fields():
+ p = SessionPresentation()
+ p.context = {"percentage": 43.2055, "tokens": 123456789}
+ p.rates = {
+ "codex": {
+ "secondary": {
+ "used_percent": 20.555,
+ "window_duration_mins": 10080,
+ }
+ }
+ }
+ text = p.panel("Usage / Context")
+ assert "Context used: 43%" in text
+ assert "Consumed: 21%" in text
+ assert "1.23亿 tokens" in text
+ assert '"used_percent"' not in text
+ assert "New field: No" in details({"new_field": False})
+ assert "Zero: 0" in details({"zero": 0})
+ assert "\x1b" not in details({"title": "\x1b[31mremote"})
diff --git a/tests/test_tui_resize_anchor.py b/tests/test_tui_resize_anchor.py
new file mode 100644
index 00000000..90c3eeb0
--- /dev/null
+++ b/tests/test_tui_resize_anchor.py
@@ -0,0 +1,65 @@
+"""Terminal reflow must preserve the bottom or the source being read."""
+
+import pytest
+from textual.geometry import Offset
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, Transcript
+from cc_remote.tui_state import Block
+
+
+def make_app():
+ client = WorkspaceClient("ws://localhost/ws", "", "", "codex", "s")
+ view = client.workspace.view("s")
+ view.put(Block("a", "assistant", "\n".join(
+ f"line {n}: " + "中 English words " * 12 for n in range(80)
+ )))
+ return WorkspaceApp(client, connect=False), view
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("follow", [False, True])
+async def test_bottom_stays_bottom_even_when_not_following(follow):
+ app, view = make_app()
+ async with app.run_test(size=(100, 35)) as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.pause()
+ view.follow = follow
+ if not follow:
+ reader.move_cursor((30, 3))
+ reader.scroll_end(animate=False, immediate=True)
+ cursor = reader.cursor_location
+ await pilot.press("ctrl+j", "i", "x")
+ view.follow = follow
+ for width, height in [(43, 25), (125, 40), (30, 20), (100, 35)]:
+ await pilot.resize_terminal(width, height)
+ await pilot.pause()
+ assert reader.scroll_y == reader.max_scroll_y
+ assert view.follow
+ assert reader.cursor_location == cursor
+ assert app.focused.id == "composer" and app.focused.text == "x"
+
+
+@pytest.mark.asyncio
+async def test_middle_keeps_source_at_top_not_old_screen_row():
+ app, view = make_app()
+ async with app.run_test(size=(100, 35)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ app.stop_following()
+ reader.selection = Selection((40, 3), (40, 8))
+ selection = reader.selection
+ reader.scroll_to(y=77, animate=False, immediate=True)
+ point = reader.wrapped_document.offset_to_location(Offset(0, 77))
+ for width, height in [(43, 25), (125, 40), (30, 20), (100, 35)]:
+ await pilot.resize_terminal(width, height)
+ await pilot.pause()
+ expected = reader.wrapped_document.location_to_offset(point).y
+ assert reader.scroll_y == expected
+ assert reader.selection == selection
+ assert not view.follow
+ # Reflow can move the first character to a different wrapped row;
+ # the next resize preserves that row's visible source start.
+ point = reader.wrapped_document.offset_to_location(
+ reader.scroll_offset
+ )
diff --git a/tests/test_tui_review_delivery.py b/tests/test_tui_review_delivery.py
new file mode 100644
index 00000000..3eab5da5
--- /dev/null
+++ b/tests/test_tui_review_delivery.py
@@ -0,0 +1,73 @@
+"""Retain private attachments until authoritative delivery or rejection."""
+
+import json
+
+import pytest
+
+from cc_remote.tui_attachments import read_attachment
+from tests.test_tui_workspace import client, emit
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("queue", [False, True])
+@pytest.mark.parametrize("correlation", ["msg_id", "request_id"])
+async def test_rejected_delivery_restores_files_after_ack(
+ tmp_path, queue, correlation,
+):
+ c = client()
+ view = c.workspace.view("s")
+ path = tmp_path / "notes.txt"
+ path.write_text("private attachment")
+ attachment = read_attachment(str(path))
+ view.attachments.append(attachment)
+ assert await c.submit("with file", queue=queue)
+ frame = json.loads(next(iter(c._outbox.values()))[0])
+ assert not view.attachments and view.pending_attachments
+ # ACK drains transport storage, not the retained delivery payload.
+ c._outbox.clear()
+ newer = {**attachment, "name": "new draft attachment"}
+ view.attachments.append(newer)
+ emit(c, "error", code="read_only", message="ownership changed", **{
+ correlation: frame["msg_id" if correlation == "msg_id" else "cmd_id"],
+ })
+ assert view.attachments == [newer, attachment]
+ assert not view.pending_attachments
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["user_msg", "turn_steered", "query_queue"])
+async def test_authoritative_delivery_releases_only_sent_attachments(
+ tmp_path, kind,
+):
+ c = client()
+ path = tmp_path / "notes.txt"
+ path.write_text("attachment")
+ view = c.workspace.view("s")
+ attachment = read_attachment(str(path))
+ view.attachments.append(attachment)
+ assert await c.submit("with file", queue=kind == "query_queue")
+ frame = json.loads(next(iter(c._outbox.values()))[0])
+ view.attachments.append(attachment)
+ payload = (dict(items=[dict(msg_id=frame["msg_id"])])
+ if kind == "query_queue" else
+ dict(msg_id=frame["msg_id"], prompt="with file"))
+ emit(c, kind, **payload)
+ assert not view.pending_attachments
+ assert view.attachments == [attachment]
+
+
+@pytest.mark.asyncio
+async def test_fast_rejection_during_send_does_not_erase_restored_files(tmp_path):
+ c = client()
+ path = tmp_path / "notes.txt"
+ path.write_text("attachment")
+ attachment = read_attachment(str(path))
+ c.workspace.view("s").attachments.append(attachment)
+
+ async def send(command):
+ emit(c, "error", code="busy", message="rejected", msg_id=command.msg_id)
+ return True
+
+ c._send = send
+ assert await c.submit("with file")
+ assert c.workspace.view("s").attachments == [attachment]
diff --git a/tests/test_tui_review_history.py b/tests/test_tui_review_history.py
new file mode 100644
index 00000000..1c3c7669
--- /dev/null
+++ b/tests/test_tui_review_history.py
@@ -0,0 +1,88 @@
+"""Discontinuous projections must not retain artifacts from old sources."""
+
+import pytest
+
+from cc_remote.tui_app import WorkspaceApp
+from cc_remote.tui_inline_images import TranscriptViewport
+from cc_remote.tui_state import Block, SessionView
+from tests.test_tui_workspace import client, emit
+
+
+def page(revision, **kwargs):
+ return dict(
+ type="history", session_id="s", revision=revision, generation="g",
+ turns=[dict(id=revision, prompt=revision, blocks=[], done=True)],
+ **kwargs,
+ )
+
+
+@pytest.mark.parametrize("continuity,keep", [(None, False), ("old", True)])
+def test_history_requires_explicit_continuity_for_old_rows(continuity, keep):
+ view = SessionView()
+ view.history(page("old"))
+ view.history(page("new", build_seq=1, continuity_revision=continuity))
+ assert ("user:old" in {b.id for b in view.blocks}) is keep
+ assert "user:new" in {b.id for b in view.blocks}
+
+
+def test_repeated_alias_revisions_preserve_same_continuity_epoch():
+ view = SessionView()
+ view.history(page("old"))
+ view.history(page("alias1", build_seq=1, continuity_revision="old"))
+ view.history(page("alias2", build_seq=2, continuity_revision="old"))
+ assert {b.id for b in view.blocks} == {
+ "user:old", "user:alias1", "user:alias2",
+ }
+
+
+def test_discontinuity_retains_only_proven_newer_live_tail():
+ view = SessionView()
+ view.history(page("old"))
+ view.put(Block("live", "assistant", "new delta", "active", seq=11))
+ view.history(page("new", build_seq=1, live_seq=10))
+ assert {b.id for b in view.blocks} == {"user:new", "live"}
+
+
+@pytest.mark.asyncio
+async def test_artifact_invalidation_resets_inline_image_identity():
+ c = client()
+ view = c.workspace.view("s")
+ view.put(Block("a", "assistant", "image path"))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ viewport = app.query_one(TranscriptViewport)
+ original = viewport.identity
+ viewport.errors[("a", "image.png")] = "old failure"
+ emit(c, "artifact_invalidated")
+ app.paint()
+ await pilot.pause()
+ assert viewport.identity != original
+ assert not viewport.errors
+
+
+@pytest.mark.asyncio
+async def test_idle_paints_do_not_rebuild_projection_after_invalidation(monkeypatch):
+ c = client()
+ c.workspace.view("s").put(Block("a", "assistant", "**stable** body"))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ viewport = app.query_one(TranscriptViewport)
+ calls = []
+ original = viewport.project
+
+ def project(*args):
+ calls.append(args[-1])
+ return original(*args)
+
+ monkeypatch.setattr(viewport, "project", project)
+ for _ in range(10):
+ app.paint()
+ assert not calls
+ emit(c, "artifact_invalidated")
+ app.paint()
+ assert calls == [("s", None, 1)]
+ for _ in range(10):
+ app.paint()
+ assert len(calls) == 1
diff --git a/tests/test_tui_review_scope.py b/tests/test_tui_review_scope.py
new file mode 100644
index 00000000..5361ca3c
--- /dev/null
+++ b/tests/test_tui_review_scope.py
@@ -0,0 +1,128 @@
+"""Catalog authority for direct startup and removed tabs."""
+
+import json
+
+import pytest
+
+from cc_remote import tui
+from cc_remote.protocol import ListSessions
+from tests.test_tui_workspace import client, emit
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("engine,space", [
+ ("claude", "code"), ("codex", "work"), ("claude", "work"),
+])
+async def test_direct_id_waits_for_catalog_scope(engine, space):
+ c = client()
+ sent = []
+
+ async def send(command):
+ sent.append(command.model_dump())
+ return True
+
+ async def raw(frame):
+ sent.append(json.loads(frame))
+ return True
+
+ c._send, c._send_raw = send, raw
+ await c._recovery_preamble()
+ assert not any(f["type"] == "switch_session" for f in sent)
+ assert c.attached_sid is None
+ emit(c, "session_list", engine=engine, space=space, sessions=[
+ dict(session_id="s", engine=engine, space=space, cwd="/work"),
+ ])
+ await c.restore_surface()
+ switch = next(f for f in sent if f["type"] == "switch_session")
+ assert (switch["session_id"], switch["engine"], switch["space"]) == (
+ "s", engine, space,
+ )
+ assert c.scope == (engine, space) and c.attached_sid == "s"
+
+
+@pytest.mark.asyncio
+async def test_catalog_deletion_disables_retained_tab_without_erasing_draft():
+ c = client()
+ emit(c, "session_list", engine="codex", sessions=[dict(session_id="s")])
+ view = c.workspace.view("s")
+ view.write_state = "writable"
+ view.draft = "keep draft"
+ emit(c, "session_list", engine="claude", sessions=[])
+ assert view.write_state == "writable"
+ emit(c, "session_list", engine="codex", sessions=[])
+ assert view.write_state == "unavailable" and view.draft == "keep draft"
+ assert not await c.submit("must not send")
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cap", ["TUI_OUTBOX_CAP", "TUI_OUTBOX_BYTES"])
+@pytest.mark.parametrize("existing", [False, True])
+async def test_catalog_rejection_preserves_fence_and_retries(
+ monkeypatch, cap, existing,
+):
+ c = client(None)
+ scope = ("codex", "work")
+ frames = []
+
+ async def raw(frame):
+ frames.append(json.loads(frame))
+ return True
+
+ c._send_raw = raw
+ if existing:
+ assert await c._send(ListSessions(
+ engine="codex", space="work", cmd_id="previous",
+ ))
+ with monkeypatch.context() as blocked:
+ blocked.setattr(tui, cap, 0)
+ await c.switch_surface(*scope)
+ assert c.session_catalog_requests.get(scope) == (
+ "previous" if existing else None
+ )
+ assert scope in c.catalog_retries
+ assert scope not in c.catalog_dirty
+ assert scope not in c.catalog_ready
+ if existing:
+ emit(c, "session_list", engine="codex", space="work",
+ request_id="previous", sessions=[])
+ assert scope in c.catalog_ready
+ emit(c, "ack", cmd_id="previous")
+ await c._flush_history_refreshes()
+ request = next(f for f in reversed(frames) if f["type"] == "list_sessions")
+ assert request["cmd_id"] != "previous"
+ assert c.session_catalog_requests[scope] == request["cmd_id"]
+ assert scope not in c.catalog_retries
+ emit(c, "session_list", engine="codex", space="work",
+ request_id=request["cmd_id"], sessions=[])
+ assert scope in c.catalog_ready
+
+
+@pytest.mark.asyncio
+async def test_catalog_fence_precedes_immediate_response():
+ c = client(None)
+
+ async def raw(frame):
+ request = json.loads(frame)
+ emit(c, "session_list", engine="codex", space="work",
+ request_id=request["cmd_id"], sessions=[])
+ return True
+
+ c._send_raw = raw
+ c.session_catalog_requests["codex", "work"] = "older"
+ await c.switch_surface("codex", "work")
+ assert ("codex", "work") in c.catalog_ready
+
+
+@pytest.mark.asyncio
+async def test_rejected_refresh_keeps_invalidation_fence(monkeypatch):
+ c = client(None)
+ scope = ("codex", "work")
+ c.session_catalog_requests[scope] = "invalidated"
+ c.catalog_dirty.add(scope)
+ monkeypatch.setattr(tui, "TUI_OUTBOX_CAP", 0)
+ await c._flush_history_refreshes()
+ assert scope in c.catalog_dirty and scope in c.catalog_retries
+ emit(c, "session_list", engine="codex", space="work",
+ request_id="invalidated", sessions=[])
+ assert scope not in c.catalog_ready
diff --git a/tests/test_tui_scroll_frames.py b/tests/test_tui_scroll_frames.py
new file mode 100644
index 00000000..d9d304b9
--- /dev/null
+++ b/tests/test_tui_scroll_frames.py
@@ -0,0 +1,227 @@
+"""Observe intermediate scroll offsets and frames, not just settled positions."""
+
+import asyncio
+
+import pytest
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp
+from cc_remote.tui_state import Block
+from tests.test_tui_send_jumps import client
+
+
+def watch_frames(monkeypatch, app):
+ frames = []
+ original = app._display
+
+ def display(screen, renderable):
+ if renderable is not None:
+ reader = app.query_one(Transcript)
+ frames.append((reader.scroll_y, reader.max_scroll_y))
+ original(screen, renderable)
+
+ monkeypatch.setattr(app, "_display", display)
+ return frames
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("draft_focus", [False, True])
+@pytest.mark.parametrize("chunk", ["new line\n" * 4, "新内容 wrapped words " * 20])
+async def test_stream_never_scrolls_up_between_bottom_frames(
+ monkeypatch, draft_focus, chunk
+):
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ offsets, frames = [], []
+ watch = Transcript.watch_scroll_y
+ display = app._display
+ recording = False
+
+ def observe(reader, old, new):
+ if recording:
+ offsets.append((old, new))
+ watch(reader, old, new)
+
+ def frame(screen, renderable):
+ if recording and renderable is not None:
+ reader = app.query_one(Transcript)
+ frames.append((reader.scroll_y, reader.max_scroll_y))
+ display(screen, renderable)
+
+ monkeypatch.setattr(Transcript, "watch_scroll_y", observe)
+ monkeypatch.setattr(app, "_display", frame)
+ async with app.run_test(size=(80, 25)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ # Retain an older reading cursor while the viewport follows the tail.
+ reader.move_cursor((25, 1))
+ if draft_focus:
+ app.query_one(Composer).focus()
+ await pilot.pause()
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ recording = True
+ for n in range(5):
+ view.put(Block("stream", "assistant", f"{n}: {chunk}"), append=True)
+ app.paint()
+ await pilot.pause()
+ recording = False
+ assert reader.scroll_y == reader.max_scroll_y
+ assert offsets and frames
+ assert all(new >= old for old, new in offsets), offsets
+ assert all(y == bottom for y, bottom in frames), frames
+
+
+@pytest.mark.asyncio
+async def test_scrolled_up_stream_has_no_intermediate_viewport_movement(monkeypatch):
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ offsets = []
+ async with app.run_test() as pilot:
+ await pilot.press("g", "g", "j", "j", "ctrl+j")
+ reader = app.query_one(Transcript)
+ reader.scroll_to(y=20, animate=False, immediate=True)
+ await pilot.pause()
+ original = reader.watch_scroll_y
+
+ def watch(old, new):
+ offsets.append((old, new))
+ original(old, new)
+
+ monkeypatch.setattr(reader, "watch_scroll_y", watch)
+ for n in range(5):
+ view.put(Block("stream", "assistant", f"chunk {n}\n" * 4), append=True)
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == 20
+ assert all(old == new == 20 for old, new in offsets), offsets
+
+
+@pytest.mark.asyncio
+async def test_resize_frames_stay_at_bottom(monkeypatch):
+ c = client()
+ c.workspace.view("s").put(Block("wide", "assistant", "wide words " * 300))
+ app = WorkspaceApp(c, connect=False)
+ frames = []
+ async with app.run_test(size=(90, 30)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.move_cursor((10, 0))
+ await pilot.press("ctrl+j")
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ original = app._display
+
+ def frame(screen, renderable):
+ if renderable is not None:
+ frames.append((reader.scroll_y, reader.max_scroll_y))
+ original(screen, renderable)
+
+ monkeypatch.setattr(app, "_display", frame)
+ for size in [(42, 22), (105, 40), (65, 27)]:
+ await pilot.resize_terminal(*size)
+ await pilot.pause()
+ assert frames and all(y == bottom for y, bottom in frames), frames
+
+
+@pytest.mark.asyncio
+async def test_unchanged_projection_does_not_reload_the_document(monkeypatch):
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+
+ def forbidden(text):
+ raise AssertionError("An unchanged projection must not reload")
+
+ monkeypatch.setattr(reader, "load_text", forbidden)
+ for _ in range(3):
+ c.workspace.view("s").version += 1
+ app.paint()
+ await pilot.pause()
+
+
+@pytest.mark.asyncio
+async def test_completion_fold_has_no_intermediate_cursor_frame(monkeypatch):
+ c = client()
+ view = c.workspace.view("s")
+ view.event(dict(type="user_msg", msg_id="live", prompt="work"))
+ view.put(Block("progress", "assistant", "progress\n" * 100,
+ "live", "commentary"))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.move_cursor((25, 0))
+ await pilot.press("ctrl+j")
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ frames = watch_frames(monkeypatch, app)
+ view.put(Block("final", "assistant", "finished", "live", "final"))
+ view.event(dict(type="turn_end", turn_id="live", result={}))
+ app.paint()
+ await pilot.pause()
+ assert frames and all(y == bottom for y, bottom in frames), frames
+ assert "progress\n" in reader.text # Completion keeps the outer open.
+
+
+@pytest.mark.asyncio
+async def test_elapsed_clock_updates_never_scroll_to_the_reading_cursor(monkeypatch):
+ from cc_remote.tui_presentation import TurnDisplay
+
+ now = [1009.0]
+ monkeypatch.setattr("cc_remote.tui_app.time.time", lambda: now[0])
+ c = client()
+ view = c.workspace.view("s")
+ view.presentation.turns["t"] = TurnDisplay(started=1000)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.move_cursor((20, 0))
+ await pilot.press("ctrl+j")
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ frames = watch_frames(monkeypatch, app)
+ for value in (1010.0, 1011.0, 1060.0):
+ now[0] = value
+ app.paint()
+ await pilot.pause()
+ assert frames and all(y == bottom for y, bottom in frames), frames
+
+
+@pytest.mark.asyncio
+async def test_async_inline_image_expansion_keeps_every_frame_at_bottom(monkeypatch):
+ from cc_remote.tui_inline_images import TranscriptViewport
+ from tests.test_tui_preview import FakeImage, image_reply
+
+ release = asyncio.Event()
+
+ async def image_request(*args, **kwargs):
+ await release.wait()
+ return image_reply()
+
+ monkeypatch.setattr("cc_remote.tui_inline_images.preview_request", image_request)
+ c = client()
+ view = c.workspace.view("s")
+ view.put(Block("image", "assistant", ""))
+ app = WorkspaceApp(c, connect=False)
+ app.graphics = FakeImage
+ async with app.run_test(size=(80, 30)) as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.move_cursor((20, 0))
+ await pilot.press("ctrl+j")
+ reader.scroll_end(animate=False, immediate=True)
+ await pilot.pause()
+ frames = watch_frames(monkeypatch, app)
+ release.set()
+ viewport = app.query_one(TranscriptViewport)
+ tasks = list(viewport.pending.values())
+ assert tasks
+ await asyncio.wait_for(asyncio.gather(*tasks), 2)
+ await pilot.pause()
+ assert viewport.cache
+ assert frames and all(y == bottom for y, bottom in frames), frames
diff --git a/tests/test_tui_search_live.py b/tests/test_tui_search_live.py
new file mode 100644
index 00000000..13289d16
--- /dev/null
+++ b/tests/test_tui_search_live.py
@@ -0,0 +1,177 @@
+"""Search opens ready to type; directory results follow remote disk changes."""
+
+import asyncio
+
+import pytest
+from textual.widgets import OptionList
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient
+from cc_remote.tui_directories import DirectoryPicker
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_panels import ActionPicker, ShortcutPicker
+from cc_remote.tui_settings import ValuePicker
+from cc_remote.tui_buffers import BufferPicker
+from cc_remote.tui_keys import KeyConfig
+
+
+def app_client():
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ c.buffers.open("s")
+ return WorkspaceApp(c, connect=False), c
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["actions", "shortcuts", "values", "buffers"])
+async def test_search_immediately_types_navigates_and_closes_once(kind):
+ app, c = app_client()
+ picker = {
+ "actions": lambda: ActionPicker(c, "s"),
+ "shortcuts": lambda: ShortcutPicker(c),
+ "values": lambda: ValuePicker("Pick", [("one", 1), ("two", 2)]),
+ "buffers": lambda: BufferPicker(c),
+ }[kind]()
+ async with app.run_test() as pilot:
+ app.push_screen(picker)
+ await pilot.pause()
+ editor = picker.query_one(ModalEditor)
+ assert editor.vim_mode == "INSERT" and app.focused is editor
+ await pilot.press("ctrl+j", "ctrl+k", "x")
+ assert editor.text == "x" and app.focused is editor
+ await pilot.press("escape")
+ assert len(app.screen_stack) == 1 and not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_search_close_can_be_remapped_while_typing():
+ app, c = app_client()
+ c.keys = KeyConfig({"picker": {"close": ["ctrl+y"]}})
+ async with app.run_test() as pilot:
+ app.push_screen(ValuePicker("Pick", [("one", 1)]))
+ await pilot.pause()
+ await pilot.press("o", "ctrl+y")
+ assert len(app.screen_stack) == 1
+
+
+@pytest.mark.asyncio
+async def test_directory_recursion_and_live_creation_preserve_search(monkeypatch):
+ app, c = app_client()
+ pages = {"/root": ["/root/repo"], "/root/repo": [],
+ "/root/repo/deep": ["/root/repo/deep/new-project"]}
+ reads = []
+
+ async def listing(path, *, refresh=False):
+ reads.append((path, refresh))
+ return {"path": path, "dirs": [{"path": p} for p in pages.get(path, [])]}
+
+ async def rank(paths, query):
+ return [p for p in paths if query in p]
+
+ monkeypatch.setattr(c, "list_directories", listing)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ monkeypatch.setattr(DirectoryPicker, "REFRESH_SECONDS", 0.3)
+ async with app.run_test() as pilot:
+ picker = DirectoryPicker(c, "/root")
+ app.push_screen(picker)
+ await pilot.pause(0.2)
+ await pilot.press(*"new-project")
+ pages["/root/repo"] = ["/root/repo/deep"]
+ for _ in range(20):
+ await pilot.pause(0.1)
+ if picker.matches == ["/root/repo/deep/new-project"]:
+ break
+ assert picker.matches == ["/root/repo/deep/new-project"]
+ assert picker.query_one(ModalEditor).text == "new-project"
+ assert all(refresh for _, refresh in reads)
+ await pilot.press("escape")
+ assert len(app.screen_stack) == 1 and not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_recursive_scan_is_bounded_and_cancels_pending_reads(monkeypatch):
+ app, c = app_client()
+ count = 0
+ hanging = False
+ cancelled = asyncio.Event()
+
+ async def listing(path, **kwargs):
+ nonlocal count
+ count += 1
+ if hanging and path != "/root":
+ try:
+ await asyncio.Future()
+ finally:
+ cancelled.set()
+ return {"path": path, "dirs": [{"path": path + "/child"}]}
+
+ async def rank(paths, query):
+ return paths
+
+ monkeypatch.setattr(c, "list_directories", listing)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ monkeypatch.setattr(DirectoryPicker, "MAX_READS", 6)
+ async with app.run_test() as pilot:
+ picker = DirectoryPicker(c, "/root")
+ app.push_screen(picker)
+ await pilot.pause(0.6)
+ assert count == 6 and "limit reached" in picker.scan_note
+ hanging = True
+ picker.action_refresh()
+ await pilot.pause(0.1)
+ await pilot.press("escape")
+ await asyncio.wait_for(cancelled.wait(), 1)
+
+
+@pytest.mark.asyncio
+async def test_symlink_cycles_and_outside_roots_are_not_traversed(monkeypatch):
+ app, c = app_client()
+ reads = []
+
+ async def listing(path, **kwargs):
+ reads.append(path)
+ if path.endswith("/escape"):
+ return {"path": "/outside", "dirs": [{"path": "/outside/deep"}]}
+ return {"path": "/root", "dirs": [
+ {"path": "/root/loop"}, {"path": "/root/escape"}]}
+
+ monkeypatch.setattr(c, "list_directories", listing)
+ # This tests traversal, not the optional host fzf executable. CI need not
+ # have it installed; real ranking has a separate capability-gated test.
+ async def rank(paths, query):
+ return paths
+
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ async with app.run_test() as pilot:
+ picker = DirectoryPicker(c, "/root")
+ app.push_screen(picker)
+ await pilot.pause()
+ await picker.load_worker.wait()
+ await picker.filter_worker.wait()
+ await pilot.pause()
+ assert reads == ["/root", "/root/loop", "/root/escape"]
+ assert "/outside/deep" not in picker.paths
+ assert picker.query_one(OptionList).option_count
+
+
+@pytest.mark.asyncio
+async def test_background_directory_refresh_keeps_highlight(monkeypatch):
+ app, c = app_client()
+
+ async def listing(path, **kwargs):
+ return {"path": path, "dirs": [
+ {"path": "/root/a"}, {"path": "/root/b"}
+ ] if path == "/root" else []}
+
+ async def rank(paths, query):
+ return paths
+
+ monkeypatch.setattr(c, "list_directories", listing)
+ monkeypatch.setattr("cc_remote.tui_directories.fuzzy_directories", rank)
+ async with app.run_test() as pilot:
+ picker = DirectoryPicker(c, "/root")
+ app.push_screen(picker)
+ await pilot.pause(0.25)
+ await pilot.press("ctrl+j", "ctrl+j")
+ assert picker.selected_path() == "/root/b"
+ picker.refresh_if_idle()
+ await pilot.pause(0.25)
+ assert picker.selected_path() == "/root/b"
diff --git a/tests/test_tui_send_jumps.py b/tests/test_tui_send_jumps.py
new file mode 100644
index 00000000..cb5046e6
--- /dev/null
+++ b/tests/test_tui_send_jumps.py
@@ -0,0 +1,132 @@
+"""Draft submission and Vim jump-list regressions, without model calls."""
+
+import json
+
+import pytest
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp, WorkspaceClient
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_state import Block, SessionView
+
+
+def client():
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ v = c.workspace.view("s")
+ v.write_state = "writable"
+ v.put(Block("user:t", "user", "question\n" * 80, "t"))
+ v.put(Block("answer", "assistant", "answer\n" * 80, "t", "final"))
+ return c
+
+
+def test_typed_tool_replaces_commentary_scaffold_without_dumping_payload():
+ v = SessionView(active_turn="t")
+ v.put(Block("command", "assistant", "", "t", "commentary", expanded=True))
+ v.event(dict(type="tool_use", tool_use_id="command", title="Run command",
+ input={"command": "private long command"}))
+ v.event(dict(type="tool_result", tool_use_id="command", content="output"))
+ text, _ = v.render()
+ assert "1 个工具调用" in text and "succeeded" in text
+ assert "── Progress" not in text
+ assert "private long command" not in text and "output" not in text
+ v.tool_groups["tools:command"].expanded = True
+ assert "private long command" in v.render()[0]
+ assert "output" in v.render()[0]
+
+
+@pytest.mark.asyncio
+async def test_send_preserves_scrolled_up_viewport_until_explicit_bottom_jump():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(70, 24)) as pilot:
+ await pilot.press("g", "g", "ctrl+j", "i", "a", "enter", "b")
+ editor = app.query_one(Composer)
+ reader = app.query_one(Transcript)
+ assert editor.text == "a\nb" and not c._outbox
+ await pilot.press("ctrl+s")
+ assert not c._outbox
+ await pilot.press("escape", "enter")
+ await pilot.pause()
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert len(frames) == 1 and frames[0]["prompt"] == "a\nb"
+ assert editor.text == "" and editor.vim_mode == "NORMAL"
+ assert not c.workspace.view("s").follow
+ top = reader.scroll_y
+ c.workspace.view("s").put(Block("next", "assistant", "new\n" * 80))
+ app.paint()
+ await pilot.pause()
+ assert reader.scroll_y == top
+ await pilot.press("ctrl+k", "G")
+ await pilot.pause()
+ assert c.workspace.view("s").follow
+ assert reader.scroll_y == reader.max_scroll_y
+
+
+@pytest.mark.asyncio
+async def test_rejected_send_preserves_draft_and_reading_position():
+ c = client()
+ c.workspace.view("s").write_state = "read_only"
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("g", "g", "ctrl+j", "i", "x", "escape", "enter")
+ assert app.query_one(Composer).text == "x"
+ assert not c._outbox and not c.workspace.view("s").follow
+
+
+@pytest.mark.asyncio
+async def test_jump_list_back_forward_tab_and_branching():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ await pilot.press("k")
+ origin = reader.cursor_location
+ await pilot.press("g", "g")
+ first = reader.cursor_location
+ await pilot.press("g", "a")
+ answer = reader.cursor_location
+ await pilot.press("ctrl+o")
+ assert reader.cursor_location == first
+ await pilot.press("ctrl+o")
+ assert reader.cursor_location == origin
+ await pilot.press("ctrl+i")
+ assert reader.cursor_location == first
+ await pilot.press("tab")
+ assert reader.cursor_location == answer
+ assert app.focused is reader
+ await pilot.press("ctrl+o", "G")
+ assert not c.workspace.view("s").jump_forward
+ await pilot.press("ctrl+j", "i", "x", "ctrl+o")
+ assert app.focused is app.query_one(Composer)
+ assert app.query_one(Composer).text == "x"
+
+
+def test_old_send_and_tab_focus_defaults_are_removed():
+ keys = KeyConfig()
+ assert not keys.global_keys["send"]
+ assert not keys.global_keys["toggle_pane"]
+ assert keys.normal_keys["jump_back"] == ("ctrl+o",)
+ assert "tab" in keys.normal_keys["jump_forward"]
+ assert "Draft Normal" in keys.help()
+ assert "Send (Normal) [draft.send]" in keys.help()
+
+
+@pytest.mark.asyncio
+async def test_send_rekey_clears_the_same_draft_and_keeps_follow(monkeypatch):
+ c = client()
+ view = c.workspace.view("s")
+
+ async def accepted(text, *, queue=False):
+ c.workspace.event(dict(type="session_rekey", old_key="s", session_id="real"))
+ c.attached_sid = "real"
+ view.follow = True
+ return True
+
+ monkeypatch.setattr(c, "submit", accepted)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("g", "g", "ctrl+j", "i", "x", "escape", "enter")
+ await pilot.pause()
+ assert "s" not in c.workspace.views
+ assert c.workspace.view("real") is view and view.draft == ""
+ assert app.query_one(Composer).text == ""
+ assert view.follow
diff --git a/tests/test_tui_side_catalog.py b/tests/test_tui_side_catalog.py
new file mode 100644
index 00000000..228e2645
--- /dev/null
+++ b/tests/test_tui_side_catalog.py
@@ -0,0 +1,39 @@
+"""BTW metadata follows its parent regardless of Hello/catalog ordering."""
+
+import pytest
+
+from cc_remote.tui_state import WorkspaceState
+
+
+@pytest.mark.parametrize("engine", ["codex", "claude"])
+@pytest.mark.parametrize("space", ["work", "code"])
+@pytest.mark.parametrize("parent_first", [False, True])
+def test_side_chat_scope_reconciles_after_parent_catalog(
+ engine, space, parent_first,
+):
+ state = WorkspaceState()
+ catalog = dict(type="session_list", engine=engine, space=space, sessions=[
+ dict(session_id="parent", summary="Parent", cwd="/work"),
+ ])
+ side = dict(type="btw_sync", generation="g", revision=1, sessions=[
+ dict(btw_sid="btw-side", parent_sid="parent", engine=engine,
+ state="running"),
+ ])
+ for event in ([catalog, side] if parent_first else [side, catalog]):
+ state.event(event)
+ row = state.catalog["btw-side"]
+ assert (row["space"], row["cwd"], row["summary"]) == (
+ space, "/work", "BTW · Parent",
+ )
+ assert row["state"] == "running" and row["engine"] == engine
+ state.event(dict(type="session_list", engine=engine,
+ space="code" if space == "work" else "work", sessions=[]))
+ assert row["space"] == space
+ catalog["sessions"][0].update(summary="Renamed", cwd="/new")
+ state.event(catalog)
+ assert (row["summary"], row["cwd"]) == ("BTW · Renamed", "/new")
+ state.event(dict(type="session_migrated", session_id="parent", cwd="/moved"))
+ assert row["cwd"] == "/moved"
+ state.event(dict(type="btw_sync", generation="g", revision=2, sessions=[]))
+ state.event(catalog)
+ assert "btw-side" not in state.catalog
diff --git a/tests/test_tui_stop.py b/tests/test_tui_stop.py
new file mode 100644
index 00000000..a34feb85
--- /dev/null
+++ b/tests/test_tui_stop.py
@@ -0,0 +1,118 @@
+"""Stop is a configurable, session-scoped command, not a local state edit."""
+
+import json
+
+import pytest
+
+from cc_remote.tui_app import Composer, WorkspaceApp, WorkspaceClient
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_settings import TextValue
+
+
+def make_app(config=None):
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ client.keys = KeyConfig(config)
+ client.workspace.catalog["s"] = dict(
+ session_id="s", engine="codex", space="code", cwd="/project",
+ summary="Session",
+ )
+ view = client.workspace.view("s")
+ view.write_state = "writable"
+ view.state = "running"
+ return WorkspaceApp(client, connect=False)
+
+
+def interrupts(app):
+ return [frame for raw, _ in app.client._outbox.values()
+ if (frame := json.loads(raw))["type"] == "interrupt"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("focus", ["reader", "draft_normal", "draft_insert"])
+@pytest.mark.parametrize("engine", ["codex", "claude"])
+async def test_stop_targets_current_turn_and_preserves_workspace(focus, engine):
+ app = make_app()
+ app.client.engine = engine
+ app.client.session_engines["s"] = engine
+ app.client.workspace.catalog["s"]["engine"] = engine
+ view = app.client.workspace.view("s")
+ view.queue = [dict(msg_id="queued", prompt_preview="later", kind="queue",
+ image_count=0, file_count=0)]
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", *"keep draft", "escape")
+ if focus == "reader":
+ await pilot.press("ctrl+k")
+ elif focus == "draft_insert":
+ await pilot.press("i")
+ editor = app.query_one(Composer)
+ before = editor.text, editor.cursor_location, app.mode, app.focused
+ view.attachments = [{"name": "note", "image": False, "content": {}}]
+ await pilot.press("ctrl+x")
+ assert len(interrupts(app)) == 1
+ assert interrupts(app)[0]["sid"] == "s"
+ assert (editor.text, editor.cursor_location, app.mode, app.focused) == before
+ assert view.state == "running" # Wait for the authoritative terminal.
+ assert view.queue[0]["msg_id"] == "queued"
+ assert view.attachments[0]["name"] == "note"
+ assert "Stop requested" in app.client.notice
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("keys", [["ctrl+y"], []])
+async def test_stop_can_be_rebound_or_disabled_without_default_fallback(keys):
+ app = make_app({"keys": {"stop": keys}})
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+x")
+ assert not interrupts(app)
+ if keys:
+ await pilot.press("ctrl+y")
+ assert len(interrupts(app)) == 1
+ row = next(r for r in app.client.keys.index() if r["id"] == "keys.stop")
+ assert row["keys"] == keys
+ assert row["enabled"] == bool(keys)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("scope", ["modal", "tree", "search"])
+async def test_stop_does_not_escape_nested_key_scopes(scope):
+ app = make_app()
+ async with app.run_test() as pilot:
+ if scope == "modal":
+ app.push_screen(TextValue("Rename", "value"))
+ await pilot.pause()
+ else:
+ await pilot.press("space", "e")
+ if scope == "search":
+ await pilot.press("slash")
+ await pilot.press("ctrl+x")
+ assert not interrupts(app)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("state", ["idle", "read_only", "no_session"])
+async def test_stop_requires_a_running_writable_session(state):
+ app = make_app()
+ if state == "idle":
+ app.client.workspace.view("s").state = "idle"
+ elif state == "read_only":
+ app.client.workspace.view("s").write_state = "read_only"
+ else:
+ app.client.attached_sid = None
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+x")
+ assert not interrupts(app)
+
+
+@pytest.mark.asyncio
+async def test_failed_stop_does_not_claim_success_or_change_running_state():
+ app = make_app()
+
+ async def reject(message):
+ app.client.notice = "Disconnected; try again"
+ return False
+
+ app.client._send = reject
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+x")
+ assert app.client.notice == "Disconnected; try again"
+ assert app.client.workspace.view("s").state == "running"
diff --git a/tests/test_tui_tab_status.py b/tests/test_tui_tab_status.py
new file mode 100644
index 00000000..7ffc8284
--- /dev/null
+++ b/tests/test_tui_tab_status.py
@@ -0,0 +1,114 @@
+"""Terminal tab badges are unread results, not inferred idle transitions."""
+
+import pytest
+from rich.console import Console
+
+from cc_remote.tui_app import WorkspaceApp
+from cc_remote.tui_buffers import tab_line
+from cc_remote.tui_state import SessionView
+from tests.test_tui_buffers import client
+
+
+def finish(view, identity="turn", subtype="success", is_error=False):
+ view.event(dict(type="user_msg", msg_id=identity, prompt="question"))
+ view.event(dict(type="turn_end", turn_id=identity,
+ result=dict(subtype=subtype, is_error=is_error)))
+ view.event(dict(type="state", state="idle"))
+
+
+@pytest.mark.parametrize(("subtype", "error", "status", "color"), [
+ ("success", False, "completed", "green"),
+ ("interrupted", False, "interrupted", "red"),
+ ("error_during_execution", True, "interrupted", "red"),
+ ("error_max_turns", True, "failed", "red"),
+])
+def test_terminal_badge_color_read_and_replay(subtype, error, status, color):
+ c, _ = client()
+ c.buffers.open("s")
+ view = c.workspace.view("s")
+ finish(view, subtype=subtype, is_error=error)
+ assert view.tab_badge() == status
+ text = tab_line(c, 80)
+ style = text.get_style_at_offset(Console(), text.plain.index("●"))
+ assert style.color.name == color
+ view.read_tab()
+ assert "●" not in tab_line(c, 80).plain
+ finish(view, subtype=subtype, is_error=error)
+ assert view.tab_badge() is None # Replayed terminal stays read.
+ finish(view, "next", subtype, error)
+ assert view.tab_badge() == status
+
+
+def test_running_wins_and_idle_alone_does_not_invent_completion():
+ view = SessionView()
+ view.event(dict(type="state", state="running"))
+ assert view.tab_badge() == "running"
+ view.event(dict(type="state", state="idle"))
+ assert view.tab_badge() is None
+ finish(view)
+ view.event(dict(type="state", state="running"))
+ assert view.tab_badge() == "running"
+
+
+def test_server_receipt_read_sync_and_late_receipt_do_not_revive_badge():
+ view = SessionView()
+ finish(view)
+ view.read_tab()
+ view.event(dict(type="completion_state", completion_id="turn",
+ unread=True, revision=1))
+ assert view.tab_badge() is None
+ finish(view, "next")
+ view.event(dict(type="completion_state", completion_id="next",
+ unread=False, revision=3))
+ assert view.tab_badge() is None
+ view.event(dict(type="completion_state", completion_id="next",
+ unread=True, revision=2))
+ assert view.tab_badge() is None
+ cold = SessionView()
+ cold.event(dict(type="completion_state", completion_id="cold",
+ unread=True, revision=1))
+ assert cold.tab_badge() == "completed"
+
+
+def test_late_binding_and_session_rekey_preserve_read_identity():
+ c, _ = client()
+ view = c.workspace.view("tmp-s")
+ finish(view, "local")
+ view.read_tab()
+ view.event(dict(type="turn_binding", msg_id="local", turn_id="native"))
+ view.event(dict(type="completion_state", completion_id="native",
+ unread=True, revision=1))
+ c.workspace.event(dict(type="session_rekey", old_key="tmp-s",
+ session_id="real"))
+ assert c.workspace.view("real").tab_badge() is None
+
+
+def test_neutral_boundary_and_background_error_do_not_signal_completion():
+ view = SessionView()
+ finish(view, subtype="steered")
+ assert view.tab_badge() is None
+ view.event(dict(type="error", code="lookup_failed", message="No details"))
+ assert view.tab_badge() is None
+
+
+@pytest.mark.asyncio
+async def test_only_visible_tab_is_read_without_needing_follow():
+ c, _ = client()
+ for sid in ("a", "b"):
+ c.buffers.open(sid)
+ finish(c.workspace.view(sid), sid, "interrupted")
+ c.attached_sid = "a"
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ assert c.workspace.view("a").tab_badge() is None
+ assert c.workspace.view("b").tab_badge() == "interrupted"
+ app.app_focus = False
+ c.attached_sid = "b"
+ c.workspace.view("b").follow = False
+ app.paint()
+ await pilot.pause()
+ assert c.workspace.view("b").tab_badge() == "interrupted"
+ app.app_focus = True
+ app.paint()
+ assert c.workspace.view("b").tab_badge() is None
diff --git a/tests/test_tui_tab_store.py b/tests/test_tui_tab_store.py
new file mode 100644
index 00000000..7596db0a
--- /dev/null
+++ b/tests/test_tui_tab_store.py
@@ -0,0 +1,155 @@
+"""Tab-only persistence, concurrent terminal edits and guarded restoration."""
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient
+from cc_remote.tui_tab_store import TabStore
+
+
+def store(tmp_path, **kwargs):
+ return TabStore(kwargs.get("url", "ws://localhost:8766/ws"),
+ kwargs.get("machine", "default"),
+ kwargs.get("username", "user"), directory=tmp_path)
+
+
+def client():
+ return WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", None)
+
+
+def test_private_atomic_tabs_exclude_transient_ids_and_secrets(tmp_path):
+ saved = store(tmp_path)
+ assert saved.load() is None
+ saved.save(["a", "a", "b", "tmp-new", "btw-aside"], "b", ("claude", "work"))
+ assert saved.load() == {
+ "tabs": ["a", "b"], "active": "b", "scope": ["claude", "work"]}
+ assert saved.path.stat().st_mode & 0o777 == 0o600
+ assert "localhost" not in saved.path.read_text()
+ assert len(list(tmp_path.glob(".tabs-*"))) == 0
+
+
+@pytest.mark.parametrize("boundary", ["url", "machine", "username"])
+def test_scopes_do_not_restore_other_device_or_account(tmp_path, boundary):
+ first = store(tmp_path)
+ first.save(["private"], "private", ("codex", "code"))
+ assert store(tmp_path, **{boundary: "different"}).load() is None
+
+
+def test_two_terminals_merge_edits_without_resurrecting_closed_tabs(tmp_path):
+ first = store(tmp_path)
+ first.save(["a", "b"], "a", ("codex", "code"))
+ second = store(tmp_path)
+ second.load()
+ first.save(["a"], "a", ("codex", "code"))
+ second.save(["a", "b", "c"], "c", ("codex", "code"))
+ assert store(tmp_path).load()["tabs"] == ["a", "c"]
+ first.save(["a", "d"], "d", ("codex", "code"))
+ assert store(tmp_path).load()["tabs"] == ["a", "c", "d"]
+
+
+@pytest.mark.asyncio
+async def test_restore_waits_for_catalog_and_attaches_only_saved_active(tmp_path):
+ saved = store(tmp_path)
+ saved.save(["a", "b"], "b", ("claude", "work"))
+ c = client()
+ c.restore_tabs(store(tmp_path))
+ c._attach = AsyncMock()
+ assert c.scope == ("claude", "work")
+ assert c.buffers.ids == ["a", "b"] and not c._outbox
+ await c.restore_surface()
+ c._attach.assert_not_awaited()
+ c.workspace.catalog.update({sid: {
+ "session_id": sid, "engine": "claude", "space": "work",
+ } for sid in ("a", "b", "newer")})
+ c.catalog_ready.add(c.scope)
+ await c.restore_surface()
+ c._attach.assert_awaited_once_with("b", "claude")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("tabs", [[], ["deleted"]])
+async def test_empty_or_deleted_working_set_does_not_open_recent_session(tmp_path, tabs):
+ saved = store(tmp_path)
+ saved.save(tabs, None, ("codex", "code"))
+ c = client()
+ c.restore_tabs(store(tmp_path))
+ c._attach = AsyncMock()
+ c.workspace.catalog["unrelated"] = {
+ "session_id": "unrelated", "engine": "codex", "space": "code"}
+ c.catalog_ready.add(c.scope)
+ await c.restore_surface()
+ c._attach.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_open_close_rekey_and_exit_persist_without_model_commands(tmp_path):
+ c = client()
+ c.demo = True
+ c.restore_pending = False
+ c.restore_tabs(store(tmp_path))
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await app.attach_session("a")
+ await app.attach_session("b")
+ assert store(tmp_path).load()["tabs"] == ["a", "b"]
+ await app.action_close_buffer()
+ assert store(tmp_path).load()["tabs"] == ["a"]
+ c.buffers.rekey("a", "real")
+ c.attached_sid = "real"
+ app.paint()
+ await pilot.pause()
+ assert store(tmp_path).load()["tabs"] == ["real"]
+ assert not c._outbox
+
+
+def test_corrupt_state_reported_without_overwriting(tmp_path):
+ saved = store(tmp_path)
+ saved.save(["a"], "a", ("codex", "code"))
+ saved.path.write_text("not json")
+ c = client()
+ c.restore_tabs(saved)
+ assert c.tab_store is None and "Cannot read" in c.notice
+ c.save_tabs()
+ assert saved.path.read_text() == "not json"
+
+
+def test_explicit_session_overrides_restored_focus(tmp_path):
+ saved = store(tmp_path)
+ saved.save(["a"], "a", ("claude", "work"))
+ c = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "explicit")
+ c.restore_tabs(store(tmp_path))
+ assert c.attached_sid == "explicit" and c.scope == ("codex", "code")
+ assert not c.restore_pending
+
+
+@pytest.mark.parametrize("engine,space,expected", [
+ (True, False, ("codex", "work")),
+ (False, True, ("claude", "code")),
+ (True, True, ("codex", "code")),
+])
+def test_explicit_scope_axes_override_saved_scope(
+ tmp_path, engine, space, expected,
+):
+ saved = store(tmp_path)
+ saved.save(["a"], "a", ("claude", "work"))
+ c = client()
+ c.explicit_engine, c.explicit_space = engine, space
+ c.restore_tabs(saved)
+ assert c.scope == expected
+ assert c.buffers.ids == ["a"]
+ assert c.scope not in c.last_focus
+
+
+def test_save_only_on_working_set_change(tmp_path):
+ c = client()
+ c.restore_tabs(store(tmp_path))
+ c.restore_pending = False
+ c.buffers.open("a")
+ c.attached_sid = "a"
+ c.save_tabs()
+ before = c.tab_store.path.stat().st_mtime_ns
+ c.save_tabs()
+ assert c.tab_store.path.stat().st_mtime_ns == before
+ assert json.loads(c.tab_store.path.read_text())["active"] == "a"
diff --git a/tests/test_tui_text_objects.py b/tests/test_tui_text_objects.py
new file mode 100644
index 00000000..88e6e1c4
--- /dev/null
+++ b/tests/test_tui_text_objects.py
@@ -0,0 +1,53 @@
+"""Text objects are local draft operations, never commands or model turns."""
+
+import pytest
+
+from cc_remote.tui_text_objects import text_object
+
+
+@pytest.mark.parametrize(
+ ("text", "cursor", "kind", "around", "selected"),
+ [
+ ("one two three", 5, "w", False, "two"),
+ ("one two three", 5, "w", True, "two "),
+ ("one two", 5, "w", True, " two"),
+ ("one two", 3, "w", False, " "),
+ ("one two", 3, "w", True, " two"),
+ ("foo.bar rest", 2, "W", False, "foo.bar"),
+ ("foo.bar rest", 2, "w", False, "foo"),
+ ("foo.bar rest", 3, "w", False, "."),
+ ("中文 测试", 1, "w", True, "中文 "),
+ ("(outer(inner)tail)", 8, "(", False, "inner"),
+ ("(outer(inner)tail)", 8, ")", True, "(inner)"),
+ ("(outer(inner)tail)", 2, "b", False, "outer(inner)tail"),
+ ("(\nhello\n)", 4, "(", False, "\nhello\n"),
+ ("[]", 0, "[", False, ""),
+ ("[value]", 6, "]", True, "[value]"),
+ ("{value}", 3, "B", False, "value"),
+ ("", 3, ">", False, "value"),
+ ('say "hello" now', 6, '"', False, "hello"),
+ ('say "hello" now', 0, '"', True, '"hello"'),
+ ("'one' 'two'", 8, "'", False, "two"),
+ ('"a\\"b"', 3, '"', False, 'a\\"b'),
+ ("`hello`", 2, "`", True, "`hello`"),
+ ],
+)
+def test_text_object_ranges(text, cursor, kind, around, selected):
+ span = text_object(text, cursor, kind, around)
+ assert span is not None
+ assert text[slice(*span)] == selected
+
+
+@pytest.mark.parametrize(
+ ("text", "kind"),
+ [
+ ("", "w"),
+ ("plain", "("),
+ ("(unfinished", "("),
+ ('"unfinished', '"'),
+ ('"split\nquote"', '"'),
+ ("text", "t"),
+ ],
+)
+def test_missing_objects_fail_closed(text, kind):
+ assert text_object(text, 0, kind, False) is None
diff --git a/tests/test_tui_tree.py b/tests/test_tui_tree.py
new file mode 100644
index 00000000..070df875
--- /dev/null
+++ b/tests/test_tui_tree.py
@@ -0,0 +1,205 @@
+"""Directory explorer is local UI state, not another control transport."""
+
+import os
+import json
+import subprocess
+import sys
+
+import pytest
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, seed_demo, Composer
+from cc_remote.tui_tree import SessionExplorer, SessionTree, TreeSearch, RenameDialog
+from textual.widgets import TextArea
+from cc_remote.tui_keys import KeyConfig
+
+
+def make_app():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", None)
+ seed_demo(client)
+ return WorkspaceApp(client, connect=False)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("width", [60, 120])
+async def test_tree_is_left_docked_toggleable_and_folders_expand(width):
+ app = make_app()
+ async with app.run_test(size=(width, 35)) as pilot:
+ explorer = app.query_one(SessionExplorer)
+ conversation = app.query_one("#conversation")
+ assert not explorer.display
+ await pilot.press("ctrl+p")
+ assert not explorer.display
+ await pilot.press("space", "e")
+ assert explorer.display and len(app.screen_stack) == 1
+ assert explorer.region.right == conversation.region.x
+ assert conversation.region.width > 25
+ tree = app.query_one(SessionTree)
+ folder = tree.root.children[1]
+ tree.move_cursor(folder)
+ await pilot.press("enter")
+ assert folder.is_expanded
+ await pilot.press("h")
+ assert not folder.is_expanded
+ await pilot.press("l", "j", "enter")
+ assert app.client.attached_sid == "demo-tests"
+ assert explorer.display
+ await pilot.press("space", "e")
+ assert not explorer.display
+ assert conversation.region.width == width
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_catalog_migration_delete_and_rename_update_existing_tree():
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e")
+ explorer = app.query_one(SessionExplorer)
+ tree = app.query_one(SessionTree)
+ row = app.client.workspace.catalog["demo-tests"]
+ row.update(cwd="/new/place", summary="Renamed session")
+ explorer.refresh_catalog()
+ folders = {node.data[1]: node for node in tree.root.children}
+ assert "/example/tests" not in folders
+ assert folders["/new/place"].children[0].label.plain == "Renamed session"
+ del app.client.workspace.catalog["demo-tests"]
+ explorer.refresh_catalog()
+ assert "/new/place" not in {node.data[1] for node in tree.root.children}
+
+
+@pytest.mark.asyncio
+async def test_tree_search_does_not_send_and_insert_keeps_space_e_literal():
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "space", "e")
+ draft = app.query_one(Composer)
+ assert draft.text == " e"
+ assert not app.query_one(SessionExplorer).display
+ await pilot.press("escape", "space", "e", "slash", "z", "z")
+ await pilot.press("ctrl+s", "ctrl+e", "ctrl+t", "enter")
+ assert not app.client._outbox
+ assert app.client.attached_sid == "demo-review"
+ await pilot.press("escape")
+ assert app.focused is app.query_one(SessionTree)
+ assert not app.query_one(TreeSearch).display
+ await pilot.press("escape", "ctrl+j")
+ assert app.focused is draft and draft.text == " e"
+
+
+def test_bare_tui_default_engine_is_codex():
+ result = subprocess.run(
+ [sys.executable, "-c", "from cc_remote.tui import ENGINE; print(ENGINE)"],
+ env={k: v for k, v in os.environ.items() if k != "ENGINE"},
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ assert result.stdout.strip() == "codex"
+
+
+@pytest.mark.asyncio
+async def test_tree_vim_counts_first_last_and_all_folders_are_local():
+ app = make_app()
+ for i in range(100):
+ sid = f"extra-{i}"
+ app.client.workspace.catalog[sid] = dict(
+ session_id=sid, summary=sid, cwd="/example/many",
+ engine="codex", space="code",
+ )
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ sid = app.client.attached_sid
+ assert all(n.is_expanded for n in tree.root.children)
+ await pilot.press("G")
+ last = tree.cursor_line
+ assert last == tree.last_line and last > 100
+ await pilot.press("5", "0", "k")
+ assert tree.cursor_line == last - 50
+ await pilot.press("g", "g")
+ assert tree.cursor_line == 0
+ await pilot.press("5", "0", "j")
+ assert tree.cursor_line == 50
+ await pilot.press("5", "0", "G")
+ assert tree.cursor_line == 49
+ await pilot.press("H")
+ assert all(not n.is_expanded for n in tree.root.children)
+ assert tree.cursor_node.data[0] == "folder"
+ await pilot.press("L")
+ assert all(n.is_expanded for n in tree.root.children)
+ assert app.client.attached_sid == sid and not app.client._outbox
+ await pilot.press("space", "h")
+ assert len(app.screen_stack) == 2
+ assert "Collapse all folders" in app.screen.query_one(TextArea).text
+
+
+@pytest.mark.asyncio
+async def test_tree_rename_targets_highlight_not_attached_session():
+ app = make_app()
+ app.client.demo = False
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ target = next(n for f in tree.root.children for n in f.children
+ if n.data[1] == "demo-tests")
+ tree.move_cursor(target)
+ await pilot.press("r")
+ assert isinstance(app.screen, RenameDialog)
+ editor = app.screen.query_one(TextArea)
+ assert editor.read_only
+ await pilot.press("i", "ctrl+a")
+ editor.load_text("renamed")
+ await pilot.press("escape", "enter")
+ await pilot.pause()
+ frames = [json.loads(raw) for raw, _ in app.client._outbox.values()]
+ rename = next(f for f in frames if f["type"] == "rename_session")
+ assert (rename["session_id"], rename["title"], rename["engine"],
+ rename["space"]) == ("demo-tests", "renamed", "codex", "code")
+ assert app.client.attached_sid == "demo-review"
+ assert app.client.workspace.catalog["demo-tests"]["summary"] != "renamed"
+
+
+@pytest.mark.asyncio
+async def test_tree_search_keeps_vim_letters_literal_and_count_is_reset():
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "5", "slash", "r", "H", "L", "G")
+ search = app.query_one(TreeSearch)
+ assert search.value == "rHLG"
+ assert len(app.screen_stack) == 1
+ await pilot.press("escape")
+ tree = app.query_one(SessionTree)
+ assert not tree.count and not tree.chord
+ await pilot.press("g", "g", "r")
+ assert len(app.screen_stack) == 1 # Folder is not a rename target.
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_tree_rename_validates_and_does_not_resurrect_deleted_target():
+ app = make_app()
+ app.client.demo = False
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L", "g", "g", "j", "r")
+ dialog = app.screen
+ assert isinstance(dialog, RenameDialog)
+ sid = app.query_one(SessionTree).cursor_node.data[1]
+ editor = dialog.query_one(TextArea)
+ for name in ("", "a" * 201):
+ editor.load_text(name)
+ await pilot.press("enter")
+ assert app.screen is dialog and not app.client._outbox
+ del app.client.workspace.catalog[sid]
+ editor.load_text("new title")
+ await pilot.press("enter")
+ await pilot.pause()
+ assert not app.client._outbox
+
+
+def test_tree_chords_reject_unreachable_prefixes_and_count_keys():
+ with pytest.raises(ValueError, match="prefix"):
+ KeyConfig({"tree": {"rename": ["g"]}})
+ with pytest.raises(ValueError, match="digits"):
+ KeyConfig({"tree": {"rename": ["5"]}})
+ keys = KeyConfig({"tree": {"rename": ["R"], "first": ["g t"]}})
+ assert keys.layers["tree"]["rename"] == ("R",)
diff --git a/tests/test_tui_tree_actions.py b/tests/test_tui_tree_actions.py
new file mode 100644
index 00000000..d3c7ca60
--- /dev/null
+++ b/tests/test_tui_tree_actions.py
@@ -0,0 +1,203 @@
+"""Tree mutations act on the highlighted session, not the open transcript."""
+
+import json
+
+import pytest
+from textual.widgets import OptionList, TextArea
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, seed_demo
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_tree import DeleteDialog, RenameDialog, SessionTree, TreeSearch
+from cc_remote.tui_tree import tree_nodes
+
+
+def make_app(config=None):
+ client = WorkspaceClient("ws://localhost/ws", "", "", "codex", None)
+ seed_demo(client)
+ client.demo = False
+ client.keys = KeyConfig(config)
+ return WorkspaceApp(client, connect=False)
+
+
+def sent(client, kind):
+ return [frame for raw, _ in client._outbox.values()
+ if (frame := json.loads(raw))["type"] == kind]
+
+
+async def select_target(app, pilot):
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ tree.move_cursor(next(n for f in tree.root.children for n in f.children
+ if n.data[1] == "demo-tests"))
+
+
+async def confirm_archive(client, pilot):
+ await pilot.pause()
+ commands = sent(client, "archive_session")
+ assert len(commands) == 1 and not sent(client, "delete_session")
+ command = commands[0]
+ client._handle(dict(
+ type="session_list", engine="codex", space="code",
+ to=client.client_id, request_id=command["cmd_id"],
+ sessions=[dict(client.workspace.catalog["demo-tests"], tag="archived")],
+ ))
+ assert not sent(client, "delete_session")
+ client._handle(dict(type="command_ack", client_id=client.client_id,
+ to=client.client_id, cmd_id=command["cmd_id"]))
+ await pilot.pause()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("keys", [("d", "y"), ("D",)])
+async def test_tree_closes_side_chat_without_native_deletion(keys):
+ app = make_app()
+ app.client._handle(dict(
+ type="btw_opened", btw_sid="btw-side", parent_sid="demo-review",
+ engine="codex", revision=1, generation="g",
+ ))
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "L")
+ tree = app.query_one(SessionTree)
+ tree.move_cursor(next(n for n in tree_nodes(tree.root)
+ if n.data == ("session", "btw-side")))
+ await pilot.press(*keys)
+ assert sent(app.client, "close_btw")[0]["sid"] == "btw-side"
+ assert not sent(app.client, "delete_session")
+ assert not sent(app.client, "archive_session")
+ assert "btw-side" in app.client.workspace.catalog
+ app.client._handle(dict(type="btw_closed", btw_sid="btw-side",
+ generation="g", revision=2))
+ assert "btw-side" not in app.client.workspace.catalog
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cancel", ["enter", "n", "escape"])
+async def test_delete_defaults_to_no_and_cancellation_never_sends(cancel):
+ app = make_app()
+ async with app.run_test() as pilot:
+ await select_target(app, pilot)
+ await pilot.press("d")
+ assert isinstance(app.screen, DeleteDialog)
+ assert app.screen.sid == "demo-tests"
+ assert app.screen.query_one(OptionList).highlighted == 0
+ await pilot.press(cancel)
+ assert len(app.screen_stack) == 1
+ assert not sent(app.client, "delete_session")
+ assert not sent(app.client, "archive_session")
+ assert app.focused is app.query_one(SessionTree)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("keys", [("d", "y"), ("d", "j", "enter"), ("D",)])
+async def test_confirmed_and_direct_delete_only_target_highlight(keys):
+ app = make_app()
+ async with app.run_test() as pilot:
+ await select_target(app, pilot)
+ await pilot.press(*keys)
+ await confirm_archive(app.client, pilot)
+ commands = sent(app.client, "delete_session")
+ assert len(commands) == 1
+ command = commands[0]
+ assert (command["session_id"], command["engine"], command["space"]) == (
+ "demo-tests", "codex", "code",
+ )
+ assert len(app.screen_stack) == 1
+ assert app.client.attached_sid == "demo-review"
+ assert "demo-tests" in app.client.workspace.catalog
+ app.client._handle(dict(
+ type="error", code="busy", message="Session is running",
+ request_id=command["cmd_id"], to=app.client.client_id,
+ ))
+ assert "demo-tests" in app.client.workspace.catalog
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("change", ["remove", "space", "cursor"])
+async def test_delete_dialog_pins_identity_and_revalidates_scope(change):
+ app = make_app()
+ async with app.run_test() as pilot:
+ await select_target(app, pilot)
+ await pilot.press("d")
+ if change == "remove":
+ del app.client.workspace.catalog["demo-tests"]
+ elif change == "space":
+ app.client.workspace.catalog["demo-tests"]["space"] = "work"
+ else:
+ tree = app.query_one(SessionTree)
+ tree.move_cursor(tree.root.children[0].children[0])
+ await pilot.press("y")
+ await pilot.pause()
+ commands = sent(app.client, "delete_session")
+ if change == "cursor":
+ await confirm_archive(app.client, pilot)
+ commands = sent(app.client, "delete_session")
+ assert len(commands) == 1
+ assert commands[0]["session_id"] == "demo-tests"
+ else:
+ assert not commands
+ assert not sent(app.client, "archive_session")
+
+
+@pytest.mark.asyncio
+async def test_folder_and_search_text_never_delete_sessions():
+ app = make_app()
+ async with app.run_test() as pilot:
+ await pilot.press("space", "e", "g", "g", "d", "D", "r")
+ assert len(app.screen_stack) == 1
+ await pilot.press("slash", "d", "D", "r")
+ assert app.query_one(TreeSearch).value == "dDr"
+ assert not sent(app.client, "delete_session")
+ assert not sent(app.client, "rename_session")
+
+
+@pytest.mark.asyncio
+async def test_tree_mutations_and_confirmation_are_rebindable():
+ app = make_app({
+ "tree": {"rename": ["R"], "delete": ["x"], "delete_direct": ["X"]},
+ "confirmation": {"yes": ["v"], "close": ["b"]},
+ })
+ async with app.run_test() as pilot:
+ await select_target(app, pilot)
+ await pilot.press("r", "d", "D")
+ assert len(app.screen_stack) == 1
+ assert not sent(app.client, "delete_session")
+ await pilot.press("R")
+ assert isinstance(app.screen, RenameDialog)
+ await pilot.press("escape", "x", "y", "n", "escape")
+ assert isinstance(app.screen, DeleteDialog)
+ assert not sent(app.client, "delete_session")
+ await pilot.press("b", "x", "v")
+ await confirm_archive(app.client, pilot)
+ assert len(sent(app.client, "delete_session")) == 1
+
+
+@pytest.mark.asyncio
+async def test_rename_result_refreshes_visible_tree_without_focus_change():
+ app = make_app()
+ client = app.client
+ client.session_catalog_requests[client.scope] = "initial"
+ async with app.run_test() as pilot:
+ await select_target(app, pilot)
+ await pilot.press("r")
+ app.screen.query_one(TextArea).load_text("New session title")
+ await pilot.press("enter")
+ await pilot.pause()
+ command = sent(client, "rename_session")[0]
+ rows = [dict(row) for row in client.visible_catalog().values()]
+ for row in rows:
+ if row["session_id"] == "demo-tests":
+ row["summary"] = "New session title"
+ listing = dict(type="session_list", engine="codex", space="code",
+ sessions=rows, request_id=command["cmd_id"])
+ client._handle(listing)
+ client._handle(dict(type="command_ack", client_id=client.client_id,
+ to=client.client_id, cmd_id=command["cmd_id"]))
+ await client._flush_history_refreshes()
+ client._handle(dict(listing, request_id=
+ client.session_catalog_requests[client.scope]))
+ app.paint()
+ tree = app.query_one(SessionTree)
+ target = next(n for f in tree.root.children for n in f.children
+ if n.data[1] == "demo-tests")
+ assert target.label.plain == "New session title"
+ assert client.attached_sid == "demo-review"
diff --git a/tests/test_tui_upstream_controls.py b/tests/test_tui_upstream_controls.py
new file mode 100644
index 00000000..0fbdcb05
--- /dev/null
+++ b/tests/test_tui_upstream_controls.py
@@ -0,0 +1,85 @@
+"""Upstream controls retain the TUI's scoped, protocol-backed action path."""
+
+import json
+
+import pytest
+
+from cc_remote import protocol as p
+from cc_remote.tui_actions import build_action, defaults, is_read
+from cc_remote.tui_app import WorkspaceClient
+from cc_remote.tui_panels import PANEL_ACTIONS
+from cc_remote.tui_presentation import SessionPresentation
+
+
+@pytest.mark.parametrize("name,payload,readonly", [
+ ("browse_files", {"path": ".", "offset": 100}, True),
+ ("get_turn_file_changes", {
+ "engine": "codex", "turn_id": "turn", "revision": "r",
+ }, True),
+ ("set_codex_context", {"max_context_tokens": 900000}, False),
+])
+def test_new_actions_use_native_schema_and_pinned_session(
+ name, payload, readonly,
+):
+ action = build_action(name, json.dumps(payload), "s", "client")
+ assert p.deserialize(p.serialize(action)) == action
+ assert action.sid == "s" and action.client_id == "client"
+ assert is_read(name) == readonly
+ if name == "browse_files":
+ assert action.request_id == action.cmd_id
+ with pytest.raises(ValueError, match="Select a target session"):
+ build_action(name, json.dumps(payload), None, "client")
+ with pytest.raises(ValueError, match="routing fields"):
+ build_action(name, json.dumps({**payload, "sid": "other"}),
+ "s", "client")
+
+
+def test_context_settings_preserve_pending_and_applied_values():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ client._handle(json.loads(p.serialize(p.CodexContext(
+ sid="s", max_context_tokens=900000, applied_max_context_tokens=200000,
+ pending=True, error="Reload pending",
+ ))))
+ presentation = client.workspace.view("s").presentation
+ current = presentation.settings["codex_context"]
+ assert current["pending"] and current["error"] == "Reload pending"
+ assert current["applied_max_context_tokens"] == 200000
+ values = defaults("set_codex_context", "s", "codex", {}, presentation)
+ assert values == {"max_context_tokens": 900000}
+ assert "900000" in presentation.panel("Usage / Context")
+ assert "Reload pending" in presentation.panel("Settings")
+ assert "set_codex_context" in PANEL_ACTIONS["Usage / Context"]
+ assert "set_codex_context" in PANEL_ACTIONS["Settings"]
+ assert not client._outbox
+
+
+def test_live_diff_defaults_do_not_select_incomplete_archive_identity():
+ values = defaults("get_diff", "s", "codex", {}, SessionPresentation())
+ values["file"] = "file.py"
+ live = build_action("get_diff", json.dumps(values), "s", "client")
+ assert live.engine is live.turn_id is live.revision is None
+ values.update(engine="codex", turn_id="turn", revision="r")
+ archive = build_action("get_diff", json.dumps(values), "s", "client")
+ assert archive.turn_id == "turn" and archive.revision == "r"
+
+
+@pytest.mark.parametrize("frame", [
+ p.FilesListed(sid="s", request_id="request", path="/project", entries=[]),
+ p.TurnFileChanges(sid="s", turn_id="turn", changes=p.TurnChangeSummary(
+ revision="r", files=[], total_files=0,
+ )),
+ p.TurnFileChangesPage(sid="s", engine="codex", turn_id="turn",
+ revision="r", offset=0, files=[], total_files=0),
+])
+def test_file_responses_are_visible_only_in_the_target_session(frame):
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ event = json.loads(p.serialize(frame))
+ client._handle({**event, "to": "another-client"})
+ assert not client.workspace.view("s").presentation.reports
+ client._handle({**event, "to": client.client_id})
+ reports = client.workspace.view("s").presentation.reports
+ assert reports[frame.type]["sid"] == "s"
+ assert not client.workspace.view("other").presentation.reports
+ assert not client.workspace.reports
+ assert not client._outbox
+ assert {"browse_files", "get_turn_file_changes"} <= PANEL_ACTIONS["Reports"]
diff --git a/tests/test_tui_viewport_follow.py b/tests/test_tui_viewport_follow.py
new file mode 100644
index 00000000..1d7f5bb7
--- /dev/null
+++ b/tests/test_tui_viewport_follow.py
@@ -0,0 +1,71 @@
+"""Only the viewport's pre-update bottom position controls auto-follow."""
+
+import pytest
+from textual.widgets.text_area import Selection
+
+from cc_remote.tui_app import Composer, Transcript, WorkspaceApp
+from cc_remote.tui_state import Block
+from tests.test_tui_send_jumps import client
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("focus", ["reader", "draft"])
+@pytest.mark.parametrize("bottom", [False, True])
+async def test_follow_depends_only_on_viewport_not_focus_or_old_flag(focus, bottom):
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ editor = app.query_one(Composer)
+ reader.move_cursor((20, 1))
+ (editor if focus == "draft" else reader).focus()
+ await pilot.pause()
+ reader.scroll_to(y=reader.max_scroll_y if bottom else 10,
+ animate=False, immediate=True)
+ view.follow = not bottom # A stale flag must never override position.
+ top = reader.scroll_y
+ for number in range(3):
+ view.put(Block(f"new-{number}", "assistant", "new output\n" * 30))
+ app.paint()
+ await pilot.pause()
+ assert view.follow is bottom
+ assert reader.scroll_y == (reader.max_scroll_y if bottom else top)
+ assert app.focused is (editor if focus == "draft" else reader)
+
+
+@pytest.mark.asyncio
+async def test_bottom_selection_follows_without_moving_selection():
+ c = client()
+ view = c.workspace.view("s")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ reader.selection = Selection((20, 0), (20, 4))
+ reader.set_mode("VISUAL")
+ selection = reader.selection
+ selected = reader.selected_text
+ reader.scroll_end(animate=False, immediate=True)
+ view.put(Block("new", "assistant", "output\n" * 40))
+ app.paint()
+ await pilot.pause()
+ assert view.follow and reader.scroll_y == reader.max_scroll_y
+ assert reader.selection == selection and reader.selected_text == selected
+
+
+@pytest.mark.asyncio
+async def test_one_row_above_bottom_is_not_bottom():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ reader = app.query_one(Transcript)
+ reader.scroll_to(y=reader.max_scroll_y - 1,
+ animate=False, immediate=True)
+ top = reader.scroll_y
+ c.workspace.view("s").put(Block("new", "assistant", "output\n" * 40))
+ app.paint()
+ await pilot.pause()
+ assert not c.workspace.view("s").follow
+ assert reader.scroll_y == top
diff --git a/tests/test_tui_vim_shared.py b/tests/test_tui_vim_shared.py
new file mode 100644
index 00000000..74a8d9b6
--- /dev/null
+++ b/tests/test_tui_vim_shared.py
@@ -0,0 +1,211 @@
+"""The same Vim command must work in chat, reports, forms and search."""
+
+import pytest
+from textual.keys import _character_to_key
+
+from cc_remote.tui_app import WorkspaceApp, WorkspaceClient, Transcript
+from cc_remote.tui_widgets import Composer
+from cc_remote.tui_modal import ModalEditor
+from cc_remote.tui_panels import (
+ ActionForm,
+ ActionPicker,
+ PanelReader,
+ DetailPanel,
+)
+
+
+def make_app():
+ client = WorkspaceClient("ws://localhost:8766/ws", "", "", "codex", "s")
+ client.workspace.view("s").write_state = "writable"
+ return WorkspaceApp(client, connect=False)
+
+
+async def open_surface(app, surface):
+ if surface == "chat":
+ widget = app.query_one(Transcript)
+ elif surface == "draft":
+ widget = app.query_one(Composer)
+ elif surface == "report":
+ await app.push_screen(DetailPanel(app.client, "s", "Reports"))
+ widget = app.screen.query_one(PanelReader)
+ elif surface == "form":
+ await app.push_screen(ActionForm(app.client, "s", "set_goal"))
+ widget = app.screen.query_one(ModalEditor)
+ else:
+ await app.push_screen(ActionPicker(app.client, "s"))
+ widget = app.screen.query_one(ModalEditor)
+ widget.focus()
+ return widget
+
+
+CASES = [
+ ("call(one two)", 7, "yi(", "one two"),
+ ("one two three", 5, "yaw", "two "),
+ ("one two three", 5, "yiw", "two"),
+ ("foo.bar baz", 2, "yiW", "foo.bar"),
+ ("(outer(inner)tail)", 9, "2yi(", "outer(inner)tail"),
+ ('say "你好 world"', 7, 'ya"', '"你好 world"'),
+ ("one two three four", 0, "y2aw", "one two "),
+ ("one two three four", 0, "2y2w", "one two three four"),
+ ("one two three", 0, "ye", "one"),
+ ("one:two:end", 0, "yf:", "one:"),
+ ("one:two:end", 0, "yt:", "one"),
+ ("a\nb\nc\nd", 0, "2yy", "a\nb\n"),
+ ("(hello [world])", 0, "y%", "(hello [world])"),
+ ("one two\nthree\n\nfour", 5, "yip", "one two\nthree"),
+ ("call(one two)", 7, "vi(y", "one two"),
+ ("abcd", 0, "vy", "a"),
+ ("abcd", 3, "vy", "d"),
+ ("abcd", 1, "vy", "b"),
+ ("abcd", 0, "vlly", "abc"),
+ ("abcd", 2, "vhhy", "abc"),
+ ("abcd", 1, "vlhhy", "ab"),
+ ("中文字符", 0, "vlly", "中文字"),
+ ("one two", 0, "vey", "one"),
+ ("abc\ndef", 1, "vjy", "bc\nde"),
+ ("abcd", 0, "v$y", "abcd"),
+]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "surface", ["chat", "draft", "report", "form", "search"]
+)
+async def test_identical_vim_objects_counts_and_motions(surface):
+ app = make_app()
+ async with app.run_test() as pilot:
+ widget = await open_surface(app, surface)
+ for text, cursor, keys, expected in CASES:
+ widget.load_text(text)
+ widget.move_cursor(widget.document.get_location_from_index(cursor))
+ widget.set_mode("NORMAL")
+ app.copy_to_clipboard("")
+ await pilot.press(*map(_character_to_key, keys))
+ assert app.clipboard == expected, (surface, keys, app.clipboard)
+ assert widget.text == text
+ assert not widget.prefix
+ assert widget.vim_mode == "NORMAL", (surface, keys)
+ assert not app.client._outbox
+ assert widget.cursor_location == (
+ widget.document.get_location_from_index(text.index(expected))
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("surface", ["chat", "report"])
+async def test_read_only_objects_never_delete_insert_or_undo(surface):
+ app = make_app()
+ async with app.run_test() as pilot:
+ widget = await open_surface(app, surface)
+ text = "call(one two)"
+ widget.load_text(text)
+ widget.move_cursor((0, 7))
+ for command in ["di(", "ciw", "2dd", "x", "p", "u", "A", "O"]:
+ await pilot.press(*map(_character_to_key, command))
+ assert widget.text == text
+ assert widget.read_only
+ assert widget.vim_mode != "INSERT"
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_pending_commands_cancel_and_do_not_cross_panes_or_panels():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ reader.load_text("one two")
+ reader.move_cursor((0, 0))
+ await pilot.press("y", "i", "escape")
+ assert not reader.prefix and not app.clipboard
+ await pilot.press("y", "ctrl+j")
+ assert not reader.prefix
+ editor = app.query_one(Composer)
+ editor.load_text("draft words")
+ await pilot.press("y", "ctrl+k", "w")
+ assert not editor.prefix
+ assert reader.cursor_location == (0, 4)
+ assert not app.clipboard
+ await pilot.press("space", "g")
+ before = dict(app.client._outbox)
+ panel = app.screen
+ widget = panel.query_one(PanelReader)
+ widget.load_text("one two")
+ widget.move_cursor((0, 0))
+ await pilot.press("y", "a", "w")
+ assert app.screen is panel and app.clipboard == "one "
+ assert dict(app.client._outbox) == before
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("surface", ["draft", "form"])
+async def test_counted_change_and_delete_share_undo(surface):
+ # Search opens directly in Insert and Esc dismisses it; editable forms
+ # and drafts retain the full Vim change/undo interaction.
+ app = make_app()
+ async with app.run_test() as pilot:
+ widget = await open_surface(app, surface)
+ widget.load_text("one two three four")
+ widget.move_cursor((0, 0))
+ await pilot.press("c", "2", "w", "X", "escape")
+ assert widget.text == "X three four"
+ await pilot.press("u")
+ assert widget.text == "one two three four"
+ widget.move_cursor((0, 0))
+ await pilot.press("d", "2", "a", "w")
+ assert widget.text == "three four"
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_find_repeat_word_ends_and_counted_motion_in_chat():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ reader.load_text("one:two:end\nnext line")
+ reader.move_cursor((0, 0))
+ await pilot.press("f", "colon", "semicolon")
+ assert reader.cursor_location == (0, 7)
+ await pilot.press("comma")
+ assert reader.cursor_location == (0, 3)
+ await pilot.press("0", "2", "w")
+ assert reader.cursor_location == (0, 4)
+ await pilot.press("e")
+ assert reader.cursor_location == (0, 6)
+ await pilot.press("2", "g", "g")
+ assert reader.cursor_location == (1, 0)
+
+
+@pytest.mark.asyncio
+async def test_yank_in_chat_replaces_register_and_lands_at_object_start():
+ app = make_app()
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ editor = app.query_one(Composer)
+ editor.load_text("stale")
+ await pilot.press("ctrl+j", "d", "i", "w", "ctrl+k")
+ reader.load_text("call(fresh)")
+ reader.move_cursor((0, 7))
+ await pilot.press("y", "i", "left_parenthesis", "ctrl+j", "p")
+ assert editor.text == "fresh"
+ assert reader.cursor_location == (0, 5)
+ assert not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_multiline_change_undo_redo_and_final_lines_delete():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = app.query_one(Composer)
+ editor.load_text("a\nb\nc")
+ editor.move_cursor((1, 0))
+ await pilot.press("ctrl+j", "2", "c", "c", "X", "enter", "Y", "escape")
+ assert editor.text == "a\nX\nY"
+ await pilot.press("u")
+ assert editor.text == "a\nb\nc"
+ await pilot.press("ctrl+r")
+ assert editor.text == "a\nX\nY"
+ editor.move_cursor((1, 0))
+ await pilot.press("2", "d", "d")
+ assert editor.text == "a"
+ await pilot.press("u")
+ assert editor.text == "a\nX\nY"
diff --git a/tests/test_tui_visual_caret.py b/tests/test_tui_visual_caret.py
new file mode 100644
index 00000000..2c0ee632
--- /dev/null
+++ b/tests/test_tui_visual_caret.py
@@ -0,0 +1,85 @@
+"""Visual's painted caret must be inside the exact inclusive yank range."""
+
+import pytest
+
+from tests.test_tui_vim_shared import make_app, open_surface
+
+
+def painted_cursor(widget):
+ style = widget._theme.cursor_style
+ return "".join(
+ segment.text
+ for y in range(widget.scrollable_content_region.height)
+ for segment in widget.render_line(y)
+ if segment.style
+ and segment.style.color == style.color
+ and segment.style.bgcolor == style.bgcolor
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "surface", ["chat", "draft", "report", "form", "search"]
+)
+@pytest.mark.parametrize(
+ "text,start,moves,caret,copied",
+ [
+ ("abcd", (0, 0), (), "a", "a"),
+ ("abcd", (0, 0), ("l",), "b", "ab"),
+ ("abc", (0, 0), ("l", "l"), "c", "abc"),
+ ("abcd", (0, 2), ("h", "h"), "a", "abc"),
+ ("中文字符", (0, 0), ("l", "l"), "字", "中文字"),
+ ("abc\ndef", (0, 0), ("j", "l"), "e", "abc\nde"),
+ ],
+)
+async def test_visible_character_is_also_copied(
+ surface, text, start, moves, caret, copied
+):
+ app = make_app()
+ async with app.run_test(size=(80, 30)) as pilot:
+ widget = await open_surface(app, surface)
+ widget.load_text(text)
+ widget.set_mode("NORMAL")
+ widget.move_cursor(start)
+ await pilot.press("v", *moves)
+ await pilot.pause()
+ assert painted_cursor(widget) == caret
+ assert widget._cursor_offset == (
+ widget.wrapped_document.location_to_offset(widget.visual_cursor)
+ )
+ await pilot.press("y")
+ assert app.clipboard == copied
+
+
+@pytest.mark.asyncio
+async def test_wrapped_visual_endpoint_does_not_draw_on_next_line():
+ app = make_app()
+ async with app.run_test(size=(35, 20)) as pilot:
+ widget = await open_surface(app, "draft")
+ widget.load_text("a" * 200)
+ width = widget.wrap_width
+ widget.move_cursor((0, width - 2))
+ await pilot.press("v", "l")
+ await pilot.pause()
+ point = widget.wrapped_document.location_to_offset(widget.visual_cursor)
+ assert widget._cursor_offset == point
+ assert point.y == 0
+ assert painted_cursor(widget) == "a"
+ await pilot.press("y")
+ assert app.clipboard == "aa"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("surface", ["draft", "form"])
+async def test_visual_delete_clamps_caret_before_document_shrinks(surface):
+ app = make_app()
+ async with app.run_test() as pilot:
+ widget = await open_surface(app, surface)
+ widget.load_text("abc\ndef\nghi")
+ widget.set_mode("NORMAL")
+ widget.move_cursor((0, 0))
+ await pilot.press("v", "j", "j", "l", "l", "d")
+ await pilot.pause()
+ assert widget.text == ""
+ assert widget.vim_mode == "NORMAL"
+ assert widget._cursor_offset == (0, 0)
diff --git a/tests/test_tui_workspace.py b/tests/test_tui_workspace.py
new file mode 100644
index 00000000..e15e8c2c
--- /dev/null
+++ b/tests/test_tui_workspace.py
@@ -0,0 +1,844 @@
+"""Model-free terminal interaction and control-link regressions."""
+
+import asyncio
+import json
+
+import pytest
+from textual.widgets import OptionList, Static
+from textual.widgets.text_area import Selection
+from websockets.asyncio.server import serve
+
+from cc_remote.protocol import PROTOCOL_VERSION
+from cc_remote.tui_app import (
+ Composer,
+ Transcript,
+ WorkspaceApp,
+ WorkspaceClient,
+ location,
+ offset,
+ seed_demo,
+)
+from cc_remote.tui_state import Block, MAX_BLOCKS, SessionView, WorkspaceState
+
+
+def client(sid="s"):
+ c = WorkspaceClient("ws://localhost:8765/ws", "", "", "codex", sid)
+ if sid:
+ c.workspace.view(sid).write_state = "writable"
+ return c
+
+
+def emit(c, kind, **values):
+ c._handle({"type": kind, "v": PROTOCOL_VERSION, "sid": "s", **values})
+
+
+def test_workspace_reuses_replay_dedup_before_projection():
+ c = client()
+ emit(c, "delta", seq=1, message_id="a", text="once")
+ emit(c, "delta", seq=1, message_id="a", text="once")
+ assert c.workspace.view("s").blocks[0].text == "once"
+
+
+def test_background_events_do_not_steal_focus():
+ c = client()
+ emit(c, "user_msg", sid="other", msg_id="m", prompt="background")
+ emit(c, "session_focus", session_id="other")
+ assert c.attached_sid == "s"
+ assert c.workspace.view("other").blocks[0].text == "background"
+ assert not c.workspace.view("s").blocks
+
+
+def test_background_error_is_projected_without_replacing_focused_notice():
+ c = client()
+ c.notice = "Focused task"
+ emit(c, "error", sid="other", code="busy", message="Background failure")
+ assert c.notice == "Focused task"
+ assert "Background failure" in c.workspace.view("other").render()[0]
+ emit(c, "error", code="busy", message="Focused failure")
+ assert "Focused failure" in c.notice
+ emit(c, "error", sid=None, code="offline", message="Connection failure")
+ assert "Connection failure" in c.notice
+
+
+def test_protocol_mismatch_is_terminal_not_a_refresh_loop():
+ c = client()
+ c._handle({"type": "snapshot", "v": PROTOCOL_VERSION - 1, "sid": "s"})
+ assert c._quitting and c.protocol_error
+ assert "Protocol mismatch" in c.notice
+ assert not c.workspace.view("s").blocks
+
+
+def test_control_revision_and_snapshot_are_authoritative():
+ c = client()
+ emit(
+ c,
+ "snapshot",
+ state="running",
+ control={"generation": "g", "revision": 4, "write_state": "read_only"},
+ )
+ emit(
+ c, "session_control", generation="g", revision=3, write_state="writable"
+ )
+ view = c.workspace.view("s")
+ assert view.state == "running"
+ assert view.write_state == "read_only"
+
+
+def test_history_summary_merges_newer_live_text_without_duplicates():
+ view = SessionView()
+ view.event(
+ {
+ "type": "delta",
+ "message_id": "a",
+ "turn_id": "m",
+ "seq": 8,
+ "text": "new answer",
+ }
+ )
+ view.history(
+ {
+ "revision": "r",
+ "live_seq": 5,
+ "turns": [
+ {
+ "id": "m",
+ "prompt": "question",
+ "blocks": [
+ {
+ "kind": "text",
+ "message_id": "a",
+ "text": "old answer",
+ }
+ ],
+ }
+ ],
+ }
+ )
+ assert [b.id for b in view.blocks] == ["user:m", "a"]
+ assert view.blocks[-1].text == "new answer"
+
+
+def test_newest_history_does_not_move_older_live_turn_after_latest():
+ view = SessionView()
+ view.event(
+ {"type": "user_msg", "msg_id": "old", "seq": 2, "prompt": "older"}
+ )
+ view.history(
+ {
+ "revision": "r",
+ "live_seq": 8,
+ "turns": [{"id": "new", "prompt": "latest"}],
+ }
+ )
+ assert [b.text for b in view.blocks] == ["older", "latest"]
+
+
+def test_unchanged_summary_keeps_an_expanded_detail_page():
+ view = SessionView(revision="r", details={"m": None})
+ view.put(Block("detail:m", "detail", "expanded output", "m"))
+ view.history(
+ {"revision": "r", "turns": [{"id": "m", "detailEventCount": 10}]}
+ )
+ assert view.blocks[0].text == "expanded output"
+
+
+def test_history_error_and_stale_page_cannot_erase_projection():
+ view = SessionView()
+ view.history(
+ {
+ "revision": "r",
+ "generation": "g",
+ "build_seq": 4,
+ "turns": [{"id": "m", "prompt": "keep"}],
+ }
+ )
+ for change in (
+ {"error": "unavailable"},
+ {"authoritative": False},
+ {"build_seq": 3},
+ {"before": "m", "revision": "old"},
+ ):
+ view.history(
+ {
+ "revision": "r",
+ "generation": "g",
+ "build_seq": 4,
+ "turns": [],
+ **change,
+ }
+ )
+ assert view.blocks[0].text == "keep"
+
+
+def test_client_message_alias_deduplicates_history_and_keeps_anchor():
+ view = SessionView()
+ view.event(
+ {"type": "user_msg", "msg_id": "client", "seq": 8, "prompt": "question"}
+ )
+ text, starts = view.render()
+ anchor = view.locate(text.index("question"), starts)
+ view.history(
+ {
+ "revision": "r",
+ "live_seq": 5,
+ "turns": [
+ {"id": "native", "clientMsgId": "client", "prompt": "question"}
+ ],
+ }
+ )
+ text, starts = view.render()
+ assert text.count("question") == 1
+ assert text[view.resolve(anchor, starts, len(text)) :].startswith(
+ "question"
+ )
+
+
+def test_paging_keeps_stable_anchor_and_oldest_cursor():
+ view = SessionView()
+ view.history(
+ {
+ "revision": "r",
+ "oldest_id": "b",
+ "has_more": True,
+ "turns": [{"id": "b", "prompt": "newer"}],
+ }
+ )
+ text, starts = view.render()
+ anchor = view.locate(text.index("newer"), starts)
+ view.history(
+ {
+ "revision": "r",
+ "before": "b",
+ "oldest_id": "a",
+ "has_more": False,
+ "turns": [{"id": "a", "prompt": "older"}],
+ }
+ )
+ text, starts = view.render()
+ assert text[view.resolve(anchor, starts, len(text)) :].startswith("newer")
+ assert view.oldest == "a" and not view.has_more
+
+
+def test_reset_removes_rolled_back_turns_but_not_draft():
+ view = SessionView(draft="do not lose")
+ view.put(Block("old", "assistant", "removed"))
+ view.history({"reset": True, "revision": "new", "turns": []})
+ assert not view.blocks
+ assert view.draft == "do not lose"
+
+
+def test_output_controls_are_not_interpreted_and_size_is_bounded():
+ view = SessionView()
+ view.put(Block("a", "assistant", "\x1b]52;c;secret\x07[bold]hello\u202e"))
+ text, _ = view.render()
+ assert "\x1b" not in text and "\u202e" not in text
+ assert "[bold]" in text
+ view.put(Block("large", "assistant", "x" * 100000))
+ assert len(view.blocks[-1].text) == 65536
+
+
+def test_rekey_preserves_session_draft():
+ state = WorkspaceState()
+ state.view("tmp").draft = "draft"
+ state.event(
+ {"type": "session_rekey", "old_key": "tmp", "session_id": "real"}
+ )
+ assert state.view("real").draft == "draft"
+ assert "tmp" not in state.views
+
+
+@pytest.mark.asyncio
+async def test_demo_never_sends_or_authenticates():
+ c = client()
+ seed_demo(c)
+ assert not await c.submit("do not send")
+ assert not await c.submit("do not queue", queue=True)
+ assert not c._outbox
+ assert c.cookie == "" and c.ws is None
+
+
+def test_bounded_live_tail_never_evicts_a_reading_selection():
+ view = SessionView(follow=False, anchor=("keep", 0), selection=("keep", 0))
+ view.put(Block("keep", "user", "selected text"))
+ for index in range(MAX_BLOCKS * 2):
+ view.put(Block(str(index), "tool", "output"))
+ assert view.blocks[0].id == "keep"
+ assert len(view.blocks) == MAX_BLOCKS
+ assert view.tail_hidden
+
+
+@pytest.mark.asyncio
+async def test_queue_ownership_and_reliable_retry_identity():
+ c = client()
+ assert await c.submit("deferred", queue=True)
+ assert len(c._outbox) == 1
+ raw, _ = next(iter(c._outbox.values()))
+ frame = json.loads(raw)
+ assert frame["delivery"] == "queue" and frame["sid"] == "s"
+ assert not c.workspace.view("s").blocks
+ sent = []
+
+ async def send(raw):
+ sent.append(raw)
+ return True
+
+ c._send_raw = send
+ await c._flush_outbox()
+ await c._flush_outbox()
+ assert sent == [raw, raw]
+ c._handle(
+ {
+ "type": "command_ack",
+ "cmd_id": frame["cmd_id"],
+ "client_id": c.client_id,
+ }
+ )
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_read_only_cannot_send_or_queue():
+ c = client()
+ c.workspace.view("s").write_state = "read_only"
+ assert not await c.submit("no")
+ assert not await c.submit("no", queue=True)
+ assert not c._outbox
+
+
+@pytest.mark.asyncio
+async def test_question_options_and_free_text_are_exactly_scoped():
+ c = client()
+ emit(
+ c,
+ "ask_user",
+ ask_id="ask",
+ question="why?",
+ allow_text=True,
+ options=[{"label": "Yes"}, {"label": "No"}],
+ )
+ assert await c.answer("2")
+ frame = json.loads(next(iter(c._outbox.values()))[0])
+ assert (frame["sid"], frame["ask_id"], frame["answer"]) == (
+ "s",
+ "ask",
+ "No",
+ )
+ c._outbox.clear()
+ emit(
+ c,
+ "ask_user",
+ ask_id="text",
+ question="why?",
+ allow_text=True,
+ options=[],
+ )
+ assert await c.answer("explanation")
+ assert (
+ json.loads(next(iter(c._outbox.values()))[0])["answer"] == "explanation"
+ )
+
+
+@pytest.mark.asyncio
+async def test_all_blocking_questions_remain_answerable_after_first_reply():
+ from cc_remote.tui_modal import ModalEditor
+ from cc_remote.tui_panels import QuestionDialog
+
+ c = client()
+ for identity in ("first", "second"):
+ emit(c, "ask_user", ask_id=identity, question=identity,
+ allow_text=True, options=[])
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ for identity in ("first", "second"):
+ await pilot.press("ctrl+t")
+ assert isinstance(app.screen, QuestionDialog)
+ assert app.screen.ask["ask_id"] == identity
+ app.screen.query_one("#answer", ModalEditor).load_text("answer")
+ await pilot.press("enter")
+ await pilot.pause()
+ sent = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert [f["ask_id"] for f in sent] == ["first", "second"]
+ assert c._pending_ask_for_attached() is None
+
+
+@pytest.mark.asyncio
+async def test_normal_insert_and_quote_keep_independent_cursors():
+ c = client()
+ emit(
+ c, "user_msg", msg_id="m", prompt="first line\nsecond line\nthird line"
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(110, 35)) as pilot:
+ await pilot.press("g", "u", "j", "v", "l", "l", "l")
+ reader = app.query_one(Transcript)
+ end = reader.cursor_location
+ selected = reader.selected_text
+ assert selected
+ await pilot.press("space", "q")
+ assert reader.cursor_location == end
+ assert app.focused is reader and app.mode == "NORMAL"
+ assert "> " + selected in app.query_one(Composer).text
+ await pilot.press("ctrl+j", "i", "w", "h", "y", "escape", "ctrl+k")
+ assert reader.cursor_location == end
+ assert "why" in app.query_one(Composer).text
+ assert not c._outbox # Quotes are not commands.
+
+
+@pytest.mark.asyncio
+async def test_stream_and_resize_do_not_move_visual_selection():
+ c = client()
+ emit(c, "user_msg", msg_id="m", prompt="汉字 code\n" * 30)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(110, 35)) as pilot:
+ await pilot.press("g", "u", "j", "v", "l", "l")
+ reader = app.query_one(Transcript)
+ selection = reader.selection
+ chosen = reader.selected_text
+ emit(c, "delta", seq=2, message_id="a", text="streaming\n" * 20)
+ await pilot.pause()
+ await pilot.resize_terminal(70, 25)
+ await pilot.pause()
+ assert reader.selection == selection
+ assert reader.selected_text == chosen
+ assert app.mode == "VISUAL"
+
+
+@pytest.mark.asyncio
+async def test_switch_restores_reading_position_and_each_draft():
+ c = client()
+ emit(c, "user_msg", msg_id="m", prompt="one\ntwo\nthree")
+ emit(c, "user_msg", sid="b", msg_id="n", prompt="another")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press(
+ "g", "u", "j", "ctrl+j", "i", "d", "r", "a", "f", "t", "escape", "ctrl+k"
+ )
+ reader = app.query_one(Transcript)
+ position = reader.cursor_location
+ c.attached_sid = "b"
+ app.paint()
+ await pilot.pause()
+ assert app.query_one(Composer).text == ""
+ await pilot.press("ctrl+j", "i", "b", "escape", "ctrl+k")
+ c.attached_sid = "s"
+ app.paint()
+ await pilot.pause()
+ assert app.query_one(Composer).text == "draft"
+ assert reader.cursor_location == position
+ assert c.workspace.view("b").draft == "b"
+
+
+@pytest.mark.asyncio
+async def test_copy_does_not_focus_composer_and_latest_message_jumps():
+ c = client()
+ emit(c, "user_msg", msg_id="m", prompt="question")
+ emit(c, "delta", message_id="a", text="answer", channel="final")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("g", "a", "j", "v", "l", "l", "y")
+ reader = app.query_one(Transcript)
+ assert app.focused is reader and app.mode == "NORMAL"
+ assert app.clipboard == "ans"
+ await pilot.press("g", "u")
+ assert reader.cursor_location == (0, 0)
+ await pilot.press("right_square_bracket", "m")
+ assert reader.cursor_location[0] > 0
+
+
+@pytest.mark.asyncio
+async def test_ui_queue_keeps_editor_if_send_rejected_then_submits_once():
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "a")
+ c.workspace.view("s").write_state = "read_only"
+ await pilot.press("ctrl+e")
+ assert app.query_one(Composer).text == "a"
+ c.workspace.view("s").write_state = "writable"
+ await pilot.press("ctrl+e", "ctrl+e")
+ assert app.query_one(Composer).text == ""
+ assert len(c._outbox) == 1
+
+
+@pytest.mark.asyncio
+async def test_tool_expansion_and_command_escape_preserve_draft():
+ c = client()
+ emit(c, "tool_use", tool_use_id="t", tool="shell", input={"cmd": "pwd"})
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ reader = app.query_one(Transcript)
+ start = next(i for i, b in app.starts if b.role == "tool_group")
+ reader.move_cursor(location(reader.text, start))
+ await pilot.press("enter")
+ app.paint()
+ await pilot.pause()
+ assert "pwd" in app.query_one(Transcript).text
+ await pilot.press(
+ "ctrl+j",
+ "i",
+ "d",
+ "escape",
+ "ctrl+k",
+ "colon",
+ "s",
+ "t",
+ "o",
+ "p",
+ "escape",
+ "escape",
+ )
+ assert app.query_one(Composer).text == "d"
+ assert not c._outbox
+
+
+def test_unicode_location_roundtrip():
+ text = "hello\n汉字🙂\n"
+ for index in range(len(text) + 1):
+ assert offset(text, location(text, index)) == index
+
+
+def test_rollback_barrier_rejects_late_history_without_waiting_for_reset():
+ state = WorkspaceState()
+ state.event(
+ {
+ "type": "history",
+ "session_id": "s",
+ "revision": "old",
+ "turns": [{"id": "m", "prompt": "removed"}],
+ }
+ )
+ state.event(
+ {"type": "history_invalidated", "session_id": "s", "revision": "new"}
+ )
+ state.event(
+ {
+ "type": "history",
+ "session_id": "s",
+ "revision": "old",
+ "turns": [{"id": "m", "prompt": "removed"}],
+ }
+ )
+ assert not state.view("s").blocks
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("machine", ["test-device", "default"])
+async def test_real_websocket_reuses_device_route_and_summary_protocol(machine):
+ received = []
+ complete = asyncio.Event()
+
+ async def server(ws):
+ assert ws.request.path == f"/ws?machine={machine}"
+ async for raw in ws:
+ frame = json.loads(raw)
+ received.append(frame)
+ if frame["type"] == "list_sessions":
+ matches = (frame["engine"], frame["space"]) == ("codex", "code")
+ await ws.send(json.dumps({
+ "type": "session_list", "v": PROTOCOL_VERSION,
+ "engine": frame["engine"], "space": frame["space"],
+ "request_id": frame["cmd_id"],
+ "sessions": [dict(session_id="s", engine="codex",
+ space="code")] if matches else [],
+ }))
+ if frame["type"] == "get_history":
+ switch = next(f for f in received if f["type"] == "switch_session")
+ assert (switch["engine"], switch["space"]) == ("codex", "code")
+ await ws.send(
+ json.dumps(
+ {
+ "type": "history",
+ "v": PROTOCOL_VERSION,
+ "session_id": "s",
+ "revision": "r",
+ "detail": "summary",
+ "turns": [{"id": "m", "prompt": "wire history"}],
+ }
+ )
+ )
+ complete.set()
+
+ async with serve(server, "127.0.0.1", 0) as socket:
+ port = socket.sockets[0].getsockname()[1]
+ c = WorkspaceClient(
+ f"ws://127.0.0.1:{port}/ws",
+ "",
+ "",
+ "codex",
+ "s",
+ machine_id=machine,
+ )
+ task = asyncio.create_task(c._connection_loop())
+ try:
+ await asyncio.wait_for(complete.wait(), 3)
+ for _ in range(100):
+ if c.workspace.view("s").blocks:
+ break
+ await asyncio.sleep(0.01)
+ assert c.workspace.view("s").blocks[0].text == "wire history"
+ assert received[0]["type"] == "hello"
+ assert received[0]["machine_id"] == machine
+ assert (
+ next(f for f in received if f["type"] == "get_history")[
+ "detail"
+ ]
+ == "summary"
+ )
+ finally:
+ c._quitting = True
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+async def test_visual_line_selects_whole_next_line():
+ c = client()
+ emit(c, "user_msg", msg_id="m", prompt="short\nlonger line here")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("g", "u", "j", "V", "j")
+ assert (
+ app.query_one(Transcript).selected_text == "short\nlonger line here"
+ )
+
+
+@pytest.mark.asyncio
+async def test_rekey_while_editing_does_not_recreate_temporary_view():
+ c = client("tmp")
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+j", "i", "d", "r", "a", "f", "t")
+ emit(c, "session_rekey", old_key="tmp", session_id="real")
+ app.paint()
+ assert app.query_one(Composer).text == "draft"
+ assert c.workspace.view("real").draft == "draft"
+ assert "tmp" not in c.workspace.views
+
+
+@pytest.mark.asyncio
+async def test_session_picker_navigation_and_attach_updates_title():
+ c = client(None)
+ emit(
+ c,
+ "session_list",
+ engine="codex",
+ sessions=[
+ {
+ "session_id": "a",
+ "cwd": "/a",
+ "summary": "first",
+ "engine": "codex",
+ },
+ {
+ "session_id": "b",
+ "cwd": "/b",
+ "summary": "second",
+ "engine": "codex",
+ },
+ ],
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(110, 35)) as pilot:
+ heading = app.query_one("#session-title", Static)
+ assert "Space e" in heading.content.plain
+ assert not app.query(OptionList)
+ await pilot.press("space", "e", "j", "l", "j", "enter")
+ await pilot.pause()
+ assert c.attached_sid == "b"
+ assert app.focused is app.query_one(Transcript)
+ assert heading.content.plain == "Codex / Code · Session: second"
+ assert not app.query(OptionList)
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert any(
+ f["type"] == "switch_session" and f["session_id"] == "b"
+ for f in frames
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("demo", [False, True])
+@pytest.mark.parametrize("width", [60, 110, 180])
+async def test_workspace_has_full_width_conversation_and_one_line_title(
+ demo, width
+):
+ c = client()
+ if demo:
+ seed_demo(c)
+ else:
+ emit(
+ c,
+ "session_list",
+ engine="codex",
+ sessions=[
+ {"session_id": "s", "summary": "当前工作", "engine": "codex"}
+ ],
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(width, 35)) as pilot:
+ await pilot.pause()
+ heading = app.query_one("#session-title", Static)
+ expected = "Review a change" if demo else "当前工作"
+ assert heading.content.plain == "Codex / Code · Session: " + expected
+ assert heading.region.height == 1
+ assert not app.query("#sessions")
+ assert not app.query(OptionList)
+ for widget in (app.query_one(Transcript), app.query_one(Composer)):
+ assert widget.region.x == 0
+ assert widget.region.width == width
+ # Resizing must never restore a list or reserve a sidebar's width.
+ await pilot.resize_terminal(width + 15, 35)
+ await pilot.pause()
+ assert heading.region.height == 1
+ assert app.query_one(Transcript).region.width == width + 15
+ assert not app.query(OptionList)
+
+
+@pytest.mark.asyncio
+async def test_title_updates_from_catalog_without_moving_editor(monkeypatch):
+ c = client()
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test(size=(60, 30)) as pilot:
+ heading = app.query_one("#session-title", Static)
+ assert heading.content.plain == "Codex / Code · Session: s"
+ await pilot.press("ctrl+j", "i", "d", "r", "a", "f", "t")
+ editor = app.query_one(Composer)
+ cursor = editor.cursor_location
+ emit(
+ c,
+ "session_list",
+ engine="codex",
+ sessions=[
+ {
+ "session_id": "s",
+ "summary": "[bold]新标题\n\x1b[31m第二行\u202e " * 30,
+ "engine": "codex",
+ },
+ {
+ "session_id": "other",
+ "summary": "Background title",
+ "engine": "codex",
+ },
+ ],
+ )
+ app.paint()
+ await pilot.pause()
+ content = heading.content
+ assert content.plain.startswith("Codex / Code · Session: [bold]新标题")
+ assert not content.spans
+ assert not any(c in content.plain for c in "\n\x1b\u202e")
+ assert "Background title" not in content.plain
+ assert heading.styles.text_wrap == "nowrap"
+ assert heading.styles.text_overflow == "ellipsis"
+ assert heading.region.height == 1
+ assert heading.render_line(0).text.rstrip().endswith("…")
+ assert app.focused is editor
+ assert editor.text == "draft" and editor.cursor_location == cursor
+ assert editor.vim_mode == "INSERT"
+ updates = []
+ monkeypatch.setattr(heading, "update", updates.append)
+ for _ in range(10):
+ app.paint()
+ assert not updates
+
+
+@pytest.mark.asyncio
+async def test_title_falls_back_to_prompt_and_tracks_rekey():
+ c = client("tmp")
+ c.workspace.catalog["tmp"] = {
+ "session_id": "tmp",
+ "summary": None,
+ "first_prompt": "first line\nsecond line",
+ }
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ heading = app.query_one("#session-title", Static)
+ assert heading.content.plain == "Codex / Code · Session: first line second line"
+ emit(c, "session_rekey", old_key="tmp", session_id="real")
+ app.paint()
+ await pilot.pause()
+ assert c.attached_sid == "real"
+ assert heading.content.plain == "Codex / Code · Session: first line second line"
+ c.workspace.catalog["real"]["first_prompt"] = None
+ app.paint()
+ assert heading.content.plain == "Codex / Code · Session: real"
+
+
+@pytest.mark.asyncio
+async def test_half_page_keys_and_idle_ticks_do_not_repaint(monkeypatch):
+ c = client()
+ emit(c, "user_msg", msg_id="m", prompt="line\n" * 80)
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("g", "g", "ctrl+d")
+ assert app.query_one(Transcript).cursor_location[0] > 0
+ await pilot.press("ctrl+u")
+ assert app.query_one(Transcript).cursor_location == (0, 0)
+ app.paint()
+ updates = []
+ monkeypatch.setattr(app.query_one("#status"), "update", updates.append)
+ monkeypatch.setattr(
+ app.query_one("#question"), "update", updates.append
+ )
+ for _ in range(10):
+ app.paint()
+ assert not updates
+
+
+@pytest.mark.asyncio
+async def test_return_to_evicted_tail_requests_summary_not_a_model_turn():
+ c = client()
+ view = c.workspace.view("s")
+ view.tail_hidden = True
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("G")
+ frames = [json.loads(raw) for raw, _ in c._outbox.values()]
+ assert [f["type"] for f in frames] == ["get_history"]
+ assert frames[0]["detail"] == "summary"
+ # A hidden tail is not the real bottom. Exercise idle paints while the
+ # request is pending, then assert follow after its summary arrives.
+ # The transient flag immediately after G depends on timer scheduling.
+ for _ in range(3):
+ app.paint()
+ await pilot.pause()
+ emit(c, "history", session_id="s", revision="fresh", turns=[{
+ "id": "newest", "prompt": "latest user message",
+ "blocks": [{"kind": "text", "message_id": "answer",
+ "channel": "final", "text": "latest answer\n" * 60}],
+ }])
+ app.paint()
+ await pilot.pause()
+ assert not view.tail_hidden
+ assert view.follow
+ reader = app.query_one(Transcript)
+ assert reader.scroll_y == reader.max_scroll_y
+ assert "latest answer" in reader.text
+
+
+@pytest.mark.asyncio
+async def test_pagination_preserves_selected_text_in_ui():
+ c = client()
+ emit(
+ c,
+ "history",
+ session_id="s",
+ revision="r",
+ turns=[{"id": "m", "prompt": "original line"}],
+ )
+ app = WorkspaceApp(c, connect=False)
+ async with app.run_test() as pilot:
+ await pilot.press("g", "u", "j", "v", "l", "l")
+ reader = app.query_one(Transcript)
+ chosen = reader.selected_text
+ emit(
+ c,
+ "history",
+ session_id="s",
+ revision="r",
+ before="m",
+ turns=[{"id": "old", "prompt": "older line"}],
+ )
+ app.paint()
+ await pilot.pause()
+ assert reader.selected_text == chosen
+ assert reader.selection != Selection.cursor((0, 0))
diff --git a/tests/test_tui_yank_feedback.py b/tests/test_tui_yank_feedback.py
new file mode 100644
index 00000000..f41087e0
--- /dev/null
+++ b/tests/test_tui_yank_feedback.py
@@ -0,0 +1,140 @@
+"""Yank feedback is shared, transient and independent from selection."""
+
+import pytest
+
+from cc_remote.tui_keys import KeyConfig
+from cc_remote.tui_preview_views import MarkdownReader
+from cc_remote.tui_panels import PanelReader, QuestionDialog
+from tests.test_tui_vim_shared import make_app, open_surface
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "surface", ["chat", "draft", "report", "form", "search"]
+)
+async def test_yank_flash_lands_at_start_without_visual_selection(surface):
+ app = make_app()
+ app.client.keys = KeyConfig({"vim": {"yank_highlight_ms": 5000}})
+ async with app.run_test() as pilot:
+ editor = await open_surface(app, surface)
+ editor.load_text("call(中文 word)")
+ editor.set_mode("NORMAL")
+ editor.move_cursor((0, 9))
+ await pilot.press("y", "i", "left_parenthesis")
+ assert app.clipboard == "中文 word"
+ assert editor.cursor_location == (0, 5)
+ assert editor.selection.start == editor.selection.end
+ assert editor.yank_range == ((0, 5), (0, 12))
+ assert editor.vim_mode == "NORMAL"
+ style = editor.get_component_rich_style("vim--yank")
+ assert any(
+ segment.style and segment.style.bgcolor == style.bgcolor
+ for segment in editor.render_line(0)
+ )
+ assert "Copied 7 characters" in app.client.notice
+ revision = editor.yank_revision
+ await pilot.press("y", "i", "w")
+ latest = editor.yank_range
+ editor.clear_yank(revision)
+ assert editor.yank_range == latest
+ editor.clear_yank()
+ assert editor.yank_range is None
+ assert app.clipboard and not app.client._outbox
+
+
+@pytest.mark.asyncio
+async def test_yank_timer_clears_only_paint_not_cursor_or_scroll():
+ app = make_app()
+ async with app.run_test() as pilot:
+ editor = await open_surface(app, "draft")
+ editor.load_text("line (copy)")
+ editor.move_cursor((0, 8))
+ editor.operate("y", (0, 6), (0, 10))
+ assert editor.yank_range
+ cursor, selection, scroll = (
+ editor.cursor_location,
+ editor.selection,
+ editor.scroll_offset,
+ )
+ await pilot.pause(0.3)
+ assert editor.yank_range is None
+ assert (
+ editor.cursor_location,
+ editor.selection,
+ editor.scroll_offset,
+ ) == (cursor, selection, scroll)
+
+
+@pytest.mark.asyncio
+async def test_line_yank_keeps_column_and_document_changes_clear_highlight():
+ app = make_app()
+ app.client.keys = KeyConfig({"vim": {"yank_highlight_ms": 5000}})
+ async with app.run_test() as pilot:
+ editor = await open_surface(app, "draft")
+ editor.load_text("first line\nsecond line\nthird")
+ editor.move_cursor((1, 5))
+ await pilot.press("y", "k")
+ assert editor.cursor_location == (0, 5)
+ assert editor.yank_range == ((0, 0), (2, 0))
+ assert app.vim_register[1]
+ editor.load_text("replacement")
+ assert editor.yank_range is None
+ editor.operate("y", (0, 0), (0, 3))
+ editor.insert("edit")
+ assert editor.yank_range is None
+
+
+@pytest.mark.asyncio
+async def test_preview_and_question_readers_share_feedback():
+ app = make_app()
+ app.client.keys = KeyConfig({"vim": {"yank_highlight_ms": 5000}})
+ async with app.run_test() as pilot:
+ await app.push_screen(
+ QuestionDialog(
+ app.client,
+ {
+ "sid": "s",
+ "ask_id": "ask",
+ "question": "choose (one two)",
+ },
+ )
+ )
+ reader = app.screen.query_one(PanelReader)
+ await pilot.press("ctrl+k")
+ reader.move_cursor((0, 11))
+ await pilot.press("y", "i", "left_parenthesis")
+ assert app.clipboard == "one two" and reader.yank_range
+ # Preview's styled get_line override must retain the shared overlay.
+ preview = MarkdownReader()
+ await app.screen.query_one(".tui-panel").mount(preview)
+ preview.focus()
+ preview.show_markdown("**hello** (world)")
+ begin = preview.text.index("world")
+ start = preview.document.get_location_from_index(begin)
+ end = preview.document.get_location_from_index(begin + 5)
+ preview.operate("y", start, end)
+ style = preview.get_component_rich_style("vim--yank")
+ assert any(segment.style and segment.style.bgcolor == style.bgcolor
+ for segment in preview.render_line(0))
+
+
+def test_yank_timeout_config_is_bounded_and_can_disable():
+ assert KeyConfig().yank_highlight_ms == 200
+ assert KeyConfig({"vim": {"yank_highlight_ms": 0}}).yank_highlight_ms == 0
+ for value in (-1, 5001, True, "200"):
+ with pytest.raises(ValueError):
+ KeyConfig({"vim": {"yank_highlight_ms": value}})
+
+
+@pytest.mark.asyncio
+async def test_flash_disabled_and_empty_yank_preserves_register():
+ app = make_app()
+ app.client.keys = KeyConfig({"vim": {"yank_highlight_ms": 0}})
+ async with app.run_test():
+ editor = await open_surface(app, "draft")
+ editor.load_text("copy")
+ editor.operate("y", (0, 0), (0, 4))
+ assert app.clipboard == "copy" and editor.yank_range is None
+ editor.operate("y", (0, 2), (0, 2))
+ assert app.clipboard == "copy" and editor.yank_range is None
+ assert app.client.notice == "Nothing to copy"
diff --git a/tests/test_viewer.py b/tests/test_viewer.py
index 86b226a3..66316810 100644
--- a/tests/test_viewer.py
+++ b/tests/test_viewer.py
@@ -861,8 +861,17 @@ async def test_logout_and_removal_revoke_previews(tmp_path, publication):
async with live_viewer(tmp_path, publication) as (client, app, cfg, site):
grant = await create_grant(client, cfg)
await activate_grant(client, cfg, grant)
- publication[1].write_text('{"sites":[]}')
- assert (await client.get(grant["origin"] + site.entry)).status_code == 403
+ # Match the CLI's atomic registry update. Truncating a file that the
+ # catalog worker is reading can intentionally disconnect the peer.
+ with edit_sites(publication[1]) as sites:
+ sites.clear()
+ # Before catalog propagation the Wrapper denies the read; afterwards
+ # the Relay expires the old publication. Neither may serve bytes.
+ assert (await client.get(grant["origin"] + site.entry)).status_code in {403, 410}
+ async with asyncio.timeout(5):
+ while app.state.viewers.peers["device"].publication(site.id):
+ await asyncio.sleep(0.01)
+ assert (await client.get(grant["origin"] + site.entry)).status_code == 410
await client.post("/api/logout", headers={"Origin": cfg.public_origin})
assert (await client.get(grant["origin"] + site.entry)).status_code == 401
diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json
index 05954fef..e8899076 100644
--- a/web/public/cc-remote-build.json
+++ b/web/public/cc-remote-build.json
@@ -1,4 +1,4 @@
{
"version": "3.0.0",
- "protocol": 66
+ "protocol": 67
}
diff --git a/web/src/protocol.ts b/web/src/protocol.ts
index 7fa0ef44..573c24c5 100644
--- a/web/src/protocol.ts
+++ b/web/src/protocol.ts
@@ -60,6 +60,7 @@ export interface QueryImg { media_type: "image/png" | "image/jpeg" | "image/jpg"
export interface QueryFile { filename: string; data: string }
export interface Query extends Base { type: "query"; prompt: string; msg_id: string; images?: QueryImg[] | null; files?: QueryFile[] | null; delivery?: "immediate" | "queue" | "replace" }
export interface CancelQueuedQuery extends Base { type: "cancel_queued_query"; sid: string; msg_id: string; cmd_id: string; client_id: string }
+export interface ReorderQueuedQueries extends Base { type: "reorder_queued_queries"; sid: string; cmd_id: string; client_id: string; expected: string[]; order: string[] }
export interface GetQueuedQuery extends Base { type: "get_queued_query"; sid: string; msg_id: string; cmd_id: string; client_id: string }
export interface QueuedQueryDetail extends Base {
type: "queued_query_detail";
@@ -710,7 +711,7 @@ export type ServerEvent = FilesListed | CodexContext
| ProcessEvent | BackgroundProcessSync | TurnPlan | TurnDiff | TurnFileChanges | TurnBinding
| TurnEnd | ErrorMsg | WrapperDisconnected | WrapperReconnected | Hello;
-export const PROTOCOL_VERSION = 66;
+export const PROTOCOL_VERSION = 67;
export const MIN_AUTO_COMPACT_TOKENS = 100_000;
export const MAX_AUTO_COMPACT_TOKENS = 1_000_000;