| 🛟 Updated. 28d5985 k33g 19h ago | 1 | package acp |
| 2 | |
| 3 | import ( |
| 📦 Turbo Core f3ade8d k33g 11h ago | 4 | "rickub.com/turbo-editors/turbo-core/syntax" |
| 5 | "rickub.com/turbo-editors/turbo-core/theme" |
| 🛟 Updated. 28d5985 k33g 19h ago | 6 | ) |
| 7 | |
| 8 | // Line is one drawn line of a conversation: its text, and how to colour it. |
| 9 | // |
| 10 | // Turning entries into lines is separate from drawing them so that wrapping, |
| 11 | // fence detection and colouring can all be tested by comparing values, with no |
| 12 | // screen involved. |
| 13 | type Line struct { |
| 14 | // Text is what to draw, already wrapped to the width asked for. |
| 15 | Text string |
| 16 | |
| 17 | // Style is the theme key the whole line takes, for a line that is |
| 18 | // furniture rather than content — a speaker's label, a tool's status. An |
| 19 | // empty one means the window's ordinary text. |
| 20 | // |
| 21 | // A theme key rather than a syntax class, because the classes have no |
| 22 | // notion of "something went wrong" and the themes do: diagnostic.error is |
| 23 | // already set by every one of them, for the language server's own |
| 24 | // problems, and a failed tool call is the same kind of fact. |
| 25 | Style string |
| 26 | |
| 27 | // Spans colour the line piece by piece, and are set instead of Style for a |
| 28 | // line of code inside a fence. They are in rune coordinates. |
| 29 | Spans []syntax.Span |
| 30 | |
| 31 | // Region is which copyable run of text this line belongs to: one fenced |
| 32 | // code block, one passage of prose, one tool call's output. Lines sharing |
| 33 | // a Region are what "copy the block under the cursor" copies, which is the |
| 34 | // thing somebody actually wants — a code block, without the label above it |
| 35 | // and without the sentence after it. |
| 36 | // |
| 37 | // The zero value is a region of its own, so a Line built by hand is never |
| 38 | // silently part of the one before it. |
| 39 | Region int |
| 40 | } |
| 41 | |
| 42 | // Lines lays a whole conversation out at a width, ready to draw. |
| 43 | // |
| 44 | // Prose is wrapped; **code is not**. A wrapped line of code would need its |
| 45 | // syntax spans remapped onto the pieces, and a half-line of Go put under the |
| 46 | // line above it reads worse than one that is simply too long — editors clip |
| 47 | // code, they do not reflow it. The window scrolls sideways instead. |
| 48 | // |
| 49 | // lines := acp.Lines(session.Entries(), session.AgentName(), 78) |
| 50 | func Lines(entries []Entry, agentName string, width int) []Line { |
| 51 | var out []Line |
| 52 | region := 0 |
| 53 | |
| 54 | for i, entry := range entries { |
| 55 | if i > 0 { |
| 56 | region++ |
| 57 | out = append(out, Line{Region: region}) |
| 58 | } |
| 59 | region++ |
| 60 | out = append(out, numberRegions(linesOf(entry, agentName, width), ®ion)...) |
| 61 | } |
| 62 | return out |
| 63 | } |
| 64 | |
| 65 | // numberRegions turns the per-entry region marks into numbers unique across |
| 66 | // the whole conversation. |
| 67 | // |
| 68 | // linesOf marks a boundary by leaving Region at 1 rather than 0 — it cannot |
| 69 | // know what came before it — so this walks the lines it produced and turns |
| 70 | // each boundary into the next number. |
| 71 | func numberRegions(lines []Line, region *int) []Line { |
| 72 | for i := range lines { |
| 73 | if lines[i].Region != 0 { |
| 74 | *region++ |
| 75 | } |
| 76 | lines[i].Region = *region |
| 77 | } |
| 78 | return lines |
| 79 | } |
| 80 | |
| 81 | // linesOf lays one entry out. |
| 82 | // |
| 83 | // One return rather than one per kind: the switch is a mapping from kind to |
| 84 | // layout, and a reader checking that every kind is covered should not have to |
| 85 | // scan seven exit points to be sure. |
| 86 | func linesOf(entry Entry, agentName string, width int) []Line { |
| 87 | var out []Line |
| 88 | |
| 89 | switch entry.Kind { |
| 90 | case EntryUser: |
| 91 | out = append(label(speakerOf(entry, "You")), own(prose(entry.Text, width, ""))...) |
| 92 | case EntryAgent: |
| 93 | out = append(label(speakerOf(entry, agentName)), body(entry.Text, width)...) |
| 94 | case EntryThought: |
| 95 | out = append(thoughtLabel(), own(prose(entry.Text, width, theme.KeySyntaxComment))...) |
| 96 | case EntryTool: |
| 97 | out = toolLines(entry, width) |
| 98 | case EntryPlan: |
| 99 | out = planLines(entry, width) |
| 100 | case EntryNotice: |
| 101 | out = prose(entry.Text, width, theme.KeyDiagnosticError) |
| 102 | default: |
| 103 | out = prose(entry.Text, width, "") |
| 104 | } |
| 105 | return out |
| 106 | } |
| 107 | |
| 108 | // own marks a run of lines as a copyable region of its own. |
| 109 | // |
| 110 | // It is what keeps a speaker's label out of what gets copied: "‣ Bob |
| 111 | // (llama.cpp)" above a code block is furniture, and pasting it into a source |
| 112 | // file is never what anybody meant. |
| 113 | func own(lines []Line) []Line { |
| 114 | if len(lines) > 0 { |
| 115 | lines[0].Region = 1 |
| 116 | } |
| 117 | return lines |
| 118 | } |
| 119 | |
| 120 | // speakerOf returns an entry's speaker, falling back to a default. |
| 121 | func speakerOf(entry Entry, fallback string) string { |
| 122 | if entry.Speaker != "" { |
| 123 | return entry.Speaker |
| 124 | } |
| 125 | return fallback |
| 126 | } |
| 127 | |
| 128 | // label is the one line naming who is speaking. |
| 129 | func label(who string) []Line { |
| 130 | return []Line{{Text: "\u2023 " + who, Style: theme.KeySyntaxKeyword}} |
| 131 | } |
| 132 | |
| 133 | // thoughtLabel names a passage of the agent thinking aloud. |
| 134 | func thoughtLabel() []Line { |
| 135 | return []Line{{Text: "\u2023 thinking", Style: theme.KeySyntaxComment}} |
| 136 | } |
| 137 | |
| 138 | // prose wraps plain text at one style. |
| 139 | func prose(text string, width int, style string) []Line { |
| 140 | var out []Line |
| 141 | for _, line := range Wrap(text, width-indent) { |
| 142 | out = append(out, Line{Text: pad + line, Style: style}) |
| 143 | } |
| 144 | return out |
| 145 | } |
| 146 | |
| 147 | // body lays out an agent's message, colouring what came out of a fence. |
| 148 | func body(text string, width int) []Line { |
| 149 | var out []Line |
| 150 | |
| 151 | for _, block := range SplitBlocks(text) { |
| 152 | var lines []Line |
| 153 | if block.Code { |
| 154 | lines = code(block) |
| 155 | } else { |
| 156 | lines = prose(block.Text, width, "") |
| 157 | } |
| 158 | |
| 159 | // Every block is a copyable region of its own — including the first, |
| 160 | // which is what keeps the speaker's label above it out of the copy. |
| 161 | out = append(out, own(lines)...) |
| 162 | } |
| 163 | return out |
| 164 | } |
| 165 | |
| 166 | // code draws a fenced block, coloured by the scanner the fence named. |
| 167 | // |
| 168 | // A fence naming a language nothing colours is drawn plainly rather than |
| 169 | // approximated, which is the same rule every scanner here already follows. |
| 170 | func code(block Block) []Line { |
| 171 | source := splitLines(block.Text) |
| 172 | spans := syntax.Highlight(block.Language, block.Text+"\n") |
| 173 | |
| 174 | out := make([]Line, 0, len(source)) |
| 175 | for i, line := range source { |
| 176 | drawn := Line{Text: pad + line} |
| 177 | if block.Language != syntax.LanguageNone && i < len(spans) { |
| 178 | drawn.Spans = shift(spans[i], indent) |
| 179 | } |
| 180 | out = append(out, drawn) |
| 181 | } |
| 182 | return out |
| 183 | } |
| 184 | |
| 185 | // shift moves spans right by the indent the lines are drawn at. |
| 186 | func shift(spans []syntax.Span, by int) []syntax.Span { |
| 187 | if len(spans) == 0 { |
| 188 | return nil |
| 189 | } |
| 190 | |
| 191 | out := make([]syntax.Span, len(spans)) |
| 192 | for i, span := range spans { |
| 193 | span.Start += by |
| 194 | span.End += by |
| 195 | out[i] = span |
| 196 | } |
| 197 | return out |
| 198 | } |
| 199 | |
| 200 | // toolLines draw a tool call: what it was, what it did, and what came back. |
| 201 | func toolLines(entry Entry, width int) []Line { |
| 202 | style := theme.KeySyntaxType |
| 203 | if entry.Failed() { |
| 204 | style = theme.KeyDiagnosticError |
| 205 | } |
| 206 | |
| 207 | heading := "\u2023 " + speakerOf(entry, "tool") |
| 208 | if entry.Detail != "" { |
| 209 | heading += " " + entry.Detail |
| 210 | } |
| 211 | heading += " " + statusMark(entry.Status) |
| 212 | |
| 213 | out := []Line{{Text: trimTo(heading, width), Style: style}} |
| 214 | return append(out, own(prose(entry.Text, width, theme.KeySyntaxComment))...) |
| 215 | } |
| 216 | |
| 217 | // statusMark is the short sign of how a tool call is going. |
| 218 | // |
| 219 | // A word, not only a symbol: a tick and a cross are a single cell apart at a |
| 220 | // glance, and one of them means an agent did something to your files. |
| 221 | func statusMark(status string) string { |
| 222 | switch status { |
| 223 | case StatusCompleted: |
| 224 | return "\u2713 done" |
| 225 | case StatusFailed: |
| 226 | return "\u2717 failed" |
| 227 | case StatusInProgress: |
| 228 | return "\u2026 running" |
| 229 | default: |
| 230 | return "\u2026 waiting" |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // planLines draw a plan, one line per step. |
| 235 | func planLines(entry Entry, width int) []Line { |
| 236 | out := []Line{{Text: "\u2023 plan", Style: theme.KeySyntaxKeyword}} |
| 237 | for _, step := range entry.Plan { |
| 238 | out = append(out, Line{ |
| 239 | Text: trimTo(pad+planMark(step.Status)+" "+oneLine(step.Content), width), |
| 240 | Style: theme.KeySyntaxComment, |
| 241 | }) |
| 242 | } |
| 243 | return out |
| 244 | } |
| 245 | |
| 246 | // planMark is the box beside one step of a plan. |
| 247 | func planMark(status string) string { |
| 248 | switch status { |
| 249 | case StatusCompleted: |
| 250 | return "[x]" |
| 251 | case StatusInProgress: |
| 252 | return "[~]" |
| 253 | default: |
| 254 | return "[ ]" |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // trimTo clips a line to a width, counting runes rather than bytes: a tick and |
| 259 | // a box-drawing character are three bytes and one column each. |
| 260 | func trimTo(text string, width int) string { |
| 261 | runes := []rune(text) |
| 262 | if width < 1 || len(runes) <= width { |
| 263 | return text |
| 264 | } |
| 265 | return string(runes[:width]) |
| 266 | } |
| 267 | |
| 268 | // indent is how far an entry's text sits from the left, under its label. |
| 269 | const indent = 2 |
| 270 | |
| 271 | // pad is that indent, as the spaces every body line starts with. |
| 272 | const pad = " " |