diff --git a/cli/internal/tui/rowactions.go b/cli/internal/tui/rowactions.go index b9538c4d..6f9af14e 100644 --- a/cli/internal/tui/rowactions.go +++ b/cli/internal/tui/rowactions.go @@ -18,9 +18,11 @@ const ( verbCopy = "copy" // verbOpen lists or hides the steps of a burst. verbOpen = "open" + // verbMore shows or folds the rest of a long message of yours. + verbMore = "more" ) -var actionMark = regexp.MustCompile(`\x1b_albedo:(copy|open):([0-9a-f]+-[0-9a-f]+)\x1b\\`) +var actionMark = regexp.MustCompile(`\x1b_albedo:(copy|open|more):([0-9a-f]+-[0-9a-f]+)\x1b\\`) type rowAction struct{ verb, key string } @@ -55,10 +57,45 @@ func (m *ChatModel) actAt(row int) tea.Cmd { return m.copyReply(act.key) case verbOpen: m.toggleBurst(act.key, row) + case verbMore: + m.toggleFold(act.key, row) } return nil } +// toggleFold shows or folds the rest of the message whose fold row is at row, +// the last row of its block. Only that block renders again and is spliced into +// the settled rows. The clicked row keeps its place on screen: expanding opens +// the text below it, and folding brings the new fold row to where it was. +func (m *ChatModel) toggleFold(key string, row int) { + entries := m.History.Entries() + at := slices.IndexFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryUser && entryKey(e) == key }) + if at < 0 { + return + } + was := m.Renderer + was.Open = map[string]bool{key: m.Renderer.Open[key]} + old, _ := was.Block(entries[:at], entries[at], m.Flags) + if m.Renderer.Open == nil { + m.Renderer.Open = map[string]bool{} + } + m.Renderer.Open[key] = !m.Renderer.Open[key] + fresh, _ := m.Renderer.Block(entries[:at], entries[at], m.Flags) + from := row - m.settledOffset - (len(old) - 1) + if from < 0 || from+len(old) > len(m.settledLines) || m.settledLines[from+len(old)-1] != old[len(old)-1] { + m.rebuildSettledLines() + m.refreshViewportContent() + return + } + screen := row - m.scrollOffset + if !m.Renderer.Open[key] { + row += len(fresh) - len(old) + } + row -= m.replaceSettled(from, len(old), fresh) + m.Follow, m.scrollOffset = false, max(0, row-screen) + m.Follow = m.scrollOffset >= m.refreshViewportContent() +} + // toggleBurst opens or closes the burst whose summary is at row, keeping that // row where it was on screen; an opened list that would end below the screen // scrolls up into view. Only the burst renders again: a settled one is diff --git a/cli/internal/tui/rowactions_test.go b/cli/internal/tui/rowactions_test.go index a96f3f22..1cd661bc 100644 --- a/cli/internal/tui/rowactions_test.go +++ b/cli/internal/tui/rowactions_test.go @@ -119,3 +119,40 @@ func TestClickingABurstListsItsSteps(t *testing.T) { t.Fatalf("a second click does not close the burst:\n%s", text) } } + +// A long message of yours shows its first rows and a click target; clicking +// it shows the rest, and the row that closes it folds the message again. +func TestClickingALongMessageShowsTheRestAndFoldsIt(t *testing.T) { + m := newTestChatModel(t, &daemon.Session{ID: "s"}) + m.SetSize(100, 30) + var lines []string + for i := range 40 { + lines = append(lines, fmt.Sprintf("line %02d", i)) + } + m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: strings.Join(lines, "\n\n"), Timestamp: 1}) + m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "done", Timestamp: 2}) + m.refreshViewportContent() + shown := func() string { return ansi.Strip(strings.Join(m.frameLines, "\n")) } + if text := shown(); strings.Contains(text, "line 39") || !strings.Contains(text, "more lines · click to expand") { + t.Fatalf("a long message is not folded:\n%s", text) + } + spliced := func() { + t.Helper() + got := slices.Clone(m.settledLines) + if m.rebuildSettledLines(); !slices.Equal(got, m.settledLines) { + t.Fatal("the splice drew rows a rebuild does not") + } + m.refreshViewportContent() + } + click := rowWith(t, m.frameLines, "click to expand") + m.actAt(click) + spliced() + if text := shown(); !strings.Contains(text, "line 39") || !strings.Contains(text, "show less") { + t.Fatalf("clicking does not show the rest:\n%s", text) + } + m.actAt(rowWith(t, m.frameLines, "show less")) + spliced() + if text := shown(); strings.Contains(text, "line 39") || !strings.Contains(text, "click to expand") { + t.Fatalf("clicking again does not fold the message:\n%s", text) + } +} diff --git a/cli/internal/tui/transcript.go b/cli/internal/tui/transcript.go index 42df6c69..14c18bd5 100644 --- a/cli/internal/tui/transcript.go +++ b/cli/internal/tui/transcript.go @@ -520,7 +520,11 @@ func (r TranscriptRenderer) RenderAfter(before prior, entry HistoryEntry, flags if entry.Live { render = RenderMarkdownAnsi } - rows = append(rows, render(entry.Text, width)) + body := render(entry.Text, width) + if entry.Kind == EntryUser { + body = r.foldUser(entry, body) + } + rows = append(rows, body) case EntryThinking: rows = []string{markChrome + r.Styles.Faint.Render("thinking")} if flags.Thinking { @@ -577,6 +581,33 @@ func (r TranscriptRenderer) RenderAfter(before prior, entry HistoryEntry, flags return strings.Join(rows, "\n") } +// A message of yours taller than userFoldRows plus userFoldSlack shows its +// first userFoldRows rows and a click target for the rest; the slack keeps +// a fold from hiding only a line or two. +const ( + userFoldRows = 10 + userFoldSlack = 2 +) + +// foldUser is body, a settled message's rendered rows, cut to its first rows +// until the message's key is in Open. +func (r TranscriptRenderer) foldUser(entry HistoryEntry, body string) string { + rows := strings.Split(body, "\n") + if len(rows) <= userFoldRows+userFoldSlack { + return body + } + key := entryKey(entry) + if r.Open[key] { + return body + "\n" + r.foldRow(key, toggleOpen+"show less") + } + label := fmt.Sprintf("%s%d more lines · click to expand", toggleClosed, len(rows)-userFoldRows) + return strings.Join(rows[:userFoldRows], "\n") + "\n" + r.foldRow(key, label) +} + +func (r TranscriptRenderer) foldRow(key, label string) string { + return markChrome + rowAction{verbMore, key}.mark() + r.Styles.Faint.Render(label) +} + // compactionWords names what a strategy did with the evicted items and what // ctrl+k shows: snapcompact archives them as rendered frames, lcm folds them // into summary nodes, and rolling (or an unnamed strategy) summarizes them.