1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039 | // Package tui is blinker's terminal interface: the conversation, approval
// prompts, and setup. It runs inline rather than full screen: finished
// output goes into the terminal's scrollback, where it can be scrolled,
// searched and copied, and only the live part at the bottom is redrawn.
package tui
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"blinker/internal/agent"
"blinker/internal/config"
"blinker/internal/diffview"
"blinker/internal/history"
"blinker/internal/llm"
"blinker/internal/policy"
"blinker/internal/sandbox"
"blinker/internal/tooldef"
"blinker/internal/tools"
)
var (
accent = lipgloss.Color("12")
plainStyle = lipgloss.NewStyle()
userStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
dimStyle = lipgloss.NewStyle().Foreground(dimOnDark)
thinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Italic(true)
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true)
selStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("11")).Padding(0, 1)
)
// Secondary text is light gray on a dark background and darker gray on a
// light one (see the BackgroundColorMsg case in Update). ANSI 8, the usual
// "dim", is barely readable on many dark themes.
var dimOnDark, dimOnLight = lipgloss.Color("250"), lipgloss.Color("242")
// clockwiseDot is spinner.Dot with its frames reversed: in Dot the gap
// runs down the left side and up the right, which turns counterclockwise.
var clockwiseDot = spinner.Spinner{
Frames: []string{"⣷ ", "⣯ ", "⣟ ", "⡿ ", "⢿ ", "⣻ ", "⣽ ", "⣾ "},
FPS: spinner.Dot.FPS,
}
// Deps are what the interface works with.
type Deps struct {
Config config.Config
ConfigDir string
StateDir string
SB *sandbox.Sandbox
// NewClient builds the LLM client for a config.
NewClient func(config.Config) (*llm.Client, error)
// SetupOnly runs setup and quits.
SetupOnly bool
}
// bridge lets goroutines (the agent, the proxy) reach the program.
type bridge struct {
mu sync.Mutex
p *tea.Program
}
func (b *bridge) send(msg tea.Msg) {
b.mu.Lock()
p := b.p
b.mu.Unlock()
if p != nil {
p.Send(msg)
}
}
// Messages from other goroutines.
type (
eventMsg struct{ ev agent.Event }
questionMsg struct {
ctx context.Context
q tools.Question
reply chan tools.Answer
}
printedMsg struct{}
editorDone struct{ err error }
setupDoneMsg struct{ cfg config.Config }
)
// question is a question waiting for the user.
type question struct {
ctx context.Context
q tools.Question
reply chan tools.Answer
shown time.Time // see questionGrace
sel int
noting bool // typing a note for the model
note textinput.Model
// With Question.Pager: its body and diff, rendered, and the first
// line shown.
pager []string
top int
}
// Model is the interface's state.
type Model struct {
d Deps
// saved is the settings as last saved, which /config and /setup start
// from. d.Config is those in force, until the next start.
saved config.Config
bridge *bridge
width int
height int
client *llm.Client
agent *agent.Agent
tools *tools.Toolbox
input textarea.Model
history []string // earlier inputs, oldest first
histPos int // len(history) when not browsing
draft string // the input being typed before browsing history
running bool
cancel context.CancelFunc
started time.Time
// The reply streaming in. Pointers, since Bubble Tea copies the model
// on every update and a used strings.Builder must not be copied.
reply *strings.Builder
reasoning *strings.Builder
toolName string // the tool running now
toolArgs string
queued []string
showThink bool
question *question
waiting []questionMsg // questions asked while another was up, oldest first
spin spinner.Model
// Output waiting to go into the scrollback, one print at a time so it
// keeps its order.
out []string
printing bool
setup *setupModel
settings *settingsModel
quitting bool
}
// Run runs the interface until the user quits.
func Run(d Deps) error {
b := &bridge{}
m := newModel(d, b)
p := tea.NewProgram(m)
b.mu.Lock()
b.p = p
b.mu.Unlock()
d.SB.SetAskDomain(func(ctx context.Context, host string) sandbox.DomainAnswer {
opts := []tools.Option{
{Label: "Allow for this script", Allow: true},
{Label: "Allow in Unsafe_Bash for the session", Allow: true},
{Label: "Always allow in Unsafe_Bash (adds a host rule to this project's unsafe policy)", Allow: true},
{Label: "Deny"},
}
answers := []sandbox.DomainAnswer{sandbox.DomainAllowOnce, sandbox.DomainAllowSession, sandbox.DomainAllowAlways}
a := ask(ctx, b, tools.Question{
Tool: "network",
Title: fmt.Sprintf("The running Unsafe_Bash script wants to connect to %q", host),
Opts: opts,
})
for i, ans := range answers {
if a.Option == opts[i] {
return ans
}
}
return sandbox.DomainDeny
})
_, err := p.Run()
return err
}
// ask puts a question to the user and waits. If ctx ends first, the answer
// denies.
func ask(ctx context.Context, b *bridge, q tools.Question) tools.Answer {
reply := make(chan tools.Answer, 1)
b.send(questionMsg{ctx: ctx, q: q, reply: reply})
select {
case a := <-reply:
return a
case <-ctx.Done():
return tools.Answer{}
}
}
func newModel(d Deps, b *bridge) Model {
in := newInput()
in.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j", "alt+enter", "shift+enter"))
m := Model{
d: d,
saved: d.Config,
bridge: b,
input: in,
spin: spinner.New(spinner.WithSpinner(clockwiseDot)),
reply: &strings.Builder{},
reasoning: &strings.Builder{},
width: 80,
height: 24,
}
if prompts, err := history.Prompts(d.StateDir, d.SB.Project(), 500); err == nil {
m.history = prompts
}
m.histPos = len(m.history)
m.tools = tools.New(d.SB, func(ctx context.Context, q tools.Question) tools.Answer { return ask(ctx, b, q) },
time.Duration(d.Config.CommandTimeout))
// Init runs the command of the setup this opens.
if d.SetupOnly || !d.Config.Configured() {
m.openSetup(d.Config, nil)
} else if err := m.connect(d.Config); err != nil {
m.openSetup(d.Config, err)
}
return m
}
// openSetup opens setup with cfg, saying why if err is set, and returns
// setup's first command.
func (m *Model) openSetup(cfg config.Config, err error) tea.Cmd {
s := newSetup(cfg, m.d.ConfigDir, m.d.NewClient)
s.width = m.width
if err != nil {
s.errText = err.Error()
}
m.setup = &s
return s.Init()
}
// connect builds the client and agent for a config.
func (m *Model) connect(cfg config.Config) error {
client, err := m.d.NewClient(cfg)
if err != nil {
return err
}
m.d.Config, m.saved = cfg, cfg
m.client = client
m.tools.Model = cfg.Model
m.agent = agent.New(client, m.tools, m.d.ConfigDir, m.d.StateDir, func(ev agent.Event) { m.bridge.send(eventMsg{ev}) })
return nil
}
func (m Model) Init() tea.Cmd {
if m.setup != nil {
return tea.Batch(tea.RequestBackgroundColor, m.setup.Init())
}
return tea.Batch(tea.RequestBackgroundColor, textarea.Blink, m.greeting())
}
func (m *Model) greeting() tea.Cmd {
pol, loaded := m.d.SB.Policy()
count := func(f *policy.File) int { return len(f.Files) + len(f.Network) + len(f.Tools) + len(f.Env) }
where := fmt.Sprintf("%d safe and %d unsafe project rules", count(loaded.Own[policy.SafeMode].File), count(loaded.Own[policy.UnsafeMode].File))
net := "none"
if d := pol.Safe.AllowedHosts(); len(d) > 0 {
net = strings.Join(d, ", ")
}
m.print(fmt.Sprintf("%s %s · %s\n%s",
userStyle.Render("blinker"), dimStyle.Render(Sanitize(m.d.Config.Model)), dimStyle.Render(Sanitize(m.d.SB.Project())),
dimStyle.Render(fmt.Sprintf("sandbox: %s · policy: %s (/policy) · safe network: %s", m.d.SB.Backend(), where, net))))
m.printLint()
return m.flush()
}
// print queues text for the scrollback.
func (m *Model) print(s string) { m.out = append(m.out, s) }
// flush starts printing queued output, if not already printing.
func (m *Model) flush() tea.Cmd {
if m.printing || len(m.out) == 0 {
return nil
}
text := strings.Join(m.out, "\n")
m.out = nil
m.printing = true
return tea.Sequence(tea.Println(text), func() tea.Msg { return printedMsg{} })
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.input.SetWidth(max(msg.Width-2, 10))
if m.setup != nil {
m.setup.width = msg.Width
}
if m.settings != nil {
m.settings.width = msg.Width
}
if q := m.question; q != nil && q.q.Pager {
q.pager = m.pagerLines(q.q)
q.top = min(q.top, m.maxTop(q))
}
case tea.BackgroundColorMsg:
if !msg.IsDark() {
dimStyle = dimStyle.Foreground(dimOnLight)
markSGR = "38;5;242"
diffStyles = diffview.Light
}
case printedMsg:
m.printing = false
return m, m.flush()
case setupCancel:
m.setup = nil
if m.agent == nil || m.d.SetupOnly {
m.quitting = true
return m, tea.Quit
}
m.print(dimStyle.Render("Setup canceled; the settings are unchanged."))
return m, m.flush()
case setupDoneMsg:
m.setup = nil
if m.d.SetupOnly {
m.print(okStyle.Render("Saved the settings in " + m.d.ConfigDir))
m.quitting = true
return m, tea.Sequence(m.flush(), tea.Quit)
}
if err := m.connect(msg.cfg); err != nil {
return m, m.openSetup(msg.cfg, err)
}
return m, tea.Batch(textarea.Blink, m.greeting())
case settingsDone:
m.settings = nil
if msg.saved {
m.saved = msg.cfg
m.print(dimStyle.Render("Saved the settings in " + m.d.ConfigDir + ". They apply the next time blinker starts."))
}
return m, tea.Batch(textarea.Blink, m.flush())
case questionMsg:
if msg.ctx.Err() != nil {
msg.reply <- tools.Answer{}
return m, nil
}
if m.question != nil {
// One question at a time; the newer one waits its turn.
m.waiting = append(m.waiting, msg)
return m, nil
}
m.showQuestion(msg)
// Ring the bell: the user may be looking elsewhere.
return m, tea.Batch(m.flush(), tea.Raw("\a"))
case eventMsg:
m.handleEvent(msg.ev)
if _, done := msg.ev.(agent.TurnDone); done && len(m.queued) > 0 && !m.quitting {
next := m.queued[0]
m.queued = m.queued[1:]
cmds = append(cmds, m.submit(next))
}
case diffEdited:
m.diffEditDone(msg)
return m, tea.Batch(textarea.Blink, m.flush())
case editorDone:
if msg.err != nil {
m.print(errStyle.Render("Editor: " + msg.err.Error()))
}
m.reloadPolicy()
case spinner.TickMsg:
if m.running || m.setup != nil {
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
cmds = append(cmds, cmd)
}
case tea.KeyPressMsg:
if msg.String() == "ctrl+z" {
return m, tea.Suspend
}
if m.setup == nil && m.settings == nil {
return m.key(msg)
}
}
_, isKey := msg.(tea.KeyPressMsg)
switch {
case m.settings != nil:
s, cmd := m.settings.Update(msg)
m.settings = &s
cmds = append(cmds, cmd)
case m.setup != nil:
s, cmd := m.setup.Update(msg)
m.setup = &s
cmds = append(cmds, cmd)
case !isKey:
// Anything else, such as pasted text, goes to the field being
// edited.
var cmd tea.Cmd
if q := m.question; q != nil && q.noting {
q.note, cmd = q.note.Update(msg)
} else {
m.input, cmd = m.input.Update(msg)
}
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.flush())
return m, tea.Batch(cmds...)
}
func (m *Model) printQuestion(q tools.Question) {
var b strings.Builder
b.WriteString(warnStyle.Render("? " + diffview.Visible(q.Title)))
if !q.Pager {
if q.Body != "" {
b.WriteString("\n" + indent(diffview.Visible(q.Body), " │ "))
}
if q.Diff != "" {
b.WriteString("\n" + m.renderDiff(diffview.Visible(q.Diff), 0))
}
if q.About != "" {
b.WriteString("\n" + indent(diffview.Visible(q.About), " "))
}
}
m.print(b.String())
}
// pagerLines renders a question's body, diff and explanation for its
// pager, wrapped to the screen so that each is one line on it.
func (m *Model) pagerLines(q tools.Question) []string {
var parts []string
if q.Body != "" {
parts = append(parts, indent(wrap(diffview.Visible(q.Body), m.width-2), " "))
}
if q.Diff != "" {
parts = append(parts, m.renderDiff(diffview.Visible(q.Diff), 0))
}
if q.About != "" {
parts = append(parts, indent(wrap(diffview.Visible(q.About), m.width-2), " "))
}
return strings.Split(strings.Join(parts, "\n\n"), "\n")
}
// pagerHeight is how many lines of a pager fit above the question's box.
func (m *Model) pagerHeight(q *question) int {
// The box's border, title and hint, its options, the line under the
// pager, and the empty last line. (The spinner's line is hidden while
// a question waits.)
return max(m.height-len(q.q.Opts)-6, 3)
}
func (m *Model) maxTop(q *question) int { return max(len(q.pager)-m.pagerHeight(q), 0) }
func (m *Model) handleEvent(ev agent.Event) {
switch ev := ev.(type) {
case agent.Token:
if ev.Reasoning {
m.reasoning.WriteString(ev.Text)
} else {
m.reply.WriteString(ev.Text)
}
case agent.ReplyDone:
if r := strings.TrimSpace(m.reasoning.String()); r != "" && m.showThink {
m.print(styleLines(thinkStyle, wrap(Sanitize(r), m.width-2)))
}
if r := strings.TrimSpace(m.reply.String()); r != "" {
m.print(markdown(Sanitize(r), m.width) + "\n")
}
m.reply.Reset()
m.reasoning.Reset()
case agent.ToolStart:
m.toolName, m.toolArgs = ev.Name, ev.Args
case agent.ToolDone:
m.toolName = ""
m.printTool(ev.Result)
case agent.Notice:
m.print(styleLines(warnStyle, wrap(Sanitize(ev.Text), m.width)))
case agent.TurnDone:
m.running = false
m.toolName = ""
if m.cancel != nil {
m.cancel()
m.cancel = nil
}
m.dismissQuestion()
if ev.Err != nil {
if errors.Is(ev.Err, context.Canceled) {
m.print(dimStyle.Render("Interrupted."))
} else {
m.print(errStyle.Render("Error: " + Sanitize(ev.Err.Error())))
}
}
}
}
// closeQuestion takes down the question once answered. The turn's clock
// leaves out the time the question waited for the user.
func (m *Model) closeQuestion() {
m.started = m.started.Add(time.Since(m.question.shown))
m.question = nil
// The next question still waiting for its answer takes its place.
for len(m.waiting) > 0 {
next := m.waiting[0]
m.waiting = m.waiting[1:]
if next.ctx.Err() != nil {
next.reply <- tools.Answer{}
continue
}
m.showQuestion(next)
return
}
}
// showQuestion puts a question up, to answer before anything else.
func (m *Model) showQuestion(msg questionMsg) {
ti := textinput.New()
ti.Placeholder = "why (sent to the model)"
ti.SetWidth(min(m.width, 100) - 6)
m.question = &question{ctx: msg.ctx, q: msg.q, reply: msg.reply, note: ti, shown: time.Now()}
if msg.q.Pager {
m.question.pager = m.pagerLines(msg.q)
}
m.printQuestion(msg.q)
}
// dismissQuestion answers a pending question with a denial.
func (m *Model) dismissQuestion() {
if m.question == nil {
return
}
m.question.reply <- tools.Answer{}
m.closeQuestion()
}
// diffStyles are the colors of diffs: Dark until the terminal says its
// background is light.
var diffStyles = diffview.Dark
// renderDiff shows a diff, keeping at most limit lines (0 for all), with
// the diff_command if one is set, or else as diffview.Render does.
func (m *Model) renderDiff(d string, limit int) string {
argv := m.d.Config.DiffCommand
if len(argv) == 0 {
return diffview.Render(d, limit, m.width, diffStyles)
}
out, err := externalDiff(argv, d, limit, m.width)
if err != nil {
return diffview.Render(d, limit, m.width, diffStyles) + "\n" +
warnStyle.Render("diff_command failed: "+Sanitize(err.Error()))
}
return out
}
func (m *Model) printTool(r tools.Result) {
mark := okStyle.Render("●")
if r.Failed {
mark = errStyle.Render("●")
}
var b strings.Builder
b.WriteString(mark + " " + Sanitize(r.Summary))
detail := strings.TrimRight(Sanitize(r.Detail), "\n")
switch {
case r.Failed && detail == "" && r.Output != "":
b.WriteString("\n" + result(clipLines(wrap(Sanitize(r.Output), m.width-4), 8), plainStyle))
case r.Diff && detail != "":
b.WriteString("\n" + m.renderDiff(detail, 40))
case detail != "":
b.WriteString("\n" + result(clipLines(wrap(detail, m.width-4), 12), dimStyle))
}
m.print(b.String())
}
// key handles a key press in the conversation.
func (m Model) key(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.question != nil {
return m.questionKey(msg)
}
switch msg.String() {
case "ctrl+c":
switch {
case m.running:
m.interrupt()
case m.input.Value() != "":
m.input.Reset()
default:
return m, m.quit()
}
return m, m.flush()
case "ctrl+d":
if m.input.Value() == "" {
return m, m.quit()
}
case "esc":
if m.running {
m.interrupt()
return m, m.flush()
}
case "ctrl+t":
m.showThink = !m.showThink
state := "hidden"
if m.showThink {
state = "shown"
}
m.print(dimStyle.Render("Model reasoning will be " + state + "."))
return m, m.flush()
case "enter":
text := strings.TrimSpace(m.input.Value())
if text == "" {
return m, nil
}
m.input.Reset()
m.addHistory(text)
if m.running && !strings.HasPrefix(text, "/") {
m.queued = append(m.queued, text)
m.print(dimStyle.Render("Queued: " + Sanitize(firstLine(text))))
return m, m.flush()
}
cmd := m.submit(text)
return m, tea.Batch(cmd, m.flush())
// Up and down move the cursor between rows, wrapped ones too, and go
// through history from the first row and the last.
case "up":
if m.input.Line() == 0 && m.input.LineInfo().RowOffset == 0 && m.histPos > 0 {
if m.histPos == len(m.history) {
m.draft = m.input.Value()
}
m.histPos--
m.input.SetValue(m.history[m.histPos])
return m, nil
}
case "down":
info := m.input.LineInfo()
if m.input.Line() == m.input.LineCount()-1 && info.RowOffset == info.Height-1 && m.histPos < len(m.history) {
m.histPos++
if m.histPos == len(m.history) {
m.input.SetValue(m.draft)
} else {
m.input.SetValue(m.history[m.histPos])
}
return m, nil
}
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m *Model) addHistory(text string) {
if len(m.history) == 0 || m.history[len(m.history)-1] != text {
m.history = append(m.history, text)
}
m.histPos = len(m.history)
m.draft = ""
}
func (m *Model) interrupt() {
if m.cancel != nil {
m.cancel()
}
m.queued = nil
m.dismissQuestion()
}
func (m *Model) quit() tea.Cmd {
m.interrupt()
m.quitting = true
return tea.Sequence(m.flush(), tea.Quit)
}
// questionGrace is how long after a question appears Enter and the digits
// cannot answer it (Esc still denies): a question can arrive while the
// user types something else, and their next Enter (on Allow, the first
// option) or digit was not meant for it.
const questionGrace = 500 * time.Millisecond
func (m Model) questionKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
q := m.question
early := time.Since(q.shown) < questionGrace
if q.noting {
switch msg.String() {
case "enter":
q.reply <- tools.Answer{Option: q.q.Opts[q.sel], Note: strings.TrimSpace(q.note.Value())}
m.print(dimStyle.Render(" → " + q.q.Opts[q.sel].Label + ": " + Sanitize(q.note.Value())))
m.closeQuestion()
return m, m.flush()
case "esc":
q.noting = false
q.note.Blur()
return m, nil
}
var cmd tea.Cmd
q.note, cmd = q.note.Update(msg)
return m, cmd
}
if q.q.Pager {
page := m.pagerHeight(q) - 1
scroll := map[string]int{"up": -1, "k": -1, "down": 1, "j": 1, "pgup": -page, "b": -page,
"pgdown": page, "space": page, "f": page, "home": -len(q.pager), "g": -len(q.pager),
"end": len(q.pager), "G": len(q.pager)}
if n, ok := scroll[msg.String()]; ok {
q.top = min(max(q.top+n, 0), m.maxTop(q))
return m, nil
}
}
switch msg.String() {
case "up", "k", "shift+tab":
q.sel = (q.sel + len(q.q.Opts) - 1) % len(q.q.Opts)
case "down", "j", "tab":
q.sel = (q.sel + 1) % len(q.q.Opts)
case "esc":
// Deny.
for i, o := range q.q.Opts {
if !o.Allow && !o.Explain {
q.sel = i
}
}
return m.answer()
case "ctrl+c":
if m.running {
m.interrupt()
}
return m, m.flush()
case "enter":
if early {
return m, nil
}
return m.answer()
default:
if s := msg.String(); !early && len(s) == 1 && s[0] >= '1' && s[0] <= '9' {
if i := int(s[0] - '1'); i < len(q.q.Opts) {
q.sel = i
return m.answer()
}
}
}
return m, nil
}
func (m Model) answer() (tea.Model, tea.Cmd) {
q := m.question
opt := q.q.Opts[q.sel]
if opt.Explain {
q.noting = true
return m, q.note.Focus()
}
if opt.Edit {
return m, m.editDiff(q.q.Edits)
}
q.reply <- tools.Answer{Option: opt}
style := okStyle
if !opt.Allow {
style = errStyle
}
m.print(style.Render(" → " + opt.Label))
m.closeQuestion()
return m, m.flush()
}
// submit handles a line of input: a command, or a message for the agent.
func (m *Model) submit(text string) tea.Cmd {
if strings.HasPrefix(text, "/") {
return m.command(text)
}
m.print(userStyle.Render("❯ ") + Sanitize(text))
ctx := m.startTurn()
go m.agent.Turn(ctx, text)
return m.spin.Tick
}
const helpText = `Commands:
/help this help
/clear start a new conversation
/compact free context: remove old tool output, and have the model
summarize the conversation so far
/policy edit this project's access policy in $EDITOR
/policy global edit the global access policy in $EDITOR
/policy REQUEST have the model change a policy as you ask, for you to review
/show PATH... let the model read these paths without asking
/hide PATH... keep the model from these paths, in every mode
/tool edit NAME edit a tool (a new one starts from a template)
/setup choose the LLM backend again
/config edit the settings
/quit quit
Keys: enter sends · alt+enter or ctrl+j adds a line · esc interrupts the agent ·
ctrl+t shows or hides the model's reasoning · ↑/↓ recall earlier input · ctrl+z suspends · ctrl+d quits`
// startTurn marks the agent running, for esc to interrupt through the
// context it returns.
func (m *Model) startTurn() context.Context {
ctx, cancel := context.WithCancel(context.Background())
m.running, m.cancel, m.started = true, cancel, time.Now()
return ctx
}
func (m *Model) command(text string) tea.Cmd {
fields := strings.Fields(text)
switch fields[0] {
case "/clear", "/compact", "/setup", "/config", "/policy":
if m.running {
m.print(errStyle.Render("Wait for the agent to finish, or press esc to interrupt it."))
return nil
}
}
switch fields[0] {
case "/help":
m.print(helpText)
case "/quit", "/exit":
return m.quit()
case "/clear":
m.agent.Reset()
m.print(dimStyle.Render("Started a new conversation."))
case "/compact":
ctx := m.startTurn()
go m.agent.Compact(ctx)
return m.spin.Tick
case "/setup":
return m.openSetup(m.saved, nil)
case "/config":
s := newSettings(m.saved, m.d.ConfigDir, m.width)
m.settings = &s
case "/policy":
request := strings.TrimSpace(strings.TrimPrefix(text, "/policy"))
if request == "" || request == "global" {
paths, err := m.d.SB.Store().Create(m.d.SB.Project(), request == "global")
if err != nil {
m.print(errStyle.Render(Sanitize(err.Error())))
return nil
}
m.print(dimStyle.Render(Sanitize("Editing " + paths[0] + " and " + paths[1] + ".")))
return m.openEditor(paths[:]...)
}
m.print(userStyle.Render("❯ ") + Sanitize(text))
ctx := m.startTurn()
d, client, b := m.d, m.client, m.bridge
go func() { b.send(eventMsg{agent.TurnDone{Err: policyRequest(ctx, d, client, b, request)}}) }()
return m.spin.Tick
case "/show", "/hide":
if len(fields) == 1 {
m.print(errStyle.Render("Usage: " + fields[0] + " PATH..."))
return nil
}
m.showHide(fields[0] == "/show", fields[1:])
case "/tool":
if len(fields) != 3 || fields[1] != "edit" {
m.print(errStyle.Render("Usage: /tool edit NAME"))
return nil
}
path, err := tooldef.Create(m.d.ConfigDir, fields[2])
if err != nil {
m.print(errStyle.Render(Sanitize(err.Error())))
return nil
}
m.print(dimStyle.Render("Editing " + path + `. A policy's tool rules decide whether the model may use it.`))
return m.openEditor(path)
default:
m.print(errStyle.Render("Unknown command " + fields[0] + "; /help lists them."))
}
return nil
}
// printLint shows the policy's rules that can never apply.
func (m *Model) printLint() {
pol, _ := m.d.SB.Policy()
for _, w := range sandbox.Lint(pol) {
m.print(styleLines(warnStyle, wrap("policy: "+Sanitize(w.Text), m.width)))
}
}
// showHide adds a /show or /hide rule for each path, stopping at the
// first that fails.
func (m *Model) showHide(show bool, paths []string) {
store := m.d.SB.Store()
set, verb := store.Hide, "Hid"
if show {
set, verb = store.Show, "Showed"
}
var done []policy.Shown
for _, p := range paths {
r, err := set(m.d.SB.Env(), p)
if err != nil {
m.print(errStyle.Render(Sanitize(p + ": " + err.Error())))
break
}
done = append(done, r)
}
if len(done) == 0 {
return
}
if err := m.d.SB.Reload(); err != nil {
m.print(errStyle.Render("The policy has an error, so the previous one stays in force: " + Sanitize(err.Error())))
return
}
pol, _ := m.d.SB.Policy()
for _, r := range done {
what := "from the model in every mode"
if show {
what = "to the model: readable without asking"
if r.Unsafe == policy.Write {
what += ", writable once approved"
}
}
m.print(okStyle.Render(Sanitize(verb + " " + r.Path + " " + what + ".")))
expanded := policy.Expand(r.Path, m.d.SB.Home(), m.d.SB.Project())
if show && !pol.Safe.Access(expanded).Read {
m.print(warnStyle.Render(Sanitize("But the credential floor still blocks " + r.Path + ": " + pol.Safe.Explain(expanded, false))))
}
}
m.printLint()
}
// openEditor edits files in $VISUAL or $EDITOR, then reloads the policy.
func (m *Model) openEditor(paths ...string) tea.Cmd {
return openEditor(paths, func(err error) tea.Msg { return editorDone{err} })
}
// openEditor edits files in $VISUAL or $EDITOR, then sends done's message.
func openEditor(paths []string, done func(error) tea.Msg) tea.Cmd {
editor := os.Getenv("VISUAL")
if editor == "" {
editor = os.Getenv("EDITOR")
}
args := strings.Fields(editor)
if len(args) == 0 {
args = []string{"vi"}
}
cmd := exec.Command(args[0], append(args[1:], paths...)...)
return tea.ExecProcess(cmd, done)
}
func (m *Model) reloadPolicy() {
if err := m.d.SB.Reload(); err != nil {
m.print(errStyle.Render("The policy has an error, so the previous one stays in force: " + Sanitize(err.Error())))
return
}
m.print(okStyle.Render("Loaded the policy."))
m.printLint()
}
func (m Model) View() tea.View {
if m.quitting {
return tea.NewView("")
}
if m.setup != nil {
return tea.NewView(m.setup.View())
}
if m.settings != nil {
return tea.NewView(m.settings.View())
}
var b strings.Builder
if m.running {
if r := strings.TrimSpace(m.reasoning.String()); r != "" && m.reply.Len() == 0 && m.toolName == "" {
b.WriteString(styleLines(thinkStyle, tailLines(wrap(Sanitize(r), m.width-2), 3)) + "\n")
}
if r := strings.TrimSpace(m.reply.String()); r != "" {
b.WriteString(tailLines(markdown(Sanitize(r), m.width), max(m.height-12, 3)) + "\n")
}
}
// While a question waits for the user, nothing runs: the question
// stands in for the status line.
if m.running && m.question == nil {
status := "Thinking"
if m.toolName != "" {
status = "Running " + m.toolName
if s := toolBrief(m.toolArgs); s != "" {
status += ": " + s
}
}
elapsed := time.Since(m.started).Round(time.Second)
b.WriteString(m.spin.View() + " " + truncate(Sanitize(status), m.width-20) + dimStyle.Render(fmt.Sprintf(" %s · esc to interrupt", elapsed)) + "\n")
}
if q := m.question; q != nil {
if q.q.Pager {
end := min(q.top+m.pagerHeight(q), len(q.pager))
for _, line := range q.pager[q.top:end] {
b.WriteString(line + "\n")
}
rule := fmt.Sprintf("── lines %d-%d of %d ", q.top+1, end, len(q.pager)) + strings.Repeat("─", m.width)
b.WriteString(dimStyle.Render(ansi.Truncate(rule, m.width, "")) + "\n")
}
var qb strings.Builder
qb.WriteString(warnStyle.Render(diffview.Visible(q.q.Title)) + "\n")
for i, o := range q.q.Opts {
line := fmt.Sprintf("%d. %s", i+1, o.Label)
qb.WriteString(menuRow(line, i == q.sel))
}
switch {
case q.noting:
qb.WriteString(q.note.View() + "\n")
qb.WriteString(dimStyle.Render("enter sends · esc goes back"))
case q.q.Pager:
qb.WriteString(dimStyle.Render("↑/↓ pgup/pgdn scroll · tab and enter, or a number · esc denies"))
default:
qb.WriteString(dimStyle.Render("↑/↓ and enter, or a number · esc denies"))
}
b.WriteString(boxStyle.Width(min(m.width, 100)).Render(qb.String()) + "\n")
return tea.NewView(b.String())
}
above := strings.Count(b.String(), "\n")
in, x, y := m.inputView()
b.WriteString(in + "\n")
b.WriteString(m.statusLine())
v := tea.NewView(b.String())
if c := m.input.Cursor(); c != nil {
c.Position.X, c.Position.Y = x, y+above
v.Cursor = c
}
return v
}
func (m Model) statusLine() string {
parts := []string{Sanitize(m.d.Config.Model)}
if n := len(m.queued); n > 0 {
parts = append(parts, fmt.Sprintf("%d queued", n))
}
if m.showThink {
parts = append(parts, "reasoning shown")
}
parts = append(parts, "/help")
return dimStyle.Render(truncate(strings.Join(parts, " · "), m.width))
}
// toolBrief describes a running tool call from its arguments.
func toolBrief(args string) string {
var a struct {
Path string `json:"path"`
Script string `json:"script"`
}
if err := json.Unmarshal([]byte(args), &a); err != nil {
return ""
}
if a.Path != "" {
return a.Path
}
return firstLine(a.Script)
}
|