Skip to content

feat(tui): Alt+C copy hotkey and harden copy-as-markdown behavior#16966

Open
fcoury-oai wants to merge 28 commits intomainfrom
fcoury/copy-as-markdown
Open

feat(tui): Alt+C copy hotkey and harden copy-as-markdown behavior#16966
fcoury-oai wants to merge 28 commits intomainfrom
fcoury/copy-as-markdown

Conversation

@fcoury-oai
Copy link
Copy Markdown
Contributor

@fcoury-oai fcoury-oai commented Apr 7, 2026

TL;DR

  • New Alt+C shortcut on top of the existing /copy command, allowing users to copy a plan without having to cancel the plan and type /copy
  • Copy server clipboard to the client over SSH (OSC 52)
  • Fixes linux copy behavior: a clipboard handle has to be kept alive while the paste happens for the contents to be preserved
  • Uses arboard as primary mechanism on Windows, falling back to PowerShell copy clipboard function
  • Works with resumes, rolling back during a session, etc.

Tested on macOS, Linux/X11, Windows WSL2, Windows cmd.exe, Windows PowerShell, Windows VSCode PowerShell, Windows VSCode WSL2, SSH (macOS -> macOS).

Problem

The TUI's /copy command was fragile. It relied on a single last_copyable_output field that was bluntly cleared on every rollback and thread reconfiguration, making copied content unavailable after common operations like backtracking. It also had no keyboard shortcut, requiring users to type /copy each time. The previous clipboard backend mixed platform selection policy with low-level I/O in a way that was hard to test, and it did not keep the Linux clipboard owner alive — meaning pasted content could vanish once the process that wrote it dropped its arboard::Clipboard.

This addresses the text-copy failure modes reported in #12836, #15452, and #15663: native Linux clipboard access failing in remote or unreachable-display environments, copy state going blank even after visible assistant output, and local Linux X11 reporting success while leaving the clipboard empty.

Mental model

Agent responses are now tracked as a bounded, ordinal-indexed history (agent_turn_markdowns: Vec<AgentTurnMarkdown>) rather than a single nullable string. Each completed agent turn appends an entry keyed by its ordinal (the number of user turns seen so far). Rollbacks pop entries whose ordinal exceeds the remaining turn count, then use the visible transcript cells as a best-effort fallback if the ordinal history no longer has a surviving entry. This means /copy and Alt+C reflect the most recent surviving agent response after a backtrack, instead of going blank.

The clipboard backend was rewritten as clipboard_copy.rs with a strategy-injection design: copy_to_clipboard_with accepts closures for the OSC 52, arboard, and WSL PowerShell paths, making the selection logic fully unit-testable without touching real clipboards. On Linux, the Clipboard handle is returned as a ClipboardLease stored on ChatWidget, keeping X11/Wayland clipboard ownership alive for the lifetime of the TUI. When native copy fails under WSL, the backend now tries the Windows clipboard through PowerShell before falling back to OSC 52.

Non-goals

  • This change does not introduce rich-text (HTML) clipboard support; the copied content is raw markdown.
  • It does not add a paste-from-history picker or multi-entry clipboard ring.
  • WSL support remains a best-effort fallback, not a new configuration surface or guarantee for every terminal/host combination.

Tradeoffs

  • Bounded history (256 entries): MAX_AGENT_COPY_HISTORY caps memory. For sessions with thousands of turns this silently drops the oldest entries. The cap is generous enough for realistic sessions.
  • saw_copy_source_this_turn flag: Prevents double-recording when both AgentMessage and TurnComplete.last_agent_message fire for the same turn. The flag is reset on turn start and on turn complete, creating a narrow window where a race between the two events could theoretically skip recording. In practice the protocol delivers them sequentially.
  • Transcript fallback on rollback: last_agent_markdown_from_transcript walks the visible transcript cells to reconstruct plain text when the ordinal history has been fully truncated. This path uses AgentMessageCell::plain_text() which joins rendered spans, so it reconstructs display text rather than the original raw markdown. It keeps visible text copyable after rollback, but responses with markdown-specific syntax can diverge from the original source.
  • Clipboard fallback ordering: SSH still uses OSC 52 exclusively because native/PowerShell clipboard access would target the wrong machine. Local sessions try native clipboard first, then WSL PowerShell when running under WSL, then OSC 52. This adds one process-spawn fallback for WSL users but keeps the normal desktop and SSH paths simple.

Architecture

chatwidget.rs
├── agent_turn_markdowns: Vec<AgentTurnMarkdown>  // ordinal-indexed history
├── last_agent_markdown: Option<String>            // always == last entry's markdown
├── completed_turn_count: usize                    // incremented when user turns enter history
├── saw_copy_source_this_turn: bool                // dedup guard
├── clipboard_lease: Option<ClipboardLease>        // keeps Linux clipboard owner alive
│
├── record_agent_markdown(&str)                    // append/update history entry
├── truncate_agent_turn_markdowns_to_turn_count()  // rollback support
├── copy_last_agent_markdown()                     // public entry point (slash + hotkey)
└── copy_last_agent_markdown_with(fn)              // testable core

clipboard_copy.rs
├── copy_to_clipboard(text) -> Result<Option<ClipboardLease>>
├── copy_to_clipboard_with(text, ssh, wsl, osc52_fn, arboard_fn, wsl_fn)
├── ClipboardLease { _clipboard on linux }
├── arboard_copy(text)          // platform-conditional native clipboard path
├── wsl_clipboard_copy(text)    // WSL PowerShell fallback
├── osc52_copy(text)            // /dev/tty -> stdout fallback
├── SuppressStderr              // macOS stderr redirect guard
├── is_ssh_session()
└── is_wsl_session()

app_backtrack.rs
├── last_agent_markdown_from_transcript()  // reconstruct from visible cells
└── truncate call sites in trim/apply_confirmed_rollback

Observability

  • tracing::warn! on native clipboard failure before OSC 52 fallback.
  • tracing::debug! on /dev/tty open/write failure before stdout fallback.
  • History cell messages: "Copied last message to clipboard", "Copy failed: {error}", "No agent response to copy" appear in the TUI transcript.

Tests

  • clipboard_copy.rs: Unit tests cover OSC 52 encoding roundtrip, payload size rejection, writer output, SSH-only OSC52 routing, non-WSL native-to-OSC52 fallback, WSL native-to-PowerShell fallback, WSL PowerShell-to-OSC52 fallback, and all-error reporting via strategy injection.
  • chatwidget/tests/slash_commands.rs: Updated existing /copy tests to use last_agent_markdown_text() accessor. Added coverage for the Linux clipboard lease lifecycle, missing TurnComplete.last_agent_message fallback through completed assistant items, replayed legacy agent messages, and stale-output prevention after rollback.
  • app_backtrack.rs: Added agent_group_count_ignores_context_compacted_marker verifying that info-event cells don't inflate the agent group count.

@etraut-openai etraut-openai added the oai PRs contributed by OpenAI employees label Apr 7, 2026
@fcoury-oai fcoury-oai changed the title feat(tui): Add Alt+C hotkey and harden copy-as-markdown behavior feat(tui): Alt+C hotkey and harden copy-as-markdown behavior Apr 7, 2026
@AnttiJalomaki
Copy link
Copy Markdown

Nice work on the clipboard hardening. Just wanted to share my quickly made fix, where Alt+C keybinding itself can be done in 12 lines, with reusing the existing /copy dispatch.

AnttiJalomaki@84453d60d

The approach is a single KeyEvent match arm in ChatWidget::handle_key_event that calls self.dispatch_command(SlashCommand::Copy).

It works on all platforms since it goes through the existing clipboard_text.rs backend.
I've been running this on Fedora 43 KDE Wayland and it works well.

Obviously this doesn't include your big fixes. This is just keybinding for quick fix and minimal patch, if the team wants to land on quick shortcut while the larger rewrite is tested thoroughly for the roadmap vision. I'd like to have more features in the copy to clipboard and paste from clipboard in the future, what the big rewrite enables. For example image pasting is currently broken.

@fcoury-oai fcoury-oai force-pushed the fcoury/copy-as-markdown branch 2 times, most recently from 1b807bc to 9517b15 Compare April 7, 2026 18:12
@fcoury-oai fcoury-oai requested a review from etraut-openai April 7, 2026 18:56
@fcoury-oai fcoury-oai changed the title feat(tui): Alt+C hotkey and harden copy-as-markdown behavior feat(tui): Alt+C copy hotkey and harden copy-as-markdown behavior Apr 7, 2026
fcoury and others added 22 commits April 7, 2026 16:01
Promote non-empty TurnItem::Plan text to the active copy source so Alt+C and /copy copy the final plan output instead of prior commentary in the same turn. Adds regression coverage for replayed user message + commentary + completed plan item flow.
Rename the per-turn copy-source flag to reflect non-AgentMessage sources, clarify ordinal docs for copy-history entries, and make the backtrack continuation-group test assert real merged output.
Record rendered review output on ExitedReviewMode so Alt+C and /copy use the latest review content, including explanation-only and findings-based review results. Adds regression tests for both paths.
Remove the newer /copy cache and clipboard_text module so the rebased
branch consistently uses the markdown-history implementation.

Update slash command tests and snapshots to assert the markdown history
state, including rollback truncation and item-completion fallback.
Keep the native Linux `arboard::Clipboard` in a TUI-held lease after
`/copy` succeeds so X11 and Wayland clipboard contents remain available
while Codex is running.

Thread the optional lease through copy handling, preserve the previous
lease on failed copies, and cover the behavior in focused copy tests.
Remove the empty rollback callback now that copy-state rollback is
owned by app_backtrack, and document the copy-state invariants.
Restore the WSL PowerShell clipboard fallback after native copy fails
so WSL users are not dependent on terminal OSC 52 support.

Keep SSH on OSC 52 only, preserve native clipboard as the first local
path, and cover the fallback ordering with injected backend tests.
Add required argument comments to new copy-as-markdown callsites.

This keeps the Bazel argument-comment lint aligned with CI.
Explain that the ChatWidget lease keeps copied clipboard text available while the platform requires the lease to be held.
Wrap OSC 52 clipboard copies in tmux passthrough sequences when
the TUI is running under `TMUX`.

This keeps SSH and terminal fallback clipboard copy working for
nested tmux sessions.
Advance the copy turn count when locally rendered prompts are added to
history, so resumed threads do not overwrite prior assistant markdown
when a new response arrives before rollback.

Use the recorded item-level assistant markdown as the completion
notification fallback when `TurnComplete` omits final text, while
avoiding stale notification previews across later empty completions.
Construct new slash command test turn-completion events through serde so
they compile against both the current branch protocol shape and the newer
PR merge-base shape with completion timing fields.
Add the required parameter comments to the new turn-completion test
helper calls so the Bazel argument-comment lint accepts the explicit
`None` fallback cases.
@fcoury-oai fcoury-oai force-pushed the fcoury/copy-as-markdown branch from 9517b15 to 36b6896 Compare April 7, 2026 19:02
fcoury-oai and others added 2 commits April 7, 2026 16:09
Add inline comments to record_agent_markdown and on_task_complete
explaining the turn ordinal computation and saw_copy_source_this_turn
guard, plus a doc comment on the copy_last_agent_markdown_with testing
seam.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Annotate the three AgentMessage match arms that now feed
record_agent_markdown (previously no-ops), the ThreadRolledBack
handler pointing to app_backtrack for cleanup, and the
completed_turn_count bump that drives ordinal assignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oai PRs contributed by OpenAI employees

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants