diff --git a/CHANGELOG.md b/CHANGELOG.md index 554aa8a4..ec4241ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- In the native window, "Unfold long pastes" now unfolds Claude Code and Kiro CLI pastes: the clipboard, a batch of review comments, and the prompt a pull request or issue opens a session with. +- In the native window, "Unfold long pastes" now unfolds Claude Code and Kiro CLI pastes: the clipboard, a batch of review comments, the prompt a pull request or issue opens a session with, and dropped or attached file paths. ## [0.71.1] - 2026-10-18 diff --git a/docs/ceilings.md b/docs/ceilings.md index d303d13b..01ccf2a3 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -67,9 +67,11 @@ work when nobody knows it and that the call site never shows. The mechanism and replacing it; opencode's own "Disable paste summary" command is stored in its state and wins over any config, so once used there the switch changes nothing. Codex, Antigravity, Crush and Cursor CLI fold with a constant, no setting and no key that unfolds, so they have no switch; oh-my-pi was not measured. The native window also - unfolds what it writes as a paste itself (a batch of review comments, a pull request's or an issue's prompt), - measured against the session's own terminal; one written into a session whose terminal the window never opened - has no height to measure and lands folded. Dropped and attached file paths are never unfolded. + unfolds what it writes as a paste itself (a batch of review comments, a pull request's or an issue's prompt, + dropped and attached file paths), measured against the session's own terminal; one written into a session whose + terminal the window never opened has no height to measure and lands folded. No caller does that today: each + writes into the session on screen, or waits for a session it just opened to reach its prompt, which only + happens after the window started its terminal. - **`LICH_WORKTREE_PORT` is reserved, never held** (`internal/terminal/worktreeport.go`): the number is a name the checkout owns, nothing binds it, and anything on the machine can take the port before the dev server starts. - **A folder is a name on a session, so it has no life of its own** (`internal/store/mutations.go`, diff --git a/native/cmd/lich-native/footer.go b/native/cmd/lich-native/footer.go index 56a442ac..e05a1de3 100644 --- a/native/cmd/lich-native/footer.go +++ b/native/cmd/lich-native/footer.go @@ -40,6 +40,8 @@ type footer struct { quota *planQuota handsOn *handsOn cost *costGate + // writePaste is terminals.writePaste, which unfolds a long paste. + writePaste func(ctx context.Context, id, block string) error mu sync.Mutex layout footerLayout @@ -230,7 +232,7 @@ func (f *footer) act(gtx C, in footerInput) { // A failure is toasted in the backend's own words, which name the ceiling a // file was refused for. func (f *footer) attach(in footerInput) { - if err := attachFile(f.ctx, f.client, in.sessionID, in.checkout, in.sandboxed, runtime.GOOS == "windows"); err != nil { + if err := attachFile(f.ctx, f.client, f.writePaste, in.sessionID, in.checkout, in.sandboxed, runtime.GOOS == "windows"); err != nil { f.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: lichclient.ErrorText(err)}) } } diff --git a/native/cmd/lich-native/footer_attach.go b/native/cmd/lich-native/footer_attach.go index 00b7fb32..58ec6033 100644 --- a/native/cmd/lich-native/footer_attach.go +++ b/native/cmd/lich-native/footer_attach.go @@ -13,13 +13,14 @@ import ( // its checkout, so the call that chooses the file also copies it where the // session can read it. checkout, not the shell's cwd, because the sandbox is // built around the spawn directory. A cancelled dialog is no error; a failure -// carries the backend's sentence, which names why a file was refused. -func attachFile(ctx context.Context, c *lichclient.Client, sessionID, checkout string, sandboxed, windows bool) error { +// carries the backend's sentence, which names why a file was refused. write +// puts the paste at the prompt. +func attachFile(ctx context.Context, c *lichclient.Client, write func(ctx context.Context, id, block string) error, sessionID, checkout string, sandboxed, windows bool) error { a, err := c.Attach(ctx, sessionID, checkout, sandboxed) if err != nil || a.Path == "" { return err } - return c.Write(ctx, sessionID, composeDroppedPaths([]string{a.Path}, windows, []string{a.Notice})) + return write(ctx, sessionID, composeDroppedPaths([]string{a.Path}, windows, []string{a.Notice})) } // composeDroppedPaths is drop-files.ts: the paths quoted and space-separated diff --git a/native/cmd/lich-native/footer_readout_test.go b/native/cmd/lich-native/footer_readout_test.go index 249d63ba..17bc6a1a 100644 --- a/native/cmd/lich-native/footer_readout_test.go +++ b/native/cmd/lich-native/footer_readout_test.go @@ -351,7 +351,7 @@ func attachBackend(t *testing.T, picked lichclient.Attachment, writes *[][]strin func TestAttachFilePastesThePickedPathUnsent(t *testing.T) { var writes [][]string c := attachBackend(t, lichclient.Attachment{Path: "/cfg/dropped/b.png", Notice: "copy of b"}, &writes) - if err := attachFile(context.Background(), c, "s1", "/repo", true, false); err != nil { + if err := attachFile(context.Background(), c, c.Write, "s1", "/repo", true, false); err != nil { t.Fatal(err) } want := [][]string{{"s1", "\x1b[200~/cfg/dropped/b.png\ncopy of b\n\x1b[201~"}} @@ -363,7 +363,7 @@ func TestAttachFilePastesThePickedPathUnsent(t *testing.T) { func TestAttachFileCancelledWritesNothing(t *testing.T) { var writes [][]string c := attachBackend(t, lichclient.Attachment{}, &writes) - if err := attachFile(context.Background(), c, "s1", "/repo", true, false); err != nil || len(writes) != 0 { + if err := attachFile(context.Background(), c, c.Write, "s1", "/repo", true, false); err != nil || len(writes) != 0 { t.Errorf("cancelled dialog: err %v, writes %q", err, writes) } } diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 5a050d74..04249877 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -264,6 +264,7 @@ func run(w *app.Window, rt lichclient.Runtime) error { u.dock.tab = map[string]dockTab{"code": dockCode, "review": dockReview}[*flagTab] u.footer.dock = u.dock u.footer.openPullRequest = u.showPulls + u.footer.writePaste = u.terms.writePaste u.footer.showDelegates = func(id string) { u.layoutStage.delegates = id } u.settings.footer = u.footer u.footer.customize = u.customizeFooter diff --git a/native/cmd/lich-native/paste_unfold_test.go b/native/cmd/lich-native/paste_unfold_test.go index 3afeabef..be449fb0 100644 --- a/native/cmd/lich-native/paste_unfold_test.go +++ b/native/cmd/lich-native/paste_unfold_test.go @@ -2,6 +2,9 @@ package main import ( "encoding/json" + "os" + "path/filepath" + "strconv" "strings" "testing" "time" @@ -145,3 +148,25 @@ func TestBatchOfCommentsStaysFoldedWithTheSwitchOff(t *testing.T) { t.Errorf("wrote %q, want the batch once", got) } } + +// Dropping enough files writes a paste Claude Code would fold, and it lands +// unfolded the same as any other. +func TestDropOfManyFilesUnfoldsInClaudeCode(t *testing.T) { + u, b := newGateWindow(t) + b.answer["store.GetSetting"] = "true" + u.terms = claudeTerminals(t, b) + dir := t.TempDir() + var paths []string + for i := range 12 { + path := filepath.Join(dir, strings.Repeat("a-file-with-a-long-name-", 3)+strconv.Itoa(i)) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + paths = append(paths, path) + } + u.dropFiles(dropTarget{sessionID: "s", checkout: dir}, paths) + paste := composeDroppedPaths(paths, false, nil) + if got := injectedWrite(t, b); got != paste+paste { + t.Errorf("wrote %q, want the drop twice", got) + } +} diff --git a/native/cmd/lich-native/terminal_drop_hint.go b/native/cmd/lich-native/terminal_drop_hint.go index 9dc2368e..eedcc5fc 100644 --- a/native/cmd/lich-native/terminal_drop_hint.go +++ b/native/cmd/lich-native/terminal_drop_hint.go @@ -97,5 +97,5 @@ func (u *window) dropFiles(target dropTarget, paths []string) { if paste == "" { return } - u.logErr("drop files", u.client.Write(u.ctx, target.sessionID, paste)) + u.logErr("drop files", u.terms.writePaste(u.ctx, target.sessionID, paste)) }