mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
5bc679065f
commit
2021a52452
11 files changed
+433
-30
No files matched your search
+16
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in new issue
Block a user