Restructure: pkg/core interface, internal/ packages, cmd/ollie TUI entry point

- pkg/core: public Core interface (Submit/Prompt/Interrupt) + Event/EventHandler types
- internal/agentcore: AgentCore implementation of Core (session, tools, commands)
- internal/tui: TUI package (readline loop, splitInput, signals, bracketed paste)
- internal/agent,backend,config,exec,mcp,tools: moved from root to internal/
- cmd/ollie: minimal main() that wires agentcore + tui together (~130 lines)
- mkfile: update build target to ./cmd/ollie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-09 21:00:04 +02:00
parent 868ae09364
commit c5b16e739e
34 changed files with 465 additions and 452 deletions

View File

@ -5,7 +5,7 @@ import (
"slices"
"strings"
"ollie/backend"
"ollie/internal/backend"
)
// ContextConfig controls the bounded context window behaviour.

View File

@ -8,7 +8,7 @@ import (
"strings"
"time"
"ollie/backend"
"ollie/internal/backend"
)
const maxRateLimitRetries = 3

View File

@ -7,7 +7,7 @@ import (
"os"
"strings"
"ollie/backend"
"ollie/internal/backend"
)
// PersistedSession is the on-disk format for a saved session.

View File

@ -1,6 +1,6 @@
package agent
import "ollie/backend"
import "ollie/internal/backend"
// ToolResult holds the output of a single tool call.
type ToolResult struct {

View File

@ -1,14 +1,11 @@
package main
package agentcore
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
@ -17,16 +14,15 @@ import (
"sync/atomic"
"time"
multiline "github.com/hymkor/go-multiline-ny"
gotty "github.com/mattn/go-tty"
readline "github.com/nyaosorg/go-readline-ny"
"crypto/rand"
"ollie/agent"
"ollie/backend"
"ollie/config"
execpkg "ollie/exec"
"ollie/mcp"
"ollie/tools"
"ollie/internal/agent"
"ollie/internal/backend"
"ollie/internal/config"
execpkg "ollie/internal/exec"
"ollie/internal/mcp"
"ollie/internal/tools"
"ollie/pkg/core"
)
const systemPromptBase = `Use the fewest words possible. No preamble, filler, or narration ("Let me...", "I'll now...", "Great!"). No explanations of actions taken. No summaries of completed work. No reasoning unless asked. If the answer is one word, write one word.
@ -48,7 +44,9 @@ Tool call examples:
Run named tool: {"tool": "discover_skill.sh", "args": ["keyword"]}
Pipeline: {"pipe": [{"code": "cat file.txt"}, {"code": "grep foo"}]}`
func buildFirstPrompt(input string) string {
// BuildFirstPrompt seeds the first user message with the project file listing
// and README so the agent has immediate context.
func BuildFirstPrompt(input string) string {
cwd, err := os.Getwd()
if err != nil {
return input
@ -123,7 +121,8 @@ func buildFirstPrompt(input string) string {
return sb.String()
}
func systemPrompt(allTools []backend.Tool) string {
// SystemPrompt builds the full system prompt for a given tool set.
func SystemPrompt(allTools []backend.Tool) string {
cwd, _ := os.Getwd()
now := time.Now().Format("2006-01-02 15:04:05 MST")
names := make([]string, len(allTools))
@ -135,7 +134,8 @@ func systemPrompt(allTools []backend.Tool) string {
"\nAvailable tools: " + strings.Join(names, ", ")
}
var builtinTools = []backend.Tool{
// BuiltinTools is the set of tools provided by the agent core.
var BuiltinTools = []backend.Tool{
{
Name: "execute_code",
Description: "Run inline code in a sandboxed environment.",
@ -214,21 +214,22 @@ var builtinTools = []backend.Tool{
},
}
// agentEnv holds the runtime state derived from an agent config file.
type agentEnv struct {
mcpExec *tools.Executor
tools []backend.Tool
exec agent.ToolExecutor
confirm *agent.ConfirmFn
hooks map[string]string
systemPrompt string
genParams backend.GenerationParams
ctxOverhead int
messages []string
invalidateCaches func()
// AgentEnv holds the runtime state derived from an agent config file.
type AgentEnv struct {
McpExec *tools.Executor
Tools []backend.Tool
Exec agent.ToolExecutor
Confirm *agent.ConfirmFn
Hooks map[string]string
SystemPrompt string
GenParams backend.GenerationParams
CtxOverhead int
Messages []string
InvalidateCaches func()
}
func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
// BuildAgentEnv constructs an AgentEnv from a config file and a builtin executor.
func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv {
var messages []string
mcpExec := tools.NewExecutor()
@ -260,7 +261,7 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
serverOf[t.Name] = t.Server
}
allTools := append(mcpToolsToBackend(mcpTools), builtinTools...)
allTools := append(mcpToolsToBackend(mcpTools), BuiltinTools...)
hooks := map[string]string{}
agentPrompt := ""
@ -282,9 +283,9 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
}
}
sp := systemPrompt(allTools)
sp := SystemPrompt(allTools)
if agentPrompt != "" {
sp = systemPrompt(allTools) + "\n\n" + agentPrompt
sp = SystemPrompt(allTools) + "\n\n" + agentPrompt
}
overhead := len(sp)
@ -292,8 +293,8 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
overhead += len(t.Name) + len(t.Description) + len(t.Parameters)
}
builtinNames := make(map[string]struct{}, len(builtinTools))
for _, t := range builtinTools {
builtinNames := make(map[string]struct{}, len(BuiltinTools))
for _, t := range BuiltinTools {
builtinNames[t.Name] = struct{}{}
}
@ -385,24 +386,25 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
return rawExec(ctx, name, args)
}
return agentEnv{
mcpExec: mcpExec,
tools: allTools,
exec: execFn,
confirm: confirmPtr,
hooks: hooks,
systemPrompt: sp,
genParams: genParams,
ctxOverhead: overhead,
messages: messages,
invalidateCaches: func() {
return AgentEnv{
McpExec: mcpExec,
Tools: allTools,
Exec: execFn,
Confirm: confirmPtr,
Hooks: hooks,
SystemPrompt: sp,
GenParams: genParams,
CtxOverhead: overhead,
Messages: messages,
InvalidateCaches: func() {
clear(fileReadCache)
clear(toolCallSeen)
},
}
}
func agentConfigPath(agentsDir, name string) string {
// AgentConfigPath resolves the config file path for a named agent.
func AgentConfigPath(agentsDir, name string) string {
p := agentsDir + "/" + name + ".json"
if _, err := os.Stat(p); err == nil {
return p
@ -414,14 +416,15 @@ func agentConfigPath(agentsDir, name string) string {
return p
}
func newSessionID() string {
// NewSessionID generates a unique session identifier.
func NewSessionID() string {
b := make([]byte, 3)
rand.Read(b) //nolint:errcheck
return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b)
}
// defaultModelForBackend returns a sensible default model for the given backend label.
func defaultModelForBackend(name string) string {
// DefaultModelForBackend returns a sensible default model for the given backend label.
func DefaultModelForBackend(name string) string {
switch name {
case "anthropic":
return "claude-sonnet-4-5"
@ -434,8 +437,8 @@ func defaultModelForBackend(name string) string {
}
}
// resolveBackendName returns a short human-readable backend label.
func resolveBackendName() string {
// ResolveBackendName returns a short human-readable backend label from env vars.
func ResolveBackendName() string {
which := os.Getenv("OLLIE_BACKEND")
if which == "" {
which = "ollama"
@ -462,13 +465,33 @@ func resolveBackendName() string {
}
}
// infoEvent wraps a plain-text message as an info Event.
func infoEvent(text string) core.Event {
return core.Event{Role: "info", Content: text + "\n"}
}
// actionHandle holds the cancel function for the current agent turn.
type actionHandle struct {
cancel context.CancelCauseFunc
}
// appState is the top-level runtime state, replacing the old bubbletea model.
type appState struct {
// AgentCoreConfig is the configuration for creating an AgentCore.
type AgentCoreConfig struct {
Backend backend.Backend
BackendName string
ModelName string
AgentName string
AgentsDir string
SessionsDir string
SessionID string
Session *agent.Session
Env AgentEnv
BuiltinExec *execpkg.Executor
}
// AgentCore is the Core implementation. It owns all agent and session state
// but has no knowledge of how output is rendered.
type AgentCore struct {
session *agent.Session
loopcfg agent.Config
hooks map[string]string
@ -483,31 +506,48 @@ type appState struct {
confirmPtr *agent.ConfirmFn
ctxOverhead int
invalidateCaches func()
split *splitInput
currentAction atomic.Pointer[actionHandle]
history recentHistory
}
// recentHistory implements readline.IHistory for the multiline editor.
type recentHistory struct {
entries []string
}
var _ core.Core = (*AgentCore)(nil) // compile-time interface check
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]
// NewAgentCore creates an AgentCore from the given configuration.
func NewAgentCore(cfg AgentCoreConfig) *AgentCore {
loopcfg := agent.Config{
Backend: cfg.Backend,
Model: cfg.ModelName,
SystemPrompt: cfg.Env.SystemPrompt,
Tools: cfg.Env.Tools,
Exec: cfg.Env.Exec,
MaxSteps: 20,
GenerationParams: cfg.Env.GenParams,
}
return &AgentCore{
session: cfg.Session,
loopcfg: loopcfg,
hooks: cfg.Env.Hooks,
modelName: cfg.ModelName,
backendName: cfg.BackendName,
agentName: cfg.AgentName,
agentsDir: cfg.AgentsDir,
sessionsDir: cfg.SessionsDir,
sessionID: cfg.SessionID,
mcpExec: cfg.Env.McpExec,
builtinExec: cfg.BuiltinExec,
confirmPtr: cfg.Env.Confirm,
ctxOverhead: cfg.Env.CtxOverhead,
invalidateCaches: cfg.Env.InvalidateCaches,
}
return ""
}
func (s *appState) prompt() string {
func (s *AgentCore) prompt() string {
return fmt.Sprintf("[%s :: %s] ", s.backendName, s.agentName)
}
func (s *appState) saveSession() {
// Prompt returns the display prompt string for the current session state.
func (s *AgentCore) Prompt() string { return s.prompt() }
func (s *AgentCore) saveSession() {
if s.session == nil || s.sessionID == "" || s.sessionsDir == "" {
return
}
@ -517,137 +557,28 @@ func (s *appState) saveSession() {
}
}
func (s *appState) getActionCancel() context.CancelCauseFunc {
func (s *AgentCore) getActionCancel() context.CancelCauseFunc {
if a := s.currentAction.Load(); a != nil {
return a.cancel
}
return nil
}
func (s *appState) runInteractiveTTY(ctx context.Context) {
t, err := gotty.Open()
if err != nil {
fmt.Fprintln(os.Stderr, "tty:", err)
return
}
defer t.Close()
var ed multiline.Editor
restorePaste, err := setupBracketedPaste(t, &ed)
if err != nil {
fmt.Fprintln(os.Stderr, "bracketed paste:", err)
} else {
defer restorePaste()
}
ed.SetPrompt(func(w io.Writer, lnum int) (int, error) {
if lnum == 0 {
return fmt.Fprint(w, s.prompt())
}
return fmt.Fprint(w, "... ")
})
ed.SubmitOnEnterWhen(func(lines []string, _ int) bool {
if len(lines) <= 1 {
return true
}
return !strings.HasSuffix(strings.TrimSpace(lines[len(lines)-1]), "\\")
})
ed.SetHistory(&s.history)
ed.SetHistoryCycling(true)
s.split = newSplitInput(t, t.Output(), s.prompt(), nil)
appCtx, appCancel := context.WithCancelCause(ctx)
startSignalWatcher(appCancel, s.getActionCancel, os.Stderr)
var lastCtrlC time.Time
firstRead := true
for appCtx.Err() == nil {
if firstRead {
firstRead = false
if _, h, err := t.Size(); err == nil && h > 0 {
clearScreenAndMoveToBottom(t.Output(), h)
}
}
lines, err := ed.Read(appCtx)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
errs := err.Error()
if errs == "interrupted" || errs == "^C" {
now := time.Now()
if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) <= ctrlCExitWindow {
break
}
lastCtrlC = now
fmt.Fprint(os.Stderr, "^C (press Ctrl-C again to exit)\n")
continue
}
fmt.Fprintln(os.Stderr, "input error:", err)
break
}
input := strings.Join(lines, "\n")
lastCtrlC = time.Time{}
if strings.TrimSpace(input) == "" {
continue
}
s.history.entries = append(s.history.entries, input)
if len(lines) == 1 {
if w, _, err := t.Size(); err == nil {
prompt := s.prompt()
if shouldRerenderSubmittedSingleLine(prompt, input, w) {
rerenderSubmittedSingleLine(t.Output(), prompt, input)
}
}
}
s.processInputWithSplit(appCtx, input, &ed)
// Interrupt cancels the current in-progress agent turn.
// Returns true if an action was running and was cancelled.
func (s *AgentCore) Interrupt(cause error) bool {
if cancel := s.getActionCancel(); cancel != nil {
cancel(cause)
return true
}
return false
}
func (s *appState) processInputWithSplit(ctx context.Context, input string, ed *multiline.Editor) {
if s.split == nil {
s.processInput(ctx, input, os.Stdout)
return
}
s.split.SetPrompt(s.prompt())
wrapper := s.split.Enter()
out := io.Writer(os.Stdout)
if wrapper != nil {
out = wrapper
}
s.processInput(ctx, input, out)
for ctx.Err() == nil {
q, ok := s.split.PopQueue()
if !ok {
break
}
s.split.SetPrompt(s.prompt())
s.split.EchoQueuedInput(q)
s.history.entries = append(s.history.entries, q)
s.processInput(ctx, q, out)
}
_, pending := s.split.Exit()
if pending != "" {
ed.SetDefault([]string{pending})
}
}
func (s *appState) processInput(ctx context.Context, input string, out io.Writer) {
if s.handleCommand(ctx, input, out) {
// Submit implements Core. It processes one line of user input: slash commands
// and shell shortcuts are dispatched immediately via handler; any other input
// starts an agent turn that streams events to handler.
func (s *AgentCore) Submit(ctx context.Context, input string, handler core.EventHandler) {
if s.handleCommand(ctx, input, handler) {
return
}
@ -656,7 +587,7 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer
}
if s.session == nil {
s.session = agent.NewSessionWithConfig(buildFirstPrompt(input), agent.ContextConfig{
s.session = agent.NewSessionWithConfig(BuildFirstPrompt(input), agent.ContextConfig{
FixedOverheadChars: s.ctxOverhead,
})
} else {
@ -667,7 +598,7 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer
handle := &actionHandle{cancel: actCancel}
s.currentAction.Store(handle)
s.loopcfg.Output = makeOutputFn(out)
s.loopcfg.Output = handler
*s.confirmPtr = nil // auto-approve all confirmations for now
if hook := s.hooks["agentSpawn"]; hook != "" {
@ -678,12 +609,12 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer
actCancel(nil)
s.currentAction.CompareAndSwap(handle, nil)
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrInterrupted) {
fmt.Fprintf(out, "error: %v\n", err)
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, core.ErrInterrupted) {
handler(core.Event{Role: "error", Content: err.Error()})
s.session.Rollback()
}
fmt.Fprintln(out)
handler(core.Event{Role: "newline"})
if hook := s.hooks["stop"]; hook != "" {
exec.Command("sh", "-c", hook).Run() //nolint:errcheck
@ -692,34 +623,7 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer
s.saveSession()
}
func makeOutputFn(out io.Writer) agent.OutputFn {
return func(em agent.OutputMsg) {
switch em.Role {
case "assistant":
fmt.Fprint(out, em.Content)
case "call":
args := squashWhitespace(em.Content)
if len(args) > 500 {
args = args[:500] + "..."
}
fmt.Fprintf(out, "-> %s(%s)\n", em.Name, args)
case "tool":
s := strings.TrimRight(em.Content, "\n")
if len(s) > 500 {
s = s[:500] + "..."
}
fmt.Fprintf(out, "= %s\n", s)
case "retry":
fmt.Fprintf(out, "retrying in %ss...\n", em.Content)
case "error":
fmt.Fprintf(out, "error: %s\n", em.Content)
case "stalled":
fmt.Fprintln(out, "agent stalled")
}
}
}
func (s *appState) handleCommand(ctx context.Context, input string, out io.Writer) bool {
func (s *AgentCore) handleCommand(ctx context.Context, input string, handler core.EventHandler) bool {
if strings.HasPrefix(input, "!") {
cmdStr := strings.TrimSpace(input[1:])
if cmdStr == "" {
@ -727,10 +631,10 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write
}
o, err := exec.Command("sh", "-c", cmdStr).CombinedOutput()
if err != nil {
fmt.Fprintf(out, "error: %v\n", err)
handler(infoEvent("error: " + err.Error()))
}
if len(o) > 0 {
fmt.Fprint(out, strings.TrimRight(string(o), "\n")+"\n")
handler(infoEvent(strings.TrimRight(string(o), "\n")))
}
return true
}
@ -748,58 +652,38 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write
args := parts[1:]
switch cmd {
case "/queued":
var qcmd queuedCommand
var err error
if len(args) == 0 {
qcmd, err = parseQueuedCommandArgs("")
} else {
qcmd, err = parseQueuedCommandArgs(strings.Join(args, " "))
}
if err != nil {
fmt.Fprintln(out, err)
return true
}
if s.split == nil {
_, msg := runQueuedCommand(nil, qcmd)
fmt.Fprintln(out, msg)
} else {
fmt.Fprintln(out, s.split.runQueuedCommand(qcmd))
}
return true
case "/backend":
if len(args) == 0 {
fmt.Fprintln(out, "error: /backend requires an argument (e.g., /backend ollama)")
handler(infoEvent("error: /backend requires an argument (e.g., /backend ollama)"))
return true
}
os.Setenv("OLLIE_BACKEND", args[0])
be, err := backend.New()
if err != nil {
fmt.Fprintf(out, "error: failed to switch backend: %v\n", err)
handler(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err)))
return true
}
s.loopcfg.Backend = be
s.backendName = resolveBackendName()
s.loopcfg.Model = defaultModelForBackend(s.backendName)
s.backendName = ResolveBackendName()
s.loopcfg.Model = DefaultModelForBackend(s.backendName)
s.modelName = s.loopcfg.Model
fmt.Fprintf(out, "switched backend to: %s (model: %s)\n", s.backendName, s.modelName)
handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", s.backendName, s.modelName)))
return true
case "/model":
if len(args) == 0 {
fmt.Fprintln(out, "error: /model requires an argument (e.g., /model qwen3:8b)")
handler(infoEvent("error: /model requires an argument (e.g., /model qwen3:8b)"))
return true
}
s.loopcfg.Model = args[0]
s.modelName = args[0]
fmt.Fprintf(out, "switched model to: %s\n", args[0])
handler(infoEvent("switched model to: " + args[0]))
return true
case "/agents":
entries, err := os.ReadDir(s.agentsDir)
if err != nil {
fmt.Fprintf(out, "agents: %v\n", err)
handler(infoEvent(fmt.Sprintf("agents: %v", err)))
return true
}
found := false
@ -812,63 +696,63 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write
if name == s.agentName {
marker = "* "
}
fmt.Fprintln(out, marker+name)
handler(infoEvent(marker + name))
found = true
}
if !found {
fmt.Fprintln(out, "no agents found in "+s.agentsDir)
handler(infoEvent("no agents found in " + s.agentsDir))
}
return true
case "/agent":
if len(args) == 0 {
fmt.Fprintf(out, "active agent: %s\n", s.agentName)
handler(infoEvent("active agent: " + s.agentName))
return true
}
name := args[0]
cfgPath := agentConfigPath(s.agentsDir, name)
cfgPath := AgentConfigPath(s.agentsDir, name)
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Fprintf(out, "error: agent %q: %v\n", name, err)
handler(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
return true
}
if s.mcpExec != nil {
s.mcpExec.Close()
}
env := buildAgentEnv(cfg, s.builtinExec)
s.mcpExec = env.mcpExec
s.hooks = env.hooks
s.loopcfg.SystemPrompt = env.systemPrompt
s.loopcfg.Tools = env.tools
s.loopcfg.Exec = env.exec
s.loopcfg.GenerationParams = env.genParams
s.ctxOverhead = env.ctxOverhead
s.confirmPtr = env.confirm
s.invalidateCaches = env.invalidateCaches
env := BuildAgentEnv(cfg, s.builtinExec)
s.mcpExec = env.McpExec
s.hooks = env.Hooks
s.loopcfg.SystemPrompt = env.SystemPrompt
s.loopcfg.Tools = env.Tools
s.loopcfg.Exec = env.Exec
s.loopcfg.GenerationParams = env.GenParams
s.ctxOverhead = env.CtxOverhead
s.confirmPtr = env.Confirm
s.invalidateCaches = env.InvalidateCaches
s.agentName = name
s.session = nil
s.sessionID = newSessionID()
for _, msg := range env.messages {
fmt.Fprintln(out, msg)
s.sessionID = NewSessionID()
for _, msg := range env.Messages {
handler(infoEvent(msg))
}
fmt.Fprintf(out, "agent: %s\n", name)
handler(infoEvent("agent: " + name))
return true
case "/compact":
if s.session == nil {
fmt.Fprintln(out, "nothing to compact")
handler(infoEvent("nothing to compact"))
return true
}
n, summary, err := s.session.Compact(ctx, s.loopcfg.Backend, s.loopcfg.Model)
if err != nil {
fmt.Fprintln(out, "compact error:", err)
handler(infoEvent("compact error: " + err.Error()))
} else if n == 0 {
fmt.Fprintln(out, "nothing to compact")
handler(infoEvent("nothing to compact"))
} else {
fmt.Fprintf(out, "compacted %d messages\n", n)
handler(infoEvent(fmt.Sprintf("compacted %d messages", n)))
if summary != "" {
fmt.Fprintln(out)
fmt.Fprintln(out, summary)
handler(core.Event{Role: "newline"})
handler(infoEvent(summary))
}
s.saveSession()
if s.invalidateCaches != nil {
@ -879,15 +763,15 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write
case "/context":
if s.session == nil {
fmt.Fprintln(out, "no active session")
handler(infoEvent("no active session"))
return true
}
fmt.Fprintln(out, s.session.ContextDebug())
handler(infoEvent(strings.TrimRight(s.session.ContextDebug(), "\n")))
return true
case "/history":
if s.session == nil {
fmt.Fprintln(out, "no active session")
handler(infoEvent("no active session"))
return true
}
for _, msg := range s.session.History() {
@ -895,23 +779,23 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write
if len(preview) > 200 {
preview = preview[:200] + "..."
}
fmt.Fprintf(out, "[%s] %s\n", msg.Role, preview)
handler(infoEvent(fmt.Sprintf("[%s] %s", msg.Role, preview)))
}
return true
case "/clear":
s.session = nil
s.sessionID = newSessionID()
s.sessionID = NewSessionID()
if s.invalidateCaches != nil {
s.invalidateCaches()
}
fmt.Fprintln(out, "cleared")
handler(infoEvent("cleared"))
return true
case "/sessions":
entries, err := os.ReadDir(s.sessionsDir)
if err != nil {
fmt.Fprintf(out, "sessions: %v\n", err)
handler(infoEvent(fmt.Sprintf("sessions: %v", err)))
return true
}
found := false
@ -942,161 +826,39 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write
label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)
}
}
fmt.Fprintln(out, marker+label)
handler(infoEvent(marker + label))
found = true
}
if !found {
fmt.Fprintln(out, "no sessions found in "+s.sessionsDir)
handler(infoEvent("no sessions found in " + s.sessionsDir))
}
return true
case "/help":
fmt.Fprintln(out, "Available commands:")
fmt.Fprintln(out, " /agents - list available agent configs")
fmt.Fprintln(out, " /sessions - list saved sessions")
fmt.Fprintln(out, " /agent [name] - show or switch active agent")
fmt.Fprintln(out, " /backend <type> - switch backend (ollama, openai)")
fmt.Fprintln(out, " /model <name> - switch model")
fmt.Fprintln(out, " /queued [pop|clear] - manage queued prompts")
fmt.Fprintln(out, " /compact - summarize evicted context messages")
fmt.Fprintln(out, " /context - show context window debug info")
fmt.Fprintln(out, " /history - dump bounded message history")
fmt.Fprintln(out, " /clear - clear session")
fmt.Fprintln(out, " /help - show this help")
fmt.Fprintln(out, " !<cmd> - run shell command")
lines := []string{
"Available commands:",
" /agents - list available agent configs",
" /sessions - list saved sessions",
" /agent [name] - show or switch active agent",
" /backend <type> - switch backend (ollama, openai)",
" /model <name> - switch model",
" /queued [pop|clear] - manage queued prompts",
" /compact - summarize evicted context messages",
" /context - show context window debug info",
" /history - dump bounded message history",
" /clear - clear session",
" /help - show this help",
" !<cmd> - run shell command",
}
for _, l := range lines {
handler(infoEvent(l))
}
return true
}
return false
}
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)
}
backendName := resolveBackendName()
modelName := os.Getenv("OLLIE_MODEL")
if modelName == "" {
modelName = defaultModelForBackend(backendName)
}
builtinExec := execpkg.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 := buildAgentEnv(cfg, builtinExec)
var initialSession *agent.Session
if len(resumeMessages) > 0 {
initialSession = agent.RestoreSession(resumeMessages, agent.ContextConfig{
FixedOverheadChars: env.ctxOverhead,
})
}
loopcfg := agent.Config{
Backend: be,
Model: modelName,
SystemPrompt: env.systemPrompt,
Tools: env.tools,
Exec: env.exec,
MaxSteps: 20,
GenerationParams: env.genParams,
}
s := &appState{
session: initialSession,
loopcfg: loopcfg,
hooks: env.hooks,
modelName: modelName,
backendName: backendName,
agentName: agentName,
agentsDir: agentsDir,
sessionsDir: sessionsDir,
sessionID: sessionID,
mcpExec: env.mcpExec,
builtinExec: builtinExec,
confirmPtr: env.confirm,
ctxOverhead: env.ctxOverhead,
invalidateCaches: env.invalidateCaches,
}
if cfgErr != nil {
fmt.Fprintln(os.Stderr, "agent config:", cfgErr)
}
for _, msg := range env.messages {
fmt.Fprintln(os.Stderr, msg)
}
if len(resumeMessages) > 0 {
fmt.Fprintf(os.Stderr, "session: %s (resumed)\n", sessionID)
} else {
fmt.Fprintf(os.Stderr, "session: %s\n", sessionID)
}
if hook := env.hooks["agentSpawn"]; hook != "" {
exec.Command("sh", "-c", hook).Run() //nolint:errcheck
}
if *promptFlag != "" {
s.processInput(context.Background(), *promptFlag, os.Stdout)
return
}
s.runInteractiveTTY(context.Background())
}
func squashWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func mcpToolsToBackend(mcpTools []tools.ToolInfo) []backend.Tool {
out := make([]backend.Tool, len(mcpTools))
for i, t := range mcpTools {
@ -1332,3 +1094,7 @@ func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, e
}
return fmt.Sprintf("replaced lines %d-%d in %s", start, end, a.Path), nil
}
func squashWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}

View File

@ -3,7 +3,7 @@ package tools
import (
"encoding/json"
"fmt"
"ollie/mcp"
"ollie/internal/mcp"
)
// ToolInfo describes a tool provided by an MCP server.

View File

@ -1,4 +1,4 @@
package main
package tui
import (
"fmt"

View File

@ -1,4 +1,4 @@
package main
package tui
import (
"fmt"

View File

@ -1,4 +1,4 @@
package main
package tui
import (
"fmt"

View File

@ -1,4 +1,4 @@
package main
package tui
import (
"fmt"

View File

@ -1,22 +1,19 @@
package main
package tui
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"time"
)
var ErrInterrupted = errors.New("interrupted")
"ollie/pkg/core"
)
const ctrlCExitWindow = 750 * time.Millisecond
type ActionCanceler func() context.CancelCauseFunc
func startSignalWatcher(appCancel context.CancelCauseFunc, getActionCancel ActionCanceler, errStream io.Writer) {
func startSignalWatcher(appCancel context.CancelCauseFunc, c core.Core, errStream io.Writer) {
ch := make(chan os.Signal, 16)
signals := []os.Signal{os.Interrupt}
if haveSIGTERM {
@ -34,10 +31,8 @@ func startSignalWatcher(appCancel context.CancelCauseFunc, getActionCancel Actio
appCancel(context.Canceled)
continue
case os.Interrupt:
if cancel := getActionCancel(); cancel != nil {
if c.Interrupt(core.ErrInterrupted) {
fmt.Fprint(errStream, "\n^C\n")
cancel(ErrInterrupted)
continue
}
}
}

View File

@ -1,6 +1,6 @@
//go:build !windows
package main
package tui
import "syscall"

View File

@ -1,6 +1,6 @@
//go:build windows
package main
package tui
import "os"

View File

@ -1,6 +1,6 @@
//go:build !windows
package main
package tui
import (
"fmt"

View File

@ -1,6 +1,6 @@
//go:build windows
package main
package tui
import "io"

219
internal/tui/tui.go Normal file
View File

@ -0,0 +1,219 @@
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/core"
)
// 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 core.Core
split *splitInput
history recentHistory
}
// New creates a TUI backed by the given Core.
func New(c core.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)
startSignalWatcher(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) <= 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) core.EventHandler {
return func(em core.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), " ")
}

View File

@ -1 +0,0 @@
package main

2
mkfile
View File

@ -3,7 +3,7 @@ INSTALL_PATH=$HOME/bin
all:V: install
build:V:
go build -o $INSTALL_PATH/ollie
go build -o $INSTALL_PATH/ollie ./cmd/ollie
mkdir -p $HOME/.config/ollie
cp -rf agents $HOME/.config/ollie

34
pkg/core/core.go Normal file
View File

@ -0,0 +1,34 @@
package core
import (
"context"
"errors"
"ollie/internal/agent"
)
// ErrInterrupted is returned when the user cancels an agent turn (Ctrl-C).
var ErrInterrupted = errors.New("interrupted")
// Event is a typed output event emitted by the Core during an agent turn
// or in response to a command.
type Event = agent.OutputMsg
// EventHandler receives events from the Core.
type EventHandler = agent.OutputFn
// Core is the interface between a frontend (TUI, HTTP handler, etc.) and the
// agent engine. All output from the agent is delivered via EventHandler.
type Core interface {
// Submit processes one line of user input. Slash commands and shell
// shortcuts are dispatched synchronously; any other input starts an agent
// turn that streams events to handler until the turn is complete.
Submit(ctx context.Context, input string, handler EventHandler)
// Prompt returns the display prompt string for the current session state.
Prompt() string
// Interrupt cancels the current in-progress agent turn.
// Returns true if an action was running and was cancelled.
Interrupt(cause error) bool
}