feat(tui): add a Vim-style terminal workspace - #43
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28bf02b82d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if not self.want_sid: | ||
| self.engine, self.space = saved["scope"] | ||
| self.attached_engine = self.engine |
There was a problem hiding this comment.
Honor explicit startup scope when restoring tabs
When tab state already exists and no session ID is supplied, this unconditionally replaces the requested engine and space with the saved scope. Consequently, cc-remote-tui --engine claude --space work (and an explicit ENGINE) can reopen an unrelated saved Codex/Code surface, contrary to the documented promise that these options choose the initial surface. Preserve the saved scope only when the user did not explicitly select one.
Useful? React with 👍 / 👎.
| socket_url = self.url | ||
| if self.machine_id != "default": | ||
| socket_url += "?" + urlencode({"machine": self.machine_id}) |
There was a problem hiding this comment.
Route the default machine explicitly
When machine_id is default, omitting the query parameter does not select that machine: the relay's /ws handler instead chooses the first connected authorized machine, and RelayHub.machine_ids is sorted, so a named device such as aaa wins over default. Thus even an explicit --machine default can connect to the wrong wrapper while the TUI sends/stores the identity as default; include the route parameter for this valid machine ID as well.
AGENTS.md reference: AGENTS.md:L102-L106
Useful? React with 👍 / 👎.
| else: | ||
| request = event.get("request_id") | ||
| receipt = self.pending.get(request) | ||
| if receipt is None or receipt.future.done(): |
There was a problem hiding this comment.
Correlate Work deletion errors before waiting
When deleting a running or queued Work session, _handle_delete_work_session emits its rejection with sid and to but no request_id. This lookup therefore ignores the error, while the later ACK cannot satisfy confirmed, leaving the deletion task blocked for 30 seconds before it replaces the real error with a timeout. The deletion workflow needs to correlate the wrapper's existing Work rejection shape or the wrapper must attach the command ID to those errors.
AGENTS.md reference: AGENTS.md:L147-L148
Useful? React with 👍 / 👎.
| cursor = ( | ||
| event.get("oldest_cursor") if event.get("has_more") else None | ||
| ) | ||
| view.details[turn] = cursor |
There was a problem hiding this comment.
Keep a route back from older detail pages
After o requests an older source-disjoint TurnDetail page, the reducer replaces the displayed detail payload but records only oldest_cursor/has_more, ignoring the response's has_newer and newer_cursor. Further o presses can only move farther backward, and collapsing/re-expanding reuses the replaced page, so the newest detail content becomes inaccessible until the projection is reset or the TUI restarts. Track both paging directions or retain the previously loaded page.
Useful? React with 👍 / 👎.
| for index, option in enumerate(self.ask.get("options", []), 1): | ||
| labels.append( | ||
| f"{index}. {option['label']}\n {option.get('description', '')}" | ||
| ) |
There was a problem hiding this comment.
Render the wire-format option descriptions
Blocking AskUser options carry their description in the protocol's ds field, but this dialog reads description, so every explanatory sentence is silently blank in the full-screen TUI even though the line-mode client and Web UI display it. For choices whose labels only summarize the action, this removes the impact/tradeoff information the user needs before answering; read option.get('ds') here.
Useful? React with 👍 / 👎.
| if await self.client._send(command): | ||
| self.query_one(ModalEditor).set_mode("NORMAL") | ||
| self.query_one("#queue-result", Static).update( | ||
| "Submitted. Queue/result updates are authoritative; already-started messages cannot be edited." |
There was a problem hiding this comment.
Surface authoritative queue-edit rejections
A successful _send only means the edit entered the reliable outbox, yet this immediately reports it as submitted and never correlates the subsequent QueuedQueryUpdated result. If the prompt was emptied without attachments, the queue exceeded its byte cap, or the item started between the read and update, the wrapper returns updated=false with an error while this screen continues implying success. Wait for the matching request_id and display that result so users do not assume a rejected edit changed the server-owned prompt.
AGENTS.md reference: AGENTS.md:L142-L149
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47f75d193b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| text = "\n\n".join( | ||
| f"{q['title']}\n{answer}" | ||
| for q, answer in zip(self.questions, self.answers) | ||
| ) |
There was a problem hiding this comment.
Use the shared async-answer envelope
When a Codex async question is answered from the TUI and the same session is later viewed in Web, this plain title\nanswer format is not recognized as an answer. Web marks questions answered only when the subsequent prompt exactly matches supplementalAnswerPrompt in web/src/async-question-presentation.ts, so the question remains actionable and another client can submit a duplicate answer. Build the prompt with the same canonical envelope used by the Web client.
Useful? React with 👍 / 👎.
| 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) |
There was a problem hiding this comment.
Keep background errors out of the focused notice
When another resident session emits a broadcast Error, the inherited Tui._handle calls _line without checking _for_me; this override consequently replaces the focused workspace's global notice with that background session's failure. The error is already projected into its own SessionView, so only sessionless or focused-session errors should update this notice.
AGENTS.md reference: AGENTS.md:L120-L126
Useful? React with 👍 / 👎.
| 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 [] |
There was a problem hiding this comment.
Preserve every pending blocking question
When concurrent tools or subagents leave more than one blocking AskUser pending for a session, the wrapper sends every entry in ctx.pending_asks, but the legacy pending_asks projection keeps only one value per SID. This action consults only that single value rather than view.presentation.questions; after answering it, answer_for removes the SID entry entirely, leaving the other still-blocking question visible in the Questions report but impossible to open or answer from the TUI. Select from the complete per-session question collection instead.
Useful? React with 👍 / 👎.
| request_id = "tui-preview-" + uuid.uuid4().hex | ||
| future = asyncio.get_running_loop().create_future() | ||
| client.preview_waiters[request_id] = (sid, future) | ||
| command = ( | ||
| AuthorizePreview( | ||
| sid=sid, | ||
| authorization_id=challenge, | ||
| request_id=request_id, |
There was a problem hiding this comment.
Reuse the challenged preview request ID
When a file outside the session directory requires authorization, this function generates a new request_id for the subsequent AuthorizePreview. The wrapper binds each challenge to the original file-read request and explicitly requires challenge.request_id == cmd.request_id in _handle_authorize_preview, so every TUI authorization attempt is returned as expired and external files can never be opened. Retain the request ID from PreviewAuthorizationRequired and send it with the authorization command.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d72fd98554
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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)))): |
There was a problem hiding this comment.
Resolve a direct session's scope before prewarming it
When the positional session ID belongs to a different engine or space than the startup defaults, recovery immediately sends SwitchSession using attached_engine and _session_space() before any catalog identifies the session. For example, a bare Claude ID now uses the new Codex default, while a Work ID defaults to Code; the wrapper rejects or attempts the wrong backend, and restore_pending prevents the later catalogs from triggering a corrected attach. Delay this prewarm until the ID's catalog row supplies its actual engine and space.
Useful? React with 👍 / 👎.
| if len(matches) == 1: | ||
| pending.pop(matches[0]) | ||
| else: | ||
| pending.clear() |
There was a problem hiding this comment.
Preserve questions when a canonical reply is ambiguous
If two pending async-question messages have identical question metadata, one canonical supplemental reply matches both candidates, so len(matches) > 1 reaches this else and clears every question. The Web projection deliberately leaves ambiguous matches unanswered, and the TUI can therefore hide both still-actionable questions while another client continues offering them; only the uniquely matched candidate should be removed.
Useful? React with 👍 / 👎.
| text, starts = reader.parent.project( | ||
| text, starts, (sid, row.get("cwd")) | ||
| ) |
There was a problem hiding this comment.
Invalidate inline image caches after rollback
When rollback restores a previously referenced image path, the session ID and cwd remain unchanged, so this projection identity does not reset TranscriptViewport.cache; the same (block.id, path) key then reuses pixels decoded before the rollback. Although the wrapper emits artifact_invalidated, no artifact epoch reaches this identity, leaving the terminal preview stale until the user switches cwd/session or restarts the TUI. Include an invalidation generation in the image projection identity or explicitly clear the viewport cache on that event.
Useful? React with 👍 / 👎.
| elif ask.get("allow_text"): | ||
| answer = text |
There was a problem hiding this comment.
Reject blank free-text answers before sending
When a blocking question allows free text, submitting whitespace takes this branch and is accepted into the reliable outbox, after which answer_for removes the question locally. The wrapper rejects such answers because _handle_answer_question requires answer.strip() to be nonempty but leaves the question pending, so the TUI can no longer reopen it until a reconnect supplies a fresh ask sync; an actually empty string can instead raise during AnswerQuestion validation. Validate a nonempty trimmed answer before constructing or sending the command.
Useful? React with 👍 / 👎.
| if not async_questions: | ||
| view.attachments.clear() |
There was a problem hiding this comment.
Retain attachments until the server confirms the message
A successful _send only means the complete command entered the local reliable outbox, but this immediately discards every staged attachment. If the wrapper then rejects the query or queued delivery because ownership/control state changed, the correlated error marks only the text receipt failed and the binary/file contents are no longer present in either the draft or outbox after ACK processing, forcing the user to locate and attach them again. Clear attachments on the authoritative user_msg/turn_steered echo, or restore them on a correlated rejection.
Useful? React with 👍 / 👎.
| and self.app_focus | ||
| and not self.client.demo | ||
| ): | ||
| self.acknowledged.add(key) |
There was a problem hiding this comment.
Retry completion receipts when the outbox rejects them
If the reliable outbox is at its command or byte cap when an unread completion is painted, the key is added to acknowledged before _send reports failure. Once earlier commands drain, every later paint still skips this completion, so the server-side unread receipt—and therefore the badge on other clients—remains uncleared for the rest of this TUI process. Record the key only after _send accepts the acknowledgement, or remove it again on failure.
Useful? React with 👍 / 👎.
| remaining = [ | ||
| b | ||
| for b in old | ||
| if b.id not in ids and (b.turn not in covered or b.id in fresh) | ||
| ] |
There was a problem hiding this comment.
Drop old turns when history continuity changes
When a newest History page arrives with a new revision whose continuity_revision does not preserve the previous epoch, this merge still retains every old block whose turn is outside the four-turn incoming page. A Codex projection-family/source switch can therefore leave phantom turns from the previous source mixed into the new canonical conversation; only an explicitly continuous revision may retain uncovered cached rows, while a discontinuity must replace them.
AGENTS.md reference: AGENTS.md:L166-L167
Useful? React with 👍 / 👎.
| self.catalog = { | ||
| sid: row | ||
| for sid, row in self.catalog.items() | ||
| if sid.startswith("btw-") | ||
| or (row.get("engine", "claude"), row.get("space", "code")) |
There was a problem hiding this comment.
Disable tabs removed by an authoritative catalog
When another client deletes an open session, this authoritative scoped replacement removes its catalog row but leaves the existing SessionView.write_state unchanged. An attached tab that was writable therefore continues accepting prompts for the deleted SID, and only discovers the deletion through a server rejection; retained transcript and draft state can remain visible, but the removed view should be marked unavailable (as the side-chat removal path already does) or the tab should be closed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c3473bfca
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if reader.parent.identity != (sid, row.get("cwd")): | ||
| self.rendered_version = -1 |
There was a problem hiding this comment.
Compare the complete projection identity
After the first render, TranscriptViewport.identity is the three-element tuple (sid, cwd, artifact_epoch) assigned below, so comparing it with this two-element tuple is always unequal. Because paint() runs every 100 ms, this continually resets rendered_version to -1 and forces the entire transcript, Markdown, and inline-image projection to be rebuilt even when nothing changed, causing persistent CPU usage and severe lag on larger histories. Compare against the same three-element identity used by project().
Useful? React with 👍 / 👎.
| from cc_remote.tui_app import WorkspaceClient, run_workspace | ||
| except ModuleNotFoundError as exc: | ||
| if exc.name != "textual": | ||
| raise | ||
| parser.exit(1, "Install requirements-tui.txt, or use --line-mode.\n") |
There was a problem hiding this comment.
Handle every missing optional TUI module
When a checkout has only the core requirements, or an incomplete optional TUI installation, importing tui_app can fail first on rich, markdown_it, or textual_image; this handler only translates an error whose name is exactly textual. Those expected missing optional modules therefore produce a traceback instead of the advertised Install requirements-tui.txt, or use --line-mode guidance. Handle all modules supplied by the optional TUI dependency set here while continuing to re-raise unrelated import failures.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a92fb0add6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| #!/usr/bin/env bash | ||
| # Resolve the checkout owning this launcher, including when installed by symlink. | ||
| set -euo pipefail | ||
| launcher=$(readlink -f -- "${BASH_SOURCE[0]}") |
There was a problem hiding this comment.
Avoid GNU-only readlink in the launcher
On macOS/BSD, readlink does not support GNU's -f, --canonicalize option, so the documented symlink installation exits at this assignment before locating the checkout or starting Python. Use a portable symlink-resolution loop or a Python-based realpath so cc-remote-tui works on supported macOS hosts as well as GNU/Linux.
Useful? React with 👍 / 👎.
| self.session_catalog_requests[message.engine, message.space] = ( | ||
| message.cmd_id |
There was a problem hiding this comment.
Register catalog fences only after outbox admission
When the reliable outbox is at its command or byte cap, this records a fresh session_catalog_requests ID before super()._send() rejects the unsent ListSessions. A surface switch has already cleared catalog_ready, and subsequent real catalog responses are then discarded because their request IDs do not match the unsent fence; even after ACKs free the outbox, no automatic retry occurs, leaving the surface on “Loading…” until an explicit action sends another catalog read. Commit this fence only after admission succeeds, or restore the previous request ID on failure.
Useful? React with 👍 / 👎.
| "engine": row["engine"], | ||
| "cwd": parent.get("cwd"), | ||
| "state": row.get("state", "idle"), | ||
| "space": parent.get("space", "code"), |
There was a problem hiding this comment.
Reconcile side chats after parent catalogs arrive
On a fresh TUI connection that inherits an existing Work side chat, the wrapper deliberately sends the authoritative BtwSync during Hello before the per-engine SessionList replies, so the parent is not yet in self.catalog and this permanently assigns the fork to code with no cwd. Later session-list handling preserves btw-* rows but never updates them from the newly arrived parent, causing the Work fork to disappear from its proper surface and appear under Code with incorrect scope metadata. Reconcile retained side rows whenever their parent catalog row arrives, or carry the parent scope in the BTW catalog.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6862e7129
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ticket and event["request_id"] != ticket[0]: | ||
| return |
There was a problem hiding this comment.
Preserve goal mutation confirmations
After the automatic GetGoal issued when a session is attached, read_tickets[(sid, "goal_state")] remains populated indefinitely. A later SetGoal, ClearGoal, or DismissGoal emits an authoritative GoalState carrying that mutation's different command ID, so this branch discards the update and leaves the progress strip and Goal panel stale until another explicit refresh or unsolicited goal event occurs. Only fence one-shot GetGoal replies here, and retire the ticket once its matching response is processed.
Useful? React with 👍 / 👎.
| "agent_detail", | ||
| "work_dashboard", | ||
| "work_artifacts", | ||
| "queued_query_detail", |
There was a problem hiding this comment.
Keep queued prompts out of persistent reports
Whenever QueueEdit performs its private full-prompt read, the same queued_query_detail is also copied into SessionPresentation.reports, retaining up to 64 KiB of the queued prompt after the editor closes and exposing it later in the general Reports panel. Route this response only to the correlated one-shot queue-detail consumer rather than including it in REPORT_TYPES.
AGENTS.md reference: AGENTS.md:L148-L150
Useful? React with 👍 / 👎.
| or (row.get("engine", "claude"), row.get("space", "code")) | ||
| != (engine, event.get("space", "code")) |
There was a problem hiding this comment.
Preserve sessions from unavailable account profiles
When a multi-profile SessionList reports an error for one Claude or Codex profile, that response intentionally omits the unavailable profile's rows while retaining successful profiles, but this replacement drops every prior row in the engine/space before re-adding the partial result. Those sessions disappear and are marked unavailable; because their existing control revision is retained, even a later same-revision SessionControl can be ignored, leaving restored tabs unwritable. Retain prior rows owned by profiles whose catalog entry carries an error, as normalizeSessionList already does for Web.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e6714ebcd7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| self.client.workspace.view(sid).draft = "" | ||
| if self.client.attached_sid == sid and editor.text == text: | ||
| editor.load_text("") |
There was a problem hiding this comment.
Retain queued text until the wrapper accepts it
When a Ctrl+e submission enters the reliable outbox but the wrapper later rejects it because the server queue reached its item/byte limit or the session became non-writable, these lines clear both copies of the draft immediately. A queued submission creates no pending_messages block, and the correlated error path restores only attachments, so the complete prompt is unrecoverable after the rejection; retain a private receipt until the matching query_queue projection or restore the text on the correlated Error.
AGENTS.md reference: AGENTS.md:L142-L150
Useful? React with 👍 / 👎.
| if "request_id" in cls.model_fields: | ||
| values["request_id"] = command_id |
There was a problem hiding this comment.
Reuse challenge IDs in the generic preview action
Although the specialized preview flow now reuses its challenged request ID, the remaining authorize_preview entry in the All Actions palette always reaches this code, which hides the original request_id from the form and replaces it with a fresh command ID. After a generic get_file_preview exposes PreviewAuthorizationRequired in Reports, the wrapper's challenge.request_id == cmd.request_id check therefore always returns expired; preserve the reported challenge ID for this command or remove the unsupported generic action.
Useful? React with 👍 / 👎.
| url, | ||
| password, | ||
| origin, | ||
| args.engine or os.environ.get("ENGINE") or ENGINE, |
There was a problem hiding this comment.
Reject invalid ENGINE values before starting the client
When ENGINE contains a typo such as CODEX or foo and no --engine flag overrides it, this passes the unchecked value into the client even though the previous entry point constrained the environment value. The connection loop later constructs ListSessions(engine=<invalid>); Pydantic rejects it, the broad connection handler retries, and the full-screen workspace remains stuck reconnecting instead of reporting an invalid configuration or selecting a valid fallback.
Useful? React with 👍 / 👎.
| def show_markdown(self, source): | ||
| self.source = _safe_remote_text(source[: 256 * 1024]) | ||
| self.reflow() |
There was a problem hiding this comment.
Mark locally truncated file previews
When the wrapper returns a valid Markdown preview between 256 KiB and its 512 KiB wire limit with truncated=false, this silently discards everything after the first 256 KiB; reflow() can additionally discard output after 5,000 rendered lines. The preview status consequently still says only “Read-only,” so users inspecting a long file can mistake the locally shortened document for its complete contents; preserve the full bounded response or display an explicit truncation marker and Web handoff.
Useful? React with 👍 / 👎.
| await client.execute_action(DeleteSession( | ||
| session_id=sid, engine=engine, space=space, | ||
| client_id=client.client_id, | ||
| )) |
There was a problem hiding this comment.
Close side chats with their dedicated command
When the highlighted tree leaf is a btw-* side conversation, this still sends DeleteSession. A BTW ID is the wrapper's ephemeral routing key rather than a native Claude/Codex catalog ID, so the native deletion handler rejects it and the side conversation remains open despite the tree presenting a valid deletion action; dispatch CloseBtw for these rows or explicitly disable the deletion shortcut for them.
Useful? React with 👍 / 👎.
e6714eb to
cf00cb4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cf00cb4eed
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| and self.app_focus | ||
| and not self.client.demo | ||
| ): | ||
| self.acknowledged.add(key) |
There was a problem hiding this comment.
Retry completion acknowledgements after server rejection
When the wrapper accepts this command into the outbox but later returns an Error—for example, when the completion presentation store is temporarily unavailable—the key remains in acknowledged, while the subsequent command ACK removes the failed command from the reliable outbox. The unread completion_state therefore continues to render but can never trigger another acknowledgement during this TUI process. The new cleanup only covers _send() returning false; correlate the command's server result and discard this key on an authoritative rejection as well.
Useful? React with 👍 / 👎.
| if isinstance(self.focused, TextArea): | ||
| self.focused.action_paste() | ||
| return |
There was a problem hiding this comment.
Preserve Ctrl+V paste in secret answer fields
When a blocking AskUser has secret=true, the focused editor is ModalInput, which derives from Input rather than TextArea. The priority global Ctrl+V binding therefore reaches this branch, skips action_paste(), and returns, so Ctrl+V silently does nothing even while the secret field is in Insert mode; modal TextArea fields do receive ordinary paste here. Delegate the paste to editable Input fields as well while keeping desktop-image capture restricted to the main composer.
Useful? React with 👍 / 👎.
| if done | ||
| else "running", | ||
| ) | ||
| self.presentation.turns[tid] = current |
There was a problem hiding this comment.
Bound turn metadata while paging older history
When a long session is paged backward, every historical turn is inserted into presentation.turns, but history() never prunes that mapping even after trim() limits the visible blocks. After more than 160 turns have been loaded, the next live event invokes SessionPresentation.event()'s insertion-order [-160:] trim; because older pages were inserted after the newest page, this can evict the current active turn while retaining old offscreen turns. Subsequent terminal events then cannot find the active TurnDisplay, so elapsed/status headers and the local completion badge can be lost, in addition to metadata growing without a bound until another live event arrives. Prune turn metadata alongside retained blocks while explicitly preserving the active/live turn.
Useful? React with 👍 / 👎.
cf00cb4 to
da3d104
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: da3d10439e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ticket and event["request_id"] != ticket[0]: | ||
| return |
There was a problem hiding this comment.
Retire stale status-read fences before mutation refreshes
After the automatic GetStatus issued on attach succeeds, its read_tickets[(sid, "status_report")] entry is never removed. If the user later runs ConsumeRateLimitResetCredit, _handle_consume_rate_limit_reset_credit emits the refreshed StatusReport using the mutation's command ID, so this mismatch check discards that authoritative refresh and leaves the displayed reset-credit and quota state stale, potentially encouraging another redemption attempt. Retire a matched GetStatus ticket after its response, or explicitly accept the mutation-correlated status refresh.
Useful? React with 👍 / 👎.
| if not pending and visual and char in {"d", "c", "y"}: | ||
| self.operate(char, *sorted(self.selection)) |
There was a problem hiding this comment.
Preserve linewise semantics for Visual Line operations
When the editor is in VISUAL LINE mode, pressing d, c, or y takes this generic visual branch and calls operate() without linewise=True. Consequently V followed by y creates a characterwise register, while Vd/Vc leave the selected line boundary behind; a later p inserts text within a line instead of putting whole lines. Pass a full-line range and retain the linewise flag for operations originating from VISUAL LINE.
Useful? React with 👍 / 👎.
| remaining -= cost + len(block.text) | ||
| if remaining < len(TRUNCATED): | ||
| break | ||
| view.detail_blocks[turn] = children |
There was a problem hiding this comment.
Reject turn-detail pages superseded by newer navigation
When detail reads are slow, repeated o navigation can leave duplicate GetTurnDetail reads for one cursor in flight, and the wrapper runs those history commands in independent tasks. After one reply advances the view and the user requests the next older page, a delayed reply for the previous before cursor reaches this unconditional assignment and replaces the newer page, snapping the panel back to stale tool output. Track the expected before cursor or a local request generation per turn and ignore replies superseded by later navigation.
Useful? React with 👍 / 👎.
da3d104 to
afb5be4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: afb5be43cb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| option = document.body.split("\n", 1)[0].strip() | ||
| if option and option not in { |
There was a problem hiding this comment.
Inspect the first nonempty line before classifying Mermaid
When an unfenced assistant message starts with a recognized non-flowchart keyword on its own line, document.body begins with \n, so this split produces an empty option and bypasses the prose guard. For example, info\nabout the server is replaced by the Mermaid “info” projection rather than displayed as the original answer; other family names such as timeline and pie have the same issue. Validate the first nonempty body line (and reject incomplete family syntax) before treating ordinary Markdown as a standalone diagram.
Useful? React with 👍 / 👎.
afb5be4 to
3ed235f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ed235f74f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if sid in self._history_requested: | ||
| return False |
There was a problem hiding this comment.
Retry history refreshes blocked by an in-flight read
If history_invalidated arrives while the initial GetHistory is still represented in _history_requested, this guard rejects the forced refresh after _flush_history_refreshes has already consumed its retry flag. The old response is then rejected by SessionView.pending_revision, but nothing schedules another read, leaving the rollback-cleared transcript blank until the user explicitly reloads or reattaches. Preserve the refresh request until the in-flight read completes, then issue a new revision-aware read.
AGENTS.md reference: AGENTS.md:L166-L167
Useful? React with 👍 / 👎.
| if event.get("type") in { | ||
| "tool_use", "tool_result", "tool_delta", "process", "state", | ||
| "assistant_msg_start", "assistant_msg_end", "delta", "turn_end", | ||
| }: |
There was a problem hiding this comment.
Prevent user messages from replacing the workspace notice
For an attached session's user_msg or turn_steered event, super()._handle(event) invokes the legacy _line() renderer, which this client maps to self.notice; unlike the other transcript event types listed here, these two types do not restore the prior notice. Consequently an own echo or a message sent from another client replaces statuses such as “Message submitted” with the prompt text in the footer until another action updates it. Suppress these narrative types from the legacy notice path as well.
Useful? React with 👍 / 👎.
3ed235f to
10bc1f0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 10bc1f0905
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if any(profile.get("error") for profile in event.get( | ||
| command.engine + "_profiles", [] | ||
| )): | ||
| receipt.confirmed = False | ||
| return # An unavailable catalog cannot prove absence. |
There was a problem hiding this comment.
Scope deletion confirmation to the target profile
When the target belongs to a healthy account but any sibling profile is temporarily unavailable, the successful archive/delete response still includes that sibling's profile error, so this blanket check refuses the catalog proof. The command is then ACKed and may already have deleted the session, but the TUI waits 30 seconds, retains the tab, and—during the Codex archive prerequisite—never sends the deletion at all. Capture the target row's profile identity when starting the receipt and distrust only an error for that profile.
Useful? React with 👍 / 👎.
| if expected_profile is None: | ||
| expected_profile = self.workspace.reports.get( | ||
| event["engine"] + "_profiles", {} | ||
| ).get("default_" + profile) | ||
| if expected_profile != event.get(profile): |
There was a problem hiding this comment.
Retain model replies until the default profile is known
If a new-session or settings form is opened before the first SessionList arrives, the pending GetModels has no explicit profile and workspace.reports has no default profile yet. The wrapper resolves that request to a concrete profile ID, so this comparison drops the response; when the later catalog supplies the default, nothing reprocesses the discarded models reply, leaving model and effort choices unavailable until the user closes and reopens the form. Cache the unmatched reply or reconcile it when default-profile metadata arrives.
Useful? React with 👍 / 👎.
| self.blocks = ( | ||
| incoming + remaining | ||
| if event.get("before") |
There was a problem hiding this comment.
Reject superseded history pagination replies
When an older-page History frame is received but its command ACK is lost, reconnect recovery safely reruns that request from the reliable outbox. If the user has meanwhile requested the next older cursor, the two history reads run independently, and a delayed duplicate of the previous same-revision page reaches this unconditional prepend after the newer page; it moves the middle turns ahead of older turns and resets oldest/has_more, causing subsequent paging to repeat or skip the real boundary. Track the expected before cursor or a navigation generation and ignore superseded pages.
AGENTS.md reference: AGENTS.md:L158-L167
Useful? React with 👍 / 👎.
| else {} | ||
| ), | ||
| ), | ||
| append=True, |
There was a problem hiding this comment.
Replace streamed tool output at completion
When Codex sends item/commandExecution/outputDelta events and later finishes with a ToolResult containing the aggregated output, this append=True path concatenates the authoritative result after the already-streamed bytes. Expanding the tool therefore displays command output twice; streamed diffs are similarly followed by the complete final diff, and the duplication can consume the 64 KiB block limit and hide the actual tail. Keep the streamed fields separate and replace them with the authoritative result at completion instead of appending it.
Useful? React with 👍 / 👎.
10bc1f0 to
9b20039
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9b20039158
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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) |
There was a problem hiding this comment.
Correlate failed sends by command ID
When a session is deleted or evicted after _handle_query() resolves its context but before it acquires query_lock, _missing_session_error() returns an Error containing the command's request_id but no msg_id. This branch therefore never removes the corresponding optimistic user block, leaving the rejected prompt permanently displayed as “awaiting confirmation” even after the error and command ACK arrive. Store the command ID with pending_messages and accept either correlation field, as the attachment and queued-message receipt paths already do.
Useful? React with 👍 / 👎.
| for index, option in enumerate(self.ask.get("options", []), 1): | ||
| labels.append( | ||
| f"{index}. {option['label']}\n {option.get('ds', '')}" | ||
| ) |
There was a problem hiding this comment.
Sanitize question choices before rendering
When a model supplies an AskUser option label or description containing C0/C1, ANSI, or bidi control characters, this interpolation places the untrusted text directly into the Textual reader without passing through _safe_remote_text; the protocol only length-bounds these fields. Unlike the question text and the async-question picker, such choices can therefore inject terminal control sequences or visually reorder the confirmation UI. Sanitize both fields before constructing the panel text.
Useful? React with 👍 / 👎.
9b20039 to
6cdbbbf
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6cdbbbf1be
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if event.get("type") in { | ||
| "tool_use", "tool_result", "tool_delta", "process", "state", | ||
| "assistant_msg_start", "assistant_msg_end", "delta", "turn_end", | ||
| }: |
There was a problem hiding this comment.
Keep background context reports out of the focused notice
When a nonfocused resident session emits a context_report—for example, from its automatic post-turn context probe—super()._handle(event) unconditionally calls the legacy _render_context() and replaces self.notice. Because this restoration list omits context_report, the focused workspace footer then shows the background session's token count and overwrites statuses such as submission confirmations; preserve the prior notice for nonfocused context reports as is already done for background errors and transcript events.
Useful? React with 👍 / 👎.
| if all( | ||
| word in label.casefold() for word in text.casefold().split() | ||
| ): | ||
| listing.add_option(Option(Text(label), id=str(index))) |
There was a problem hiding this comment.
Sanitize remote settings choices before rendering
In the full-screen model and permission-profile pickers, label can contain a server-provided model display name or permission-profile description, but it is rendered directly without _safe_remote_text. These protocol fields are length-bounded but may still contain ANSI, C0/C1, or bidi controls, allowing a catalog entry to visually spoof or reorder a permission choice in the terminal; sanitize the complete label before constructing Text.
Useful? React with 👍 / 👎.
6cdbbbf to
27dd7cc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27dd7cc2cd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| p.ClearGoal, | ||
| p.DismissGoal, | ||
| p.AcknowledgeCompletion, | ||
| p.GetQueuedQuery, |
There was a problem hiding this comment.
Route generic queued-query reads to a consumer
GetQueuedQuery remains available through the generic Actions palette, but that form has no consumer for its response: _on_event() stores the full prompt in queue_details and returns, while only a QueueEdit instance polling its own request ID ever removes or displays it. Selecting this generic action therefore reports “Submitted” without showing the result and retains the private prompt until unrelated reads evict it. Remove this command from the generic inventory or open the dedicated one-shot queue editor for it.
AGENTS.md reference: AGENTS.md:L148-L150
Useful? React with 👍 / 👎.
| )) | ||
| stage = "Deletion" | ||
| self.client.notice = "Deleting; waiting for server confirmation…" | ||
| await self.wait_for(command) |
There was a problem hiding this comment.
Keep tracking timed-out deletion commands
If the wrapper remains offline for more than 30 seconds after a deletion is admitted to the reliable outbox, this await times out and the receipt/task is discarded, but the deletion frame remains in _outbox and is replayed when the wrapper reconnects. The permanent deletion can therefore complete after the TUI stopped tracking it; the later catalog refresh removes the row, but the local buffer remains attached and no success confirmation is shown. Preserve an observer for admitted deletion commands after the UI timeout so their eventual authoritative result can finish local cleanup.
Useful? React with 👍 / 👎.
| + json.dumps( | ||
| event.get("input", {}), ensure_ascii=False, indent=2 | ||
| ) |
There was a problem hiding this comment.
Render the bounded tool input instead of the original payload
When a live tool_use carries a large input, the code first creates the bounded inputs value and even discards it when its serialized display exceeds 64 KiB, but then formats event["input"] again for the block text. A multi-megabyte Write/Edit/tool payload is therefore fully pretty-printed on the Textual event loop before put() truncates it, causing avoidable allocations and UI stalls while also defeating the preceding display-size check. Serialize the already-bounded inputs value here.
Useful? React with 👍 / 👎.
| self.update_chrome( | ||
| "#attachments", | ||
| "Attachments: " + ", ".join(a["name"] for a in view.attachments) | ||
| if view.attachments |
There was a problem hiding this comment.
Sanitize attachment names before rendering status chrome
When the user attaches a file whose basename contains bidi, C0/C1, or ANSI control characters—for example, a crafted filename from an untrusted checkout—this joins the raw name directly into a Text object via update_chrome(). Unlike transcript filenames, the attachment strip never passes through _safe_remote_text, so the filename can visually reorder or spoof the persistent terminal chrome. Sanitize each displayed attachment name before constructing this string.
Useful? React with 👍 / 👎.
| editor = self.query_one("#answer") | ||
| value = editor.value if isinstance(editor, Input) else editor.text | ||
| if await self.client.answer_for(self.ask, value): |
There was a problem hiding this comment.
Reject oversized free-text answers inside the dialog
When a blocking question permits free text and the user enters more than the protocol's 4,096-character answer limit, answer_for() constructs AnswerQuestion and raises a Pydantic validation error. This dialog awaits it without validation or exception handling, so the Textual action fails instead of updating #answer-result and retaining a usable answer flow; secret-answer fields are affected as well. Validate the limit before sending or catch ValueError here and display it without dismissing the dialog.
Useful? React with 👍 / 👎.
27dd7cc to
85a6a3a
Compare
- Attach command IDs to missing, busy and failed Work deletions. - Preserve authoritative rejection reporting for every control client.
- Exercise the native atomic registry transaction during concurrent reads. - Assert denial before catalog propagation and expiration afterwards.
- Reuse device-scoped transport and bounded reliable command delivery. - Discover same-user local relay configuration without exporting secrets. - Validate engine selection and support portable checkout-based launchers.
- Project bounded history, live activity, goals, questions and control state. - Preserve authoritative profile catalogs, scoped results and queue receipts. - Keep private queue payloads in one-shot consumers, outside general reports.
85a6a3a to
b046829
Compare
left a comment
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b046829736
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| socket_url = self.url + "?" + urlencode( | ||
| {"machine": self.machine_id} | ||
| ) |
There was a problem hiding this comment.
Preserve automatic device selection when machine is omitted
When the account has only a named/enrolled wrapper, launching either the default workspace or --line-mode without --machine now always requests machine=default. This bypasses the relay's no-query auto-selection path, so restricted accounts are closed as unauthorized and wildcard accounts connect to an empty default route instead of their available wrapper; the previous line client omitted this query and worked in that setup. Keep an omitted CLI value distinct and append the machine query only when the user explicitly selects one.
AGENTS.md reference: AGENTS.md:L102-L106
Useful? React with 👍 / 👎.
| 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" |
There was a problem hiding this comment.
Allow numeric free-text question answers
When an AskUser prompt has both options and allow_text=true, any all-numeric response is unconditionally interpreted as an option index. A valid custom answer such as 2026 or 0 therefore produces “Invalid option selection” instead of being sent as free text, and the dialog provides no escape syntax to force the intended interpretation. Fall back to the allowed text path when the numeric input is not a valid selection, or provide an explicit choice/text mode.
Useful? React with 👍 / 👎.
| + self.client.keys.layer_label("picker", "open") | ||
| + " browses", markup=False | ||
| ) | ||
| yield Label(self.path, id="directory-path", markup=False) |
There was a problem hiding this comment.
Sanitize the initial directory heading
When the directory picker is opened for a session whose cwd contains C0/C1, ANSI, or bidi controls—possible with a crafted Unix checkout name—the initial path is rendered directly before the asynchronous wrapper listing returns. Later refreshes sanitize the path, but this first frame can still inject or visually reorder terminal content; pass the initial value through _safe_remote_text as the update path already does.
Useful? React with 👍 / 👎.
- Share operators, text objects, inclusive Visual selection and jump motions. - Keep reading and editing cursors independent across modal key layers. - Index configurable shortcuts without overriding Vim text commands.
- Expose protocol-backed models, permissions, goals and work controls. - Keep modal search immediate and directory discovery live and bounded. - Correlate private reads, queue edits and explicit authorization results. - Add discoverable queue editing, cancellation and atomic server ordering. - Require coordinated protocol v67 deployment for queue order commands.
- Browse engine/workspace scopes with searchable folders and cached tabs. - Persist the open working set and group archived sessions separately. - Confirm native deletion and close ephemeral side chats through CloseBtw.
- Render Markdown with visible Unicode URLs and stable source coordinates. - Place cached images inline and support full-resolution pan and zoom. - Scope external-file grants and expose local preview truncation clearly.
- Connect session navigation, settings, transcript rendering and drafts. - Follow only at the viewport bottom and preserve anchors during reflow. - Integrate shared queues, questions, confirmations and a configurable stop. - Cover interaction, replay, background events and control-link regressions.
- Provide English and Chinese keyboard, setup and shared-control guides. - Document shortcut configuration, preview boundaries and scroll debugging.
- Project diagram families as bounded, selectable terminal text. - Lay out sequence lifelines, messages and fragments in terminal cells. - Retain unprojected syntax explicitly instead of hiding information. - Offer configurable Web handoff for graphics and interaction. - Keep diagram source and credentials out of browser URLs. - Cover Unicode, bounds, family examples and mounted shortcut dispatch.
b046829 to
c25d03f
Compare
left a comment
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c25d03f7d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| title = {"thinking": "Thinking", "final": "Answering"}.get( | ||
| e.get("channel"), "Processing" | ||
| ) | ||
| turn.activity = str(e.get("title") or e.get("tool") or title) |
There was a problem hiding this comment.
Sanitize activity labels before transcript headers
When a tool or process event supplies a title containing ANSI, C0/C1, or bidi controls, this stores the untrusted value verbatim in TurnDisplay.activity. SessionView.block_header() later interpolates turn.label() into the transcript header, which lies outside the assistant body sanitized by the Markdown projection, so a model or MCP tool can spoof or visually reorder transcript chrome. Pass the title/tool value through _safe_remote_text before retaining or rendering it.
Useful? React with 👍 / 👎.
| if not accepted: | ||
| self.attached_sid, self.attached_engine = previous, previous_engine | ||
| self.engine, self.space = previous_scope |
There was a problem hiding this comment.
Restore the previous tab when a switch is rejected
When a catalog entry disappears, changes Code/Work ownership, or otherwise fails to resume between listing and selection, _send() still returns true because it only admits the reliable command to the outbox. The wrapper later emits an Error, but this rollback runs only for local send failure, leaving attached_sid on the rejected target even though the wrapper remains focused on the previous session; the workspace then shows an unusable stale tab until the user manually switches away. Track the pending switch and restore the prior focus when the latest target is authoritatively rejected.
AGENTS.md reference: AGENTS.md:L127-L131
Useful? React with 👍 / 👎.
| 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 |
There was a problem hiding this comment.
Confirm the requested setting value before clearing pending state
When another client changes the same setting type while this form's command is pending, the new settings dictionary is always a different object from previous, even if its value differs from the value requested here. This branch therefore reports the local change as confirmed and clears pending_change; if the local command is then rejected, its action_results error is no longer inspected and the user sees a false success. Compare the authoritative field value with the requested value (including the fast/default mapping) before retiring the pending ticket.
Useful? React with 👍 / 👎.
Summary
Add an optional, full-screen Textual terminal workspace for cc-remote, based
on the current upstream
master(ddc03f0). This is a client of the existingrelay and wrapper, not a separate Codex/Claude runner. Queue reordering adds protocol v67 and requires a coordinated
Relay/Web/Wrapper/TUI upgrade. The TUI itself does not restart, replace, or
take over the shared app-server.
The goal is to make multi-session work practical from a keyboard without
managing a separate tmux pane for every conversation. Reading position,
selection, and the unsent draft stay independent.
Features
and Code/Work scopes, responsive tabs, background activity/unread markers,
per-session drafts and cached projections, and a privately persisted open
tab set. Closing a tab does not delete a session or stop its task.
objects, selections, jump history, and previous/next user or assistant
message navigation. Copying or quoting text preserves the reading cursor.
Non-Vim shortcuts use a configurable, searchable, layered registry.
visible link URLs, distinct
user/final/progress/tool presentation, live elapsed times, and two-level
activity folds. Outer progress sections start open; tool groups beneath
each progress message start closed and summarize calls/file changes.
independently of focus. Reflow, streamed updates, folding, and image layout
retain the viewport or its source anchor instead of following the cursor.
permissions, goals/plans, usage/context, wrapper-owned queues, background
work, and report/action panels reuse the Python protocol. Async question
answers steer a running session rather than interrupting it; blocking
questions retain their original response path.
clipboard attachments, inline image placement, and a pannable/zoomable
image viewer. Kitty image resources are reused during navigation. New
upstream context-capacity, directory-browsing, and turn-file-page actions
are exposed through the same scoped action/report system.
Architecture and review guide
The branch contains 11 feature-focused, GPG-signed commits. Fixes to this
unmerged TUI are folded into their originating feature modules; only the
pre-existing Work error-correlation bug and Viewer test race remain separate.
Suggested review order:
tui.py,tui_local.py, andtui_actions.py: authenticated transport,local startup discovery, routing, and protocol-backed commands.
tui_state.py,tui_presentation.py,tui_activity.py, andtui_questions.py: bounded history/live projections and turn semantics.tui_vim.py,tui_modal.py,tui_text_objects.py, andtui_keys.py:independent editing modes and scoped key dispatch.
tui_tree.py,tui_buffers.py,tui_tab_store.py,tui_panels.py, andtui_settings.py: session navigation and controls.tui_app.py, Markdown/preview/image modules, andtests/test_tui*.py:rendering, viewport behavior, and mounted keyboard/frame regressions.
The relay implementation is unchanged. Python/Web wire schemas and build
metadata move to v67 for a bounded, compare-and-swap queue-order command.
The wrapper atomically reorders pending queries, broadcasts the authoritative
queue, rejects stale/starting changes, and retains prompt/attachment identity.
It also correlates existing Work deletion errors with their requests.
Textual dependencies are optional for runtime installations and are included
in development requirements for the tests. The original line client remains
available with
--line-mode.Authentication and boundaries
without another password prompt. It does not disable authentication,
persist a second password, or send discovered credentials to another
endpoint. Other configurations still require explicit authentication.
authorization, and mutation confirmation remain enforced.
the client leaves wrapper-owned running tasks and deferred queries intact.
client does not deploy or restart either service.
Try it
In an existing development checkout:
uv pip install --python .venv/bin/python -r requirements-tui.txt .venv/bin/python -m cc_remote.tui --demo # Connect to your configured relay: .venv/bin/python -m cc_remote.tui --engine codexSpace hopens help,Space eopens the session tree,H/Lswitch tabs,and
Ctrl+j/Ctrl+kswitch between draft and transcript. Complete Englishand Chinese guides and a shortcut configuration example are included in
docs/tui.md,docs/tui_zh.md, anddocs/tui-keys.example.toml.Ctrl+xstops the active writable turn without clearing its deferred queue.The binding is configurable and does not fire from tree/dialog interactions.
Terminal image support is capability-dependent. PDF, interactive Viewer,
and other browser-oriented artifacts retain an explicit Web handoff rather
than pretending to provide a full browser inside the terminal.
Review follow-up
Space lprovides queue management:iedits the full prompt,dopenscancellation confirmation, and
K/Jmove the selected item earlier/later.All queue keys are configurable and indexed. Failed-preflight prompts
remain editable; server replies, not optimistic local mutations, drive state.
Closing a preview opened from a numbered file list returns to that same
list page and selection, rather than discarding the list and returning to chat.
Queued submissions retain the complete prompt privately until authoritative
acceptance. A correlated rejection restores it without overwriting a newer
draft; transport ACKs do not discard it, and immediate rejections do not
clear the original draft.
Generic
authorize_previewforms populate the original challenge requestID and preserve it when constructing the command. Other actions still reject
caller-supplied transport request IDs.
Visual selections include both endpoints, including backward selections.
Invalid effective
ENGINEvalues fail before the reconnect loop starts.Local Markdown source/line limits show an explicit truncation marker.
Side-chat deletion uses
CloseBtwand waits for authoritative closure.Mermaid flowcharts render as bounded selectable boxes/arrows in chat and
Markdown previews, including Unicode labels and line wrapping. Unsupported
syntax stays visible as source; no external renderer or script is executed.
Explicit engine/space arguments and
ENGINEoverride saved tab scope;unspecified axes can still be restored.
The WebSocket always selects its machine explicitly, including
default.Work deletion errors carry the command ID and finish the matching waiter.
Detail pages retain both directions:
oreads older details, and configurableOreturns toward newer content. Nested folds display the effective keys.Blocking question choices render the protocol's
dsdescriptions.Queue edits wait for a matching server result, preserve rejected drafts, and
do not treat an ACK or local outbox admission as success.
Directory traversal tests no longer depend on a locally installed
fzf.Tail-follow tests validate the completed history response, not a transient
state. Late directory-picker callbacks cannot access torn-down controls.
Unicode link and image destinations display readable characters, including
Chinese filenames. Encoded URL syntax, controls and literal code stay intact.
Supplemental answers use Web's exact canonical envelope, one native question
message at a time. Other unanswered questions remain reachable.
Every pending blocking question is read from the full session collection,
not the legacy single-question slot.
Background session errors remain in their own transcript without replacing
the focused workspace notice; global errors remain visible.
External-file authorization reuses the challenged request ID and accepts
only the matching authorization result, not a late file-read response.
Direct session IDs wait for catalog-provided engine/space before prewarming;
retained tabs removed from an authoritative catalog become unavailable.
Ambiguous supplemental replies keep their candidate questions pending, and
blank blocking answers are rejected before entering the reliable outbox.
Attachment payloads survive transport ACKs until authoritative delivery;
correlated rejections restore them without replacing newer draft files.
Completion acknowledgements can retry after reliable-outbox backpressure.
Discontinuous history replaces old source rows while preserving a proven
newer live tail. Rollback artifact epochs invalidate inline image caches.
Projection comparisons and rendering use the same session/cwd/artifact
identity, avoiding a transcript rebuild on every 100 ms timer tick.
Missing optional TUI dependencies produce installation guidance; unrelated
import failures remain visible rather than being masked.
Explicit Work deletions enter the correlated confirmation/result flow.
WebSocket and image-authorization fixtures model catalog discovery and
original request IDs. The Viewer revocation test uses atomic registry
updates and checks denial both before and after catalog propagation.
The launcher resolves chained symlinks without GNU-only
readlink -f,including relative targets and paths with spaces on BSD/macOS utilities.
Rejected catalog requests restore their prior correlation fence and retry
after outbox backpressure. Invalidated catalogs stay fenced; immediate
replies to admitted requests are still accepted.
BTW side chats refresh inherited scope, directory, and title after parent
catalogs arrive, so Hello ordering does not strand Work side chats in Code.
Goal read fences apply only to identified GetGoal replies and retire on
completion. Set/Clear/Dismiss confirmations remain authoritative, and late
reads cannot overwrite a newer mutation.
Full queued prompts go only to the correlated one-shot editor, never to
persistent Reports, including late replies after editor teardown.
Partial account catalog failures preserve the unavailable profile's prior
sessions and control state, matching Web normalization. Successful profiles
still apply deletions normally, and incoming rows replace retained metadata.
Validation
Latest follow-up:
6cdbbbfatomic reorder, automatic drain order, stale/starting rejection, replay
deduplication, preview navigation, configurable keys and protocol routing.
cc_remote,tests, anddeploy.git diff --checkpassed.autosquash preserved the exact tested source tree.
these targeted results are not a claim that full or remote CI is green.
be deployed to all tiers together before connecting this client.
Previous validated head:
47f75d1All required local gate commands completed successfully against
47f75d1.The container's tracked-source checksums matched that committed snapshot.
root-bypassed
/procpermission check, macOS kernel API, optionalfzf,and explicitly opt-in real-model E2E scenario.
this also exercises the
/procpermission and realfzfcases.Viewer resource/discovery unit tests also passed.
git diff --checkpassed.
Web source, browser assertions, and browser skip conditions are unchanged.
Native-host browser attempts with layout/scroll failures were not counted as
passes; the complete passing browser gate ran in the Ubuntu CI environment.
No running relay, wrapper, or Codex daemon was deployed or restarted.
Remote CI must also be green before merge.