Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,17 @@ 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:
use `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`; the footer
shows `Pasting image…` until the attachment token is ready.
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 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

Expand Down
12 changes: 7 additions & 5 deletions docs/CLI.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,13 @@ 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 接管
本地会话的鼠标时,没有选区的右键会读取剪贴板文本并走同一粘贴路径,有选区时右键优先复制。
SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键;`/mouse` 可恢复终端原生右键菜单。
图片粘贴由 Reasonix 接管:macOS/Linux 使用 `Ctrl+V`,Windows 使用 `Alt+V`,也可运行
`/paste-image`;附件标记准备完成前,底栏会显示“正在粘贴图片…”。

## 会话内命令

Expand Down
4 changes: 3 additions & 1 deletion docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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. |
Expand Down
4 changes: 3 additions & 1 deletion docs/GUIDE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 处理。SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键;`/mouse` 可恢复终端原生右键菜单。存在活动选区时,右键仍优先复制该选区。 |
| `/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]` 标记。 |
Expand Down
54 changes: 48 additions & 6 deletions internal/cli/chat_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +35,6 @@ func newTestChatTUI() chatTUI {
reasoning: &strings.Builder{},
pending: &strings.Builder{},
pendingCommit: &commit,
renderer: newMarkdownRenderer(80),
shellOutputs: shellOut,
shellExpanded: shellExp,
shellTranscriptIdx: shellIdx,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -98,15 +137,18 @@ 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])
}
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)
Expand Down
Loading