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 <noreply@anthropic.com>
This commit is contained in:
parent
124e4d4e0e
commit
4d4c1708e6
|
|
@ -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
|
||||
}
|
||||
41
go.mod
41
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
|
||||
)
|
||||
|
|
|
|||
53
go.sum
53
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=
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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:])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
const (
|
||||
haveSIGTERM = true
|
||||
SIGTERM = syscall.SIGTERM
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
const haveSIGTERM = false
|
||||
|
||||
var SIGTERM = os.Signal(nil)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Reference in New Issue