turbo-editors/turbo-corepublic Fork 0
v1.0.0
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.0 · k33g · 8h ago
view.go · 176 lines · 6.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
package acp

import (
	"strings"
	"time"

	"rickub.com/turbo-editors/turbo-core/syntax"
	"rickub.com/turbo-editors/turbo-core/ui"
)

// InputHeight is how many rows the box you type into takes, not counting the
// rule above it. Three is enough for a sentence that wrapped once, and leaves
// the conversation the rest.
const InputHeight = 3

// View is the inside of an agent window: the conversation above, a box to type
// in below.
//
// It draws from whatever the session's transcript says at that moment and
// keeps no copy, so a reply arriving on the connection's goroutine shows up on
// the next turn of the event loop with nothing to keep in step.
//
//	view := acp.NewView(session)
//	window := ui.NewWindow(session.Agent().Name, view)
type View struct {
	ui.Box

	session *Session

	// input is what has been typed but not sent, as lines.
	input  []string
	cursor int // which line the cursor is on
	column int // and where in it, in runes

	// scroll is how many laid-out lines are hidden above the top of the
	// conversation, and follow says to keep the last line in sight as the
	// agent writes.
	scroll int
	follow bool

	// onTranscript has the focus when false. Two panes and one Tab between
	// them is enough of a focus model for a window with two things in it.
	onInput bool
	focused bool

	// laidOut is the conversation as it was last drawn, kept so that a click
	// and a scroll agree with what is on the screen.
	laidOut int

	// caret is the line of the conversation the cursor is on, and anchor the
	// other end of the selection, or noSelection. Both are indices into the
	// laid-out lines, which are re-wrapped at the current width every frame —
	// so a resize moves them, which is the same bargain the scroll position
	// already makes.
	caret  int
	anchor int

	// dragging says a selection is being made with the mouse held down.
	dragging bool

	// OnCopy is given the text to put on the clipboard. It is a field rather
	// than a parameter because nothing here starts a goroutine that reads it,
	// and because what a clipboard *is* belongs to the editor around this
	// window rather than to the window.
	OnCopy func(text string)

	// Files returns the project's files, for the picker "@" opens and for
	// telling a mention from an email address when the prompt is sent. It is
	// the editor's to supply because the editor knows where the project is;
	// nil means "@" is only ever a character.
	Files func() []Mention

	// picker is the popup that opens over the conversation while a "/" or
	// "@" word is being typed.
	picker picker

	// now is the clock, so that the spinner can be tested without waiting for
	// it. Same reason editor.View and app.App have one.
	now func() time.Time
}

// Span is a run of one colour on a line, re-exported so that a window drawing
// a conversation need not import the syntax package for one type.
type Span = syntax.Span

// NewView returns the inside of a window onto a session.
func NewView(session *Session) *View {
	return &View{
		session: session,
		input:   []string{""},
		follow:  true,
		onInput: true,
		anchor:  noSelection,
		now:     time.Now,
	}
}

// SetClock replaces the view's clock, so a test can say what the spinner shows
// without waiting for a frame to come round.
func (v *View) SetClock(now func() time.Time) { v.now = now }

// LinesForTest returns the conversation as it is currently laid out.
//
// Exported for the same reason app.Tick and app.Render are: the laid-out lines
// are what a caret index means, and a test that wanted to put the cursor on a
// particular line would otherwise have to count them by hand and go stale the
// moment the layout changed.
func (v *View) LinesForTest() []Line { return v.lines() }

// SetCaretForTest puts the transcript's cursor on a line, as clicking it would.
func (v *View) SetCaretForTest(at int) { v.moveCaret(at, false) }

// Session returns the conversation this view is showing.
func (v *View) Session() *Session { return v.session }

// SetFocused gives or takes the keyboard focus, which is what stops every open
// window drawing a cursor.
func (v *View) SetFocused(focused bool) { v.focused = focused }

// Focused reports whether this view has the keyboard focus.
func (v *View) Focused() bool { return v.focused }

// CanFocus reports that this widget takes the keyboard.
func (v *View) CanFocus() bool { return true }

// Input returns what has been typed and not yet sent.
func (v *View) Input() string { return strings.Join(v.input, "\n") }

// SetInput replaces what has been typed, putting the cursor at the end.
func (v *View) SetInput(text string) {
	v.input = strings.Split(text, "\n")
	v.cursor = len(v.input) - 1
	v.column = len([]rune(v.input[v.cursor]))
}

// Send hands the input to the agent and clears the box.
//
// It does nothing with an empty box: Enter on a blank line is somebody
// thinking, not a turn worth spending. A file named with "@" goes along as a
// mention, so the agent is given the file and not merely its name.
func (v *View) Send() {
	text := strings.TrimSpace(v.Input())
	if text == "" {
		return
	}

	v.session.Prompt(text, v.mentions(text)...)
	v.SetInput("")
	v.follow = true
}

// transcriptHeight is how many rows the conversation gets, leaving room for
// the rule and the box below it.
func (v *View) transcriptHeight() int {
	return max(v.Bounds().H-InputHeight-1, 0)
}

// transcriptRect is where the conversation is, in absolute coordinates, for
// hit-testing a click.
func (v *View) transcriptRect() ui.Rect {
	bounds := v.Bounds()
	return ui.Rect{X: bounds.X, Y: bounds.Y, W: bounds.W, H: v.transcriptHeight()}
}

// inputRect is where what you type is, in absolute coordinates.
func (v *View) inputRect() ui.Rect {
	bounds := v.Bounds()
	top := bounds.Y + v.transcriptHeight() + 1
	return ui.Rect{X: bounds.X, Y: top, W: bounds.W, H: max(bounds.Bottom()-top, 0)}
}

// lines lays the conversation out at the current width.
func (v *View) lines() []Line {
	width := max(v.Bounds().W-3, 1) // the margin, the scroll bar, and a space
	return Lines(v.session.Entries(), v.session.AgentName(), width)
}