package acp import ( "codeberg.org/turbo-editors/turbo-core/syntax" "codeberg.org/turbo-editors/turbo-core/theme" ) // Line is one drawn line of a conversation: its text, and how to colour it. // // Turning entries into lines is separate from drawing them so that wrapping, // fence detection and colouring can all be tested by comparing values, with no // screen involved. type Line struct { // Text is what to draw, already wrapped to the width asked for. Text string // Style is the theme key the whole line takes, for a line that is // furniture rather than content — a speaker's label, a tool's status. An // empty one means the window's ordinary text. // // A theme key rather than a syntax class, because the classes have no // notion of "something went wrong" and the themes do: diagnostic.error is // already set by every one of them, for the language server's own // problems, and a failed tool call is the same kind of fact. Style string // Spans colour the line piece by piece, and are set instead of Style for a // line of code inside a fence. They are in rune coordinates. Spans []syntax.Span // Region is which copyable run of text this line belongs to: one fenced // code block, one passage of prose, one tool call's output. Lines sharing // a Region are what "copy the block under the cursor" copies, which is the // thing somebody actually wants — a code block, without the label above it // and without the sentence after it. // // The zero value is a region of its own, so a Line built by hand is never // silently part of the one before it. Region int } // Lines lays a whole conversation out at a width, ready to draw. // // Prose is wrapped; **code is not**. A wrapped line of code would need its // syntax spans remapped onto the pieces, and a half-line of Go put under the // line above it reads worse than one that is simply too long — editors clip // code, they do not reflow it. The window scrolls sideways instead. // // lines := acp.Lines(session.Entries(), session.AgentName(), 78) func Lines(entries []Entry, agentName string, width int) []Line { var out []Line region := 0 for i, entry := range entries { if i > 0 { region++ out = append(out, Line{Region: region}) } region++ out = append(out, numberRegions(linesOf(entry, agentName, width), ®ion)...) } return out } // numberRegions turns the per-entry region marks into numbers unique across // the whole conversation. // // linesOf marks a boundary by leaving Region at 1 rather than 0 — it cannot // know what came before it — so this walks the lines it produced and turns // each boundary into the next number. func numberRegions(lines []Line, region *int) []Line { for i := range lines { if lines[i].Region != 0 { *region++ } lines[i].Region = *region } return lines } // linesOf lays one entry out. // // One return rather than one per kind: the switch is a mapping from kind to // layout, and a reader checking that every kind is covered should not have to // scan seven exit points to be sure. func linesOf(entry Entry, agentName string, width int) []Line { var out []Line switch entry.Kind { case EntryUser: out = append(label(speakerOf(entry, "You")), own(prose(entry.Text, width, ""))...) case EntryAgent: out = append(label(speakerOf(entry, agentName)), body(entry.Text, width)...) case EntryThought: out = append(thoughtLabel(), own(prose(entry.Text, width, theme.KeySyntaxComment))...) case EntryTool: out = toolLines(entry, width) case EntryPlan: out = planLines(entry, width) case EntryNotice: out = prose(entry.Text, width, theme.KeyDiagnosticError) default: out = prose(entry.Text, width, "") } return out } // own marks a run of lines as a copyable region of its own. // // It is what keeps a speaker's label out of what gets copied: "‣ Bob // (llama.cpp)" above a code block is furniture, and pasting it into a source // file is never what anybody meant. func own(lines []Line) []Line { if len(lines) > 0 { lines[0].Region = 1 } return lines } // speakerOf returns an entry's speaker, falling back to a default. func speakerOf(entry Entry, fallback string) string { if entry.Speaker != "" { return entry.Speaker } return fallback } // label is the one line naming who is speaking. func label(who string) []Line { return []Line{{Text: "\u2023 " + who, Style: theme.KeySyntaxKeyword}} } // thoughtLabel names a passage of the agent thinking aloud. func thoughtLabel() []Line { return []Line{{Text: "\u2023 thinking", Style: theme.KeySyntaxComment}} } // prose wraps plain text at one style. func prose(text string, width int, style string) []Line { var out []Line for _, line := range Wrap(text, width-indent) { out = append(out, Line{Text: pad + line, Style: style}) } return out } // body lays out an agent's message, colouring what came out of a fence. func body(text string, width int) []Line { var out []Line for _, block := range SplitBlocks(text) { var lines []Line if block.Code { lines = code(block) } else { lines = prose(block.Text, width, "") } // Every block is a copyable region of its own — including the first, // which is what keeps the speaker's label above it out of the copy. out = append(out, own(lines)...) } return out } // code draws a fenced block, coloured by the scanner the fence named. // // A fence naming a language nothing colours is drawn plainly rather than // approximated, which is the same rule every scanner here already follows. func code(block Block) []Line { source := splitLines(block.Text) spans := syntax.Highlight(block.Language, block.Text+"\n") out := make([]Line, 0, len(source)) for i, line := range source { drawn := Line{Text: pad + line} if block.Language != syntax.LanguageNone && i < len(spans) { drawn.Spans = shift(spans[i], indent) } out = append(out, drawn) } return out } // shift moves spans right by the indent the lines are drawn at. func shift(spans []syntax.Span, by int) []syntax.Span { if len(spans) == 0 { return nil } out := make([]syntax.Span, len(spans)) for i, span := range spans { span.Start += by span.End += by out[i] = span } return out } // toolLines draw a tool call: what it was, what it did, and what came back. func toolLines(entry Entry, width int) []Line { style := theme.KeySyntaxType if entry.Failed() { style = theme.KeyDiagnosticError } heading := "\u2023 " + speakerOf(entry, "tool") if entry.Detail != "" { heading += " " + entry.Detail } heading += " " + statusMark(entry.Status) out := []Line{{Text: trimTo(heading, width), Style: style}} return append(out, own(prose(entry.Text, width, theme.KeySyntaxComment))...) } // statusMark is the short sign of how a tool call is going. // // A word, not only a symbol: a tick and a cross are a single cell apart at a // glance, and one of them means an agent did something to your files. func statusMark(status string) string { switch status { case StatusCompleted: return "\u2713 done" case StatusFailed: return "\u2717 failed" case StatusInProgress: return "\u2026 running" default: return "\u2026 waiting" } } // planLines draw a plan, one line per step. func planLines(entry Entry, width int) []Line { out := []Line{{Text: "\u2023 plan", Style: theme.KeySyntaxKeyword}} for _, step := range entry.Plan { out = append(out, Line{ Text: trimTo(pad+planMark(step.Status)+" "+oneLine(step.Content), width), Style: theme.KeySyntaxComment, }) } return out } // planMark is the box beside one step of a plan. func planMark(status string) string { switch status { case StatusCompleted: return "[x]" case StatusInProgress: return "[~]" default: return "[ ]" } } // trimTo clips a line to a width, counting runes rather than bytes: a tick and // a box-drawing character are three bytes and one column each. func trimTo(text string, width int) string { runes := []rune(text) if width < 1 || len(runes) <= width { return text } return string(runes[:width]) } // indent is how far an entry's text sits from the left, under its label. const indent = 2 // pad is that indent, as the spaces every body line starts with. const pad = " "