feat: rate limit pools, elf tree view, permission prompts, dep updates

Rate limits:
- Add PoolRPS/PoolTPM/PoolTokensMonth/PoolCostMonth pool kinds
- Provider defaults for Mistral/Anthropic/OpenAI/Google (tier-aware)
- Config override via [rate_limits.<provider>] TOML section
- Pools auto-attached to arms on registration

Elf tree view (CC-style):
- Structured elf.Progress type replaces flat string channel
- Tree with ├─/└─ branches, per-elf stats (tool uses, tokens)
- Live activity updates: tool calls, "generating… (N chars)"
- Completed elfs stay in tree with "Done (duration)" until turn ends
- Suppress raw elf output from chat (tree + LLM summary instead)
- Remove background elf mode (wait: false) — always wait
- Truncate elf results to 2000 chars for parent context
- Parallel hint in system prompt and tool description

Permission prompts:
- Show actual command in prompt: "bash wants to execute: find . -name '*.go'"
- Compact hint in separator bar: "⚠ bash: find . | wc -l [y/n]"
- PermReqMsg carries tool name + args

Other:
- Fix /model not updating status bar (session.Local.SetModel)
- Add make targets: run, check, install
- Update deps: BurntSushi/toml v1.6.0, chroma v2.23.1, x/text v0.35.0, cloud.google.com/go v0.123.0
This commit is contained in:
2026-04-03 20:54:48 +02:00
parent 1f416bac8f
commit 706363f94b
15 changed files with 1030 additions and 253 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: build test lint cover clean fmt vet .PHONY: build run check install test lint cover clean fmt vet
BINARY := gnoma BINARY := gnoma
BINDIR := ./bin BINDIR := ./bin
@@ -7,6 +7,15 @@ MODULE := somegit.dev/Owlibou/gnoma
build: build:
go build -o $(BINDIR)/$(BINARY) ./cmd/gnoma go build -o $(BINDIR)/$(BINARY) ./cmd/gnoma
run: build
$(BINDIR)/$(BINARY)
check: fmt vet lint test
@echo "All checks passed!"
install:
go install $(MODULE)/cmd/$(BINARY)
test: test:
go test ./... go test ./...

View File

@@ -154,14 +154,19 @@ func main() {
armModel = prov.DefaultModel() armModel = prov.DefaultModel()
} }
armID := router.NewArmID(*providerName, armModel) armID := router.NewArmID(*providerName, armModel)
rtr.RegisterArm(&router.Arm{ arm := &router.Arm{
ID: armID, ID: armID,
Provider: prov, Provider: prov,
ModelName: armModel, ModelName: armModel,
IsLocal: localProviders[*providerName], IsLocal: localProviders[*providerName],
Capabilities: provider.Capabilities{ToolUse: true}, // trust CLI provider Capabilities: provider.Capabilities{ToolUse: true}, // trust CLI provider
}) }
arm.Pools = resolveRateLimitPools(armID, *providerName, armModel, cfg)
rtr.RegisterArm(arm)
rtr.ForceArm(armID) rtr.ForceArm(armID)
if len(arm.Pools) > 0 {
logger.Debug("rate limit pools attached", "arm", armID, "pools", len(arm.Pools))
}
// Discover local models (ollama + llama.cpp) and register as additional arms // Discover local models (ollama + llama.cpp) and register as additional arms
localModels := router.DiscoverLocalModels(context.Background(), logger, localModels := router.DiscoverLocalModels(context.Background(), logger,
@@ -185,7 +190,7 @@ func main() {
Tools: reg, Tools: reg,
Logger: logger, Logger: logger,
}) })
elfProgressCh := make(chan string, 1) elfProgressCh := make(chan elf.Progress, 16)
agentTool := agent.New(elfMgr) agentTool := agent.New(elfMgr)
agentTool.SetProgressCh(elfProgressCh) agentTool.SetProgressCh(elfProgressCh)
reg.Register(agentTool) reg.Register(agentTool)
@@ -292,11 +297,11 @@ func main() {
} else { } else {
// TUI mode: permission prompts via channels // TUI mode: permission prompts via channels
permCh := make(chan bool) // TUI → engine: y/n response permCh := make(chan bool) // TUI → engine: y/n response
permReqCh := make(chan string, 1) // engine → TUI: tool name requesting permission permReqCh := make(chan tui.PermReqMsg, 1) // engine → TUI: tool requesting permission
permChecker.SetPromptFunc(func(ctx context.Context, toolName string, args json.RawMessage) (bool, error) { permChecker.SetPromptFunc(func(ctx context.Context, toolName string, args json.RawMessage) (bool, error) {
// Notify TUI that a permission prompt is needed // Notify TUI that a permission prompt is needed
select { select {
case permReqCh <- toolName: case permReqCh <- tui.PermReqMsg{ToolName: toolName, Args: args}:
default: default:
} }
// Block until TUI responds // Block until TUI responds
@@ -432,6 +437,45 @@ func buildToolRegistry() *tool.Registry {
return reg return reg
} }
// resolveRateLimitPools builds limit pools for an arm from provider defaults + config overrides.
func resolveRateLimitPools(armID router.ArmID, provName, modelName string, cfg *gnomacfg.Config) []*router.LimitPool {
defaults := provider.DefaultRateLimits(provName)
rl, _ := defaults.LookupModel(modelName)
// Apply config overrides
if cfg.RateLimits != nil {
if override, ok := cfg.RateLimits[provName]; ok {
if override.RPS > 0 {
rl.RPS = override.RPS
}
if override.RPM > 0 {
rl.RPM = override.RPM
}
if override.RPD > 0 {
rl.RPD = override.RPD
}
if override.TPM > 0 {
rl.TPM = override.TPM
}
if override.ITPM > 0 {
rl.ITPM = override.ITPM
}
if override.OTPM > 0 {
rl.OTPM = override.OTPM
}
if override.TokensMonth > 0 {
rl.TokensMonth = override.TokensMonth
}
if override.SpendCap > 0 {
rl.SpendCap = override.SpendCap
}
}
}
return router.PoolsFromRateLimits(armID, rl)
}
const defaultSystem = `You are gnoma, a provider-agnostic agentic coding assistant. const defaultSystem = `You are gnoma, a provider-agnostic agentic coding assistant.
You help users with software engineering tasks by reading files, writing code, and executing commands. You help users with software engineering tasks by reading files, writing code, and executing commands.
Be concise and direct. Use tools when needed to accomplish the task.` Be concise and direct. Use tools when needed to accomplish the task.
When spawning multiple elfs (sub-agents), call ALL agent tools in a single response so they run in parallel. Do NOT spawn one elf, wait for its result, then spawn the next.`

43
go.mod
View File

@@ -7,22 +7,23 @@ require (
charm.land/bubbletea/v2 v2.0.2 charm.land/bubbletea/v2 v2.0.2
charm.land/glamour/v2 v2.0.0 charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2
github.com/BurntSushi/toml v0.3.1 github.com/BurntSushi/toml v1.6.0
github.com/VikingOwl91/mistral-go-sdk v1.3.0 github.com/VikingOwl91/mistral-go-sdk v1.3.0
github.com/anthropics/anthropic-sdk-go v1.29.0 github.com/anthropics/anthropic-sdk-go v1.29.0
github.com/openai/openai-go v1.12.0 github.com/openai/openai-go v1.12.0
golang.org/x/text v0.27.0 golang.org/x/text v0.35.0
google.golang.org/genai v1.52.1 google.golang.org/genai v1.52.1
mvdan.cc/sh/v3 v3.13.0 mvdan.cc/sh/v3 v3.13.0
) )
require ( require (
cloud.google.com/go v0.116.0 // indirect cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.9.3 // indirect cloud.google.com/go/auth v0.19.0 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
@@ -32,11 +33,14 @@ require (
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -51,12 +55,17 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/crypto v0.40.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
golang.org/x/net v0.41.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect
golang.org/x/sync v0.19.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/api v0.267.0 // indirect
google.golang.org/grpc v1.66.2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
) )

201
go.sum
View File

@@ -6,27 +6,24 @@ charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/VikingOwl91/mistral-go-sdk v1.2.1 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOrQPnyoVKlpkA=
github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
github.com/VikingOwl91/mistral-go-sdk v1.3.0 h1:OkTsodDE5lmdf7p2cwScqD2vIk8sScQ2IGk65dUjuz0= github.com/VikingOwl91/mistral-go-sdk v1.3.0 h1:OkTsodDE5lmdf7p2cwScqD2vIk8sScQ2IGk65dUjuz0=
github.com/VikingOwl91/mistral-go-sdk v1.3.0/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4= github.com/VikingOwl91/mistral-go-sdk v1.3.0/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anthropics/anthropic-sdk-go v1.29.0 h1:7h1ZyRflhtxyuFkdwkVuJ1LdFAYdmizvgg0gd1uvOfI= github.com/anthropics/anthropic-sdk-go v1.29.0 h1:7h1ZyRflhtxyuFkdwkVuJ1LdFAYdmizvgg0gd1uvOfI=
github.com/anthropics/anthropic-sdk-go v1.29.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8= github.com/anthropics/anthropic-sdk-go v1.29.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -35,7 +32,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
@@ -52,52 +50,37 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -120,19 +103,12 @@ github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -150,82 +126,51 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk= google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg=
mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM=

View File

@@ -7,6 +7,7 @@ type Config struct {
Provider ProviderSection `toml:"provider"` Provider ProviderSection `toml:"provider"`
Permission PermissionSection `toml:"permission"` Permission PermissionSection `toml:"permission"`
Tools ToolsSection `toml:"tools"` Tools ToolsSection `toml:"tools"`
RateLimits RateLimitSection `toml:"rate_limits"`
} }
type PermissionSection struct { type PermissionSection struct {
@@ -34,6 +35,34 @@ type ToolsSection struct {
MaxFileSize int64 `toml:"max_file_size"` MaxFileSize int64 `toml:"max_file_size"`
} }
// RateLimitSection allows overriding default rate limits per provider.
//
// Example config:
//
// [rate_limits.mistral]
// tier = "starter"
// rps = 1
// spend_cap = 20.0
//
// [rate_limits.anthropic]
// tier = "tier2"
// rpm = 1000
// itpm = 450000
// otpm = 90000
type RateLimitSection map[string]RateLimitOverride
type RateLimitOverride struct {
Tier string `toml:"tier"`
RPS float64 `toml:"rps"`
RPM int `toml:"rpm"`
RPD int `toml:"rpd"`
TPM int `toml:"tpm"`
ITPM int `toml:"itpm"`
OTPM int `toml:"otpm"`
TokensMonth int64 `toml:"tokens_month"`
SpendCap float64 `toml:"spend_cap"`
}
// Duration wraps time.Duration for TOML string parsing (e.g. "30s", "5m"). // Duration wraps time.Duration for TOML string parsing (e.g. "30s", "5m").
type Duration time.Duration type Duration time.Duration

15
internal/elf/progress.go Normal file
View File

@@ -0,0 +1,15 @@
package elf
import "time"
// Progress carries structured status from an active elf to the TUI.
type Progress struct {
ElfID string // unique elf identifier
Description string // task prompt (truncated)
ToolUses int // total tool calls completed
Tokens int // tokens consumed (input+output)
Activity string // current line: "⚙ [bash] running…", "→ output…", ""
Done bool // true when elf completes
Error string // non-empty on failure
Duration time.Duration // set on Done
}

View File

@@ -0,0 +1,128 @@
package provider
// RateLimits describes the rate limits for a provider+model pair.
// Zero values mean "no limit" or "unknown".
type RateLimits struct {
RPS float64 // requests per second (Mistral global)
RPM int // requests per minute
RPD int // requests per day
TPM int // tokens per minute (combined input+output)
ITPM int // input tokens per minute (Anthropic)
OTPM int // output tokens per minute (Anthropic)
TokensMonth int64 // tokens per month
SpendCap float64 // monthly spend cap in provider currency
}
// ProviderDefaults holds default rate limits keyed by model glob.
// The special key "*" matches any model not explicitly listed.
type ProviderDefaults struct {
Provider string
Tier string // "free", "tier1", "tier2", etc.
Models map[string]RateLimits
}
// DefaultRateLimits returns conservative defaults for known providers.
// These are "starter tier" limits — users should override via config.
func DefaultRateLimits(providerName string) ProviderDefaults {
switch providerName {
case "mistral":
return mistralDefaults()
case "anthropic":
return anthropicDefaults()
case "openai":
return openaiDefaults()
case "google":
return googleDefaults()
default:
return ProviderDefaults{Provider: providerName}
}
}
// LookupModel finds rate limits for a specific model, falling back to "*".
func (pd ProviderDefaults) LookupModel(model string) (RateLimits, bool) {
if rl, ok := pd.Models[model]; ok {
return rl, true
}
if rl, ok := pd.Models["*"]; ok {
return rl, true
}
return RateLimits{}, false
}
func mistralDefaults() ProviderDefaults {
// Starter tier from Mistral dashboard. Spend cap is variable — not hardcoded.
base := RateLimits{RPS: 1, TPM: 50_000, TokensMonth: 4_000_000}
return ProviderDefaults{
Provider: "mistral",
Tier: "starter",
Models: map[string]RateLimits{
"*": base,
// Magistral models get higher limits
"magistral-medium-2509": {RPS: 1, TPM: 75_000, TokensMonth: 1_000_000_000},
"magistral-small-2509": {RPS: 1, TPM: 75_000, TokensMonth: 1_000_000_000},
// Large/medium get higher TPM
"mistral-large-2411": {RPS: 1, TPM: 600_000, TokensMonth: 200_000_000_000},
"mistral-large-latest": {RPS: 1, TPM: 50_000, TokensMonth: 4_000_000},
"mistral-medium-2505": {RPS: 1, TPM: 375_000},
"mistral-medium-2508": {RPS: 1, TPM: 375_000},
"mistral-small-2603": {RPS: 1, TPM: 375_000},
// Codestral
"codestral-2508": {RPS: 1, TPM: 50_000, TokensMonth: 4_000_000},
// Pixtral
"pixtral-large-2411": {RPS: 1, TPM: 50_000, TokensMonth: 4_000_000},
},
}
}
func anthropicDefaults() ProviderDefaults {
// Tier 1 (lowest paid tier, $5 deposit). Users on higher tiers override via config.
return ProviderDefaults{
Provider: "anthropic",
Tier: "tier1",
Models: map[string]RateLimits{
"*": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
// Claude 4.x Opus (shared across 4, 4.1, 4.5, 4.6)
"claude-opus-4-20250514": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
"claude-opus-4-0": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
// Claude 4.x Sonnet (shared across 4, 4.5, 4.6)
"claude-sonnet-4-20250514": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
"claude-sonnet-4-0": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
// Haiku
"claude-haiku-4-5-20251001": {RPM: 50, ITPM: 50_000, OTPM: 10_000},
"claude-3-5-haiku-20241022": {RPM: 50, ITPM: 50_000, OTPM: 10_000},
},
}
}
func openaiDefaults() ProviderDefaults {
// Tier 1 ($5 paid). Higher tiers have dramatically higher limits.
return ProviderDefaults{
Provider: "openai",
Tier: "tier1",
Models: map[string]RateLimits{
"*": {RPM: 500, TPM: 30_000, RPD: 10_000},
"gpt-4o": {RPM: 500, TPM: 30_000, RPD: 10_000},
"gpt-4o-mini": {RPM: 500, TPM: 200_000, RPD: 10_000},
"o1": {RPM: 500, TPM: 30_000},
"o3": {RPM: 500, TPM: 30_000},
"o3-mini": {RPM: 500, TPM: 200_000},
"o4-mini": {RPM: 500, TPM: 200_000},
},
}
}
func googleDefaults() ProviderDefaults {
// Free tier. Pay-as-you-go Tier 1 is significantly higher.
return ProviderDefaults{
Provider: "google",
Tier: "free",
Models: map[string]RateLimits{
"*": {RPM: 15, TPM: 250_000, RPD: 250},
"gemini-2.5-pro": {RPM: 5, TPM: 250_000, RPD: 100},
"gemini-2.5-pro-preview-05-06": {RPM: 5, TPM: 250_000, RPD: 100},
"gemini-2.5-flash": {RPM: 15, TPM: 250_000, RPD: 250},
"gemini-2.5-flash-preview-04-17": {RPM: 15, TPM: 250_000, RPD: 250},
"gemini-2.0-flash": {RPM: 10, RPD: 1_500},
},
}
}

View File

@@ -0,0 +1,117 @@
package provider
import "testing"
func TestDefaultRateLimits_Mistral(t *testing.T) {
defaults := DefaultRateLimits("mistral")
if defaults.Provider != "mistral" {
t.Errorf("Provider = %q, want mistral", defaults.Provider)
}
// Wildcard should match unknown models
rl, ok := defaults.LookupModel("some-unknown-model")
if !ok {
t.Fatal("wildcard should match unknown models")
}
if rl.RPS != 1 {
t.Errorf("RPS = %v, want 1", rl.RPS)
}
if rl.TPM != 50_000 {
t.Errorf("TPM = %d, want 50000", rl.TPM)
}
if rl.TokensMonth != 4_000_000 {
t.Errorf("TokensMonth = %d, want 4000000", rl.TokensMonth)
}
// Specific model
rl, ok = defaults.LookupModel("magistral-medium-2509")
if !ok {
t.Fatal("magistral-medium-2509 should be found")
}
if rl.TPM != 75_000 {
t.Errorf("magistral TPM = %d, want 75000", rl.TPM)
}
if rl.TokensMonth != 1_000_000_000 {
t.Errorf("magistral TokensMonth = %d, want 1000000000", rl.TokensMonth)
}
}
func TestDefaultRateLimits_Anthropic(t *testing.T) {
defaults := DefaultRateLimits("anthropic")
rl, ok := defaults.LookupModel("claude-sonnet-4-20250514")
if !ok {
t.Fatal("claude-sonnet should be found")
}
if rl.RPM != 50 {
t.Errorf("RPM = %d, want 50", rl.RPM)
}
if rl.ITPM != 30_000 {
t.Errorf("ITPM = %d, want 30000", rl.ITPM)
}
if rl.OTPM != 8_000 {
t.Errorf("OTPM = %d, want 8000", rl.OTPM)
}
// Haiku has different limits
rl, _ = defaults.LookupModel("claude-haiku-4-5-20251001")
if rl.ITPM != 50_000 {
t.Errorf("Haiku ITPM = %d, want 50000", rl.ITPM)
}
}
func TestDefaultRateLimits_OpenAI(t *testing.T) {
defaults := DefaultRateLimits("openai")
rl, ok := defaults.LookupModel("gpt-4o")
if !ok {
t.Fatal("gpt-4o should be found")
}
if rl.RPM != 500 {
t.Errorf("RPM = %d, want 500", rl.RPM)
}
if rl.TPM != 30_000 {
t.Errorf("TPM = %d, want 30000", rl.TPM)
}
if rl.RPD != 10_000 {
t.Errorf("RPD = %d, want 10000", rl.RPD)
}
}
func TestDefaultRateLimits_Google(t *testing.T) {
defaults := DefaultRateLimits("google")
rl, ok := defaults.LookupModel("gemini-2.5-pro")
if !ok {
t.Fatal("gemini-2.5-pro should be found")
}
if rl.RPM != 5 {
t.Errorf("RPM = %d, want 5", rl.RPM)
}
if rl.RPD != 100 {
t.Errorf("RPD = %d, want 100", rl.RPD)
}
}
func TestDefaultRateLimits_Unknown(t *testing.T) {
defaults := DefaultRateLimits("unknown-provider")
_, ok := defaults.LookupModel("any-model")
if ok {
t.Error("unknown provider should return no limits")
}
}
func TestLookupModel_FallbackToWildcard(t *testing.T) {
defaults := DefaultRateLimits("mistral")
// Non-existent model falls back to wildcard
rl, ok := defaults.LookupModel("mistral-future-2099")
if !ok {
t.Fatal("should fall back to wildcard")
}
if rl.TPM != 50_000 {
t.Errorf("wildcard TPM = %d, want 50000", rl.TPM)
}
}

View File

@@ -11,9 +11,12 @@ type PoolKind int
const ( const (
PoolRPM PoolKind = iota // requests per minute PoolRPM PoolKind = iota // requests per minute
PoolRPS // requests per second
PoolRPD // requests per day PoolRPD // requests per day
PoolTPM // tokens per minute
PoolTPD // tokens per day PoolTPD // tokens per day
PoolCostEUR // monetary cost cap PoolTokensMonth // tokens per month
PoolCostMonth // monetary cost cap per month
PoolCustom // arbitrary units PoolCustom // arbitrary units
) )

127
internal/router/pools.go Normal file
View File

@@ -0,0 +1,127 @@
package router
import (
"fmt"
"time"
"somegit.dev/Owlibou/gnoma/internal/provider"
)
// PoolsFromRateLimits creates limit pools for an arm based on rate limits.
// Each non-zero limit dimension becomes a pool attached to the arm.
func PoolsFromRateLimits(armID ArmID, rl provider.RateLimits) []*LimitPool {
now := time.Now()
var pools []*LimitPool
if rl.RPS > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/rps", armID),
Kind: PoolRPS,
TotalLimit: rl.RPS,
ResetPeriod: time.Second,
ResetAt: now.Add(time.Second),
ArmRates: map[ArmID]float64{armID: 1.0 / 1000.0}, // 1 request per call, normalized
ScarcityK: 3.0,
})
}
if rl.RPM > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/rpm", armID),
Kind: PoolRPM,
TotalLimit: float64(rl.RPM),
ResetPeriod: time.Minute,
ResetAt: now.Add(time.Minute),
ArmRates: map[ArmID]float64{armID: 1.0 / 1000.0},
ScarcityK: 2.0,
})
}
if rl.RPD > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/rpd", armID),
Kind: PoolRPD,
TotalLimit: float64(rl.RPD),
ResetPeriod: 24 * time.Hour,
ResetAt: nextMidnight(),
ArmRates: map[ArmID]float64{armID: 1.0 / 1000.0},
ScarcityK: 2.0,
})
}
if rl.TPM > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/tpm", armID),
Kind: PoolTPM,
TotalLimit: float64(rl.TPM),
ResetPeriod: time.Minute,
ResetAt: now.Add(time.Minute),
ArmRates: map[ArmID]float64{armID: 1.0}, // 1 token per token
ScarcityK: 2.0,
})
}
// Anthropic-style split: ITPM+OTPM. Use TPM pool kind for the more
// restrictive one (output) since that's what throttles agentic use.
if rl.ITPM > 0 && rl.TPM == 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/itpm", armID),
Kind: PoolTPM,
TotalLimit: float64(rl.ITPM),
ResetPeriod: time.Minute,
ResetAt: now.Add(time.Minute),
ArmRates: map[ArmID]float64{armID: 0.6}, // ~60% of tokens are input
ScarcityK: 2.0,
})
}
if rl.OTPM > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/otpm", armID),
Kind: PoolTPM,
TotalLimit: float64(rl.OTPM),
ResetPeriod: time.Minute,
ResetAt: now.Add(time.Minute),
ArmRates: map[ArmID]float64{armID: 0.4}, // ~40% of tokens are output
ScarcityK: 3.0, // output is more precious
})
}
if rl.TokensMonth > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/tokens-month", armID),
Kind: PoolTokensMonth,
TotalLimit: float64(rl.TokensMonth),
ResetPeriod: 30 * 24 * time.Hour,
ResetAt: nextMonthStart(),
ArmRates: map[ArmID]float64{armID: 1.0},
ScarcityK: 4.0, // aggressive hoarding for monthly budgets
})
}
if rl.SpendCap > 0 {
pools = append(pools, &LimitPool{
ID: fmt.Sprintf("%s/spend-cap", armID),
Kind: PoolCostMonth,
TotalLimit: rl.SpendCap,
ResetPeriod: 30 * 24 * time.Hour,
ResetAt: nextMonthStart(),
ArmRates: map[ArmID]float64{}, // cost tracked externally per arm
ScarcityK: 4.0,
})
}
return pools
}
func nextMidnight() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
}
func nextMonthStart() time.Time {
now := time.Now()
if now.Month() == 12 {
return time.Date(now.Year()+1, 1, 1, 0, 0, 0, 0, now.Location())
}
return time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location())
}

View File

@@ -0,0 +1,120 @@
package router
import (
"testing"
"somegit.dev/Owlibou/gnoma/internal/provider"
)
func TestPoolsFromRateLimits_Mistral(t *testing.T) {
armID := ArmID("mistral/mistral-large-latest")
rl := provider.RateLimits{RPS: 1, TPM: 50_000, TokensMonth: 4_000_000}
pools := PoolsFromRateLimits(armID, rl)
// Should create: RPS, TPM, TokensMonth = 3 pools
if len(pools) != 3 {
t.Fatalf("len(pools) = %d, want 3", len(pools))
}
kinds := map[PoolKind]bool{}
for _, p := range pools {
kinds[p.Kind] = true
}
if !kinds[PoolRPS] {
t.Error("missing RPS pool")
}
if !kinds[PoolTPM] {
t.Error("missing TPM pool")
}
if !kinds[PoolTokensMonth] {
t.Error("missing TokensMonth pool")
}
}
func TestPoolsFromRateLimits_Anthropic(t *testing.T) {
armID := ArmID("anthropic/claude-sonnet-4-20250514")
rl := provider.RateLimits{RPM: 50, ITPM: 30_000, OTPM: 8_000}
pools := PoolsFromRateLimits(armID, rl)
// Should create: RPM, ITPM (as TPM), OTPM (as TPM) = 3 pools
if len(pools) != 3 {
t.Fatalf("len(pools) = %d, want 3", len(pools))
}
var rpmPool *LimitPool
for _, p := range pools {
if p.Kind == PoolRPM {
rpmPool = p
}
}
if rpmPool == nil {
t.Fatal("missing RPM pool")
}
if rpmPool.TotalLimit != 50 {
t.Errorf("RPM limit = %f, want 50", rpmPool.TotalLimit)
}
}
func TestPoolsFromRateLimits_OpenAI(t *testing.T) {
armID := ArmID("openai/gpt-4o")
rl := provider.RateLimits{RPM: 500, TPM: 30_000, RPD: 10_000}
pools := PoolsFromRateLimits(armID, rl)
// Should create: RPM, RPD, TPM = 3 pools
if len(pools) != 3 {
t.Fatalf("len(pools) = %d, want 3", len(pools))
}
kinds := map[PoolKind]int{}
for _, p := range pools {
kinds[p.Kind]++
}
if kinds[PoolRPM] != 1 {
t.Error("expected 1 RPM pool")
}
if kinds[PoolRPD] != 1 {
t.Error("expected 1 RPD pool")
}
if kinds[PoolTPM] != 1 {
t.Error("expected 1 TPM pool")
}
}
func TestPoolsFromRateLimits_Empty(t *testing.T) {
pools := PoolsFromRateLimits("test/model", provider.RateLimits{})
if len(pools) != 0 {
t.Errorf("empty rate limits should produce 0 pools, got %d", len(pools))
}
}
func TestPoolsFromRateLimits_SpendCap(t *testing.T) {
armID := ArmID("mistral/model")
rl := provider.RateLimits{SpendCap: 20.0}
pools := PoolsFromRateLimits(armID, rl)
if len(pools) != 1 {
t.Fatalf("len(pools) = %d, want 1", len(pools))
}
if pools[0].Kind != PoolCostMonth {
t.Errorf("Kind = %v, want PoolCostMonth", pools[0].Kind)
}
if pools[0].TotalLimit != 20.0 {
t.Errorf("TotalLimit = %f, want 20.0", pools[0].TotalLimit)
}
}
func TestPoolsFromRateLimits_RPSResetPeriod(t *testing.T) {
armID := ArmID("mistral/model")
rl := provider.RateLimits{RPS: 1}
pools := PoolsFromRateLimits(armID, rl)
if len(pools) != 1 {
t.Fatalf("len(pools) = %d, want 1", len(pools))
}
if pools[0].ResetPeriod.Seconds() != 1.0 {
t.Errorf("RPS reset period = %v, want 1s", pools[0].ResetPeriod)
}
}

View File

@@ -112,6 +112,13 @@ func (s *Local) Close() error {
return nil return nil
} }
// SetModel updates the displayed model name.
func (s *Local) SetModel(model string) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
}
func (s *Local) Status() Status { func (s *Local) Status() Status {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View File

@@ -25,10 +25,6 @@ var paramSchema = json.RawMessage(`{
"description": "Task type hint for provider routing", "description": "Task type hint for provider routing",
"enum": ["generation", "review", "refactor", "debug", "explain", "planning"] "enum": ["generation", "review", "refactor", "debug", "explain", "planning"]
}, },
"wait": {
"type": "boolean",
"description": "Wait for the elf to complete (default true)"
},
"max_turns": { "max_turns": {
"type": "integer", "type": "integer",
"description": "Maximum tool-calling rounds for the elf (default 30)" "description": "Maximum tool-calling rounds for the elf (default 30)"
@@ -40,7 +36,7 @@ var paramSchema = json.RawMessage(`{
// Tool allows the LLM to spawn sub-agents (elfs). // Tool allows the LLM to spawn sub-agents (elfs).
type Tool struct { type Tool struct {
manager *elf.Manager manager *elf.Manager
ProgressCh chan<- string // optional: sends 2-line progress to TUI ProgressCh chan<- elf.Progress // optional: sends structured progress to TUI
} }
func New(mgr *elf.Manager) *Tool { func New(mgr *elf.Manager) *Tool {
@@ -48,12 +44,12 @@ func New(mgr *elf.Manager) *Tool {
} }
// SetProgressCh sets the channel for forwarding elf progress to the TUI. // SetProgressCh sets the channel for forwarding elf progress to the TUI.
func (t *Tool) SetProgressCh(ch chan<- string) { func (t *Tool) SetProgressCh(ch chan<- elf.Progress) {
t.ProgressCh = ch t.ProgressCh = ch
} }
func (t *Tool) Name() string { return "agent" } func (t *Tool) Name() string { return "agent" }
func (t *Tool) Description() string { return "Spawn a sub-agent (elf) to handle a task independently. The elf gets its own conversation and tools." } func (t *Tool) Description() string { return "Spawn a sub-agent (elf) to handle a task independently. The elf gets its own conversation and tools. IMPORTANT: To spawn multiple elfs in parallel, call this tool multiple times in the SAME response — do not wait for one to finish before spawning the next." }
func (t *Tool) Parameters() json.RawMessage { return paramSchema } func (t *Tool) Parameters() json.RawMessage { return paramSchema }
func (t *Tool) IsReadOnly() bool { return true } func (t *Tool) IsReadOnly() bool { return true }
func (t *Tool) IsDestructive() bool { return false } func (t *Tool) IsDestructive() bool { return false }
@@ -61,7 +57,6 @@ func (t *Tool) IsDestructive() bool { return false }
type agentArgs struct { type agentArgs struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
TaskType string `json:"task_type,omitempty"` TaskType string `json:"task_type,omitempty"`
Wait *bool `json:"wait,omitempty"`
MaxTurns int `json:"max_turns,omitempty"` MaxTurns int `json:"max_turns,omitempty"`
} }
@@ -75,15 +70,17 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
} }
taskType := parseTaskType(a.TaskType) taskType := parseTaskType(a.TaskType)
wait := true
if a.Wait != nil {
wait = *a.Wait
}
maxTurns := a.MaxTurns maxTurns := a.MaxTurns
if maxTurns <= 0 { if maxTurns <= 0 {
maxTurns = 30 // default maxTurns = 30 // default
} }
// Truncate description for tree display
desc := a.Prompt
if len(desc) > 60 {
desc = desc[:60] + "…"
}
systemPrompt := "You are an elf — a focused sub-agent of gnoma. Complete the given task thoroughly and concisely. Use tools as needed." systemPrompt := "You are an elf — a focused sub-agent of gnoma. Complete the given task thoroughly and concisely. Use tools as needed."
e, err := t.manager.Spawn(ctx, taskType, a.Prompt, systemPrompt, maxTurns) e, err := t.manager.Spawn(ctx, taskType, a.Prompt, systemPrompt, maxTurns)
@@ -91,66 +88,71 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
return tool.Result{Output: fmt.Sprintf("Failed to spawn elf: %v", err)}, nil return tool.Result{Output: fmt.Sprintf("Failed to spawn elf: %v", err)}, nil
} }
if !wait { // Send initial progress
return tool.Result{ t.sendProgress(elf.Progress{
Output: fmt.Sprintf("Elf %s spawned in background (task: %s)", e.ID(), taskType), ElfID: e.ID(),
Metadata: map[string]any{"elf_id": e.ID(), "background": true}, Description: desc,
}, nil Activity: "starting…",
} })
// Drain elf events while waiting, forward progress to TUI // Drain elf events while waiting, forward progress to TUI
done := make(chan elf.Result, 1) done := make(chan elf.Result, 1)
go func() { done <- e.Wait() }() go func() { done <- e.Wait() }()
// Forward elf streaming events as live progress // Forward elf streaming events as structured progress
go func() { go func() {
var textBuf strings.Builder toolUses := 0
tokens := 0
lastSend := time.Now()
textChars := 0
for evt := range e.Events() { for evt := range e.Events() {
if t.ProgressCh == nil { if t.ProgressCh == nil {
continue continue
} }
var progress string p := elf.Progress{
ElfID: e.ID(),
Description: desc,
ToolUses: toolUses,
Tokens: tokens,
}
switch evt.Type { switch evt.Type {
case stream.EventTextDelta: case stream.EventTextDelta:
if evt.Text != "" { textChars += len(evt.Text)
textBuf.WriteString(evt.Text) // Throttle text progress to every 500ms
// Show last 2 non-empty lines of text if time.Since(lastSend) < 500*time.Millisecond {
allLines := strings.Split(textBuf.String(), "\n") continue
var recent []string
for i := len(allLines) - 1; i >= 0 && len(recent) < 2; i-- {
line := strings.TrimSpace(allLines[i])
if line != "" {
if len(line) > 70 {
line = line[:70] + "…"
}
recent = append([]string{line}, recent...)
}
}
progress = strings.Join(recent, "\n")
} }
p.Activity = fmt.Sprintf("generating… (%d chars)", textChars)
case stream.EventToolCallDone: case stream.EventToolCallDone:
name := evt.ToolCallName name := evt.ToolCallName
if name == "" { if name == "" {
name = "tool" name = "tool"
} }
progress = fmt.Sprintf("⚙ [%s] running...", name) p.Activity = fmt.Sprintf("⚙ [%s] running", name)
case stream.EventToolResult: case stream.EventToolResult:
// Show truncated tool result toolUses++
p.ToolUses = toolUses
out := evt.ToolOutput out := evt.ToolOutput
if len(out) > 70 { if len(out) > 60 {
out = out[:70] + "…" out = out[:60] + "…"
} }
out = strings.ReplaceAll(out, "\n", " ") out = strings.ReplaceAll(out, "\n", " ")
progress = fmt.Sprintf(" → %s", out) p.Activity = fmt.Sprintf("→ %s", out)
case stream.EventUsage:
if evt.Usage != nil {
tokens = int(evt.Usage.TotalTokens())
p.Tokens = tokens
}
p.Activity = "" // no activity change on usage alone
default:
continue
} }
if progress != "" { lastSend = time.Now()
select { t.sendProgress(p)
case t.ProgressCh <- progress:
default:
}
}
} }
}() }()
@@ -159,27 +161,45 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
case result = <-done: case result = <-done:
case <-ctx.Done(): case <-ctx.Done():
e.Cancel() e.Cancel()
t.sendProgress(elf.Progress{ElfID: e.ID(), Description: desc, Done: true, Error: "cancelled"})
return tool.Result{Output: "Elf cancelled"}, nil return tool.Result{Output: "Elf cancelled"}, nil
case <-time.After(5 * time.Minute): case <-time.After(5 * time.Minute):
e.Cancel() e.Cancel()
t.sendProgress(elf.Progress{ElfID: e.ID(), Description: desc, Done: true, Error: "timed out"})
return tool.Result{Output: "Elf timed out after 5 minutes"}, nil return tool.Result{Output: "Elf timed out after 5 minutes"}, nil
} }
// Clear progress // Send done signal — stays in tree until turn completes
if t.ProgressCh != nil { doneProgress := elf.Progress{
select { ElfID: result.ID,
case t.ProgressCh <- "": Description: desc,
default: Tokens: int(result.Usage.TotalTokens()),
Done: true,
Duration: result.Duration,
} }
if result.Error != nil {
doneProgress.Error = result.Error.Error()
} }
t.sendProgress(doneProgress)
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "Elf %s completed (%s, %s)\n\n", result.ID, result.Status, result.Duration.Round(time.Millisecond)) fmt.Fprintf(&b, "Elf %s completed (%s, %s, %s)\n\n",
result.ID, result.Status,
result.Duration.Round(time.Millisecond),
formatTokens(int(result.Usage.TotalTokens())),
)
if result.Error != nil { if result.Error != nil {
fmt.Fprintf(&b, "Error: %v\n", result.Error) fmt.Fprintf(&b, "Error: %v\n", result.Error)
} }
if result.Output != "" { if result.Output != "" {
b.WriteString(result.Output) // Truncate elf output to avoid flooding parent context.
// The parent LLM gets enough to summarize; full text stays in the elf.
output := result.Output
const maxOutputChars = 2000
if len(output) > maxOutputChars {
output = output[:maxOutputChars] + fmt.Sprintf("\n\n[truncated — full output was %d chars]", len(result.Output))
}
b.WriteString(output)
} }
return tool.Result{ return tool.Result{
@@ -192,6 +212,26 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
}, nil }, nil
} }
func (t *Tool) sendProgress(p elf.Progress) {
if t.ProgressCh == nil {
return
}
select {
case t.ProgressCh <- p:
default:
}
}
func formatTokens(tokens int) string {
if tokens >= 1_000_000 {
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
}
if tokens >= 1_000 {
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
}
return fmt.Sprintf("%d tokens", tokens)
}
func parseTaskType(s string) router.TaskType { func parseTaskType(s string) router.TaskType {
switch strings.ToLower(s) { switch strings.ToLower(s) {
case "generation": case "generation":

View File

@@ -7,12 +7,14 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textarea"
"charm.land/glamour/v2" "charm.land/glamour/v2"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"somegit.dev/Owlibou/gnoma/internal/elf"
"somegit.dev/Owlibou/gnoma/internal/engine" "somegit.dev/Owlibou/gnoma/internal/engine"
"somegit.dev/Owlibou/gnoma/internal/message" "somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/permission" "somegit.dev/Owlibou/gnoma/internal/permission"
@@ -26,8 +28,12 @@ const version = "v0.1.0-dev"
type streamEventMsg struct{ event stream.Event } type streamEventMsg struct{ event stream.Event }
type turnDoneMsg struct{ err error } type turnDoneMsg struct{ err error }
type permReqMsg struct{ toolName string } // PermReqMsg carries a permission request from engine to TUI.
type elfProgressMsg struct{ text string } type PermReqMsg struct {
ToolName string
Args json.RawMessage
}
type elfProgressMsg struct{ progress elf.Progress }
type chatMessage struct { type chatMessage struct {
role string role string
@@ -41,8 +47,8 @@ type Config struct {
Permissions *permission.Checker // for mode switching Permissions *permission.Checker // for mode switching
Router *router.Router // for model listing Router *router.Router // for model listing
PermCh chan bool // TUI → engine: y/n response PermCh chan bool // TUI → engine: y/n response
PermReqCh <-chan string // engine → TUI: tool name needing approval PermReqCh <-chan PermReqMsg // engine → TUI: tool requesting approval
ElfProgress <-chan string // elf → TUI: progress updates ElfProgress <-chan elf.Progress // elf → TUI: structured progress updates
} }
type Model struct { type Model struct {
@@ -59,13 +65,16 @@ type Model struct {
input textarea.Model input textarea.Model
mdRenderer *glamour.TermRenderer mdRenderer *glamour.TermRenderer
expandOutput bool // ctrl+o toggles expanded tool output expandOutput bool // ctrl+o toggles expanded tool output
elfProgress string // last 2 lines from active elf elfStates map[string]*elf.Progress // active elf states keyed by ID
elfOrder []string // insertion-ordered elf IDs for tree rendering
elfToolActive bool // suppresses next toolresult (elf output)
cwd string cwd string
gitBranch string gitBranch string
scrollOffset int scrollOffset int
incognito bool incognito bool
permPending bool // waiting for user to approve/deny a tool permPending bool // waiting for user to approve/deny a tool
permToolName string // which tool is asking permToolName string // which tool is asking
permArgs json.RawMessage // tool args for display
} }
func New(sess session.Session, cfg Config) Model { func New(sess session.Session, cfg Config) Model {
@@ -106,6 +115,7 @@ func New(sess session.Session, cfg Config) Model {
config: cfg, config: cfg,
input: ti, input: ti,
mdRenderer: mdRenderer, mdRenderer: mdRenderer,
elfStates: make(map[string]*elf.Progress),
cwd: cwd, cwd: cwd,
gitBranch: gitBranch, gitBranch: gitBranch,
streamBuf: &strings.Builder{}, streamBuf: &strings.Builder{},
@@ -240,14 +250,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case elfProgressMsg: case elfProgressMsg:
m.elfProgress = msg.text p := msg.progress
// Keep completed elfs in tree — only cleared on turnDoneMsg
if _, exists := m.elfStates[p.ElfID]; !exists {
m.elfOrder = append(m.elfOrder, p.ElfID)
}
m.elfStates[p.ElfID] = &p
return m, m.listenForEvents() return m, m.listenForEvents()
case permReqMsg: case PermReqMsg:
m.permPending = true m.permPending = true
m.permToolName = msg.toolName m.permToolName = msg.ToolName
m.permArgs = msg.Args
m.messages = append(m.messages, chatMessage{role: "system", m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("⚠ %s wants to execute. Allow? [y/n]", msg.toolName)}) content: formatPermissionPrompt(msg.ToolName, msg.Args)})
m.scrollOffset = 0 m.scrollOffset = 0
return m, nil return m, nil
@@ -257,7 +273,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case turnDoneMsg: case turnDoneMsg:
m.streaming = false m.streaming = false
m.scrollOffset = 0 m.scrollOffset = 0
m.elfProgress = "" // clear elf progress m.elfStates = make(map[string]*elf.Progress) // clear elf states
m.elfOrder = nil
if m.streamBuf.Len() > 0 { if m.streamBuf.Len() > 0 {
m.messages = append(m.messages, chatMessage{ m.messages = append(m.messages, chatMessage{
role: m.currentRole, content: m.streamBuf.String(), role: m.currentRole, content: m.streamBuf.String(),
@@ -383,6 +400,10 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
} }
if m.config.Engine != nil { if m.config.Engine != nil {
m.config.Engine.SetModel(args) m.config.Engine.SetModel(args)
// Update session status display
if ls, ok := m.session.(*session.Local); ok {
ls.SetModel(args)
}
m.messages = append(m.messages, chatMessage{role: "system", m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("model switched to: %s", args)}) content: fmt.Sprintf("model switched to: %s", args)})
} }
@@ -478,30 +499,23 @@ func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) {
} }
case stream.EventToolCallDone: case stream.EventToolCallDone:
if evt.ToolCallName == "agent" { if evt.ToolCallName == "agent" {
// Extract prompt from args for elf display // Suppress tool message — elf tree view handles display
prompt := "working..." m.elfToolActive = true
if evt.Args != nil {
var a struct{ Prompt string }
if json.Unmarshal(evt.Args, &a) == nil && a.Prompt != "" {
prompt = a.Prompt
if len(prompt) > 60 {
prompt = prompt[:60] + "..."
}
}
}
m.messages = append(m.messages, chatMessage{
role: "tool", content: fmt.Sprintf("🦉 [elf] %s", prompt),
})
} else { } else {
m.messages = append(m.messages, chatMessage{ m.messages = append(m.messages, chatMessage{
role: "tool", content: fmt.Sprintf("⚙ [%s] running...", evt.ToolCallName), role: "tool", content: fmt.Sprintf("⚙ [%s] running...", evt.ToolCallName),
}) })
} }
case stream.EventToolResult: case stream.EventToolResult:
if m.elfToolActive {
// Suppress raw elf output — tree shows progress, LLM summarizes
m.elfToolActive = false
} else {
m.messages = append(m.messages, chatMessage{ m.messages = append(m.messages, chatMessage{
role: "toolresult", content: evt.ToolOutput, role: "toolresult", content: evt.ToolOutput,
}) })
} }
}
return m, m.listenForEvents() return m, m.listenForEvents()
} }
@@ -522,14 +536,14 @@ func (m Model) listenForEvents() tea.Cmd {
return turnDoneMsg{err: err} return turnDoneMsg{err: err}
} }
return streamEventMsg{event: evt} return streamEventMsg{event: evt}
case toolName, ok := <-permReqCh: case req, ok := <-permReqCh:
if ok { if ok {
return permReqMsg{toolName: toolName} return req
} }
return nil return nil
case progress, ok := <-elfProgressCh: case progress, ok := <-elfProgressCh:
if ok { if ok {
return elfProgressMsg{text: progress} return elfProgressMsg{progress: progress}
} }
return nil return nil
} }
@@ -617,6 +631,11 @@ func (m Model) renderChat(height int) string {
lines = append(lines, m.renderMessage(msg)...) lines = append(lines, m.renderMessage(msg)...)
} }
// Elf tree view — shows active elfs with structured progress
if m.streaming && len(m.elfStates) > 0 {
lines = append(lines, m.renderElfTree()...)
}
// Streaming // Streaming
if m.streaming && m.streamBuf.Len() > 0 { if m.streaming && m.streamBuf.Len() > 0 {
// Stream raw text — markdown rendered only after completion // Stream raw text — markdown rendered only after completion
@@ -725,12 +744,6 @@ func (m Model) renderMessage(msg chatMessage) []string {
case "tool": case "tool":
lines = append(lines, indent+sToolOutput.Render(msg.content)) lines = append(lines, indent+sToolOutput.Render(msg.content))
// Show elf progress under elf tool messages
if strings.HasPrefix(msg.content, "🦉") && m.streaming && m.elfProgress != "" {
for _, pLine := range strings.Split(m.elfProgress, "\n") {
lines = append(lines, indent+indent+sToolResult.Render(pLine))
}
}
case "toolresult": case "toolresult":
resultLines := strings.Split(msg.content, "\n") resultLines := strings.Split(msg.content, "\n")
@@ -775,6 +788,102 @@ func (m Model) renderMessage(msg chatMessage) []string {
return lines return lines
} }
func (m Model) renderElfTree() []string {
if len(m.elfOrder) == 0 {
return nil
}
var lines []string
// Count running vs done
running := 0
for _, id := range m.elfOrder {
if p, ok := m.elfStates[id]; ok && !p.Done {
running++
}
}
// Header
if running > 0 {
header := fmt.Sprintf("● Running %d elf", len(m.elfOrder))
if len(m.elfOrder) != 1 {
header += "s"
}
header += "…"
lines = append(lines, sStatusStreaming.Render(header))
} else {
header := fmt.Sprintf("● %d elf", len(m.elfOrder))
if len(m.elfOrder) != 1 {
header += "s"
}
header += " completed"
lines = append(lines, sToolOutput.Render(header))
}
for i, elfID := range m.elfOrder {
p, ok := m.elfStates[elfID]
if !ok {
continue
}
isLast := i == len(m.elfOrder)-1
// Branch character
branch := "├─"
childPrefix := "│ "
if isLast {
branch = "└─"
childPrefix = " "
}
// Main line: branch + description + stats
var stats []string
if p.ToolUses > 0 {
stats = append(stats, fmt.Sprintf("%d tool uses", p.ToolUses))
}
if p.Tokens > 0 {
stats = append(stats, formatTokens(p.Tokens))
}
line := sToolOutput.Render(branch+" ") + sText.Render(p.Description)
if len(stats) > 0 {
line += sToolResult.Render(" · "+strings.Join(stats, " · "))
}
lines = append(lines, line)
// Activity sub-line
var activity string
if p.Done {
if p.Error != "" {
activity = sError.Render("Error: " + p.Error)
} else {
dur := p.Duration.Round(time.Millisecond)
activity = sToolOutput.Render(fmt.Sprintf("Done (%s)", dur))
}
} else {
activity = p.Activity
if activity == "" {
activity = "working…"
}
activity = sToolResult.Render(activity)
}
lines = append(lines, sToolResult.Render(childPrefix+"└─ ")+activity)
}
lines = append(lines, "") // spacing after tree
return lines
}
func formatTokens(tokens int) string {
if tokens >= 1_000_000 {
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
}
if tokens >= 1_000 {
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
}
return fmt.Sprintf("%d tokens", tokens)
}
func (m Model) renderSeparators() (string, string) { func (m Model) renderSeparators() (string, string) {
lineColor := cSurface // default dim lineColor := cSurface // default dim
modeLabel := "" modeLabel := ""
@@ -791,10 +900,11 @@ func (m Model) renderSeparators() (string, string) {
modeLabel = "🔒 " + modeLabel modeLabel = "🔒 " + modeLabel
} }
// Permission pending — flash the line // Permission pending — flash the line with command summary
if m.permPending { if m.permPending {
lineColor = cRed lineColor = cRed
modeLabel = "⚠ " + m.permToolName + " [y/n]" hint := shortPermHint(m.permToolName, m.permArgs)
modeLabel = "⚠ " + hint + " [y/n]"
} }
lineStyle := lipgloss.NewStyle().Foreground(lineColor) lineStyle := lipgloss.NewStyle().Foreground(lineColor)
@@ -921,6 +1031,77 @@ func (m Model) injectSystemContext(text string) {
} }
} }
// shortPermHint returns a compact string for the separator bar (e.g., "bash: find . -name '*.go'").
func shortPermHint(toolName string, args json.RawMessage) string {
switch toolName {
case "bash":
var a struct{ Command string }
if json.Unmarshal(args, &a) == nil && a.Command != "" {
cmd := a.Command
if len(cmd) > 50 {
cmd = cmd[:50] + "…"
}
return "bash: " + cmd
}
case "fs.write", "fs_write":
var a struct {
Path string `json:"file_path"`
}
if json.Unmarshal(args, &a) == nil && a.Path != "" {
return "write: " + a.Path
}
case "fs.edit", "fs_edit":
var a struct {
Path string `json:"file_path"`
}
if json.Unmarshal(args, &a) == nil && a.Path != "" {
return "edit: " + a.Path
}
}
return toolName
}
// formatPermissionPrompt builds a readable prompt showing what the tool wants to do.
func formatPermissionPrompt(toolName string, args json.RawMessage) string {
var detail string
switch toolName {
case "bash":
var a struct{ Command string }
if json.Unmarshal(args, &a) == nil && a.Command != "" {
cmd := a.Command
if len(cmd) > 120 {
cmd = cmd[:120] + "…"
}
detail = cmd
}
case "fs.write", "fs_write":
var a struct {
Path string `json:"file_path"`
}
if json.Unmarshal(args, &a) == nil && a.Path != "" {
detail = a.Path
}
case "fs.edit", "fs_edit":
var a struct {
Path string `json:"file_path"`
}
if json.Unmarshal(args, &a) == nil && a.Path != "" {
detail = a.Path
}
default:
// Generic: try to extract a readable summary from args
if len(args) > 0 && len(args) < 200 {
detail = string(args)
}
}
if detail != "" {
return fmt.Sprintf("⚠ %s wants to execute: %s [y/n]", toolName, detail)
}
return fmt.Sprintf("⚠ %s wants to execute [y/n]", toolName)
}
func detectGitBranch() string { func detectGitBranch() string {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
out, err := cmd.Output() out, err := cmd.Output()

View File

@@ -91,6 +91,9 @@ var (
sDiffRemove = lipgloss.NewStyle(). sDiffRemove = lipgloss.NewStyle().
Foreground(cRed) Foreground(cRed)
sText = lipgloss.NewStyle().
Foreground(cText)
) )
// Status bar // Status bar