From 1064a8be3474549bb556892be75f2c19e06a98a1 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:24:38 +0800 Subject: [PATCH 1/3] fix(cli): restore terminal interactions and transcript hierarchy Problem: - Captured mouse mode could copy selections but could not right-click paste clipboard text. - Composer drag selections were visible but did not reliably reach the system clipboard. - Non-Git workspaces reserved or misaligned an empty footer data band. - Assistant answers lacked a distinct identity and spacing from reasoning and turn receipts. Root cause: - Mouse handling had no text-only clipboard read path, composer selection release stopped at local state, and footer/transcript rendering assumed fixed visual bands. Fix: - Route right-click clipboard text through the canonical paste event and auto-copy composer drag selections. - Make optional footer telemetry compact and left-aligned outside Git repositories. - Render assistant markdown with a Reasonix identity, stable indentation, replay support, and semantic spacing. - Document the interaction changes and add focused regression coverage. Verification: - go test ./internal/cli - go test ./... - git diff --check --- CHANGELOG.md | 3 + docs/CLI.md | 10 +-- docs/CLI.zh-CN.md | 11 +-- docs/GUIDE.md | 4 +- docs/GUIDE.zh-CN.md | 4 +- internal/cli/chat_render_test.go | 54 ++++++++++++-- internal/cli/chat_tui.go | 88 ++++++++++++++--------- internal/cli/chat_tui_paste.go | 18 +++++ internal/cli/chat_tui_test.go | 96 ++++++++++++++++++++----- internal/cli/composer_selection_test.go | 50 +++++++++++++ internal/cli/status_footer.go | 16 ++--- internal/cli/status_footer_test.go | 44 ++++++++---- internal/cli/statusline_test.go | 10 ++- internal/cli/transcript.go | 49 ++++++++++--- internal/cli/transcript_test.go | 48 +++++++++++++ internal/i18n/i18n.go | 1 + internal/i18n/messages_en.go | 1 + internal/i18n/messages_zh.go | 1 + internal/i18n/messages_zh_tw.go | 1 + 19 files changed, 409 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d32f22a1..3d7c636f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ branch. ### Fixed +- Restored captured-mouse right-click text paste, made composer drag selection + copy through the verified native clipboard path, and kept non-Git footer + telemetry left-aligned without reserving an empty data band. - Restored stateful MCP behavior after the v1.17.13 regression: compatible trust receipts migrate instead of prompting again, user-added servers work without extra trust settings (including delivery-mode on-demand calls), and diff --git a/docs/CLI.md b/docs/CLI.md index c6bf52250c..4b1b16b2f5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -225,10 +225,12 @@ selection, scrollbar, and footer use the active CLI theme. See multiline input, rewind, and clipboard controls. Clipboard actions are deliberately split by content type. Local transcript -selection uses the native system clipboard and reports success only after that -write completes; SSH falls back to an explicitly labelled OSC 52 request. Text -paste remains the terminal's bracketed-paste action (`Cmd+V` on macOS and the -terminal's configured shortcut elsewhere). Image paste is application-owned: +and composer selections use the native system clipboard and report success only +after that write completes; SSH falls back to an explicitly labelled OSC 52 +request. Text paste remains the terminal's bracketed-paste action (`Cmd+V` on +macOS and the terminal's configured shortcut elsewhere). While Reasonix owns the +mouse, right-click with no selection reads clipboard text through the same paste +path; right-click with a selection copies it. Image paste is application-owned: use `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`; the footer shows `Pasting image…` until the attachment token is ready. diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index 8aadb8dc6a..c78fa84b59 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -207,11 +207,12 @@ reasonix -p "同时更新两个项目" \ 强调色。输入框上下边线、插入光标、选区、滚动条和底栏都会使用当前 CLI 主题。Transcript 导航、多行输入、rewind 和剪贴板操作见[快捷键](./GUIDE.zh-CN.md#快捷键)。 -剪贴板操作按内容类型明确分开。本地 transcript 选区写入系统剪贴板,并且只有写入成功后 -才提示完成;SSH 会回退到明确标记的 OSC 52 请求。文本粘贴继续走终端的 bracketed-paste -动作(macOS 通常为 `Cmd+V`,其它平台使用终端自身配置)。图片粘贴由 Reasonix 接管: -macOS/Linux 使用 `Ctrl+V`,Windows 使用 `Alt+V`,也可运行 `/paste-image`;附件标记 -准备完成前,底栏会显示“正在粘贴图片…”。 +剪贴板操作按内容类型明确分开。本地 transcript 和输入框选区写入系统剪贴板,并且只有写入 +成功后才提示完成;SSH 会回退到明确标记的 OSC 52 请求。文本粘贴继续走终端的 +bracketed-paste 动作(macOS 通常为 `Cmd+V`,其它平台使用终端自身配置)。Reasonix 接管 +鼠标时,没有选区的右键会读取剪贴板文本并走同一粘贴路径,有选区时右键优先复制。图片粘贴 +由 Reasonix 接管:macOS/Linux 使用 `Ctrl+V`,Windows 使用 `Alt+V`,也可运行 +`/paste-image`;附件标记准备完成前,底栏会显示“正在粘贴图片…”。 ## 会话内命令 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 2af750465a..23db059659 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -421,8 +421,10 @@ Chat and transcript shortcuts: | `Esc` | Backs out of the current action | It un-sends a just-submitted turn before any reply, cancels a running turn, or clears non-empty input. | | Double `Esc` on an empty idle composer | Opens the rewind picker | Same entry point as `/rewind`. | | Transcript text selection | Copies transcript text | Releasing an in-app drag writes through the verified native clipboard path in a local session (`pbcopy` on macOS, the available Wayland/X11 tool on Linux, or the Windows clipboard). SSH falls back to OSC 52 and labels the fallback instead of claiming native success. `Ctrl+C`/`Super+C`/`Meta+C` or right-clicking the active selection copies it again. | +| Composer text selection | Selects, copies, or replaces draft text | Releasing an in-app drag copies the selection through the same verified clipboard path as transcript text. Typing or pasting replaces the selection; arrow keys collapse it. | +| Right-click with no active selection | Pastes clipboard text | While in-app mouse capture is on, Reasonix reads text only and routes it through the normal bracketed-paste handling. Right-click with an active selection copies that selection instead. | | `/mouse` | Toggles in-app mouse capture | Off hands the mouse back to your terminal, restoring its native click-drag selection and right-click context menu, at the cost of in-app drag-select, the transcript scrollbar, and wheel-scroll. Set `REASONIX_DISABLE_MOUSE=1` to start every session with it off. | -| `Ctrl+C` | Copies, cancels, clears, or quits | Copies an active transcript selection first. Otherwise it cancels a running turn, clears non-empty input, or quits on a second empty-composer press. | +| `Ctrl+C` | Copies, cancels, clears, or quits | Copies an active transcript or composer selection first. Otherwise it cancels a running turn, clears non-empty input, or quits on a second empty-composer press. | | `Ctrl+D` | Quits the TUI | Immediate quit. | | Your terminal's text-paste shortcut | Pastes text | Text stays on the terminal's bracketed-paste path (`Cmd+V` on macOS, commonly `Ctrl+Shift+V` on Linux, and the terminal's configured shortcut elsewhere). Reasonix consumes the resulting paste event and never probes for an image first. | | `Ctrl+V` on macOS/Linux; `Alt+V` on Windows | Pastes a clipboard image | Image paste is a separate application action. The footer shows `Pasting image…` while the clipboard is read, then inserts an editable `[image #N]` token at the cursor. | diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index e48080d623..d93d114b44 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -341,8 +341,10 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u | `Esc` | 退出当前最具体的动作 | 可在无回复前撤回刚提交的 turn、取消运行中的 turn,或清空非空输入。 | | 空闲且输入为空时双击 `Esc` | 打开 rewind 选择器 | 和 `/rewind` 是同一个入口。 | | transcript 文本选择 | 复制 transcript 文本 | 应用内拖选松开后,本地会话通过可验证的系统剪贴板路径写入(macOS `pbcopy`、Linux 可用的 Wayland/X11 工具、Windows 系统剪贴板);SSH 才回退到 OSC 52,并明确标记为回退而不是宣称原生复制成功。`Ctrl+C`/`Super+C`/`Meta+C` 或右键当前选区可再次复制。 | +| 输入框文本选择 | 选中、复制或替换草稿文本 | 应用内拖选松开后,会通过与 transcript 相同的可验证剪贴板路径复制;输入或粘贴会替换选区,方向键会收起选区。 | +| 没有活动选区时右键 | 粘贴剪贴板文本 | 开启鼠标接管时,Reasonix 只读取文本并交给正常的 bracketed-paste 处理;存在活动选区时,右键仍优先复制该选区。 | | `/mouse` | 切换应用内鼠标接管 | 关闭后由终端处理原生拖选和右键菜单,但会失去应用内选区、滚动条和滚轮。可用 `REASONIX_DISABLE_MOUSE=1` 让每次会话默认关闭。 | -| `Ctrl+C` | 复制、取消、清空或退出 | 有活动选区时优先复制;否则取消运行中的 turn、清空非空输入,或在空输入下连按两次退出。 | +| `Ctrl+C` | 复制、取消、清空或退出 | 有 transcript 或输入框活动选区时优先复制;否则取消运行中的 turn、清空非空输入,或在空输入下连按两次退出。 | | `Ctrl+D` | 退出 TUI | 立即退出。 | | 终端的文本粘贴快捷键 | 粘贴文本 | 文本保持终端原生 bracketed-paste 路径:macOS 通常是 `Cmd+V`,Linux 通常是 `Ctrl+Shift+V`,其它环境使用终端自身配置。Reasonix 只消费收到的文本粘贴事件,不会先探测图片。 | | macOS/Linux `Ctrl+V`;Windows `Alt+V` | 粘贴剪贴板图片 | 图片粘贴是独立的应用动作。读取期间底栏显示“正在粘贴图片…”,完成后在光标处插入可编辑的 `[image #N]` 标记。 | diff --git a/internal/cli/chat_render_test.go b/internal/cli/chat_render_test.go index e6065dabb3..8ba5812a39 100644 --- a/internal/cli/chat_render_test.go +++ b/internal/cli/chat_render_test.go @@ -5,8 +5,10 @@ import ( "testing" "charm.land/bubbles/v2/textarea" + "github.com/charmbracelet/x/ansi" "reasonix/internal/event" + "reasonix/internal/provider" ) // newTestChatTUI builds a chatTUI with just the pieces the streaming/commit and @@ -33,7 +35,6 @@ func newTestChatTUI() chatTUI { reasoning: &strings.Builder{}, pending: &strings.Builder{}, pendingCommit: &commit, - renderer: newMarkdownRenderer(80), shellOutputs: shellOut, shellExpanded: shellExp, shellTranscriptIdx: shellIdx, @@ -69,8 +70,11 @@ func TestIngestSeparatesReasoningFromAnswer(t *testing.T) { } m.ingestEvent(event.Event{Kind: event.Text, Text: "Hello answer"}) // answer begins → block collapses - if len(m.transcript) != 1 || !strings.Contains(m.transcript[0], "thought for") { - t.Fatalf("block should collapse to a duration summary, transcript=%v", m.transcript) + if len(m.transcript) != 2 || !strings.Contains(m.transcript[0], "thought for") { + t.Fatalf("block should collapse to a duration summary plus answer separator, transcript=%v", m.transcript) + } + if strings.TrimSpace(m.transcript[1]) != "" { + t.Fatalf("reasoning/answer separator = %q, want one blank block", m.transcript[1]) } if strings.Contains(strings.Join(m.transcript, "\n"), "…reasoning…") { t.Fatalf("collapsed reasoning text should be removed, transcript=%v", m.transcript) @@ -83,9 +87,44 @@ func TestIngestSeparatesReasoningFromAnswer(t *testing.T) { } m.commitPending() // turn end - if len(m.transcript) != 2 || !strings.Contains(m.transcript[1], "Hello") { + if len(m.transcript) != 3 || !strings.Contains(m.transcript[2], "Hello") { t.Fatalf("answer should commit as a separate entry, transcript=%v", m.transcript) } + if plain := ansi.Strip(m.transcript[2]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Hello answer") { + t.Fatalf("answer should have an explicit assistant identity and indented body, got %q", plain) + } +} + +func TestAssistantAnswerWithoutReasoningHasNoLeadingSpacer(t *testing.T) { + m := newTestChatTUI() + m.ingestEvent(event.Event{Kind: event.Text, Text: "Direct answer"}) + m.ingestEvent(event.Event{Kind: event.Message}) + + if len(m.transcript) != 1 { + t.Fatalf("direct answer should remain one compact block, got %d: %v", len(m.transcript), m.transcript) + } + if plain := ansi.Strip(m.transcript[0]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Direct answer") { + t.Fatalf("direct answer block = %q", plain) + } +} + +func TestTurnReceiptLeavesOneBlankRowAfterAssistantAnswer(t *testing.T) { + m := newTestChatTUI() + m.ingestEvent(event.Event{Kind: event.Text, Text: "Answer"}) + m.ingestEvent(event.Event{Kind: event.Message}) + m.ingestEvent(event.Event{Kind: event.Usage, Usage: &provider.Usage{ + PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, + }}) + + if len(m.transcript) != 3 { + t.Fatalf("answer + spacer + receipt should be three blocks, got %d: %v", len(m.transcript), m.transcript) + } + if strings.TrimSpace(m.transcript[1]) != "" { + t.Fatalf("answer/receipt separator = %q, want one blank block", m.transcript[1]) + } + if !strings.Contains(ansi.Strip(m.transcript[2]), "TURN") { + t.Fatalf("last block should be the turn receipt, got %q", m.transcript[2]) + } } // TestVerboseReasoningInsertsTextUnderSummary proves /verbose mode keeps the full @@ -98,8 +137,8 @@ func TestVerboseReasoningInsertsTextUnderSummary(t *testing.T) { m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "step two"}) m.ingestEvent(event.Event{Kind: event.Text, Text: "Answer"}) // closes the block - if len(m.transcript) != 2 { - t.Fatalf("verbose block should be summary + text, transcript=%v", m.transcript) + if len(m.transcript) != 3 { + t.Fatalf("verbose block should be summary + text + answer separator, transcript=%v", m.transcript) } if !strings.Contains(m.transcript[0], "thought for") { t.Errorf("first line should be the duration summary, got %q", m.transcript[0]) @@ -107,6 +146,9 @@ func TestVerboseReasoningInsertsTextUnderSummary(t *testing.T) { if !strings.Contains(m.transcript[1], "step one") || !strings.Contains(m.transcript[1], "step two") { t.Errorf("verbose text should appear under the summary, got %q", m.transcript[1]) } + if strings.TrimSpace(m.transcript[2]) != "" { + t.Errorf("verbose reasoning/answer separator = %q, want blank block", m.transcript[2]) + } } // TestIngestEventFlushesAnswer confirms an event line (e.g. a tool dispatch) diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 52ce16cdaa..1595f18aa8 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -136,7 +136,6 @@ type chatTUI struct { reasoning *strings.Builder pending *strings.Builder pendingCommit *[]string - renderer *mdRenderer showReasoning bool // Ctrl+O / /verbose: show raw thinking text in the CLI cfg *config.Config // reasoningLineIdx is the transcript index of the live "▎ thinking…" marker @@ -543,7 +542,6 @@ func newChatTUI(ctrl control.SessionAPI, missing string, eventCh chan event.Even commitBuf := []string{} nativeScrollback := detectTermuxTerminal() - renderW := transcriptContentWidth(termW, nativeScrollback) return chatTUI{ ctrl: ctrl, label: ctrl.Label(), @@ -562,7 +560,6 @@ func newChatTUI(ctrl control.SessionAPI, missing string, eventCh chan event.Even reasoning: &strings.Builder{}, pending: &strings.Builder{}, pendingCommit: &commitBuf, - renderer: newMarkdownRenderer(renderW), diffMaxLines: diffFoldLimit, showReasoning: nativeScrollback, shellOutputs: make(map[string]string), @@ -857,8 +854,6 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.input.SetWidth(max(msg.Width-4, 1)) - contentW := transcriptContentWidth(msg.Width, m.nativeScrollback) - m.renderer = newMarkdownRenderer(contentW) // Commit the banner — and a resumed session's transcript — once, now // that the width is known. if !m.started { @@ -895,9 +890,10 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseClickMsg: - // Right-click copies the active selection (Windows Terminal convention); - // left-press in the transcript region begins a text selection — unless - // the click lands on the scrollbar or a shell-output hint line. + // Match the complete terminal right-click convention while Reasonix owns + // the mouse: copy an active selection, otherwise paste clipboard text into + // the visible composer. Left-press begins a selection unless it lands on + // the transcript scrollbar or a shell-output hint line. if msg.Button == tea.MouseRight && m.validComposerSelection() && !m.composerSel.empty() { cmds = append(cmds, m.copySelectionWithNotice(m.selectedComposerText())) return m, finalize(m, cmds) @@ -908,6 +904,10 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.copySelectionWithNotice(text)) return m, finalize(m, cmds) } + if msg.Button == tea.MouseRight && !m.hideComposer() { + cmds = append(cmds, pasteClipboardText()) + return m, finalize(m, cmds) + } if msg.Button == tea.MouseLeft { if at, ok := m.composerCaretAt(msg.X, msg.Y, false); ok { m.sel = selection{} @@ -1000,8 +1000,14 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.composerSel.empty() { m.composerSel = composerSelection{} + return m, nil } - return m, nil + // The terminal cannot see Reasonix's application-owned highlight, and + // macOS commonly consumes Cmd+C before it reaches the TUI. Copy on drag + // release just like transcript selection so the visible selection always + // has a usable clipboard result. + cmds = append(cmds, m.copySelectionWithNotice(m.selectedComposerText())) + return m, finalize(m, cmds) } // Release finalizes the selection: a real drag auto-copies it (native // terminal convention), while the highlight stays on as the visual @@ -1658,6 +1664,19 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, tea.ClearScreen) } + case clipboardTextPasteMsg: + if msg.err != nil { + m.notice(fmt.Sprintf(i18n.M.ClipboardTextPasteFailedFmt, msg.err)) + break + } + if msg.text == "" { + break + } + // Re-enter through the canonical paste path so selection replacement, + // folded blocks, file references, completion, and wide-cell repainting + // behave exactly like the terminal's bracketed-paste event. + return m.update(tea.PasteMsg{Content: msg.text}) + case clipboardCopyMsg: if msg.statusHint && msg.seq != m.copyNoticeSeq { break @@ -2419,6 +2438,17 @@ func (m *chatTUI) commitReasoning() { m.reasoningTextIdx = -1 } +// commitReasoningBeforeAnswer closes a real reasoning block and leaves exactly +// one blank transcript row before the assistant answer. Answers that start +// without reasoning keep their existing compact placement. +func (m *chatTUI) commitReasoningBeforeAnswer() { + hadReasoning := m.reasoningNative || m.reasoningLineIdx >= 0 + m.commitReasoning() + if hadReasoning { + m.commitSpacer() + } +} + // streamAnswer renders the answer streamed so far up to its last completed // paragraph (flushableMarkdownPrefix) and writes it as one transcript block, // rewritten in place as later paragraphs land — so a long reply appears chunk by @@ -2433,17 +2463,14 @@ func (m *chatTUI) streamAnswer() { if len(prefix) <= m.answerFlushed { return } - rendered := m.renderer.Render(prefix) - if rendered == "" { - return - } + source := transcriptSource{kind: transcriptSourceMarkdown, raw: prefix} m.answerFlushed = len(prefix) - block := strings.TrimRight(rendered, "\n") if m.answerIdx < 0 { m.answerIdx = len(m.transcript) - m.commitTranscriptSource(transcriptSource{kind: transcriptSourceMarkdown, raw: prefix}) + m.commitTranscriptSource(source) } else { - m.setTranscriptBlock(m.answerIdx, block, transcriptSource{kind: transcriptSourceMarkdown, raw: prefix}) + block := m.renderTranscriptSource(source, m.width) + m.setTranscriptBlock(m.answerIdx, block, source) m.transcriptDirty = true } } @@ -2459,15 +2486,12 @@ func (m *chatTUI) commitPending() { return } raw := m.pending.String() - rendered := m.renderer.Render(raw) - if rendered == "" { - rendered = raw - } - block := strings.TrimRight(rendered, "\n") + source := transcriptSource{kind: transcriptSourceMarkdown, raw: raw} if m.answerIdx < 0 { - m.commitTranscriptSource(transcriptSource{kind: transcriptSourceMarkdown, raw: raw}) + m.commitTranscriptSource(source) } else { - m.setTranscriptBlock(m.answerIdx, block, transcriptSource{kind: transcriptSourceMarkdown, raw: raw}) + block := m.renderTranscriptSource(source, m.width) + m.setTranscriptBlock(m.answerIdx, block, source) m.transcriptDirty = true } m.pending.Reset() @@ -3224,9 +3248,10 @@ func wrapStatusLine(s string, width int) string { } // computeStatusLineCount returns the number of terminal rows the status block -// (working line + first status line + data line) will occupy after wrapping to -// `width`. It mirrors the construction in View() so the reserved height matches -// the rendered height exactly — the load-bearing invariant for bottomRows(). +// (working line + first status line + optional data band) will occupy after +// wrapping to `width`. It mirrors the construction in View() so the reserved +// height matches the rendered height exactly — the load-bearing invariant for +// bottomRows(). // Use the same width (m.width) that View() passes to wrapStatusLine. func (m chatTUI) computeStatusLineCount(width int) int { if m.ctrl == nil { @@ -3598,7 +3623,7 @@ func (m *chatTUI) ingestEvent(e event.Event) { m.streamReasoning(e.Text) case event.Text: - m.commitReasoning() // reasoning ends as the answer begins + m.commitReasoningBeforeAnswer() m.pending.WriteString(e.Text) m.streamAnswer() @@ -3659,6 +3684,7 @@ func (m *chatTUI) ingestEvent(e event.Event) { } if line := renderTurnReceipt(e.Usage, e.Pricing, e.CacheDiagnostics); line != "" { m.finalizeStreamed() + m.commitSpacer() m.commitTranscriptSource(transcriptSource{kind: transcriptSourceTurnReceipt, raw: line}) } @@ -4387,7 +4413,7 @@ func (m *chatTUI) runMCPPrompt(input string) tea.Cmd { // replaySectionsFor turns a loaded session into scrollback blocks: user bubbles // and assistant markdown. Tool messages are dropped — needed in session state // but noise in the visible transcript on resume. -func replaySectionsFor(history []provider.Message, width int, renderer *mdRenderer) []string { +func replaySectionsFor(history []provider.Message, width int) []string { var out []string for _, m := range history { switch m.Role { @@ -4404,11 +4430,7 @@ func replaySectionsFor(history []provider.Message, width int, renderer *mdRender if body == "" { continue } - rendered := renderer.Render(body) - if rendered == "" { - rendered = body - } - out = append(out, rendered+"\n") + out = append(out, renderAssistantMarkdown(body, width)+"\n\n") } } return out diff --git a/internal/cli/chat_tui_paste.go b/internal/cli/chat_tui_paste.go index d784ebff43..45bb12fe1d 100644 --- a/internal/cli/chat_tui_paste.go +++ b/internal/cli/chat_tui_paste.go @@ -8,6 +8,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" "reasonix/internal/control" "reasonix/internal/shellparse" @@ -114,6 +115,23 @@ func pasteClipboardImage() tea.Cmd { } } +type clipboardTextPasteMsg struct { + text string + err error +} + +var readNativeClipboardText = clipboard.ReadAll + +// pasteClipboardText backs the captured-mouse right-click path. Keyboard text +// paste still arrives from the terminal as a bracketed tea.PasteMsg; this read +// is deliberately text-only so right-click never probes for an image first. +func pasteClipboardText() tea.Cmd { + return func() tea.Msg { + text, err := readNativeClipboardText() + return clipboardTextPasteMsg{text: text, err: err} + } +} + func imagePasteShortcut(keyName, goos string) bool { if goos == "windows" { return keyName == "alt+v" diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index ba24eb2582..6937e36418 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -262,9 +262,9 @@ func TestCompletionMenuPadsWithNonBreakingSpaces(t *testing.T) { } // TestTranscriptViewportSizing proves the viewport tracks the terminal size and -// gets the rows left over after the pinned bottom region (input box + two -// information rows and their divider = 6 with an empty 1-line composer), and -// is fed the committed transcript. +// gets the rows left over after the pinned bottom region (input box + the one +// available information row = 4 with an empty 1-line composer and no Git or +// telemetry), and is fed the committed transcript. func TestTranscriptViewportSizing(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) @@ -272,14 +272,14 @@ func TestTranscriptViewportSizing(t *testing.T) { m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = m0.(chatTUI) - if got := m.bottomRows(); got != 6 { - t.Fatalf("bottomRows with an empty composer = %d, want 6 (input 1 + border 2 + status 3)", got) + if got := m.bottomRows(); got != 4 { + t.Fatalf("bottomRows with an empty composer = %d, want 4 (input 1 + border 2 + status 1)", got) } if m.viewport.Width() != 79 { t.Errorf("viewport content width = %d, want 79 (terminal 80 - 1 scrollbar column)", m.viewport.Width()) } - if want := m.transcriptHeight(); m.viewport.Height() != want || want != 18 { - t.Errorf("viewport height = %d, transcriptHeight = %d, want 18 (24-6)", m.viewport.Height(), want) + if want := m.transcriptHeight(); m.viewport.Height() != want || want != 20 { + t.Errorf("viewport height = %d, transcriptHeight = %d, want 20 (24-4)", m.viewport.Height(), want) } if m.viewport.TotalLineCount() == 0 { t.Errorf("viewport should hold the committed banner after the first resize") @@ -518,8 +518,8 @@ func TestTranscriptResizeRerendersCommittedMarkdownAtNewWidth(t *testing.T) { break } } - if got, want := ruleWidth, transcriptContentWidth(80, false); got != want { - t.Fatalf("resized thematic rule width = %d, want new transcript width %d", got, want) + if got, want := ruleWidth, transcriptContentWidth(80, false)-visibleWidth(assistantTranscriptIndent); got != want { + t.Fatalf("resized thematic rule width = %d, want indented assistant body width %d", got, want) } if newLines >= oldLines { t.Fatalf("wider transcript kept old hard wrapping: old lines=%d new lines=%d\n%s", oldLines, newLines, newRendered) @@ -552,15 +552,14 @@ func TestTranscriptResizeKeepsScrolledReaderOnSameBlock(t *testing.T) { if m.viewport.AtBottom() { t.Fatal("test reader anchor must be above the transcript bottom") } - if top := ansi.Strip(m.wrappedLines[m.viewport.YOffset()]); !strings.Contains(top, "ANCHOR-1") { - t.Fatalf("test setup top row = %q, want ANCHOR-1", top) - } m0, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) m = m0.(chatTUI) - top := ansi.Strip(m.wrappedLines[m.viewport.YOffset()]) - if !strings.Contains(top, "ANCHOR-1") { - t.Fatalf("resize moved reader to another transcript block: top row %q", top) + newContentWidth := transcriptContentWidth(m.width, false) + newSecondBlockStart := transcriptBlockLineCount(m.transcript[0], newContentWidth) + newThirdBlockStart := newSecondBlockStart + transcriptBlockLineCount(m.transcript[1], newContentWidth) + if offset := m.viewport.YOffset(); offset < newSecondBlockStart || offset >= newThirdBlockStart { + t.Fatalf("resize moved reader outside ANCHOR-1 block: offset=%d block=[%d,%d)", offset, newSecondBlockStart, newThirdBlockStart) } } @@ -809,7 +808,7 @@ func TestMarkdownDividerFitsTranscriptContentWidth(t *testing.T) { if m.viewport.Width() != wantW { t.Fatalf("viewport width = %d, want transcript content width %d", m.viewport.Width(), wantW) } - rule := strings.TrimRight(m.renderer.Render("---"), "\n") + rule := strings.TrimRight(newMarkdownRenderer(wantW).Render("---"), "\n") lines := strings.Split(wrapTranscript(rule, m.viewport.Width()), "\n") if len(lines) != 1 { t.Fatalf("markdown divider wrapped into %d lines at width %d: %q", len(lines), m.viewport.Width(), lines) @@ -1429,6 +1428,71 @@ func clipboardCopyResultFromCmd(t *testing.T, cmd tea.Cmd) clipboardCopyMsg { return clipboardCopyMsg{} } +func clipboardTextPasteResultFromCmd(t *testing.T, cmd tea.Cmd) clipboardTextPasteMsg { + t.Helper() + if cmd == nil { + t.Fatal("expected clipboard paste command") + } + msg := cmd() + switch msg := msg.(type) { + case clipboardTextPasteMsg: + return msg + case tea.BatchMsg: + for _, child := range msg { + if child == nil { + continue + } + childMsg := child() + if result, ok := childMsg.(clipboardTextPasteMsg); ok { + return result + } + } + } + t.Fatalf("clipboard paste command returned %T, want clipboardTextPasteMsg", msg) + return clipboardTextPasteMsg{} +} + +func TestMouseRightClickWithoutSelectionPastesClipboardText(t *testing.T) { + m := newComposerMouseTestTUI(t, 60, 16) + m.input.SetValue("before ") + + previous := readNativeClipboardText + t.Cleanup(func() { readNativeClipboardText = previous }) + readNativeClipboardText = func() (string, error) { return "pasted text", nil } + + next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseRight}) + m = next.(chatTUI) + result := clipboardTextPasteResultFromCmd(t, cmd) + next, _ = m.Update(result) + m = next.(chatTUI) + + if got := m.input.Value(); got != "before pasted text" { + t.Fatalf("right-click paste produced %q, want %q", got, "before pasted text") + } +} + +func TestMouseRightClickPasteUsesCanonicalFoldedPastePath(t *testing.T) { + m := newComposerMouseTestTUI(t, 60, 16) + pasted := "one\ntwo\nthree\nfour\nfive" + + previous := readNativeClipboardText + t.Cleanup(func() { readNativeClipboardText = previous }) + readNativeClipboardText = func() (string, error) { return pasted, nil } + + next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseRight}) + m = next.(chatTUI) + result := clipboardTextPasteResultFromCmd(t, cmd) + next, _ = m.Update(result) + m = next.(chatTUI) + + if got := m.input.Value(); got != "[Pasted text #1 · 5 lines] " { + t.Fatalf("right-click folded paste display = %q", got) + } + if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].text != pasted { + t.Fatalf("right-click folded paste block = %+v", m.pastedBlocks) + } +} + // TestMouseDragReleaseAutoCopies verifies that releasing the mouse after a // left-drag over the transcript copies the selection to the clipboard // automatically (native terminal convention), keeps the selection highlighted diff --git a/internal/cli/composer_selection_test.go b/internal/cli/composer_selection_test.go index 0ade276154..045d086594 100644 --- a/internal/cli/composer_selection_test.go +++ b/internal/cli/composer_selection_test.go @@ -11,6 +11,7 @@ import ( "reasonix/internal/control" "reasonix/internal/event" + "reasonix/internal/i18n" ) func newComposerMouseTestTUI(t *testing.T, width, height int) chatTUI { @@ -285,6 +286,55 @@ func TestComposerSelectionPasteAndCopyTakePrecedence(t *testing.T) { } } +func TestComposerDragReleaseAutoCopies(t *testing.T) { + m := newComposerMouseTestTUI(t, 40, 12) + m.input.SetValue("alpha beta") + x, y, _ := m.composerOrigin() + m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 6, Y: y, Button: tea.MouseLeft}) + m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 10, Y: y, Button: tea.MouseLeft}) + + previous := writeNativeClipboardText + t.Cleanup(func() { writeNativeClipboardText = previous }) + writeNativeClipboardText = func(text string) error { + if text != "beta" { + t.Fatalf("composer drag copied %q, want beta", text) + } + return nil + } + + next, cmd := m.Update(tea.MouseReleaseMsg{X: x + 10, Y: y, Button: tea.MouseLeft}) + m = next.(chatTUI) + if cmd == nil { + t.Fatal("composer drag release should copy the selected text") + } + if got := m.selectedComposerText(); got != "beta" { + t.Fatalf("composer selection after drag copy = %q, want beta", got) + } + + result := clipboardCopyResultFromCmd(t, cmd) + next, _ = m.Update(result) + m = next.(chatTUI) + if m.copyNoticeText != i18n.M.MouseCopiedHint { + t.Fatalf("composer drag copy notice = %q, want %q", m.copyNoticeText, i18n.M.MouseCopiedHint) + } +} + +func TestComposerPlainClickReleaseDoesNotCopy(t *testing.T) { + m := newComposerMouseTestTUI(t, 40, 12) + m.input.SetValue("alpha beta") + x, y, _ := m.composerOrigin() + m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 3, Y: y, Button: tea.MouseLeft}) + + next, cmd := m.Update(tea.MouseReleaseMsg{X: x + 3, Y: y, Button: tea.MouseLeft}) + m = next.(chatTUI) + if cmd != nil { + t.Fatal("plain composer click must not copy an empty selection") + } + if m.validComposerSelection() { + t.Fatal("plain composer click should clear its empty selection") + } +} + func TestComposerSelectionDoesNotTurnCommandShortcutIntoText(t *testing.T) { m := newComposerMouseTestTUI(t, 40, 12) m.input.SetValue("keep this") diff --git a/internal/cli/status_footer.go b/internal/cli/status_footer.go index 5fd9d19dbf..1c1910be8e 100644 --- a/internal/cli/status_footer.go +++ b/internal/cli/status_footer.go @@ -262,9 +262,9 @@ func (m chatTUI) statusTelemetryGroups() []string { return data } -// renderStatusBlock owns the complete persistent footer layout. Wide screens -// get two visually stable information rows; narrow screens add deliberate -// left-aligned rows only between semantic groups. +// renderStatusBlock owns the complete persistent footer layout. The optional +// data band is separated from interaction state when Git or telemetry exists; +// narrow screens add deliberate left-aligned rows only between semantic groups. func (m chatTUI) renderStatusBlock(primary string, width int) string { if width <= 0 { width = 1 @@ -274,7 +274,7 @@ func (m chatTUI) renderStatusBlock(primary string, width int) string { first := layoutStatusSides(primary, modelWork, width) second := m.layoutGitTelemetry(width) if second == "" { - second = statusFooterIndent + return first } return first + "\n" + statusFooterDivider(width) + "\n" + second } @@ -360,10 +360,10 @@ func (m chatTUI) layoutGitTelemetry(width int) string { telemetry := strings.Join(telemetryGroups, " ") hasGit := strings.TrimSpace(m.gitStatus.Repo) != "" && strings.TrimSpace(m.gitStatus.Branch) != "" if !hasGit { - if visibleWidth(telemetry) > width { - return packStatusGroups(telemetryGroups, width) - } - return rightAlignStatusGroup(telemetry, width) + // Without a Git identity there is no left-hand peer to balance. Keep the + // telemetry anchored to the normal footer indent instead of leaving a + // repo-sized visual hole across most of a wide terminal. + return packStatusGroups(telemetryGroups, width) } fullGitBudget := max(width-visibleWidth(statusFooterIndent), 1) diff --git a/internal/cli/status_footer_test.go b/internal/cli/status_footer_test.go index 2153a11067..2ee12d1f71 100644 --- a/internal/cli/status_footer_test.go +++ b/internal/cli/status_footer_test.go @@ -288,8 +288,8 @@ func TestStatusFooterUsesReadableLocalizedHintAndWrapsCleanly(t *testing.T) { primary := m.primaryStatusLine(" Auto ", false, false) block := ansi.Strip(m.renderStatusBlock(primary, 100)) lines := strings.Split(block, "\n") - if len(lines) != 4 { - t.Fatalf("localized footer rows = %d, want wrapped primary/session rows plus divider and telemetry:\n%s", len(lines), block) + if len(lines) != 2 { + t.Fatalf("localized footer rows = %d, want wrapped primary/session rows without an empty data band:\n%s", len(lines), block) } if !strings.Contains(lines[0], tt.compact) || !strings.Contains(lines[1], tt.session) { t.Fatalf("localized footer did not keep readable shortcut and session groups:\n%s", block) @@ -409,6 +409,33 @@ func TestStatusFooterSwapsModelAndGitGroups(t *testing.T) { } } +func TestStatusFooterWithoutGitLeftAlignsTelemetry(t *testing.T) { + defer i18n.DetectLanguage("en") + i18n.DetectLanguage("en") + + m := newTestChatTUI() + m.balance = "¥12.34" + line := ansi.Strip(m.layoutGitTelemetry(120)) + if !strings.HasPrefix(line, statusFooterIndent+"BAL ¥12.34") { + t.Fatalf("non-Git telemetry should be left aligned, got %q", line) + } + if visibleWidth(line) >= 120 { + t.Fatalf("non-Git telemetry unexpectedly retained right-alignment padding: %q", line) + } +} + +func TestStatusFooterOmitsEmptyDataBand(t *testing.T) { + m := newTestChatTUI() + primary := " Auto · ready" + block := ansi.Strip(m.renderStatusBlock(primary, 120)) + if block != primary { + t.Fatalf("empty Git/telemetry status block = %q, want only %q", block, primary) + } + if strings.Contains(block, "─") { + t.Fatalf("empty Git/telemetry status block retained a divider: %q", block) + } +} + func TestStatusFooterMediumLayoutLeftAlignsModelWork(t *testing.T) { i18n.DetectLanguage("en") @@ -420,17 +447,10 @@ func TestStatusFooterMediumLayoutLeftAlignsModelWork(t *testing.T) { primary := m.primaryStatusLine(" Auto ", false, false) lines := strings.Split(ansi.Strip(m.renderStatusBlock(primary, 82)), "\n") - divider := -1 - for i, line := range lines { - if strings.Trim(line, "─ ") == "" && strings.Contains(line, "─") { - divider = i - break - } - } - if divider < 2 { - t.Fatalf("medium footer should place model/work on a deliberate row before the divider:\n%s", strings.Join(lines, "\n")) + if len(lines) != 2 { + t.Fatalf("medium footer rows = %d, want primary plus model/work without an empty data band:\n%s", len(lines), strings.Join(lines, "\n")) } - modelRow := lines[divider-1] + modelRow := lines[1] if !strings.HasPrefix(modelRow, statusFooterIndent+"MODEL deepseek-v4-flash") || !strings.Contains(modelRow, "EFFORT auto WORK balanced") { t.Fatalf("medium model/effort/work row should be left aligned, got %q:\n%s", modelRow, strings.Join(lines, "\n")) diff --git a/internal/cli/statusline_test.go b/internal/cli/statusline_test.go index 682c8f5885..8cd0380176 100644 --- a/internal/cli/statusline_test.go +++ b/internal/cli/statusline_test.go @@ -195,12 +195,10 @@ func TestStatuslineShowsEffortInPersistentFooter(t *testing.T) { i18n.DetectLanguage("en") content := renderStatuslineViewWithEffort(t, "auto") - lines := bottomStatusPlainLines(content) - if len(lines) != 3 { - t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n")) - } - if !strings.Contains(lines[0], "MODEL deepseek-v4-flash EFFORT auto") { - t.Fatalf("session row should keep effort beside the model:\n%s", strings.Join(lines, "\n")) + lines := strings.Split(ansi.Strip(content), "\n") + statusLine := lines[len(lines)-1] + if !strings.Contains(statusLine, "MODEL deepseek-v4-flash EFFORT auto") { + t.Fatalf("session row should keep effort beside the model:\n%s", statusLine) } } diff --git a/internal/cli/transcript.go b/internal/cli/transcript.go index d97241539f..4635bfdf49 100644 --- a/internal/cli/transcript.go +++ b/internal/cli/transcript.go @@ -84,12 +84,7 @@ func (m *chatTUI) renderTranscriptSource(source transcriptSource, terminalWidth contentWidth := transcriptContentWidth(terminalWidth, m.nativeScrollback) switch source.kind { case transcriptSourceMarkdown: - renderer := newMarkdownRenderer(contentWidth) - rendered := renderer.Render(source.raw) - if rendered == "" { - rendered = source.raw - } - return strings.TrimRight(rendered, "\n") + return renderAssistantMarkdown(source.raw, contentWidth) case transcriptSourceUser: return renderUserBubble(source.raw, terminalWidth, source.planMode) case transcriptSourceReasoning: @@ -101,8 +96,7 @@ func (m *chatTUI) renderTranscriptSource(source transcriptSource, terminalWidth case transcriptSourceReplayBundle: var b strings.Builder b.WriteString(renderTUIBanner(m.label, source.raw, contentWidth)) - renderer := newMarkdownRenderer(contentWidth) - for _, section := range replaySectionsFor(source.history, contentWidth, renderer) { + for _, section := range replaySectionsFor(source.history, contentWidth) { b.WriteString(section) } return strings.TrimRight(b.String(), "\n") @@ -113,6 +107,45 @@ func (m *chatTUI) renderTranscriptSource(source transcriptSource, terminalWidth } } +const assistantTranscriptIndent = " " + +// renderAssistantMarkdown gives assistant prose the same explicit transcript +// identity that user, reasoning, tool, and receipt blocks already have. The +// body keeps a restrained two-cell gutter instead of using a heavy card, and +// rendering at the reduced width keeps every indented row inside the viewport. +func renderAssistantMarkdown(raw string, contentWidth int) string { + contentWidth = max(contentWidth, 1) + indent := assistantTranscriptIndent + if contentWidth <= visibleWidth(indent) { + indent = "" + } + bodyWidth := max(contentWidth-visibleWidth(indent), 1) + renderer := newMarkdownRenderer(bodyWidth) + rendered := renderer.Render(raw) + if rendered == "" { + rendered = raw + } + body := strings.TrimRight(rendered, "\n") + header := indent + accent("◆") + " " + bold("Reasonix") + if body == "" { + return header + } + return header + "\n\n" + indentTranscriptBlock(body, indent) +} + +func indentTranscriptBlock(block, indent string) string { + if indent == "" || block == "" { + return block + } + lines := strings.Split(block, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") +} + func renderTurnReceiptBand(receipt string, contentWidth int) string { if strings.TrimSpace(ansi.Strip(receipt)) == "" { return "" diff --git a/internal/cli/transcript_test.go b/internal/cli/transcript_test.go index df31f24b30..e26477dc3f 100644 --- a/internal/cli/transcript_test.go +++ b/internal/cli/transcript_test.go @@ -2,9 +2,57 @@ package cli import ( "errors" + "strings" "testing" + + "github.com/charmbracelet/x/ansi" + + "reasonix/internal/provider" ) +func TestAssistantMarkdownHasIdentityAndIndentedBody(t *testing.T) { + defer restoreThemeForTest(colorEnabled, activeCLITheme) + colorEnabled = false + configureCLITheme("dark") + + rendered := renderAssistantMarkdown("A concise answer that wraps across the available width.", 32) + lines := strings.Split(ansi.Strip(rendered), "\n") + if len(lines) < 4 { + t.Fatalf("assistant block should contain a header, gap, and wrapped body:\n%s", rendered) + } + if lines[0] != " ◆ Reasonix" { + t.Fatalf("assistant header = %q, want %q", lines[0], " ◆ Reasonix") + } + if lines[1] != "" { + t.Fatalf("assistant header/body separator = %q, want blank row", lines[1]) + } + for i, line := range lines[2:] { + if line != "" && !strings.HasPrefix(line, assistantTranscriptIndent) { + t.Fatalf("assistant body row %d lacks the two-cell gutter: %q", i+2, line) + } + if width := visibleWidth(line); width > 32 { + t.Fatalf("assistant row %d width = %d, want <= 32: %q", i+2, width, line) + } + } +} + +func TestReplaySectionsKeepAssistantIdentity(t *testing.T) { + defer restoreThemeForTest(colorEnabled, activeCLITheme) + colorEnabled = false + configureCLITheme("dark") + + sections := replaySectionsFor([]provider.Message{ + {Role: provider.RoleUser, Content: "Which version?"}, + {Role: provider.RoleAssistant, Content: "Version 1.2.3"}, + }, 48) + if len(sections) != 2 { + t.Fatalf("replay sections = %d, want user and assistant", len(sections)) + } + if plain := ansi.Strip(sections[1]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Version 1.2.3") { + t.Fatalf("replayed assistant answer lost its identity: %q", plain) + } +} + func TestScrollbarThumb(t *testing.T) { if _, size := scrollbarThumb(10, 0, 5); size != 0 { t.Errorf("content within viewport should have no thumb, got size %d", size) diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 425c902943..fc509a1a42 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -195,6 +195,7 @@ type Messages struct { MouseCopiedHint string // transient status-line hint after a mouse/Ctrl+C selection copy ClipboardCopyOSC52Hint string // copy was sent through OSC 52 because the session is remote ClipboardCopyFallbackHint string // native clipboard failed and copy fell back to OSC 52 + ClipboardTextPasteFailedFmt string // text clipboard read failed, one %v ClipboardImagePastingHint string // shown while an image is being read from the system clipboard ClipboardImagePasteFailedFmt string // image clipboard read failed, one %v MouseCaptureOnHint string // "/mouse" turned in-app mouse handling back on diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index c3804f3a64..a7e330b094 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -209,6 +209,7 @@ var English = Messages{ MouseCopiedHint: "copied to clipboard", ClipboardCopyOSC52Hint: "copy sent via OSC 52 — terminal permission may be required", ClipboardCopyFallbackHint: "native clipboard unavailable — copy sent via OSC 52", + ClipboardTextPasteFailedFmt: "paste text failed: %v", ClipboardImagePastingHint: "Pasting image…", ClipboardImagePasteFailedFmt: "paste image failed: %v", MouseCaptureOnHint: "mouse capture on — in-app drag-select/scrollbar/wheel active", diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index 1e76ab247f..75208f3e2b 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -210,6 +210,7 @@ var Chinese = Messages{ MouseCopiedHint: "已复制到剪贴板", ClipboardCopyOSC52Hint: "已通过 OSC 52 请求复制 — 可能需要终端授权", ClipboardCopyFallbackHint: "系统剪贴板不可用 — 已回退到 OSC 52", + ClipboardTextPasteFailedFmt: "粘贴文本失败:%v", ClipboardImagePastingHint: "正在粘贴图片…", ClipboardImagePasteFailedFmt: "粘贴图片失败:%v", MouseCaptureOnHint: "鼠标接管已开启 — 应用内拖拽选中/滚动条/滚轮生效", diff --git a/internal/i18n/messages_zh_tw.go b/internal/i18n/messages_zh_tw.go index 244bc7e9fc..b14984351e 100644 --- a/internal/i18n/messages_zh_tw.go +++ b/internal/i18n/messages_zh_tw.go @@ -200,6 +200,7 @@ var ChineseTraditional = Messages{ MouseCopiedHint: "已複製到剪貼簿", ClipboardCopyOSC52Hint: "已透過 OSC 52 請求複製 — 可能需要終端授權", ClipboardCopyFallbackHint: "系統剪貼簿無法使用 — 已改用 OSC 52", + ClipboardTextPasteFailedFmt: "貼上文字失敗:%v", ClipboardImagePastingHint: "正在貼上圖片…", ClipboardImagePasteFailedFmt: "貼上圖片失敗:%v", MouseCaptureOnHint: "滑鼠接管已開啟 — 應用內拖拽選取/捲軸/滾輪生效", From 2f8455efa4d2b1b16c545f6957b051e273d29cbf Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:55:06 +0800 Subject: [PATCH 2/3] fix(cli): clarify right-click paste over SSH Problem: Captured right-click paste attempted to read the remote host clipboard during SSH sessions and the documentation implied it could access the user's local clipboard. Root cause: The right-click path called the native clipboard reader without applying the remote-session boundary already used by clipboard copy. Fix: Detect SSH sessions before reading clipboard text, preserve the composer, show a localized terminal-paste hint, and document local versus remote behavior. Verification: go test ./internal/cli; go test ./...; go vet ./...; git diff --check. --- docs/CLI.md | 11 +++++++---- docs/CLI.zh-CN.md | 5 +++-- docs/GUIDE.md | 2 +- docs/GUIDE.zh-CN.md | 2 +- internal/cli/chat_tui.go | 4 ++++ internal/cli/chat_tui_paste.go | 8 ++++++-- internal/cli/chat_tui_test.go | 32 ++++++++++++++++++++++++++++++++ internal/i18n/i18n.go | 1 + internal/i18n/messages_en.go | 1 + internal/i18n/messages_zh.go | 1 + internal/i18n/messages_zh_tw.go | 1 + 11 files changed, 58 insertions(+), 10 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 4b1b16b2f5..755e3b5a89 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -229,10 +229,13 @@ and composer selections use the native system clipboard and report success only after that write completes; SSH falls back to an explicitly labelled OSC 52 request. Text paste remains the terminal's bracketed-paste action (`Cmd+V` on macOS and the terminal's configured shortcut elsewhere). While Reasonix owns the -mouse, right-click with no selection reads clipboard text through the same paste -path; right-click with a selection copies it. Image paste is application-owned: -use `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`; the footer -shows `Pasting image…` until the attachment token is ready. +mouse in a local session, right-click with no selection reads clipboard text +through the same paste path; right-click with a selection copies it. Over SSH, +use the terminal paste shortcut because the remote process cannot read the local +clipboard; `/mouse` restores the terminal's native right-click menu. Image paste +is application-owned: use `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or +`/paste-image`; the footer shows `Pasting image…` until the attachment token is +ready. ## In-session commands diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index c78fa84b59..bd2e111fb5 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -210,8 +210,9 @@ reasonix -p "同时更新两个项目" \ 剪贴板操作按内容类型明确分开。本地 transcript 和输入框选区写入系统剪贴板,并且只有写入 成功后才提示完成;SSH 会回退到明确标记的 OSC 52 请求。文本粘贴继续走终端的 bracketed-paste 动作(macOS 通常为 `Cmd+V`,其它平台使用终端自身配置)。Reasonix 接管 -鼠标时,没有选区的右键会读取剪贴板文本并走同一粘贴路径,有选区时右键优先复制。图片粘贴 -由 Reasonix 接管:macOS/Linux 使用 `Ctrl+V`,Windows 使用 `Alt+V`,也可运行 +本地会话的鼠标时,没有选区的右键会读取剪贴板文本并走同一粘贴路径,有选区时右键优先复制。 +SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键;`/mouse` 可恢复终端原生右键菜单。 +图片粘贴由 Reasonix 接管:macOS/Linux 使用 `Ctrl+V`,Windows 使用 `Alt+V`,也可运行 `/paste-image`;附件标记准备完成前,底栏会显示“正在粘贴图片…”。 ## 会话内命令 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 23db059659..c9385fbfe1 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -422,7 +422,7 @@ Chat and transcript shortcuts: | Double `Esc` on an empty idle composer | Opens the rewind picker | Same entry point as `/rewind`. | | Transcript text selection | Copies transcript text | Releasing an in-app drag writes through the verified native clipboard path in a local session (`pbcopy` on macOS, the available Wayland/X11 tool on Linux, or the Windows clipboard). SSH falls back to OSC 52 and labels the fallback instead of claiming native success. `Ctrl+C`/`Super+C`/`Meta+C` or right-clicking the active selection copies it again. | | Composer text selection | Selects, copies, or replaces draft text | Releasing an in-app drag copies the selection through the same verified clipboard path as transcript text. Typing or pasting replaces the selection; arrow keys collapse it. | -| Right-click with no active selection | Pastes clipboard text | While in-app mouse capture is on, Reasonix reads text only and routes it through the normal bracketed-paste handling. Right-click with an active selection copies that selection instead. | +| Right-click with no active selection | Pastes clipboard text locally | In a local session with in-app mouse capture on, Reasonix reads text only and routes it through the normal bracketed-paste handling. Over SSH, use the terminal paste shortcut because the remote process cannot read the local clipboard; `/mouse` restores the terminal's native right-click menu. Right-click with an active selection still copies that selection. | | `/mouse` | Toggles in-app mouse capture | Off hands the mouse back to your terminal, restoring its native click-drag selection and right-click context menu, at the cost of in-app drag-select, the transcript scrollbar, and wheel-scroll. Set `REASONIX_DISABLE_MOUSE=1` to start every session with it off. | | `Ctrl+C` | Copies, cancels, clears, or quits | Copies an active transcript or composer selection first. Otherwise it cancels a running turn, clears non-empty input, or quits on a second empty-composer press. | | `Ctrl+D` | Quits the TUI | Immediate quit. | diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index d93d114b44..514c51e35a 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -342,7 +342,7 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u | 空闲且输入为空时双击 `Esc` | 打开 rewind 选择器 | 和 `/rewind` 是同一个入口。 | | transcript 文本选择 | 复制 transcript 文本 | 应用内拖选松开后,本地会话通过可验证的系统剪贴板路径写入(macOS `pbcopy`、Linux 可用的 Wayland/X11 工具、Windows 系统剪贴板);SSH 才回退到 OSC 52,并明确标记为回退而不是宣称原生复制成功。`Ctrl+C`/`Super+C`/`Meta+C` 或右键当前选区可再次复制。 | | 输入框文本选择 | 选中、复制或替换草稿文本 | 应用内拖选松开后,会通过与 transcript 相同的可验证剪贴板路径复制;输入或粘贴会替换选区,方向键会收起选区。 | -| 没有活动选区时右键 | 粘贴剪贴板文本 | 开启鼠标接管时,Reasonix 只读取文本并交给正常的 bracketed-paste 处理;存在活动选区时,右键仍优先复制该选区。 | +| 没有活动选区时右键 | 在本地会话粘贴剪贴板文本 | 本地会话开启鼠标接管时,Reasonix 只读取文本并交给正常的 bracketed-paste 处理。SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键;`/mouse` 可恢复终端原生右键菜单。存在活动选区时,右键仍优先复制该选区。 | | `/mouse` | 切换应用内鼠标接管 | 关闭后由终端处理原生拖选和右键菜单,但会失去应用内选区、滚动条和滚轮。可用 `REASONIX_DISABLE_MOUSE=1` 让每次会话默认关闭。 | | `Ctrl+C` | 复制、取消、清空或退出 | 有 transcript 或输入框活动选区时优先复制;否则取消运行中的 turn、清空非空输入,或在空输入下连按两次退出。 | | `Ctrl+D` | 退出 TUI | 立即退出。 | diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 1595f18aa8..45c73635a3 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -1665,6 +1665,10 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { } case clipboardTextPasteMsg: + if msg.remote { + m.notice(i18n.M.ClipboardTextPasteRemoteHint) + break + } if msg.err != nil { m.notice(fmt.Sprintf(i18n.M.ClipboardTextPasteFailedFmt, msg.err)) break diff --git a/internal/cli/chat_tui_paste.go b/internal/cli/chat_tui_paste.go index 45bb12fe1d..824812a575 100644 --- a/internal/cli/chat_tui_paste.go +++ b/internal/cli/chat_tui_paste.go @@ -116,8 +116,9 @@ func pasteClipboardImage() tea.Cmd { } type clipboardTextPasteMsg struct { - text string - err error + text string + err error + remote bool } var readNativeClipboardText = clipboard.ReadAll @@ -127,6 +128,9 @@ var readNativeClipboardText = clipboard.ReadAll // is deliberately text-only so right-click never probes for an image first. func pasteClipboardText() tea.Cmd { return func() tea.Msg { + if remoteClipboardSession() { + return clipboardTextPasteMsg{remote: true} + } text, err := readNativeClipboardText() return clipboardTextPasteMsg{text: text, err: err} } diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index 6937e36418..dd0fd46547 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -1471,6 +1471,38 @@ func TestMouseRightClickWithoutSelectionPastesClipboardText(t *testing.T) { } } +func TestMouseRightClickPasteOverSSHDoesNotReadRemoteClipboard(t *testing.T) { + t.Setenv("SSH_CONNECTION", "host 22 client 1234") + t.Setenv("SSH_CLIENT", "") + t.Setenv("SSH_TTY", "") + + m := newComposerMouseTestTUI(t, 60, 16) + m.input.SetValue("before ") + + previous := readNativeClipboardText + t.Cleanup(func() { readNativeClipboardText = previous }) + readNativeClipboardText = func() (string, error) { + t.Fatal("SSH right-click paste must not read the remote host clipboard") + return "", nil + } + + next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseRight}) + m = next.(chatTUI) + result := clipboardTextPasteResultFromCmd(t, cmd) + if !result.remote { + t.Fatalf("SSH right-click paste result = %+v, want remote hint", result) + } + + next, _ = m.Update(result) + m = next.(chatTUI) + if got := m.input.Value(); got != "before " { + t.Fatalf("SSH right-click paste changed composer to %q", got) + } + if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, i18n.M.ClipboardTextPasteRemoteHint) { + t.Fatalf("SSH right-click paste notice = %q, want %q", got, i18n.M.ClipboardTextPasteRemoteHint) + } +} + func TestMouseRightClickPasteUsesCanonicalFoldedPastePath(t *testing.T) { m := newComposerMouseTestTUI(t, 60, 16) pasted := "one\ntwo\nthree\nfour\nfive" diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index fc509a1a42..7316d90056 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -195,6 +195,7 @@ type Messages struct { MouseCopiedHint string // transient status-line hint after a mouse/Ctrl+C selection copy ClipboardCopyOSC52Hint string // copy was sent through OSC 52 because the session is remote ClipboardCopyFallbackHint string // native clipboard failed and copy fell back to OSC 52 + ClipboardTextPasteRemoteHint string // right-click paste cannot read the user's local clipboard over SSH ClipboardTextPasteFailedFmt string // text clipboard read failed, one %v ClipboardImagePastingHint string // shown while an image is being read from the system clipboard ClipboardImagePasteFailedFmt string // image clipboard read failed, one %v diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index a7e330b094..3eebbcf4d2 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -209,6 +209,7 @@ var English = Messages{ MouseCopiedHint: "copied to clipboard", ClipboardCopyOSC52Hint: "copy sent via OSC 52 — terminal permission may be required", ClipboardCopyFallbackHint: "native clipboard unavailable — copy sent via OSC 52", + ClipboardTextPasteRemoteHint: "right-click paste cannot read your local clipboard over SSH — use the terminal paste shortcut or /mouse", ClipboardTextPasteFailedFmt: "paste text failed: %v", ClipboardImagePastingHint: "Pasting image…", ClipboardImagePasteFailedFmt: "paste image failed: %v", diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index 75208f3e2b..ce6b8506bc 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -210,6 +210,7 @@ var Chinese = Messages{ MouseCopiedHint: "已复制到剪贴板", ClipboardCopyOSC52Hint: "已通过 OSC 52 请求复制 — 可能需要终端授权", ClipboardCopyFallbackHint: "系统剪贴板不可用 — 已回退到 OSC 52", + ClipboardTextPasteRemoteHint: "SSH 下右键粘贴无法读取本地剪贴板 — 请使用终端粘贴快捷键或 /mouse", ClipboardTextPasteFailedFmt: "粘贴文本失败:%v", ClipboardImagePastingHint: "正在粘贴图片…", ClipboardImagePasteFailedFmt: "粘贴图片失败:%v", diff --git a/internal/i18n/messages_zh_tw.go b/internal/i18n/messages_zh_tw.go index b14984351e..db2fb3d322 100644 --- a/internal/i18n/messages_zh_tw.go +++ b/internal/i18n/messages_zh_tw.go @@ -200,6 +200,7 @@ var ChineseTraditional = Messages{ MouseCopiedHint: "已複製到剪貼簿", ClipboardCopyOSC52Hint: "已透過 OSC 52 請求複製 — 可能需要終端授權", ClipboardCopyFallbackHint: "系統剪貼簿無法使用 — 已改用 OSC 52", + ClipboardTextPasteRemoteHint: "SSH 下右鍵貼上無法讀取本機剪貼簿 — 請使用終端貼上快捷鍵或 /mouse", ClipboardTextPasteFailedFmt: "貼上文字失敗:%v", ClipboardImagePastingHint: "正在貼上圖片…", ClipboardImagePasteFailedFmt: "貼上圖片失敗:%v", From e6b230d8c5841d652dbd22d8d80afc76444c0119 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:05:46 +0800 Subject: [PATCH 3/3] test(cli): isolate local clipboard cases from SSH Problem: Four local clipboard interaction tests inherited SSH environment variables and failed when the suite ran on a remote development host. Root cause: The tests stubbed native clipboard operations without explicitly selecting a local clipboard session. Fix: Add a shared test helper that clears all supported SSH markers and use it in each local paste and drag-copy case. Verification: Normal and simulated-SSH go test ./internal/cli -count=1; go test ./...; go vet ./...; git diff --check. --- internal/cli/chat_tui_test.go | 10 ++++++++++ internal/cli/composer_selection_test.go | 1 + 2 files changed, 11 insertions(+) diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index dd0fd46547..2980154e8b 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -1452,7 +1452,15 @@ func clipboardTextPasteResultFromCmd(t *testing.T, cmd tea.Cmd) clipboardTextPas return clipboardTextPasteMsg{} } +func setLocalClipboardSession(t *testing.T) { + t.Helper() + t.Setenv("SSH_CONNECTION", "") + t.Setenv("SSH_CLIENT", "") + t.Setenv("SSH_TTY", "") +} + func TestMouseRightClickWithoutSelectionPastesClipboardText(t *testing.T) { + setLocalClipboardSession(t) m := newComposerMouseTestTUI(t, 60, 16) m.input.SetValue("before ") @@ -1504,6 +1512,7 @@ func TestMouseRightClickPasteOverSSHDoesNotReadRemoteClipboard(t *testing.T) { } func TestMouseRightClickPasteUsesCanonicalFoldedPastePath(t *testing.T) { + setLocalClipboardSession(t) m := newComposerMouseTestTUI(t, 60, 16) pasted := "one\ntwo\nthree\nfour\nfive" @@ -1531,6 +1540,7 @@ func TestMouseRightClickPasteUsesCanonicalFoldedPastePath(t *testing.T) { // so a follow-up right-click can still re-copy it, and arms the transient // "copied to clipboard" status-line notice. func TestMouseDragReleaseAutoCopies(t *testing.T) { + setLocalClipboardSession(t) m := newTestChatTUI() m.transcript = []string{"hello world"} m.wrappedLines = []string{"hello world"} diff --git a/internal/cli/composer_selection_test.go b/internal/cli/composer_selection_test.go index 045d086594..46a0d00cb6 100644 --- a/internal/cli/composer_selection_test.go +++ b/internal/cli/composer_selection_test.go @@ -287,6 +287,7 @@ func TestComposerSelectionPasteAndCopyTakePrecedence(t *testing.T) { } func TestComposerDragReleaseAutoCopies(t *testing.T) { + setLocalClipboardSession(t) m := newComposerMouseTestTUI(t, 40, 12) m.input.SetValue("alpha beta") x, y, _ := m.composerOrigin()