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,