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

view.go · 176 lines · 6.0 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 5h ago1package acp
2
3import (
4 "strings"
5 "time"
6
7 "codeberg.org/turbo-editors/turbo-core/syntax"
8 "codeberg.org/turbo-editors/turbo-core/ui"
9)
10
11// InputHeight is how many rows the box you type into takes, not counting the
12// rule above it. Three is enough for a sentence that wrapped once, and leaves
13// the conversation the rest.
14const InputHeight = 3
15
16// View is the inside of an agent window: the conversation above, a box to type
17// in below.
18//
19// It draws from whatever the session's transcript says at that moment and
20// keeps no copy, so a reply arriving on the connection's goroutine shows up on
21// the next turn of the event loop with nothing to keep in step.
22//
23// view := acp.NewView(session)
24// window := ui.NewWindow(session.Agent().Name, view)
25type View struct {
26 ui.Box
27
28 session *Session
29
30 // input is what has been typed but not sent, as lines.
31 input []string
32 cursor int // which line the cursor is on
33 column int // and where in it, in runes
34
35 // scroll is how many laid-out lines are hidden above the top of the
36 // conversation, and follow says to keep the last line in sight as the
37 // agent writes.
38 scroll int
39 follow bool
40
41 // onTranscript has the focus when false. Two panes and one Tab between
42 // them is enough of a focus model for a window with two things in it.
43 onInput bool
44 focused bool
45
46 // laidOut is the conversation as it was last drawn, kept so that a click
47 // and a scroll agree with what is on the screen.
48 laidOut int
49
50 // caret is the line of the conversation the cursor is on, and anchor the
51 // other end of the selection, or noSelection. Both are indices into the
52 // laid-out lines, which are re-wrapped at the current width every frame —
53 // so a resize moves them, which is the same bargain the scroll position
54 // already makes.
55 caret int
56 anchor int
57
58 // dragging says a selection is being made with the mouse held down.
59 dragging bool
60
61 // OnCopy is given the text to put on the clipboard. It is a field rather
62 // than a parameter because nothing here starts a goroutine that reads it,
63 // and because what a clipboard *is* belongs to the editor around this
64 // window rather than to the window.
65 OnCopy func(text string)
66
67 // Files returns the project's files, for the picker "@" opens and for
68 // telling a mention from an email address when the prompt is sent. It is
69 // the editor's to supply because the editor knows where the project is;
70 // nil means "@" is only ever a character.
71 Files func() []Mention
72
73 // picker is the popup that opens over the conversation while a "/" or
74 // "@" word is being typed.
75 picker picker
76
77 // now is the clock, so that the spinner can be tested without waiting for
78 // it. Same reason editor.View and app.App have one.
79 now func() time.Time
80}
81
82// Span is a run of one colour on a line, re-exported so that a window drawing
83// a conversation need not import the syntax package for one type.
84type Span = syntax.Span
85
86// NewView returns the inside of a window onto a session.
87func NewView(session *Session) *View {
88 return &View{
89 session: session,
90 input: []string{""},
91 follow: true,
92 onInput: true,
93 anchor: noSelection,
94 now: time.Now,
95 }
96}
97
98// SetClock replaces the view's clock, so a test can say what the spinner shows
99// without waiting for a frame to come round.
100func (v *View) SetClock(now func() time.Time) { v.now = now }
101
102// LinesForTest returns the conversation as it is currently laid out.
103//
104// Exported for the same reason app.Tick and app.Render are: the laid-out lines
105// are what a caret index means, and a test that wanted to put the cursor on a
106// particular line would otherwise have to count them by hand and go stale the
107// moment the layout changed.
108func (v *View) LinesForTest() []Line { return v.lines() }
109
110// SetCaretForTest puts the transcript's cursor on a line, as clicking it would.
111func (v *View) SetCaretForTest(at int) { v.moveCaret(at, false) }
112
113// Session returns the conversation this view is showing.
114func (v *View) Session() *Session { return v.session }
115
116// SetFocused gives or takes the keyboard focus, which is what stops every open
117// window drawing a cursor.
118func (v *View) SetFocused(focused bool) { v.focused = focused }
119
120// Focused reports whether this view has the keyboard focus.
121func (v *View) Focused() bool { return v.focused }
122
123// CanFocus reports that this widget takes the keyboard.
124func (v *View) CanFocus() bool { return true }
125
126// Input returns what has been typed and not yet sent.
127func (v *View) Input() string { return strings.Join(v.input, "\n") }
128
129// SetInput replaces what has been typed, putting the cursor at the end.
130func (v *View) SetInput(text string) {
131 v.input = strings.Split(text, "\n")
132 v.cursor = len(v.input) - 1
133 v.column = len([]rune(v.input[v.cursor]))
134}
135
136// Send hands the input to the agent and clears the box.
137//
138// It does nothing with an empty box: Enter on a blank line is somebody
139// thinking, not a turn worth spending. A file named with "@" goes along as a
140// mention, so the agent is given the file and not merely its name.
141func (v *View) Send() {
142 text := strings.TrimSpace(v.Input())
143 if text == "" {
144 return
145 }
146
147 v.session.Prompt(text, v.mentions(text)...)
148 v.SetInput("")
149 v.follow = true
150}
151
152// transcriptHeight is how many rows the conversation gets, leaving room for
153// the rule and the box below it.
154func (v *View) transcriptHeight() int {
155 return max(v.Bounds().H-InputHeight-1, 0)
156}
157
158// transcriptRect is where the conversation is, in absolute coordinates, for
159// hit-testing a click.
160func (v *View) transcriptRect() ui.Rect {
161 bounds := v.Bounds()
162 return ui.Rect{X: bounds.X, Y: bounds.Y, W: bounds.W, H: v.transcriptHeight()}
163}
164
165// inputRect is where what you type is, in absolute coordinates.
166func (v *View) inputRect() ui.Rect {
167 bounds := v.Bounds()
168 top := bounds.Y + v.transcriptHeight() + 1
169 return ui.Rect{X: bounds.X, Y: top, W: bounds.W, H: max(bounds.Bottom()-top, 0)}
170}
171
172// lines lays the conversation out at the current width.
173func (v *View) lines() []Line {
174 width := max(v.Bounds().W-3, 1) // the margin, the scroll bar, and a space
175 return Lines(v.session.Entries(), v.session.AgentName(), width)
176}