Show tool calls as foldable cards, add /cells, scroll while selecting
/v draws each call as a colored card that folds on click. /cells lists every call with the selected one in full. Dragging a selection past an edge scrolls it and copies the rows it crossed. The turn signoff offers copy reply, which copies the turn as markdown.
30 unmodified lines31323334353637383991 unmodified lines131132133134135136137138139402 unmodified linesExpand all542543544545546547548549550551552553554555160 unmodified linesExpand all716717718719720721722723724263 unmodified linesExpand all98898999099198399299399499599610 unmodified lines10071008100910101011101210131014101510161017139 unmodified linesExpand all1157115811591160116111621163116411651166116736 unmodified lines12041205120612071208120912101211121212131214121559 unmodified lines30 unmodified lines AppStateCapabilityPage AppStateWebhooksPage AppStateAgents AppStateFolderPicker AppStateCells)
type sessionsLoadedMsg struct { Sessions []daemon.Session91 unmodified lines ModelPicker ModelPickerModel ExtensionPicker ExtensionPickerModel TreePicker TreePickerModel ContextInspector ContextInspectorModel Cells CellsModel PageView PageViewModel Login LoginModel CapabilityPage CapabilityPageModel WebhooksPage WebhooksPageModel402 unmodified linesExpand all m.TreePicker = NewTreePickerModel(m.Conn, m.ActiveSession.ID) return m.openScreen(AppStateTreePicker, &m.TreePicker)}
func (m *AppModel) openCells() tea.Cmd { m.Cells = NewCellsModel() m.Cells.Sync(m.Chat.History.Entries()) return m.openScreen(AppStateCells, &m.Cells)}
func (m *AppModel) openContextInspector() tea.Cmd { if m.ActiveSession == nil { return nil }160 unmodified linesExpand all m.ModelPicker.SetSize(msg.Width, msg.Height) m.ExtensionPicker.SetSize(msg.Width, msg.Height) m.TreePicker.SetSize(msg.Width, msg.Height) m.ContextInspector.SetSize(msg.Width, msg.Height) m.Cells.SetSize(msg.Width, msg.Height) m.PageView.SetSize(msg.Width, msg.Height) m.CapabilityPage.SetSize(msg.Width, msg.Height) m.WebhooksPage.SetSize(msg.Width, msg.Height) m.Agents.SetSize(msg.Width, msg.Height)263 unmodified linesExpand all } return m, m.openSession(session)
case ModelPickerCancelMsg, WebhooksPageDoneMsg, ExtensionPickerDoneMsg, TreeCancelMsg, ContextDoneMsg, CapabilityPageDoneMsg, PageCancelMsg: TreeCancelMsg, ContextDoneMsg, CellsDoneMsg, CapabilityPageDoneMsg, PageCancelMsg: m.State = AppStateChat return m, nil
case ExtensionPickerChangedMsg:10 unmodified lines
case TreeForkSuccessMsg: return m, m.setChatSession(msg.Session, true)
case ChatOpenCellsMsg: return m, m.openCells()
case ChatOpenContextInspectorMsg: return m, m.openContextInspector()
case ChatOpenCapabilityPageMsg:139 unmodified linesExpand all case AppStateExtensionPicker: m.ExtensionPicker, cmd = m.ExtensionPicker.Update(msg) case AppStateTreePicker: m.TreePicker, cmd = m.TreePicker.Update(msg) case AppStateCells: m.Cells.Sync(m.Chat.History.Entries()) m.Cells, cmd = m.Cells.Update(msg) case AppStateContextInspector: m.ContextInspector, cmd = m.ContextInspector.Update(msg) case AppStatePageView: m.PageView, cmd = m.PageView.Update(msg)36 unmodified lines case AppStateExtensionPicker: return m.ExtensionPicker.View() case AppStateTreePicker: return m.TreePicker.View() case AppStateCells: cells := m.Cells cells.Sync(m.Chat.History.Entries()) return cells.View() case AppStateContextInspector: return m.ContextInspector.View() case AppStatePageView: return m.PageView.View()59 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162package tui
import ( "encoding/json" "fmt" "sort" "strings" "unicode"
"github.com/charmbracelet/x/ansi")
// A tool card is one call in verbose mode: a header to read at a glance, the// input on a tinted panel, the output beneath it. A long card shows its start// and its end; a click on its header or on the fold row opens the rest.const ( cardInputRows = 6 cardOutHead = 4 cardOutTail = 2 // cardHang indents the rows an input line wraps onto. cardHang = 2)
// cardInput is what a call was given, and the language to tint it in. Calls// whose header already names their input have none.func cardInput(entry HistoryEntry) (text, lang string) { arg := func(key string) string { s, _ := entry.ToolArgs[key].(string); return s } switch entry.ToolName { case "python": return arg("code"), "python" case "shell", "bash": return arg("command"), "bash" case "read_file", "write_file", "edit_file": return "", "" } keys := make([]string, 0, len(entry.ToolArgs)) for key := range entry.ToolArgs { keys = append(keys, key) } sort.Strings(keys) lines := make([]string, len(keys)) for i, key := range keys { value, ok := entry.ToolArgs[key].(string) if !ok { encoded, _ := json.Marshal(entry.ToolArgs[key]) value = string(encoded) } lines[i] = key + ": " + value } return strings.Join(lines, "\n"), ""}
func cardName(entry HistoryEntry) string { switch entry.ToolName { case "python": return "python" case "shell", "bash": return "shell" case "read_file", "write_file", "edit_file": return oneLine(toolSummary(entry)) } return entry.ToolName}
// plainLines is text as rows of printable cells: tool output may carry// colors, tabs, and carriage returns that would wreck the layout around it.func plainLines(text string) []string { text = strings.NewReplacer("\r\n", "\n", "\r", "\n", "\t", " ").Replace(ansi.Strip(text)) text = strings.Map(func(r rune) rune { if unicode.IsControl(r) && r != '\n' { return ' ' } return r }, text) return strings.Split(text, "\n")}
// panelRows tints text as code, every row out to width. A line too long for// a row wraps onto an indented one, at a space or a bracket where it can; a// copy joins them back.func panelRows(text, lang string, width int) []string { panel := diffPanel() var rows []string for _, line := range plainLines(text) { body := keepBackground(HighlightCode(line, lang)) for i, part := range strings.Split(ansi.Wrap(body, max(1, width-cardHang), " ,;)"), "\n") { cell := pick(i > 0, markSplit+strings.Repeat(" ", cardHang), "") + part rows = append(rows, panel+cell+strings.Repeat(" ", max(0, width-ansi.StringWidth(cell)))+ansiReset) } } return rows}
// outputRows is what a call printed, wrapped to width with the wraps marked.func outputRows(text string, width int) []string { var rows []string for _, line := range plainLines(text) { rows = append(rows, markChunks(line, wrapOrChunkLine(line, width))...) } return rows}
// clipRows keeps the first head and last tail of rows and counts the rest;// a fold that hides only a few rows would cost as much as it saves, so a// card just over its limit stays whole.func clipRows(rows []string, head, tail int) (top, bottom []string, hidden int) { if len(rows) <= head+tail+3 { return rows, nil, 0 } return rows[:head], rows[len(rows)-tail:], len(rows) - head - tail}
// toolCard draws one call in verbose mode.func (r TranscriptRenderer) toolCard(entry HistoryEntry, flags DisplayFlags, failed bool, width int) []string { key := entryKey(entry) open := r.Expanded[key] foldable := false var body []string bar := r.Styles.Decor.Render("▎") + " " section := func(rows []string, head, tail int) { top, bottom, hidden := clipRows(rows, head, tail) foldable = foldable || hidden > 0 if open || hidden == 0 { body = append(body, rows...) return } fold := fmt.Sprintf("⋯ %d more · click to expand", hidden) body = append(body, top...) body = append(body, markChrome+rowAction{verbFold, key}.mark()+r.Styles.Faint.Render(ansi.Truncate(fold, width, "…"))) body = append(body, bottom...) } if text, lang := cardInput(entry); strings.TrimSpace(text) != "" { section(panelRows(text, lang, width), cardInputRows, 0) } if output := toolOutput(entry); output != "" { ink := pick(failed, r.Styles.Error, r.Styles.Muted) rows := outputRows(output, width-2) for i, row := range rows { rows[i] = bar + ink.Render(row) } section(rows, cardOutHead, cardOutTail) } else { body = append(body, markChrome+r.Styles.Faint.Render("(no output)")) } if open && foldable { body = append(body, markChrome+rowAction{verbFold, key}.mark()+r.Styles.Faint.Render("▴ click to collapse")) }
glyph := "▪" if foldable { glyph = pick(open, "▾", "▸") } name := pick(failed, r.Styles.Error, r.Styles.Busy).Render(cardName(entry)) _, tail := toolRowParts(entry, failed, formatClock(entry.Timestamp)) header := markChrome + rowAction{verbHead, key}.mark() + fit(r.Styles.Busy.Render(glyph)+" "+name, r.Styles.Faint.Render(tail), width)
rows := []string{header} if trace := entry.ToolTrace; trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) { rows = append(rows, chrome(r.RenderToolTrace(trace, flags, width))) } return append(rows, body...)}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181package tui
import ( "encoding/json" "fmt" "strings"
tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi")
// The cells page is the session's REPL as a notebook: every tool call in a// list, newest last, and the one under the cursor in full beneath it. The// transcript keeps calls to a line; this is where you read them.
type CellsDoneMsg struct{}
type CellsModel struct { page entries []HistoryEntry calls []HistoryEntry // tail keeps the cursor on the newest call as calls arrive. tail bool // Scroll is how far the detail below the list is read. Scroll int}
func NewCellsModel() CellsModel { return CellsModel{tail: true} }
func (m CellsModel) Init() tea.Cmd { return nil }
// Sync takes the session's history, which keeps arriving while the page is open.func (m *CellsModel) Sync(entries []HistoryEntry) { m.entries = entries m.calls = m.calls[:0:0] for _, entry := range entries { if entry.Kind == EntryTool { m.calls = append(m.calls, entry) } } if m.tail { m.Cursor = max(0, len(m.calls)-1) } m.Cursor = max(0, min(m.Cursor, len(m.calls)-1))}
// cellRun is what a python cell's result envelope says beyond its output.func cellRun(entry HistoryEntry) (duration string) { var cell struct { Duration float64 `json:"duration"` } if entry.ToolName == "python" && json.Unmarshal([]byte(entry.ToolResult), &cell) == nil && cell.Duration > 0 { return formatElapsed(int64(cell.Duration * 1000)) } return ""}
func (m CellsModel) Update(msg tea.Msg) (CellsModel, tea.Cmd) { key, ok := msg.(tea.KeyPressMsg) if !ok { return m, nil } detail := max(1, m.Height/2) switch k := key.String(); k { case "esc", "q", "ctrl+c", "ctrl+d": return m, func() tea.Msg { return CellsDoneMsg{} } case "up", "ctrl+p", "down", "ctrl+n": m.step(k, len(m.calls)) m.tail, m.Scroll = m.Cursor == len(m.calls)-1, 0 case "j": m.Scroll++ case "k": m.Scroll = max(0, m.Scroll-1) case "pgdown", "space": m.Scroll += detail case "pgup": m.Scroll = max(0, m.Scroll-detail) case "c", "o": if m.Cursor < len(m.calls) { call := m.calls[m.Cursor] text, what := toolOutput(call), "output" if k == "c" { text, _ = cardInput(call) what = "input" } m.Notice = "copied " + what return m, CopyText(text) } } return m, nil}
// glyph is a call's outcome in its own color.func (m CellsModel) glyph(entry HistoryEntry) string { if toolFailed(entry) { return DefaultStyles.Error.Render("✗") } return DefaultStyles.Success.Render("✓")}
func (m CellsModel) listRow(entry HistoryEntry, selected bool, width int) string { head, _ := toolRowParts(entry, false, "") if code, ok := entry.ToolArgs["code"].(string); ok && entry.ToolName == "python" { head = "python · " + codeGist(code) } meta := []string{formatClock(entry.Timestamp)} if d := cellRun(entry); d != "" { meta = append([]string{d}, meta...) } tail := " " + DefaultStyles.Faint.Render(strings.Join(meta, " · ")) row := m.glyph(entry) + " " + fit(DefaultStyles.Busy.Render(oneLine(head)), tail, width-4) return listRow(selected, row, width)}
// codeGist is the line of a cell that says what it does: the first that is// not a comment, else the first comment.func codeGist(code string) string { for _, line := range strings.Split(code, "\n") { if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") { return line } } return firstLine(code)}
// detailRows is one call in full: what it was given on the panel, what it// printed below, errors in red.func (m CellsModel) detailRows(entry HistoryEntry, width int) []string { var rows []string input, lang := cardInput(entry) if strings.TrimSpace(input) != "" { rows = append(rows, panelRows(input, lang, width)...) rows = append(rows, "") } out := outputRows(toolOutput(entry), width) if toolOutput(entry) == "" { out = []string{DefaultStyles.Faint.Render("(no output)")} } style := pick(toolFailed(entry), DefaultStyles.Error, DefaultStyles.Muted) bar := DefaultStyles.Decor.Render("▎") + " " for _, line := range out { rows = append(rows, bar+style.Render(ansi.Truncate(line, width-2, "…"))) } return rows}
func (m CellsModel) View() string { width := max(1, m.Width) rows := m.header("/cells", fmt.Sprintf("%d calls", len(m.calls))) hints := keyHints(hint{"↑↓", "cell"}, hint{"j/k", "scroll"}, hint{"c", "copy input"}, hint{"o", "copy output"}, hint{"esc", "back"}) if len(m.calls) == 0 { rows = append(rows, DefaultStyles.Faint.Render("no tool calls yet"), "", hints) return m.fit(rows) }
listRows := min(len(m.calls), max(3, m.Height/3)) lines := make([]string, len(m.calls)) for i, call := range m.calls { lines[i] = m.listRow(call, i == m.Cursor, width) } rows = append(rows, scrolled(lines, m.Cursor, listRows)...)
call := m.calls[m.Cursor] title := DefaultStyles.Bold.Render(cardName(call)) rows = append(rows, titleRule(width, title, DefaultStyles.Faint.Render(formatClock(call.Timestamp))))
room := max(1, m.Height-len(rows)-1) body := m.detailRows(call, width) m.Scroll = max(0, min(m.Scroll, len(body)-room)) body = body[min(m.Scroll, len(body)):] rows = append(rows, body[:min(len(body), room)]...) for len(rows) < m.Height-1 { rows = append(rows, "") } more := pick(len(body) > room, DefaultStyles.Faint.Render(fmt.Sprintf(" ↓ %d more rows", len(body)-room)), "") rows = append(rows, hints+more) if m.Notice != "" { rows = append(rows, DefaultStyles.Faint.Render(m.Notice)) } return m.fit(rows)}44 unmodified lines454647484950515253541517 unmodified linesExpand all157215731574157515741576157715781579158055 unmodified lines1636163716381639164016411642164316441645740 unmodified linesExpand all44 unmodified linestype ChatNewSessionMsg struct{}type ChatOpenModelPickerMsg struct{}type ChatOpenExtensionPickerMsg struct{}type ChatOpenTreePickerMsg struct{}
type ChatOpenCellsMsg struct{}type ChatOpenContextInspectorMsg struct{}type ChatOpenPageMsg struct { Command string}1517 unmodified linesExpand all } token := strings.Fields(trimmed)[0] switch token { case "/a", "/agents", "/sessions", "/q", "/quit", "/exit", "/new", "/model", "/extensions", "/plugins", "/tree", "/context", "/t", "/thinking", "/v", "/verbose", "/plugins", "/tree", "/cells", "/context", "/t", "/thinking", "/v", "/verbose", "/status", "/login", "/mouse", "/skills", "/instructions", "/mcp", "/cd": return true } return slices.ContainsFunc(m.CommandMenu.Catalog, func(cmd daemon.SessionCommand) bool {55 unmodified lines case "/extensions", "/plugins": emit(ChatOpenExtensionPickerMsg{}) case "/tree": emit(ChatOpenTreePickerMsg{}) case "/cells": emit(ChatOpenCellsMsg{}) case "/context": emit(ChatOpenContextInspectorMsg{}) case "/webhooks": emit(ChatOpenWebhooksPageMsg{})740 unmodified linesExpand all14 unmodified lines1516171819192021222324117 unmodified linesExpand all14 unmodified lines
var CommonCommands = []ChatCommand{ {Name: "/a", Description: "browse sessions"}, {Name: "/t", Description: "show or hide thinking"}, {Name: "/v", Description: "expand or collapse code, output and diffs"}, {Name: "/v", Description: "show tool calls in full; click a card to fold it"}, {Name: "/cells", Description: "browse tool calls and their output"}, {Name: "/status", Description: "show state, workspace and usage"}, {Name: "/q", Description: "quit"},}
117 unmodified linesExpand all12 unmodified lines13141516171819202122232421252627282917 unmodified lines474849504751525354555657585960616263646566676869707172737475767778798081828384858687888981 unmodified lines12 unmodified lines// A row action is a click target. Like the copy marks in selection.go, its// mark is an APC string that draws nothing, so the row itself says what a// click on it does and survives wrapping, trimming, and rebuilds.const ( // verbHead leads a tool card and folds or unfolds it. verbHead = "head" // verbFold is a row inside a card that does the same. verbFold = "fold" // verbCopy copies a turn's replies as the markdown they were written in. verbCopy = "copy")
var actionMark = regexp.MustCompile(`\x1b_albedo:(copy):([0-9a-f]+-[0-9a-f]+)\x1b\\`)var actionMark = regexp.MustCompile(`\x1b_albedo:(head|fold|copy):([0-9a-f]+-[0-9a-f]+)\x1b\\`)
type rowAction struct{ verb, key string }
func (a rowAction) mark() string { return "\x1b_albedo:" + a.verb + ":" + a.key + "\x1b\\" }17 unmodified linesfunc (m *ChatModel) actAt(row int) tea.Cmd { if row < 0 || row >= len(m.frameLines) { return nil } if act, ok := actionOf(m.frameLines[row]); ok && act.verb == verbCopy { act, ok := actionOf(m.frameLines[row]) switch { case !ok: return nil case act.verb == verbCopy: return m.copyReply(act.key) } m.toggleCard(act.key, row) return nil}
// toggleCard folds or unfolds a card, keeping its header where it stood on// screen so the click does not move what you were reading.func (m *ChatModel) toggleCard(key string, row int) { head := rowAction{verbHead, key}.mark() top := row for top > 0 && !strings.Contains(m.frameLines[top], head) { top-- } onScreen := top - m.scrollOffset if m.Renderer.Expanded == nil { m.Renderer.Expanded = map[string]bool{} } m.Renderer.Expanded[key] = !m.Renderer.Expanded[key] m.dragAnchor = nil m.rebuildSettledLines() m.refreshViewportContent() if at := slices.IndexFunc(m.frameLines, func(line string) bool { return strings.Contains(line, head) }); at >= 0 { m.Follow = false m.scrollOffset = max(0, at-onScreen) m.refreshViewportContent() m.Follow = m.scrollOffset >= m.scrollLimit }}
// copyReply copies a reply's markdown.func (m *ChatModel) copyReply(key string) tea.Cmd { if text, ok := m.replyMarkdown(key); ok { return m.copied(text)81 unmodified lines11234567891011121 unmodified line141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013133 unmodified lines165166167168585960616263646566676869169170171172173741741751761771783 unmodified lines182183184185186187188189190191192193194195196197198199// Clickable rows and selections that scroll are drawn and// Clickable rows, tool cards, and selections that scroll are drawn and// driven in the terminal client: the daemon e2e sees committed text, never// rendered rows or mouse events, so these regressions are invisible outside// the TUI.package tui
import ( "encoding/json" "fmt" "strings" "testing"
1 unmodified line tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi")
func numbered(prefix string, n int) string { lines := make([]string, n) for i := range lines { lines[i] = fmt.Sprintf("%s %d", prefix, i+1) } return strings.Join(lines, "\n")}
func pythonCall(code, output string) HistoryEntry { result, _ := json.Marshal(map[string]string{"status": "ok", "output": output}) return HistoryEntry{ Kind: EntryTool, ToolName: "python", Timestamp: 1000, ToolArgs: map[string]any{"code": code}, ToolResult: string(result), }}
func verboseChat(t *testing.T, height int, entries ...HistoryEntry) ChatModel { t.Helper() m := NewChatModel(&daemon.Session{ID: "s"}, nil) m.SetSize(80, height) m.Flags.Tools = true for _, entry := range entries { m.appendSettledEntry(entry) } m.refreshViewportContent() return m}
func rowWith(t *testing.T, lines []string, text string) int { t.Helper() for i, line := range lines { if strings.Contains(ansi.Strip(line), text) { return i } } t.Fatalf("no row says %q in:\n%s", text, ansi.Strip(strings.Join(lines, "\n"))) return -1}
// A long call shows the start of its input and both ends of its output// behind a fold row, and a click on the header opens all of it and back.func TestToolCardFoldsAndUnfoldsOnClick(t *testing.T) { m := verboseChat(t, 120, pythonCall(numbered("step", 30), numbered("out", 40))) screen := func() string { return ansi.Strip(m.Viewport.View()) } collapsed := screen() for _, want := range []string{"▸ python", "step 6", "⋯ 24 more · click to expand", "out 1", "out 4", "out 40", "⋯ 34 more"} { if !strings.Contains(collapsed, want) { t.Fatalf("collapsed card lacks %q:\n%s", want, collapsed) } } if strings.Contains(collapsed, "step 7") || strings.Contains(collapsed, "out 20") { t.Fatalf("collapsed card shows its middle:\n%s", collapsed) }
m.actAt(rowWith(t, m.frameLines, "▸ python")) open := screen() for _, want := range []string{"▾ python", "step 30", "out 20", "▴ click to collapse"} { if !strings.Contains(open, want) { t.Fatalf("open card lacks %q:\n%s", want, open) } }
m.actAt(rowWith(t, m.frameLines, "▴ click to collapse")) if got := screen(); got != collapsed { t.Fatalf("collapsing did not restore the card:\n%s", got) }}
// Folding keeps the header where it stood, so the click does not move what// you were reading, and a short card has nothing to fold.func TestFoldingKeepsTheHeaderOnScreen(t *testing.T) { entries := []HistoryEntry{pythonCall("print(1)", "1")} entries[0].Timestamp = 1 for i := int64(2); i < 6; i++ { entries = append(entries, pythonCall(numbered("step", 30), numbered("out", 40))) entries[len(entries)-1].Timestamp = i } m := verboseChat(t, 20, entries...) if first := ansi.Strip(m.frameLines[0]); !strings.Contains(first, "▪ python") { t.Fatalf("a one-line call offers a fold: %q", first) } m.Follow = false m.scrollOffset = rowWith(t, m.frameLines, "▸ python") - 2 m.refreshViewportContent() header := func() int { return rowWith(t, strings.Split(m.Viewport.View(), "\n"), "▸ python") } before := header() m.actAt(m.scrollOffset + before) for i, line := range strings.Split(m.Viewport.View(), "\n") { if strings.Contains(ansi.Strip(line), "▾ python") && i == before { return } } t.Fatalf("the header left row %d:\n%s", before, ansi.Strip(m.Viewport.View()))}
// Tool output is untrusted text: colors and control characters are dropped// and a row never outgrows the transcript.func TestToolCardContainsHostileOutput(t *testing.T) { m := verboseChat(t, 40, pythonCall("print(1)", "\x1b[31mred\x1b[0m\ttab\rcarriage "+strings.Repeat("w", 300))) for _, line := range strings.Split(m.Viewport.View(), "\n") { if w := ansi.StringWidth(line); w > 80 { t.Fatalf("a %d-cell row in an 80-cell transcript: %q", w, ansi.Strip(line)) } } if screen := ansi.Strip(m.Viewport.View()); !strings.Contains(screen, "red tab") { t.Fatalf("output was not cleaned:\n%s", screen) }}
// Dragging past the top edge scrolls the transcript and keeps selecting, so// the copy reaches rows that were never on screen together.func TestDraggingPastTheEdgeScrollsAndKeepsSelecting(t *testing.T) { m := NewChatModel(&daemon.Session{ID: "s"}, nil)33 unmodified lines}
// The signoff copies the whole turn's prose as markdown, and the cells page lists every// call with the one under the cursor in full.func rowWith(t *testing.T, lines []string, text string) int { t.Helper() for i, line := range lines { if strings.Contains(ansi.Strip(line), text) { return i } } t.Fatalf("no row says %q", text) return -1}
func TestSignoffCopiesTheTurnAsMarkdown(t *testing.T) {func TestSignoffCopiesTheTurnAndCellsPageReadsCalls(t *testing.T) { m := NewChatModel(&daemon.Session{ID: "s"}, nil) m.SetSize(80, 40) m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "hi", Timestamp: 1}) m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "# first\n\n```go\nx := 1\n```", Timestamp: 2}) m.appendSettledEntry(HistoryEntry{Kind: EntryTool, ToolName: "bash", Timestamp: 3}) m.appendSettledEntry(pythonCall("print('a')", "a")) m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "**second**", Timestamp: 4}) m.appendSettledEntry(HistoryEntry{Kind: EntryTurnEnd, Mood: moodDone, Timestamp: 5}) m.refreshViewportContent() row := rowWith(t, m.frameLines, "⧉")3 unmodified lines } if got, _ := m.replyMarkdown(act.key); got != "# first\n\n```go\nx := 1\n```\n\n**second**" { t.Fatalf("the signoff copied %q", got) }
cells := NewCellsModel() cells.SetSize(80, 24) cells.Sync(m.History.Entries()) view := ansi.Strip(cells.View()) for _, want := range []string{"/cells", "1 calls", "✓ python", "print('a')", "copy output"} { if !strings.Contains(view, want) { t.Fatalf("cells page lacks %q:\n%s", want, view) } } if rows := strings.Count(cells.View(), "\n") + 1; rows > 24 { t.Fatalf("cells page is %d rows on a 24-row screen", rows) }}27 unmodified lines282930313233343536353738394041469 unmodified linesExpand all511512513514513514515516517518519520521522523524515516517518519520521522523524525528529530526532533534535536537538539527528529530172 unmodified linesExpand all27 unmodified lines Styles Styles BodyWidth int // Workspace roots the paths trace rows name. Workspace string // Expanded holds the tool cards opened past their folds, by entryKey. Expanded map[string]bool}
func NewTranscriptRenderer() TranscriptRenderer { return TranscriptRenderer{Styles: DefaultStyles} return TranscriptRenderer{Styles: DefaultStyles, Expanded: map[string]bool{}}}
func formatClock(timestamp int64) string { if timestamp <= 0 {469 unmodified linesExpand all case EntryTool: trace := entry.ToolTrace hasTrace := trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) failed := toolFailed(entry) if !hasTrace || flags.Tools || failed { // a glance in normal mode, read in full in verbose mode style := r.Styles.Faint clock := "" if flags.Tools { style = lipgloss.NewStyle() clock = formatClock(entry.Timestamp) } if failed { style = r.Styles.Error } rows = append(rows, markChrome+style.Render(toolRow(entry, failed, clock, width))) if flags.Tools { rows = r.toolCard(entry, flags, failed, width) break } // a glance: the trace when the harness recorded one, else the call if !hasTrace || failed { style := pick(failed, r.Styles.Error, r.Styles.Faint) rows = append(rows, markChrome+style.Render(toolRow(entry, failed, "", width))) } if hasTrace { rows = append(rows, chrome(r.RenderToolTrace(trace, flags, width))) if !flags.Tools && !failed { break } } if flags.Tools { if entry.ToolName == "python" { if code, ok := entry.ToolArgs["code"].(string); ok { rows = append(rows, renderCopyable("```python\n"+code+"\n```", width)) } } rows = append(rows, strings.Split(cmp.Or(toolOutput(entry), "(no output)"), "\n")...) } case EntryTurnEnd: rows = []string{r.signoff(entry)} case EntryNote: rows = []string{r.Styles.Faint.Render(entry.Text)}172 unmodified linesExpand all