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) }