turbo-editors/turbo-corepublic Fork 0
v1.0.1
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.

📦 Turbo Core f3ade8d · on v1.0.1 · k33g · 5h ago
render.go · 272 lines · 8.0 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
package acp

import (
	"rickub.com/turbo-editors/turbo-core/syntax"
	"rickub.com/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), &region)...)
	}
	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 = "  "