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"": 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 "&lt;"\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="![A](a.png)\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 ![A](a.png).\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)", "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![web](https://host/b.png)") + 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)", + "![讲稿](/docs/%E6%BC%94%E8%AE%B2.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 & "]', 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" + "![image](/work/image.png)\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 ![pic](https://host/x.png)") + 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![remote](https://host/img.png)", + } + + 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", "![pic](result.png)") + ) + 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", "![preview](preview.png)")) + 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;