refactor: promote backend/config to pkg/, extract TUI to ollie-tui
Move internal/backend and internal/config to pkg/ so external consumers can import them. Remove cmd/ollie and internal/tui — they now live in the separate ollie-tui repo which imports ollie as a library. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a3e1a078c4
commit
97b85e5163
|
|
@ -1,136 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ollie/internal/backend"
|
|
||||||
"ollie/internal/config"
|
|
||||||
execute "ollie/pkg/tools/execute"
|
|
||||||
"ollie/internal/tui"
|
|
||||||
"ollie/pkg/agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
sessionFlag := flag.String("session", "", "resume a session by ID")
|
|
||||||
promptFlag := flag.String("prompt", "", "run a single prompt non-interactively and exit")
|
|
||||||
flag.Parse()
|
|
||||||
extraArgs := flag.Args()
|
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
agentsDir := home + "/.config/ollie/agents"
|
|
||||||
sessionsDir := home + "/.config/ollie/sessions"
|
|
||||||
if err := os.MkdirAll(sessionsDir, 0700); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "sessions dir:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
be, err := backend.New()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "failed to create backend:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if modelName := os.Getenv("OLLIE_MODEL"); modelName != "" {
|
|
||||||
be.SetModel(modelName)
|
|
||||||
}
|
|
||||||
|
|
||||||
builtinExec := execute.New(
|
|
||||||
home+"/.local/state/ollie",
|
|
||||||
home+"/.cache/ollie/exec",
|
|
||||||
)
|
|
||||||
|
|
||||||
agentName := os.Getenv("OLLIE_AGENT")
|
|
||||||
if agentName == "" {
|
|
||||||
agentName = "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID := newSessionID()
|
|
||||||
var resumeMessages []backend.Message
|
|
||||||
if *sessionFlag != "" {
|
|
||||||
sessionPath := sessionsDir + "/" + *sessionFlag + ".json"
|
|
||||||
data, readErr := os.ReadFile(sessionPath)
|
|
||||||
if readErr != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "--session:", readErr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
var ps agent.PersistedSession
|
|
||||||
if jsonErr := json.Unmarshal(data, &ps); jsonErr != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "--session: bad JSON:", jsonErr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
sessionID = ps.ID
|
|
||||||
resumeMessages = ps.Messages
|
|
||||||
if ps.Agent != "" && len(extraArgs) == 0 {
|
|
||||||
agentName = ps.Agent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(extraArgs) > 0 {
|
|
||||||
agentName = extraArgs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
cfgPath := agentConfigPath(agentsDir, agentName)
|
|
||||||
cfg, cfgErr := config.Load(cfgPath)
|
|
||||||
env := agent.BuildAgentEnv(cfg, builtinExec)
|
|
||||||
|
|
||||||
var initialSession *agent.Session
|
|
||||||
if len(resumeMessages) > 0 {
|
|
||||||
initialSession = agent.RestoreSession(resumeMessages, env.CtxOverhead)
|
|
||||||
}
|
|
||||||
|
|
||||||
agentCore := agent.NewAgentCore(agent.AgentCoreConfig{
|
|
||||||
Backend: be,
|
|
||||||
AgentName: agentName,
|
|
||||||
AgentsDir: agentsDir,
|
|
||||||
SessionsDir: sessionsDir,
|
|
||||||
SessionID: sessionID,
|
|
||||||
Session: initialSession,
|
|
||||||
Env: env,
|
|
||||||
BuiltinExec: builtinExec,
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
env.Hooks.Run(agent.HookAgentSpawn)
|
|
||||||
|
|
||||||
if *promptFlag != "" {
|
|
||||||
agentCore.Submit(context.Background(), *promptFlag, tui.MakeOutputFn(os.Stdout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tui.New(agentCore).Run(context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func newSessionID() string {
|
|
||||||
b := make([]byte, 3)
|
|
||||||
rand.Read(b) //nolint:errcheck
|
|
||||||
return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentConfigPath(agentsDir, name string) string {
|
|
||||||
p := agentsDir + "/" + name + ".json"
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
if name == "default" {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return home + "/.config/ollie/config.json"
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const interruptCommandUsage = "/interrupt <prompt>"
|
|
||||||
|
|
||||||
func parseInterruptCommandLine(input string) (prompt string, ok bool, err error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
if !strings.HasPrefix(input, "/") {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
name, args, _ := strings.Cut(input[1:], " ")
|
|
||||||
if name != "interrupt" {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
prompt, err = parseInterruptCommandArgs(args)
|
|
||||||
return prompt, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInterruptCommandArgs(args string) (string, error) {
|
|
||||||
prompt := strings.TrimSpace(args)
|
|
||||||
if prompt == "" {
|
|
||||||
return "", fmt.Errorf("usage: %s", interruptCommandUsage)
|
|
||||||
}
|
|
||||||
if unquoted, err := strconv.Unquote(prompt); err == nil {
|
|
||||||
prompt = strings.TrimSpace(unquoted)
|
|
||||||
} else if len(prompt) >= 2 && prompt[0] == '\'' && prompt[len(prompt)-1] == '\'' {
|
|
||||||
prompt = strings.TrimSpace(prompt[1 : len(prompt)-1])
|
|
||||||
}
|
|
||||||
if prompt == "" {
|
|
||||||
return "", fmt.Errorf("usage: %s", interruptCommandUsage)
|
|
||||||
}
|
|
||||||
return prompt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func interruptQueuedMessage(prompt string) string {
|
|
||||||
return "◇ interrupt queued: " + prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
func interruptExpiredMessage(count int) string {
|
|
||||||
return fmt.Sprintf("◇ %d interrupt(s) expired (turn ended)", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func interruptQueueFullMessage() string {
|
|
||||||
return "Interrupt queue is full."
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
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:])
|
|
||||||
}
|
|
||||||
|
|
@ -1,900 +0,0 @@
|
||||||
//go:build !windows
|
|
||||||
|
|
||||||
package tui
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
//go:build windows
|
|
||||||
|
|
||||||
package tui
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
multiline "github.com/hymkor/go-multiline-ny"
|
|
||||||
gotty "github.com/mattn/go-tty"
|
|
||||||
readline "github.com/nyaosorg/go-readline-ny"
|
|
||||||
|
|
||||||
"ollie/pkg/agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// TUI is the terminal frontend. It owns all TUI state and drives a Core.
|
|
||||||
type TUI struct {
|
|
||||||
core agent.Core
|
|
||||||
split *splitInput
|
|
||||||
history recentHistory
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a TUI backed by the given Core.
|
|
||||||
func New(c agent.Core) *TUI {
|
|
||||||
return &TUI{core: c}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the interactive readline loop, blocking until the user exits.
|
|
||||||
func (t *TUI) Run(ctx context.Context) {
|
|
||||||
tt, err := gotty.Open()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "tty:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer tt.Close()
|
|
||||||
|
|
||||||
var ed multiline.Editor
|
|
||||||
restorePaste, err := setupBracketedPaste(tt, &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, t.core.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(&t.history)
|
|
||||||
ed.SetHistoryCycling(true)
|
|
||||||
|
|
||||||
t.split = newSplitInput(tt, tt.Output(), t.core.Prompt(), nil)
|
|
||||||
|
|
||||||
appCtx, appCancel := context.WithCancelCause(ctx)
|
|
||||||
agent.WatchSignals(appCancel, t.core, os.Stderr)
|
|
||||||
|
|
||||||
var lastCtrlC time.Time
|
|
||||||
firstRead := true
|
|
||||||
|
|
||||||
for appCtx.Err() == nil {
|
|
||||||
if firstRead {
|
|
||||||
firstRead = false
|
|
||||||
if _, h, err := tt.Size(); err == nil && h > 0 {
|
|
||||||
clearScreenAndMoveToBottom(tt.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) <= agent.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
|
|
||||||
}
|
|
||||||
|
|
||||||
t.history.entries = append(t.history.entries, input)
|
|
||||||
|
|
||||||
if len(lines) == 1 {
|
|
||||||
if w, _, err := tt.Size(); err == nil {
|
|
||||||
prompt := t.core.Prompt()
|
|
||||||
if shouldRerenderSubmittedSingleLine(prompt, input, w) {
|
|
||||||
rerenderSubmittedSingleLine(tt.Output(), prompt, input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.processInputWithSplit(appCtx, input, &ed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TUI) processInputWithSplit(ctx context.Context, input string, ed *multiline.Editor) {
|
|
||||||
// /queued is TUI-specific: it manipulates the split input queue.
|
|
||||||
if cmd, ok, err := parseQueuedCommandLine(input); ok {
|
|
||||||
var msg string
|
|
||||||
if err != nil {
|
|
||||||
msg = err.Error()
|
|
||||||
} else if t.split == nil {
|
|
||||||
_, msg = runQueuedCommand(nil, cmd)
|
|
||||||
} else {
|
|
||||||
msg = t.split.runQueuedCommand(cmd)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(os.Stdout, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.split == nil {
|
|
||||||
t.core.Submit(ctx, input, MakeOutputFn(os.Stdout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.split.SetPrompt(t.core.Prompt())
|
|
||||||
wrapper := t.split.Enter()
|
|
||||||
out := io.Writer(os.Stdout)
|
|
||||||
if wrapper != nil {
|
|
||||||
out = wrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := MakeOutputFn(out)
|
|
||||||
t.core.Submit(ctx, input, handler)
|
|
||||||
|
|
||||||
for ctx.Err() == nil {
|
|
||||||
q, ok := t.split.PopQueue()
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
t.split.SetPrompt(t.core.Prompt())
|
|
||||||
t.split.EchoQueuedInput(q)
|
|
||||||
t.history.entries = append(t.history.entries, q)
|
|
||||||
t.core.Submit(ctx, q, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, pending := t.split.Exit()
|
|
||||||
if pending != "" {
|
|
||||||
ed.SetDefault([]string{pending})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MakeOutputFn returns an EventHandler that renders events as text to out.
|
|
||||||
// This is the TUI's bridge from typed events to terminal output.
|
|
||||||
func MakeOutputFn(out io.Writer) agent.EventHandler {
|
|
||||||
return func(em agent.Event) {
|
|
||||||
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")
|
|
||||||
case "info":
|
|
||||||
fmt.Fprint(out, em.Content)
|
|
||||||
case "newline":
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func squashWhitespace(s string) string {
|
|
||||||
return strings.Join(strings.Fields(s), " ")
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ollie/internal/backend"
|
"ollie/pkg/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextConfig controls the bounded context window behaviour.
|
// contextConfig controls the bounded context window behaviour.
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import (
|
||||||
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
|
||||||
"ollie/internal/backend"
|
"ollie/pkg/backend"
|
||||||
"ollie/internal/config"
|
"ollie/pkg/config"
|
||||||
execute "ollie/pkg/tools/execute"
|
execute "ollie/pkg/tools/execute"
|
||||||
"ollie/pkg/mcp"
|
"ollie/pkg/mcp"
|
||||||
"ollie/pkg/tools"
|
"ollie/pkg/tools"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ollie/internal/backend"
|
"ollie/pkg/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxRateLimitRetries = 3
|
const maxRateLimitRetries = 3
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ollie/internal/backend"
|
"ollie/pkg/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PersistedSession is the on-disk format for a saved session.
|
// PersistedSession is the on-disk format for a saved session.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import "ollie/internal/backend"
|
import "ollie/pkg/backend"
|
||||||
|
|
||||||
// toolResult holds the output of a single tool call.
|
// toolResult holds the output of a single tool call.
|
||||||
type toolResult struct {
|
type toolResult struct {
|
||||||
|
|
|
||||||
Reference in New Issue