turbo-editors/turbo-corepublic Fork 0
v1.0.2
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

render.go · 272 lines · 8.0 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 19h ago1package acp
2
3import (
📦 Turbo Core f3ade8d k33g 11h ago4 "rickub.com/turbo-editors/turbo-core/syntax"
5 "rickub.com/turbo-editors/turbo-core/theme"
🛟 Updated. 28d5985 k33g 19h ago6)
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.
13type 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)
50func 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), &region)...)
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.
71func 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.
86func 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.
113func 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.
121func 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.
129func 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.
134func thoughtLabel() []Line {
135 return []Line{{Text: "\u2023 thinking", Style: theme.KeySyntaxComment}}
136}
137
138// prose wraps plain text at one style.
139func 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.
148func 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.
170func 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.
186func 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.
201func 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.
221func 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.
235func 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.
247func 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.
260func 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.
269const indent = 2
270
271// pad is that indent, as the spaces every body line starts with.
272const pad = " "