feat(composer): fold long pasted text in CLI and desktop (#2517)

Long pastes (>=1000 chars or >=5 lines) collapse to a [Pasted text #N · L lines] placeholder while editing, keeping the composer compact. The full text is expanded back at send time so the model receives it intact; Esc before the reply restores the compact placeholder. The CLI bubble renders the expanded text (Claude Code parity) while the desktop transcript stays compact via a sha256-keyed .display.json sidecar, loaded once per History call.
This commit is contained in:
Sivan authored and GitHub committed 2026-06-01 01:50:34 -07:00
1 parent 5bc679065f
commit 2021a52452
11 files changed
+433 -30

No files matched your search

+16 -1
View File
@@ -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
}
+3 -3
View File
@@ -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],
);
+72 -4
View File
@@ -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<Attachment[]>([]);
const [pendingPaste, setPendingPaste] = useState(0);
const pastedBlocksRef = useRef<PastedBlock[]>([]);
const nextPasteId = useRef(1);
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const [dragOver, setDragOver] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(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<CommandInfo[]>([]);
@@ -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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
+4
View File
@@ -30,6 +30,7 @@ import type {
// (or regenerate with `wails generate module` and import wailsjs instead).
export interface AppBindings {
Submit(input: string): Promise<void>;
SubmitDisplay(display: string, input: string): Promise<void>;
Cancel(): Promise<void>;
Approve(id: string, allow: boolean, session: boolean): Promise<void>;
AnswerQuestion(id: string, answers: QuestionAnswer[]): Promise<void>;
@@ -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" });
+6 -3
View File
@@ -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
+87 -2
View File
@@ -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)
}
+33
View File
@@ -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)
}
}
+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675" role="img" aria-label="Paste folding effect in Reasonix CLI and desktop">
<rect width="1200" height="675" fill="#313242"/>
<text x="48" y="58" fill="#d8dcf2" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="28" font-weight="700">Reasonix pasted text folding</text>
<text x="48" y="94" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="18">Long JSON stays compact while editing, then expands for the model and transcript.</text>
<rect x="48" y="132" width="520" height="430" rx="10" fill="#242633" stroke="#5b6078"/>
<rect x="632" y="132" width="520" height="430" rx="10" fill="#242633" stroke="#5b6078"/>
<text x="78" y="177" fill="#ff9e64" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22" font-weight="700">Before</text>
<text x="662" y="177" fill="#9ece6a" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22" font-weight="700">After</text>
<rect x="78" y="210" width="460" height="250" rx="4" fill="#3a3d50"/>
<text x="104" y="250" fill="#c9d1ee" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22">{</text>
<text x="104" y="288" fill="#c9d1ee" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22"> "avatar": "https://cdn.example/...</text>
<text x="104" y="326" fill="#c9d1ee" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22"> "current_star_master_list": [</text>
<text x="104" y="364" fill="#c9d1ee" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22"> ... 26 lines ...</text>
<text x="104" y="402" fill="#c9d1ee" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="22">}</text>
<text x="92" y="510" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="19">Composer grows, scrolls to the last line,</text>
<text x="92" y="538" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="19">and the prompt becomes hard to edit.</text>
<rect x="662" y="210" width="460" height="76" rx="4" fill="#3a3d50"/>
<text x="690" y="257" fill="#d8dcf2" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="23">› [Pasted text #1 · 26 lines] 这是什么?</text>
<text x="676" y="336" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="19">Input stays one compact line.</text>
<text x="676" y="374" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="19">Enter sends the full JSON to the model.</text>
<text x="676" y="412" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="19">The CLI transcript renders the expanded</text>
<text x="676" y="440" fill="#aab0ce" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="19">user message, matching Claude Code.</text>
<rect x="48" y="596" width="1104" height="1" fill="#5b6078"/>
<text x="48" y="630" fill="#c9d1ee" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="18">Desktop keeps display/submit text in sync with a session sidecar; CLI folds bracketed paste before textarea handles it.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+1
View File
@@ -17,6 +17,7 @@ func newTestChatTUI() chatTUI {
ti.SetWidth(80)
return chatTUI{
input: ti,
nextPasteID: 1,
reasoning: &strings.Builder{},
pending: &strings.Builder{},
pendingCommit: &commit,
+126 -17
View File
@@ -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}
}
}
+55
View File
@@ -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" {