diff --git a/desktop/app.go b/desktop/app.go index f2b6566c30..dfec3ba73b 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -111,6 +111,16 @@ func (a *App) Submit(input string) { } } +// SubmitDisplay runs input as a turn while recording a shorter UI-only display +// string for the saved desktop transcript. The model still receives input. +func (a *App) SubmitDisplay(display, input string) { + if a.ctrl == nil { + return + } + _ = recordSessionDisplay(config.SessionDir(), a.ctrl.SessionPath(), input, display) + a.ctrl.Submit(input) +} + // Cancel aborts the in-flight turn. func (a *App) Cancel() { if a.ctrl != nil { @@ -375,9 +385,14 @@ func (a *App) History() []HistoryMessage { return nil } msgs := a.ctrl.History() + resolve := sessionDisplayResolver(config.SessionDir(), a.ctrl.SessionPath()) out := make([]HistoryMessage, 0, len(msgs)) for _, m := range msgs { - out = append(out, HistoryMessage{Role: string(m.Role), Content: m.Content}) + content := m.Content + if m.Role == provider.RoleUser { + content = resolve(m.Content) + } + out = append(out, HistoryMessage{Role: string(m.Role), Content: content}) } return out } diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 4aa7c39f44..ab295ce903 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -106,8 +106,8 @@ export default function App() { // (/skill, /hooks, /mcp) — goes straight to Submit, which the controller // resolves (a turn, or a listing Notice). const handleSend = useCallback( - (text: string) => { - const t = text.trim(); + (displayText: string, submitText = displayText) => { + const t = displayText.trim(); const model = /^\/model\s+(\S+)$/.exec(t); if (model) { void switchModel(model[1]); @@ -117,7 +117,7 @@ export default function App() { void openMemory(); return; } - send(t); + send(t, submitText.trim()); }, [switchModel, openMemory, send], ); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 9f2c32b97a..f37ef299aa 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -13,6 +13,27 @@ interface Attachment { previewUrl: string; } +const LONG_PASTE_MIN_CHARS = 1000; +const LONG_PASTE_MIN_LINES = 5; + +type PastedBlock = { + label: string; + text: string; +}; + +function lineCount(s: string): number { + if (s === "") return 0; + return s.split(/\r\n|\r|\n/).length; +} + +function shouldFoldPaste(s: string): boolean { + return s.length >= LONG_PASTE_MIN_CHARS || lineCount(s) >= LONG_PASTE_MIN_LINES; +} + +function renderPastedBlock(block: PastedBlock): string { + return `${block.label}\n\n--- Begin ${block.label} ---\n${block.text}\n--- End ${block.label} ---`; +} + export function Composer({ running, mode, @@ -22,7 +43,7 @@ export function Composer({ }: { running: boolean; mode: Mode; - onSend: (text: string) => void; + onSend: (displayText: string, submitText?: string) => void; // Returns the un-sent text when cancelling before the server replied (so it can // be restored to the input); undefined for a normal cancel. onCancel: () => string | undefined; @@ -32,10 +53,20 @@ export function Composer({ const [text, setText] = useState(""); const [attachments, setAttachments] = useState([]); const [pendingPaste, setPendingPaste] = useState(0); + const pastedBlocksRef = useRef([]); + const nextPasteId = useRef(1); const [active, setActive] = useState(0); const [dismissed, setDismissed] = useState(false); const [dragOver, setDragOver] = useState(false); const taRef = useRef(null); + const wasRunning = useRef(running); + + useEffect(() => { + if (wasRunning.current && !running && text.trim() === "") { + pastedBlocksRef.current = []; + } + wasRunning.current = running; + }, [running, text]); // --- slash commands (whole-input "/token") --- const [commands, setCommands] = useState([]); @@ -166,11 +197,23 @@ export function Composer({ }); }; + const expandPastedBlocks = (displayText: string): string => { + let expanded = displayText; + for (const block of pastedBlocksRef.current) { + if (expanded.includes(block.label)) { + expanded = expanded.split(block.label).join(renderPastedBlock(block)); + } + } + return expanded; + }; + const submit = () => { const t = text.trim(); if ((!t && attachments.length === 0) || pendingPaste > 0) return; const refs = attachments.map((a) => `@${a.path}`).join(" "); - onSend([t, refs].filter(Boolean).join(t && refs ? " " : "")); + const displayText = [t, refs].filter(Boolean).join(t && refs ? " " : ""); + const submitText = [expandPastedBlocks(t), refs].filter(Boolean).join(t && refs ? " " : ""); + onSend(displayText, submitText); setText(""); setAttachments([]); }; @@ -200,9 +243,34 @@ export function Composer({ const onPaste = (e: ClipboardEvent) => { const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/")); - if (files.length === 0) return; + if (files.length > 0) { + e.preventDefault(); + void attachImageFiles(files); + return; + } + + const pasted = e.clipboardData.getData("text"); + if (!shouldFoldPaste(pasted)) return; + e.preventDefault(); - void attachImageFiles(files); + const ta = e.currentTarget; + const start = ta.selectionStart ?? text.length; + const end = ta.selectionEnd ?? text.length; + const id = nextPasteId.current++; + const lines = lineCount(pasted); + const label = `[Pasted text #${id} · ${lines} lines]`; + const block: PastedBlock = { label, text: pasted }; + const next = text.slice(0, start) + label + text.slice(end); + + pastedBlocksRef.current = [...pastedBlocksRef.current, block]; + setText(next); + requestAnimationFrame(() => { + const node = taRef.current; + if (!node) return; + const pos = start + label.length; + node.focus(); + node.selectionStart = node.selectionEnd = pos; + }); }; const onDrop = (e: DragEvent) => { diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 39828d5027..85828eb534 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -30,6 +30,7 @@ import type { // (or regenerate with `wails generate module` and import wailsjs instead). export interface AppBindings { Submit(input: string): Promise; + SubmitDisplay(display: string, input: string): Promise; Cancel(): Promise; Approve(id: string, allow: boolean, session: boolean): Promise; AnswerQuestion(id: string, answers: QuestionAnswer[]): Promise; @@ -274,6 +275,9 @@ function makeMockApp(): AppBindings { }); emit({ kind: "turn_done" }); }, + async SubmitDisplay(_display, input) { + await this.Submit(input); + }, async Cancel() { cancelled = true; emit({ kind: "turn_done" }); diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 746165c0f2..34ad8b9681 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -449,9 +449,12 @@ export function useController() { return off; }, []); - const send = useCallback((text: string) => { - dispatch({ type: "user", text }); - app.Submit(text).catch(() => {}); + const send = useCallback((displayText: string, submitText = displayText) => { + dispatch({ type: "user", text: displayText }); + const display = displayText.trim(); + const submit = submitText.trim(); + const call = display !== submit ? app.SubmitDisplay(display, submit) : app.Submit(submit); + call.catch(() => {}); }, []); // cancel aborts the in-flight turn. If the server hasn't replied yet (the user diff --git a/desktop/sessions.go b/desktop/sessions.go index ecb93ede7f..4da0afb7b5 100644 --- a/desktop/sessions.go +++ b/desktop/sessions.go @@ -1,8 +1,10 @@ package main import ( + "crypto/sha256" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -19,8 +21,10 @@ var errActiveSession = errors.New("can't delete the session you're in — start // it. Deleting a session also drops its title entry. const sessionTitlesFile = ".titles.json" +const sessionDisplayFile = ".display.json" -func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) } +func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) } +func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) } // loadSessionTitles reads the basename→title map (missing/corrupt → empty). func loadSessionTitles(dir string) map[string]string { @@ -79,7 +83,88 @@ func deleteSessionFile(dir, sessionPath string) error { m := loadSessionTitles(dir) if _, ok := m[filepath.Base(sessionPath)]; ok { delete(m, filepath.Base(sessionPath)) - return saveSessionTitles(dir, m) + if err := saveSessionTitles(dir, m); err != nil { + return err + } + } + if dm := loadSessionDisplays(dir); dm[filepath.Base(sessionPath)] != nil { + delete(dm, filepath.Base(sessionPath)) + if err := saveSessionDisplays(dir, dm); err != nil { + return err + } } return nil } + +type sessionDisplayMap map[string]map[string]string + +func messageDisplayKey(content string) string { + sum := sha256.Sum256([]byte(content)) + return fmt.Sprintf("%x", sum[:]) +} + +func loadSessionDisplays(dir string) sessionDisplayMap { + m := sessionDisplayMap{} + b, err := os.ReadFile(sessionDisplayPath(dir)) + if err != nil { + return m + } + _ = json.Unmarshal(b, &m) + return m +} + +func saveSessionDisplays(dir string, m sessionDisplayMap) error { + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".display.*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := tmp.Write(b); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + return os.Rename(tmpPath, sessionDisplayPath(dir)) +} + +func recordSessionDisplay(dir, sessionPath, content, display string) error { + if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" { + return nil + } + m := loadSessionDisplays(dir) + key := filepath.Base(sessionPath) + if m[key] == nil { + m[key] = map[string]string{} + } + m[key][messageDisplayKey(content)] = display + return saveSessionDisplays(dir, m) +} + +// sessionDisplayResolver loads the sidecar once and returns a per-message +// resolver, so a transcript of N messages doesn't re-read .display.json N times. +func sessionDisplayResolver(dir, sessionPath string) func(content string) string { + byHash := loadSessionDisplays(dir)[filepath.Base(sessionPath)] + return func(content string) string { + if byHash != nil { + if display := byHash[messageDisplayKey(content)]; strings.TrimSpace(display) != "" { + return display + } + } + return content + } +} + +func resolveSessionDisplay(dir, sessionPath, content string) string { + return sessionDisplayResolver(dir, sessionPath)(content) +} diff --git a/desktop/sessions_test.go b/desktop/sessions_test.go index c7a8f21e27..a946896b5a 100644 --- a/desktop/sessions_test.go +++ b/desktop/sessions_test.go @@ -117,6 +117,9 @@ func TestDeleteSessionFile(t *testing.T) { // Set a title first. setSessionTitle(dir, sessionPath, "My Title") + if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil { + t.Fatalf("record display: %v", err) + } if err := deleteSessionFile(dir, sessionPath); err != nil { t.Fatalf("delete: %v", err) @@ -130,6 +133,9 @@ func TestDeleteSessionFile(t *testing.T) { if _, ok := m["session.jsonl"]; ok { t.Error("title should be removed after delete") } + if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" { + t.Errorf("display sidecar should be removed after delete, got %q", got) + } } func TestDeleteSessionFileNoTitle(t *testing.T) { @@ -170,3 +176,30 @@ func TestErrActiveSession(t *testing.T) { t.Error("errActiveSession should have a message") } } + +func TestSessionDisplayRoundTrip(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "s.jsonl") + content := "prefix\n--- Begin [Pasted text #1 · 5 lines] ---\nfull text\n--- End [Pasted text #1 · 5 lines] ---" + display := "[Pasted text #1 · 5 lines]" + if err := recordSessionDisplay(dir, sessionPath, content, display); err != nil { + t.Fatalf("record display: %v", err) + } + if got := resolveSessionDisplay(dir, sessionPath, content); got != display { + t.Fatalf("display = %q, want %q", got, display) + } + if got := resolveSessionDisplay(dir, sessionPath, "other"); got != "other" { + t.Fatalf("unknown content should pass through, got %q", got) + } +} + +func TestRecordSessionDisplaySkipsNoop(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "s.jsonl") + if err := recordSessionDisplay(dir, sessionPath, "same", "same"); err != nil { + t.Fatalf("record display: %v", err) + } + if _, err := os.Stat(sessionDisplayPath(dir)); !os.IsNotExist(err) { + t.Fatalf("noop display should not create sidecar, stat err = %v", err) + } +} diff --git a/docs/paste-folding-effect.svg b/docs/paste-folding-effect.svg new file mode 100644 index 0000000000..8b49b03292 --- /dev/null +++ b/docs/paste-folding-effect.svg @@ -0,0 +1,30 @@ + + + Reasonix pasted text folding + Long JSON stays compact while editing, then expands for the model and transcript. + + + + + Before + After + + + { + "avatar": "https://cdn.example/... + "current_star_master_list": [ + ... 26 lines ... + } + Composer grows, scrolls to the last line, + and the prompt becomes hard to edit. + + + › [Pasted text #1 · 26 lines] 这是什么? + Input stays one compact line. + Enter sends the full JSON to the model. + The CLI transcript renders the expanded + user message, matching Claude Code. + + + Desktop keeps display/submit text in sync with a session sidecar; CLI folds bracketed paste before textarea handles it. + diff --git a/internal/cli/chat_render_test.go b/internal/cli/chat_render_test.go index eb0c94aa0d..bd685a9ae0 100644 --- a/internal/cli/chat_render_test.go +++ b/internal/cli/chat_render_test.go @@ -17,6 +17,7 @@ func newTestChatTUI() chatTUI { ti.SetWidth(80) return chatTUI{ input: ti, + nextPasteID: 1, reasoning: &strings.Builder{}, pending: &strings.Builder{}, pendingCommit: &commit, diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 87b5fab628..da4a70b7eb 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -58,6 +58,9 @@ type chatTUI struct { input textarea.Model spinner spinner.Model + pastedBlocks []pastedBlock + nextPasteID int + state tuiState runStart time.Time elapsed int @@ -117,9 +120,11 @@ type chatTUI struct { // reaches scrollback. bubblePending is true from startTurn until the bubble // commits or is un-sent; turnDiscarded then swallows the turn's already-buffered // events until its TurnDone settles. - pendingBubble string - bubblePending bool - turnDiscarded bool + pendingBubble string + pendingRestore string + pendingPastes []string + bubblePending bool + turnDiscarded bool // attachments are image refs queued for the next user turn. They render as a // tray above the input and are appended to the provider-facing prompt as // @-references only when the turn is sent. @@ -293,6 +298,7 @@ type promptResolvedMsg struct { type refsResolvedMsg struct { sent string display string + restore string attachments []chatAttachment block string errs []string @@ -342,6 +348,7 @@ func newChatTUI(ctrl *control.Controller, missing string, eventCh chan event.Eve missing: missing, input: ti, spinner: sp, + nextPasteID: 1, reasoning: &strings.Builder{}, pending: &strings.Builder{}, pendingCommit: &commitBuf, @@ -504,6 +511,12 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state != tuiRunning && m.attachPastedImages(msg.Content) { return m, finalize(m, cmds) } + if !m.chooserTyping() && m.pendingApproval == nil && m.rewind == nil && m.shouldFoldPaste(msg.Content) { + m.insertFoldedPaste(msg.Content) + m.growInputToFit() + m.updateCompletion() + return m, finalize(m, cmds) + } case tea.KeyPressMsg: // Ctrl+C copies the active selection (terminal convention: copy when @@ -614,6 +627,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { } } else { m.input.Reset() + m.pastedBlocks = nil } } return m, nil @@ -668,6 +682,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.HasPrefix(line, "#") { m.input.Reset() m.input.SetHeight(1) + m.pastedBlocks = nil note := strings.TrimSpace(strings.TrimPrefix(line, "#")) if note == "" { m.notice("nothing to remember") @@ -684,13 +699,15 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.HasPrefix(line, "/") && len(m.attachments) == 0 { m.input.Reset() m.input.SetHeight(1) + m.pastedBlocks = nil cmds = append(cmds, m.runSlashCommand(line)) return m, finalize(m, cmds) } attachments := cloneAttachments(m.attachments) - sentLine := withAttachmentRefs(line, attachments) - displayLine := withAttachmentLabels(line, attachments) + sentText := m.expandPastedBlocks(line) + sentLine := withAttachmentRefs(sentText, attachments) + displayLine := withAttachmentLabels(sentText, attachments) m.input.Reset() m.input.SetHeight(1) m.attachments = nil @@ -699,11 +716,11 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { // loop by the controller; the turn starts when they resolve // (refsResolvedMsg). if m.ctrl.HasRefs(sentLine) { - cmds = append(cmds, m.resolveRefs(sentLine, displayLine, attachments)) + cmds = append(cmds, m.resolveRefs(sentLine, displayLine, line, attachments)) return m, finalize(m, cmds) } - cmds = append(cmds, m.startTurn(m.ctrl.Compose(sentLine), displayLine, attachments)) + cmds = append(cmds, m.startTurn(m.ctrl.Compose(sentLine), displayLine, line, attachments)) return m, finalize(m, cmds) } @@ -768,7 +785,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { case strings.TrimSpace(msg.sent) == "": m.notice(i18n.M.SlashPromptEmpty) default: - cmds = append(cmds, m.startTurn(m.ctrl.Compose(msg.sent), msg.display, nil)) + cmds = append(cmds, m.startTurn(m.ctrl.Compose(msg.sent), msg.display, msg.display, nil)) } case refsResolvedMsg: @@ -779,7 +796,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.block != "" { sent = "Referenced context:\n\n" + msg.block + "\n\n" + msg.sent } - cmds = append(cmds, m.startTurn(m.ctrl.Compose(sent), msg.display, msg.attachments)) + cmds = append(cmds, m.startTurn(m.ctrl.Compose(sent), msg.display, msg.restore, msg.attachments)) case clipboardImageMsg: if msg.err != nil { @@ -796,7 +813,11 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.attachments = append(m.attachments, chatAttachment{Path: msg.path}) case msg.text != "": if !m.attachPastedImages(msg.text) { - m.input.InsertString(msg.text) + if m.shouldFoldPaste(msg.text) { + m.insertFoldedPaste(msg.text) + } else { + m.input.InsertString(msg.text) + } m.growInputToFit() m.updateCompletion() return m, finalize(m, cmds) @@ -1366,6 +1387,85 @@ func clampStatusLine(s string, width int) string { // growInputToFit resizes the textarea to the number of lines its value spans, // capped at maxInputRows so a long paste doesn't crowd the screen. const maxInputRows = 5 +const foldedPasteMinChars = 1000 +const foldedPasteMinLines = 5 + +type pastedBlock struct { + label string + text string +} + +func pastedLineCount(s string) int { + if s == "" { + return 0 + } + return strings.Count(strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n"), "\n") + 1 +} + +func foldedPasteLabel(id, lines int) string { + return fmt.Sprintf("[Pasted text #%d · %d lines]", id, lines) +} + +func renderFoldedPasteBlock(block pastedBlock) string { + return fmt.Sprintf("%s\n\n--- Begin %s ---\n%s\n--- End %s ---", block.label, block.label, block.text, block.label) +} + +func shouldFoldPastedText(s string) bool { + return len([]rune(s)) >= foldedPasteMinChars || pastedLineCount(s) >= foldedPasteMinLines +} + +func (m *chatTUI) chooserTyping() bool { + return m.chooser != nil && m.chooser.typing +} + +func (m *chatTUI) shouldFoldPaste(s string) bool { + return shouldFoldPastedText(s) +} + +func (m *chatTUI) insertFoldedPaste(s string) { + label := foldedPasteLabel(m.nextPasteID, pastedLineCount(s)) + m.nextPasteID++ + m.pastedBlocks = append(m.pastedBlocks, pastedBlock{label: label, text: s}) + m.input.InsertString(label + " ") +} + +func (m *chatTUI) expandPastedBlocks(displayed string) string { + sent := displayed + for _, block := range m.pastedBlocks { + if strings.Contains(sent, block.label) { + sent = strings.ReplaceAll(sent, block.label, renderFoldedPasteBlock(block)) + } + } + return sent +} + +func (m *chatTUI) pasteLabelsIn(s string) []string { + var labels []string + for _, block := range m.pastedBlocks { + if strings.Contains(s, block.label) { + labels = append(labels, block.label) + } + } + return labels +} + +func (m *chatTUI) clearSubmittedPastes() { + if len(m.pendingPastes) == 0 { + return + } + submitted := make(map[string]bool, len(m.pendingPastes)) + for _, label := range m.pendingPastes { + submitted[label] = true + } + kept := m.pastedBlocks[:0] + for _, block := range m.pastedBlocks { + if !submitted[block.label] { + kept = append(kept, block) + } + } + m.pastedBlocks = kept + m.pendingPastes = nil +} func (m *chatTUI) growInputToFit() { lines := strings.Count(m.input.Value(), "\n") + 1 @@ -1601,8 +1701,9 @@ func (m *chatTUI) cycleMode() { // startTurn commits the user bubble to scrollback, resets the turn accumulator, // and kicks off runner.Run. `sent` goes to the model (may carry a plan-mode -// marker); `displayed` is what the transcript shows. -func (m *chatTUI) startTurn(sent, displayed string, attachments []chatAttachment) tea.Cmd { +// marker), `displayed` is what the transcript shows, and `restore` is what Esc +// puts back into the input while the bubble is still deferred. +func (m *chatTUI) startTurn(sent, displayed, restore string, attachments []chatAttachment) tea.Cmd { // Flush any half-streamed leftover before the new turn (defensive). m.commitReasoning() m.commitPending() @@ -1611,6 +1712,8 @@ func (m *chatTUI) startTurn(sent, displayed string, attachments []chatAttachment // pressing Esc before the server replies un-sends the message, restoring its // text to the input box with nothing stranded in scrollback. m.pendingBubble = displayed + m.pendingRestore = restore + m.pendingPastes = m.pasteLabelsIn(restore) m.pendingAttachments = cloneAttachments(attachments) m.bubblePending = true m.turnDiscarded = false @@ -1637,6 +1740,7 @@ func (m *chatTUI) commitPendingBubble() { m.commitLine("") // blank line separating turns m.commitLine(renderUserBubble(m.pendingBubble, m.width, m.planMode)) m.pendingBubble = "" + m.pendingRestore = "" m.pendingAttachments = nil } @@ -1646,12 +1750,14 @@ func (m *chatTUI) commitPendingBubble() { // already-buffered events reach nothing. Once a packet has arrived the bubble is // committed and this path isn't taken (Esc cancels normally instead). func (m *chatTUI) unsendPending() { - m.input.SetValue(m.pendingBubble) + m.input.SetValue(m.pendingRestore) m.growInputToFit() m.attachments = cloneAttachments(m.pendingAttachments) m.pendingAttachments = nil m.bubblePending = false m.pendingBubble = "" + m.pendingRestore = "" + m.pendingPastes = nil m.turnDiscarded = true m.ctrl.Cancel() } @@ -1784,8 +1890,11 @@ func (m *chatTUI) ingestEvent(e event.Event) { m.bubblePending = false m.pendingBubble = "" m.pendingAttachments = nil + m.pendingRestore = "" + m.pendingPastes = nil } m.state = tuiIdle + m.clearSubmittedPastes() _ = m.ctrl.Snapshot() // best-effort; never the user's problem mid-chat if e.Err != nil && e.Err.Error() != "" && !strings.Contains(e.Err.Error(), "context canceled") { m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+e.Err.Error(), m.width, lipgloss.Color("3"))) @@ -1887,10 +1996,10 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd { default: // A custom command wins over a skill of the same name; both resolve to a turn. if sent, ok := m.ctrl.CustomCommand(input); ok { - return m.startTurn(m.ctrl.Compose(sent), input, nil) + return m.startTurn(m.ctrl.Compose(sent), input, input, nil) } if sent, ok := m.ctrl.RunSkill(input); ok { - return m.startTurn(m.ctrl.Compose(sent), input, nil) + return m.startTurn(m.ctrl.Compose(sent), input, input, nil) } m.notice(fmt.Sprintf("%s: %s", i18n.M.SlashUnknown, cmd)) } @@ -2006,10 +2115,10 @@ func (m *chatTUI) notice(note string) { // resolveRefs resolves a line's @references off the event loop via the // controller, delivering a refsResolvedMsg with the tagged context block. -func (m *chatTUI) resolveRefs(sent, display string, attachments []chatAttachment) tea.Cmd { +func (m *chatTUI) resolveRefs(sent, display, restore string, attachments []chatAttachment) tea.Cmd { return func() tea.Msg { block, errs := m.ctrl.ResolveRefs(context.Background(), sent) - return refsResolvedMsg{sent: sent, display: display, attachments: attachments, block: block, errs: errs} + return refsResolvedMsg{sent: sent, display: display, restore: restore, attachments: attachments, block: block, errs: errs} } } diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index 124fe07d09..afc5fd52e5 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -239,6 +239,61 @@ func TestTranscriptTailFollow(t *testing.T) { } } +func TestFoldedPasteUsesPlaceholderAndExpandsOnSend(t *testing.T) { + m := newTestChatTUI() + pasted := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}" + if !shouldFoldPastedText(pasted) { + t.Fatal("five-line paste should fold") + } + + m.insertFoldedPaste(pasted) + display := m.input.Value() + if display != "[Pasted text #1 · 6 lines] " { + t.Fatalf("display = %q", display) + } + + sent := m.expandPastedBlocks(display) + for _, want := range []string{ + "--- Begin [Pasted text #1 · 6 lines] ---", + `"d": 4`, + "--- End [Pasted text #1 · 6 lines] ---", + } { + if !strings.Contains(sent, want) { + t.Fatalf("expanded paste missing %q in:\n%s", want, sent) + } + } +} + +func TestPasteMsgFoldsBeforeTextareaConsumesNewlines(t *testing.T) { + m := newTestChatTUI() + model, _ := m.Update(tea.PasteMsg{Content: "1\n2\n3\n4\n5"}) + got := model.(chatTUI) + if got.input.Value() != "[Pasted text #1 · 5 lines] " { + t.Fatalf("input = %q", got.input.Value()) + } + if got.input.Height() != 1 { + t.Fatalf("folded paste should keep one input row, got %d", got.input.Height()) + } +} + +func TestUnsendRestoresFoldedPastePlaceholder(t *testing.T) { + m := newTestChatTUI() + m.ctrl = control.New(control.Options{}) + m.pendingBubble = "expanded JSON" + m.pendingRestore = "[Pasted text #1 · 5 lines] 这是什么?" + m.bubblePending = true + m.state = tuiRunning + + m.unsendPending() + + if got := m.input.Value(); got != "[Pasted text #1 · 5 lines] 这是什么?" { + t.Fatalf("restored input = %q", got) + } + if m.pendingBubble != "" || m.pendingRestore != "" || m.bubblePending { + t.Fatalf("pending state not cleared: bubble=%q restore=%q pending=%v", m.pendingBubble, m.pendingRestore, m.bubblePending) + } +} + func TestApprovalToolDetailsShortensMCPNames(t *testing.T) { name, detail := approvalToolDetails("mcp__minimax-coding-plan-mcp__understand_image") if name != "understand_image" {