diff --git a/cmd/ollie/main.go b/cmd/ollie/main.go deleted file mode 100644 index 1578d62..0000000 --- a/cmd/ollie/main.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/bracketed_paste.go b/internal/tui/bracketed_paste.go deleted file mode 100644 index 885e9d0..0000000 --- a/internal/tui/bracketed_paste.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/interrupt_commands.go b/internal/tui/interrupt_commands.go deleted file mode 100644 index aeefbb4..0000000 --- a/internal/tui/interrupt_commands.go +++ /dev/null @@ -1,50 +0,0 @@ -package tui - -import ( - "fmt" - "strconv" - "strings" -) - -const interruptCommandUsage = "/interrupt " - -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." -} diff --git a/internal/tui/queued_commands.go b/internal/tui/queued_commands.go deleted file mode 100644 index 9d33f32..0000000 --- a/internal/tui/queued_commands.go +++ /dev/null @@ -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." - } -} diff --git a/internal/tui/rerender_input.go b/internal/tui/rerender_input.go deleted file mode 100644 index e545a81..0000000 --- a/internal/tui/rerender_input.go +++ /dev/null @@ -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:]) -} diff --git a/internal/tui/splitinput.go b/internal/tui/splitinput.go deleted file mode 100644 index 7796d2d..0000000 --- a/internal/tui/splitinput.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/splitinput_windows.go b/internal/tui/splitinput_windows.go deleted file mode 100644 index c1444e5..0000000 --- a/internal/tui/splitinput_windows.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index 16be88c..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -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), " ") -} diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 513fbd1..24802cf 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - "ollie/internal/backend" + "ollie/pkg/backend" ) // contextConfig controls the bounded context window behaviour. diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 52d8eb6..a761f66 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -16,8 +16,8 @@ import ( "crypto/rand" - "ollie/internal/backend" - "ollie/internal/config" + "ollie/pkg/backend" + "ollie/pkg/config" execute "ollie/pkg/tools/execute" "ollie/pkg/mcp" "ollie/pkg/tools" diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8e98711..3c290d3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "ollie/internal/backend" + "ollie/pkg/backend" ) const maxRateLimitRetries = 3 diff --git a/pkg/agent/session.go b/pkg/agent/session.go index 58678db..e7cdd77 100644 --- a/pkg/agent/session.go +++ b/pkg/agent/session.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "ollie/internal/backend" + "ollie/pkg/backend" ) // PersistedSession is the on-disk format for a saved session. diff --git a/pkg/agent/state.go b/pkg/agent/state.go index 97dc9f5..85f2601 100644 --- a/pkg/agent/state.go +++ b/pkg/agent/state.go @@ -1,6 +1,6 @@ package agent -import "ollie/internal/backend" +import "ollie/pkg/backend" // toolResult holds the output of a single tool call. type toolResult struct { diff --git a/internal/backend/anthropic.go b/pkg/backend/anthropic.go similarity index 100% rename from internal/backend/anthropic.go rename to pkg/backend/anthropic.go diff --git a/internal/backend/backend.go b/pkg/backend/backend.go similarity index 100% rename from internal/backend/backend.go rename to pkg/backend/backend.go diff --git a/internal/backend/codewhisperer.go b/pkg/backend/codewhisperer.go similarity index 100% rename from internal/backend/codewhisperer.go rename to pkg/backend/codewhisperer.go diff --git a/internal/backend/codewhisperer_internal.go b/pkg/backend/codewhisperer_internal.go similarity index 100% rename from internal/backend/codewhisperer_internal.go rename to pkg/backend/codewhisperer_internal.go diff --git a/internal/backend/copilot.go b/pkg/backend/copilot.go similarity index 100% rename from internal/backend/copilot.go rename to pkg/backend/copilot.go diff --git a/internal/backend/new.go b/pkg/backend/new.go similarity index 100% rename from internal/backend/new.go rename to pkg/backend/new.go diff --git a/internal/backend/ollama.go b/pkg/backend/ollama.go similarity index 100% rename from internal/backend/ollama.go rename to pkg/backend/ollama.go diff --git a/internal/backend/openai.go b/pkg/backend/openai.go similarity index 100% rename from internal/backend/openai.go rename to pkg/backend/openai.go diff --git a/internal/config/config.go b/pkg/config/config.go similarity index 100% rename from internal/config/config.go rename to pkg/config/config.go diff --git a/internal/config/config_test.go b/pkg/config/config_test.go similarity index 100% rename from internal/config/config_test.go rename to pkg/config/config_test.go