From 4d4c1708e6a62d50bcc8d18a429e7bd9be4c084d Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 8 Apr 2026 23:42:47 +0200 Subject: [PATCH] main: replace bubbletea TUI with goq readline + splitInput Drop charmbracelet/bubbletea, bubbles, and lipgloss. Replace the full-screen Elm-architecture TUI with goq's readline-based approach: - go-multiline-ny for readline input with history and bracketed paste - splitInput persistent bottom band during agent turns (pulse separator, queue display, Ctrl-U/W/Backspace, /queued pop/clear) - Signal handling: Ctrl-C cancels the current turn; double-Ctrl-C exits - Plain text agent output (no ANSI formatting yet) - All existing slash commands preserved; /queued integrated New files: splitinput.go, splitinput_windows.go, bracketed_paste.go, rerender_input.go, queued_commands.go, signals.go, signals_unix.go, signals_windows.go Co-Authored-By: Claude Sonnet 4.6 --- bracketed_paste.go | 152 +++++ go.mod | 41 +- go.sum | 53 +- main.go | 1467 ++++++++++++++++------------------------- queued_commands.go | 84 +++ rerender_input.go | 73 ++ signals.go | 45 ++ signals_unix.go | 10 + signals_windows.go | 9 + splitinput.go | 900 +++++++++++++++++++++++++ splitinput_windows.go | 20 + 11 files changed, 1883 insertions(+), 971 deletions(-) create mode 100644 bracketed_paste.go create mode 100644 queued_commands.go create mode 100644 rerender_input.go create mode 100644 signals.go create mode 100644 signals_unix.go create mode 100644 signals_windows.go create mode 100644 splitinput.go create mode 100644 splitinput_windows.go diff --git a/bracketed_paste.go b/bracketed_paste.go new file mode 100644 index 0000000..95aadc6 --- /dev/null +++ b/bracketed_paste.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "strings" + + multiline "github.com/hymkor/go-multiline-ny" + "github.com/mattn/go-tty" + readline "github.com/nyaosorg/go-readline-ny" + "github.com/nyaosorg/go-readline-ny/keys" + ttyadapter "github.com/nyaosorg/go-ttyadapter" + "github.com/nyaosorg/go-ttyadapter/tty8" +) + +const ( + bracketedPasteEnable = "\x1b[?2004h" + bracketedPasteDisable = "\x1b[?2004l" + BracketedPasteStart = "\x1b[200~" + BracketedPasteEnd = "\x1b[201~" + maxBracketedPasteBytes = 10 << 20 +) + +const GoqPasteNewlineKey = keys.Code("\x1b[goq-paste-newline]") + +func setupBracketedPaste(t *tty.TTY, ed *multiline.Editor) (cleanup func(), err error) { + fmt.Fprint(t.Output(), bracketedPasteEnable) + cleanup = func() { fmt.Fprint(t.Output(), bracketedPasteDisable) } + ed.SetTty(NewBracketedPasteTty(&tty8.Tty{TTY: t})) + if err = ed.BindKey(GoqPasteNewlineKey, readline.AnonymousCommand(ed.NewLine)); err != nil { + cleanup() + return nil, err + } + return cleanup, nil +} + +type adaptedTty ttyadapter.Tty + +type BracketedPasteTty struct { + adaptedTty + buf []string + pendingErr error +} + +var _ ttyadapter.Tty = (*BracketedPasteTty)(nil) + +func NewBracketedPasteTty(inner ttyadapter.Tty) *BracketedPasteTty { + return &BracketedPasteTty{adaptedTty: inner} +} + +func (t *BracketedPasteTty) GetKey() (string, error) { + if t.pendingErr != nil && len(t.buf) == 0 { + err := t.pendingErr + t.pendingErr = nil + return "", err + } + if len(t.buf) > 0 { + k := t.buf[0] + t.buf = t.buf[1:] + return k, nil + } + + key, err := t.adaptedTty.GetKey() + if err != nil { + return "", err + } + + if !strings.Contains(key, BracketedPasteStart) { + return key, nil + } + + raw := key + for !strings.Contains(raw, BracketedPasteEnd) { + next, nextErr := t.adaptedTty.GetKey() + if nextErr != nil { + t.buf = append(t.buf, raw) + t.pendingErr = nextErr + break + } + if len(raw)+len(next) > maxBracketedPasteBytes { + return raw, nil + } + raw += next + } + + if t.pendingErr == nil { + t.buf = append(t.buf, TokenizeBracketedPaste(raw)...) + } + if len(t.buf) > 0 { + k := t.buf[0] + t.buf = t.buf[1:] + return k, nil + } + return t.adaptedTty.GetKey() +} + +func TokenizeBracketedPaste(raw string) []string { + var out []string + for { + start := strings.Index(raw, BracketedPasteStart) + if start < 0 { + if raw != "" { + out = append(out, raw) + } + return out + } + if start > 0 { + out = append(out, raw[:start]) + } + raw = raw[start+len(BracketedPasteStart):] + + end := strings.Index(raw, BracketedPasteEnd) + if end < 0 { + out = append(out, BracketedPasteStart+raw) + return out + } + + content := raw[:end] + out = append(out, tokenizePasteContent(content)...) + raw = raw[end+len(BracketedPasteEnd):] + } +} + +func tokenizePasteContent(s string) []string { + var out []string + var buf strings.Builder + + flush := func() { + if buf.Len() == 0 { + return + } + out = append(out, buf.String()) + buf.Reset() + } + + for i := 0; i < len(s); i++ { + switch s[i] { + case '\r': + flush() + out = append(out, string(GoqPasteNewlineKey)) + if i+1 < len(s) && s[i+1] == '\n' { + i++ + } + case '\n': + flush() + out = append(out, string(GoqPasteNewlineKey)) + default: + buf.WriteByte(s[i]) + } + } + flush() + return out +} diff --git a/go.mod b/go.mod index e30dea8..d17fd44 100644 --- a/go.mod +++ b/go.mod @@ -5,34 +5,19 @@ go 1.25.6 require ( 9fans.net/go v0.0.7 anvillm v0.0.0-00010101000000-000000000000 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 + github.com/hymkor/go-multiline-ny v0.22.4 + github.com/mattn/go-runewidth v0.0.19 + github.com/mattn/go-tty v0.0.7 + github.com/nyaosorg/go-readline-ny v1.14.1 + github.com/nyaosorg/go-ttyadapter v0.3.0 +) + +require ( + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace anvillm => ../anvillm/main - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum index 1cdbbed..97b32b8 100644 --- a/go.sum +++ b/go.sum @@ -2,62 +2,28 @@ 9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/hymkor/go-multiline-ny v0.22.4 h1:Ag2rkBpDnr3jp+AHe1CuXSOQ4AQ8D0+gBNVf+BAX+/0= +github.com/hymkor/go-multiline-ny v0.22.4/go.mod h1:v2lqQooHVAO53WICAIbgTn74bQtzsg4tFn29d+7JPwY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= +github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= +github.com/nyaosorg/go-readline-ny v1.14.1 h1:bWyXpR6jRaCXysx4bnioxk36+YjQ6dypHKMjHnzIXdk= +github.com/nyaosorg/go-readline-ny v1.14.1/go.mod h1:/BDf3/H/AScnvey4LoDws1bjTZDB76EE7uKnW2apoKU= +github.com/nyaosorg/go-ttyadapter v0.3.0 h1:/Y7+rGJ0LEcs+AExevwNmND2VJvvpBmgbMuCbntKq3c= +github.com/nyaosorg/go-ttyadapter v0.3.0/go.mod h1:w6ySb/Y8rpr0uIju4vN/TMRHC/6ayabORHmEVs6d/qE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= -golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= -golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= @@ -75,13 +41,10 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= diff --git a/main.go b/main.go index bfdd0a8..a6cea68 100644 --- a/main.go +++ b/main.go @@ -5,22 +5,21 @@ import ( "context" "crypto/rand" "encoding/json" + "errors" "flag" "fmt" + "io" "io/fs" - "log" "os" "os/exec" "path/filepath" - "strconv" "strings" + "sync/atomic" "time" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + multiline "github.com/hymkor/go-multiline-ny" + gotty "github.com/mattn/go-tty" + readline "github.com/nyaosorg/go-readline-ny" "ollie/agent" "ollie/backend" @@ -49,8 +48,6 @@ Tool call examples: Run named tool: {"tool": "discover_skill.sh", "args": ["keyword"]} Pipeline: {"pipe": [{"code": "cat file.txt"}, {"code": "grep foo"}]}` -// buildFirstPrompt augments the initial user message with a directory listing -// and README.md contents so the agent starts with real context instead of guessing. func buildFirstPrompt(input string) string { cwd, err := os.Getwd() if err != nil { @@ -60,16 +57,13 @@ func buildFirstPrompt(input string) string { var sb strings.Builder sb.WriteString(input) - const maxListingBytes = 16 * 1024 // 16 KB - const maxReadmeBytes = 8 * 1024 // 8 KB + const maxListingBytes = 16 * 1024 + const maxReadmeBytes = 8 * 1024 - // Append file listing: prefer git ls-files (respects .gitignore), fall back - // to a simple recursive walk skipping hidden files and directories. lsOut, err := exec.Command("git", "-C", cwd, "ls-files").Output() if err == nil && len(lsOut) > 0 { if len(lsOut) > maxListingBytes { lsOut = lsOut[:maxListingBytes] - // trim to last newline to avoid a partial path if i := bytes.LastIndexByte(lsOut, '\n'); i >= 0 { lsOut = lsOut[:i+1] } @@ -113,7 +107,6 @@ func buildFirstPrompt(input string) string { } } - // Append README.md if present, capped to avoid blowing context. readmeData, err := os.ReadFile(cwd + "/README.md") if err == nil && len(readmeData) > 0 { if len(readmeData) > maxReadmeBytes { @@ -137,10 +130,9 @@ func systemPrompt(allTools []backend.Tool) string { for i, t := range allTools { names[i] = t.Name } - s := systemPromptBase + "\n\nWorking directory: " + cwd + + return systemPromptBase + "\n\nWorking directory: " + cwd + "\nCurrent time: " + now + "\nAvailable tools: " + strings.Join(names, ", ") - return s } var builtinTools = []backend.Tool{ @@ -222,129 +214,20 @@ var builtinTools = []backend.Tool{ }, } -// agentState represents what the agent is currently doing. -type agentState int - -const ( - agentIdle agentState = iota - agentThinking - agentRunningTool - agentRetrying - agentConfirming - agentStalled -) - -// resolveBackendName returns a short human-readable backend label derived -// from OLLIE_BACKEND and (for openai-compatible backends) OLLIE_OPENAI_URL. -// Known provider hostnames are mapped to friendly names so that e.g. -// openrouter.ai shows up as "openrouter" rather than "openai". -func resolveBackendName() string { - which := os.Getenv("OLLIE_BACKEND") - if which == "" { - which = "ollama" - } - if which != "openai" { - return which - } - // For openai-compatible backends, try to identify the provider from the URL. - url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL")) - switch { - case strings.Contains(url, "openrouter"): - return "openrouter" - case strings.Contains(url, "together"): - return "together" - case strings.Contains(url, "groq"): - return "groq" - case strings.Contains(url, "mistral"): - return "mistral" - case strings.Contains(url, "anthropic"): - return "anthropic" - case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): - return "local" - case url == "": - return "openai" - default: - return "openai" - } -} - -// NOTE: buf and display lines use plain strings; bubbletea copies the model -// by value on every Update(), so strings.Builder and other copy-sensitive -// types will panic. -type model struct { - textarea textarea.Model - viewport viewport.Model - session *agent.Session - loopcfg agent.Config - display []string - buf string // live streaming assistant text - hooks map[string]string - ready bool - agentCh chan tea.Msg - cancel context.CancelFunc - doneCh chan struct{} - modelName string - backendName string // e.g. "ollama", "openrouter", "openai" - agentName string // active agent config name, e.g. "default" - agentsDir string // path to ~/.config/ollie/agents/ - mcpExec *tools.Executor // current agent's MCP executor; closed on agent switch - builtinExec *execpkg.Executor - confirmPtr *agent.ConfirmFn // indirection so startAgent can set it per-run - ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas) - invalidateCaches func() // clears tool-call dedup caches; set from agentEnv - sessionsDir string - sessionID string - - quitPending bool // whether a second Ctrl+C should quit - lastCtrlC time.Time // timestamp of last Ctrl+C press - // status bar state - state agentState - currentTool string - retrySecsLeft int - lastUsage backend.Usage - ctxStats agent.ContextStats - confirmCh chan bool // non-nil when waiting for user confirmation -} - -type agentMsg struct { - role string - content string - name string - done bool - usage backend.Usage - ctxStats agent.ContextStats - confirmCh chan bool // set when role=="confirm" -} - -type confirmMsg struct { - approved bool -} - -// statusBarStyle is a full-width reversed bar. -var statusBarStyle = lipgloss.NewStyle(). - Reverse(true). - Padding(0, 1) - -// timeoutMsg is sent when the Ctrl+C double-press window expires -type timeoutMsg struct{} - // agentEnv holds the runtime state derived from an agent config file. type agentEnv struct { - mcpExec *tools.Executor // kept so it can be closed on agent switch + mcpExec *tools.Executor tools []backend.Tool exec agent.ToolExecutor - confirm *agent.ConfirmFn // pointer filled in by startAgent per-run + confirm *agent.ConfirmFn hooks map[string]string systemPrompt string genParams backend.GenerationParams ctxOverhead int - messages []string // startup / status messages to display - invalidateCaches func() // clears per-session tool-call dedup caches on compact/clear + messages []string + invalidateCaches func() } -// buildAgentEnv constructs the runtime environment for a given agent config. -// cfg may be nil (no agent file found), in which case only the builtin tool -// is available and no hooks or agent prompt are set. func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { var messages []string mcpExec := tools.NewExecutor() @@ -414,15 +297,10 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { builtinNames[t.Name] = struct{}{} } - var confirmFn agent.ConfirmFn // filled in by startAgent + var confirmFn agent.ConfirmFn confirmPtr := &confirmFn - // fileReadCache tracks which files have been read this session (by path). - // Used to guard file_write against blind overwrites and to block redundant re-reads. - // Cleared on /compact and /clear. fileReadCache := make(map[string]bool) - - // toolCallSeen tracks all non-file tool calls by exact (name, args) to warn on repeats. toolCallSeen := make(map[string]bool) rawExec := func(ctx context.Context, name string, args json.RawMessage) (string, error) { @@ -468,9 +346,7 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { if a.Path != "" { _, statErr := os.Stat(a.Path) fileExists := !os.IsNotExist(statErr) - if fileExists { - // Existing files: whole-file overwrites are forbidden. if a.StartLine == 0 && a.EndLine == 0 { return "", fmt.Errorf("file_write: whole-file overwrite of existing file %s is not allowed; use start_line/end_line to write specific line ranges", a.Path) } @@ -478,7 +354,6 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { return "", fmt.Errorf("file_write: %s has not been read this session; read it first", a.Path) } } - // New files: whole-file write is the only option and is safe. } result, err := dispatchBuiltinExec(ctx, name, builtinExec, cfn, args) if err != nil { @@ -496,7 +371,6 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { } execFn := func(ctx context.Context, name string, args json.RawMessage) (string, error) { - // Hard-error on duplicate tool calls (file_read/file_write handled above). if name != "file_read" && name != "file_write" { key := name + "\x00" + string(args) if toolCallSeen[key] { @@ -528,40 +402,558 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { } } -// agentConfigPath returns the path for a named agent config, falling back to -// the legacy config.json if the agents/ directory file does not exist. func agentConfigPath(agentsDir, name string) string { p := agentsDir + "/" + name + ".json" if _, err := os.Stat(p); err == nil { return p } - // Legacy fallback: only for "default" if name == "default" { home, _ := os.UserHomeDir() return home + "/.config/ollie/config.json" } - return p // will produce a load error, which is the right behaviour + return p } -// newSessionID returns a unique session identifier embedding the current timestamp. func newSessionID() string { b := make([]byte, 3) rand.Read(b) //nolint:errcheck return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b) } -// saveSession persists the current session to disk; silently no-ops if no -// active session or session ID is set. -func (m *model) saveSession() { - if m.session == nil || m.sessionID == "" || m.sessionsDir == "" { +// resolveBackendName returns a short human-readable backend label. +func resolveBackendName() string { + which := os.Getenv("OLLIE_BACKEND") + if which == "" { + which = "ollama" + } + if which != "openai" { + return which + } + url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL")) + switch { + case strings.Contains(url, "openrouter"): + return "openrouter" + case strings.Contains(url, "together"): + return "together" + case strings.Contains(url, "groq"): + return "groq" + case strings.Contains(url, "mistral"): + return "mistral" + case strings.Contains(url, "anthropic"): + return "anthropic" + case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): + return "local" + default: + return "openai" + } +} + +// actionHandle holds the cancel function for the current agent turn. +type actionHandle struct { + cancel context.CancelCauseFunc +} + +// appState is the top-level runtime state, replacing the old bubbletea model. +type appState struct { + session *agent.Session + loopcfg agent.Config + hooks map[string]string + modelName string + backendName string + agentName string + agentsDir string + sessionsDir string + sessionID string + mcpExec *tools.Executor + builtinExec *execpkg.Executor + confirmPtr *agent.ConfirmFn + ctxOverhead int + invalidateCaches func() + split *splitInput + currentAction atomic.Pointer[actionHandle] + history recentHistory +} + +// recentHistory implements readline.IHistory for the multiline editor. +type recentHistory struct { + entries []string +} + +var _ readline.IHistory = (*recentHistory)(nil) + +func (h *recentHistory) Len() int { return len(h.entries) } +func (h *recentHistory) At(i int) string { + if i >= 0 && i < len(h.entries) { + return h.entries[i] + } + return "" +} + +func (s *appState) prompt() string { + return fmt.Sprintf("[%s :: %s] ", s.backendName, s.agentName) +} + +func (s *appState) saveSession() { + if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { return } - path := m.sessionsDir + "/" + m.sessionID + ".json" - if err := m.session.SaveTo(path, m.sessionID, m.agentName); err != nil { - m.appendDisplay("session save: " + err.Error()) + path := s.sessionsDir + "/" + s.sessionID + ".json" + if err := s.session.SaveTo(path, s.sessionID, s.agentName); err != nil { + fmt.Fprintln(os.Stderr, "session save:", err) } } +func (s *appState) getActionCancel() context.CancelCauseFunc { + if a := s.currentAction.Load(); a != nil { + return a.cancel + } + return nil +} + +func (s *appState) runInteractiveTTY(ctx context.Context) { + t, err := gotty.Open() + if err != nil { + fmt.Fprintln(os.Stderr, "tty:", err) + return + } + defer t.Close() + + var ed multiline.Editor + restorePaste, err := setupBracketedPaste(t, &ed) + if err != nil { + fmt.Fprintln(os.Stderr, "bracketed paste:", err) + } else { + defer restorePaste() + } + + ed.SetPrompt(func(w io.Writer, lnum int) (int, error) { + if lnum == 0 { + return fmt.Fprint(w, s.prompt()) + } + return fmt.Fprint(w, "... ") + }) + + ed.SubmitOnEnterWhen(func(lines []string, _ int) bool { + if len(lines) <= 1 { + return true + } + return !strings.HasSuffix(strings.TrimSpace(lines[len(lines)-1]), "\\") + }) + + ed.SetHistory(&s.history) + ed.SetHistoryCycling(true) + + s.split = newSplitInput(t, t.Output(), s.prompt(), nil) + + appCtx, appCancel := context.WithCancelCause(ctx) + startSignalWatcher(appCancel, s.getActionCancel, os.Stderr) + + var lastCtrlC time.Time + firstRead := true + + for appCtx.Err() == nil { + if firstRead { + firstRead = false + if _, h, err := t.Size(); err == nil && h > 0 { + clearScreenAndMoveToBottom(t.Output(), h) + } + } + + lines, err := ed.Read(appCtx) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + errs := err.Error() + if errs == "interrupted" || errs == "^C" { + now := time.Now() + if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) <= ctrlCExitWindow { + break + } + lastCtrlC = now + fmt.Fprint(os.Stderr, "^C (press Ctrl-C again to exit)\n") + continue + } + fmt.Fprintln(os.Stderr, "input error:", err) + break + } + + input := strings.Join(lines, "\n") + lastCtrlC = time.Time{} + + if strings.TrimSpace(input) == "" { + continue + } + + s.history.entries = append(s.history.entries, input) + + if len(lines) == 1 { + if w, _, err := t.Size(); err == nil { + prompt := s.prompt() + if shouldRerenderSubmittedSingleLine(prompt, input, w) { + rerenderSubmittedSingleLine(t.Output(), prompt, input) + } + } + } + + s.processInputWithSplit(appCtx, input, &ed) + } +} + +func (s *appState) processInputWithSplit(ctx context.Context, input string, ed *multiline.Editor) { + if s.split == nil { + s.processInput(ctx, input, os.Stdout) + return + } + + s.split.SetPrompt(s.prompt()) + wrapper := s.split.Enter() + out := io.Writer(os.Stdout) + if wrapper != nil { + out = wrapper + } + + s.processInput(ctx, input, out) + + for ctx.Err() == nil { + q, ok := s.split.PopQueue() + if !ok { + break + } + s.split.SetPrompt(s.prompt()) + s.split.EchoQueuedInput(q) + s.history.entries = append(s.history.entries, q) + s.processInput(ctx, q, out) + } + + _, pending := s.split.Exit() + if pending != "" { + ed.SetDefault([]string{pending}) + } +} + +func (s *appState) processInput(ctx context.Context, input string, out io.Writer) { + if s.handleCommand(ctx, input, out) { + return + } + + if hook := s.hooks["userPromptSubmit"]; hook != "" { + exec.Command("sh", "-c", hook).Run() //nolint:errcheck + } + + if s.session == nil { + s.session = agent.NewSessionWithConfig(buildFirstPrompt(input), agent.ContextConfig{ + FixedOverheadChars: s.ctxOverhead, + }) + } else { + s.session.AppendUserMessage(input) + } + + actCtx, actCancel := context.WithCancelCause(ctx) + handle := &actionHandle{cancel: actCancel} + s.currentAction.Store(handle) + + s.loopcfg.Output = makeOutputFn(out) + *s.confirmPtr = nil // auto-approve all confirmations for now + + if hook := s.hooks["agentSpawn"]; hook != "" { + exec.Command("sh", "-c", hook).Run() //nolint:errcheck + } + + err := agent.Run(actCtx, s.loopcfg, s.session) + actCancel(nil) + s.currentAction.CompareAndSwap(handle, nil) + + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrInterrupted) { + fmt.Fprintf(out, "error: %v\n", err) + s.session.Rollback() + } + + fmt.Fprintln(out) + + if hook := s.hooks["stop"]; hook != "" { + exec.Command("sh", "-c", hook).Run() //nolint:errcheck + } + + s.saveSession() +} + +func makeOutputFn(out io.Writer) agent.OutputFn { + return func(em agent.OutputMsg) { + switch em.Role { + case "assistant": + fmt.Fprint(out, em.Content) + case "call": + args := squashWhitespace(em.Content) + if len(args) > 500 { + args = args[:500] + "..." + } + fmt.Fprintf(out, "-> %s(%s)\n", em.Name, args) + case "tool": + s := strings.TrimRight(em.Content, "\n") + if len(s) > 500 { + s = s[:500] + "..." + } + fmt.Fprintf(out, "= %s\n", s) + case "retry": + fmt.Fprintf(out, "retrying in %ss...\n", em.Content) + case "error": + fmt.Fprintf(out, "error: %s\n", em.Content) + case "stalled": + fmt.Fprintln(out, "agent stalled") + } + } +} + +func (s *appState) handleCommand(ctx context.Context, input string, out io.Writer) bool { + if strings.HasPrefix(input, "!") { + cmdStr := strings.TrimSpace(input[1:]) + if cmdStr == "" { + return true + } + o, err := exec.Command("sh", "-c", cmdStr).CombinedOutput() + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + } + if len(o) > 0 { + fmt.Fprint(out, strings.TrimRight(string(o), "\n")+"\n") + } + return true + } + + if !strings.HasPrefix(input, "/") { + return false + } + + parts := strings.Fields(input) + if len(parts) == 0 { + return false + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "/queued": + var qcmd queuedCommand + var err error + if len(args) == 0 { + qcmd, err = parseQueuedCommandArgs("") + } else { + qcmd, err = parseQueuedCommandArgs(strings.Join(args, " ")) + } + if err != nil { + fmt.Fprintln(out, err) + return true + } + if s.split == nil { + _, msg := runQueuedCommand(nil, qcmd) + fmt.Fprintln(out, msg) + } else { + fmt.Fprintln(out, s.split.runQueuedCommand(qcmd)) + } + return true + + case "/backend": + if len(args) == 0 { + fmt.Fprintln(out, "error: /backend requires an argument (e.g., /backend ollama)") + return true + } + os.Setenv("OLLIE_BACKEND", args[0]) + be, err := backend.New() + if err != nil { + fmt.Fprintf(out, "error: failed to switch backend: %v\n", err) + return true + } + s.loopcfg.Backend = be + s.backendName = resolveBackendName() + fmt.Fprintf(out, "switched backend to: %s\n", s.backendName) + return true + + case "/model": + if len(args) == 0 { + fmt.Fprintln(out, "error: /model requires an argument (e.g., /model qwen3:8b)") + return true + } + s.loopcfg.Model = args[0] + s.modelName = args[0] + fmt.Fprintf(out, "switched model to: %s\n", args[0]) + return true + + case "/agents": + entries, err := os.ReadDir(s.agentsDir) + if err != nil { + fmt.Fprintf(out, "agents: %v\n", err) + return true + } + found := false + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + name := strings.TrimSuffix(e.Name(), ".json") + marker := " " + if name == s.agentName { + marker = "* " + } + fmt.Fprintln(out, marker+name) + found = true + } + if !found { + fmt.Fprintln(out, "no agents found in "+s.agentsDir) + } + return true + + case "/agent": + if len(args) == 0 { + fmt.Fprintf(out, "active agent: %s\n", s.agentName) + return true + } + name := args[0] + cfgPath := agentConfigPath(s.agentsDir, name) + cfg, err := config.Load(cfgPath) + if err != nil { + fmt.Fprintf(out, "error: agent %q: %v\n", name, err) + return true + } + if s.mcpExec != nil { + s.mcpExec.Close() + } + env := buildAgentEnv(cfg, s.builtinExec) + s.mcpExec = env.mcpExec + s.hooks = env.hooks + s.loopcfg.SystemPrompt = env.systemPrompt + s.loopcfg.Tools = env.tools + s.loopcfg.Exec = env.exec + s.loopcfg.GenerationParams = env.genParams + s.ctxOverhead = env.ctxOverhead + s.confirmPtr = env.confirm + s.invalidateCaches = env.invalidateCaches + s.agentName = name + s.session = nil + s.sessionID = newSessionID() + for _, msg := range env.messages { + fmt.Fprintln(out, msg) + } + fmt.Fprintf(out, "agent: %s\n", name) + return true + + case "/compact": + if s.session == nil { + fmt.Fprintln(out, "nothing to compact") + return true + } + n, summary, err := s.session.Compact(ctx, s.loopcfg.Backend, s.loopcfg.Model) + if err != nil { + fmt.Fprintln(out, "compact error:", err) + } else if n == 0 { + fmt.Fprintln(out, "nothing to compact") + } else { + fmt.Fprintf(out, "compacted %d messages\n", n) + if summary != "" { + fmt.Fprintln(out) + fmt.Fprintln(out, summary) + } + s.saveSession() + if s.invalidateCaches != nil { + s.invalidateCaches() + } + } + return true + + case "/context": + if s.session == nil { + fmt.Fprintln(out, "no active session") + return true + } + fmt.Fprintln(out, s.session.ContextDebug()) + return true + + case "/history": + if s.session == nil { + fmt.Fprintln(out, "no active session") + return true + } + for _, msg := range s.session.History() { + preview := msg.Content + if len(preview) > 200 { + preview = preview[:200] + "..." + } + fmt.Fprintf(out, "[%s] %s\n", msg.Role, preview) + } + return true + + case "/clear": + s.session = nil + s.sessionID = newSessionID() + if s.invalidateCaches != nil { + s.invalidateCaches() + } + fmt.Fprintln(out, "cleared") + return true + + case "/sessions": + entries, err := os.ReadDir(s.sessionsDir) + if err != nil { + fmt.Fprintf(out, "sessions: %v\n", err) + return true + } + found := false + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + id := strings.TrimSuffix(e.Name(), ".json") + marker := " " + if id == s.sessionID { + marker = "* " + } + label := id + if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil { + var ps agent.PersistedSession + if json.Unmarshal(data, &ps) == nil { + goal := "" + for _, msg := range ps.Messages { + if msg.Role == "user" { + goal = msg.Content + break + } + } + if len(goal) > 60 { + goal = goal[:60] + "..." + } + label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal) + } + } + fmt.Fprintln(out, marker+label) + found = true + } + if !found { + fmt.Fprintln(out, "no sessions found in "+s.sessionsDir) + } + return true + + case "/help": + fmt.Fprintln(out, "Available commands:") + fmt.Fprintln(out, " /agents - list available agent configs") + fmt.Fprintln(out, " /sessions - list saved sessions") + fmt.Fprintln(out, " /agent [name] - show or switch active agent") + fmt.Fprintln(out, " /backend - switch backend (ollama, openai)") + fmt.Fprintln(out, " /model - switch model") + fmt.Fprintln(out, " /queued [pop|clear] - manage queued prompts") + fmt.Fprintln(out, " /compact - summarize evicted context messages") + fmt.Fprintln(out, " /context - show context window debug info") + fmt.Fprintln(out, " /history - dump bounded message history") + fmt.Fprintln(out, " /clear - clear session") + fmt.Fprintln(out, " /help - show this help") + fmt.Fprintln(out, " ! - run shell command") + return true + } + + return false +} + func main() { sessionFlag := flag.String("session", "", "resume a session by ID") flag.Parse() @@ -571,12 +963,14 @@ func main() { agentsDir := home + "/.config/ollie/agents" sessionsDir := home + "/.config/ollie/sessions" if err := os.MkdirAll(sessionsDir, 0700); err != nil { - log.Fatalf("sessions dir: %v", err) + fmt.Fprintln(os.Stderr, "sessions dir:", err) + os.Exit(1) } be, err := backend.New() if err != nil { - log.Fatalf("Failed to create backend: %v", err) + fmt.Fprintln(os.Stderr, "failed to create backend:", err) + os.Exit(1) } modelName := os.Getenv("OLLIE_MODEL") @@ -584,33 +978,30 @@ func main() { modelName = "qwen3.5:9b" } - // Resolve after backend.New() so that loadEnvFile has already run and - // populated OLLIE_BACKEND / OLLIE_OPENAI_URL from ~/.config/ollie/env. backendName := resolveBackendName() builtinExec := execpkg.New( home+"/.local/state/ollie", home+"/.cache/ollie/exec", ) - // Determine agent name: explicit arg > session file > env > "default". agentName := os.Getenv("OLLIE_AGENT") if agentName == "" { agentName = "default" } - // If resuming a session, peek at the file to get the stored agent name - // (used only when no agent is specified on the command line). sessionID := newSessionID() var resumeMessages []backend.Message if *sessionFlag != "" { sessionPath := sessionsDir + "/" + *sessionFlag + ".json" data, readErr := os.ReadFile(sessionPath) if readErr != nil { - log.Fatalf("--session: %v", readErr) + fmt.Fprintln(os.Stderr, "--session:", readErr) + os.Exit(1) } var ps agent.PersistedSession if jsonErr := json.Unmarshal(data, &ps); jsonErr != nil { - log.Fatalf("--session: bad JSON: %v", jsonErr) + fmt.Fprintln(os.Stderr, "--session: bad JSON:", jsonErr) + os.Exit(1) } sessionID = ps.ID resumeMessages = ps.Messages @@ -625,23 +1016,13 @@ func main() { cfgPath := agentConfigPath(agentsDir, agentName) cfg, cfgErr := config.Load(cfgPath) - var startup []string - if cfgErr != nil { - startup = append(startup, fmt.Sprintf("agent config: %v", cfgErr)) - } - env := buildAgentEnv(cfg, builtinExec) - startup = append(startup, env.messages...) - // Restore or record session info. var initialSession *agent.Session if len(resumeMessages) > 0 { initialSession = agent.RestoreSession(resumeMessages, agent.ContextConfig{ FixedOverheadChars: env.ctxOverhead, }) - startup = append(startup, fmt.Sprintf("session: %s (resumed)", sessionID)) - } else { - startup = append(startup, fmt.Sprintf("session: %s", sessionID)) } loopcfg := agent.Config{ @@ -654,32 +1035,10 @@ func main() { GenerationParams: env.genParams, } - ta := textarea.New() - ta.Placeholder = "Type your message..." - ta.Prompt = "" - ta.ShowLineNumbers = false - ta.CharLimit = 0 - ta.SetHeight(5) - ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j")) - ta.Focus() - - vp := viewport.New(0, 0) - vp.KeyMap = viewport.KeyMap{ - PageDown: key.NewBinding(key.WithKeys("pgdown")), - PageUp: key.NewBinding(key.WithKeys("pgup")), - HalfPageDown: key.NewBinding(key.WithKeys("alt+d")), - HalfPageUp: key.NewBinding(key.WithKeys("alt+u")), - Down: key.NewBinding(key.WithKeys("down", "alt+n")), - Up: key.NewBinding(key.WithKeys("up", "alt+p")), - } - - p := tea.NewProgram(model{ - textarea: ta, - viewport: vp, + s := &appState{ session: initialSession, loopcfg: loopcfg, hooks: env.hooks, - display: startup, modelName: modelName, backendName: backendName, agentName: agentName, @@ -691,717 +1050,31 @@ func main() { confirmPtr: env.confirm, ctxOverhead: env.ctxOverhead, invalidateCaches: env.invalidateCaches, - quitPending: false, - state: agentIdle, - }) + } + + if cfgErr != nil { + fmt.Fprintln(os.Stderr, "agent config:", cfgErr) + } + for _, msg := range env.messages { + fmt.Fprintln(os.Stderr, msg) + } + if len(resumeMessages) > 0 { + fmt.Fprintf(os.Stderr, "session: %s (resumed)\n", sessionID) + } else { + fmt.Fprintf(os.Stderr, "session: %s\n", sessionID) + } if hook := env.hooks["agentSpawn"]; hook != "" { - exec.Command("sh", "-c", hook).Run() + exec.Command("sh", "-c", hook).Run() //nolint:errcheck } - if _, err := p.Run(); err != nil { - log.Fatal(err) - } + s.runInteractiveTTY(context.Background()) } -func (m model) Init() tea.Cmd { - return textarea.Blink -} - -// renderDisplay produces the full viewport text. -func (m model) renderDisplay() string { - var b strings.Builder - for i, line := range m.display { - if i > 0 { - b.WriteByte('\n') - } - b.WriteString(line) - } - if m.buf != "" { - if len(m.display) > 0 { - b.WriteString("\n\n") - } - b.WriteString("Bot: " + m.buf) - } - return b.String() -} - -func (m *model) refreshView() { - m.viewport.SetContent(wordWrap(m.renderDisplay(), m.viewport.Width)) - m.viewport.GotoBottom() -} - -// renderStatusBar returns the one-line status bar string. -func (m model) renderStatusBar() string { - // Agent state segment. - var stateStr string - switch m.state { - case agentIdle: - stateStr = "idle" - case agentThinking: - stateStr = "thinking\u2026" - case agentRunningTool: - stateStr = "tool: " + m.currentTool - case agentRetrying: - stateStr = fmt.Sprintf("retry %ds", m.retrySecsLeft) - case agentConfirming: - stateStr = "confirm [y/n]" - case agentStalled: - stateStr = "stalled" - } - - // Token usage segment. - usageStr := "last: -" - if m.lastUsage.InputTokens > 0 || m.lastUsage.OutputTokens > 0 { - usageStr = fmt.Sprintf("last: \u2191%d \u2193%d", m.lastUsage.InputTokens, m.lastUsage.OutputTokens) - } - - // Context stats segment. - ctxStr := "ctx: -" - if m.ctxStats.StoredMessages > 0 { - ktok := float64(m.ctxStats.ApproxTokens) / 1000.0 - ctxStr = fmt.Sprintf("ctx: ~%.1fk tokens (%d msgs)", ktok, m.ctxStats.BoundedMessages) - if m.ctxStats.Evicted > 0 { - ctxStr += fmt.Sprintf(", %d evicted", m.ctxStats.Evicted) - } - } - - bar := fmt.Sprintf("[%s :: %s :: %s] %s | %s | %s", - m.backendName, m.modelName, m.agentName, stateStr, usageStr, ctxStr) - return statusBarStyle.Width(m.viewport.Width).Render(bar) -} - -// finalizeBuf commits the in-progress streaming text as a new display line. - -// addStreamingContent concatenates a streaming chunk onto the buffer, -// inserting a single space when neither side has whitespace at the boundary. -// -// Some backends (e.g. DeepInfra) occasionally drop whitespace-only tokens -// (emitting null or "" for a space token), causing words to fuse. Since -// LLM APIs always deliver complete BPE tokens — which never split mid-word — -// inserting a space whenever two non-space runes meet is safe in practice. -func addStreamingContent(buf, chunk string) string { - return buf + chunk -} - -func (m *model) appendDisplay(line string) { - if len(m.display) > 0 { - m.display = append(m.display, "") - } - m.display = append(m.display, line) -} - -func (m *model) finalizeBuf() { - if m.buf != "" { - m.appendDisplay("Bot: " + m.buf) - m.buf = "" - } -} - -// apply writes one agent output event into the model. -func (m *model) apply(am agentMsg) { - switch am.role { - case "assistant": - m.state = agentThinking - m.buf = addStreamingContent(m.buf, am.content) - - case "call": - m.state = agentRunningTool - m.currentTool = am.name - m.finalizeBuf() - args := squashWhitespace(am.content) - if len(args) > 500 { - args = args[:500] + "..." - } - m.appendDisplay(fmt.Sprintf("-> %s(%s)", am.name, args)) - - case "tool": - m.state = agentThinking - s := strings.TrimRight(am.content, "\n") - if len(s) > 500 { - s = s[:500] + "..." - } - m.appendDisplay("= " + s) - - case "retry": - m.state = agentRetrying - if secs, err := strconv.Atoi(am.content); err == nil { - m.retrySecsLeft = secs - } - - case "error": - m.finalizeBuf() - m.appendDisplay("Error: " + am.content) - if m.session != nil { - m.session.Rollback() - } - - case "confirm": - m.state = agentConfirming - m.confirmCh = am.confirmCh - m.appendDisplay("Confirm: " + am.content + " [y/n]") - - case "stalled": - m.state = agentStalled - - case "usage": - // Update status bar only; no display line appended. - m.lastUsage = am.usage - m.ctxStats = am.ctxStats - } -} - -// drainAgent cancels the in-flight goroutine and drains remaining events. -// If interrupted is true, rolls back any incomplete assistant turn. -func (m *model) drainAgent(interrupted bool) { - if m.cancel == nil { - return - } - m.cancel() - m.cancel = nil - if m.doneCh != nil { - <-m.doneCh - m.doneCh = nil - } - if m.agentCh != nil { - for msg := range m.agentCh { - if am, ok := msg.(agentMsg); ok { - m.apply(am) - } - } - m.agentCh = nil - } - if interrupted && m.session != nil { - m.session.Rollback() - } - m.state = agentIdle - m.currentTool = "" -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.viewport.Width = msg.Width - m.textarea.SetWidth(msg.Width) - barHeight := lipgloss.Height(m.renderStatusBar()) - m.viewport.Height = msg.Height - m.textarea.Height() - barHeight - m.refreshView() - m.ready = true - - case timeoutMsg: - // Clear quitPending flag when timeout expires - m.quitPending = false - return m, nil - - - case confirmMsg: - if m.confirmCh != nil { - m.confirmCh <- msg.approved - m.confirmCh = nil - } - m.state = agentRunningTool - m.refreshView() - if m.agentCh == nil { - return m, nil - } - return m, func() tea.Msg { return <-m.agentCh } - - case agentMsg: - m.apply(msg) - m.refreshView() - if msg.done { - if m.state != agentStalled { - m.state = agentIdle - } - m.currentTool = "" - m.agentCh = nil - m.cancel = nil - m.doneCh = nil - m.saveSession() - return m, nil - } - // Stop pumping while waiting for user confirmation. - if m.state == agentConfirming { - return m, nil - } - return m, func() tea.Msg { return <-m.agentCh } - - case tea.KeyMsg: - switch msg.String() { - case "esc": - // ESC always interrupts if agent is running - if m.cancel != nil { - m.drainAgent(true) - } - m.quitPending = false - return m, nil - - case "ctrl+c": - now := time.Now() - - // Handle double-press to quit - if m.quitPending && now.Sub(m.lastCtrlC) <= 1*time.Second { - // Double Ctrl+C within 1 second: quit - return m, tea.Quit - } - - // First Ctrl+C: interrupt if agent running - if m.cancel != nil { - m.drainAgent(true) - m.quitPending = false - return m, nil - } - - // No agent running, track for potential double-press - if m.lastCtrlC.IsZero() { - // First Ctrl+C with no agent - m.lastCtrlC = now - m.quitPending = true - return m, tea.Cmd(func() tea.Msg { - time.Sleep(1 * time.Second) - return timeoutMsg{} - }) - } else if now.Sub(m.lastCtrlC) <= 1*time.Second { - // Double Ctrl+C within 1 second with no agent: quit - return m, tea.Quit - } else { - // Ctrl+C after timeout, reset and start new timer - m.lastCtrlC = now - m.quitPending = true - return m, tea.Cmd(func() tea.Msg { - time.Sleep(1 * time.Second) - return timeoutMsg{} - }) - } - - case "enter": - input := strings.TrimSpace(m.textarea.Value()) - if input == "" { - return m, nil - } - - // Handle confirmation prompt - if m.state == agentConfirming && m.confirmCh != nil { - m.textarea.Reset() - lower := strings.ToLower(input) - if lower == "y" || lower == "yes" { - m.appendDisplay("You: " + input) - m.refreshView() - return m, func() tea.Msg { return confirmMsg{approved: true} } - } else if lower == "n" || lower == "no" { - m.appendDisplay("You: " + input) - m.refreshView() - return m, func() tea.Msg { return confirmMsg{approved: false} } - } - // Anything else: deny and fall through to normal prompt handling - ch := m.confirmCh - m.confirmCh = nil - m.state = agentIdle - ch <- false - } - - m.drainAgent(false) - m.finalizeBuf() - m.appendDisplay("You: " + input) - m.refreshView() - m.textarea.Reset() - - if hook := m.hooks["userPromptSubmit"]; hook != "" { - exec.Command("sh", "-c", hook).Run() - } - - if m.handleCommand(input) { - m.refreshView() - return m, nil - } - - if m.session == nil { - m.session = agent.NewSessionWithConfig(buildFirstPrompt(input), agent.ContextConfig{ - FixedOverheadChars: m.ctxOverhead, - }) - } else { - m.session.AppendUserMessage(input) - } - m.state = agentThinking - ch, cancel, doneCh := m.startAgent(m.session) - m.agentCh = ch - m.cancel = cancel - m.doneCh = doneCh - return m, func() tea.Msg { return <-ch } - } - } - - var vpCmd tea.Cmd - m.viewport, vpCmd = m.viewport.Update(msg) - m.textarea, cmd = m.textarea.Update(msg) - return m, tea.Batch(cmd, vpCmd) -} - -// handleCommand processes special slash commands and returns true if handled. -func (m *model) handleCommand(input string) bool { - if strings.HasPrefix(input, "!") { - cmdStr := strings.TrimSpace(input[1:]) - if cmdStr == "" { - return true - } - out, err := exec.Command("sh", "-c", cmdStr).CombinedOutput() - if err != nil { - m.appendDisplay("Error: " + err.Error()) - } - if len(out) > 0 { - m.appendDisplay(strings.TrimRight(string(out), "\n")) - } - return true - } - if !strings.HasPrefix(input, "/") { - return false - } - - parts := strings.Fields(input) - if len(parts) == 0 { - return false - } - - cmd := parts[0] - args := parts[1:] - - switch cmd { - case "/backend": - if len(args) == 0 { - m.display = append(m.display, "Error: /backend requires an argument (e.g., /backend ollama)") - return true - } - backendType := args[0] - os.Setenv("OLLIE_BACKEND", backendType) - be, err := backend.New() - if err != nil { - m.display = append(m.display, fmt.Sprintf("Error: Failed to switch backend: %v", err)) - return true - } - m.loopcfg.Backend = be - m.backendName = resolveBackendName() - m.display = append(m.display, fmt.Sprintf("Switched backend to: %s", m.backendName)) - return true - - case "/model": - if len(args) == 0 { - m.display = append(m.display, "Error: /model requires an argument (e.g., /model qwen3:8b)") - return true - } - m.loopcfg.Model = args[0] - m.modelName = args[0] - m.display = append(m.display, fmt.Sprintf("Switched model to: %s", args[0])) - return true - - case "/agents": - entries, err := os.ReadDir(m.agentsDir) - if err != nil { - m.display = append(m.display, fmt.Sprintf("agents: %v", err)) - return true - } - found := false - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { - continue - } - name := strings.TrimSuffix(e.Name(), ".json") - marker := " " - if name == m.agentName { - marker = "* " - } - m.display = append(m.display, marker+name) - found = true - } - if !found { - m.display = append(m.display, "no agents found in "+m.agentsDir) - } - return true - - case "/agent": - if len(args) == 0 { - m.display = append(m.display, fmt.Sprintf("active agent: %s", m.agentName)) - return true - } - name := args[0] - cfgPath := agentConfigPath(m.agentsDir, name) - cfg, err := config.Load(cfgPath) - if err != nil { - m.display = append(m.display, fmt.Sprintf("Error: agent %q: %v", name, err)) - return true - } - if m.mcpExec != nil { - m.mcpExec.Close() - } - env := buildAgentEnv(cfg, m.builtinExec) - m.mcpExec = env.mcpExec - m.hooks = env.hooks - m.loopcfg.SystemPrompt = env.systemPrompt - m.loopcfg.Tools = env.tools - m.loopcfg.Exec = env.exec - m.loopcfg.GenerationParams = env.genParams - m.ctxOverhead = env.ctxOverhead - m.confirmPtr = env.confirm - m.invalidateCaches = env.invalidateCaches - m.agentName = name - m.session = nil // new agent, new session - m.sessionID = newSessionID() - for _, msg := range env.messages { - m.display = append(m.display, msg) - } - m.display = append(m.display, fmt.Sprintf("agent: %s", name)) - return true - - case "/compact": - if m.session == nil { - m.display = append(m.display, "nothing to compact") - return true - } - n, summary, err := m.session.Compact(context.Background(), m.loopcfg.Backend, m.loopcfg.Model) - if err != nil { - m.display = append(m.display, "compact error: "+err.Error()) - } else if n == 0 { - m.display = append(m.display, "nothing to compact") - } else { - m.display = append(m.display, fmt.Sprintf("compacted %d messages", n)) - if summary != "" { - m.display = append(m.display, "") - for _, line := range strings.Split(summary, "\n") { - m.display = append(m.display, line) - } - } - m.saveSession() - if m.invalidateCaches != nil { - m.invalidateCaches() - } - } - return true - - case "/context": - if m.session == nil { - m.display = append(m.display, "no active session") - return true - } - for _, line := range strings.Split(m.session.ContextDebug(), "\n") { - m.display = append(m.display, line) - } - return true - - case "/history": - if m.session == nil { - m.display = append(m.display, "no active session") - return true - } - for _, msg := range m.session.History() { - preview := msg.Content - if len(preview) > 200 { - preview = preview[:200] + "..." - } - m.display = append(m.display, fmt.Sprintf("[%s] %s", msg.Role, preview)) - } - return true - - case "/clear": - m.session = nil - m.display = nil - m.buf = "" - m.ctxStats = agent.ContextStats{} - m.lastUsage = backend.Usage{} - m.sessionID = newSessionID() - if m.invalidateCaches != nil { - m.invalidateCaches() - } - return true - - case "/sessions": - entries, err := os.ReadDir(m.sessionsDir) - if err != nil { - m.display = append(m.display, fmt.Sprintf("sessions: %v", err)) - return true - } - found := false - // ReadDir returns sorted by name (date-prefixed IDs → chronological). - // Show most recent first. - for i := len(entries) - 1; i >= 0; i-- { - e := entries[i] - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { - continue - } - id := strings.TrimSuffix(e.Name(), ".json") - marker := " " - if id == m.sessionID { - marker = "* " - } - // Quick-read agent name and first user message for context. - label := id - if data, readErr := os.ReadFile(m.sessionsDir + "/" + e.Name()); readErr == nil { - var ps agent.PersistedSession - if json.Unmarshal(data, &ps) == nil { - agentLabel := ps.Agent - goal := "" - for _, msg := range ps.Messages { - if msg.Role == "user" { - goal = msg.Content - break - } - } - if len(goal) > 60 { - goal = goal[:60] + "..." - } - label = fmt.Sprintf("%-24s [%s] %q", id, agentLabel, goal) - } - } - m.display = append(m.display, marker+label) - found = true - } - if !found { - m.display = append(m.display, "no sessions found in "+m.sessionsDir) - } - return true - - case "/help": - m.display = append(m.display, "Available commands:") - m.display = append(m.display, " /agents - List available agent configs") - m.display = append(m.display, " /sessions - List saved sessions") - m.display = append(m.display, " /agent [name] - Show or switch active agent") - m.display = append(m.display, " /backend - Switch backend (ollama, openai)") - m.display = append(m.display, " /model - Switch model") - m.display = append(m.display, " /compact - Summarize evicted context messages") - m.display = append(m.display, " /context - Show context window debug info") - m.display = append(m.display, " /history - Dump bounded message history") - m.display = append(m.display, " /clear - Clear session and display") - m.display = append(m.display, " /help - Show this help") - return true - } - - return false -} - -// startAgent launches the loop in a goroutine, wiring its output to ch. -func (m model) startAgent(session *agent.Session) (chan tea.Msg, context.CancelFunc, chan struct{}) { - loopcfg := m.loopcfg - hooks := m.hooks - ch := make(chan tea.Msg, 64) - ctx, cancel := context.WithCancel(context.Background()) - doneCh := make(chan struct{}) - - go func() { - defer close(ch) - defer close(doneCh) - - loopcfg.Output = func(em agent.OutputMsg) { - var msg agentMsg - msg.role = em.Role - msg.content = em.Content - msg.name = em.Name - - if em.Role == "usage" { - msg.usage = em.Usage - msg.ctxStats = session.ContextStats() - } - - select { - case ch <- msg: - case <-ctx.Done(): - return - } - } - - loopcfg.Confirm = func(prompt string) bool { - replyCh := make(chan bool, 1) - select { - case ch <- agentMsg{role: "confirm", content: prompt, confirmCh: replyCh}: - case <-ctx.Done(): - return false - } - select { - case approved := <-replyCh: - return approved - case <-ctx.Done(): - return false - } - } - if m.confirmPtr != nil { - *m.confirmPtr = loopcfg.Confirm - } - - if err := agent.Run(ctx, loopcfg, session); err != nil { - select { - case ch <- agentMsg{role: "error", content: err.Error()}: - case <-ctx.Done(): - return - } - } - - if hook := hooks["stop"]; hook != "" { - exec.Command("sh", "-c", hook).Run() - } - - select { - case ch <- agentMsg{done: true}: - case <-ctx.Done(): - } - }() - - return ch, cancel, doneCh -} - -func (m model) View() string { - if !m.ready { - return "Loading..." - } - return m.viewport.View() + "\n" + m.renderStatusBar() + "\n" + m.textarea.View() -} - -// compactJSON removes whitespace between JSON tokens. -func compactJSON(s string) string { - var buf bytes.Buffer - if err := json.Compact(&buf, []byte(s)); err != nil { - return squashWhitespace(s) - } - return squashWhitespace(buf.String()) -} - -// squashWhitespace collapses all runs of whitespace into single spaces. func squashWhitespace(s string) string { return strings.Join(strings.Fields(s), " ") } -// wordWrap wraps text at word boundaries so no line exceeds width. -func wordWrap(s string, width int) string { - if width <= 0 { - return s - } - var out strings.Builder - lines := strings.Split(s, "\n") - for _, line := range lines { - if out.Len() > 0 { - out.WriteByte('\n') - } - col, first := 0, true - for _, word := range strings.Fields(line) { - wl := len(word) - switch { - case first: - out.WriteString(word) - col = wl - first = false - case col+wl+1 > width: - out.WriteByte('\n') - out.WriteString(word) - col = wl - first = true - default: - out.WriteByte(' ') - out.WriteString(word) - col += wl + 1 - } - } - } - return out.String() -} - func mcpToolsToBackend(mcpTools []tools.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(mcpTools)) for i, t := range mcpTools { @@ -1443,7 +1116,7 @@ func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, return dispatchExecutePipe(ctx, e, confirm, args) case "execute_tool": return dispatchExecuteTool(ctx, e, confirm, args) - default: // execute_code + default: return dispatchExecuteCode(ctx, e, confirm, args) } } @@ -1608,7 +1281,6 @@ func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, e return "", fmt.Errorf("file_write: denied by user") } - // Full overwrite if a.StartLine == 0 && a.EndLine == 0 { if err := os.WriteFile(a.Path, []byte(a.Content), 0644); err != nil { return "", fmt.Errorf("file_write: %w", err) @@ -1616,7 +1288,6 @@ func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, e return fmt.Sprintf("wrote %d bytes to %s", len(a.Content), a.Path), nil } - // Range replacement data, err := os.ReadFile(a.Path) if err != nil { return "", fmt.Errorf("file_write: %w", err) diff --git a/queued_commands.go b/queued_commands.go new file mode 100644 index 0000000..4db176c --- /dev/null +++ b/queued_commands.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "strconv" + "strings" +) + +const queuedCommandUsage = "/queued [pop [N] | clear]" + +type queuedCommandAction int + +const ( + queuedCommandHelp queuedCommandAction = iota + queuedCommandPop + queuedCommandClear +) + +type queuedCommand struct { + action queuedCommandAction + count int +} + +func parseQueuedCommandLine(input string) (cmd queuedCommand, ok bool, err error) { + input = strings.TrimSpace(input) + if !strings.HasPrefix(input, "/") { + return queuedCommand{}, false, nil + } + name, args, _ := strings.Cut(input[1:], " ") + if name != "queued" { + return queuedCommand{}, false, nil + } + cmd, err = parseQueuedCommandArgs(args) + return cmd, true, err +} + +func parseQueuedCommandArgs(args string) (queuedCommand, error) { + fields := strings.Fields(strings.TrimSpace(args)) + if len(fields) == 0 { + return queuedCommand{action: queuedCommandHelp}, nil + } + switch fields[0] { + case "pop": + if len(fields) == 1 { + return queuedCommand{action: queuedCommandPop, count: 1}, nil + } + if len(fields) != 2 { + return queuedCommand{}, fmt.Errorf("usage: %s", queuedCommandUsage) + } + n, err := strconv.Atoi(fields[1]) + if err != nil || n <= 0 { + return queuedCommand{}, fmt.Errorf("pop count must be a positive integer") + } + return queuedCommand{action: queuedCommandPop, count: n}, nil + case "clear": + if len(fields) != 1 { + return queuedCommand{}, fmt.Errorf("usage: %s", queuedCommandUsage) + } + return queuedCommand{action: queuedCommandClear}, nil + default: + return queuedCommand{}, fmt.Errorf("usage: %s", queuedCommandUsage) + } +} + +func runQueuedCommand(queue []string, cmd queuedCommand) ([]string, string) { + switch cmd.action { + case queuedCommandHelp: + return queue, fmt.Sprintf("Usage: %s", queuedCommandUsage) + case queuedCommandPop: + if len(queue) == 0 { + return queue, "No queued prompts." + } + n := min(cmd.count, len(queue)) + return queue[:len(queue)-n], fmt.Sprintf("Removed %d queued prompt(s).", n) + case queuedCommandClear: + if len(queue) == 0 { + return queue, "No queued prompts." + } + n := len(queue) + return nil, fmt.Sprintf("Cleared %d queued prompt(s).", n) + default: + return queue, "No queued prompts." + } +} diff --git a/rerender_input.go b/rerender_input.go new file mode 100644 index 0000000..08bd542 --- /dev/null +++ b/rerender_input.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "io" + "regexp" + "strings" + + "github.com/mattn/go-runewidth" +) + +var ansiCSIPattern = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]") +var ansiOSCPattern = regexp.MustCompile("\x1b\\][^\x07]*(\x07|\x1b\\\\)") + +func visibleWidth(s string) int { + s = ansiOSCPattern.ReplaceAllString(s, "") + s = ansiCSIPattern.ReplaceAllString(s, "") + return runewidth.StringWidth(s) +} + +func shouldRerenderSubmittedSingleLine(prompt, input string, termWidth int) bool { + if termWidth <= 0 { + return false + } + return visibleWidth(prompt)+runewidth.StringWidth(input) > termWidth +} + +func rerenderSubmittedSingleLine(w io.Writer, prompt, input string) { + fmt.Fprint(w, "\x1b[1A\r\x1b[2K") + fmt.Fprint(w, prompt) + fmt.Fprint(w, input) + fmt.Fprint(w, "\n") +} + +func clearScreenAndMoveToBottom(w io.Writer, termHeight int) { + if termHeight <= 0 { + return + } + fmt.Fprint(w, strings.Repeat("\r\n", termHeight)) + fmt.Fprintf(w, "\x1b[%d;1H", termHeight) +} + +func truncateToWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + w := 0 + for i, r := range s { + w += runewidth.RuneWidth(r) + if w > maxWidth { + return s[:i] + } + } + return s +} + +func truncateToWidthFromEnd(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + runes := []rune(s) + w := 0 + start := len(runes) + for start > 0 { + nextWidth := runewidth.RuneWidth(runes[start-1]) + if w+nextWidth > maxWidth { + break + } + start-- + w += nextWidth + } + return string(runes[start:]) +} diff --git a/signals.go b/signals.go new file mode 100644 index 0000000..0117679 --- /dev/null +++ b/signals.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "time" +) + +var ErrInterrupted = errors.New("interrupted") + +const ctrlCExitWindow = 750 * time.Millisecond + +type ActionCanceler func() context.CancelCauseFunc + +func startSignalWatcher(appCancel context.CancelCauseFunc, getActionCancel ActionCanceler, errStream io.Writer) { + ch := make(chan os.Signal, 16) + signals := []os.Signal{os.Interrupt} + if haveSIGTERM { + signals = append(signals, SIGTERM) + } + signal.Notify(ch, signals...) + + go func() { + for sig := range ch { + switch sig { + case SIGTERM: + signal.Stop(ch) + close(ch) + fmt.Fprint(errStream, "\n(terminated)\n") + appCancel(context.Canceled) + continue + case os.Interrupt: + if cancel := getActionCancel(); cancel != nil { + fmt.Fprint(errStream, "\n^C\n") + cancel(ErrInterrupted) + continue + } + } + } + }() +} diff --git a/signals_unix.go b/signals_unix.go new file mode 100644 index 0000000..b21a202 --- /dev/null +++ b/signals_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +import "syscall" + +const ( + haveSIGTERM = true + SIGTERM = syscall.SIGTERM +) diff --git a/signals_windows.go b/signals_windows.go new file mode 100644 index 0000000..4c6b1c0 --- /dev/null +++ b/signals_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "os" + +const haveSIGTERM = false + +var SIGTERM = os.Signal(nil) diff --git a/splitinput.go b/splitinput.go new file mode 100644 index 0000000..9fc2c49 --- /dev/null +++ b/splitinput.go @@ -0,0 +1,900 @@ +//go:build !windows + +package main + +import ( + "fmt" + "io" + "math" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + "unicode/utf8" + + gotty "github.com/mattn/go-tty" +) + +const splitInputVisibleQueueRows = 3 + +type splitInputReadRuneFunc func() (rune, error) + +// splitInput manages the persistent input line at the bottom of the terminal +// during agent turns. It uses ANSI scroll regions to pin a bottom band while +// output scrolls above. +type splitInput struct { + mainTTY *gotty.TTY + out io.Writer + mu sync.Mutex + prompt string + complete func(fields []string) (completionSet []string, listingSet []string) + + width, height int + scrollEnd int + bandStart int + + active bool + inputTTY *gotty.TTY + buf []rune + queue []string + completions []string + outputStarted bool + deferredLFs int + + winchCh chan os.Signal + done chan struct{} + + pulseStart time.Time +} + +func newSplitInput(t *gotty.TTY, out io.Writer, prompt string, complete func(fields []string) ([]string, []string)) *splitInput { + return &splitInput{ + mainTTY: t, + out: out, + prompt: prompt, + complete: complete, + } +} + +func (s *splitInput) maxQueueRows() int { + rows := max(s.height/3, 1) + if rows > splitInputVisibleQueueRows { + return splitInputVisibleQueueRows + } + return rows +} + +func (s *splitInput) reservedRowsLocked() int { + rows := 2 + bodyRows := s.promptBodyRowsLocked() + len(s.completions) + if bodyRows > 0 { + rows += 1 + bodyRows + } + if rows > s.height-2 { + rows = s.height - 2 + } + if rows < 2 { + rows = 2 + } + return rows +} + +func (s *splitInput) recalcLayoutLocked() { + s.scrollEnd = max(s.height-s.reservedRowsLocked(), 1) + s.bandStart = s.scrollEnd + 1 +} + +func (s *splitInput) promptBodyRowsLocked() int { + return len(s.queue) +} + +func resizeRedrawStart(oldHeight, oldBandStart, newHeight, newBandStart int) int { + if newBandStart < 1 { + newBandStart = 1 + } + if oldHeight <= 0 || oldBandStart <= 0 || newHeight <= 0 { + return newBandStart + } + oldReserved := max(oldHeight-oldBandStart+1, 1) + shiftedOldBandStart := min(max(newHeight-oldReserved+1, 1), newHeight) + return min(shiftedOldBandStart, newBandStart) +} + +func (s *splitInput) redrawLayoutLocked(clearFrom int) { + if clearFrom < 1 { + clearFrom = 1 + } + fmt.Fprintf(s.out, "\033[1;%dr", s.scrollEnd) + for row := clearFrom; row <= s.height; row++ { + fmt.Fprintf(s.out, "\033[%d;1H\033[2K", row) + } + s.drawBottomAreaLocked() + fmt.Fprintf(s.out, "\033[%d;1H", s.scrollEnd) +} + +func (s *splitInput) prepareSubmittedPromptGapLocked() { + if s.height <= 0 { + return + } + fmt.Fprintf(s.out, "\033[%d;1H\n", s.height) +} + +func (s *splitInput) Enter() io.Writer { + w, h, err := s.mainTTY.Size() + if err != nil || h < 6 { + return nil + } + + inputTTY, err := gotty.Open() + if err != nil { + return nil + } + + s.mu.Lock() + s.width = w + s.height = h + s.active = true + s.inputTTY = inputTTY + s.buf = s.buf[:0] + s.queue = s.queue[:0] + s.completions = nil + s.outputStarted = false + s.deferredLFs = 0 + s.pulseStart = time.Now() + s.done = make(chan struct{}) + s.recalcLayoutLocked() + + fmt.Fprint(s.out, "\033[?25l") + s.prepareSubmittedPromptGapLocked() + s.redrawLayoutLocked(s.bandStart) + s.mu.Unlock() + + s.winchCh = make(chan os.Signal, 1) + signal.Notify(s.winchCh, syscall.SIGWINCH) + go s.watchResize() + go s.captureKeys(inputTTY) + go s.pulseSeparator() + + return &splitWriter{s: s} +} + +func (s *splitInput) Exit() (queued []string, pending string) { + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return nil, "" + } + s.active = false + close(s.done) + + inputTTY := s.inputTTY + s.inputTTY = nil + + queued = s.queue + s.queue = nil + s.completions = nil + if len(s.buf) > 0 { + pending = string(s.buf) + s.buf = s.buf[:0] + } + s.flushDeferredLFsForExitLocked() + + fmt.Fprint(s.out, "\033[r") + s.clearBandLocked() + fmt.Fprint(s.out, "\033[?25h") + fmt.Fprintf(s.out, "\033[%d;1H", s.height) + + s.mu.Unlock() + + if inputTTY != nil { + inputTTY.Close() + } + signal.Stop(s.winchCh) + + return queued, pending +} + +func (s *splitInput) SetPrompt(prompt string) { + s.mu.Lock() + s.prompt = prompt + if s.active { + fmt.Fprint(s.out, "\033[s") + s.clearBandLocked() + s.drawBottomAreaLocked() + s.restoreOverlayCursorLocked(s.height) + } + s.mu.Unlock() +} + +func (s *splitInput) PopQueue() (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.queue) == 0 { + return "", false + } + + item := s.queue[0] + s.queue = s.queue[1:] + s.completions = nil + oldBandStart := s.bandStart + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + + return item, true +} + +func (s *splitInput) runQueuedCommand(cmd queuedCommand) string { + s.mu.Lock() + defer s.mu.Unlock() + var cmdMsg string + s.queue, cmdMsg = runQueuedCommand(s.queue, cmd) + if s.active { + oldBandStart := s.bandStart + s.completions = nil + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + } + return cmdMsg +} + +func (s *splitInput) EchoQueuedInput(input string) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.active { + return + } + s.flushDeferredLFsLocked() + fmt.Fprintf(s.out, "%s%s\n", s.prompt, input) + s.completions = nil + s.outputStarted = false +} + +func (s *splitInput) clearBandLocked() { + for row := s.bandStart; row <= s.height; row++ { + fmt.Fprintf(s.out, "\033[%d;1H\033[2K", row) + } +} + +func (s *splitInput) renderPulseSepLocked() string { + const ( + baseGray = 238 + peakGray = 252 + sigma = 3.0 + ) + period := 14.0 * float64(s.width-1) / 139.0 + phase := time.Since(s.pulseStart).Seconds() * 2 * math.Pi / period + pos := (math.Sin(phase) + 1) / 2 * float64(s.width-1) + + var b strings.Builder + for i := range s.width { + d := float64(i) - pos + gray := baseGray + int(float64(peakGray-baseGray)*math.Exp(-d*d/(2*sigma*sigma))) + fmt.Fprintf(&b, "\033[38;5;%dm─", gray) + } + b.WriteString("\033[0m") + return b.String() +} + +func (s *splitInput) redrawBottomSepLocked() { + sepRow := s.height - 1 + if sepRow < s.bandStart { + return + } + fmt.Fprintf(s.out, "\033[s\033[%d;1H\033[2K%s", sepRow, s.renderPulseSepLocked()) + s.restoreOverlayCursorLocked(sepRow) +} + +func (s *splitInput) drawBottomAreaLocked() { + h := s.height + inputRow := h + queueRows := len(s.queue) + bodyRows := queueRows + len(s.completions) + + pulseSep := s.renderPulseSepLocked() + if bodyRows == 0 { + sepRow := inputRow - 1 + if sepRow >= s.bandStart { + fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", sepRow, pulseSep) + } + } else { + topSepRow := inputRow - bodyRows - 2 + bottomSepRow := inputRow - 1 + if topSepRow >= s.bandStart { + sep := strings.Repeat("─", s.width) + fmt.Fprintf(s.out, "\033[%d;1H\033[2K\033[2m%s\033[0m", topSepRow, sep) + } + + row := inputRow - bodyRows - 1 + for i, q := range s.queue { + currentRow := row + i + if currentRow >= s.bandStart { + display := truncateToWidth(q, s.width-4) + fmt.Fprintf(s.out, "\033[%d;1H\033[2K\033[2m ◆ %s\033[0m", currentRow, display) + } + } + row += queueRows + for i, item := range s.completions { + currentRow := row + i + if currentRow >= s.bandStart { + display := truncateToWidth(item, s.width) + fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", currentRow, display) + } + } + if bottomSepRow >= s.bandStart { + fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", bottomSepRow, pulseSep) + } + } + + fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", inputRow, s.renderInputBufferLocked()) +} + +func (s *splitInput) captureKeys(inputTTY *gotty.TTY) { + if inputTTY == nil { + return + } + var pendingTokens []string + for { + token, rest, err := readSplitInputToken(inputTTY.ReadRune, pendingTokens) + pendingTokens = rest + if err != nil { + return + } + if token == "" { + continue + } + + select { + case <-s.done: + return + default: + } + + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + + switch { + case token == "\t": + s.completeLocked() + + case token == "\r" || token == "\n": + if len(s.buf) > 0 && s.handleLocalCommandLocked(strings.TrimSpace(string(s.buf))) { + break + } + if len(s.buf) > 0 && len(s.queue) < s.maxQueueRows() { + oldBandStart := s.bandStart + s.completions = nil + s.queue = append(s.queue, string(s.buf)) + s.buf = s.buf[:0] + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + } + + case token == "\x7f" || token == "\b": + if len(s.buf) > 0 { + oldBandStart := s.bandStart + cleared := s.clearCompletionsLocked() + s.buf = s.buf[:len(s.buf)-1] + if cleared { + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + } else { + s.redrawInputLineLocked() + } + } + + case token == "\x03": // Ctrl-C — signal handler deals with it + + case token == "\x15": + if len(s.buf) > 0 { + oldBandStart := s.bandStart + cleared := s.clearCompletionsLocked() + s.buf = s.buf[:0] + if cleared { + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + } else { + s.redrawInputLineLocked() + } + } + + case token == "\x17": + oldBandStart := s.bandStart + cleared := s.clearCompletionsLocked() + s.deleteWordLocked() + if cleared { + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + } else { + s.redrawInputLineLocked() + } + + default: + if isPrintableSplitInputToken(token) { + oldBandStart := s.bandStart + cleared := s.clearCompletionsLocked() + s.buf = append(s.buf, []rune(token)...) + if cleared { + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + } else { + s.redrawInputLineLocked() + } + } + } + s.mu.Unlock() + } +} + +func readSplitInputToken(readRune splitInputReadRuneFunc, pending []string) (string, []string, error) { + if len(pending) > 0 { + return pending[0], pending[1:], nil + } + + token, err := readSplitInputRawToken(readRune) + if err != nil { + return "", nil, err + } + if token != BracketedPasteStart { + return token, nil, nil + } + + raw := token + for !strings.Contains(raw, BracketedPasteEnd) { + next, err := readSplitInputRawToken(readRune) + if err != nil { + return "", nil, err + } + if len(raw)+len(next) > maxBracketedPasteBytes { + return raw, nil, nil + } + raw += next + } + + tokens := tokenizeSplitInputPaste(raw) + if len(tokens) == 0 { + return "", nil, nil + } + return tokens[0], tokens[1:], nil +} + +func readSplitInputRawToken(readRune splitInputReadRuneFunc) (string, error) { + r, err := readRune() + if err != nil { + return "", err + } + if r != '\x1b' { + return string(r), nil + } + + var b strings.Builder + b.WriteRune(r) + + next, err := readRune() + if err != nil { + return b.String(), nil + } + b.WriteRune(next) + if next != '[' && next != 'O' { + return b.String(), nil + } + + for { + r, err := readRune() + if err != nil { + return b.String(), nil + } + b.WriteRune(r) + if r >= 0x40 && r <= 0x7e { + return b.String(), nil + } + } +} + +func tokenizeSplitInputPaste(raw string) []string { + tokens := TokenizeBracketedPaste(raw) + out := make([]string, 0, len(tokens)) + needSpace := false + for _, token := range tokens { + if token == string(GoqPasteNewlineKey) { + needSpace = true + continue + } + token = normalizeSplitInputPasteText(token) + if token == "" { + continue + } + if needSpace { + if len(out) > 0 && !strings.HasSuffix(out[len(out)-1], " ") && !strings.HasPrefix(token, " ") { + out = append(out, " ") + } + needSpace = false + } + out = append(out, token) + } + return out +} + +func normalizeSplitInputPasteText(token string) string { + var b strings.Builder + for _, r := range token { + switch r { + case '\t', '\v', '\f': + b.WriteByte(' ') + default: + if r < 32 || r == utf8.RuneError { + continue + } + b.WriteRune(r) + } + } + return b.String() +} + +func isPrintableSplitInputToken(token string) bool { + if token == "" { + return false + } + for _, r := range token { + if r < 32 || r == utf8.RuneError { + return false + } + } + return true +} + +func (s *splitInput) deleteWordLocked() { + for len(s.buf) > 0 && s.buf[len(s.buf)-1] == ' ' { + s.buf = s.buf[:len(s.buf)-1] + } + for len(s.buf) > 0 && s.buf[len(s.buf)-1] != ' ' { + s.buf = s.buf[:len(s.buf)-1] + } +} + +func (s *splitInput) redrawInputLineLocked() { + fmt.Fprintf(s.out, "\033[s\033[%d;1H\033[2K%s", s.height, s.renderInputBufferLocked()) + s.restoreOverlayCursorLocked(s.height) +} + +func (s *splitInput) restoreOverlayCursorLocked(row int) { + if row < 1 { + row = 1 + } + fmt.Fprintf(s.out, "\033[%d;1H\033[u", row) +} + +func (s *splitInput) clearCompletionsLocked() bool { + if len(s.completions) == 0 { + return false + } + s.completions = nil + return true +} + +func (s *splitInput) completeLocked() { + if s.complete == nil { + return + } + + buf := string(s.buf) + fields := completionFieldsForBuffer(buf) + if len(fields) == 0 { + return + } + + completionSet, listingSet := s.complete(fields) + prefixStart := lastTokenStart(buf) + prefix := buf[prefixStart:] + matches := filterCompletionMatches(completionSet, prefix) + if len(matches) == 0 { + return + } + + if len(matches) == 1 { + s.setCompletionTokenLocked(prefixStart, matches[0], true) + return + } + + common := longestCommonPrefix(matches) + if len(common) > len(prefix) { + s.setCompletionTokenLocked(prefixStart, common, false) + return + } + + listing := filterCompletionMatches(listingSet, prefix) + if len(listing) == 0 { + listing = matches + } + s.showCompletionListingLocked(listing) +} + +func (s *splitInput) setCompletionTokenLocked(prefixStart int, replacement string, appendSpace bool) { + oldBandStart := s.bandStart + s.completions = nil + if appendSpace && replacement != "" && !strings.HasSuffix(replacement, "/") && !strings.HasSuffix(replacement, " ") { + replacement += " " + } + s.buf = []rune(string(s.buf)[:prefixStart] + replacement) + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) +} + +func (s *splitInput) showCompletionListingLocked(listing []string) { + if len(listing) == 0 { + return + } + oldBandStart := s.bandStart + s.completions = s.limitCompletionListingLocked(listing) + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) +} + +func (s *splitInput) limitCompletionListingLocked(listing []string) []string { + maxRows := s.height - 5 - s.promptBodyRowsLocked() + if maxRows <= 0 { + return nil + } + if maxRows > 10 { + maxRows = 10 + } + if len(listing) <= maxRows { + return append([]string(nil), listing...) + } + if maxRows == 1 { + return []string{"..."} + } + limited := append([]string(nil), listing[:maxRows-1]...) + return append(limited, "...") +} + +func (s *splitInput) watchResize() { + for { + select { + case <-s.done: + return + case _, ok := <-s.winchCh: + if !ok { + return + } + s.handleResize() + } + } +} + +func (s *splitInput) pulseSeparator() { + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-s.done: + return + case <-ticker.C: + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + s.redrawBottomSepLocked() + s.mu.Unlock() + } + } +} + +func (s *splitInput) handleResize() { + w, h, err := s.mainTTY.Size() + if err != nil || h < 6 { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + if !s.active { + return + } + + oldHeight := s.height + oldBandStart := s.bandStart + s.width = w + s.height = h + + s.recalcLayoutLocked() + s.redrawLayoutLocked(resizeRedrawStart(oldHeight, oldBandStart, s.height, s.bandStart)) +} + +type splitWriter struct { + s *splitInput +} + +func (w *splitWriter) Write(p []byte) (int, error) { + w.s.mu.Lock() + defer w.s.mu.Unlock() + if len(w.s.completions) > 0 { + oldBandStart := w.s.bandStart + w.s.completions = nil + w.s.recalcLayoutLocked() + w.s.redrawLayoutLocked(min(oldBandStart, w.s.bandStart)) + } + if len(p) > 0 && !w.s.outputStarted { + w.s.writeScrollbackSeparatorLocked() + w.s.outputStarted = true + } + w.s.flushDeferredLFsLocked() + trailingLFs := trailingLFCount(p) + body := p[:len(p)-trailingLFs] + if len(body) > 0 { + if _, err := w.s.out.Write(body); err != nil { + return 0, err + } + } + w.s.deferredLFs += trailingLFs + return len(p), nil +} + +func (s *splitInput) writeScrollbackSeparatorLocked() { + sep := strings.Repeat("─", s.width) + fmt.Fprintf(s.out, "\n\033[2m%s\033[0m\n", sep) +} + +func (s *splitInput) flushDeferredLFsLocked() { + if s.deferredLFs == 0 { + return + } + fmt.Fprint(s.out, strings.Repeat("\n", s.deferredLFs)) + s.deferredLFs = 0 +} + +func (s *splitInput) flushDeferredLFsForExitLocked() { + if s.deferredLFs <= 1 { + s.deferredLFs = 0 + return + } + fmt.Fprint(s.out, strings.Repeat("\n", s.deferredLFs-1)) + s.deferredLFs = 0 +} + +func trailingLFCount(p []byte) int { + n := 0 + for i := len(p) - 1; i >= 0 && p[i] == '\n'; i-- { + n++ + } + return n +} + +func (s *splitInput) renderInputBufferLocked() string { + promptWidth := visibleWidth(s.prompt) + maxInputWidth := s.width - promptWidth - 1 + display := truncateToWidthFromEnd(string(s.buf), maxInputWidth) + return s.prompt + display + "\033[7m \033[0m" +} + +func (s *splitInput) handleLocalCommandLocked(input string) bool { + return s.handleQueuedCommandLocked(input) +} + +func (s *splitInput) handleQueuedCommandLocked(input string) bool { + cmd, ok, err := parseQueuedCommandLine(input) + if !ok { + return false + } + + oldBandStart := s.bandStart + s.buf = s.buf[:0] + s.completions = nil + if err != nil { + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + s.writeLocalStatusMessageLocked(err.Error()) + return true + } + + s.queue, input = runQueuedCommand(s.queue, cmd) + s.recalcLayoutLocked() + s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) + s.writeLocalStatusMessageLocked(input) + return true +} + +func (s *splitInput) writeLocalStatusMessageLocked(msg string) { + if msg == "" { + return + } + s.flushDeferredLFsLocked() + for line := range strings.SplitSeq(msg, "\n") { + fmt.Fprintf(s.out, "\033[2m%s\033[0m\n", line) + } +} + + +// --- completion helpers --- + +func completionFieldsForBuffer(buf string) []string { + fields := splitFields(buf) + if len(buf) == 0 { + return fields + } + if containsByte(" \t\r\n\v\f", buf[len(buf)-1]) { + return append(fields, "") + } + return fields +} + +func lastTokenStart(buf string) int { + for i := len(buf) - 1; i >= 0; i-- { + if containsByte(" \t\r\n\v\f", buf[i]) { + return i + 1 + } + } + return 0 +} + +func filterCompletionMatches(candidates []string, prefix string) []string { + if len(candidates) == 0 { + return nil + } + if prefix == "" { + return append([]string(nil), candidates...) + } + matches := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if strings.HasPrefix(candidate, prefix) { + matches = append(matches, candidate) + } + } + if len(matches) > 0 { + return matches + } + lowerPrefix := strings.ToLower(prefix) + for _, candidate := range candidates { + if strings.HasPrefix(strings.ToLower(candidate), lowerPrefix) { + matches = append(matches, candidate) + } + } + return matches +} + +func longestCommonPrefix(values []string) string { + if len(values) == 0 { + return "" + } + prefix := values[0] + for _, value := range values[1:] { + for !strings.HasPrefix(value, prefix) && prefix != "" { + prefix = prefix[:len(prefix)-1] + } + if prefix == "" { + return "" + } + } + return prefix +} + +func splitFields(line string) []string { + const spaces = " \t\r\n\v\f" + var fields []string + for len(line) > 0 { + i := 0 + for i < len(line) && containsByte(spaces, line[i]) { + i++ + } + line = line[i:] + if len(line) == 0 { + break + } + i = 0 + for i < len(line) && !containsByte(spaces, line[i]) { + i++ + } + fields = append(fields, line[:i]) + line = line[i:] + } + return fields +} + +func containsByte(set string, b byte) bool { + return strings.IndexByte(set, b) >= 0 +} diff --git a/splitinput_windows.go b/splitinput_windows.go new file mode 100644 index 0000000..563cfe4 --- /dev/null +++ b/splitinput_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package main + +import "io" + +type splitInput struct{} + +func newSplitInput(_ interface{}, _ io.Writer, _ string, _ func([]string) ([]string, []string)) *splitInput { + return &splitInput{} +} +func (s *splitInput) Enter() io.Writer { return nil } +func (s *splitInput) Exit() ([]string, string) { return nil, "" } +func (s *splitInput) SetPrompt(_ string) {} +func (s *splitInput) PopQueue() (string, bool) { return "", false } +func (s *splitInput) EchoQueuedInput(_ string) {} +func (s *splitInput) runQueuedCommand(cmd queuedCommand) string { + _, msg := runQueuedCommand(nil, cmd) + return msg +}