diff --git a/.gitattributes b/.gitattributes
index 497fe6c8..b1a8b087 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,3 +3,6 @@
# release time. Say so explicitly so no future line-ending rule can rewrite it.
assets/windows/conpty/**/*.dll binary
assets/windows/conpty/**/*.exe binary
+
+# include_str! fixtures are compared byte-for-byte; autocrlf would add \r on Windows.
+src/terminal/view/fixtures/*.txt -text
diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs
index fe7af692..017ff05d 100644
--- a/crates/tty7-core/src/core/config.rs
+++ b/crates/tty7-core/src/core/config.rs
@@ -381,6 +381,10 @@ pub struct Config {
#[serde(default, deserialize_with = "de_lenient")]
pub mouse_zoom_modifier: MouseZoomModifier,
pub clipboard_trim_trailing_spaces: bool,
+ /// In a Claude Code pane, copy joins the rows Claude wrapped itself, so a
+ /// paragraph pastes as one line. ⌘⌥C and Copy Raw copy the rows as is.
+ #[serde(default)]
+ pub copy_join_wrapped: bool,
pub copy_on_select: bool,
/// Optional HTTP/SOCKS proxy for tty7's *own* update checks and release
/// downloads, and (HTTP only) the mobile gateway's relay; when set it is
@@ -854,6 +858,7 @@ impl Default for Config {
mouse_reporting: true,
mouse_zoom_modifier: MouseZoomModifier::default(),
clipboard_trim_trailing_spaces: false,
+ copy_join_wrapped: false,
copy_on_select: false,
http_proxy: None,
smart_select: true,
diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx
index 9c53fc7c..90bcfe2d 100644
--- a/docs/reference/configuration.mdx
+++ b/docs/reference/configuration.mdx
@@ -145,6 +145,7 @@ their id from the file name. [More about themes →](/customization/themes)
| `word_separators` | string | see below | Characters that end a word. Used when smart selection is off. |
| `copy_on_select` | bool | `false` | |
| `clipboard_trim_trailing_spaces` | bool | `false` | |
+| `copy_join_wrapped` | bool | `false` | In a Claude Code pane, copy rejoins the lines Claude wrapped at the pane's edge, so a paragraph pastes as one line; other panes copy as shown. ⌘⌥C and Copy Raw copy the rows as shown. |
| `macos_option_as_alt` | bool | `false` | ⌥+key sends the escape chord instead of typing a special character. |
| `keybindings` | object | `{}` | `{"SplitRight": "cmd-d"}`. [Syntax →](/customization/keybindings) |
| `keybinding_preset` | string | `"default"` | Or `"tmux"`. |
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index 609da591..24db33c6 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -35,6 +35,8 @@ use crate::core::shell_quote::quote_for_shell;
use crate::daemon::protocol::{RemoteContext, ShellSpec};
use crate::ui::i18n::{L10nKey, t, t_fmt};
+mod copy_unwrap;
+
pub(super) const GRID_PAD_X: f32 = 8.;
pub(super) const GRID_PAD_Y: f32 = 4.;
@@ -2638,6 +2640,10 @@ impl TerminalView {
return;
}
+ if m.platform && m.alt && !m.control && ks.key == "c" && self.copy_raw(cx) {
+ cx.stop_propagation();
+ return;
+ }
if m.platform && !m.control && !m.alt {
match self.handle_cmd_shortcut(ks, window, cx) {
CmdKey::Consumed => {
@@ -3554,7 +3560,11 @@ impl TerminalView {
}
pub fn copy_selection(&mut self, cx: &mut Context) {
- let text = self.terminal.term.lock().selection_to_string();
+ self.copy_selection_as(false, cx);
+ }
+
+ fn copy_selection_as(&mut self, raw: bool, cx: &mut Context) {
+ let text = copy_unwrap::selection_text(self, raw, cx);
if let Some(mut text) = text {
if cx.global::().clipboard_trim_trailing_spaces {
text = trim_trailing_spaces(&text);
diff --git a/src/terminal/view/copy_unwrap.rs b/src/terminal/view/copy_unwrap.rs
new file mode 100644
index 00000000..c7a33dee
--- /dev/null
+++ b/src/terminal/view/copy_unwrap.rs
@@ -0,0 +1,503 @@
+//! Copy rejoins the rows a program wrapped itself. Claude Code (Ink) wraps at
+//! the pane's width by writing CRLF plus the block's indent, so the grid never
+//! sets WRAPLINE on those rows and a plain copy keeps every break. The rule
+//! was measured on 16 captures of Claude panes; `fixtures/` holds three.
+
+use alacritty_terminal::Term;
+use alacritty_terminal::grid::Dimensions;
+use alacritty_terminal::index::{Column, Line, Point};
+use alacritty_terminal::term::cell::Flags;
+use gpui::Context;
+use unicode_width::UnicodeWidthChar;
+
+use super::TerminalView;
+use crate::core::cli_agent::CLIAgent;
+use crate::core::config::Config;
+
+/// One line as the copy sees it: the grid's rows up to the next one without
+/// WRAPLINE, read from column 0 whatever the selection's start.
+struct Row {
+ text: String,
+ /// Where the last physical row's text ends, in cells.
+ end: usize,
+}
+
+/// What `copy_selection` copies: the selection with Claude's wrapped rows
+/// joined, in a Claude pane with `copy_join_wrapped` on and `raw` off. Every
+/// other program's output is copied as the grid holds it: `ls` columns,
+/// separators and an editor's own wraps all look like Claude's.
+pub(super) fn selection_text(
+ view: &TerminalView,
+ raw: bool,
+ cx: &Context,
+) -> Option {
+ let join = !raw
+ && cx.global::().copy_join_wrapped
+ && view.terminal.foreground_agent() == Some(CLIAgent::Claude);
+ let term = view.terminal.term.lock();
+ if join {
+ joined(&term)
+ } else {
+ term.selection_to_string()
+ }
+}
+
+/// The selection's text with app-wrapped rows joined. Any doubt (block
+/// selection, a row count that doesn't line up) copies raw.
+fn joined(term: &Term) -> Option {
+ let raw = term.selection_to_string()?;
+ let Some(range) = term.selection.as_ref().and_then(|s| s.to_range(term)) else {
+ return Some(raw);
+ };
+ if range.is_block {
+ return Some(raw);
+ }
+ let rows = grid_rows(term, range.start.line, range.end.line);
+ Some(rejoin(&raw, &rows, term.columns()))
+}
+
+/// `raw` split at its newlines, which must be one per row boundary.
+fn rejoin(raw: &str, rows: &[Row], cols: usize) -> String {
+ // A Lines selection ends in a newline its rows don't account for.
+ let (body, tail) = match raw.strip_suffix('\n') {
+ Some(body) if body.split('\n').count() == rows.len() => (body, "\n"),
+ _ => (raw, ""),
+ };
+ let parts: Vec<&str> = body.split('\n').collect();
+ if parts.len() != rows.len() {
+ return raw.to_string();
+ }
+ apply(&parts, &joins(rows, cols)) + tail
+}
+
+fn grid_rows(term: &Term, first: Line, last: Line) -> Vec {
+ let cols = term.columns();
+ let mut rows = Vec::new();
+ let mut text = String::new();
+ let mut line = first;
+ while line <= last {
+ let grid_row = &term.grid()[line];
+ text += &term.bounds_to_string(
+ Point::new(line, Column(0)),
+ Point::new(line, Column(cols - 1)),
+ );
+ let wraps = grid_row[Column(cols - 1)].flags.contains(Flags::WRAPLINE);
+ if !wraps || line == last {
+ let end = (0..cols)
+ .rev()
+ .find(|&c| {
+ let cell = &grid_row[Column(c)];
+ cell.c != ' ' && !cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER)
+ })
+ .map_or(0, |c| {
+ c + 1 + grid_row[Column(c)].flags.contains(Flags::WIDE_CHAR) as usize
+ });
+ rows.push(Row {
+ text: std::mem::take(&mut text).trim_end_matches('\n').to_string(),
+ end,
+ });
+ }
+ line += 1;
+ }
+ rows
+}
+
+/// What goes between two rows.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Sep {
+ Newline,
+ Space,
+ /// Ink broke inside a word: a row that is one word, or wide (CJK) text,
+ /// which wraps between any two characters.
+ Nothing,
+}
+
+/// For each boundary between `rows[i]` and `rows[i + 1]`, what goes there.
+fn joins(rows: &[Row], cols: usize) -> Vec {
+ let mut out = Vec::with_capacity(rows.len().saturating_sub(1));
+ // (leading spaces, text column) of the row that opened the current run.
+ let mut opened: Option<(usize, usize)> = None;
+ let mut fenced = false;
+ // The indent of a `⎿` tool result whose rows we are inside.
+ let mut tool: Option = None;
+ for pair in rows.windows(2) {
+ let (a, b) = (&pair[0], &pair[1]);
+ let a_trim = a.text.trim();
+ if a_trim.starts_with("```") {
+ fenced = !fenced;
+ }
+ if !a_trim.is_empty() {
+ if tool.is_some_and(|t| lead(&a.text) <= t) {
+ tool = None;
+ }
+ if a_trim.starts_with('⎿') {
+ tool = Some(lead(&a.text));
+ }
+ }
+ let (lo, hi) = *opened.get_or_insert_with(|| (lead(&a.text), text_col(&a.text)));
+ let b_trim = b.text.trim();
+ let word = cells(b_trim.split(' ').next().unwrap_or(""));
+ // Ink breaks one cell early when the next word would land exactly on
+ // the edge, so a word that just fits still counts as not fitting.
+ // A word too long for any row tells nothing about where `a` ended.
+ let full = a.end + 1 + word >= cols && lead(&b.text) + word <= cols;
+ let join = full
+ && !fenced
+ && tool.is_none()
+ && !b_trim.is_empty()
+ && b_trim
+ .chars()
+ .next()
+ .is_some_and(|c| c.is_ascii() || c.is_alphabetic())
+ && !a_trim.ends_with('…')
+ && (lo..=hi).contains(&lead(&b.text))
+ && text_col(&b.text) == lead(&b.text)
+ && !structural(&a.text)
+ && !structural(&b.text);
+ let wide = |c: Option| c.is_some_and(|c| c.width() == Some(2));
+ // A one-word row that fills the row is a word Ink cut in two: its hard
+ // breaks fill to the edge, its word wraps stop short of it.
+ let one_word = a.end >= cols && !a.text[text_byte(&a.text)..].trim_end().contains(' ');
+ out.push(if !join {
+ opened = None;
+ Sep::Newline
+ } else if one_word || (wide(a_trim.chars().last()) && wide(b_trim.chars().next())) {
+ Sep::Nothing
+ } else {
+ Sep::Space
+ });
+ }
+ out
+}
+
+fn apply(parts: &[&str], seps: &[Sep]) -> String {
+ let mut out = parts[0].to_string();
+ for (part, &sep) in parts[1..].iter().zip(seps) {
+ if sep == Sep::Newline {
+ out.push('\n');
+ out.push_str(part);
+ continue;
+ }
+ out.truncate(out.trim_end().len());
+ if sep == Sep::Space {
+ out.push(' ');
+ }
+ out.push_str(part.trim_start());
+ }
+ out
+}
+
+/// Cells `text` takes in the grid, which sizes each char on its own: VS16
+/// adds nothing, so `⚠️` is one cell, not the two `str` width gives it.
+fn cells(text: &str) -> usize {
+ text.chars().map(|c| c.width().unwrap_or(0)).sum()
+}
+
+fn lead(text: &str) -> usize {
+ text.len() - text.trim_start_matches(' ').len()
+}
+
+/// Where a row's text starts once a list or block marker is skipped: `- `,
+/// `1. `, or a run of symbols and a space (`⏺ `, `⚠️ `, `◯ `).
+fn text_col(text: &str) -> usize {
+ let at = text_byte(text);
+ lead(text) + cells(&text[lead(text)..at])
+}
+
+/// The byte where a row's text starts, past its indent and any marker.
+fn text_byte(text: &str) -> usize {
+ let indent = lead(text);
+ let rest = &text[indent..];
+ let Some((marker, _)) = rest.split_once(' ') else {
+ return indent;
+ };
+ let digits = marker.trim_end_matches(['.', ')']);
+ let is_marker = matches!(marker, "-" | "*" | "+" | "•")
+ || (marker.len() > digits.len()
+ && (1..=3).contains(&digits.len())
+ && digits.bytes().all(|b| b.is_ascii_digit()))
+ || (!marker.is_empty()
+ && marker
+ .chars()
+ .all(|c| !c.is_ascii() && !c.is_alphanumeric()));
+ if is_marker {
+ indent + marker.len() + 1
+ } else {
+ indent
+ }
+}
+
+/// Box drawing, block elements, a raw table row, or a prompt: never joined.
+fn structural(text: &str) -> bool {
+ let t = text.trim();
+ text.chars().any(|c| ('\u{2500}'..='\u{259f}').contains(&c))
+ || ["|", "$ ", "> ", "❯ ", "```", "⎿"]
+ .iter()
+ .any(|p| t.starts_with(p))
+}
+
+impl TerminalView {
+ /// ⌘⌥C and Copy Raw: Copy, with the grid's rows as it holds them.
+ pub(crate) fn copy_raw(&mut self, cx: &mut Context) -> bool {
+ let editor_selection = self.input_active() && self.cmd.selected_text().is_some();
+ if editor_selection || !self.has_selection() {
+ return self.copy_contextual(false, cx);
+ }
+ self.copy_selection_as(true, cx);
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use alacritty_terminal::event::VoidListener;
+ use alacritty_terminal::index::Side;
+ use alacritty_terminal::selection::{Selection, SelectionType};
+
+ /// Rows the way `tty7 capture --plain` prints them; a capture line longer
+ /// than the pane was soft-wrapped, so its last row is the remainder.
+ fn unwrap(text: &str, cols: usize) -> String {
+ let rows: Vec = text
+ .split('\n')
+ .map(|p| {
+ let w = cells(p.trim_end());
+ Row {
+ text: p.to_string(),
+ end: if w > cols { (w - 1) % cols + 1 } else { w },
+ }
+ })
+ .collect();
+ rejoin(text, &rows, cols)
+ }
+
+ fn term(cols: usize, lines: usize, input: &str) -> Term {
+ let mut term = Term::new(
+ alacritty_terminal::term::Config::default(),
+ &crate::terminal::size::TermSize::new(cols, lines),
+ VoidListener,
+ );
+ alacritty_terminal::vte::ansi::Processor::::new()
+ .advance(&mut term, input.as_bytes());
+ term
+ }
+
+ fn select(
+ term: &mut Term,
+ ty: SelectionType,
+ from: (i32, usize),
+ to: (i32, usize),
+ ) {
+ let mut sel = Selection::new(ty, Point::new(Line(from.0), Column(from.1)), Side::Left);
+ sel.update(Point::new(Line(to.0), Column(to.1)), Side::Right);
+ term.selection = Some(sel);
+ }
+
+ const C111: &str = include_str!("fixtures/copy_unwrap_111.txt");
+ const C172: &str = include_str!("fixtures/copy_unwrap_172.txt");
+ const C_CODE: &str = include_str!("fixtures/copy_unwrap_code.txt");
+
+ fn has_line(out: &str, line: &str) -> bool {
+ out.lines().any(|l| l == line)
+ }
+
+ #[test]
+ fn wrapped_prose_joins() {
+ let out = unwrap(C111, 111);
+ assert!(has_line(
+ &out,
+ "⏺ #84 is now based on the new patch-stack main-niu, and GitHub reports it can merge cleanly. Its code is the same as what passed review: the only change is that its notes moved into the hotkey-window feature page. Its tests pass locally."
+ ));
+ assert!(has_line(
+ &out,
+ "※ recap: The goal is for tty7 to show the hotkey window on launch, instead of creating a new workspace, when it's your only workspace. That's PR #84, which passed review and is waiting on CI. Next, it merges and tty7 reloads on its own, and I'll check your sessions survive. (disable recaps in /config)"
+ ));
+ // A system line Claude continues at column 0.
+ assert!(has_line(
+ &out,
+ "⏺ Background command \"Merge #84 once CI passes (network-error tolerant), then reload tty7\" completed (exit code 0)"
+ ));
+ let out = unwrap(C172, 172);
+ assert!(has_line(
+ &out,
+ "※ recap: Goal: clean up shuck findings in your dotfiles, most-used first; the shell startup files are done and committed locally. Next, pick Z1, Z2 or Z3 for the no-op `unset GLOBAL_RCS` line, and say \"push\" for the 19 unpushed commits. (disable recaps in /config)"
+ ));
+ // `&&/||` in prose is not a table.
+ assert!(out.contains(
+ "fixing &&/|| logic into proper if/else, correcting a comment-continuation bug, adding"
+ ));
+ }
+
+ #[test]
+ fn the_reported_paragraph_joins() {
+ let rows = "⏺ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would\n be a bug.";
+ assert_eq!(
+ unwrap(rows, 111),
+ "⏺ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would be a bug."
+ );
+ }
+
+ #[test]
+ fn list_items_keep_their_newlines() {
+ let out = unwrap(C111, 111);
+ assert!(out.contains(" Here's how to try it:\n 1. Close every workspace except the hotkey one.\n 2. Quit tty7 and open it again.\n"));
+ let bullets = " - Rule location: the rule is .claude/rules/app-layout.md, plus its Cursor copy and a pointer from the backend\n skill.\n - What it says: nothing new goes under /services/, and other apps import only .public and\n .models.";
+ assert_eq!(
+ unwrap(bullets, 111),
+ " - Rule location: the rule is .claude/rules/app-layout.md, plus its Cursor copy and a pointer from the backend skill.\n - What it says: nothing new goes under /services/, and other apps import only .public and .models."
+ );
+ }
+
+ #[test]
+ fn code_tables_and_tool_output_never_join() {
+ let out = unwrap(C111, 111);
+ // A box drawn round a summary.
+ assert!(out.contains(" │ joins when ... the line runs to the right edge (the next word wouldn't\n │ have fit)"));
+ // A tool call's command and its char-wrapped output stay as the grid has them.
+ assert!(out.contains("tasks/befky\n 62qi.output;"));
+ assert!(out.contains(
+ "https://github.com/NorthIsUp/tty7/actions/runs/369394730\n 36/job/110627346251"
+ ));
+ assert!(out.contains("panicked at crates/tty7\n -core/src/host/server.rs"));
+ let fenced = "```\nlet joined = rows.iter().map(|r| r.text.as_str()).collect::>().join(\" \"); // a long line of code\nthat continues\n```";
+ let code_width = cells(fenced.lines().nth(1).unwrap());
+ assert_eq!(unwrap(fenced, code_width), fenced);
+ let table = "| a long table cell that runs right up to the edge of the pane, wider than the |\nrest |";
+ assert_eq!(unwrap(table, 80), table);
+ }
+
+ #[test]
+ fn a_short_row_before_an_indented_row_does_not_join() {
+ let out = unwrap(C111, 111);
+ assert!(out.contains(" Here's how to try it:\n 1."));
+ let rows = " Still open on your side: rotate the key.\n Then rerun the push.";
+ assert_eq!(unwrap(rows, 111), rows);
+ }
+
+ #[test]
+ fn a_narrow_pane_joins_the_same() {
+ // The reported paragraph and a list item, as Ink wraps them at 52 columns.
+ let rows = "⏺ If the Theme field won't change or the popup comes\n back after they pick it, send me a screenshot.\n That would be a bug.\n\n 1. Close every workspace except the hotkey one,\n then quit tty7 and open it again.\n 2. Quit tty7.";
+ assert_eq!(
+ unwrap(rows, 52),
+ "⏺ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would be a bug.\n\n 1. Close every workspace except the hotkey one, then quit tty7 and open it again.\n 2. Quit tty7."
+ );
+ }
+
+ #[test]
+ fn the_grid_selection_joins_from_a_mid_row_start() {
+ let soft = "a".repeat(45);
+ let mut term = term(
+ 40,
+ 6,
+ &format!(
+ "⏺ If the Theme field won't change or\r\n the popup comes back after they pick\r\n it.\r\n{soft}"
+ ),
+ );
+ select(&mut term, SelectionType::Simple, (0, 2), (4, 39));
+ assert_eq!(
+ joined(&term).unwrap(),
+ format!(
+ "If the Theme field won't change or the popup comes back after they pick it.\n{soft}"
+ )
+ );
+ }
+
+ #[test]
+ fn wide_text_joins_without_a_space() {
+ // Ten CJK chars fill 20 cells; Ink wraps between any two.
+ let mut term = term(20, 3, "这是一个很长的中文句\r\n继续写完。");
+ select(&mut term, SelectionType::Simple, (0, 0), (1, 19));
+ assert_eq!(joined(&term).unwrap(), "这是一个很长的中文句继续写完。");
+ }
+
+ #[test]
+ fn wide_on_one_side_only_keeps_the_space() {
+ let emoji = format!(" {} 🎉\n Next steps", "w".repeat(36));
+ assert_eq!(
+ unwrap(&emoji, 42),
+ format!(" {} 🎉 Next steps", "w".repeat(36))
+ );
+ let mixed = format!(" {} 詳しくは\n README を", "w".repeat(30));
+ assert_eq!(
+ unwrap(&mixed, 42),
+ format!(" {} 詳しくは README を", "w".repeat(30))
+ );
+ }
+
+ #[test]
+ fn a_block_selection_copies_raw() {
+ let mut term = term(
+ 40,
+ 3,
+ "⏺ If the Theme field won't change or\r\n the popup comes back",
+ );
+ select(&mut term, SelectionType::Block, (0, 0), (1, 39));
+ assert_eq!(joined(&term), term.selection_to_string());
+ assert!(joined(&term).unwrap().contains("or\n"));
+ }
+
+ #[test]
+ fn a_lines_selection_keeps_its_trailing_newline() {
+ let mut term = term(
+ 40,
+ 3,
+ "⏺ If the Theme field won't change or\r\n the popup comes back",
+ );
+ select(&mut term, SelectionType::Lines, (0, 0), (1, 0));
+ assert_eq!(
+ joined(&term).unwrap(),
+ "⏺ If the Theme field won't change or the popup comes back\n"
+ );
+ }
+
+ #[test]
+ fn rows_that_dont_line_up_with_the_text_copy_raw() {
+ let rows = [Row {
+ text: "x".repeat(40),
+ end: 40,
+ }];
+ let raw = format!("{}\nmore", "x".repeat(40));
+ assert_eq!(rejoin(&raw, &rows, 40), raw);
+ }
+
+ #[test]
+ fn a_one_word_row_at_the_edge_joins_without_a_space() {
+ let url = "https://github.com/NorthIsUp/tty7/actions/runs/36939473036";
+ let (head, tail) = url.split_at(40);
+ let rows = format!(" {head}\n {tail} is the run.");
+ assert_eq!(unwrap(&rows, 42), format!(" {url} is the run."));
+ }
+
+ #[test]
+ fn a_vs16_emoji_is_one_cell() {
+ assert_eq!(cells("⚠️"), 1);
+ // 2 + "⚠️ " (2 cells) + 37 = 41 cells: the next word would not fit at 44.
+ let rows = format!(" ⚠️ {}\n more words", "w".repeat(37));
+ assert_eq!(
+ unwrap(&rows, 44),
+ format!(" ⚠️ {} more words", "w".repeat(37))
+ );
+ assert_eq!(unwrap(&rows, 48), rows);
+ }
+
+ #[test]
+ fn a_wrapped_code_line_joins_and_separate_code_lines_dont() {
+ let out = unwrap(C_CODE, 111);
+ assert!(has_line(
+ &out,
+ " ! themes env version history sampleorg/tty/testing | head -1; cd ~/src/sampleorg/Sample_1/.worktrees/sm-12345-tty-set-run/src/sampl && .venv/bin/python ~/tmp/set_sync_testing.py --apply"
+ ));
+ assert!(out.contains(" ! cd /Users/adam/src/tty7-mdfix && mise run reload\n"));
+ assert!(out.contains(
+ " # themes/palette.py: drop `import random`\n from colour.utils.random import get_random_string\n ...\n"
+ ));
+ }
+
+ #[test]
+ fn the_status_bar_never_joins() {
+ let rows = " #14 #15 #16 #17 #18 #19 #20 #21 #24 #25 #27 #28 #29 #30 #31 #32 #33 #34 #35 #36 #37 #38 #39 #40 #41 #42 …\n ⏵⏵ auto mode on (shift+tab to cycle) · ← 19 agents";
+ assert_eq!(unwrap(rows, 111), rows);
+ }
+}
diff --git a/src/terminal/view/fixtures/copy_unwrap_111.txt b/src/terminal/view/fixtures/copy_unwrap_111.txt
new file mode 100644
index 00000000..f32ff008
--- /dev/null
+++ b/src/terminal/view/fixtures/copy_unwrap_111.txt
@@ -0,0 +1,119 @@
+⏺ #84 is now based on the new patch-stack main-niu, and GitHub reports it can merge cleanly. Its code is the
+ same as what passed review: the only change is that its notes moved into the hotkey-window feature page. Its
+ tests pass locally.
+
+ Once CI passes, it merges on its own and tty7 reloads on the new main-niu. That reload will be the first run
+ of the restructured branch, so I'll check that your sessions survive it.
+
+✻ Crunched for 28m 10s · done 4:13 PM
+
+※ recap: The goal is for tty7 to show the hotkey window on launch, instead of creating a new workspace, when
+ it's your only workspace. That's PR #84, which passed review and is waiting on CI. Next, it merges and tty7
+ reloads on its own, and I'll check your sessions survive. (disable recaps in /config)
+
+⏺ Background command "Merge #84 once CI passes (network-error tolerant), then reload tty7" completed (exit code
+0)
+
+⏺ Bash(tail -8 /private/tmp/claude-501/-Users-adam-src-clawmux/f14681bd-4bf7-4808-b727-0cb6ed1a8c5b/tasks/befky
+ 62qi.output; tty7 status 2>&1 | head -5; tty7 agents 2>&…)
+ ⎿ build & test (aarch64-apple-darwin) fail 6m8s https://github.com/NorthIsUp/tty
+ 7/actions/runs/36939473036
+ /job/110627383842
+ host boundary pass 5s https://github.com/NorthIsUp/tty7/actions/runs/369394730
+ 36/job/110627346251
+ … +13 lines (ctrl+o to expand)
+ ⎿ Allowed by auto mode classifier
+
+⏺ Bash(gh api --allow-escape-sequences repos/NorthIsUp/tty7/actions/jobs/110627383842/logs 2>&1 | sed
+ 's/\x1b\[[0-9;]*m//g' | grep -E 'FAILED|panicked at|error\[|^.{29…)
+ ⎿ test host::server::pool_tests::closing_the_pool_drops_queued_work ... FAILED
+ thread 'host::server::pool_tests::closing_the_pool_drops_queued_work' (43435) panicked at crates/tty7
+ -core/src/host/server.rs:2419:9:
+ … +6 lines (ctrl+o to expand)
+
+⏺ The CI failure is in an unrelated thread-pool test that #84 doesn't touch and main's own CI passes, so it
+ looks flaky. I'm re-running the failed job.
+
+⏺ Bash(cd ~/src/tty7 && git diff ed406c19 24f7c1eb --stat | tail -2; gh run rerun 36939473036 -R NorthIsUp/tty7
+ --failed && echo rerun-started)
+ ⎿ src/ui/windows.rs | 46 ++++++++-
+ 6 files changed, 255 insertions(+), 23 deletions(-)
+ rerun-started
+ ⎿ Allowed by auto mode classifier
+
+⏺ Bash(cd ~/src/tty7 && sleep 60; while :; do out=$(gh pr checks 84 -R NorthIsUp/tty7 --required 2>&1); rc=$?;
+ if echo "$out" | grep -qiE 'pending|no checks|queued|err…)
+ ⎿ Running in the background (↓ to manage)
+ ⎿ Allowed by auto mode classifier
+
+⏺ #84 isn't merged yet. Its CI failed in a tty7-core test (closing_the_pool_drops_queued_work) that #84 doesn't
+ touch: the PR only changes 6 files in the hotkey window and window code. main-niu's own CI passes, so this
+ looks like a flaky test. I've re-run the failed job. If it passes, #84 merges and tty7 reloads, and I'll
+ check your 24 panes are still there afterwards.
+
+ If it fails again, I'll treat that test as a real problem and investigate it instead of re-running.
+
+✻ Crunched for 2m 42s · done 4:22 PM
+
+※ recap: I'm finishing the fix that opens the hotkey window instead of a blank workspace when it's your only
+ workspace. Its CI hit an unrelated flaky test, so I re-ran it; once it passes, it merges and tty7 reloads on
+ its own. (disable recaps in /config)
+
+⏺ Background command "Wait for #84's rerun, merge on green, reload and confirm panes survived" completed (exit
+code 0)
+
+ Read 1 file (ctrl+o to expand)
+
+⏺ ✅ #84 is merged, and tty7 has reloaded on the restructured main-niu. All 24 of your panes came through the
+ reload. The re-run passed, so the earlier CI failure was a flaky test.
+
+ Here's how to try it:
+ 1. Close every workspace except the hotkey one.
+ 2. Quit tty7 and open it again.
+
+ It should open straight into the hotkey window without making a new workspace. Clicking the Dock icon should
+ do the same.
+
+✻ Sautéed for 27s · done 4:45 PM
+
+※ recap: The goal was making tty7 restore your agents reliably. That's all merged and live now (#72–#84),
+ including the hotkey window opening on its own when it's your only workspace. Next: quit and reopen tty7 to
+ confirm the spinners and that it opens straight into the hotkey window.
+
+❯ [Image #47] when coppying this i'd like it to not have a hard newline on the wrap
+
+ what coppies:
+
+ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would
+ be a bug.
+
+ what should copy:
+
+ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would
+ be a bug.
+ ⎿ [Image #47]
+
+⏺ Agent(Unwrap app-wrapped lines on copy)
+ ⎿ Backgrounded agent (↓ to manage · ctrl+o to expand)
+
+⏺ The newline comes from Claude Code itself. It wraps long lines by writing a real line break plus an indent,
+ so to tty7 they look like two separate lines. Your terminal's own soft-wrap tracking never sees it.
+
+ I've started a subagent on the fix. On copy, tty7 will rejoin lines Claude wrapped:
+
+ ┌──────────────────────────────
+ │ 📦 copy joins Claude's wrapped lines
+ ├──────────────────────────────
+ │ joins when ... the line runs to the right edge (the next word wouldn't
+ │ have fit) and the next line continues at the same indent
+ │ never joins .. code, tables, box drawing, prompts, list items
+ │ setting ...... "Join wrapped lines when copying", on for you
+ │ (off by default upstream)
+ │ raw copy ..... a Copy Raw command in ⌘P, for the exact text
+ │ 🧪 tuned on captures from your Claude tabs: prose, bullets, code, tables
+ └──────────────────────────────
+
+ The subagent only reads your tabs to build test cases and doesn't type into them or take focus. Once the PR
+ is up, I'll review it, merge it after CI passes, turn the setting on in your config, and reload tty7. Expect
+ about an hour.
+
diff --git a/src/terminal/view/fixtures/copy_unwrap_172.txt b/src/terminal/view/fixtures/copy_unwrap_172.txt
new file mode 100644
index 00000000..c5a6f635
--- /dev/null
+++ b/src/terminal/view/fixtures/copy_unwrap_172.txt
@@ -0,0 +1,34 @@
+
+⏺ I've laid out 12 fixes across the dotfiles, each to be its own commit — removing stray shebangs, fixing &&/|| logic into proper if/else, correcting a comment-continuation
+ bug, adding a default in slow-cmd, using exact-match in path-fixup, scoping the loop in 10-focus-events, and adding suppression comments for the three intentional cases.
+ I'll start committing these one at a time now.
+
+✻ Cogitated for 23m 19s · done Wednesday 5:00 PM
+
+※ recap: Goal: clean up shuck findings in your dotfiles, most-used first; the shell startup files are done and committed locally. Next, pick Z1, Z2 or Z3 for the no-op
+ `unset GLOBAL_RCS` line, and say "push" for the 19 unpushed commits. (disable recaps in /config)
+
+❯ continue
+
+⏺ Continuing without changing GLOBAL_RCS and without pushing, since you haven't answered either. The next most-run code is the shared helpers that the scripts I've already
+ fixed call into: argparse, usage2, log-*, subcommander, scale-it. Ranking the remaining flagged files by how many places call them:
+
+⏺ Bash(git status --short; python3 - <<'EOF'
+ import collections,os,re,subprocess…)
+ ⎿ Error: Exit code 1
+ M home/.claude/settings.json
+ Traceback (most recent call last):
+ File "", line 2, in
+ FileNotFoundError: [Errno 2] No such file or directory: '/tmp/claude-501/sk3.txt'
+
+⏺ Temp files were cleared since the last session. Regenerating the findings list and rerunning the ranking:
+
+⏺ Bash(mkdir -p /tmp/claude-501 && cd /Users/adam/src/dotfiles && git log --oneline -1 && cmd=$(grep -o "shuck check --output-format concise[^\"]*" hk.pkl); MISE_QUIET…)
+ ⎿ Running in the background (↓ to manage)
+ ⎿ Loaded home/.claude/CLAUDE.md
+ ⎿ Loaded 5 skills from home/.claude/skills
+ ⎿ 5 skills available
+
+⏺ The findings scan plus caller ranking is still running in the background. It went over the 5-minute foreground limit, probably because shuck is slower on a cold cache.
+ I'll start on the top-ranked helper when it finishes.
+
diff --git a/src/terminal/view/fixtures/copy_unwrap_code.txt b/src/terminal/view/fixtures/copy_unwrap_code.txt
new file mode 100644
index 00000000..7d292a69
--- /dev/null
+++ b/src/terminal/view/fixtures/copy_unwrap_code.txt
@@ -0,0 +1,26 @@
+ You didn't pick between U1 and U2, so I did U1: rewrote the PR title and body. The new title is feat(infra)!:
+ source the app colours from themes set at boot.
+
+ The testing sync is a config-files write, which auto-mode won't let me do. It prints the rollback revision
+ first, then key names only:
+
+ ! themes env version history sampleorg/tty/testing | head -1; cd
+ ~/src/sampleorg/Sample_1/.worktrees/sm-12345-tty-set-run/src/sampl && .venv/bin/python
+ ~/tmp/set_sync_testing.py --apply
+
+⏺ Background command "Rebuild and reload dev app with both fixes" was stopped because the system is running low
+on memory
+
+⏺ Rebuild the dev app yourself when you're ready:
+
+ ! cd /Users/adam/src/tty7-mdfix && mise run reload
+
+ Fixes for #1 and #2 (about 15 minutes with a test):
+
+ # themes/palette.py: drop `import random`
+ from colour.utils.random import get_random_string
+ ...
+ return get_random_string(6, allowed_chars="0123456789")
+
+ # settings/base.py
+ PALETTE_SIZE_LIMITS = {"accent_colour": "1/55s/key,10/m/ip"}
diff --git a/src/ui/app.rs b/src/ui/app.rs
index f4ff9554..64b9280a 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -6800,6 +6800,11 @@ impl Tty7App {
});
}
}
+ CopyRaw => {
+ if let Some(leaf) = self.focused_leaf(window, cx) {
+ leaf.update(cx, |view, cx| view.copy_raw(cx));
+ }
+ }
CutText => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| {
@@ -7128,6 +7133,9 @@ impl Tty7App {
L10nKey::SettingsTrimTrailingSpaces => {
self.set_clipboard_trim(defaults.clipboard_trim_trailing_spaces, cx)
}
+ L10nKey::SettingsCopyJoinWrapped => {
+ self.update_config(cx, |c| c.copy_join_wrapped = defaults.copy_join_wrapped)
+ }
L10nKey::SettingsCopyOnSelect => self.set_copy_on_select(defaults.copy_on_select, cx),
L10nKey::SettingsSmartSelection => self.set_smart_select(defaults.smart_select, cx),
L10nKey::SettingsPromptEditor => self.set_prompt_editor(defaults.prompt_editor, cx),
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 6706d6cb..542abf69 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -2340,6 +2340,14 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::GitHubShowFullText => "Show full text",
L10nKey::GitHubShowHiddenComments => "Show {count} more comments",
L10nKey::GitHubShowAllReviewers => "Show all {count} reviewers",
+ L10nKey::CmdCopyRaw => "Copy Raw",
+ L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
+ "clipboard copy wrap unwrap join lines claude raw"
+ }
+ L10nKey::SettingsCopyJoinWrapped => "Join wrapped lines when copying",
+ L10nKey::SettingsCopyJoinWrappedDesc => {
+ "In a Claude Code pane, rejoin the lines Claude wrapped at the pane's edge, so a paragraph pastes as one line. ⌘⌥C or Copy Raw copies them as shown"
+ }
}
}
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index ab562853..c282f87f 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -2411,6 +2411,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubShowFullText => "全文を表示",
L10nKey::GitHubShowHiddenComments => "ほか {count} 件のコメントを表示",
L10nKey::GitHubShowAllReviewers => "{count} 人のレビュアーをすべて表示",
+ L10nKey::CmdCopyRaw => "そのままコピー",
+ L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
+ "クリップボード コピー 折り返し 行 結合 clipboard copy wrap unwrap join lines claude raw"
+ }
+ L10nKey::SettingsCopyJoinWrapped => "コピー時に折り返し行をつなげる",
+ L10nKey::SettingsCopyJoinWrappedDesc => {
+ "Claude Code のペインで、Claude が画面端で折り返した行をつなげ、段落を 1 行として貼り付けます。⌘⌥C または「そのままコピー」で表示どおりにコピーします"
+ }
})
}
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 8c8a6847..e14dc8a9 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -1855,6 +1855,10 @@ l10n_keys! {
GitHubShowFullText,
GitHubShowHiddenComments,
GitHubShowAllReviewers,
+ CmdCopyRaw,
+ SettingsCopyJoinWrapped,
+ SettingsCopyJoinWrappedDesc,
+ SettingsSearchCopyJoinWrappedKeywords,
}
/// The source control strings that are translated but not yet displayed.
diff --git a/src/ui/i18n/ru.rs b/src/ui/i18n/ru.rs
index 44535aa9..dad9171d 100644
--- a/src/ui/i18n/ru.rs
+++ b/src/ui/i18n/ru.rs
@@ -2435,6 +2435,14 @@ pub fn translate_ru(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubShowFullText => "Показать полный текст",
L10nKey::GitHubShowHiddenComments => "Показать ещё комментарии ({count})",
L10nKey::GitHubShowAllReviewers => "Показать всех рецензентов ({count})",
+ L10nKey::CmdCopyRaw => "Копировать как есть",
+ L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
+ "буфер обмена копирование перенос склеить строки claude clipboard copy wrap unwrap join lines raw"
+ }
+ L10nKey::SettingsCopyJoinWrapped => "Склеивать перенесённые строки при копировании",
+ L10nKey::SettingsCopyJoinWrappedDesc => {
+ "В панели Claude Code склеивать строки, которые Claude перенёс у края панели, чтобы абзац вставлялся одной строкой. ⌘⌥C или «Копировать как есть» копируют их как на экране"
+ }
})
}
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index 4fdd0ef8..78051ec3 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -2185,6 +2185,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubShowFullText => "展开全文",
L10nKey::GitHubShowHiddenComments => "显示另外 {count} 条评论",
L10nKey::GitHubShowAllReviewers => "显示全部 {count} 位审查人",
+ L10nKey::CmdCopyRaw => "原样复制",
+ L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
+ "剪贴板 复制 折行 合并 clipboard copy wrap unwrap join lines claude raw"
+ }
+ L10nKey::SettingsCopyJoinWrapped => "复制时合并折行",
+ L10nKey::SettingsCopyJoinWrappedDesc => {
+ "在 Claude Code 窗格中,将 Claude 在窗格边缘折行的内容重新合并,段落粘贴为一行。⌘⌥C 或“原样复制”按显示内容复制"
+ }
})
}
diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs
index b88fd1e5..8c565a3d 100644
--- a/src/ui/search/command.rs
+++ b/src/ui/search/command.rs
@@ -67,6 +67,7 @@ pub enum CommandKind {
FindNext,
FindPrevious,
CopyText,
+ CopyRaw,
CutText,
PasteText,
SelectAllText,
@@ -248,6 +249,7 @@ impl CommandKind {
FindNext => "find-next",
FindPrevious => "find-previous",
CopyText => "copy",
+ CopyRaw => "copy-raw",
CutText => "cut",
PasteText => "paste",
SelectAllText => "select-all",
@@ -326,6 +328,7 @@ impl CommandKind {
|spec: &str| -> Option { cfg!(target_os = "macos").then(|| spec.to_string()) };
match self {
CopyText => return inline("secondary-c"),
+ CopyRaw => return inline("secondary-alt-c"),
CutText => return inline("secondary-x"),
PasteText => return inline("secondary-v"),
SelectAllText => return inline("secondary-a"),
@@ -440,6 +443,7 @@ impl CommandKind {
ToggleDiffViewMode => "ToggleDiffViewMode",
QuickOpenFile => "QuickOpenFile",
CopyText
+ | CopyRaw
| CutText
| PasteText
| SelectAllText
@@ -765,6 +769,7 @@ impl Item {
Item::localized(L10nKey::CmdFindNext, FindNext),
Item::localized(L10nKey::CmdFindPrevious, FindPrevious),
Item::localized(L10nKey::CmdCopy, CopyText),
+ Item::localized(L10nKey::CmdCopyRaw, CopyRaw),
Item::localized(L10nKey::CmdCut, CutText),
Item::localized(L10nKey::CmdPaste, PasteText),
Item::localized(L10nKey::CmdSelectAll, SelectAllText),
@@ -928,6 +933,7 @@ mod tests {
CommandKind::SplitRight,
CommandKind::ClearTerminal,
CommandKind::CopyText,
+ CommandKind::CopyRaw,
CommandKind::CutText,
CommandKind::PasteText,
CommandKind::SelectAllText,
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index d5b1dada..9bf41d31 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -539,6 +539,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsTrimTrailingSpaces,
keywords: SettingsSearchTrimTrailingSpacesKeywords,
},
+ SearchEntry {
+ section: KeyboardMouse,
+ title: SettingsCopyJoinWrapped,
+ keywords: SettingsSearchCopyJoinWrappedKeywords,
+ },
SearchEntry {
section: Ssh,
title: SettingsHosts,
@@ -845,6 +850,7 @@ impl SearchEntry {
L10nKey::SettingsSmoothScroll => "smooth_scroll",
L10nKey::SettingsMouseZoom => "mouse_zoom_modifier",
L10nKey::SettingsTrimTrailingSpaces => "clipboard_trim_trailing_spaces",
+ L10nKey::SettingsCopyJoinWrapped => "copy_join_wrapped",
L10nKey::SettingsCopyOnSelect => "copy_on_select",
L10nKey::SettingsSmartSelection => "smart_select",
L10nKey::SettingsPromptEditor => "prompt_editor",
@@ -936,6 +942,7 @@ impl SearchEntry {
L10nKey::SettingsSmartSelection => t(L10nKey::SettingsSmartSelectionDesc),
L10nKey::SettingsCopyOnSelect => t(L10nKey::SettingsCopyOnSelectDesc),
L10nKey::SettingsTrimTrailingSpaces => t(L10nKey::SettingsTrimTrailingSpacesDesc),
+ L10nKey::SettingsCopyJoinWrapped => t(L10nKey::SettingsCopyJoinWrappedDesc),
L10nKey::SettingsVerifyHostKeys => t(L10nKey::SettingsVerifyHostKeysDesc),
L10nKey::SettingsStartupWindow => t(L10nKey::SettingsStartupWindowDesc),
L10nKey::SettingsRememberWindowSize => t(L10nKey::SettingsRememberWindowSizeDesc),
@@ -1033,6 +1040,7 @@ impl SearchEntry {
L10nKey::SettingsTrimTrailingSpaces => {
cfg.clipboard_trim_trailing_spaces != defaults.clipboard_trim_trailing_spaces
}
+ L10nKey::SettingsCopyJoinWrapped => cfg.copy_join_wrapped != defaults.copy_join_wrapped,
L10nKey::SettingsCopyOnSelect => cfg.copy_on_select != defaults.copy_on_select,
L10nKey::SettingsSmartSelection => cfg.smart_select != defaults.smart_select,
L10nKey::SettingsPromptEditor => cfg.prompt_editor != defaults.prompt_editor,
diff --git a/src/ui/settings/pages.rs b/src/ui/settings/pages.rs
index a69cffb2..ec099689 100644
--- a/src/ui/settings/pages.rs
+++ b/src/ui/settings/pages.rs
@@ -1233,6 +1233,11 @@ impl Tty7App {
let trim = self.settings_switch("term-clip-trim", clip_trim, cx, |this, on, _, cx| {
this.set_clipboard_trim(on, cx)
});
+ let join_wrapped = cx.global::().copy_join_wrapped;
+ let join =
+ self.settings_switch("copy-join-wrapped", join_wrapped, cx, |this, on, _, cx| {
+ this.update_config(cx, |c| c.copy_join_wrapped = on)
+ });
// Ctrl only earns a cell where it is a different key from the platform
// modifier: off macOS the two are the same key. A config that names
// `ctrl` there still highlights it, in the one cell that means it.
@@ -1335,6 +1340,12 @@ impl Tty7App {
trim,
cx,
),
+ self.settings_row(
+ t(L10nKey::SettingsCopyJoinWrapped),
+ t(L10nKey::SettingsCopyJoinWrappedDesc),
+ join,
+ cx,
+ ),
]
.map(IntoElement::into_any_element),
cx,