initial commit

- v 0.1.0 siehe CHANGELOG.md
This commit is contained in:
Tom 2026-07-25 11:08:01 +02:00
commit a97013d876
95 changed files with 12200 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/nub
*.test
*.out
.DS_Store
.claude
commit.txt
.nub/

12
AGENTS.md Normal file
View file

@ -0,0 +1,12 @@
# nub
Schlanker CLI-Coding-Agent in Go. Konzept: docs/KONZEPT.md
- Go 1.24. `iter.Seq2` wird verwendet — keine Channel-Iteratoren.
- Definition of Done: `go test ./...` und `go vet ./...` grün.
- Kein Paket unterhalb von internal/agent importiert internal/ui (E-04).
- Keine Ausgabe auf stdout außerhalb von internal/ui. Logging via slog in
eine Datei (Abschnitt 2.3).
- Neue Abhängigkeiten nur aus der Allowlist in Abschnitt 2.3. Kein Cobra,
kein Viper.
- Festlegungen aus Abschnitt 9 sind entschieden — nicht neu verhandeln.

33
CHANGELOG.md Normal file
View file

@ -0,0 +1,33 @@
# Changelog
Alle nennenswerten Änderungen an `nub` werden in dieser Datei dokumentiert.
Das Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
Dieses Projekt ist privat und folgt keinem formalen Versionsschema
([SemVer](https://semver.org/lang/de/)) — Versionsnummern hier dienen nur
der zeitlichen Einordnung.
## [0.1.0] - 2026-07-25
### Hinzugefügt
- Kern-Loop: OpenAI-kompatibler Provider-Adapter, eingebaute
Datei-/Bash-Tools, Agent-Loop, Print-Modus (M1).
- Session-Baum: Sessions als persistenter Baum, jederzeit zurückspringen
und neu verzweigen (M2).
- Geschichtete Config und Context Assembler (`AGENTS.md`, Repomap,
Budget) (M3).
- Token-Kalibrierung und Auto-Compaction (M4).
- Skills mit Progressive Disclosure und MCP-Server-Anbindung mit
Allowlist und Profilen (M5).
- TUI auf Basis von Bubbletea, architektonisch vom Kern entfernbar (M6).
- Prompt-Cache-Beobachtbarkeit über `cache_read_tokens` (M7).
- Permissions-System: pro Tool `auto`/`ask`/`deny`, plus `deny_paths`/
`deny_bash` als Override.
- `question`-Tool: generische Freitext-Rückfrage für Modell und Skills,
inline im TUI-Transkript beantwortbar.
- `nub init` / `/init`: scaffoldet `.nub/config.toml` und `AGENTS.md`.
- Skills direkt als Slash-Commands (`/<name>`) aufrufbar.
- `/sessions`: Sessions auflisten, wechseln, löschen.
[Feature-Übersicht](docs/FEATURES.md) · [Test-Rezepte](docs/TESTING.md)

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Thomas "Tom" Adamek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

338
README.md Normal file
View file

@ -0,0 +1,338 @@
# nub
Schlanker, terminal-nativer Coding-Agent als einzelnes Go-Binary. Keine
Laufzeit-Abhängigkeiten, interaktive TUI, skriptbarer Print-Modus.
- Features: [docs/FEATURES.md](docs/FEATURES.md)
- Architektur/Design: [docs/KONZEPT.md](docs/KONZEPT.md)
- Entwicklung/Tests: [docs/TESTING.md](docs/TESTING.md)
## Voraussetzungen
- Go 1.24+
- OpenAI-kompatibler Endpoint (OpenAI-API-Key oder lokaler Server: Ollama,
LM Studio, vLLM, …)
- Linux/macOS (Windows nur über WSL)
## Installation
```bash
git clone <repo-url> nub && cd nub
go build -o nub ./cmd/nub
mv nub /usr/local/bin/ # optional
```
## Schnellstart
```bash
export OPENAI_API_KEY=sk-...
cd /pfad/zu/deinem/projekt
nub
```
Startet eine interaktive Session im aktuellen Verzeichnis — das wird zur
Wurzel für alle Datei-Tools und `bash`-Aufrufe. Ohne Config-Datei laufen
sinnvolle Defaults (Modell `gpt-4o` gegen die echte OpenAI-API).
## Konfiguration
Geschichtet, spätere Schicht gewinnt: `Defaults``~/.config/nub/config.toml`
(global) → `<projekt>/.nub/config.toml` (projektlokal) → `NUB_*`-Env-Vars.
### Nur Umgebungsvariablen
| Variable | Pflicht | Default | Bedeutung |
|---|---|---|---|
| `NUB_API_KEY` | nein* | — | API-Key, Vorrang vor `OPENAI_API_KEY` |
| `OPENAI_API_KEY` | nein* | — | Fallback |
| `NUB_BASE_URL` | nein | `https://api.openai.com/v1` | Endpoint-URL |
| `NUB_MODEL` | nein | `gpt-4o` | Modellname |
\* Pflicht, außer der Endpoint beginnt mit `http://` (lokal, kein Key nötig).
```bash
export OPENAI_API_KEY=sk-...
export NUB_MODEL=gpt-4o-mini
```
```bash
export NUB_BASE_URL=http://localhost:11434/v1
export NUB_MODEL=qwen2.5-coder:7b
```
### Mit Config-Datei
`nub init` (oder `/init` in der TUI) legt `.nub/config.toml` (kommentierte
Beispiel-Config inkl. Permissions) und ein minimales `AGENTS.md` an — nur
falls noch nicht vorhanden.
Von Hand, `.nub/config.toml` (`version = 1` ist Pflicht):
```toml
version = 1
[model]
default = "gpt-4o-mini"
endpoint = "openai"
[[endpoint]]
name = "openai"
base_url = "https://api.openai.com/v1"
api_key = "env:OPENAI_API_KEY" # oder direkt der Key, oder "none" für lokal
[context]
files = ["AGENTS.md", "REPOMAP.md"]
walk_up = true
max_tokens = 20000
```
- Mehrere `[[endpoint]]`-Blöcke möglich; `model.endpoint` wählt aus.
- `[endpoint.caps]` überschreibt einzelne Fähigkeiten (z.B.
`parallel_tool_calls = false` für Server, die das nicht unterstützen).
- Fehlt bei einem Nicht-lokalen Endpoint der Key, bricht `nub` sofort ab.
## Bedienung
TUI standardmäßig im Terminal (stdin+stdout), sonst Print-Modus. `-p`
erzwingt Print-Modus.
### TUI
| Taste/Kommando | Wirkung |
|---|---|
| `Enter` | Nachricht senden (läuft ein Turn: als Steering zwischen zwei Tool-Aufrufen eingeschleust) |
| `Alt+Enter` | als Follow-up queuen (erst nach Ende des laufenden Turns) |
| `↑`/`↓` | Eingabe-Historie (bei leerer Eingabezeile) |
| `PgUp`/`PgDn`, `Ctrl+U`/`Ctrl+D`, Mausrad | Nachrichtenliste scrollen |
| `Ctrl+C`, `/exit`, `/quit` | beenden |
Diffs (`write`/`edit`) farbig im Transkript, fertige Antworten als Markdown.
Farben passen sich automatisch an helles/dunkles Terminal-Theme an.
**Slash-Kommandos:**
| Kommando | Wirkung |
|---|---|
| `/model [name]` | Modell anzeigen oder für den Rest der Session wechseln |
| `/tree`, `/branch` | Session-Baum anzeigen, Rewind mit ↑/↓ + Enter |
| `/sessions` | Sessions auflisten, wechseln (Enter), löschen (Entf, Rückfrage) |
| `/compact` | Session sofort zusammenfassen |
| `/context` | geladenen Kontext mit Herkunft/Tokenkosten anzeigen |
| `/mcp` | verbundene MCP-Server und ihre Tools |
| `/skills` | gefundene Skills |
| `/export [pfad]` | Transkript als Markdown exportieren |
| `/init` | `.nub/config.toml` + `AGENTS.md` anlegen, falls nicht vorhanden |
| `/clear` | Anzeige leeren (Session bleibt erhalten) |
| `/exit`, `/quit` | beenden |
| `/help` | Kommando-Übersicht |
Jeder gefundene Skill ist zusätzlich direkt als `/<name> [Zusatzinfo]`
aufrufbar (siehe [Skills](#skills)).
### Print-Modus (`-p`)
Zeilenweise stdin = eine Nachricht. Antworten auf stdout, Tool-Aktivität
(`→ tool_name {...}`, `← tool_name [ok|error]`, `bash`-Output) auf stderr.
`Ctrl+D` (EOF) beendet sauber nach dem aktuellen Turn.
```bash
./nub -p
Lies die Datei go.mod und fasse in einem Satz zusammen, was das Modul ist.
```
Beim Start gibt `nub` (beide Modi) auf stderr die Session-ID aus
(`session: 01J...`).
## Sessions
Jeder Lauf ohne Subcommand startet eine neue Session, persistiert unter
`.nub/sessions/<id>.jsonl` (automatisch von Git ausgeschlossen via
`.git/info/exclude`).
```bash
nub sessions # Sessions im aktuellen Verzeichnis auflisten
nub resume <session-id> # Session fortsetzen
```
In der TUI: `/sessions` (Liste, wechseln, löschen).
**Rewind/Branch:** in der TUI über `/tree`/`/branch`. Über CLI/Print-Modus
per Node-ID:
```bash
cat .nub/sessions/<session-id>.jsonl | jq -r 'select(.node) | .node.id + " " + (.node.message.content[0].text // "" | .[0:60])'
nub resume <session-id> --branch <node-id>
```
Setzt den Head auf den gewählten Knoten zurück; eine neue Nachricht bildet
dort einen zweiten Ast. Der ursprüngliche Ast bleibt erhalten.
## Kontext (AGENTS.md, REPOMAP.md, nub map)
System-Prompt = Basis-Prompt (überschreibbar durch `SYSTEM.md` im
Projekt-Root) + statische Umgebungsinfo (OS/Shell/Repo-Root/Branch) + Dateien
aus `context.files` (Default: `AGENTS.md`, `REPOMAP.md`). Mit
`walk_up = true` (Default) werden Treffer zwischen aktuellem Verzeichnis und
Projekt-Root eingesammelt.
```bash
nub context # geladene Kontext-Dateien mit Herkunft und Tokenkosten
nub map # REPOMAP.md generieren/aktualisieren
```
`context.max_tokens` (Default 20000) begrenzt Kontext-Dateien, kürzt sichtbar
statt still. `nub map` erzeugt Verzeichnisbaum + exportierte Symbole
(`.go` über `go/ast`, sonst erste Zeile als Fallback); warnt, wenn die
Repomap vom Git-Stand abweicht.
## Compaction & todo-Tool
Vor jedem Turn geschätzte Tokenkosten; über Schwelle (Default 75 % des
Modell-Fensters) wird automatisch komprimiert: ältere Hälfte der Session
wird per separatem Prompt zusammengefasst, letzte Turns bleiben im Original.
Original-Nodes bleiben erhalten — Rewind vor die Compaction funktioniert
weiterhin.
```bash
nub compact <session-id> # Compaction sofort erzwingen
```
`todo`-Tool: In-Memory-Todo-Liste für lange Aufgaben (nicht persistiert),
das Modell entscheidet selbst über die Nutzung.
## Skills
`.nub/skills/<name>/SKILL.md` (projektlokal) oder
`~/.nub/skills/<name>/SKILL.md` (global, konfigurierbar über
`[skills] paths = [...]`). Projektlokal überschreibt global.
```markdown
---
name: refactoring
description: Vorgehen für größere Refactorings in diesem Repo.
---
# Refactoring
...Body...
```
Beim Start landet nur `name`+`description` im System-Prompt (~30 Token pro
Skill); den Body lädt das Modell selbst nach (`read_skill`), weitere
Dateien im Skill-Verzeichnis über `read`/`glob`.
```bash
nub skills # Index-Vorschau
```
**Als Slash-Command:** `/<name> [Zusatzinfo]`, z.B. `/refactoring fokus auf
error handling` — schickt eine kurze Direktive statt Rohdaten, Progressive
Disclosure bleibt erhalten. Eingebaute Kommandos haben bei Namenskollision
Vorrang.
## MCP
```toml
[[mcp]]
name = "github"
command = "gh-mcp-server"
args = []
tools = ["create_issue", "get_pull_request"] # Allowlist; leer = alle
[[mcp]]
name = "linear"
url = "https://mcp.linear.app/mcp"
```
Alle Server parallel verbunden (Timeout 5s/Server), Tools mit
`<server>__`-Präfix (z.B. `github__create_issue`). Nicht erreichbare Server
verhindern den Start nicht (Warnung, `nub` läuft weiter).
```bash
nub mcp # verbindet und listet Tools
```
**Profile** grenzen ein, welche Server pro Aufruf verbunden werden:
```toml
[profiles]
review = ["github"]
default = []
```
```bash
nub --profile review
nub resume <session-id> --profile review
```
Ohne `[profiles]`-Sektion: immer alle konfigurierten Server.
## Permissions
```toml
[permissions]
read = "auto"
glob = "auto"
grep = "auto"
write = "ask"
edit = "ask"
bash = "ask"
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
deny_bash = ["rm -rf /", "git push --force*"]
```
| Modus | Verhalten |
|---|---|
| `auto` (Default) | läuft ohne Rückfrage |
| `ask` | TUI: Rückfrage-Block im Transkript, `y`/`Y` erlaubt, jede andere Taste lehnt ab. Print-Modus: harter Fehler |
| `deny` | läuft nie |
- `deny_paths`/`deny_bash` überstimmen `auto`/`ask` immer. `deny_paths` gilt
für `read`/`write`/`edit`/`grep` (`path`-Feld), `deny_bash` matcht den
Befehlstext (Glob/Teilstring).
- Tools ohne eigenes Config-Feld (MCP, `todo`, `read_skill`) laufen immer
`auto`.
- Mehrere gleichzeitige Tool-Aufrufe: Rückfragen laufen nacheinander vor dem
parallelen Start der erlaubten Calls — nie zwei Dialoge gleichzeitig.
### `question`-Tool
Gleiche Rückfrage-Mechanik als eigenständiges Tool: Modell oder
Skill-Anweisung stellt eine freie Frage (optional mit Vorschlägen), Antwort
kommt als Tool-Ergebnis zurück. TUI: Block im Transkript, nächste Eingabe
zählt als Antwort statt neuer Nachricht/Kommando. Print-Modus: sofortiger
Fehler (kein Blockieren, Print läuft headless).
## Alle CLI-Kommandos
```
nub init .nub/config.toml + AGENTS.md anlegen, falls nicht vorhanden
nub neue Session starten (TUI im Terminal)
nub -p Print-Modus erzwingen
nub resume <session-id> Session fortsetzen
nub resume <session-id> --branch <node-id>
von einem früheren Knoten fortsetzen (Rewind)
nub sessions Sessions auflisten
nub map REPOMAP.md erzeugen/aktualisieren
nub context geladenen Kontext anzeigen
nub compact <session-id> Compaction erzwingen
nub skills gefundene Skills auflisten
nub mcp MCP-Server verbinden, Tools auflisten
nub [resume ...] --profile <p> nur MCP-Server aus profiles.<p> verbinden
```
## Bekannte Grenzen
- Bilder werden nicht verarbeitet, auch wenn der Endpoint sie unterstützt.
- Permissions/`deny_paths` gelten nur für die sechs eingebauten Datei-/
Bash-Tools — MCP-Tools laufen immer `auto`.
- Compaction nutzt immer dasselbe Modell wie die Session.
- MCP-Server reconnecten nicht zur Laufzeit (Neustart nötig); nur Tools
angebunden, keine Prompts/Resources.
- Skills müssen von Hand angelegt werden.
Ausführlichere Liste: [docs/TESTING.md](docs/TESTING.md).
## Mitentwickeln
Build, Tests, manuelle Testrezepte: [docs/TESTING.md](docs/TESTING.md).
Architektur/Design-Entscheidungen: [docs/KONZEPT.md](docs/KONZEPT.md).

566
cmd/nub/main.go Normal file
View file

@ -0,0 +1,566 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/mattn/go-isatty"
"nub/internal/agent"
"nub/internal/config"
"nub/internal/ctxasm"
"nub/internal/llm/openai"
"nub/internal/mcpc"
"nub/internal/permission"
"nub/internal/scaffold"
"nub/internal/session"
"nub/internal/skill"
"nub/internal/tokens"
"nub/internal/tool"
"nub/internal/tool/builtin"
"nub/internal/ui/plain"
"nub/internal/ui/tui"
)
// Exit-Codes (5.9): 0 Erfolg, 1 Agent-/Provider-Fehler, 2 Konfigurationsfehler,
// 3 Abbruch durch Nutzer, 4 Limit erreicht.
const (
exitOK = 0
exitAgentError = 1
exitConfigError = 2
exitUserAbort = 3
exitLimitReached = 4
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) > 0 {
switch args[0] {
case "init":
return runInit()
case "sessions":
return runSessionsList()
case "resume":
return runAgent(args[1:], true)
case "map":
return runMap()
case "context":
return runContext()
case "compact":
return runCompact(args[1:])
case "skills":
return runSkillsList()
case "mcp":
return runMCPList()
case "-h", "--help", "help":
printUsage()
return exitOK
}
}
return runAgent(args, false)
}
func printUsage() {
fmt.Fprintln(os.Stderr, `nub schlanker Coding-Agent
Usage:
nub init .nub/config.toml (Beispiel-Config) und AGENTS.md anlegen, falls nicht vorhanden
nub neue Session starten (TUI, wenn stdin/stdout ein Terminal sind)
nub -p Print-Modus erzwingen (stdin/stdout, kein TUI)
nub resume <session-id> bestehende Session fortsetzen
nub resume <session-id> --branch <node-id>
von einem früheren Knoten aus fortsetzen (Rewind)
nub sessions Sessions im aktuellen Verzeichnis auflisten
nub map REPOMAP.md im aktuellen Verzeichnis erzeugen/aktualisieren
nub context geladenen Kontext mit Herkunft und Tokenkosten anzeigen
nub compact <session-id> Session sofort kompaktieren, unabhängig vom Schwellwert
nub skills gefundene Skills (Name+Description) auflisten
nub mcp konfigurierte MCP-Server verbinden und ihre Tools auflisten
nub [resume ...] --profile <p> nur die MCP-Server aus profiles.<p> verbinden
Konfiguration: geschichtete TOML-Config + NUB_*-Umgebungsvariablen, siehe README.md`)
}
// buildLoop kapselt das Setup, das runAgent und runCompact gemeinsam
// brauchen: Config laden, Skills/MCP anbinden, Kontext assemblen, Session
// öffnen, Loop verdrahten. Der Rückgabewert []*mcpc.Server muss vom Aufrufer
// am Ende geschlossen werden (mcpc.CloseAll). cfg/skills werden zusätzlich
// zurückgegeben, weil die TUI sie für /context, /skills, /mcp braucht.
func buildLoop(repoRoot string, resume bool, sessionID, branch, profile string) (*agent.Loop, *session.Store, []*mcpc.Server, config.Config, []skill.Skill, error) {
cfg, err := config.Load(repoRoot)
if err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
resolved, err := config.Resolve(cfg)
if err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
skills, err := skill.Discover(cfg.Skills.Paths)
if err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
system, ctxMeta, err := ctxasm.Assemble(ctxasm.Options{
RepoRoot: repoRoot,
Cwd: repoRoot,
Files: cfg.Context.Files,
WalkUp: cfg.Context.WalkUp,
MaxTokens: cfg.Context.MaxTokens,
SkillsIndex: skill.RenderIndex(skills),
})
if err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
for _, w := range ctxMeta.Warnings {
fmt.Fprintln(os.Stderr, "warning:", w)
}
var store *session.Store
if resume {
store, err = session.Load(repoRoot, sessionID)
if err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
if branch != "" {
if err := store.Branch(branch); err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
}
} else {
store, err = session.Create(repoRoot)
if err != nil {
return nil, nil, nil, config.Config{}, nil, err
}
}
fmt.Fprintf(os.Stderr, "session: %s\n", store.ID)
provider := openai.New(cfg.Model.Endpoint, resolved.BaseURL, resolved.APIKey, resolved.Caps)
registry := tool.NewRegistry()
registry.Register(builtin.ReadTool{})
registry.Register(builtin.WriteTool{})
registry.Register(builtin.EditTool{})
registry.Register(builtin.GlobTool{})
registry.Register(builtin.GrepTool{})
registry.Register(builtin.BashTool{})
registry.Register(&builtin.TodoTool{})
registry.Register(builtin.NewReadSkillTool(skills))
registry.Register(builtin.QuestionTool{})
mcpServers := connectMCP(registry, cfg, profile)
loop := &agent.Loop{
Provider: provider,
Tools: registry,
Model: resolved.Model,
System: system,
Env: tool.Env{Cwd: repoRoot, RepoRoot: repoRoot},
Session: store,
Tokens: tokens.NewCalibrator(),
Permissions: buildPermissionPolicy(cfg.Permissions),
}
return loop, store, mcpServers, cfg, skills, nil
}
// buildPermissionPolicy übersetzt die Config in eine permission.Policy
// (E-11). RequestPermission wird hier bewusst NICHT gesetzt: im Print-Modus
// bleibt es nil (macht "ask" zum harten Fehler, wie im Konzept verlangt),
// die TUI setzt es selbst auf ihren eigenen Rückfrage-Mechanismus (siehe
// internal/ui/tui.New).
func buildPermissionPolicy(cfg config.PermissionsConfig) *permission.Policy {
return &permission.Policy{
Modes: map[string]permission.Mode{
"read": permission.Mode(cfg.Read),
"glob": permission.Mode(cfg.Glob),
"grep": permission.Mode(cfg.Grep),
"write": permission.Mode(cfg.Write),
"edit": permission.Mode(cfg.Edit),
"bash": permission.Mode(cfg.Bash),
},
DenyPaths: cfg.DenyPaths,
DenyBash: cfg.DenyBash,
}
}
// isInteractive entscheidet TUI vs. Print-Modus (5.8): TUI nur, wenn sowohl
// stdin als auch stdout ein echtes Terminal sind.
func isInteractive() bool {
return isatty.IsTerminal(os.Stdout.Fd()) && isatty.IsTerminal(os.Stdin.Fd())
}
// connectMCP verbindet die (per Profil gefilterten) konfigurierten MCP-
// Server und registriert ihre Tools. Ein kaputter Server oder ein
// unbekanntes Profil verhindert den Start nicht — nur eine Warnung (E-06).
func connectMCP(registry *tool.Registry, cfg config.Config, profile string) []*mcpc.Server {
selected, err := mcpc.SelectServers(cfg.MCP, cfg.Profiles, profile)
if err != nil {
fmt.Fprintln(os.Stderr, "warning: mcp profile:", err)
return nil
}
if len(selected) == 0 {
return nil
}
ctx := context.Background()
result := mcpc.ConnectAll(ctx, selected)
for _, w := range result.Warnings {
fmt.Fprintln(os.Stderr, "warning:", w)
}
byName := make(map[string]config.MCPServer, len(selected))
for _, s := range selected {
byName[s.Name] = s
}
for _, srv := range result.Servers {
warnings, err := mcpc.RegisterTools(ctx, registry, srv, byName[srv.Name])
if err != nil {
fmt.Fprintf(os.Stderr, "warning: mcp server %q: %v\n", srv.Name, err)
continue
}
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "warning:", w)
}
}
return result.Servers
}
func runAgent(args []string, resume bool) int {
fsName := "nub"
if resume {
fsName = "resume"
}
fs := flag.NewFlagSet(fsName, flag.ContinueOnError)
var branch, profile string
var printMode bool
fs.StringVar(&profile, "profile", "", "MCP-Server-Profil (siehe [profiles] in der Config)")
fs.BoolVar(&printMode, "p", false, "Print-Modus statt TUI erzwingen")
fs.BoolVar(&printMode, "print", false, "Alias für -p")
if resume {
fs.StringVar(&branch, "branch", "", "Node-ID, von dem aus fortgesetzt wird (Rewind)")
}
if err := fs.Parse(args); err != nil {
return exitConfigError
}
var sessionID string
if resume {
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "usage: nub resume <session-id> [--branch <node-id>] [--profile <name>]")
return exitConfigError
}
sessionID = fs.Arg(0)
}
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
loop, store, mcpServers, cfg, skills, err := buildLoop(repoRoot, resume, sessionID, branch, profile)
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
defer mcpc.CloseAll(mcpServers)
defer store.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if !printMode && isInteractive() {
err := tui.Run(ctx, loop, store, cfg, skills, mcpServers)
// Ein SIGTERM/SIGINT von außen bricht runCtx ab, während die TUI noch
// läuft — Program.Run() liefert dafür regulär einen "killed"-Fehler.
// Das ist der erwartete Abbruchpfad, kein echter TUI-Fehler.
if ctx.Err() != nil {
return exitUserAbort
}
if err != nil {
fmt.Fprintln(os.Stderr, "tui error:", err)
return exitAgentError
}
return exitOK
}
in := make(chan agent.Input)
out := loop.Run(ctx, in)
go feedStdin(ctx, in)
renderer := &plain.Renderer{Out: os.Stdout, Err: os.Stderr}
lastErr := renderer.Render(out)
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "aborted")
return exitUserAbort
}
if lastErr != nil {
if errors.Is(lastErr, context.DeadlineExceeded) {
return exitLimitReached
}
return exitAgentError
}
return exitOK
}
// runCompact ist das CLI-Äquivalent zum /compact-TUI-Kommando (5.8): stößt
// eine einmalige Compaction unabhängig vom Schwellwert an und beendet sich.
func runCompact(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: nub compact <session-id>")
return exitConfigError
}
sessionID := args[0]
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
loop, store, mcpServers, _, _, err := buildLoop(repoRoot, true, sessionID, "", "")
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
defer mcpc.CloseAll(mcpServers)
defer store.Close()
out := make(chan tool.UIEvent, 8)
go func() {
defer close(out)
if err := loop.Compact(context.Background(), out); err != nil {
out <- tool.ErrorEvent{Err: err}
}
}()
renderer := &plain.Renderer{Out: os.Stdout, Err: os.Stderr}
if err := renderer.Render(out); err != nil {
return exitAgentError
}
return exitOK
}
func runSessionsList() int {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
infos, err := session.List(repoRoot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitAgentError
}
if len(infos) == 0 {
fmt.Fprintln(os.Stdout, "no sessions in this directory")
return exitOK
}
for _, info := range infos {
fmt.Fprintf(os.Stdout, "%s %s %d nodes %s\n",
info.ID, info.Created.Format(time.RFC3339), info.NodeCount, info.Summary)
}
return exitOK
}
func runMap() int {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
content, err := ctxasm.GenerateRepoMap(repoRoot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitAgentError
}
path := repoRoot + "/REPOMAP.md"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitAgentError
}
fmt.Fprintln(os.Stderr, "wrote", path)
return exitOK
}
// runInit ist das CLI-Äquivalent zum /init-TUI-Kommando: legt eine
// Beispiel-Config (inkl. Permissions) und ein minimalistisches AGENTS.md
// an, überschreibt aber nie bestehende Dateien.
func runInit() int {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
res, err := scaffold.Init(repoRoot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitAgentError
}
if res.ConfigCreated {
fmt.Fprintln(os.Stdout, "wrote", res.ConfigPath)
} else {
fmt.Fprintln(os.Stdout, "skipped (already exists):", res.ConfigPath)
}
if res.AgentsCreated {
fmt.Fprintln(os.Stdout, "wrote", res.AgentsPath)
} else {
fmt.Fprintln(os.Stdout, "skipped (already exists):", res.AgentsPath)
}
return exitOK
}
// runContext ist das CLI-Äquivalent zum /context-TUI-Kommando (5.8, folgt
// dem --branch-Muster aus M2): zeigt jede geladene Kontext-Datei mit
// Herkunft und geschätzten Tokenkosten.
func runContext() int {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
cfg, err := config.Load(repoRoot)
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
_, meta, err := ctxasm.Assemble(ctxasm.Options{
RepoRoot: repoRoot,
Cwd: repoRoot,
Files: cfg.Context.Files,
WalkUp: cfg.Context.WalkUp,
MaxTokens: cfg.Context.MaxTokens,
})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitAgentError
}
if len(meta.Files) == 0 {
fmt.Fprintln(os.Stdout, "no context files loaded")
}
for _, f := range meta.Files {
trunc := ""
if f.Truncated {
trunc = " [truncated]"
}
fmt.Fprintf(os.Stdout, "%-30s %6d tokens %s%s\n", f.Path, f.Tokens, f.Source, trunc)
}
fmt.Fprintf(os.Stdout, "total: %d tokens (budget %d)\n", meta.TotalTokens, cfg.Context.MaxTokens)
for _, w := range meta.Warnings {
fmt.Fprintln(os.Stderr, "warning:", w)
}
return exitOK
}
// runSkillsList ist das CLI-Äquivalent zum /skills-TUI-Kommando: zeigt, was
// beim nächsten Start ohne Zusatzkosten (nur Index) verfügbar wäre.
func runSkillsList() int {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
cfg, err := config.Load(repoRoot)
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
skills, err := skill.Discover(cfg.Skills.Paths)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitAgentError
}
if len(skills) == 0 {
fmt.Fprintln(os.Stdout, "no skills found")
return exitOK
}
for _, s := range skills {
fmt.Fprintf(os.Stdout, "%-20s %s\n", s.Name, s.Dir)
if s.Description != "" {
fmt.Fprintf(os.Stdout, " %s\n", s.Description)
}
}
return exitOK
}
// runMCPList ist das CLI-Äquivalent zum /mcp-TUI-Kommando: verbindet alle
// konfigurierten Server (ohne Profilfilter, um wirklich alles zu zeigen)
// und listet ihre Tools.
func runMCPList() int {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
cfg, err := config.Load(repoRoot)
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return exitConfigError
}
if len(cfg.MCP) == 0 {
fmt.Fprintln(os.Stdout, "no mcp servers configured")
return exitOK
}
ctx := context.Background()
result := mcpc.ConnectAll(ctx, cfg.MCP)
defer mcpc.CloseAll(result.Servers)
for _, w := range result.Warnings {
fmt.Fprintln(os.Stderr, "warning:", w)
}
for _, srv := range result.Servers {
var names []string
for t, err := range srv.Session.Tools(ctx, nil) {
if err != nil {
fmt.Fprintf(os.Stderr, "warning: mcp server %q: %v\n", srv.Name, err)
break
}
names = append(names, t.Name)
}
fmt.Fprintf(os.Stdout, "%s: %d tools\n", srv.Name, len(names))
for _, n := range names {
fmt.Fprintf(os.Stdout, " %s__%s\n", srv.Name, n)
}
}
return exitOK
}
// feedStdin liest zeilenweise von stdin und speist sie als Input in den Loop.
// Schließt in, wenn stdin endet oder der Kontext abbricht.
func feedStdin(ctx context.Context, in chan<- agent.Input) {
defer close(in)
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
select {
case in <- agent.Input{Text: line}:
case <-ctx.Done():
return
}
}
}

206
docs/FEATURES.md Normal file
View file

@ -0,0 +1,206 @@
# nub — Feature-Übersicht
Diese Seite beschreibt, was `nub` kann, aus Anwendersicht. Für die
Architektur dahinter siehe [KONZEPT.md](KONZEPT.md), für
Bau-/Test-Anleitungen siehe die [README](../README.md) und
[TESTING.md](TESTING.md).
## Grundprinzip
`nub` ist ein terminal-nativer Coding-Agent: ein einzelnes Go-Binary ohne
Laufzeit-Abhängigkeiten, das mit einem LLM über Tool-Calling zusammenarbeitet,
um Aufgaben in einem Code-Repository zu erledigen — Dateien lesen und
ändern, Befehle ausführen, Tests laufen lassen, und das Ganze über mehrere
Schritte hinweg, bis die Aufgabe erledigt ist.
Zwei Bedienmodi:
- **TUI** (Standard, wenn stdin/stdout ein Terminal sind) — interaktive
Oberfläche mit Streaming-Anzeige, Farb-Hervorhebung, Diff-Overlay und
Slash-Kommandos.
- **Print-Modus** (`-p`) — zeilenbasiert über stdin/stdout, für Skripte,
Pipes und Automatisierung.
Beide Modi sind dünne Consumer desselben Event-Streams; der Kern-Loop kennt
keinen von beiden und verhält sich in beiden identisch.
## Agent-Loop
- **Streaming-Antworten** — Text erscheint, während das Modell es erzeugt,
nicht erst am Ende.
- **Paralleles Tool-Calling** — fordert das Modell mehrere Tool-Aufrufe in
einem Schritt an, laufen sie gleichzeitig statt nacheinander.
- **Steering** — während der Agent noch arbeitet, eine neue Nachricht
schicken (TUI: Enter); sie wird eingeschleust, sobald der aktuelle
Tool-Aufruf fertig ist, ohne auf das Ende des ganzen Turns zu warten.
- **Follow-up-Queue** — alternativ eine Nachricht für *nach* dem aktuellen
Turn vormerken (TUI: Alt+Enter), statt den laufenden Gedankengang zu
unterbrechen.
- **Sicherheitsgrenzen** — Obergrenze für Turns pro Anfrage, Timeout pro
Tool-Aufruf, automatische Erkennung, wenn das Modell denselben Tool-Aufruf
wiederholt in eine Schleife läuft.
- **Sauberer Abbruch** — Ctrl+C (TUI) bzw. Ctrl+C im Print-Modus bricht
einen laufenden `bash`-Aufruf inklusive aller Kindprozesse ab, statt sie
als Zombies zurückzulassen.
## Eingebaute Tools
| Tool | Was es tut |
|---|---|
| `read` | Datei mit Zeilennummern lesen, mit Offset/Limit für große Dateien |
| `write` | Datei vollständig (neu) schreiben |
| `edit` | Gezielter String-Ersatz; bei mehrdeutigem Treffer Fehler statt Raten |
| `bash` | Shell-Befehl ausführen, mit Timeout und sauberem Abbruch |
| `glob` | Dateien per Muster finden, `.gitignore` wird respektiert |
| `grep` | Volltextsuche mit Kontextzeilen |
| `todo` | Fortschritts-Liste für lange Aufgaben (nur In-Memory) |
| `read_skill` | Vollständigen Inhalt eines Skills nachladen (siehe unten) |
| `question` | Freitext-Rückfrage an den Nutzer stellen (in der TUI als Block im Transkript, Antwort kommt als normales Tool-Ergebnis zurück; im Print-Modus ein harter Fehler, siehe unten) |
Dazu kommen automatisch alle Tools, die über angebundene MCP-Server
verfügbar sind (siehe „MCP" unten).
## Sessions
- Jede Konversation ist ein **Baum**, kein linearer Verlauf — jederzeit
lässt sich zu einem früheren Punkt zurückspringen und von dort aus neu
weiterarbeiten, ohne den ursprünglichen Verlauf zu verlieren.
- **Persistenz**: Jede Session wird fortlaufend auf die Platte geschrieben
(`.nub/sessions/`), nicht erst beim Beenden — ein Absturz verliert keine
Historie.
- **Fortsetzen**: `nub resume <session-id>` nimmt eine frühere Session genau
dort wieder auf, wo sie aufgehört hat.
- **Rewind/Branch**: Auf einen früheren Punkt zurückspringen und neu
weiterarbeiten erzeugt einen zweiten Ast — beide bleiben erhalten und
einzeln aufrufbar (TUI: `/tree` oder `/branch`; CLI: `nub resume --branch`).
- **Sessions auflisten**: `nub sessions` zeigt alle Sessions im aktuellen
Verzeichnis mit Kurzbeschreibung.
- **Session-Wechsel ohne Neustart**: In der TUI listet `/sessions` alle
Sessions des Projekts; Enter wechselt live zur gewählten Session (der
bisherige Verlauf wird aus der Historie neu aufgebaut), Entf löscht eine
Session unwiderruflich (mit Rückfrage). Die gerade aktive Session lässt
sich nicht löschen.
## Kontext
- **Automatischer System-Prompt** aus Basis-Prompt, Umgebungsinfo
(OS/Shell/Repo-Root/Branch) und konfigurierten Kontext-Dateien
(Default: `AGENTS.md`, `REPOMAP.md`).
- **`walk_up`**: Kontext-Dateien werden nicht nur im Repo-Root gesucht,
sondern auf dem Weg vom aktuellen Verzeichnis dorthin eingesammelt —
näher am Arbeitsverzeichnis gewinnt inhaltlich.
- **Budget-Kontrolle**: Kontext-Dateien werden auf ein Token-Budget
begrenzt; bei Überschreitung wird sichtbar gekürzt statt still
abgeschnitten.
- **`nub map`**: generiert eine `REPOMAP.md` mit Verzeichnisbaum und
exportierten Symbolen (Go: über `go/ast`, andere Sprachen: Dateikopf als
Fallback). Warnt, wenn die Repomap gegenüber dem aktuellen Git-Stand
veraltet ist.
- **`SYSTEM.md`**: eine Datei im Repo-Root überschreibt den eingebauten
Basis-System-Prompt vollständig, für projektspezifisches Verhalten.
## Auto-Compaction & Tokens
- Sobald der geschätzte Kontextverbrauch eine Schwelle (Default 75 % des
Modell-Fensters) überschreitet, fasst `nub` die ältere Hälfte der Session
automatisch zusammen (bearbeitete Dateien, Entscheidungen, offene
Aufgaben) und ersetzt sie durch eine kompakte Zusammenfassung — die
letzten Turns bleiben immer im Original erhalten.
- Die Zusammenfassung ist **nicht destruktiv**: der Originalverlauf bleibt
im Session-Baum erhalten, ein Rewind auf einen Punkt davor funktioniert
weiterhin.
- **Manuell erzwingen**: `/compact` (TUI) oder `nub compact <session-id>`.
- Die Token-Schätzung kalibriert sich während des Laufs gegen die
tatsächlich vom Provider gemeldete Token-Nutzung.
## Skills
- Wiederverwendbare Anleitungen für wiederkehrende Aufgaben (z.B.
„Vorgehen bei Refactorings"), als Markdown-Datei mit kurzem
Frontmatter (Name + Beschreibung) unter `.nub/skills/<name>/SKILL.md`
oder global unter `~/.nub/skills/`.
- **Progressive Disclosure**: Nur Name und Beschreibung stehen im
System-Prompt (~30 Token pro Skill). Der volle Inhalt wird erst geladen,
wenn das Modell ihn über `read_skill` tatsächlich braucht — spart Kontext
bei Skills, die in der aktuellen Aufgabe gar nicht relevant sind.
- `nub skills` (CLI) bzw. `/skills` (TUI) zeigen, was gefunden wurde.
- **Als Slash-Command**: Jeder Skill ist in der TUI direkt per `/<name>
[Zusatzinfo]` aufrufbar — schickt eine kurze Direktive statt den Body
einzufügen, das Modell lädt ihn weiterhin selbst nach. Eingebaute
Kommandos gewinnen bei Namenskollision.
## MCP (Model Context Protocol)
- Externe Tool-Server lassen sich per Config anbinden (`[[mcp]]`-Einträge:
lokaler Prozess über stdio oder ein HTTP-Endpoint).
- Alle konfigurierten Server werden **parallel** verbunden, mit Timeout —
ein nicht erreichbarer oder kaputter Server verhindert den Start nicht,
sondern fällt mit einer Warnung raus.
- Tools erscheinen mit `<server>__`-Präfix, um Namenskollisionen zwischen
Servern zu vermeiden.
- **Allowlist**: pro Server lässt sich einschränken, welche Tools geladen
werden (`tools = [...]`); ohne Allowlist wird ab 15 Tools gewarnt, weil
das spürbar Kontext kostet.
- **Profile**: `[profiles]` in der Config gruppiert Server, sodass sich
z.B. nur die für eine Code-Review relevanten Server verbinden lassen
(`--profile review`), statt immer alle.
- `nub mcp` (CLI) bzw. `/mcp` (TUI) zeigen verbundene Server und ihre Tools.
## TUI-Details
- **Streaming mit Markdown-Rendering**: laufender Text erscheint sofort,
fertige Antworten werden nachträglich als Markdown formatiert.
- **Diff-Overlay**: Datei-Änderungen (`write`/`edit`) erscheinen farbig
(+/-) direkt im Transkript.
- **Farbliche Unterscheidung** zwischen eigenen Nachrichten, Assistant-Text,
Tool-Aufrufen/-Ergebnissen, Slash-Kommando-Ausgaben, Hintergrund-
Ereignissen (z.B. Auto-Compaction) und echten Fehlern — alle Farben
passen sich automatisch an helle oder dunkle Terminals an.
- **Eingabe-Historie** (↑/↓), Scrollen der Nachrichtenliste (PgUp/PgDn,
Ctrl+U/D, Mausrad).
- **Interaktives Baum-Modal** (`/tree`, `/branch`) zum Durchsuchen und
Auswählen früherer Punkte der Session.
- **Export**: `/export [pfad]` schreibt das komplette Transkript als
Markdown-Datei.
## Konfiguration
- **Geschichtet**: eingebaute Defaults → `~/.config/nub/config.toml`
(global) → `.nub/config.toml` (projektlokal) → `NUB_*`-Umgebungs-
variablen — spätere Schicht gewinnt.
- **Mehrere Endpoints**: beliebig viele `[[endpoint]]`-Einträge (OpenAI,
lokale Server wie Ollama/LM Studio/vLLM, …), einzeln in ihren
Fähigkeiten konfigurierbar (z.B. `parallel_tool_calls = false` für
Server, die das nicht unterstützen).
- Ohne jede Config-Datei funktioniert `nub` bereits mit sinnvollen
Defaults gegen die echte OpenAI-API.
- **`nub init`** (CLI) bzw. **`/init`** (TUI) legen `.nub/config.toml` mit
einer vollständig kommentierten Beispiel-Config (inkl. Permissions-
Beispiel) sowie ein minimalistisches `AGENTS.md` an — beides nur, falls
noch nicht vorhanden, nichts wird überschrieben.
## Sicherheit & Robustheit
- **Permissions pro Tool**: `read`/`glob`/`grep`/`write`/`edit`/`bash`
lassen sich einzeln auf `auto`, `ask` (Rückfrage in der TUI, im
Print-Modus ein harter Fehler) oder `deny` setzen. `deny_paths`/
`deny_bash` sperren zusätzlich bestimmte Pfade bzw. Befehlsmuster,
unabhängig vom sonst konfigurierten Modus.
- **`question`-Tool**: generische Rückfrage-Fähigkeit, die sowohl das
Modell selbst als auch Skills (per Anweisung) nutzen können, um eine
Freitext-Frage (optional mit Vorschlägen) an den Nutzer zu stellen und
die Antwort direkt als Tool-Ergebnis zurückzubekommen — dieselbe
Mechanik wie bei einer `ask`-Permission-Rückfrage, nur nicht an ein
bestimmtes Tool gebunden. In der TUI erscheint sie als Block im
Transkript (kein Overlay); die nächste Eingabe wird als Antwort
gewertet statt als neue Nachricht oder Slash-Kommando. Im Print-Modus
liefert sie einen klaren Fehler statt zu blockieren, da Print bewusst
headless laufen soll.
- Datei-Tools sind auf das Repository beschränkt (inkl. Auflösen von
Symlinks, um ein Verlassen des Repo-Roots zu verhindern).
- Sessiondateien haben restriktive Dateirechte (`0600`) und werden
automatisch von Git ausgeschlossen (`.git/info/exclude`, nicht
`.gitignore` — das bleibt Sache des Projekts).
- Kein Tool-Aufruf endet ohne Ergebnis — auch bei Abbruch oder Fehler
bekommt das Modell eine verwertbare Antwort statt eines hängenden
Zustands.

857
docs/KONZEPT.md Normal file
View file

@ -0,0 +1,857 @@
# Coding-Agent CLI in Go — Konzept & Umsetzungsplan
> Projektname: **nub** — der Kern einer Sache, „the nub of it".
>
> Dieses Dokument ist als Arbeitsgrundlage für die Umsetzung mit einem Coding-Agenten
> gedacht. Abschnitt 10 beschreibt, wie es dafür eingesetzt wird.
---
## 1. Ziel & Abgrenzung
### Ziel
Ein schlanker, terminal-basierter Coding-Agent als **einzelnes statisches Go-Binary**,
ohne Runtime-Abhängigkeit beim Anwender.
**Im Umfang:**
- Agent-Loop mit Tool-Calling und Streaming
- Provider: OpenAI-kompatibel (Start), Architektur offen für Anthropic/Gemini
- Eingebaute Tools: `read`, `write`, `edit`, `bash`, `glob`, `grep`, `todo`
- MCP-Client (stdio + Streamable HTTP)
- Skills mit Progressive Disclosure
- Kontext-Dateien: `AGENTS.md`, `REPOMAP.md`, frei konfigurierbar
- Session-Persistenz als **Baum** inkl. Rewind/Branch
- Auto-Compaction
- Modi: interaktiv (TUI) und Print/JSON (`-p`)
**Nicht im Umfang (v1):**
- Desktop-/Web-UI
- Sub-Agents
- LSP-Integration
- Plugin-System für Fremdcode (MCP + Skills decken den Bedarf ab)
- Eigenes Model-Hosting/Routing
### Leitprinzipien
1. **Der Kern kennt kein Terminal.** Jede Ausgabe läuft über einen Event-Channel.
2. **Das interne Datenmodell ist reicher als jeder Provider.** Adapter mappen herunter,
nie herauf.
3. **Kontext ist ein Budget.** Jede Komponente, die den Prompt befüllt, deklariert ihre
Kosten und respektiert Obergrenzen.
4. **Kein Zustand außerhalb des Session-Baums.** Alles, was einen Turn beeinflusst hat,
ist rekonstruierbar.
---
## 2. Architektur
### 2.1 Paketstruktur
```
cmd/nub/ Einstiegspunkt, Flag-Parsing, Modus-Auswahl
internal/config/ Layered Config, Auflösung, Validierung
internal/llm/ Message, Block, Event, Provider-Interface, Caps
internal/llm/openai/ OpenAI-kompatibler Adapter
internal/llm/registry/ Modell-Metadaten, Endpoint-Auflösung
internal/tool/ Tool-Interface, Registry, Permissions
internal/tool/builtin/ read, write, edit, bash, glob, grep, todo, read_skill
internal/mcpc/ MCP-Client, Bridge MCP-Tool -> tool.Tool
internal/skill/ Skill-Discovery, Frontmatter-Parsing, Index
internal/ctxasm/ Context Assembler (System-Prompt-Aufbau)
internal/session/ Baum-Modell, Store, Compaction
internal/agent/ Loop, Turn, Steering, Cancellation
internal/tokens/ Schätzung + Kalibrierung gegen echte Usage
internal/ui/plain/ Renderer für Print-Modus
internal/ui/tui/ Bubbletea-TUI (Meilenstein 6)
```
**Abhängigkeitsrichtung:** `ui``agent` → {`llm`, `tool`, `session`, `ctxasm`} →
`config`. Kein Paket unterhalb von `agent` importiert `ui`. Diese Regel ist die
wichtigste Strukturvorgabe des Projekts; sie wird per Test abgesichert (Abschnitt 7).
### 2.2 Datenfluss eines Turns
```
User-Input
└─> agent.Loop
├─> ctxasm.Assemble() → System-Blöcke + Tool-Definitionen
├─> session.PathToHead() → Message-Historie
├─> llm.Provider.Stream() → iter.Seq2[Event, error]
│ └─> UIEvent-Channel (Text-Deltas, Tool-Start, ...)
├─> tool.Registry.Run() → parallel, ctx-gebunden
│ └─> UIEvent-Channel (Tool-Output, Diffs)
└─> session.Append() → neue Nodes, Head verschieben
(Schleife bis StopReason != tool_use)
```
### 2.3 Rahmenbedingungen
**Go 1.24.** Untere Grenze ist 1.23 wegen `iter.Seq2` im Provider-Interface (4.3).
In der `go.mod` fixieren und in der `AGENTS.md` erwähnen — sonst werden
Channel-basierte Iteratoren generiert.
**Dependency-Allowlist.** Ein schlankes Tool mit 180 Modulen in der `go.sum` ist ein
Widerspruch in sich. Erlaubt sind:
| Zweck | Modul |
|---|---|
| TUI | `charmbracelet/bubbletea`, `lipgloss`, `bubbles`, `glamour`, `chroma` |
| MCP | `modelcontextprotocol/go-sdk` |
| Config | ein TOML-Parser (`BurntSushi/toml`) |
| Globbing | `bmatcuk/doublestar` |
| IDs | `oklog/ulid` |
Alles andere: stdlib. Insbesondere **kein** Viper und **kein** Cobra — `flag` aus der
stdlib deckt das Kommandoset ab. Neue Abhängigkeiten sind eine bewusste Entscheidung,
keine Nebenwirkung.
**Logging.** `log/slog` als JSON in eine Datei unter `~/.local/state/nub/log`
(bzw. `$XDG_STATE_HOME`), Level über `NUB_LOG`. **Niemals auf stdout** — das zerstört
den JSON-Modus und die TUI. `log.Printf` und `fmt.Println` sind unterhalb von
`internal/ui` verboten; derselbe Architektur-Test wie für E-04 prüft das mit.
---
## 3. Entscheidungen
Format: Entscheidung → Begründung → Konsequenz.
### E-01 — Internes Message-Modell ist blockbasiert
Nicht OpenAIs flaches Schema (`tool_calls` am Assistant-Objekt, `role:"tool"` als
eigene Message), sondern Content-Blöcke.
*Begründung:* Anthropic und Gemini brauchen Blöcke. Blöcke → flach ist mechanisch,
flach → Blöcke ist verlustbehaftet. Thinking-Blöcke mit Signaturen haben im flachen
Modell keinen Platz.
*Konsequenz:* Der OpenAI-Adapter ist am Anfang aufwendiger als nötig. Akzeptiert.
### E-02 — Provider-SDK-Typen erreichen den Kern nicht
`openai-go` (oder direkt `net/http`) nur innerhalb von `internal/llm/openai`.
*Begründung:* Sonst zementiert man OpenAIs Modell im ganzen Projekt.
*Konsequenz:* ~400 Zeilen eigenes SSE-Parsing und Mapping. Empfehlung: direkt
`net/http` + `bufio.Scanner`, das SDK bringt bei diesem Zuschnitt wenig.
### E-03 — Session ist ein Baum, ab Tag 1
Nicht als Liste mit später aufgesetztem Branching.
*Begründung:* Rewind/Branch kostet initial fast nichts und ist nachträglich kaum
einzuziehen. Compaction wird dadurch ebenfalls sauber modellierbar (E-08).
*Konsequenz:* Jeder Zugriff auf „die Historie" geht über `PathToHead()`, nie über
einen Slice.
### E-04 — Alle Kernausgabe läuft über einen Event-Channel
`agent.Run` gibt `<-chan UIEvent` zurück. Kein `fmt.Println` unterhalb von `internal/ui`.
*Begründung:* Print-Modus, JSON-Modus und TUI fallen dadurch als drei dünne Consumer ab.
Das ist die Voraussetzung dafür, die TUI zuletzt bauen zu können.
*Konsequenz:* Disziplin nötig. Wird per Test abgesichert (9.4).
### E-05 — Tool-Ergebnisse sind zweigeteilt
`Result{ForModel string, ForUI any}`.
*Begründung:* Das Modell braucht bei `edit` ein knappes „ok, 3 Zeilen geändert", die UI
einen gerenderten Diff. Ein gemeinsamer String zwingt zu einem schlechten Kompromiss und
verbrennt Tokens.
*Konsequenz:* Jedes Tool definiert einen UI-Typ. Der Print-Renderer fällt bei unbekannten
Typen auf `ForModel` zurück.
### E-06 — MCP-Tools werden beim Start geladen, Liste dann eingefroren
Alle konfigurierten Server werden parallel beim Start verbunden.
*Begründung:* Tool-Definitionen sind Teil des gecachten Prompt-Prefix. Eine mitten in der
Session wachsende Tool-Liste zerstört den Cache in jeder Runde.
*Konsequenz:* Startup-Latenz. Gegenmaßnahme: Verbindungsaufbau läuft parallel, während
der Nutzer den ersten Prompt tippt. Connect-Timeout (Default 5s) → Server fällt raus,
Warnung in die Statuszeile, Loop startet trotzdem.
### E-07 — MCP-Allowlist pro Server ist Pflicht
Ohne `tools = [...]` werden alle Tools geladen, aber es wird gewarnt, sobald ein Server
mehr als 15 Tools beisteuert.
*Begründung:* Ein GitHub-MCP-Server kostet 2040k Tokens permanent.
*Konsequenz:* Zusätzlich Server-Profile pro Projekt (`profiles.review = ["github"]`),
damit die Last nur dort anfällt, wo sie gebraucht wird.
### E-08 — Compaction ist ein Knoten im Baum
Ein `Summary`-Node mit `Replaces []NodeID`. `PathToHead()` läuft von Head Richtung Wurzel
und stoppt am ersten Summary-Node.
*Begründung:* Kein destruktives Löschen, das Original bleibt für Rewind erhalten, und die
Compaction selbst ist branchbar.
*Konsequenz:* Der Pfad-Walk braucht diese Sonderbehandlung. Trivial, aber muss getestet
sein.
### E-09 — Kein echter Tokenizer in v1
Schätzung über `len(text)/4` (bzw. `/3` für CJK), kalibriert gegen die tatsächliche
`usage` aus jeder API-Antwort.
*Begründung:* Tokenizer sind pro Modell verschieden und die exakte Zahl ist nur für den
Compaction-Trigger relevant. Ein 15%-Sicherheitsabstand ist billiger als die Abhängigkeit.
*Konsequenz:* Compaction-Schwelle konservativ (Default: 75% des Fensters).
### E-10 — Capabilities pro Endpoint, nicht pro Protokoll
„OpenAI-kompatibel" ist ein Sammelbegriff. Groq, Cerebras, vLLM, Ollama, LM Studio und
OpenRouter verhalten sich unterschiedlich.
*Konsequenz:* `Caps`-Struct (4.4), befüllt aus Modell-Registry + Config-Overrides.
### E-11 — Permissions: statisch konfiguriert, keine interaktiven Popups in v1
Modus pro Tool (`ask`/`auto`/`deny`) plus Pfad-Regeln. `ask` ist im Print-Modus ein
harter Fehler.
*Begründung:* Interaktive Rückfragen erzwingen eine UI-Abhängigkeit im Kern (verletzt
E-04) und sind im Print-Modus sinnlos.
*Konsequenz:* v1 setzt darauf, dass sicherheitskritische Nutzung im Container läuft.
Interaktive Bestätigung kann in Meilenstein 6 als UI-Event nachgerüstet werden.
### E-12 — Retry gehört in den Provider-Layer, nicht in den Loop
Wiederholungsversuche passieren unterhalb von `Provider.Stream`. Der Agent-Loop sieht
entweder einen erfolgreichen Stream oder einen endgültigen Fehler.
*Begründung:* Der Loop kennt keine HTTP-Semantik, und Retry-Logik an zwei Stellen führt
zu multiplikativen Wartezeiten.
*Konsequenz:* Ein Retry ist nur zulässig, solange noch keine Blöcke an den Consumer
emittiert wurden. Reißt der Stream mitten in einer Antwort ab, wird der Turn als Fehler
beendet — ein halb ausgegebener Text darf nicht doppelt erscheinen. Details in 5.9.
---
## 4. Datenmodelle
### 4.1 Messages & Blöcke (`internal/llm`)
```go
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
type BlockKind string
const (
KindText BlockKind = "text"
KindThinking BlockKind = "thinking"
KindToolUse BlockKind = "tool_use"
KindToolResult BlockKind = "tool_result"
KindImage BlockKind = "image"
)
type Block struct {
Kind BlockKind
// text / thinking
Text string
// tool_use
ID string
Name string
Input json.RawMessage
// tool_result
ToolUseID string
Result []Block
IsError bool
// image
MediaType string
Data []byte
// Opaker Provider-Ballast (Anthropic-Signatures, reasoning-IDs).
// Muss unverändert zurückgesendet werden können.
Raw json.RawMessage
// Cache-Breakpoint. Adapter ohne Cache-Steuerung ignorieren das Feld.
CacheMark bool
}
type Message struct {
Role Role
Content []Block
}
type Request struct {
Model string
System []Block // eigenes Feld, NICHT als Message in der Historie
Messages []Message
Tools []ToolDef
MaxTokens int
Temp *float64
}
type ToolDef struct {
Name string
Description string
Schema json.RawMessage // kanonisch: JSON Schema
}
```
**Wichtig:** `System` ist ein Request-Feld, keine Message. Sonst landet es im
Session-Baum und wird bei jedem Rewind mitgeschleppt.
### 4.2 Stream-Events (`internal/llm`)
Modelliert nach dem expliziteren Anthropic-Schema; der OpenAI-Adapter synthetisiert.
```go
type Event interface{ isEvent() }
type BlockStart struct { Index int; Block Block } // Block ohne Inhalt, nur Kind/ID/Name
type BlockDelta struct { Index int; Text string; PartialJSON string }
type BlockStop struct { Index int }
type Done struct { Stop StopReason; Usage Usage }
type StopReason string
const (
StopEnd StopReason = "end_turn"
StopToolUse StopReason = "tool_use"
StopMaxTokens StopReason = "max_tokens"
)
type Usage struct{ InputTokens, OutputTokens, CacheReadTokens, CacheWriteTokens int }
```
### 4.3 Provider-Interface
```go
type Provider interface {
Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error)
Caps() Caps
Name() string
}
```
### 4.4 Capabilities
```go
type Caps struct {
ParallelToolCalls bool
UsageInStream bool
SystemRole string // "system" | "developer" | "" = eigenes Request-Feld
ExplicitCache bool
Reasoning bool
StrictSchemas bool
MaxContext int
SupportsImages bool
}
```
### 4.5 Session-Baum (`internal/session`)
```go
type NodeKind string
const (
NodeMessage NodeKind = "message"
NodeSummary NodeKind = "summary"
)
type Node struct {
ID string // ULID
ParentID string // "" = Wurzel
Kind NodeKind
Message llm.Message
Replaces []string // nur bei NodeSummary
Meta NodeMeta
}
type NodeMeta struct {
Model string
Usage llm.Usage
CreatedAt time.Time
Label string
Bookmark bool
}
type Session struct {
ID string
Nodes map[string]*Node
Head string
}
func (s *Session) PathToHead() []llm.Message // Wurzel→Head, stoppt am Summary
func (s *Session) Branch(from string) error // setzt Head auf einen früheren Knoten
func (s *Session) Append(n *Node) error
```
**Persistenz:** Append-only JSONL unter `.nub/sessions/<id>.jsonl`, eine Zeile pro Node.
Der Head wird als eigener Record-Typ (`{"head":"..."}`) angehängt; beim Laden gewinnt der
letzte. Crash-sicher, branchbar, ohne DB-Abhängigkeit. SQLite bleibt eine spätere Option,
falls Suche über Sessions gebraucht wird.
**Jeder Record beginnt mit `"v": 1`.** Ein Feld, kostet jetzt nichts, und das Format wird
sich ändern. Beim Laden: unbekannte höhere Version → Fehler mit klarer Meldung, nicht
stillschweigend halb parsen. Dasselbe gilt für die Config (`version = 1`).
**Dateirechte und Git.** Sessiondateien enthalten Tool-Ausgaben und damit potenziell
Umgebungsvariablen, Tokens und Dateiinhalte. Deshalb:
- `.nub/` mit `0700`, Sessiondateien mit `0600`
- beim ersten Start `.nub/` nach `.git/info/exclude` schreiben, **nicht** in die
`.gitignore` — die wird committet und gehört dem Repo, nicht dem Tool
### 4.6 Tool-Interface (`internal/tool`)
```go
type Tool interface {
Name() string
Description() string
Schema() json.RawMessage
Run(ctx context.Context, input json.RawMessage, env Env) (Result, error)
}
type Result struct {
ForModel string // was in den tool_result-Block geht
ForUI any // strukturiert; z.B. DiffResult, FileResult
IsError bool
}
type Env struct {
Cwd string
RepoRoot string
Emit func(UIEvent) // für streamende Tools (bash)
}
```
### 4.7 Config (`internal/config`)
Schichtung, spätere gewinnt: Defaults → `~/.config/nub/config.toml`
`<repo>/.nub/config.toml` → Umgebungsvariablen (`NUB_*`) → Flags.
```toml
[model]
default = "gpt-4o"
endpoint = "openai" # Verweis auf [[endpoint]]
[[endpoint]]
name = "openai"
base_url = "https://api.openai.com/v1"
api_key = "env:OPENAI_API_KEY"
[[endpoint]]
name = "local"
base_url = "http://localhost:11434/v1"
api_key = "none"
[endpoint.caps] # Overrides gegen die Registry
parallel_tool_calls = false
usage_in_stream = false
[context]
files = ["AGENTS.md", "REPOMAP.md"]
walk_up = true
max_tokens = 20000
[skills]
paths = ["~/.nub/skills", ".nub/skills"]
[[mcp]]
name = "github"
command = "gh-mcp-server"
args = []
tools = ["create_issue", "get_pull_request"]
[[mcp]]
name = "linear"
url = "https://mcp.linear.app/mcp"
[profiles]
review = ["github"]
default = []
[permissions]
read = "auto"
glob = "auto"
grep = "auto"
write = "auto"
edit = "auto"
bash = "auto"
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
deny_bash = ["rm -rf /", "git push --force*"]
```
---
## 5. Komponenten im Detail
### 5.1 OpenAI-Adapter
**Aufgaben:** Request-Mapping, SSE-Parsing, Tool-Call-Akkumulation, Event-Synthese.
Kritische Punkte:
1. **Tool-Call-Akkumulation.** Argumente kommen als String-Fragmente über
`choices[0].delta.tool_calls[i]`. `id` und `name` kommen typischerweise nur im ersten
Chunk; `index` ist die einzige verlässliche Korrelation. Zustand in
`map[int]*partialCall` halten. Tolerant sein: manche Server senden den kompletten
Call in einem Chunk, manche zählen `index` falsch, manche liefern `name` nachträglich.
2. **`tools` weglassen statt leer senden.** `"tools": []` führt bei mehreren
Implementierungen zu Fehlern.
3. **Usage.** Nur mit `stream_options: {include_usage: true}` und nur bei Endpoints mit
`UsageInStream`. Sonst über die Schätzung aus `internal/tokens`.
4. **System-Rolle.** Je nach `Caps.SystemRole` als `system`, `developer` oder gar nicht
(dann als erste User-Message mit Trennmarker).
5. **`reasoning_content`.** Nicht-standardisiertes Feld (DeepSeek u.a.). Wenn vorhanden
und `Caps.Reasoning`, als `KindThinking`-Block mappen.
6. **`[DONE]`-Sentinel** ist kein JSON — vor dem Unmarshal abfangen.
7. **Fehler mitten im Stream.** Manche Endpoints senden ein `error`-Objekt als
SSE-Event statt eines HTTP-Fehlers. Behandeln.
### 5.2 Agent-Loop
```go
func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan UIEvent
```
Ablauf pro Turn:
1. Kontext zusammenbauen, Historie holen, Provider streamen.
2. Deltas als UIEvents emittieren, parallel Blöcke akkumulieren.
3. Bei `StopToolUse`: alle Tool-Calls parallel starten (Errgroup, abgeleiteter ctx).
4. Ergebnisse einsammeln, als `tool_result`-Blöcke in **eine** User-Message packen.
5. Zurück zu 1, bis `StopEnd`.
**Steering:** Der `in`-Channel wird auch während eines laufenden Turns gelesen.
Eine Steering-Nachricht wird nach Abschluss des *aktuell laufenden* Tools eingeschleust;
noch nicht gestartete Tools werden abgebrochen und bekommen einen `tool_result` mit
`IsError` und Hinweistext (das Protokoll verlangt für jeden `tool_use` ein Ergebnis —
das Auslassen ist ein häufiger Bug). Follow-up-Nachrichten werden gequeued.
**Cancellation:** Zwei Ebenen. Abbruch des Turns (Stream + Tools beenden, Session
konsistent lassen) und Programmende. Ein abgebrochener Turn muss einen gültigen
Baumzustand hinterlassen — d.h. entweder alle `tool_use`-Blöcke bekommen ein Ergebnis,
oder der unvollständige Assistant-Node wird nicht committet.
**Safety-Limits:** `max_turns` pro Anfrage (Default 50), `max_tool_time` pro Tool
(Default 120s), Wiederholungserkennung (identischer Tool-Call 3× hintereinander → Abbruch
mit Hinweis).
### 5.3 Eingebaute Tools
| Tool | Anmerkungen |
|---|---|
| `read` | Zeilennummern voranstellen, Offset/Limit-Parameter, Default-Limit ~2000 Zeilen, Binärerkennung |
| `write` | Vollständiger Dateiinhalt, Parent-Dirs anlegen, `ForUI` = Diff gegen alt |
| `edit` | Exakter String-Replace. Bei mehreren Treffern Fehler mit Trefferzahl, nicht raten. `replace_all`-Flag |
| `bash` | Timeout, **Prozessgruppen-Kill** (`Setpgid`, `syscall.Kill(-pgid)`), Output-Truncation bei ~30k Zeichen (Mitte kürzen, Anfang+Ende behalten), streamt über `Env.Emit` |
| `glob` | Doublestar-Pattern, `.gitignore` respektieren, nach mtime sortiert |
| `grep` | Regex, in-process (`regexp` + Walker) oder `rg` falls vorhanden. Kontext-Zeilen-Parameter |
| `todo` | In-Memory-Liste im Session-State. Messbarer Nutzen bei langen Tasks |
| `read_skill` | Lädt Skill-Body nach Name (5.5) |
Pfad-Regel für alle Datei-Tools: nur unterhalb von `RepoRoot`, Symlinks auflösen und
erneut prüfen, `deny_paths` anwenden.
### 5.4 Context Assembler (`internal/ctxasm`)
Baut `Request.System` in dieser Reihenfolge — von stabil nach volatil:
1. Basis-System-Prompt (eingebettet via `go:embed`, überschreibbar durch `SYSTEM.md`)
2. Umgebung: OS, Shell, Repo-Root, Branch — **statisch für die Session**, keine
Zeitstempel, kein Git-Status
3. Konfigurierte Kontext-Dateien (`AGENTS.md`, `REPOMAP.md`, …)
4. Skills-Index (nur Name + Description)
5. → `CacheMark = true` auf dem letzten Block ←
Alles Dynamische (Git-Status, Datum, Tool-Ausgaben) gehört in Messages, nie in `System`.
**Datei-Auflösung bei `walk_up = true`:** Von `cwd` bis `RepoRoot` hochlaufen, alle
Treffer sammeln, in der Reihenfolge Repo-Root → cwd einfügen (näher am
Arbeitsverzeichnis gewinnt inhaltlich, weil später gelesen). Herkunft jeder Datei
protokollieren und im `/context`-Kommando anzeigen.
**Budget:** `context.max_tokens` wird geprüft. Bei Überschreitung wird die *letzte* Datei
gekürzt und eine Warnung emittiert — nicht stillschweigend abgeschnitten.
**REPOMAP.md:** Es gibt ein `nub map`-Kommando, das eine Repomap generiert
(Verzeichnisbaum + exportierte Symbole; für Go über `go/ast`, sprachagnostisch als
Fallback der Dateikopf). Erzeugt wird ein Header mit dem Git-Commit, gegen den die Map
gebaut wurde. Beim Start wird gewarnt, wenn der aktuelle HEAD abweicht — eine falsche
Repomap ist schlechter als keine.
### 5.5 Skills (`internal/skill`)
Struktur:
```
.nub/skills/
refactoring/
SKILL.md
checklist.md
```
`SKILL.md` mit YAML-Frontmatter:
```markdown
---
name: refactoring
description: Vorgehen für größere Refactorings in diesem Repo. Nutzen, wenn mehr als 3 Dateien betroffen sind.
---
# Refactoring
...Body...
```
**Mechanik:** Beim Start werden alle Skills gefunden, aber nur `name` + `description` in
den Prompt geschrieben (~30 Tokens pro Skill). Das Tool `read_skill(name)` liefert den
Body. Weitere Dateien im Skill-Verzeichnis findet das Modell selbst über `read`.
Das ist der Kostenunterschied zu MCP: ein Skill kostet 30 Tokens bis zur Nutzung, ein
MCP-Tool 300800 permanent.
### 5.6 MCP-Client (`internal/mcpc`)
Basis: `github.com/modelcontextprotocol/go-sdk` (v1.5+, stabil, stdio + Streamable HTTP,
Client-OAuth).
- Verbindungsaufbau parallel beim Start, Timeout 5s pro Server (E-06)
- Tool-Namen werden mit `<server>__` prefixt
- Allowlist aus der Config filtert vor der Registrierung
- Bridge: MCP-Tool → `tool.Tool`. `ForModel` = textuelle Content-Blöcke,
`ForUI` = strukturierter Content
- Server-Ausfall zur Laufzeit: Tool-Aufruf liefert `IsError`, kein Panic, kein
Loop-Abbruch
- Reconnect wird in v1 **nicht** versucht (würde die Tool-Liste ändern, siehe E-06)
### 5.7 Compaction
Trigger: geschätzte Prompt-Tokens > `compact_at` (Default 75%) von `Caps.MaxContext`.
Ablauf:
1. Ältere Hälfte des Pfads auswählen; die letzten N Turns (Default 4) bleiben immer roh.
2. Zusammenfassung mit separatem Prompt erzeugen (gleiches Modell, ggf. konfigurierbar
günstigeres).
3. `NodeSummary` mit `Replaces` = IDs der zusammengefassten Knoten anhängen.
4. Head verschieben.
Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene Entscheidungen,
offene Aufgaben, wiederherstellbarer Zustand. Nicht: Prosa über den Gesprächsverlauf.
### 5.8 UI
**Print-Modus (`-p`):** Textdeltas nach stdout, Tool-Aktivität nach stderr, Exit-Code
≠ 0 bei Fehler. `--mode json` gibt stattdessen einen NDJSON-Event-Stream aus — das ist
die Integrationsschnittstelle.
**TUI (Meilenstein 6):** Bubbletea + Lipgloss + Bubbles, `glamour` für Markdown,
`chroma` für Syntax-Highlighting. Komponenten: Message-Liste (virtualisiert), Editor mit
Historie, Statuszeile (Modell, Tokens, Kosten), Modal-Stack für `/model` und `/tree`,
Diff-Overlay.
Kommandos: `/model`, `/tree`, `/branch`, `/compact`, `/context`, `/mcp`, `/skills`,
`/export`, `/clear`.
### 5.9 Fehlerbehandlung & Retry
Angesiedelt im Provider-Layer (E-12). Die Klassifikation ist wichtiger als der Backoff:
| Situation | Verhalten |
|---|---|
| 429 mit `Retry-After` | Header respektieren, bis 3 Versuche |
| 429 ohne Header | Exponentiell 1s/2s/4s + Jitter |
| 500, 502, 503, 504 | Exponentiell, bis 3 Versuche |
| Netzwerk-/Timeout-Fehler vor dem ersten Byte | Retry |
| Stream reißt nach emittierten Blöcken ab | **Kein Retry** — Turn als Fehler beenden |
| Context-Length überschritten | **Kein Retry** — Compaction auslösen, dann einmalig neu |
| 400 (ungültiges Schema o.ä.) | Kein Retry, Fehler durchreichen |
| 401/403 | Kein Retry, Hinweis auf Konfiguration |
Context-Length-Fehler zuverlässig zu erkennen ist unschön, weil jeder Endpoint sie anders
formuliert. Pragmatisch: HTTP 400 plus Substring-Match auf `context length`,
`context_length_exceeded`, `too many tokens`, `maximum context`. Bei Unsicherheit lieber
als normalen Fehler behandeln, als in eine Compaction-Schleife zu laufen — deshalb nach
einer ausgelösten Compaction **genau ein** weiterer Versuch, danach Abbruch.
Alle Retries werden über `slog` protokolliert (2.3) und als UIEvent gemeldet, damit im
interaktiven Betrieb sichtbar ist, warum es hängt.
**Exit-Codes im Print-Modus:** 0 Erfolg, 1 Agent-/Provider-Fehler, 2 Konfigurationsfehler,
3 Abbruch durch Nutzer, 4 Limit erreicht (`max_turns`, Budget).
---
## 6. Ablaufplan
Jeder Meilenstein ist eigenständig lauffähig und testbar.
### M1 — Kern-Loop (Grundlage)
Umfang: `internal/llm` (Typen + Interface), OpenAI-Adapter mit SSE und
Tool-Call-Akkumulation, `internal/tool` mit `read`/`write`/`edit`/`bash`/`glob`/`grep`,
minimaler Loop, Ausgabe über simplen stdout-Renderer, Konfiguration nur über
Umgebungsvariablen.
*Fertig, wenn:* Ein mehrstufiger Task („lies X, ändere Y, führe die Tests aus") läuft
gegen einen echten Endpoint durch. `Ctrl+C` bricht mitten in einem `bash`-Aufruf sauber
ab und tötet die Prozessgruppe. Golden-File-Tests für den Adapter grün.
### M2 — Session-Baum & Persistenz
Umfang: Baum-Modell, JSONL-Store, `PathToHead`, `nub resume`, `nub sessions`,
`/branch`-Äquivalent als CLI-Flag.
*Fertig, wenn:* Eine Session lässt sich fortsetzen; ein Rewind auf einen früheren Knoten
und Weiterarbeit erzeugen einen zweiten Ast, beide bleiben ladbar.
### M3 — Config & Kontext
Umfang: Layered Config (TOML), Endpoint-/Caps-Auflösung, Context Assembler mit
`AGENTS.md`/`REPOMAP.md`/frei konfigurierten Dateien, `walk_up`, Budget-Prüfung,
`nub map`.
*Fertig, wenn:* `/context` zeigt jede geladene Datei mit Herkunft und Token-Kosten.
Budget-Überschreitung erzeugt eine sichtbare Warnung.
### M4 — Tokens & Compaction
Umfang: Schätzung + Kalibrierung, `todo`-Tool, Auto-Compaction als Summary-Node,
`/compact` manuell.
*Fertig, wenn:* Eine künstlich verlängerte Session compactet automatisch, läuft weiter,
und ein Rewind auf einen Knoten *vor* der Compaction funktioniert weiterhin.
### M5 — Skills & MCP
Umfang: Skill-Discovery + Index + `read_skill`, MCP-Client mit Allowlist, Profile,
`/skills` und `/mcp`.
*Fertig, wenn:* Ein Skill wird nachweislich erst bei Bedarf geladen (Token-Delta
messbar). Ein absichtlich kaputter MCP-Server verhindert den Start nicht.
### M6 — TUI
Umfang: Bubbletea-Oberfläche, Streaming-Rendering, Modals, Diff-Overlay, Steering
(`Enter`) und Follow-up (`Alt+Enter`), Kommandos.
*Fertig, wenn:* Die TUI ist ein reiner Consumer des Event-Channels; das Entfernen von
`internal/ui/tui` lässt den Rest kompilieren und `-p` weiterhin funktionieren.
### M7 — Politur
Prompt-Cache gegen echte `usage`-Zahlen verifizieren (Cache-Read-Tokens müssen ab dem
zweiten Turn deutlich steigen), Token-Anzeige in der Statuszeile, `/export`, README.
Distribution bleibt `go install` (Abschnitt 9).
**Reihenfolge-Begründung:** Die TUI ist der teuerste und der einzige vollständig
wegwerfbare Teil. Sie kommt zuletzt, damit die Kern-Schnittstellen vorher unter echter
Nutzung validiert werden — M1M5 sind über `-p` bereits produktiv nutzbar.
---
## 7. Teststrategie
**Golden Files für Provider.** Echte SSE-Streams aufzeichnen (inklusive der kaputten von
lokalen Servern) und als Fixtures unter `internal/llm/openai/testdata/` ablegen. Der
Adapter-Test spielt sie ab und vergleicht die erzeugte Event-Folge. Beim Hinzufügen eines
zweiten Providers läuft dieselbe Suite gegen den neuen Adapter — dort zeigt sich sofort,
wo das interne Modell leckt. **Das ist der Mechanismus, der „später erweiterbar"
tatsächlich einlöst, nicht das Interface allein.**
**Fake-Provider für den Loop.** Ein `Provider`, der skriptierte Event-Folgen abspielt.
Damit sind Multi-Turn-Verhalten, Steering, Cancellation und Fehlerpfade ohne Netzwerk
testbar.
**Tools.** In `t.TempDir()` mit einem Fixture-Repo. Besonders: `edit` mit mehrdeutigem
Treffer, `bash` mit Timeout und Kindprozessen, Pfad-Escape-Versuche (`../`, Symlinks).
**Architektur-Test.** Ein Test, der die Import-Graphen prüft: kein Paket unterhalb von
`internal/agent` darf `internal/ui` importieren. Sichert E-04 mechanisch ab.
---
## 8. Bekannte Fallen
- **Fehlende `tool_result`-Blöcke.** Jeder `tool_use` braucht ein Ergebnis, auch bei
Abbruch. Sonst lehnt die API den nächsten Request ab.
- **Cache-Busting durch Dynamik im System-Prompt.** Zeitstempel, Git-Status,
Sessionlänge — nichts davon gehört nach oben.
- **Wachsende Tool-Liste.** Siehe E-06.
- **Verwaiste Kindprozesse.** `bash` ohne Prozessgruppen-Kill hinterlässt Zombies, die den
Terminal-Zustand ruinieren.
- **Provider-SDK-Typen im Kern.** Der schleichendste Fehler; einmal drin, überall drin.
- **Zu frühe TUI.** Bindet Wochen und zementiert Annahmen über die Ausgabe.
- **Session als Slice.** Nachträgliches Branching ist ein Rewrite.
---
## 9. Festlegungen
Die zuvor offenen Punkte sind für den aktuellen Zuschnitt (persönliches Werkzeug, kleiner
Nutzerkreis) entschieden. Kein Punkt davon ist irreversibel, aber ein Agent soll sie nicht
je Session neu beantworten.
| Punkt | Entscheidung |
|---|---|
| Session-Ablage | Repo-lokal unter `.nub/sessions`. Portabel, kein globaler Index nötig |
| Auth | Datei mit `0600` unter `$XDG_CONFIG_HOME/nub`. Kein Keyring |
| Kosten-Tracking | Entfällt. Nur Token-Zahlen in der Statuszeile |
| Permissions | Container-Haltung aus E-11. Keine interaktive Bestätigung, auch nicht in M6 |
| Plattformen | Linux und macOS. Windows nur über WSL, kein PowerShell-Pfad |
| Telemetrie | Keine |
| Distribution | `go install`. Kein Goreleaser, kein Self-Update |
**Bewusst zurückgestellt** (nicht vergessen, nur nicht jetzt): Eval-Suite gegen ein
Fixture-Repo, `nub init`, System-Prompt-Hash in `NodeMeta`, Lizenz- und
Contribution-Fragen. Der erste Punkt wird relevant, sobald du merkst, dass du viel am
System-Prompt oder an Tool-Beschreibungen änderst und nicht mehr beurteilen kannst, ob es
besser wird.
---
## 10. Umsetzung mit einem Coding-Agenten
1. **Dieses Dokument als `docs/KONZEPT.md` ins Repo.** Dazu eine kurze `AGENTS.md`:
```markdown
# nub
Schlanker CLI-Coding-Agent in Go. Konzept: docs/KONZEPT.md
- Go 1.24. `iter.Seq2` wird verwendet — keine Channel-Iteratoren.
- Definition of Done: `go test ./...` und `go vet ./...` grün.
- Kein Paket unterhalb von internal/agent importiert internal/ui (E-04).
- Keine Ausgabe auf stdout außerhalb von internal/ui. Logging via slog in
eine Datei (Abschnitt 2.3).
- Neue Abhängigkeiten nur aus der Allowlist in Abschnitt 2.3. Kein Cobra,
kein Viper.
- Festlegungen aus Abschnitt 9 sind entschieden — nicht neu verhandeln.
```
2. **Ein Meilenstein pro Session.** Nicht mehr. Am Anfang jeder Session: Abschnitt 3
(Entscheidungen), 4 (Datenmodelle) und den jeweiligen Meilenstein aus Abschnitt 6
in den Kontext geben.
3. **Abschnitt 4 wörtlich übernehmen lassen.** Die Typen sind die Vertragsgrundlage
zwischen den Meilensteinen; Abweichungen dort kosten später am meisten.
4. **Golden Files zuerst.** In M1 vor dem Adapter die Fixtures aufzeichnen lassen (ein
kleines Skript, das einen echten Stream mitschneidet). Ohne sie ist jede spätere
Änderung am Adapter Blindflug.
5. **Nach jedem Meilenstein die Akzeptanzkriterien manuell prüfen**, nicht nur die Tests.
Besonders bei M1 (Abbruchverhalten) und M4 (Compaction + Rewind).
6. **Wenn eine Frage auftaucht, die Abschnitt 3 oder 9 nicht beantwortet**, selbst
entscheiden und dort ergänzen — nicht den Agenten implizit entscheiden lassen. Sonst
stehen dieselbe Frage drei Mal unterschiedlich beantwortet im Code.

321
docs/TESTING.md Normal file
View file

@ -0,0 +1,321 @@
# Testing
Für Endnutzer-Doku siehe die [README](../README.md) und
[FEATURES.md](FEATURES.md). Diese Seite ist für die Weiterentwicklung von
`nub` selbst: automatisierte Tests, manuelle Testrezepte pro Feature, und
was aktuell (noch) nicht abgedeckt ist.
## Automatisierte Tests
```bash
go build ./...
go vet ./...
go test ./...
```
Deckt ab: SSE-Fragmentierung/Tool-Call-Akkumulation (Golden File), Agent-Loop
gegen einen Fake-Provider (Multi-Turn, Tool-Roundtrip, Stream-Fehler),
Datei-Tools inkl. Pfad-Escape via `../` und Symlink, `bash`-Timeout mit
Prozessgruppen-Kill, Architektur-Test für E-04 (kein Kern-Paket importiert
`internal/ui`), Session-Baum (Resume-Roundtrip, Rewind+Branch, Dateirechte,
Versions-Fehlerfall), Config-Layering-Präzedenz, Context Assembler
(walk_up-Reihenfolge, Budget-Truncation+Warnung, Repomap-Staleness),
Auto-Compaction (Trigger, Turn-Grenzen-Logik, Rewind auf einen Knoten vor
der Compaction), Token-Kalibrierung, todo-Tool, Skill-Discovery (Index ohne
Body, Präzedenz projekt-vor-global), MCP-Bridge (echter In-Process-Server-
Roundtrip über `mcp.NewInMemoryTransports`, Allowlist, Laufzeitfehler ohne
Panic), Profile-Filterung, TUI (Slash-Command-Dispatch ohne Backend-Kontakt,
Steering- vs. Follow-up-Queue-Semantik, Tree-/Sessions-Modal-Auswahl inkl.
Branch/Wechsel/Löschen mit Rückfrage, Diff-/Fehler-Rendering, /help-
Formatierung, Skills-als-Slash-Commands, Zeilenumbruch, historische
Token-Wiederherstellung), Permission-Policy (Modus-Auflösung, deny_paths/
deny_bash überstimmen auto/ask), Agent-Loop-Permission-Integration
(deny/ask blockieren die Tool-Ausführung nachweislich, nil-Hook -> harter
Fehler), TUI-Rückfrage-Modal (blockiert bis zur Antwort, Kontextabbruch
löst sauber auf), Scaffold/`nub init` (legt an, überschreibt bestehende
Dateien nie, generierte Config parst und rundtripped korrekt, Git-Exclude
wird gesetzt), `question`-Tool (nil-Hook -> harter Fehler, leere Frage
abgelehnt, Antwort-Roundtrip inkl. Options, Ask-Fehler landet als
`IsError`), TUI-Rückfrage-Block für `question` (blockiert bis zur Antwort,
Kontextabbruch löst sauber auf, Eingabe während offener Frage wird als
Antwort statt als Chat-Nachricht/Slash-Command behandelt, kein Overlay).
**Architektur-Nachweis M6** (TUI ist ein reiner Consumer, entfernbar):
`TestArchitecture_NoUIImportBelowAgent` (`internal/agent/arch_test.go`)
verbietet jedem Kern-Paket den Import von `internal/ui/*`, also auch
`internal/ui/tui` — automatisiert bei jedem Testlauf geprüft. Zusätzlich
manuell verifiziert: `internal/ui/tui` entfernt, `go build ./internal/...`
bleibt grün, `cmd/nub` baut nach Entfernen der TUI-Verzweigung ebenfalls,
`-p` verhält sich danach identisch.
## Manuelle Testrezepte
Die Tests unten gehen von `nub -p` aus (die beschriebenen `→`/`←`-Zeilen auf
stderr sind Print-Modus-Ausgabe). Dieselben Szenarien lassen sich genauso in
der TUI (`nub`, ohne `-p`) durchspielen — dort erscheinen Tool-Aufrufe direkt
im Transkript statt auf stderr, und Ctrl+C/Rewind laufen über die im README
beschriebenen Tastenkombinationen/Kommandos.
Aus dem Konzept, Abschnitt 6:
> Ein mehrstufiger Task ("lies X, ändere Y, führe die Tests aus") läuft gegen
> einen echten Endpoint durch. `Ctrl+C` bricht mitten in einem `bash`-Aufruf
> sauber ab und tötet die Prozessgruppe.
### Test 1 — Mehrstufiger Task
In einem kleinen Testrepo (z.B. mit ein paar `.go`-Dateien und `go test`
lauffähig):
```
Lies main.go, füge einen Kommentar über der main-Funktion hinzu der "entry point" sagt, und führe danach go build ./... aus um zu prüfen dass es noch kompiliert.
```
Erwartet: Das Modell ruft nacheinander `read`, `edit` und `bash` auf (sichtbar
an den `→`/`←`-Zeilen auf stderr), am Ende eine Textantwort mit dem Ergebnis.
Danach `Ctrl+D`, dann `nub sessions` — die eben gelaufene Session sollte mit
korrekter Node-Anzahl auftauchen. `nub resume <id>` und eine weitere Nachricht
schicken sollte den Kontext (z.B. den Dateinamen) noch kennen, ohne dass du
ihn wiederholst.
### Test 1b — Rewind/Branch
Nach Test 1: die Node-ID kurz vor der letzten Antwort ermitteln (siehe
README, Abschnitt „Sessions") und damit fortsetzen:
```bash
nub resume <session-id> --branch <node-id>
Mach stattdessen etwas anderes: ...
```
Erwartet: `nub` schreibt ab hier einen zweiten Ast. `cat
.nub/sessions/<id>.jsonl | wc -l` zeigt, dass die alten Zeilen erhalten
bleiben (nur neue kommen dazu). Mit dem `--branch`-Trick lässt sich sowohl
der alte als auch der neue Ast wieder aufrufen.
### Test 1c — Auto-Compaction
Eine echte Session lang genug laufen zu lassen, um 75% des Modell-Fensters
zu füllen, dauert. Stattdessen `max_context` künstlich klein setzen, um den
Trigger nach wenigen Nachrichten auszulösen — in `.nub/config.toml`:
```toml
version = 1
[[endpoint]]
name = "openai"
base_url = "https://api.openai.com/v1"
api_key = "env:OPENAI_API_KEY"
[endpoint.caps]
max_context = 500
```
Dann `nub` starten und 4-5 kurze Nachrichten hintereinander schicken.
Erwartet: Nach ein paar Nachrichten erscheint auf stderr
`compacted N nodes into a M-token summary`. `nub` läuft danach normal
weiter (das Modell kennt den zusammengefassten Kontext noch grob).
Dann `Ctrl+D`, Node-ID der allerersten Nachricht ermitteln (siehe
README, „Sessions") und:
```bash
nub resume <session-id> --branch <node-id>
```
Erwartet: Das funktioniert weiterhin, obwohl dieser Knoten längst
wegkompaktiert wurde — die Original-Nodes bleiben im Baum erhalten.
`.nub/config.toml` danach wieder auf einen realistischen `max_context`
zurücksetzen (oder den Override entfernen).
### Test 2 — Ctrl+C mitten in bash
```
Führe "sleep 30" aus.
```
Während `→ bash {...}` läuft: `Ctrl+C` drücken. Erwartet:
- `nub` beendet sich zügig (nicht erst nach 30s)
- stderr zeigt `aborted`, Exit-Code ist `3`
- Kein Zombie-Prozess bleibt übrig — prüfen mit `ps aux | grep sleep`
direkt danach, sollte leer sein
### Test 3 — Mehrdeutiger edit-Treffer
```
Ersetze in main.go den Text "return" durch "return " (falls "return" mehrfach vorkommt)
```
Erwartet: Falls `return` mehrfach in der Datei steht, meldet das `edit`-Tool
einen Fehler mit Trefferzahl statt zu raten — sichtbar als
`← edit [error]` auf stderr, das Modell sollte daraufhin entweder
`replace_all` nutzen oder den `old_string` präzisieren.
### Test 4 — Unbekanntes Tool / Fehlerpfad
Kein manueller Test nötig, ist per Fake-Provider automatisiert abgedeckt
(`go test ./...`).
### Test 5 — Kaputter MCP-Server verhindert den Start nicht
```toml
version = 1
[[mcp]]
name = "broken"
command = "this-binary-does-not-exist"
```
```bash
nub mcp
```
Erwartet: `warning: mcp server "broken": ...` auf stderr, Exit-Code 0, kein
Absturz. Danach echtes `nub` starten (mit derselben Config) — muss trotz des
kaputten Servers normal hochkommen.
### Test 6 — Skill wird erst bei Bedarf geladen
Ein Skill wie im README (Abschnitt „Skills") anlegen, dann im laufenden
`nub` fragen: `Welche Skills hast du verfügbar, und was steht in
"refactoring"?` — erwartet: Das Modell nennt den Skill aus dem Index, ruft
danach sichtbar `→ read_skill {"name":"refactoring"}` auf, um den Body zu
laden — nicht schon beim Start.
### Test 7 — Steering vs. Follow-up, Rewind über die TUI
Nur in der TUI (`nub`, ohne `-p`):
1. Einen Task starten, der mehrere Tool-Aufrufe braucht (wie Test 1).
Während `running: ...` in der Statuszeile steht, eine neue Nachricht
eingeben und **Enter** drücken (Steering). Erwartet: Sie wird eingeschleust,
sobald der laufende Tool-Aufruf fertig ist, ohne dass du warten musst.
2. Denselben Versuch mit **Alt+Enter**. Erwartet: Statuszeile zeigt
`queued: 1`, die Nachricht erscheint im Transkript mit `⏳`-Präfix und
wird erst gesendet, wenn der Turn ganz fertig ist (kein `tool_use` mehr
offen).
3. `/tree` (oder `/branch`) öffnen — Liste aller Nodes erscheint, aktueller
Head markiert. Mit ↑/↓ einen früheren Knoten wählen, Enter drücken.
Erwartet: Head springt zurück, eine neue Nachricht hängt sich dort an
(zweiter Ast, wie in Test 1b, nur ohne CLI-Flag).
4. `/export` ausführen, geschriebene Datei öffnen — Transkript sollte
vollständig als Markdown lesbar sein, inklusive Diffs.
5. `/help` ausführen — jedes Kommando sollte in einer eigenen Zeile mit
kurzer Beschreibung erscheinen, nicht als ein langer Fließtext.
### Test 8 — Prompt-Cache-Verifikation (M7)
Nur mit echtem OpenAI-Endpoint sinnvoll (lokale Server cachen meist nicht).
OpenAIs automatisches Prompt-Caching greift erst ab **>1024 Tokens** stabilem
Prefix — ein `AGENTS.md`/`REPOMAP.md` im Kontext hilft, diese Schwelle
zuverlässig zu überschreiten.
1. In einem Repo mit ein paar hundert Zeilen Kontext (`nub map` vorher
laufen lassen, damit `REPOMAP.md` existiert) `nub -p` starten.
2. Zwei, drei kurze Nachrichten nacheinander schicken (z.B. "Was macht
go.mod?", dann "Und main.go?").
3. Auf stderr nach jedem Turn erscheint jetzt
`usage: input=X output=Y cache_read=Z`.
Erwartet: Beim **ersten** Turn ist `cache_read` meist `0` (nichts zum
Cachen vorhanden). Ab dem **zweiten** Turn sollte `cache_read` deutlich
über 0 liegen und nah an `input` herankommen, weil System-Prompt + bisherige
Historie als stabiler Prefix erneut gesendet werden. In der TUI ist dasselbe
in der Statuszeile sichtbar: `tokens: N (cached: M)`.
Falls `cache_read` dauerhaft bei 0 bleibt, obwohl der Kontext >1024 Tokens
hat: prüfen, ob `Caps.UsageInStream` für den aktiven Endpoint tatsächlich
`true` ist (`nub context` bzw. die Modell-Registry in
`internal/llm/registry`) — ohne `stream_options.include_usage` liefert
OpenAI gar keine Usage-Daten, dann bleibt der Wert immer 0, unabhängig
davon ob gecacht wurde.
### Test 9 — Permissions: ask, deny, deny_paths
In `.nub/config.toml`:
```toml
[permissions]
edit = "ask"
bash = "deny"
deny_paths = ["**/secrets.txt"]
```
1. **TUI**, eine Nachricht schicken, die `edit` auf einer normalen Datei
auslöst. Erwartet: ein umrandeter `⚠ Rückfrage: edit`-Block erscheint
im Transkript (kein Vollbild — Nachrichtenliste, Eingabefeld und
Statuszeile bleiben sichtbar), Statuszeile zeigt zusätzlich `⚠
permission required`. Mit `n`/Esc ablehnen — Modell bekommt einen
Fehler-`tool_result` und macht sinnvoll weiter (fragt nach, versucht
etwas anderes). Erneut versuchen, mit `y` bestätigen — Edit läuft
normal.
2. Eine Nachricht schicken, die `bash` auslösen würde. Erwartet: sofortiger
Fehler `permission denied: bash is not allowed by policy`, kein Modal
(deny fragt nie).
3. Eine Datei `secrets.txt` anlegen, Modell bitten sie zu lesen/editieren.
Erwartet: Fehler durch `deny_paths`, unabhängig davon dass `read`/`edit`
sonst auf `auto`/`ask` stehen — Deny-Pfade überstimmen immer.
4. Dasselbe **im Print-Modus** (`nub -p`) mit `edit = "ask"`: Erwartet
sofortiger Fehler `requires confirmation ('ask'), which this mode does
not support` statt eines hängenden Prozesses — `ask` ist dort laut
Konzept (E-11) ein harter Fehler, keine Rückfrage über stdin.
### Test 10 — nub init / /init
In einem leeren Testverzeichnis (kein `.nub/`, kein `AGENTS.md`):
```bash
nub init
```
Erwartet: `.nub/config.toml` (mit `[permissions]`-Beispiel) und `AGENTS.md`
werden angelegt, beide Pfade auf stdout gemeldet. `.git/info/exclude`
enthält danach `.nub/` (git-Repo vorausgesetzt). Erneut `nub init`
ausführen — erwartet: beide Dateien werden als `skipped (already exists)`
gemeldet, Inhalt bleibt unverändert (vorher manuell etwas in `AGENTS.md`
ändern und danach verifizieren, dass es erhalten bleibt).
Dasselbe in der TUI mit `/init` — meldet dieselben zwei Pfade als
Command-Output im Transkript statt auf stdout.
### Test 11 — question-Tool
1. **TUI**: Modell bitten, vor einer mehrdeutigen Aktion nachzufragen (z.B.
"frag mich per question-Tool, welches Ausgabeformat ich will, mit den
Optionen json und yaml"). Erwartet: ein umrandeter `❓ Rückfrage:`-Block
erscheint im Transkript (kein Vollbild), Statuszeile zeigt `❓ question
pending`, Eingabefeld-Placeholder wechselt auf den Antwort-Hinweis. Text
eintippen und Enter drücken — landet **nicht** als neue Chat-Nachricht
oder Slash-Kommando, sondern als Antwort des Tools; Transkript zeigt
danach `Frage: … / Antwort: …`, Placeholder wechselt zurück, Modell
macht mit der Antwort sinnvoll weiter.
2. **Print-Modus** (`nub -p`): Modell dasselbe fragen lassen. Erwartet
sofortiger Fehler ("interactive questions are not supported in this
mode…") statt eines hängenden Prozesses — analog zu `ask`-Permissions
ist auch das `question`-Tool im Print-Modus bewusst kein Rückfrage-
Mechanismus über stdin, da Print headless laufen soll (E-11).
3. Skill mit einer Anweisung testen, die das Modell explizit zum
`question`-Tool greifen lässt (z.B. "frage vor dem Löschen von Dateien
immer erst per question-Tool nach") — Verhalten identisch zu 1., da das
Tool unabhängig davon nutzbar ist, ob Modell oder Skill-Anweisung den
Aufruf veranlasst.
## Bekannte Grenzen (Entwicklungsstand)
- Bilder werden nicht gemappt, auch wenn der Endpoint sie unterstützt
- Permissions/`deny_paths`/`deny_bash` gelten nur für die sechs
eingebauten Tools (`read`/`glob`/`grep`/`write`/`edit`/`bash`) — MCP-
Tools, `todo` und `read_skill` laufen immer als `auto` und lassen sich
aktuell nicht einzeln einschränken
- Compaction nutzt immer dasselbe Modell für die Zusammenfassung — keine
Möglichkeit, dafür ein günstigeres Modell zu konfigurieren
- Token-Kalibrierung ist rein In-Process — kein persistenter Zustand über
Sessions hinweg
- MCP: kein Reconnect zur Laufzeit (E-06 — würde die Tool-Liste ändern und
den Prompt-Cache brechen), kein OAuth-Login-Flow getestet, MCP-Prompts/
-Resources werden nicht angebunden, nur Tools
- Skills: kein `nub skill init` o.ä., Skills müssen von Hand angelegt werden
- TUI: kein Model-Auswahl-Picker (`/model <name>` setzt nur direkt, kein
Modal mit Liste), `/tree` zeigt Nodes flach sortiert nach Erstellzeit statt
als echten Baum mit Einrückung, `bash`-Streaming-Output läuft nur in die
Statuszeile statt live ins Transkript (erst das fertige Ergebnis landet
dort), Bildschirm-Resize während eines Modals kann die Breite nicht neu
berechnen bis zum nächsten Tastendruck

57
go.mod Normal file
View file

@ -0,0 +1,57 @@
module nub
go 1.24.2
require (
github.com/BurntSushi/toml v1.6.0
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/mattn/go-isatty v0.0.20
github.com/oklog/ulid/v2 v2.1.2
)
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
)
require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)

116
go.sum Normal file
View file

@ -0,0 +1,116 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/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/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
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/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=

View file

@ -0,0 +1,39 @@
package agent
import (
"os/exec"
"strings"
"testing"
)
// TestArchitecture_NoUIImportBelowAgent sichert E-04 mechanisch ab: Jede
// Ausgabe läuft über einen Event-Channel, kein Kern-Paket importiert
// internal/ui.
func TestArchitecture_NoUIImportBelowAgent(t *testing.T) {
pkgs := []string{
"nub/internal/agent",
"nub/internal/llm/...",
"nub/internal/tool/...",
"nub/internal/config",
"nub/internal/session/...",
"nub/internal/ctxasm/...",
"nub/internal/mcpc/...",
"nub/internal/skill/...",
"nub/internal/tokens/...",
}
for _, pkg := range pkgs {
out, err := exec.Command("go", "list", "-deps", "-f", "{{.ImportPath}}", pkg).CombinedOutput()
if err != nil {
// Pakete wie internal/session existieren in M1 noch nicht -> überspringen.
if strings.Contains(string(out), "matched no packages") {
continue
}
t.Fatalf("go list %s: %v\n%s", pkg, err, out)
}
for _, dep := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if dep == "nub/internal/ui" || strings.HasPrefix(dep, "nub/internal/ui/") {
t.Errorf("%s transitively imports %s, which violates E-04", pkg, dep)
}
}
}
}

View file

@ -0,0 +1,77 @@
package agent
import (
"context"
"encoding/json"
"testing"
"nub/internal/llm"
"nub/internal/tool"
)
// askRecordingTool prüft, dass env.Ask beim Tool ankommt und tatsächlich
// l.AskUser ist, nicht irgendein Default.
type askRecordingTool struct{}
func (askRecordingTool) Name() string { return "echo" }
func (askRecordingTool) Description() string { return "echoes input" }
func (askRecordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (askRecordingTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
if env.Ask == nil {
return tool.Result{ForModel: "no ask hook", IsError: true}, nil
}
answer, err := env.Ask(ctx, "which format?", []string{"json", "yaml"})
if err != nil {
return tool.Result{ForModel: err.Error(), IsError: true}, nil
}
return tool.Result{ForModel: "got: " + answer}, nil
}
func TestRunTools_EnvAskIsWiredToLoopAskUser(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "echo", `{}`),
textEvents("done"),
}}
loop, reg := newTestLoop(provider)
reg.Register(askRecordingTool{})
var gotQuestion string
loop.AskUser = func(ctx context.Context, question string, options []string) (string, error) {
gotQuestion = question
return "json please", nil
}
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if out.Result.IsError {
t.Fatalf("unexpected error: %s", out.Result.ForModel)
}
if out.Result.ForModel != "got: json please" {
t.Errorf("ForModel = %q", out.Result.ForModel)
}
if gotQuestion != "which format?" {
t.Errorf("question passed through = %q", gotQuestion)
}
}
func TestRunTools_EnvAskNilWithoutLoopAskUser(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "echo", `{}`),
textEvents("done"),
}}
loop, reg := newTestLoop(provider)
reg.Register(askRecordingTool{})
// loop.AskUser bleibt nil (Print-Modus-Fall).
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if !out.Result.IsError || out.Result.ForModel != "no ask hook" {
t.Errorf("expected the tool to observe env.Ask == nil, got: %+v", out.Result)
}
}

187
internal/agent/compact.go Normal file
View file

@ -0,0 +1,187 @@
package agent
import (
"context"
"fmt"
"strings"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tokens"
"nub/internal/tool"
)
const compactionSystemPrompt = `Du fasst einen Ausschnitt einer Coding-Agent-Session zusammen, der aus dem
aktiven Kontext entfernt wird, damit die Konversation fortgesetzt werden kann.
Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene
Entscheidungen, offene Aufgaben, wiederherstellbarer Zustand. Keine Prosa
über den Gesprächsverlauf. Sei präzise und knapp.`
// Compact stößt eine einmalige Compaction unabhängig vom Schwellwert an
// (CLI-Äquivalent zum /compact-TUI-Kommando, 5.8).
func (l *Loop) Compact(ctx context.Context, out chan<- tool.UIEvent) error {
return l.maybeCompact(ctx, out, true)
}
// maybeCompact prüft, ob der aktuelle Pfad das Token-Budget überschreitet
// (5.7), und komprimiert bei Bedarf die ältere Hälfte in einen Summary-Node.
// force=true umgeht die Schwellwertprüfung (manuelles `nub compact`).
func (l *Loop) maybeCompact(ctx context.Context, out chan<- tool.UIEvent, force bool) error {
if l.Session == nil {
return nil
}
nodes := l.Session.PathNodes()
if !force {
maxContext := l.Provider.Caps().MaxContext
if maxContext <= 0 {
return nil
}
estimate := tokens.EstimateMessages(l.System, nodesToMessages(nodes))
if l.Tokens != nil {
estimate = l.Tokens.Adjust(estimate)
}
if float64(estimate) < l.compactAt()*float64(maxContext) {
return nil
}
}
cut := compactionCutIndex(nodes, l.keepTurns())
if cut <= 0 {
return nil // Session ist bereits kürzer als keepTurns -> nichts zu tun
}
older, tail := nodes[:cut], nodes[cut:]
summaryText, err := l.generateSummary(ctx, older)
if err != nil {
return err
}
parentID := older[0].ParentID
replaces := make([]string, len(older))
for i, n := range older {
replaces[i] = n.ID
}
summaryNode := session.NewSummaryNode(parentID, summaryText, replaces, session.NodeMeta{Model: l.Model})
if err := l.Session.Append(summaryNode); err != nil {
return err
}
// Roher Tail wird als neue Nodes an den Summary-Node umgehängt. Die
// Original-Nodes bleiben im Baum — nicht destruktiv, Rewind auf sie
// funktioniert weiterhin (E-08).
parent := summaryNode.ID
for _, n := range tail {
next := session.NewNode(parent, n.Message, n.Meta)
if err := l.Session.Append(next); err != nil {
return err
}
parent = next.ID
}
out <- tool.CompactionEvent{ReplacedNodes: len(older), SummaryTokens: tokens.Estimate(summaryText)}
return nil
}
func nodesToMessages(nodes []*session.Node) []llm.Message {
out := make([]llm.Message, len(nodes))
for i, n := range nodes {
out[i] = n.Message
}
return out
}
// compactionCutIndex findet den Index, ab dem die letzten keepTurns Turns
// beginnen (roh belassen). Eine Turn-Grenze ist eine User-Message mit
// echtem Text — reine Tool-Result-Bündel (auch Role User) zählen nicht,
// sonst würde fast jeder Schritt als eigener Turn gezählt.
func compactionCutIndex(nodes []*session.Node, keepTurns int) int {
boundaries := 0
for i := len(nodes) - 1; i >= 0; i-- {
if isUserTurnBoundary(nodes[i]) {
boundaries++
if boundaries == keepTurns {
return i
}
}
}
return 0
}
func isUserTurnBoundary(n *session.Node) bool {
if n.Message.Role != llm.RoleUser {
return false
}
for _, b := range n.Message.Content {
if b.Kind == llm.KindText {
return true
}
}
return false
}
// generateSummary fragt das Modell separat nach einer Zusammenfassung
// (5.7). Der Ausschnitt wird als reiner Text-Transkript gerendert statt als
// strukturierter Request — sonst würden tool_result-Blöcke auf tool_use-IDs
// verweisen, die außerhalb des Ausschnitts liegen, was manche Provider
// ablehnen.
func (l *Loop) generateSummary(ctx context.Context, nodes []*session.Node) (string, error) {
transcript := renderTranscript(nodes)
req := llm.Request{
Model: l.Model,
System: []llm.Block{{Kind: llm.KindText, Text: compactionSystemPrompt}},
Messages: []llm.Message{{
Role: llm.RoleUser,
Content: []llm.Block{{Kind: llm.KindText, Text: transcript}},
}},
}
events, err := l.Provider.Stream(ctx, req)
if err != nil {
return "", err
}
var text strings.Builder
var streamErr error
events(func(ev llm.Event, err error) bool {
if err != nil {
streamErr = err
return false
}
if d, ok := ev.(llm.BlockDelta); ok {
text.WriteString(d.Text)
}
return true
})
if streamErr != nil {
return "", streamErr
}
if text.Len() == 0 {
return "", fmt.Errorf("empty summary returned")
}
return text.String(), nil
}
func renderTranscript(nodes []*session.Node) string {
var b strings.Builder
for _, n := range nodes {
role := string(n.Message.Role)
for _, blk := range n.Message.Content {
switch blk.Kind {
case llm.KindText, llm.KindThinking:
fmt.Fprintf(&b, "[%s]\n%s\n\n", role, blk.Text)
case llm.KindToolUse:
fmt.Fprintf(&b, "[%s] tool_call %s(%s)\n\n", role, blk.Name, string(blk.Input))
case llm.KindToolResult:
for _, r := range blk.Result {
fmt.Fprintf(&b, "[tool_result]\n%s\n\n", r.Text)
}
}
}
}
return b.String()
}

View file

@ -0,0 +1,162 @@
package agent
import (
"context"
"iter"
"strings"
"testing"
"time"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
func TestCompactionCutIndex_KeepsLastNTurnsRaw(t *testing.T) {
nodes := []*session.Node{
{ID: "u1", Message: textMsgNode(llm.RoleUser, "u1")},
{ID: "a1", Message: textMsgNode(llm.RoleAssistant, "a1")},
{ID: "u2", Message: textMsgNode(llm.RoleUser, "u2")},
{ID: "a2", Message: textMsgNode(llm.RoleAssistant, "a2")},
{ID: "u3", Message: textMsgNode(llm.RoleUser, "u3")},
}
if cut := compactionCutIndex(nodes, 2); cut != 2 {
t.Errorf("cut = %d, want 2 (start of u2)", cut)
}
if cut := compactionCutIndex(nodes, 10); cut != 0 {
t.Errorf("cut = %d, want 0 when keepTurns exceeds available turns", cut)
}
}
func TestIsUserTurnBoundary_IgnoresToolResultOnlyMessages(t *testing.T) {
toolResultMsg := llm.Message{
Role: llm.RoleUser,
Content: []llm.Block{
{Kind: llm.KindToolResult, ToolUseID: "x", Result: []llm.Block{{Kind: llm.KindText, Text: "ok"}}},
},
}
if isUserTurnBoundary(&session.Node{Message: toolResultMsg}) {
t.Error("tool-result-only message should not count as a turn boundary")
}
realUserMsg := textMsgNode(llm.RoleUser, "hello")
if !isUserTurnBoundary(&session.Node{Message: realUserMsg}) {
t.Error("a real user text message should count as a turn boundary")
}
}
func textMsgNode(role llm.Role, text string) llm.Message {
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
}
// scriptedProvider unterscheidet Compaction-Zusammenfassungs-Requests
// (erkennbar am System-Prompt) von normalen Turn-Requests, damit die
// Aufrufreihenfolge im Test nicht von der genauen Compaction-Logik abhängt.
type scriptedProvider struct {
caps llm.Caps
batches [][]llm.Event
idx int
}
func (p *scriptedProvider) Name() string { return "scripted" }
func (p *scriptedProvider) Caps() llm.Caps { return p.caps }
func (p *scriptedProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
if len(req.System) > 0 && strings.Contains(req.System[0].Text, "Zusammenfassung") {
return func(yield func(llm.Event, error) bool) {
yield(llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindText}}, nil)
yield(llm.BlockDelta{Index: 0, Text: "SUMMARY: files touched, decisions made, todo remains"}, nil)
yield(llm.BlockStop{Index: 0}, nil)
yield(llm.Done{Stop: llm.StopEnd}, nil)
}, nil
}
batch := p.batches[p.idx]
p.idx++
return func(yield func(llm.Event, error) bool) {
for _, ev := range batch {
if !yield(ev, nil) {
return
}
}
}, nil
}
// TestCompaction_AutoTriggersAndPreservesRewind ist das M4-Fertig-Kriterium
// aus Abschnitt 6: eine künstlich verlängerte Session compactet automatisch,
// läuft weiter, und ein Rewind auf einen Knoten *vor* der Compaction
// funktioniert weiterhin.
func TestCompaction_AutoTriggersAndPreservesRewind(t *testing.T) {
provider := &scriptedProvider{
caps: llm.Caps{MaxContext: 10}, // winzig -> Budget nach wenigen Turns überschritten
batches: [][]llm.Event{
textEvents("resp-1"),
textEvents("resp-2"),
textEvents("resp-3"),
},
}
sess := session.New("test-compaction")
loop := &Loop{
Provider: provider,
Tools: tool.NewRegistry(),
Model: "test-model",
Env: tool.Env{Cwd: ".", RepoRoot: "."},
Session: sess,
KeepTurns: 2,
CompactAt: 0.75,
}
in := make(chan Input)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
done := make(chan struct{})
go func() {
defer close(done)
for range out {
}
}()
for _, text := range []string{"user one", "user two", "user three"} {
select {
case in <- Input{Text: text}:
case <-ctx.Done():
t.Fatal("timed out sending input")
}
}
close(in)
<-done
var summary *session.Node
var rootID string
for id, n := range sess.Nodes {
if n.Kind == session.NodeSummary {
summary = n
}
if n.ParentID == "" && n.Kind == session.NodeMessage {
rootID = id
}
}
if summary == nil {
t.Fatal("expected a summary node after auto-compaction")
}
if rootID == "" {
t.Fatal("could not find original root node")
}
path := sess.PathToHead()
if len(path) == 0 || path[0].Content[0].Text != summary.Message.Content[0].Text {
t.Errorf("expected current path to start at the summary, got: %+v", path)
}
// Rewind auf den allerersten (nie kompaktierten) Node muss weiterhin
// funktionieren.
if err := sess.Branch(rootID); err != nil {
t.Fatal(err)
}
rewound := sess.PathToHead()
if len(rewound) != 1 || rewound[0].Content[0].Text != "user one" {
t.Errorf("rewind to pre-compaction root failed: %+v", rewound)
}
}

86
internal/agent/consume.go Normal file
View file

@ -0,0 +1,86 @@
package agent
import (
"encoding/json"
"iter"
"strings"
"nub/internal/llm"
"nub/internal/tool"
)
type blockAcc struct {
kind llm.BlockKind
id string
name string
text strings.Builder
args strings.Builder
}
// consume liest den Event-Stream eines Turns, emittiert Deltas als UIEvents
// und akkumuliert parallel die vollständigen Blöcke der Assistant-Message
// (Ablauf pro Turn, Schritt 2).
func (l *Loop) consume(events iter.Seq2[llm.Event, error], out chan<- tool.UIEvent) (llm.Message, llm.StopReason, llm.Usage, error) {
blocks := map[int]*blockAcc{}
var order []int
stop := llm.StopEnd
var usage llm.Usage
var streamErr error
events(func(ev llm.Event, err error) bool {
if err != nil {
streamErr = err
return false
}
switch e := ev.(type) {
case llm.BlockStart:
b := &blockAcc{kind: e.Block.Kind, id: e.Block.ID, name: e.Block.Name}
blocks[e.Index] = b
order = append(order, e.Index)
if e.Block.Kind == llm.KindToolUse {
out <- tool.ToolCallStart{ID: e.Block.ID, Name: e.Block.Name}
}
case llm.BlockDelta:
b := blocks[e.Index]
if b == nil {
return true
}
switch b.kind {
case llm.KindText:
b.text.WriteString(e.Text)
out <- tool.TextDelta{Text: e.Text}
case llm.KindThinking:
b.text.WriteString(e.Text)
out <- tool.ThinkingDelta{Text: e.Text}
case llm.KindToolUse:
b.args.WriteString(e.PartialJSON)
}
case llm.Done:
stop = e.Stop
usage = e.Usage
}
return true
})
if streamErr != nil {
return llm.Message{}, "", llm.Usage{}, streamErr
}
msg := llm.Message{Role: llm.RoleAssistant}
for _, idx := range order {
b := blocks[idx]
switch b.kind {
case llm.KindText, llm.KindThinking:
if b.text.Len() > 0 {
msg.Content = append(msg.Content, llm.Block{Kind: b.kind, Text: b.text.String()})
}
case llm.KindToolUse:
input := json.RawMessage(b.args.String())
if len(input) == 0 {
input = json.RawMessage("{}")
}
msg.Content = append(msg.Content, llm.Block{Kind: llm.KindToolUse, ID: b.id, Name: b.name, Input: input})
}
}
return msg, stop, usage, nil
}

234
internal/agent/loop.go Normal file
View file

@ -0,0 +1,234 @@
// Package agent enthält den Turn-Loop: Provider streamen, Tools ausführen,
// Historie fortschreiben, bis StopEnd. Kein Paket unterhalb von internal/agent
// importiert internal/ui (E-04) — jede Ausgabe läuft über tool.UIEvent.
package agent
import (
"context"
"encoding/json"
"fmt"
"time"
"nub/internal/llm"
"nub/internal/permission"
"nub/internal/session"
"nub/internal/tokens"
"nub/internal/tool"
)
// Input ist eine Nutzer- oder Steering-Nachricht in den Loop hinein.
type Input struct {
Text string
}
// Sessioner ist das schmale Interface, das der Loop von der Historie
// braucht. *session.Session (reines In-Memory, für Tests) und
// *session.Store (mit JSONL-Persistenz) erfüllen es beide.
type Sessioner interface {
HeadID() string
PathToHead() []llm.Message
PathNodes() []*session.Node
Append(n *session.Node) error
}
type Loop struct {
Provider llm.Provider
Tools *tool.Registry
System []llm.Block
Env tool.Env
Model string
Session Sessioner
Tokens *tokens.Calibrator // optional; nil = keine Kalibrierung
// Permissions steuert pro Tool auto/ask/deny (E-11). nil = alles auto.
Permissions *permission.Policy
// RequestPermission wird für Tools mit Modus "ask" aufgerufen und muss
// true (erlauben) oder false (ablehnen) liefern. nil = "ask" ist in
// diesem Modus nicht unterstützt und wird als harter Fehler behandelt
// (deckt E-11 "ask ist im Print-Modus ein harter Fehler" automatisch ab,
// ohne dass der Loop wissen muss, in welchem UI-Modus er läuft).
RequestPermission func(ctx context.Context, toolName string, input json.RawMessage) bool
// AskUser beantwortet das question-Tool: das Modell entscheidet selbst,
// dass es etwas klären will (anders als RequestPermission, das
// system-/config-entschieden ist). nil (z.B. Print-Modus) lässt das
// question-Tool von sich aus einen klaren Fehler statt einer Blockade
// liefern — kein Sonderfall hier im Loop nötig.
AskUser func(ctx context.Context, question string, options []string) (string, error)
MaxTurns int // Default 50
MaxToolTime time.Duration // Default 120s
CompactAt float64 // Anteil von Caps.MaxContext, Default 0.75
KeepTurns int // Default 4
}
func (l *Loop) maxTurns() int {
if l.MaxTurns > 0 {
return l.MaxTurns
}
return 50
}
func (l *Loop) maxToolTime() time.Duration {
if l.MaxToolTime > 0 {
return l.MaxToolTime
}
return 120 * time.Second
}
func (l *Loop) compactAt() float64 {
if l.CompactAt > 0 {
return l.CompactAt
}
return 0.75
}
func (l *Loop) keepTurns() int {
if l.KeepTurns > 0 {
return l.KeepTurns
}
return 4
}
// Run liest Input, bis der Channel schließt oder der Kontext endet, und
// gibt jede Ausgabe über den zurückgegebenen UIEvent-Channel aus (E-04).
func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan tool.UIEvent {
out := make(chan tool.UIEvent)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
case msg, ok := <-in:
if !ok {
return
}
if err := l.appendMessage(userMessage(msg.Text), llm.Usage{}); err != nil {
out <- tool.ErrorEvent{Err: err}
continue
}
l.runTurn(ctx, out, in)
}
}
}()
return out
}
func (l *Loop) appendMessage(msg llm.Message, usage llm.Usage) error {
node := session.NewNode(l.Session.HeadID(), msg, session.NodeMeta{Model: l.Model, Usage: usage})
return l.Session.Append(node)
}
// runTurn treibt die Turn-Schleife (Ablauf pro Turn, 5.2) bis StopEnd, ein
// Sicherheitslimit greift, oder der Kontext abbricht. Ein abgebrochener Turn
// hinterlässt einen gültigen Baumzustand: entweder bekommen alle tool_use-
// Blöcke ein Ergebnis, oder der unvollständige Assistant-Node wird nicht
// committet (siehe consume/runTools). Die Historie selbst lebt im Session-
// Baum (PathToHead), nicht mehr in einem lokalen Slice.
func (l *Loop) runTurn(ctx context.Context, out chan<- tool.UIEvent, in <-chan Input) {
var lastSignatures []string
for turnN := 0; turnN < l.maxTurns(); turnN++ {
if err := l.maybeCompact(ctx, out, false); err != nil {
out <- tool.ErrorEvent{Err: fmt.Errorf("compaction: %w", err)}
// Weiterlaufen mit unkomprimiertem Kontext ist besser als der Turn
// abzubrechen — Compaction ist eine Optimierung, kein Muss.
}
messages := l.Session.PathToHead()
req := llm.Request{
Model: l.Model,
System: l.System,
Messages: messages,
Tools: l.toolDefs(),
}
promptEstimate := tokens.EstimateMessages(l.System, messages)
events, err := l.Provider.Stream(ctx, req)
if err != nil {
out <- tool.ErrorEvent{Err: err}
return
}
assistantMsg, stop, usage, streamErr := l.consume(events, out)
if streamErr != nil {
out <- tool.ErrorEvent{Err: streamErr}
return // kein unvollständiger Assistant-Node committet
}
if len(assistantMsg.Content) == 0 {
return
}
if l.Tokens != nil {
l.Tokens.Observe(promptEstimate, usage.InputTokens)
}
if err := l.appendMessage(assistantMsg, usage); err != nil {
out <- tool.ErrorEvent{Err: err}
return
}
out <- tool.TurnDone{Stop: stop, Usage: usage}
if stop != llm.StopToolUse {
return
}
toolUses := extractToolUse(assistantMsg)
if len(toolUses) == 0 {
return
}
sig := turnSignature(toolUses)
lastSignatures = append(lastSignatures, sig)
if repeatedThrice(lastSignatures) {
cancelled := resultsMessage(cancelledResults(toolUses, "repeated identical tool call 3x in a row, aborting"))
if err := l.appendMessage(cancelled, llm.Usage{}); err != nil {
out <- tool.ErrorEvent{Err: err}
return
}
out <- tool.ErrorEvent{Err: fmt.Errorf("repeated identical tool call detected, turn aborted")}
return
}
results := l.runTools(ctx, toolUses, out)
if err := l.appendMessage(resultsMessage(results), llm.Usage{}); err != nil {
out <- tool.ErrorEvent{Err: err}
return
}
// Steering: nach Abschluss des aktuellen Tool-Batches nicht-blockierend
// prüfen und ggf. als zusätzliche User-Message einschleusen.
select {
case steer, ok := <-in:
if ok {
if err := l.appendMessage(userMessage(steer.Text), llm.Usage{}); err != nil {
out <- tool.ErrorEvent{Err: err}
return
}
}
default:
}
}
out <- tool.ErrorEvent{Err: fmt.Errorf("max_turns (%d) exceeded", l.maxTurns())}
}
func (l *Loop) toolDefs() []llm.ToolDef {
defs := l.Tools.Defs()
out := make([]llm.ToolDef, len(defs))
for i, d := range defs {
out[i] = llm.ToolDef{Name: d.Name, Description: d.Description, Schema: d.Schema}
}
return out
}
func userMessage(text string) llm.Message {
return llm.Message{Role: llm.RoleUser, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
}
func repeatedThrice(sigs []string) bool {
n := len(sigs)
if n < 3 {
return false
}
return sigs[n-1] == sigs[n-2] && sigs[n-2] == sigs[n-3]
}

232
internal/agent/loop_test.go Normal file
View file

@ -0,0 +1,232 @@
package agent
import (
"context"
"encoding/json"
"iter"
"testing"
"time"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
// fakeProvider spielt eine vordefinierte Folge von Event-Batches ab, eine
// pro Stream()-Aufruf. Damit lässt sich Multi-Turn-Verhalten ohne Netzwerk
// testen (Teststrategie, Abschnitt 7).
type fakeProvider struct {
batches [][]llm.Event
call int
caps llm.Caps
}
func (p *fakeProvider) Name() string { return "fake" }
func (p *fakeProvider) Caps() llm.Caps { return p.caps }
func (p *fakeProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
if p.call >= len(p.batches) {
return func(yield func(llm.Event, error) bool) {
yield(llm.Done{Stop: llm.StopEnd}, nil)
}, nil
}
batch := p.batches[p.call]
p.call++
return func(yield func(llm.Event, error) bool) {
for _, ev := range batch {
if !yield(ev, nil) {
return
}
}
}, nil
}
type echoTool struct{}
func (echoTool) Name() string { return "echo" }
func (echoTool) Description() string { return "echoes input" }
func (echoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (echoTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
return tool.Result{ForModel: "ok:" + string(input)}, nil
}
func textEvents(s string) []llm.Event {
return []llm.Event{
llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindText}},
llm.BlockDelta{Index: 0, Text: s},
llm.BlockStop{Index: 0},
llm.Done{Stop: llm.StopEnd, Usage: llm.Usage{OutputTokens: 1}},
}
}
func toolCallEvents(id, name, args string) []llm.Event {
return []llm.Event{
llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindToolUse, ID: id, Name: name}},
llm.BlockDelta{Index: 0, PartialJSON: args},
llm.BlockStop{Index: 0},
llm.Done{Stop: llm.StopToolUse, Usage: llm.Usage{OutputTokens: 1}},
}
}
func newTestLoop(provider llm.Provider) (*Loop, *tool.Registry) {
reg := tool.NewRegistry()
reg.Register(echoTool{})
return &Loop{
Provider: provider,
Tools: reg,
Model: "test-model",
Env: tool.Env{Cwd: ".", RepoRoot: "."},
Session: session.New("test-session"),
}, reg
}
func drain(t *testing.T, out <-chan tool.UIEvent, timeout time.Duration) []tool.UIEvent {
t.Helper()
var events []tool.UIEvent
deadline := time.After(timeout)
for {
select {
case ev, ok := <-out:
if !ok {
return events
}
events = append(events, ev)
case <-deadline:
t.Fatal("timed out waiting for events")
return nil
}
}
}
func TestLoop_SimpleTextTurn(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{textEvents("hello")}}
loop, _ := newTestLoop(provider)
in := make(chan Input, 1)
in <- Input{Text: "hi"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
var gotText string
var gotDone bool
for _, ev := range events {
switch e := ev.(type) {
case tool.TextDelta:
gotText += e.Text
case tool.TurnDone:
gotDone = true
if e.Stop != llm.StopEnd {
t.Errorf("stop = %q, want end_turn", e.Stop)
}
}
}
if gotText != "hello" {
t.Errorf("text = %q", gotText)
}
if !gotDone {
t.Error("expected TurnDone event")
}
}
func TestLoop_ToolCallRoundTrip(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "echo", `{"x":1}`),
textEvents("done"),
}}
loop, _ := newTestLoop(provider)
in := make(chan Input, 1)
in <- Input{Text: "run echo"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
var gotOutput *tool.ToolCallOutput
for _, ev := range events {
if o, ok := ev.(tool.ToolCallOutput); ok {
cp := o
gotOutput = &cp
}
}
if gotOutput == nil {
t.Fatal("expected a ToolCallOutput event")
}
if gotOutput.Result.ForModel != `ok:{"x":1}` {
t.Errorf("tool result = %q", gotOutput.Result.ForModel)
}
if provider.call != 2 {
t.Errorf("expected 2 provider calls (tool_use + follow-up), got %d", provider.call)
}
}
func TestLoop_UnknownToolProducesErrorResult(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "does_not_exist", `{}`),
textEvents("done"),
}}
loop, _ := newTestLoop(provider)
in := make(chan Input, 1)
in <- Input{Text: "go"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
found := false
for _, ev := range events {
if o, ok := ev.(tool.ToolCallOutput); ok && o.Name == "does_not_exist" {
found = true
if !o.Result.IsError {
t.Error("expected IsError for unknown tool")
}
}
}
if !found {
t.Error("expected ToolCallOutput for unknown tool")
}
}
func TestLoop_StreamErrorAbortsWithoutIncompleteMessage(t *testing.T) {
provider := &fakeProvider{}
loop, _ := newTestLoop(provider)
loop.Provider = errorProvider{}
in := make(chan Input, 1)
in <- Input{Text: "hi"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
sawError := false
for _, ev := range events {
if _, ok := ev.(tool.ErrorEvent); ok {
sawError = true
}
}
if !sawError {
t.Error("expected ErrorEvent")
}
}
type errorProvider struct{}
func (errorProvider) Name() string { return "error" }
func (errorProvider) Caps() llm.Caps { return llm.Caps{} }
func (errorProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
return func(yield func(llm.Event, error) bool) {
yield(nil, context.DeadlineExceeded)
}, nil
}

View file

@ -0,0 +1,154 @@
package agent
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
"nub/internal/llm"
"nub/internal/permission"
"nub/internal/tool"
)
// countingTool zählt, wie oft Run tatsächlich aufgerufen wurde — Grundlage,
// um zu beweisen, dass deny/ask-abgelehnte Calls das Tool gar nicht erst
// ausführen.
type countingTool struct{ calls *int32 }
func (t countingTool) Name() string { return "echo" }
func (t countingTool) Description() string { return "echoes input" }
func (t countingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (t countingTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
atomic.AddInt32(t.calls, 1)
return tool.Result{ForModel: "ran"}, nil
}
func newPermissionTestLoop(t *testing.T, calls *int32) (*Loop, *fakeProvider) {
t.Helper()
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "echo", `{}`),
textEvents("done"),
}}
loop, reg := newTestLoop(provider)
reg.Register(countingTool{calls: calls}) // überschreibt das echoTool aus newTestLoop
return loop, provider
}
func runOneRound(t *testing.T, loop *Loop) []tool.UIEvent {
t.Helper()
in := make(chan Input, 1)
in <- Input{Text: "go"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
return drain(t, out, 3*time.Second)
}
func findToolCallOutput(events []tool.UIEvent) *tool.ToolCallOutput {
for _, ev := range events {
if o, ok := ev.(tool.ToolCallOutput); ok {
return &o
}
}
return nil
}
func TestRunTools_DenyModeBlocksWithoutRunning(t *testing.T) {
var calls int32
loop, _ := newPermissionTestLoop(t, &calls)
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeDeny}}
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if !out.Result.IsError {
t.Error("expected IsError for a denied tool call")
}
if atomic.LoadInt32(&calls) != 0 {
t.Errorf("tool.Run was called %d times, want 0 (deny must block before execution)", calls)
}
}
func TestRunTools_AskModeWithNilHookIsHardError(t *testing.T) {
var calls int32
loop, _ := newPermissionTestLoop(t, &calls)
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeAsk}}
// RequestPermission bleibt nil.
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if !out.Result.IsError {
t.Error("expected IsError when ask has no RequestPermission hook")
}
if atomic.LoadInt32(&calls) != 0 {
t.Errorf("tool.Run was called %d times, want 0", calls)
}
}
func TestRunTools_AskModeAllowedByHookRuns(t *testing.T) {
var calls int32
loop, _ := newPermissionTestLoop(t, &calls)
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeAsk}}
loop.RequestPermission = func(ctx context.Context, toolName string, input json.RawMessage) bool {
return true
}
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if out.Result.IsError {
t.Errorf("expected success when the hook allows, got: %s", out.Result.ForModel)
}
if atomic.LoadInt32(&calls) != 1 {
t.Errorf("tool.Run was called %d times, want 1", calls)
}
}
func TestRunTools_AskModeDeniedByHookDoesNotRun(t *testing.T) {
var calls int32
loop, _ := newPermissionTestLoop(t, &calls)
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeAsk}}
loop.RequestPermission = func(ctx context.Context, toolName string, input json.RawMessage) bool {
return false
}
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if !out.Result.IsError {
t.Error("expected IsError when the user declines")
}
if atomic.LoadInt32(&calls) != 0 {
t.Errorf("tool.Run was called %d times, want 0", calls)
}
}
func TestRunTools_NilPolicyDefaultsToAuto(t *testing.T) {
var calls int32
loop, _ := newPermissionTestLoop(t, &calls)
// loop.Permissions bleibt nil.
events := runOneRound(t, loop)
out := findToolCallOutput(events)
if out == nil {
t.Fatal("expected a ToolCallOutput event")
}
if out.Result.IsError {
t.Errorf("expected success with no policy configured, got: %s", out.Result.ForModel)
}
if atomic.LoadInt32(&calls) != 1 {
t.Errorf("tool.Run was called %d times, want 1", calls)
}
}

146
internal/agent/tools.go Normal file
View file

@ -0,0 +1,146 @@
package agent
import (
"context"
"fmt"
"sync"
"nub/internal/llm"
"nub/internal/permission"
"nub/internal/tool"
)
type toolResult struct {
id string
result tool.Result
}
// runTools startet alle Tool-Calls eines Turns parallel, an einen von ctx
// abgeleiteten Kontext gebunden (Ablauf pro Turn, Schritt 3). Jeder Call
// bekommt garantiert ein Ergebnis — auch bei Timeout oder Abbruch — weil das
// Protokoll für jeden tool_use zwingend einen tool_result verlangt.
//
// Permission-Prüfung (E-11) läuft davor, sequentiell für den ganzen Batch:
// so muss die UI nie mehrere gleichzeitige Rückfragen anzeigen. Erst danach
// starten die tatsächlich erlaubten Calls parallel wie bisher.
func (l *Loop) runTools(ctx context.Context, calls []llm.Block, out chan<- tool.UIEvent) []toolResult {
results := make([]toolResult, len(calls))
runnable := make([]int, 0, len(calls))
for i, call := range calls {
if res, blocked := l.checkPermission(ctx, call); blocked {
results[i] = res
out <- tool.ToolCallOutput{ID: call.ID, Name: call.Name, Result: res.result}
continue
}
runnable = append(runnable, i)
}
var wg sync.WaitGroup
for _, i := range runnable {
call := calls[i]
wg.Add(1)
go func(i int, call llm.Block) {
defer wg.Done()
toolCtx, cancel := context.WithTimeout(ctx, l.maxToolTime())
defer cancel()
env := l.Env
env.Emit = func(ev tool.UIEvent) {
if st, ok := ev.(tool.ToolStream); ok {
st.ID = call.ID
out <- st
return
}
out <- ev
}
env.Ask = l.AskUser
res, err := l.Tools.Run(toolCtx, call.Name, call.Input, env)
if err != nil {
res = tool.Result{ForModel: err.Error(), IsError: true}
}
if toolCtx.Err() == context.DeadlineExceeded && !res.IsError {
res = tool.Result{ForModel: fmt.Sprintf("tool %s timed out after %s", call.Name, l.maxToolTime()), IsError: true}
}
results[i] = toolResult{id: call.ID, result: res}
out <- tool.ToolCallOutput{ID: call.ID, Name: call.Name, Result: res}
}(i, call)
}
wg.Wait()
return results
}
// checkPermission entscheidet, ob ein Tool-Call überhaupt starten darf.
// blocked=true bedeutet: res ist bereits das finale (Fehler-)Ergebnis, der
// Call wird nicht ausgeführt.
func (l *Loop) checkPermission(ctx context.Context, call llm.Block) (res toolResult, blocked bool) {
switch l.Permissions.Check(call.Name, call.Input) {
case permission.ModeDeny:
return toolResult{id: call.ID, result: tool.Result{
ForModel: fmt.Sprintf("permission denied: %s is not allowed by policy", call.Name),
IsError: true,
}}, true
case permission.ModeAsk:
if l.RequestPermission == nil {
return toolResult{id: call.ID, result: tool.Result{
ForModel: fmt.Sprintf("tool %s requires confirmation ('ask'), which this mode does not support", call.Name),
IsError: true,
}}, true
}
if !l.RequestPermission(ctx, call.Name, call.Input) {
return toolResult{id: call.ID, result: tool.Result{
ForModel: fmt.Sprintf("permission denied by user for %s", call.Name),
IsError: true,
}}, true
}
}
return toolResult{}, false
}
func resultsMessage(results []toolResult) llm.Message {
msg := llm.Message{Role: llm.RoleUser}
for _, r := range results {
msg.Content = append(msg.Content, llm.Block{
Kind: llm.KindToolResult,
ToolUseID: r.id,
Result: []llm.Block{{Kind: llm.KindText, Text: r.result.ForModel}},
IsError: r.result.IsError,
})
}
return msg
}
func cancelledResults(calls []llm.Block, reason string) []toolResult {
out := make([]toolResult, len(calls))
for i, c := range calls {
out[i] = toolResult{id: c.ID, result: tool.Result{ForModel: reason, IsError: true}}
}
return out
}
func extractToolUse(msg llm.Message) []llm.Block {
var out []llm.Block
for _, b := range msg.Content {
if b.Kind == llm.KindToolUse {
out = append(out, b)
}
}
return out
}
func turnSignature(calls []llm.Block) string {
var b []byte
for _, c := range calls {
b = append(b, c.Name...)
b = append(b, 0)
b = append(b, c.Input...)
b = append(b, 0x1f)
}
return string(b)
}

83
internal/config/load.go Normal file
View file

@ -0,0 +1,83 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// Load baut die Config aus allen Schichten auf (spätere gewinnt): Defaults,
// ~/.config/nub/config.toml, <repoRoot>/.nub/config.toml, NUB_*-Umgebungs-
// variablen. Flags folgen erst, wenn cmd/nub welche anbietet.
func Load(repoRoot string) (Config, error) {
cfg := Defaults()
if home, err := os.UserHomeDir(); err == nil {
if err := mergeFile(&cfg, filepath.Join(home, ".config", "nub", "config.toml")); err != nil {
return Config{}, err
}
}
if err := mergeFile(&cfg, filepath.Join(repoRoot, ".nub", "config.toml")); err != nil {
return Config{}, err
}
applyEnvOverrides(&cfg)
if cfg.Version != 1 {
return Config{}, fmt.Errorf("config: unsupported version %d (nub unterstützt nur version 1)", cfg.Version)
}
return cfg, nil
}
func mergeFile(cfg *Config, path string) error {
if _, err := os.Stat(path); err != nil {
return nil // Datei fehlt -> Schicht wird übersprungen
}
if _, err := toml.DecodeFile(path, cfg); err != nil {
return fmt.Errorf("config: %s: %w", path, err)
}
return nil
}
// applyEnvOverrides deckt den kleinen, konkreten Satz an NUB_*-Variablen ab,
// der v1 als letzte Schicht vor den (noch nicht existierenden) Flags dient.
func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("NUB_MODEL"); v != "" {
cfg.Model.Default = v
}
baseURL := os.Getenv("NUB_BASE_URL")
apiKey := firstNonEmpty(os.Getenv("NUB_API_KEY"), os.Getenv("OPENAI_API_KEY"))
if baseURL == "" && apiKey == "" {
return
}
idx := -1
for i, e := range cfg.Endpoints {
if e.Name == cfg.Model.Endpoint {
idx = i
break
}
}
if idx == -1 {
cfg.Endpoints = append(cfg.Endpoints, Endpoint{Name: cfg.Model.Endpoint})
idx = len(cfg.Endpoints) - 1
}
if baseURL != "" {
cfg.Endpoints[idx].BaseURL = baseURL
}
if apiKey != "" {
cfg.Endpoints[idx].APIKey = apiKey
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}

View file

@ -0,0 +1,154 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_LayerPrecedence_RepoOverridesHome(t *testing.T) {
home := t.TempDir()
repo := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "") // macOS UserHomeDir ignores this, aber sauber halten
t.Setenv("NUB_MODEL", "")
t.Setenv("NUB_BASE_URL", "")
t.Setenv("NUB_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
writeConfig(t, filepath.Join(home, ".config", "nub", "config.toml"), `
version = 1
[model]
default = "from-home"
endpoint = "openai"
`)
writeConfig(t, filepath.Join(repo, ".nub", "config.toml"), `
version = 1
[model]
default = "from-repo"
endpoint = "openai"
`)
cfg, err := Load(repo)
if err != nil {
t.Fatal(err)
}
if cfg.Model.Default != "from-repo" {
t.Errorf("model.default = %q, want from-repo (repo layer must win over home)", cfg.Model.Default)
}
}
func TestLoad_EnvOverridesFiles(t *testing.T) {
home := t.TempDir()
repo := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("NUB_MODEL", "from-env")
t.Setenv("NUB_BASE_URL", "")
t.Setenv("NUB_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
writeConfig(t, filepath.Join(repo, ".nub", "config.toml"), `
version = 1
[model]
default = "from-repo"
endpoint = "openai"
`)
cfg, err := Load(repo)
if err != nil {
t.Fatal(err)
}
if cfg.Model.Default != "from-env" {
t.Errorf("model.default = %q, want from-env (env must win over repo file)", cfg.Model.Default)
}
}
func TestLoad_DefaultsApplyWithoutAnyConfigFile(t *testing.T) {
home := t.TempDir()
repo := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("NUB_MODEL", "")
t.Setenv("NUB_BASE_URL", "")
t.Setenv("NUB_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
cfg, err := Load(repo)
if err != nil {
t.Fatal(err)
}
if cfg.Model.Default != "gpt-4o" {
t.Errorf("model.default = %q, want default gpt-4o", cfg.Model.Default)
}
if len(cfg.Context.Files) != 2 {
t.Errorf("context.files = %v, want 2 defaults", cfg.Context.Files)
}
}
func TestLoad_RejectsUnsupportedVersion(t *testing.T) {
home := t.TempDir()
repo := t.TempDir()
t.Setenv("HOME", home)
writeConfig(t, filepath.Join(repo, ".nub", "config.toml"), `version = 2`)
if _, err := Load(repo); err == nil {
t.Fatal("expected error for unsupported config version")
}
}
func TestResolve_EnvKeyIndirection(t *testing.T) {
t.Setenv("MY_TEST_KEY", "sk-secret")
cfg := Defaults()
cfg.Endpoints[0].APIKey = "env:MY_TEST_KEY"
resolved, err := Resolve(cfg)
if err != nil {
t.Fatal(err)
}
if resolved.APIKey != "sk-secret" {
t.Errorf("api key = %q, want sk-secret", resolved.APIKey)
}
}
func TestResolve_MissingKeyOnRemoteEndpointErrors(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
cfg := Defaults()
cfg.Endpoints[0].APIKey = "env:DOES_NOT_EXIST_ENV_VAR"
if _, err := Resolve(cfg); err == nil {
t.Fatal("expected error for missing API key on non-local endpoint")
}
}
func TestResolve_LocalEndpointWithoutKeyIsFine(t *testing.T) {
cfg := Defaults()
cfg.Endpoints[0].BaseURL = "http://localhost:11434/v1"
cfg.Endpoints[0].APIKey = "none"
resolved, err := Resolve(cfg)
if err != nil {
t.Fatal(err)
}
if resolved.APIKey != "" {
t.Errorf("api key = %q, want empty for local endpoint", resolved.APIKey)
}
}
func TestResolve_UnknownEndpointErrors(t *testing.T) {
cfg := Defaults()
cfg.Model.Endpoint = "does-not-exist"
if _, err := Resolve(cfg); err == nil {
t.Fatal("expected error for unknown model.endpoint reference")
}
}

View file

@ -0,0 +1,62 @@
package config
import (
"fmt"
"os"
"strings"
"nub/internal/llm"
"nub/internal/llm/registry"
)
// Resolved ist alles, was der OpenAI-Adapter zum Verbindungsaufbau braucht.
type Resolved struct {
Model string
BaseURL string
APIKey string
Caps llm.Caps
}
// Resolve löst den in Model.Endpoint referenzierten Endpoint auf, ermittelt
// den API-Key (inkl. "env:VAR"-Indirektion) und merged Caps aus der
// Modell-Registry (E-10) mit den Endpoint-Overrides.
func Resolve(cfg Config) (Resolved, error) {
var ep *Endpoint
for i := range cfg.Endpoints {
if cfg.Endpoints[i].Name == cfg.Model.Endpoint {
ep = &cfg.Endpoints[i]
break
}
}
if ep == nil {
return Resolved{}, fmt.Errorf("config: model.endpoint %q referenziert keinen konfigurierten [[endpoint]]", cfg.Model.Endpoint)
}
key := resolveAPIKey(ep.APIKey)
if key == "" && !isLocalEndpoint(ep.BaseURL) {
return Resolved{}, fmt.Errorf("config: kein API-Key für Endpoint %q — in der Config setzen oder NUB_API_KEY/OPENAI_API_KEY exportieren", ep.Name)
}
caps := registry.Apply(registry.DefaultCaps(cfg.Model.Default), ep.Caps)
return Resolved{
Model: cfg.Model.Default,
BaseURL: ep.BaseURL,
APIKey: key,
Caps: caps,
}, nil
}
func resolveAPIKey(v string) string {
if rest, ok := strings.CutPrefix(v, "env:"); ok {
return os.Getenv(rest)
}
if v == "none" {
return ""
}
return v
}
func isLocalEndpoint(baseURL string) bool {
return strings.HasPrefix(baseURL, "http://")
}

82
internal/config/schema.go Normal file
View file

@ -0,0 +1,82 @@
// Package config implementiert die geschichtete Konfiguration aus
// Abschnitt 4.7: Defaults -> ~/.config/nub/config.toml -> <repo>/.nub/config.toml
// -> Umgebungsvariablen. Spätere Schicht gewinnt.
package config
import "nub/internal/llm/registry"
type Config struct {
Version int `toml:"version"`
Model ModelConfig `toml:"model"`
Endpoints []Endpoint `toml:"endpoint"`
Context ContextConfig `toml:"context"`
Skills SkillsConfig `toml:"skills"`
MCP []MCPServer `toml:"mcp"`
Profiles map[string][]string `toml:"profiles"`
Permissions PermissionsConfig `toml:"permissions"`
}
type ModelConfig struct {
Default string `toml:"default"`
Endpoint string `toml:"endpoint"`
}
type Endpoint struct {
Name string `toml:"name"`
BaseURL string `toml:"base_url"`
APIKey string `toml:"api_key"`
Caps registry.CapsOverride `toml:"caps"`
}
type ContextConfig struct {
Files []string `toml:"files"`
WalkUp bool `toml:"walk_up"`
MaxTokens int `toml:"max_tokens"`
}
type SkillsConfig struct {
Paths []string `toml:"paths"`
}
type MCPServer struct {
Name string `toml:"name"`
Command string `toml:"command"`
Args []string `toml:"args"`
URL string `toml:"url"`
Tools []string `toml:"tools"`
}
type PermissionsConfig struct {
Read string `toml:"read"`
Glob string `toml:"glob"`
Grep string `toml:"grep"`
Write string `toml:"write"`
Edit string `toml:"edit"`
Bash string `toml:"bash"`
DenyPaths []string `toml:"deny_paths"`
DenyBash []string `toml:"deny_bash"`
}
// Defaults liefert die Konfiguration, die ohne jede Config-Datei gilt.
func Defaults() Config {
return Config{
Version: 1,
Model: ModelConfig{Default: "gpt-4o", Endpoint: "openai"},
Endpoints: []Endpoint{
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "env:OPENAI_API_KEY"},
},
Context: ContextConfig{
Files: []string{"AGENTS.md", "REPOMAP.md"},
WalkUp: true,
MaxTokens: 20000,
},
Skills: SkillsConfig{
Paths: []string{"~/.nub/skills", ".nub/skills"},
},
Permissions: PermissionsConfig{
Read: "auto", Glob: "auto", Grep: "auto",
Write: "auto", Edit: "auto", Bash: "auto",
DenyPaths: []string{".git/**", "**/.env", "**/id_rsa*"},
},
}
}

188
internal/ctxasm/assemble.go Normal file
View file

@ -0,0 +1,188 @@
// Package ctxasm baut den System-Block eines Requests auf: stabil vor
// volatil, mit einem Cache-Breakpoint am Ende (5.4). Alles Dynamische
// (Git-Status, Datum, Tool-Ausgaben) gehört in Messages, nie hierher.
package ctxasm
import (
_ "embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"nub/internal/llm"
"nub/internal/tokens"
)
//go:embed default_system.md
var defaultSystemPrompt string
// Options steuert den Assemble-Lauf. RepoRoot/Cwd werden für die
// Umgebungs-Zeile und die walk_up-Dateisuche gebraucht.
type Options struct {
RepoRoot string
Cwd string
Files []string // konfigurierte Kontext-Dateinamen, z.B. AGENTS.md
WalkUp bool
MaxTokens int // Default 20000, wenn <= 0
SkillsIndex string // vorgerendert (internal/skill.RenderIndex), leer = kein Block
}
// FileOrigin protokolliert Herkunft und Kosten jeder geladenen Kontext-Datei
// (Grundlage für `nub context`).
type FileOrigin struct {
Path string // repo-relativ
Source string // absoluter Pfad, aus dem gelesen wurde
Tokens int
Truncated bool
}
type Meta struct {
Files []FileOrigin
Warnings []string
TotalTokens int
}
// Assemble baut die System-Blöcke in der Reihenfolge: Basis-System-Prompt,
// statischer Umgebungsblock, konfigurierte Kontext-Dateien. Der letzte Block
// bekommt CacheMark=true.
func Assemble(opts Options) ([]llm.Block, Meta, error) {
budget := opts.MaxTokens
if budget <= 0 {
budget = 20000
}
var blocks []llm.Block
var meta Meta
systemText := systemPromptText(opts.RepoRoot)
envText := environmentBlock(opts.RepoRoot)
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: systemText})
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: envText})
// Das Budget gilt für die konfigurierten Kontext-Dateien, nicht für den
// (kleinen, festen) System-Prompt/Env-Block.
used := 0
for _, name := range opts.Files {
for _, path := range resolveFilePaths(opts.RepoRoot, opts.Cwd, name, opts.WalkUp) {
data, err := os.ReadFile(path)
if err != nil {
continue
}
rel, err := filepath.Rel(opts.RepoRoot, path)
if err != nil {
rel = path
}
text := string(data)
tok := tokens.Estimate(text)
truncated := false
if used+tok > budget {
remaining := budget - used
if remaining <= 0 {
meta.Warnings = append(meta.Warnings, fmt.Sprintf("context budget (%d tokens) exceeded, skipping %s", budget, rel))
continue
}
maxChars := remaining * 4
if maxChars < len(text) {
text = text[:maxChars] + "\n... [truncated: context budget exceeded] ..."
tok = tokens.Estimate(text)
truncated = true
meta.Warnings = append(meta.Warnings, fmt.Sprintf("%s truncated: context budget (%d tokens) exceeded", rel, budget))
}
}
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: fmt.Sprintf("# %s\n\n%s", rel, text)})
used += tok
meta.Files = append(meta.Files, FileOrigin{Path: rel, Source: path, Tokens: tok, Truncated: truncated})
}
}
meta.TotalTokens = used
if opts.SkillsIndex != "" {
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: opts.SkillsIndex})
}
if len(blocks) > 0 {
blocks[len(blocks)-1].CacheMark = true
}
warnStaleRepoMap(opts.RepoRoot, meta.Files, &meta.Warnings)
return blocks, meta, nil
}
func systemPromptText(repoRoot string) string {
if data, err := os.ReadFile(filepath.Join(repoRoot, "SYSTEM.md")); err == nil {
return string(data)
}
return defaultSystemPrompt
}
func environmentBlock(repoRoot string) string {
var b strings.Builder
fmt.Fprintf(&b, "OS: %s\n", runtime.GOOS)
shell := os.Getenv("SHELL")
if shell == "" {
shell = "unknown"
}
fmt.Fprintf(&b, "Shell: %s\n", shell)
fmt.Fprintf(&b, "RepoRoot: %s\n", repoRoot)
if branch, err := gitBranch(repoRoot); err == nil && branch != "" {
fmt.Fprintf(&b, "Branch: %s\n", branch)
}
return b.String()
}
func gitBranch(repoRoot string) (string, error) {
out, err := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// resolveFilePaths sammelt alle Treffer für `name` zwischen Cwd und RepoRoot
// (walk_up) in der Reihenfolge RepoRoot -> Cwd (näher am Arbeitsverzeichnis
// gewinnt inhaltlich, weil später gelesen). Ohne walk_up nur RepoRoot.
func resolveFilePaths(repoRoot, cwd, name string, walkUp bool) []string {
if !walkUp {
p := filepath.Join(repoRoot, name)
if fileExists(p) {
return []string{p}
}
return nil
}
var dirs []string
d := filepath.Clean(cwd)
root := filepath.Clean(repoRoot)
for {
dirs = append(dirs, d)
if d == root {
break
}
parent := filepath.Dir(d)
if parent == d {
break // cwd liegt nicht unterhalb von repoRoot
}
d = parent
}
var out []string
for i := len(dirs) - 1; i >= 0; i-- {
p := filepath.Join(dirs[i], name)
if fileExists(p) {
out = append(out, p)
}
}
return out
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View file

@ -0,0 +1,160 @@
package ctxasm
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"nub/internal/llm"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAssemble_WalkUp_OrdersRepoRootBeforeCwd(t *testing.T) {
repoRoot := t.TempDir()
sub := filepath.Join(repoRoot, "sub", "deeper")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(repoRoot, "AGENTS.md"), "root instructions")
writeFile(t, filepath.Join(sub, "AGENTS.md"), "deeper instructions")
blocks, meta, err := Assemble(Options{
RepoRoot: repoRoot, Cwd: sub,
Files: []string{"AGENTS.md"}, WalkUp: true, MaxTokens: 20000,
})
if err != nil {
t.Fatal(err)
}
if len(meta.Files) != 2 {
t.Fatalf("loaded %d files, want 2", len(meta.Files))
}
if meta.Files[0].Path != "AGENTS.md" {
t.Errorf("first file = %q, want repo-root AGENTS.md first", meta.Files[0].Path)
}
if meta.Files[1].Path != filepath.Join("sub", "deeper", "AGENTS.md") {
t.Errorf("second file = %q, want the deeper one last", meta.Files[1].Path)
}
joined := joinBlockTexts(blocks)
rootIdx := strings.Index(joined, "root instructions")
deeperIdx := strings.Index(joined, "deeper instructions")
if rootIdx == -1 || deeperIdx == -1 || rootIdx > deeperIdx {
t.Errorf("expected root instructions before deeper instructions in assembled text")
}
}
func TestAssemble_LastBlockHasCacheMark(t *testing.T) {
repoRoot := t.TempDir()
blocks, _, err := Assemble(Options{RepoRoot: repoRoot, Cwd: repoRoot, MaxTokens: 20000})
if err != nil {
t.Fatal(err)
}
for i, b := range blocks {
want := i == len(blocks)-1
if b.CacheMark != want {
t.Errorf("block %d CacheMark = %v, want %v", i, b.CacheMark, want)
}
}
}
func TestAssemble_BudgetExceeded_TruncatesAndWarns(t *testing.T) {
repoRoot := t.TempDir()
big := strings.Repeat("word ", 2000)
writeFile(t, filepath.Join(repoRoot, "AGENTS.md"), big)
_, meta, err := Assemble(Options{
RepoRoot: repoRoot, Cwd: repoRoot,
Files: []string{"AGENTS.md"}, WalkUp: false, MaxTokens: 100,
})
if err != nil {
t.Fatal(err)
}
if len(meta.Warnings) == 0 {
t.Fatal("expected a budget warning")
}
if len(meta.Files) != 1 || !meta.Files[0].Truncated {
t.Errorf("expected AGENTS.md to be marked truncated, got: %+v", meta.Files)
}
}
func TestAssemble_MissingFilesAreSkippedSilently(t *testing.T) {
repoRoot := t.TempDir()
_, meta, err := Assemble(Options{
RepoRoot: repoRoot, Cwd: repoRoot,
Files: []string{"DOES_NOT_EXIST.md"}, WalkUp: false, MaxTokens: 20000,
})
if err != nil {
t.Fatal(err)
}
if len(meta.Files) != 0 {
t.Errorf("expected no files loaded, got %+v", meta.Files)
}
}
func TestAssemble_WarnsOnStaleRepoMap(t *testing.T) {
repoRoot := t.TempDir()
runGit(t, repoRoot, "init")
writeFile(t, filepath.Join(repoRoot, "f.txt"), "x")
runGit(t, repoRoot, "add", "-A")
runGit(t, repoRoot, "-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-m", "init")
content, err := GenerateRepoMap(repoRoot)
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(repoRoot, "REPOMAP.md"), content)
// Neuer Commit nach Repomap-Generierung -> Repomap ist jetzt stale.
writeFile(t, filepath.Join(repoRoot, "f.txt"), "y")
runGit(t, repoRoot, "add", "-A")
runGit(t, repoRoot, "-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-m", "change")
_, meta, err := Assemble(Options{
RepoRoot: repoRoot, Cwd: repoRoot,
Files: []string{"REPOMAP.md"}, WalkUp: false, MaxTokens: 20000,
})
if err != nil {
t.Fatal(err)
}
found := false
for _, w := range meta.Warnings {
if strings.Contains(w, "REPOMAP.md") {
found = true
}
}
if !found {
t.Errorf("expected a staleness warning, got: %v", meta.Warnings)
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
func joinBlockTexts(blocks []llm.Block) string {
var b strings.Builder
for _, blk := range blocks {
b.WriteString(blk.Text)
b.WriteString("\n")
}
return b.String()
}

View file

@ -0,0 +1,11 @@
Du bist nub, ein terminal-basierter Coding-Agent. Du arbeitest im aktuellen
Repository über die dir zur Verfügung gestellten Tools (read, write, edit,
bash, glob, grep).
- Lies relevante Dateien, bevor du sie änderst.
- Nutze `edit` für gezielte Änderungen, `write` nur für neue oder komplett
zu ersetzende Dateien.
- Bei mehrdeutigen edit-Treffern: alten String präzisieren, nicht raten.
- Führe nach Änderungen, wenn sinnvoll, Tests oder einen Build-Check über
`bash` aus.
- Antworte knapp. Erkläre nur, was für den nächsten Schritt relevant ist.

204
internal/ctxasm/repomap.go Normal file
View file

@ -0,0 +1,204 @@
package ctxasm
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
const repoMapCommitPrefix = "commit: "
// GenerateRepoMap baut Verzeichnisbaum + exportierte Symbole (5.4). Für
// .go-Dateien über go/ast, sonst der sprachagnostische Fallback: die erste
// nicht-leere Zeile der Datei. Der Header trägt den Git-Commit, gegen den
// gebaut wurde — Assemble() warnt später, wenn HEAD davon abweicht.
func GenerateRepoMap(repoRoot string) (string, error) {
commit, _ := gitHead(repoRoot) // best effort; leer, wenn kein Git-Repo
var files []string
err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
rel, relErr := filepath.Rel(repoRoot, path)
if relErr != nil || rel == "." {
return nil
}
base := filepath.Base(path)
if d.IsDir() {
if base == ".git" || base == ".nub" || base == "node_modules" || base == "vendor" {
return filepath.SkipDir
}
return nil
}
files = append(files, rel)
return nil
})
if err != nil {
return "", err
}
sort.Strings(files)
var b strings.Builder
fmt.Fprintf(&b, "# REPOMAP.md\n%s%s\ngenerated_by: nub map\n\n", repoMapCommitPrefix, commit)
b.WriteString("## Tree\n\n")
for _, f := range files {
fmt.Fprintf(&b, "%s\n", f)
}
b.WriteString("\n## Symbols\n\n")
for _, f := range files {
abs := filepath.Join(repoRoot, f)
if strings.HasSuffix(f, ".go") {
syms, err := goSymbols(abs)
if err != nil || len(syms) == 0 {
continue
}
fmt.Fprintf(&b, "### %s\n", f)
for _, s := range syms {
fmt.Fprintf(&b, "- %s\n", s)
}
b.WriteString("\n")
continue
}
if header := fileHeader(abs); header != "" {
fmt.Fprintf(&b, "### %s\n%s\n\n", f, header)
}
}
return b.String(), nil
}
func goSymbols(path string) ([]string, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
var syms []string
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if d.Name.IsExported() {
recv := ""
if d.Recv != nil && len(d.Recv.List) == 1 {
recv = "(" + exprString(d.Recv.List[0].Type) + ") "
}
syms = append(syms, "func "+recv+d.Name.Name)
}
case *ast.GenDecl:
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
if s.Name.IsExported() {
syms = append(syms, "type "+s.Name.Name)
}
case *ast.ValueSpec:
kind := "var"
if d.Tok == token.CONST {
kind = "const"
}
for _, name := range s.Names {
if name.IsExported() {
syms = append(syms, kind+" "+name.Name)
}
}
}
}
}
}
return syms, nil
}
func exprString(e ast.Expr) string {
switch t := e.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return "*" + exprString(t.X)
default:
return "?"
}
}
// fileHeader ist der sprachagnostische Fallback: die erste nicht-leere Zeile.
func fileHeader(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if len(line) > 120 {
line = line[:120]
}
return line
}
return ""
}
func gitHead(repoRoot string) (string, error) {
out, err := exec.Command("git", "-C", repoRoot, "rev-parse", "HEAD").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// warnStaleRepoMap vergleicht den im REPOMAP.md-Header vermerkten Commit mit
// dem aktuellen HEAD und hängt bei Abweichung eine Warnung an (nicht fatal —
// eine falsche Repomap ist schlechter als keine, aber sie soll den Start
// nicht verhindern).
func warnStaleRepoMap(repoRoot string, files []FileOrigin, warnings *[]string) {
for _, f := range files {
if filepath.Base(f.Path) != "REPOMAP.md" {
continue
}
data, err := os.ReadFile(f.Source)
if err != nil {
return
}
recorded := ""
for _, line := range strings.Split(string(data), "\n") {
if rest, ok := strings.CutPrefix(line, repoMapCommitPrefix); ok {
recorded = strings.TrimSpace(rest)
break
}
}
if recorded == "" {
return
}
current, err := gitHead(repoRoot)
if err != nil {
return
}
if current != recorded {
*warnings = append(*warnings, fmt.Sprintf(
"REPOMAP.md wurde gegen Commit %s erzeugt, HEAD ist jetzt %s — `nub map` erneut ausführen",
shortSHA(recorded), shortSHA(current)))
}
}
}
func shortSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}

45
internal/llm/event.go Normal file
View file

@ -0,0 +1,45 @@
package llm
// Modelliert nach dem expliziteren Anthropic-Schema; der OpenAI-Adapter synthetisiert.
type Event interface{ isEvent() }
type BlockStart struct {
Index int
Block Block // Block ohne Inhalt, nur Kind/ID/Name
}
type BlockDelta struct {
Index int
Text string
PartialJSON string
}
type BlockStop struct {
Index int
}
type Done struct {
Stop StopReason
Usage Usage
}
func (BlockStart) isEvent() {}
func (BlockDelta) isEvent() {}
func (BlockStop) isEvent() {}
func (Done) isEvent() {}
type StopReason string
const (
StopEnd StopReason = "end_turn"
StopToolUse StopReason = "tool_use"
StopMaxTokens StopReason = "max_tokens"
)
type Usage struct {
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
}

69
internal/llm/message.go Normal file
View file

@ -0,0 +1,69 @@
// Package llm definiert das providerunabhängige Nachrichten- und Event-Modell.
package llm
import "encoding/json"
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
type BlockKind string
const (
KindText BlockKind = "text"
KindThinking BlockKind = "thinking"
KindToolUse BlockKind = "tool_use"
KindToolResult BlockKind = "tool_result"
KindImage BlockKind = "image"
)
type Block struct {
Kind BlockKind `json:"kind"`
// text / thinking
Text string `json:"text,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Result []Block `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
// image
MediaType string `json:"media_type,omitempty"`
Data []byte `json:"data,omitempty"`
// Opaker Provider-Ballast (Anthropic-Signatures, reasoning-IDs).
// Muss unverändert zurückgesendet werden können.
Raw json.RawMessage `json:"raw,omitempty"`
// Cache-Breakpoint. Adapter ohne Cache-Steuerung ignorieren das Feld.
CacheMark bool `json:"cache_mark,omitempty"`
}
type Message struct {
Role Role `json:"role"`
Content []Block `json:"content"`
}
type Request struct {
Model string
System []Block // eigenes Feld, NICHT als Message in der Historie
Messages []Message
Tools []ToolDef
MaxTokens int
Temp *float64
}
type ToolDef struct {
Name string
Description string
Schema json.RawMessage // kanonisch: JSON Schema
}

View file

@ -0,0 +1,98 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"iter"
"net/http"
"time"
"nub/internal/llm"
)
type Adapter struct {
name string
baseURL string
apiKey string
caps llm.Caps
httpClient *http.Client
}
func New(name, baseURL, apiKey string, caps llm.Caps) *Adapter {
return &Adapter{
name: name,
baseURL: baseURL,
apiKey: apiKey,
caps: caps,
httpClient: &http.Client{Timeout: 10 * time.Minute},
}
}
func (a *Adapter) Name() string { return a.name }
func (a *Adapter) Caps() llm.Caps { return a.caps }
// Stream öffnet die HTTP-Verbindung synchron (damit Verbindungsfehler sofort
// als error zurückkommen) und liefert einen Iterator über die synthetisierten
// Events. Retry ist bewusst nicht Teil dieser Methode (E-12) — siehe
// withRetry im Loop-Aufrufer.
func (a *Adapter) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
wireReq := buildRequest(req, a.caps)
body, err := json.Marshal(wireReq)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build http request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if a.apiKey != "" && a.apiKey != "none" {
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
}
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if resp.StatusCode >= 300 {
defer resp.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
return nil, &StatusError{Code: resp.StatusCode, Body: string(payload)}
}
dec := newDecoder(resp.Body)
return func(yield func(llm.Event, error) bool) {
defer resp.Body.Close()
for {
events, more, err := dec.next()
if err != nil {
yield(nil, err)
return
}
for _, ev := range events {
if !yield(ev, nil) {
return
}
}
if !more {
return
}
}
}, nil
}
// StatusError trägt den HTTP-Status, damit der Retry-Layer (E-12, 5.9) ihn
// klassifizieren kann, ohne Strings zu parsen.
type StatusError struct {
Code int
Body string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("openai: http %d: %s", e.Code, e.Body)
}

View file

@ -0,0 +1,143 @@
package openai
import (
"encoding/json"
"fmt"
"nub/internal/llm"
)
const systemSeparator = "--- system ---\n"
func buildRequest(req llm.Request, caps llm.Caps) wireRequest {
wr := wireRequest{
Model: req.Model,
MaxTokens: req.MaxTokens,
Temperature: req.Temp,
Stream: true,
}
if caps.UsageInStream {
wr.StreamOptions = &streamOptions{IncludeUsage: true}
}
var messages []wireMessage
for _, m := range req.Messages {
messages = append(messages, mapMessage(m)...)
}
// System-Rolle je nach Endpoint-Capability (5.1 Punkt 4): "system" oder
// "developer" als eigene Message-Rolle, sonst als erste User-Message mit
// Trennmarker (Endpoint ohne System-Rollen-Unterstützung).
if len(req.System) > 0 {
text := blocksToText(req.System)
switch caps.SystemRole {
case "system", "developer":
messages = append([]wireMessage{{Role: caps.SystemRole, Content: textContent(text)}}, messages...)
default:
messages = prependSystemAsUser(messages, text)
}
}
wr.Messages = messages
// "tools" weglassen statt leer senden (5.1 Punkt 2).
if len(req.Tools) > 0 {
wr.Tools = make([]wireToolDef, len(req.Tools))
for i, t := range req.Tools {
wr.Tools[i] = wireToolDef{
Type: "function",
Function: wireFunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Schema,
},
}
}
}
return wr
}
func mapMessage(m llm.Message) []wireMessage {
var out []wireMessage
role := string(m.Role)
var textParts []string
var toolCalls []wireToolCall
flushAssistant := func() {
if len(textParts) == 0 && len(toolCalls) == 0 {
return
}
wm := wireMessage{Role: role, ToolCalls: toolCalls}
if len(textParts) > 0 {
wm.Content = textContent(joinText(textParts))
}
out = append(out, wm)
textParts = nil
toolCalls = nil
}
for _, b := range m.Content {
switch b.Kind {
case llm.KindText:
textParts = append(textParts, b.Text)
case llm.KindThinking:
// Reasoning-Blöcke werden beim Zurücksenden nicht erneut mitgeschickt;
// sie sind rein empfangsseitig relevant (5.1 Punkt 5).
case llm.KindToolUse:
toolCalls = append(toolCalls, wireToolCall{
ID: b.ID,
Type: "function",
Function: wireToolCallFunc{
Name: b.Name,
Arguments: string(b.Input),
},
})
case llm.KindToolResult:
flushAssistant()
out = append(out, wireMessage{
Role: "tool",
ToolCallID: b.ToolUseID,
Content: textContent(blocksToText(b.Result)),
})
case llm.KindImage:
// v1: Bild-Content wird nicht gemappt (SupportsImages-Endpoints folgen später).
}
}
flushAssistant()
return out
}
func blocksToText(blocks []llm.Block) string {
var parts []string
for _, b := range blocks {
if b.Kind == llm.KindText || b.Kind == llm.KindThinking {
parts = append(parts, b.Text)
}
}
return joinText(parts)
}
func joinText(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += "\n"
}
out += p
}
return out
}
func textContent(s string) json.RawMessage {
b, _ := json.Marshal(s)
return b
}
// prependSystemAsUser wird verwendet, wenn Caps.SystemRole == "none" gesetzt
// ist (Endpoint ohne System-Rolle): System-Text als erste User-Message mit
// Trennmarker.
func prependSystemAsUser(messages []wireMessage, systemText string) []wireMessage {
marker := fmt.Sprintf("%s%s", systemSeparator, systemText)
return append([]wireMessage{{Role: "user", Content: textContent(marker)}}, messages...)
}

View file

@ -0,0 +1,215 @@
package openai
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"nub/internal/llm"
)
// partialCall akkumuliert die fragmentierten tool_calls-Deltas. `index` ist
// die einzige verlässliche Korrelation (5.1 Punkt 1); id/name kommen
// typischerweise nur im ersten Chunk.
type partialCall struct {
id string
name string
ourIndex int
started bool
}
// decoder liest einen OpenAI-kompatiblen SSE-Chatstream und synthetisiert
// unser internes Event-Schema. Tolerant gegenüber Server-Eigenheiten:
// kompletter Call in einem Chunk, falsch gezählter index, nachträglicher name.
type decoder struct {
scanner *bufio.Scanner
calls map[int]*partialCall
nextIndex int
textIndex int
textOpen bool
thinkIndex int
thinkOpen bool
usage wireUsage
haveUsage bool
finishStop string
done bool
}
func newDecoder(body io.Reader) *decoder {
s := bufio.NewScanner(body)
s.Buffer(make([]byte, 64*1024), 8*1024*1024)
return &decoder{scanner: s, calls: make(map[int]*partialCall)}
}
// next liest den nächsten SSE-Frame und liefert die daraus resultierenden
// Events. Ein Frame kann 0, 1 oder mehrere Events erzeugen (z.B. BlockStart
// + BlockDelta beim ersten Tool-Call-Fragment). more=false signalisiert das
// Ende des Streams (nach dem letzten Done-Event).
func (d *decoder) next() (events []llm.Event, more bool, err error) {
if d.done {
return nil, false, nil
}
for d.scanner.Scan() {
line := d.scanner.Text()
if line == "" || bytes.HasPrefix([]byte(line), []byte(":")) {
continue // Leerzeile oder SSE-Kommentar/Keep-Alive
}
data, ok := cutPrefix(line, "data: ")
if !ok {
data, ok = cutPrefix(line, "data:")
}
if !ok {
continue
}
data = trimSpace(data)
if data == "[DONE]" {
d.done = true
return d.finalEvents(), true, nil
}
var streamErr wireStreamError
if err := json.Unmarshal([]byte(data), &streamErr); err == nil && streamErr.Error != nil {
d.done = true
return nil, false, fmt.Errorf("stream error: %s (%s)", streamErr.Error.Message, streamErr.Error.Type)
}
var chunk wireChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
d.done = true
return nil, false, fmt.Errorf("decode chunk: %w", err)
}
return d.applyChunk(chunk), true, nil
}
if err := d.scanner.Err(); err != nil {
d.done = true
return nil, false, err
}
// Stream endete ohne explizites [DONE] (manche Server tun das) -> Finalize.
d.done = true
return d.finalEvents(), true, nil
}
func (d *decoder) applyChunk(chunk wireChunk) []llm.Event {
var events []llm.Event
if chunk.Usage != nil {
d.usage = *chunk.Usage
d.haveUsage = true
}
if len(chunk.Choices) == 0 {
return events
}
choice := chunk.Choices[0]
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
if !d.textOpen {
d.textIndex = d.nextIndex
d.nextIndex++
d.textOpen = true
events = append(events, llm.BlockStart{Index: d.textIndex, Block: llm.Block{Kind: llm.KindText}})
}
events = append(events, llm.BlockDelta{Index: d.textIndex, Text: *choice.Delta.Content})
}
if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" {
if !d.thinkOpen {
d.thinkIndex = d.nextIndex
d.nextIndex++
d.thinkOpen = true
events = append(events, llm.BlockStart{Index: d.thinkIndex, Block: llm.Block{Kind: llm.KindThinking}})
}
events = append(events, llm.BlockDelta{Index: d.thinkIndex, Text: *choice.Delta.ReasoningContent})
}
for _, tc := range choice.Delta.ToolCalls {
pc, ok := d.calls[tc.Index]
if !ok {
pc = &partialCall{}
d.calls[tc.Index] = pc
}
if tc.ID != nil {
pc.id = *tc.ID
}
if tc.Function != nil && tc.Function.Name != nil {
pc.name += *tc.Function.Name
}
if !pc.started && pc.id != "" && pc.name != "" {
pc.started = true
pc.ourIndex = d.nextIndex
d.nextIndex++
events = append(events, llm.BlockStart{
Index: pc.ourIndex,
Block: llm.Block{Kind: llm.KindToolUse, ID: pc.id, Name: pc.name},
})
}
if tc.Function != nil && tc.Function.Arguments != nil && pc.started {
events = append(events, llm.BlockDelta{Index: pc.ourIndex, PartialJSON: *tc.Function.Arguments})
}
}
if choice.FinishReason != nil {
d.finishStop = *choice.FinishReason
}
return events
}
// finalEvents schließt alle offenen Blöcke und liefert das abschließende
// Done-Event.
func (d *decoder) finalEvents() []llm.Event {
var events []llm.Event
if d.textOpen {
events = append(events, llm.BlockStop{Index: d.textIndex})
d.textOpen = false
}
if d.thinkOpen {
events = append(events, llm.BlockStop{Index: d.thinkIndex})
d.thinkOpen = false
}
for _, pc := range d.calls {
if pc.started {
events = append(events, llm.BlockStop{Index: pc.ourIndex})
}
}
stop := mapStopReason(d.finishStop)
usage := llm.Usage{}
if d.haveUsage {
usage.InputTokens = d.usage.PromptTokens
usage.OutputTokens = d.usage.CompletionTokens
if d.usage.PromptTokensDetails != nil {
usage.CacheReadTokens = d.usage.PromptTokensDetails.CachedTokens
}
}
events = append(events, llm.Done{Stop: stop, Usage: usage})
return events
}
func mapStopReason(finish string) llm.StopReason {
switch finish {
case "tool_calls":
return llm.StopToolUse
case "length":
return llm.StopMaxTokens
default:
return llm.StopEnd
}
}
func cutPrefix(s, prefix string) (string, bool) {
if len(s) < len(prefix) || s[:len(prefix)] != prefix {
return "", false
}
return s[len(prefix):], true
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == '\r' || s[len(s)-1] == '\n') {
s = s[:len(s)-1]
}
return s
}

View file

@ -0,0 +1,132 @@
package openai
import (
"os"
"strings"
"testing"
"nub/internal/llm"
)
func TestDecoder_FragmentedToolCall(t *testing.T) {
f, err := os.Open("testdata/tool_call_fragmented.sse")
if err != nil {
t.Fatal(err)
}
defer f.Close()
dec := newDecoder(f)
var events []llm.Event
for {
evs, more, err := dec.next()
if err != nil {
t.Fatalf("decode: %v", err)
}
events = append(events, evs...)
if !more {
break
}
}
var (
gotTextStart bool
gotTextDelta string
gotToolStart *llm.BlockStart
toolArgs string
gotDone *llm.Done
)
for _, ev := range events {
switch e := ev.(type) {
case llm.BlockStart:
if e.Block.Kind == llm.KindText {
gotTextStart = true
}
if e.Block.Kind == llm.KindToolUse {
cp := e
gotToolStart = &cp
}
case llm.BlockDelta:
if gotToolStart != nil && e.Index == gotToolStart.Index {
toolArgs += e.PartialJSON
} else {
gotTextDelta += e.Text
}
case llm.Done:
cp := e
gotDone = &cp
}
}
if !gotTextStart {
t.Error("expected a text BlockStart")
}
if gotTextDelta != "Ich lese die Datei." {
t.Errorf("text delta = %q", gotTextDelta)
}
if gotToolStart == nil {
t.Fatal("expected a tool_use BlockStart")
}
if gotToolStart.Block.ID != "call_abc" || gotToolStart.Block.Name != "read" {
t.Errorf("tool start = %+v", gotToolStart.Block)
}
if toolArgs != `{"path":"a.go"}` {
t.Errorf("accumulated tool args = %q", toolArgs)
}
if gotDone == nil {
t.Fatal("expected a Done event")
}
if gotDone.Stop != llm.StopToolUse {
t.Errorf("stop reason = %q, want tool_use", gotDone.Stop)
}
if gotDone.Usage.InputTokens != 42 || gotDone.Usage.OutputTokens != 7 {
t.Errorf("usage = %+v", gotDone.Usage)
}
}
// TestDecoder_ParsesCachedTokens verifiziert usage.prompt_tokens_details.
// cached_tokens (M7: Grundlage für die Prompt-Cache-Verifikation) landet
// korrekt in llm.Usage.CacheReadTokens.
func TestDecoder_ParsesCachedTokens(t *testing.T) {
f, err := os.Open("testdata/cached_usage.sse")
if err != nil {
t.Fatal(err)
}
defer f.Close()
dec := newDecoder(f)
var gotDone *llm.Done
for {
evs, more, err := dec.next()
if err != nil {
t.Fatalf("decode: %v", err)
}
for _, ev := range evs {
if d, ok := ev.(llm.Done); ok {
cp := d
gotDone = &cp
}
}
if !more {
break
}
}
if gotDone == nil {
t.Fatal("expected a Done event")
}
if gotDone.Usage.InputTokens != 1200 || gotDone.Usage.OutputTokens != 5 {
t.Errorf("usage = %+v", gotDone.Usage)
}
if gotDone.Usage.CacheReadTokens != 896 {
t.Errorf("cache read tokens = %d, want 896", gotDone.Usage.CacheReadTokens)
}
}
func TestDecoder_StreamErrorEvent(t *testing.T) {
body := `data: {"error":{"message":"rate limited","type":"rate_limit_error"}}` + "\n\ndata: [DONE]\n"
dec := newDecoder(strings.NewReader(body))
_, _, err := dec.next()
if err == nil {
t.Fatal("expected error from stream error event")
}
}

View file

@ -0,0 +1,5 @@
data: {"choices":[{"delta":{"content":"Hallo!"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1200,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":896}}}
data: [DONE]

View file

@ -0,0 +1,11 @@
data: {"choices":[{"delta":{"content":"Ich lese die Datei."},"finish_reason":null}]}
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\""}}]},"finish_reason":null}]}
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"a.go\"}"}}]},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":42,"completion_tokens":7}}
data: [DONE]

110
internal/llm/openai/wire.go Normal file
View file

@ -0,0 +1,110 @@
// Package openai ist der OpenAI-kompatible Provider-Adapter. Provider-SDK-Typen
// und das Wire-Format bleiben strikt in diesem Paket (E-02).
package openai
import "encoding/json"
type wireRequest struct {
Model string `json:"model"`
Messages []wireMessage `json:"messages"`
Tools []wireToolDef `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
}
type streamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type wireMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content,omitempty"`
ToolCalls []wireToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type wireToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function wireToolCallFunc `json:"function"`
}
type wireToolCallFunc struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type wireToolDef struct {
Type string `json:"type"`
Function wireFunctionDef `json:"function"`
}
type wireFunctionDef struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
type wireContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *wireImageURL `json:"image_url,omitempty"`
}
type wireImageURL struct {
URL string `json:"url"`
}
// --- Stream chunk ---
type wireChunk struct {
Choices []wireChoice `json:"choices"`
Usage *wireUsage `json:"usage"`
}
type wireChoice struct {
Delta wireDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type wireDelta struct {
Content *string `json:"content"`
ReasoningContent *string `json:"reasoning_content"`
ToolCalls []wireToolCallDelta `json:"tool_calls"`
}
type wireToolCallDelta struct {
Index int `json:"index"`
ID *string `json:"id"`
Type *string `json:"type"`
Function *wireToolCallFuncDelta `json:"function"`
}
type wireToolCallFuncDelta struct {
Name *string `json:"name"`
Arguments *string `json:"arguments"`
}
type wireUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails *wireTokensDetails `json:"prompt_tokens_details"`
}
type wireTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
}
// wireStreamError deckt Endpoints ab, die Fehler als SSE-Event statt als
// HTTP-Fehler senden (5.1 Punkt 7).
type wireStreamError struct {
Error *wireErrorBody `json:"error"`
}
type wireErrorBody struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}

23
internal/llm/provider.go Normal file
View file

@ -0,0 +1,23 @@
package llm
import (
"context"
"iter"
)
type Provider interface {
Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error)
Caps() Caps
Name() string
}
type Caps struct {
ParallelToolCalls bool
UsageInStream bool
SystemRole string // "system" | "developer" | "" = eigenes Request-Feld
ExplicitCache bool
Reasoning bool
StrictSchemas bool
MaxContext int
SupportsImages bool
}

View file

@ -0,0 +1,90 @@
// Package registry löst Modell-Namen auf Caps-Defaults auf (E-10):
// "OpenAI-kompatibel" ist ein Sammelbegriff, Groq/Cerebras/vLLM/Ollama/
// LM Studio/OpenRouter verhalten sich unterschiedlich. Die Tabelle hier ist
// bewusst klein — unbekannte Modelle fallen auf einen konservativen Default,
// Config-Overrides (CapsOverride) korrigieren im Einzelfall.
package registry
import "nub/internal/llm"
var known = map[string]llm.Caps{
"gpt-4o": {
ParallelToolCalls: true, UsageInStream: true, SystemRole: "system",
MaxContext: 128000,
},
"gpt-4o-mini": {
ParallelToolCalls: true, UsageInStream: true, SystemRole: "system",
MaxContext: 128000,
},
"gpt-4.1": {
ParallelToolCalls: true, UsageInStream: true, SystemRole: "system",
MaxContext: 1000000,
},
"o1": {
ParallelToolCalls: false, UsageInStream: true, SystemRole: "developer",
Reasoning: true, MaxContext: 200000,
},
}
// DefaultCaps liefert die bekannten Caps für ein Modell, oder einen
// Fallback für unbekannte Modelle (z.B. neuere OpenAI-Modelle, die noch
// nicht in der Tabelle stehen, oder Drittanbieter/lokale Endpoints).
//
// UsageInStream im Fallback ist bewusst true: stream_options.include_usage
// ist ein Request-Flag, kein modellspezifisches Feature — praktisch jedes
// OpenAI-Modell (auch zukünftige) honoriert es. Ein Endpoint, der es nicht
// unterstützt, ist die Ausnahme, nicht die Regel, und lässt sich gezielt
// über [endpoint.caps] usage_in_stream = false abschalten.
func DefaultCaps(model string) llm.Caps {
if caps, ok := known[model]; ok {
return caps
}
return llm.Caps{
ParallelToolCalls: true,
UsageInStream: true,
SystemRole: "system",
MaxContext: 32000,
}
}
// CapsOverride überschreibt einzelne Caps-Felder aus der Endpoint-Config.
// Pointer-Felder unterscheiden "nicht gesetzt" von "explizit false".
type CapsOverride struct {
ParallelToolCalls *bool `toml:"parallel_tool_calls"`
UsageInStream *bool `toml:"usage_in_stream"`
SystemRole *string `toml:"system_role"`
ExplicitCache *bool `toml:"explicit_cache"`
Reasoning *bool `toml:"reasoning"`
StrictSchemas *bool `toml:"strict_schemas"`
MaxContext *int `toml:"max_context"`
SupportsImages *bool `toml:"supports_images"`
}
// Apply merged eine CapsOverride über eine Basis-Caps-Struktur.
func Apply(base llm.Caps, o CapsOverride) llm.Caps {
if o.ParallelToolCalls != nil {
base.ParallelToolCalls = *o.ParallelToolCalls
}
if o.UsageInStream != nil {
base.UsageInStream = *o.UsageInStream
}
if o.SystemRole != nil {
base.SystemRole = *o.SystemRole
}
if o.ExplicitCache != nil {
base.ExplicitCache = *o.ExplicitCache
}
if o.Reasoning != nil {
base.Reasoning = *o.Reasoning
}
if o.StrictSchemas != nil {
base.StrictSchemas = *o.StrictSchemas
}
if o.MaxContext != nil {
base.MaxContext = *o.MaxContext
}
if o.SupportsImages != nil {
base.SupportsImages = *o.SupportsImages
}
return base
}

View file

@ -0,0 +1,35 @@
package registry
import "testing"
// TestDefaultCaps_UnknownModelStillReportsUsage sichert einen konkreten Bug
// ab: für unbekannte Modelle (z.B. neue OpenAI-Modelle, die noch nicht in
// `known` stehen) darf UsageInStream nicht fälschlich auf false fallen —
// sonst bleiben Token-/Cache-Zahlen in der UI dauerhaft bei 0, obwohl der
// Endpoint stream_options.include_usage längst unterstützt.
func TestDefaultCaps_UnknownModelStillReportsUsage(t *testing.T) {
caps := DefaultCaps("gpt-5-nano") // absichtlich nicht in der known-Tabelle
if !caps.UsageInStream {
t.Error("unknown model should default to UsageInStream=true, not silently disable usage reporting")
}
}
func TestDefaultCaps_KnownModelUsesTable(t *testing.T) {
caps := DefaultCaps("gpt-4o")
if caps.MaxContext != 128000 {
t.Errorf("MaxContext = %d, want 128000 for a known model", caps.MaxContext)
}
}
func TestApply_OverridesOnlySetFields(t *testing.T) {
base := DefaultCaps("gpt-4o")
disabled := false
got := Apply(base, CapsOverride{UsageInStream: &disabled})
if got.UsageInStream {
t.Error("explicit override to false must take effect")
}
if got.MaxContext != base.MaxContext {
t.Error("fields not present in the override must stay untouched")
}
}

106
internal/mcpc/bridge.go Normal file
View file

@ -0,0 +1,106 @@
package mcpc
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"nub/internal/config"
"nub/internal/tool"
)
const toolCountWarnThreshold = 15
// bridgeTool bindet ein einzelnes MCP-Tool eines Servers als tool.Tool ein.
// ForModel sammelt die Textblöcke, ForUI trägt den strukturierten Content.
type bridgeTool struct {
server string
def *mcp.Tool
session *mcp.ClientSession
}
func (t *bridgeTool) Name() string { return t.server + "__" + t.def.Name }
func (t *bridgeTool) Description() string { return t.def.Description }
func (t *bridgeTool) Schema() json.RawMessage {
b, err := json.Marshal(t.def.InputSchema)
if err != nil {
return json.RawMessage(`{"type":"object"}`)
}
return b
}
func (t *bridgeTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var args map[string]any
if len(input) > 0 {
if err := json.Unmarshal(input, &args); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
}
res, err := t.session.CallTool(ctx, &mcp.CallToolParams{Name: t.def.Name, Arguments: args})
if err != nil {
// Server-Ausfall zur Laufzeit: IsError statt Panic/Loop-Abbruch (5.6).
return tool.Result{ForModel: fmt.Sprintf("mcp %s: %v", t.Name(), err), IsError: true}, nil
}
var textParts []string
for _, c := range res.Content {
if tc, ok := c.(*mcp.TextContent); ok {
textParts = append(textParts, tc.Text)
}
}
return tool.Result{
ForModel: strings.Join(textParts, "\n"),
ForUI: Result{Server: t.server, Content: res.Content},
IsError: res.IsError,
}, nil
}
// Result ist die strukturierte UI-Repräsentation eines MCP-Tool-Aufrufs.
type Result struct {
Server string
Content []mcp.Content
}
// RegisterTools listet die Tools eines verbundenen Servers, filtert nach der
// konfigurierten Allowlist (cfg.Tools) und registriert sie mit
// <server>__-Präfix. Ohne Allowlist werden alle Tools geladen, aber ab
// mehr als 15 Tools gewarnt (E-07).
func RegisterTools(ctx context.Context, reg *tool.Registry, srv *Server, cfg config.MCPServer) ([]string, error) {
var all []*mcp.Tool
for t, err := range srv.Session.Tools(ctx, nil) {
if err != nil {
return nil, err
}
all = append(all, t)
}
var warnings []string
allowed := all
if len(cfg.Tools) > 0 {
allowSet := make(map[string]bool, len(cfg.Tools))
for _, n := range cfg.Tools {
allowSet[n] = true
}
allowed = make([]*mcp.Tool, 0, len(all))
for _, t := range all {
if allowSet[t.Name] {
allowed = append(allowed, t)
}
}
} else if len(all) > toolCountWarnThreshold {
warnings = append(warnings, fmt.Sprintf(
"mcp server %q liefert %d Tools ohne Allowlist — 'tools = [...]' in der Config empfohlen",
cfg.Name, len(all)))
}
for _, t := range allowed {
reg.Register(&bridgeTool{server: cfg.Name, def: t, session: srv.Session})
}
return warnings, nil
}

View file

@ -0,0 +1,132 @@
package mcpc
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"nub/internal/config"
"nub/internal/tool"
)
type echoArgs struct {
Text string `json:"text"`
}
// startTestServer läuft komplett in-process über mcp.NewInMemoryTransports —
// ein echter MCP-Server/-Client-Roundtrip ohne Subprozess oder Netzwerk.
func startTestServer(t *testing.T, toolNames ...string) *mcp.ClientSession {
t.Helper()
if len(toolNames) == 0 {
toolNames = []string{"echo"}
}
server := mcp.NewServer(&mcp.Implementation{Name: "test-server"}, nil)
for _, name := range toolNames {
name := name
mcp.AddTool(server, &mcp.Tool{Name: name, Description: "echoes text"},
func(ctx context.Context, req *mcp.CallToolRequest, args echoArgs) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: name + ":" + args.Text}}}, nil, nil
})
}
serverTransport, clientTransport := mcp.NewInMemoryTransports()
ctx := context.Background()
go func() { _ = server.Run(ctx, serverTransport) }()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = session.Close() })
return session
}
func TestBridge_RegisterAndCallTool(t *testing.T) {
session := startTestServer(t, "echo")
srv := &Server{Name: "testsrv", Session: session}
reg := tool.NewRegistry()
warnings, err := RegisterTools(context.Background(), reg, srv, config.MCPServer{Name: "testsrv"})
if err != nil {
t.Fatal(err)
}
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
got, ok := reg.Get("testsrv__echo")
if !ok {
t.Fatal("expected tool testsrv__echo to be registered with server-prefixed name")
}
input, _ := json.Marshal(map[string]string{"text": "hi"})
res, err := got.Run(context.Background(), input, tool.Env{})
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error result: %s", res.ForModel)
}
if res.ForModel != "echo:hi" {
t.Errorf("ForModel = %q, want echo:hi", res.ForModel)
}
}
func TestBridge_AllowlistFiltersTools(t *testing.T) {
session := startTestServer(t, "echo", "danger")
srv := &Server{Name: "testsrv", Session: session}
reg := tool.NewRegistry()
_, err := RegisterTools(context.Background(), reg, srv, config.MCPServer{Name: "testsrv", Tools: []string{"echo"}})
if err != nil {
t.Fatal(err)
}
if _, ok := reg.Get("testsrv__echo"); !ok {
t.Error("expected allowed tool to be registered")
}
if _, ok := reg.Get("testsrv__danger"); ok {
t.Error("expected non-allowlisted tool to be filtered out")
}
}
func TestBridge_RuntimeFailureIsErrorNotPanic(t *testing.T) {
session := startTestServer(t, "echo")
srv := &Server{Name: "testsrv", Session: session}
reg := tool.NewRegistry()
if _, err := RegisterTools(context.Background(), reg, srv, config.MCPServer{Name: "testsrv"}); err != nil {
t.Fatal(err)
}
got, _ := reg.Get("testsrv__echo")
// Server-Ausfall simulieren: Session vor dem Aufruf schließen.
if err := session.Close(); err != nil {
t.Fatal(err)
}
input, _ := json.Marshal(map[string]string{"text": "hi"})
res, err := got.Run(context.Background(), input, tool.Env{})
if err != nil {
t.Fatalf("Run should not return a Go error on server failure, got: %v", err)
}
if !res.IsError {
t.Error("expected IsError=true when the underlying MCP session is closed")
}
}
func TestConnectAll_BrokenServerDoesNotBlockStart(t *testing.T) {
result := ConnectAll(context.Background(), []config.MCPServer{
{Name: "broken", Command: "this-binary-does-not-exist-xyz"},
})
if len(result.Servers) != 0 {
t.Errorf("expected no connected servers, got %d", len(result.Servers))
}
if len(result.Warnings) != 1 {
t.Fatalf("expected exactly one warning, got %v", result.Warnings)
}
}

90
internal/mcpc/connect.go Normal file
View file

@ -0,0 +1,90 @@
// Package mcpc ist der MCP-Client: Verbindungsaufbau, Bridge MCP-Tool ->
// tool.Tool (5.6).
package mcpc
import (
"context"
"fmt"
"os/exec"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"nub/internal/config"
)
const defaultConnectTimeout = 5 * time.Second
// Server ist eine erfolgreich verbundene MCP-Session.
type Server struct {
Name string
Session *mcp.ClientSession
}
type ConnectResult struct {
Servers []*Server
Warnings []string
}
// ConnectAll verbindet alle konfigurierten Server parallel, mit einem
// Timeout pro Server (Default 5s, E-06). Ein einzelner kaputter Server
// verhindert den Start nicht — er fällt raus, mit einer Warnung.
func ConnectAll(ctx context.Context, servers []config.MCPServer) ConnectResult {
type outcome struct {
srv *Server
warn string
}
ch := make(chan outcome, len(servers))
for _, cfg := range servers {
go func(cfg config.MCPServer) {
connCtx, cancel := context.WithTimeout(ctx, defaultConnectTimeout)
defer cancel()
session, err := connect(connCtx, cfg)
if err != nil {
ch <- outcome{warn: fmt.Sprintf("mcp server %q: %v — skipped", cfg.Name, err)}
return
}
ch <- outcome{srv: &Server{Name: cfg.Name, Session: session}}
}(cfg)
}
var result ConnectResult
for range servers {
o := <-ch
if o.srv != nil {
result.Servers = append(result.Servers, o.srv)
}
if o.warn != "" {
result.Warnings = append(result.Warnings, o.warn)
}
}
return result
}
func connect(ctx context.Context, cfg config.MCPServer) (*mcp.ClientSession, error) {
client := mcp.NewClient(&mcp.Implementation{Name: "nub", Version: "0.1.0"}, nil)
var transport mcp.Transport
switch {
case cfg.Command != "":
// Der Prozess läuft für die gesamte Session, nicht nur für den
// Verbindungsaufbau — deshalb kein an connCtx gebundenes CommandContext.
transport = &mcp.CommandTransport{Command: exec.Command(cfg.Command, cfg.Args...)}
case cfg.URL != "":
transport = &mcp.StreamableClientTransport{Endpoint: cfg.URL}
default:
return nil, fmt.Errorf("neither command nor url configured")
}
return client.Connect(ctx, transport, nil)
}
// CloseAll schließt alle Sessions; Fehler werden ignoriert (best effort beim
// Beenden).
func CloseAll(servers []*Server) {
for _, s := range servers {
_ = s.Session.Close()
}
}

37
internal/mcpc/profile.go Normal file
View file

@ -0,0 +1,37 @@
package mcpc
import (
"fmt"
"nub/internal/config"
)
// SelectServers filtert die konfigurierten MCP-Server nach einem Profil
// (4.7: `[profiles] review = ["github"]`). Ohne konfigurierte Profile
// werden alle Server verbunden. Ist profileName leer, wird "default"
// verwendet, falls definiert.
func SelectServers(servers []config.MCPServer, profiles map[string][]string, profileName string) ([]config.MCPServer, error) {
if len(profiles) == 0 {
return servers, nil
}
if profileName == "" {
profileName = "default"
}
names, ok := profiles[profileName]
if !ok {
return nil, fmt.Errorf("unknown profile %q", profileName)
}
allow := make(map[string]bool, len(names))
for _, n := range names {
allow[n] = true
}
var out []config.MCPServer
for _, s := range servers {
if allow[s.Name] {
out = append(out, s)
}
}
return out, nil
}

View file

@ -0,0 +1,53 @@
package mcpc
import (
"testing"
"nub/internal/config"
)
func TestSelectServers_NoProfilesConfigured_ReturnsAll(t *testing.T) {
servers := []config.MCPServer{{Name: "a"}, {Name: "b"}}
out, err := SelectServers(servers, nil, "")
if err != nil {
t.Fatal(err)
}
if len(out) != 2 {
t.Errorf("got %d servers, want 2 (no filtering without profiles)", len(out))
}
}
func TestSelectServers_FiltersToNamedProfile(t *testing.T) {
servers := []config.MCPServer{{Name: "github"}, {Name: "linear"}}
profiles := map[string][]string{"review": {"github"}, "default": {}}
out, err := SelectServers(servers, profiles, "review")
if err != nil {
t.Fatal(err)
}
if len(out) != 1 || out[0].Name != "github" {
t.Errorf("got %+v, want only github", out)
}
}
func TestSelectServers_DefaultsToDefaultProfile(t *testing.T) {
servers := []config.MCPServer{{Name: "github"}}
profiles := map[string][]string{"default": {}}
out, err := SelectServers(servers, profiles, "")
if err != nil {
t.Fatal(err)
}
if len(out) != 0 {
t.Errorf("got %+v, want empty (default profile has no servers)", out)
}
}
func TestSelectServers_UnknownProfileErrors(t *testing.T) {
servers := []config.MCPServer{{Name: "github"}}
profiles := map[string][]string{"default": {}}
if _, err := SelectServers(servers, profiles, "does-not-exist"); err == nil {
t.Fatal("expected error for unknown profile")
}
}

View file

@ -0,0 +1,104 @@
// Package permission entscheidet pro Tool-Aufruf, ob er automatisch laufen
// darf, eine Rückfrage braucht, oder abgelehnt wird (E-11). Bewusst
// config-unabhängig (nur Strings/Maps) — der Aufrufer übersetzt
// config.PermissionsConfig in eine Policy, damit dieses Paket ohne
// Config-Kopplung testbar bleibt.
package permission
import (
"encoding/json"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
type Mode string
const (
ModeAuto Mode = "auto"
ModeAsk Mode = "ask"
ModeDeny Mode = "deny"
)
// Policy ist die ausgewertete Permissions-Config für eine Session.
type Policy struct {
// Modes bildet Tool-Namen auf einen Modus ab. Tools ohne Eintrag (z.B.
// MCP-Tools, todo, read_skill) gelten als ModeAuto.
Modes map[string]Mode
// DenyPaths/DenyBash sind zusätzliche Sperren, die "auto" und "ask"
// immer überstimmen — eine explizite Deny-Regel gewinnt.
DenyPaths []string
DenyBash []string
}
// ModeFor liefert den konfigurierten Modus für ein Tool, ohne Pfad-/Bash-
// Denylist zu prüfen. Unbekannte Tools und ein nil-Policy ergeben ModeAuto.
func (p *Policy) ModeFor(toolName string) Mode {
if p == nil {
return ModeAuto
}
if m, ok := p.Modes[toolName]; ok && m != "" {
return m
}
return ModeAuto
}
// Check entscheidet den tatsächlichen Modus für einen konkreten Aufruf
// inklusive Pfad-/Bash-Denylist. Eine Deny-Regel überstimmt immer, auch
// wenn das Tool selbst auf "auto" oder "ask" steht.
func (p *Policy) Check(toolName string, input json.RawMessage) Mode {
mode := p.ModeFor(toolName)
if p == nil {
return mode
}
if path := extractPath(input); path != "" && matchesAny(p.DenyPaths, path) {
return ModeDeny
}
if toolName == "bash" {
if cmd := extractCommand(input); cmd != "" && matchesAny(p.DenyBash, cmd) {
return ModeDeny
}
}
return mode
}
func extractPath(input json.RawMessage) string {
var v struct {
Path string `json:"path"`
}
if err := json.Unmarshal(input, &v); err != nil {
return ""
}
return v.Path
}
func extractCommand(input json.RawMessage) string {
var v struct {
Command string `json:"command"`
}
if err := json.Unmarshal(input, &v); err != nil {
return ""
}
return v.Command
}
// matchesAny prüft ein Glob-Match (deny_paths: ".git/**", "**/id_rsa*") und
// zusätzlich ein Substring-Match, weil deny_bash-Beispiele im Konzept
// literale Fragmente ohne Wildcard sind ("rm -rf /") — ein reines
// Vollstring-Glob-Match würde "cd /tmp && rm -rf /" nicht fangen. Für eine
// Denylist ist Über-Treffen die sicherere Richtung als Unter-Treffen.
func matchesAny(patterns []string, s string) bool {
for _, p := range patterns {
if p == "" {
continue
}
if ok, _ := doublestar.Match(p, s); ok {
return true
}
if strings.Contains(s, p) {
return true
}
}
return false
}

View file

@ -0,0 +1,102 @@
package permission
import (
"encoding/json"
"testing"
)
func input(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
func TestModeFor_UnknownToolDefaultsToAuto(t *testing.T) {
p := &Policy{Modes: map[string]Mode{"edit": ModeAsk}}
if got := p.ModeFor("github__create_issue"); got != ModeAuto {
t.Errorf("ModeFor(unknown) = %q, want auto", got)
}
}
func TestModeFor_NilPolicyDefaultsToAuto(t *testing.T) {
var p *Policy
if got := p.ModeFor("edit"); got != ModeAuto {
t.Errorf("ModeFor on nil policy = %q, want auto", got)
}
}
func TestModeFor_UsesConfiguredMode(t *testing.T) {
p := &Policy{Modes: map[string]Mode{"edit": ModeAsk, "bash": ModeDeny}}
if got := p.ModeFor("edit"); got != ModeAsk {
t.Errorf("edit = %q, want ask", got)
}
if got := p.ModeFor("bash"); got != ModeDeny {
t.Errorf("bash = %q, want deny", got)
}
}
func TestCheck_DenyPathOverridesAutoMode(t *testing.T) {
p := &Policy{
Modes: map[string]Mode{"edit": ModeAuto},
DenyPaths: []string{".git/**", "**/.env", "**/id_rsa*"},
}
cases := []struct {
path string
want Mode
}{
{".git/config", ModeDeny},
{".env", ModeDeny},
{"sub/.env", ModeDeny},
{"id_rsa", ModeDeny},
{"~/.ssh/id_rsa_backup", ModeDeny}, // "**/id_rsa*" matcht auch mit Verzeichnis-Präfix
{"main.go", ModeAuto},
}
for _, c := range cases {
got := p.Check("edit", input(t, map[string]string{"path": c.path}))
if got != c.want {
t.Errorf("Check(edit, path=%q) = %q, want %q", c.path, got, c.want)
}
}
}
func TestCheck_DenyBashOverridesAutoMode(t *testing.T) {
p := &Policy{
Modes: map[string]Mode{"bash": ModeAuto},
DenyBash: []string{"rm -rf /", "git push --force*"},
}
cases := []struct {
cmd string
want Mode
}{
{"rm -rf /", ModeDeny},
{"cd /tmp && rm -rf / --no-preserve-root", ModeDeny},
{"git push --force origin main", ModeDeny},
{"git status", ModeAuto},
}
for _, c := range cases {
got := p.Check("bash", input(t, map[string]string{"command": c.cmd}))
if got != c.want {
t.Errorf("Check(bash, command=%q) = %q, want %q", c.cmd, got, c.want)
}
}
}
func TestCheck_NonPathToolIgnoresDenyPaths(t *testing.T) {
p := &Policy{DenyPaths: []string{"**/.env"}}
// todo hat kein "path"-Feld -> DenyPaths darf nicht versehentlich greifen.
got := p.Check("todo", input(t, map[string]any{"items": []any{}}))
if got != ModeAuto {
t.Errorf("Check(todo) = %q, want auto (no path field to match against)", got)
}
}
func TestCheck_AskModePreservedWithoutDenyMatch(t *testing.T) {
p := &Policy{Modes: map[string]Mode{"edit": ModeAsk}}
got := p.Check("edit", input(t, map[string]string{"path": "main.go"}))
if got != ModeAsk {
t.Errorf("Check(edit) = %q, want ask", got)
}
}

133
internal/scaffold/init.go Normal file
View file

@ -0,0 +1,133 @@
// Package scaffold legt die Grundausstattung für ein Projekt an
// (`nub init` / `/init`): .nub/config.toml mit einer kommentierten
// Beispiel-Config und ein minimalistisches AGENTS.md. Von TUI und CLI
// gemeinsam genutzt.
package scaffold
import (
"fmt"
"os"
"path/filepath"
"nub/internal/session"
)
// Result protokolliert, was Init tatsächlich angelegt bzw. übersprungen hat.
type Result struct {
ConfigPath string
ConfigCreated bool
AgentsPath string
AgentsCreated bool
}
// Init legt .nub/config.toml und AGENTS.md an, falls sie noch nicht
// existieren. Bestehende Dateien werden nie überschrieben — Init meldet in
// Result nur, was es tatsächlich geschrieben hat.
func Init(repoRoot string) (Result, error) {
var res Result
nubDir := filepath.Join(repoRoot, ".nub")
if err := os.MkdirAll(nubDir, 0o700); err != nil {
return res, fmt.Errorf("scaffold: .nub anlegen: %w", err)
}
if err := session.EnsureGitExclude(repoRoot); err != nil {
return res, fmt.Errorf("scaffold: git exclude: %w", err)
}
res.ConfigPath = filepath.Join(nubDir, "config.toml")
created, err := writeIfAbsent(res.ConfigPath, exampleConfigTOML, 0o600)
if err != nil {
return res, fmt.Errorf("scaffold: config.toml: %w", err)
}
res.ConfigCreated = created
res.AgentsPath = filepath.Join(repoRoot, "AGENTS.md")
created, err = writeIfAbsent(res.AgentsPath, exampleAgentsMD, 0o644)
if err != nil {
return res, fmt.Errorf("scaffold: AGENTS.md: %w", err)
}
res.AgentsCreated = created
return res, nil
}
func writeIfAbsent(path, content string, perm os.FileMode) (bool, error) {
if _, err := os.Stat(path); err == nil {
return false, nil // existiert schon -> nicht anfassen
} else if !os.IsNotExist(err) {
return false, err
}
if err := os.WriteFile(path, []byte(content), perm); err != nil {
return false, err
}
return true, nil
}
const exampleConfigTOML = `version = 1
[model]
default = "gpt-4o-mini"
endpoint = "openai"
[[endpoint]]
name = "openai"
base_url = "https://api.openai.com/v1"
# "env:VARNAME" liest den Key aus einer Umgebungsvariable; alternativ direkt
# den Key eintragen, oder "none" für einen lokalen Endpoint ohne Key.
api_key = "env:OPENAI_API_KEY"
# [[endpoint]]
# name = "local"
# base_url = "http://localhost:11434/v1"
# api_key = "none"
# [endpoint.caps]
# parallel_tool_calls = false
[context]
files = ["AGENTS.md", "REPOMAP.md"]
walk_up = true
max_tokens = 20000
[skills]
paths = ["~/.nub/skills", ".nub/skills"]
# Pro Tool: "auto" (ohne Rückfrage), "ask" (Rückfrage in der TUI, im
# Print-Modus ein harter Fehler) oder "deny" (nie). deny_paths/deny_bash
# überstimmen den Modus immer, auch bei "auto".
[permissions]
read = "auto"
glob = "auto"
grep = "auto"
write = "ask"
edit = "ask"
bash = "ask"
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
deny_bash = ["rm -rf /", "git push --force*"]
# [[mcp]]
# name = "github"
# command = "gh-mcp-server"
# tools = ["create_issue", "get_pull_request"]
# [profiles]
# review = ["github"]
# default = []
`
const exampleAgentsMD = `# Agent Instructions
Kurze, projektspezifische Hinweise für nub. Diese Datei landet automatisch
im System-Prompt jeder Session.
## Projekt
<Was macht dieses Projekt, in ein bis zwei Sätzen?>
## Konventionen
- <z.B. Codestil, Teststrategie, Verzeichnisstruktur>
## Vorsicht bei
- <z.B. generierte Dateien, sensible Verzeichnisse, kritische Module>
`

View file

@ -0,0 +1,130 @@
package scaffold
import (
"os"
"path/filepath"
"testing"
"nub/internal/config"
)
func TestInit_CreatesConfigAndAgentsFile(t *testing.T) {
dir := t.TempDir()
res, err := Init(dir)
if err != nil {
t.Fatal(err)
}
if !res.ConfigCreated {
t.Error("expected config.toml to be created")
}
if !res.AgentsCreated {
t.Error("expected AGENTS.md to be created")
}
if _, err := os.Stat(filepath.Join(dir, ".nub", "config.toml")); err != nil {
t.Errorf(".nub/config.toml missing: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil {
t.Errorf("AGENTS.md missing: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, ".nub", "config.toml"))
if err != nil {
t.Fatal(err)
}
if !contains(string(content), "[permissions]") {
t.Error("expected the example config to include a [permissions] section")
}
if !contains(string(content), "version = 1") {
t.Error("expected the example config to declare version = 1")
}
}
func TestInit_DoesNotOverwriteExistingFiles(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".nub"), 0o700); err != nil {
t.Fatal(err)
}
customConfig := "version = 1\n# my custom config, do not touch\n"
if err := os.WriteFile(filepath.Join(dir, ".nub", "config.toml"), []byte(customConfig), 0o600); err != nil {
t.Fatal(err)
}
customAgents := "# my custom AGENTS.md\n"
if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(customAgents), 0o644); err != nil {
t.Fatal(err)
}
res, err := Init(dir)
if err != nil {
t.Fatal(err)
}
if res.ConfigCreated {
t.Error("expected an existing config.toml not to be reported as created")
}
if res.AgentsCreated {
t.Error("expected an existing AGENTS.md not to be reported as created")
}
gotConfig, _ := os.ReadFile(filepath.Join(dir, ".nub", "config.toml"))
if string(gotConfig) != customConfig {
t.Error("existing config.toml must not be overwritten")
}
gotAgents, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if string(gotAgents) != customAgents {
t.Error("existing AGENTS.md must not be overwritten")
}
}
func TestInit_GeneratedConfigParsesAndRoundTripsPermissions(t *testing.T) {
dir := t.TempDir()
t.Setenv("NUB_MODEL", "")
t.Setenv("NUB_BASE_URL", "")
t.Setenv("NUB_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "dummy-for-this-test")
if _, err := Init(dir); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("generated config.toml failed to parse: %v", err)
}
if cfg.Permissions.Write != "ask" || cfg.Permissions.Edit != "ask" || cfg.Permissions.Bash != "ask" {
t.Errorf("expected write/edit/bash = ask in the generated config, got %+v", cfg.Permissions)
}
if len(cfg.Permissions.DenyPaths) == 0 {
t.Error("expected deny_paths to be populated in the generated config")
}
}
func TestInit_SetsUpGitExclude(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".git", "info"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := Init(dir); err != nil {
t.Fatal(err)
}
excludeContent, err := os.ReadFile(filepath.Join(dir, ".git", "info", "exclude"))
if err != nil {
t.Fatal(err)
}
if !contains(string(excludeContent), ".nub/") {
t.Error("expected .nub/ to be added to .git/info/exclude")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}

View file

@ -0,0 +1,41 @@
package session
import (
"bytes"
"os"
"path/filepath"
)
// EnsureGitExclude trägt .nub/ in .git/info/exclude ein, nicht in die
// .gitignore (4.5) — die gehört dem Repo, nicht dem Tool. Kein Git-Repo:
// no-op.
func EnsureGitExclude(repoRoot string) error {
gitDir := filepath.Join(repoRoot, ".git")
info, err := os.Stat(gitDir)
if err != nil || !info.IsDir() {
return nil
}
excludePath := filepath.Join(gitDir, "info", "exclude")
data, _ := os.ReadFile(excludePath)
if bytes.Contains(data, []byte(".nub/")) {
return nil
}
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
return err
}
f, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()
if len(data) > 0 && data[len(data)-1] != '\n' {
if _, err := f.WriteString("\n"); err != nil {
return err
}
}
_, err = f.WriteString(".nub/\n")
return err
}

67
internal/session/node.go Normal file
View file

@ -0,0 +1,67 @@
// Package session enthält das Baum-Modell der Konversation (E-03) und dessen
// Persistenz als Append-only JSONL (Abschnitt 4.5).
package session
import (
"time"
"github.com/oklog/ulid/v2"
"nub/internal/llm"
)
type NodeKind string
const (
NodeMessage NodeKind = "message"
NodeSummary NodeKind = "summary"
)
type Node struct {
ID string `json:"id"`
ParentID string `json:"parent_id"` // "" = Wurzel
Kind NodeKind `json:"kind"`
Message llm.Message `json:"message"`
Replaces []string `json:"replaces,omitempty"` // nur bei NodeSummary
Meta NodeMeta `json:"meta"`
}
type NodeMeta struct {
Model string `json:"model,omitempty"`
Usage llm.Usage `json:"usage"`
CreatedAt time.Time `json:"created_at"`
Label string `json:"label,omitempty"`
Bookmark bool `json:"bookmark,omitempty"`
}
// NewNode erzeugt einen neuen Message-Node mit frischer ULID. parentID ist
// typischerweise der aktuelle Head.
func NewNode(parentID string, msg llm.Message, meta NodeMeta) *Node {
if meta.CreatedAt.IsZero() {
meta.CreatedAt = time.Now()
}
return &Node{
ID: ulid.Make().String(),
ParentID: parentID,
Kind: NodeMessage,
Message: msg,
Meta: meta,
}
}
// NewSummaryNode erzeugt einen Summary-Node, der die Nodes in replaces aus
// dem aktiven Kontext ersetzt (E-08). Die ersetzten Nodes bleiben im Baum
// erhalten — kein destruktives Löschen, Rewind auf sie funktioniert weiter.
func NewSummaryNode(parentID, text string, replaces []string, meta NodeMeta) *Node {
if meta.CreatedAt.IsZero() {
meta.CreatedAt = time.Now()
}
return &Node{
ID: ulid.Make().String(),
ParentID: parentID,
Kind: NodeSummary,
Message: llm.Message{Role: llm.RoleUser, Content: []llm.Block{{Kind: llm.KindText, Text: text}}},
Replaces: replaces,
Meta: meta,
}
}

View file

@ -0,0 +1,92 @@
package session
import (
"fmt"
"nub/internal/llm"
)
// Session ist der reine In-Memory-Baum, ab Tag 1 (E-03): jeder Zugriff auf
// "die Historie" geht über PathToHead(), nie über einen Slice.
type Session struct {
ID string
Nodes map[string]*Node
Head string
}
func New(id string) *Session {
return &Session{ID: id, Nodes: make(map[string]*Node)}
}
// HeadID gibt den aktuellen Head zurück (Methode statt nur Feld, damit
// Session und Store gemeinsam gegen ein schmales Interface programmierbar
// sind, siehe internal/agent.Sessioner).
func (s *Session) HeadID() string { return s.Head }
// Append hängt einen Node an. ParentID muss "" (Wurzel, nur beim allerersten
// Node zulässig) oder ein existierender Node sein. Setzt Head auf den neuen
// Node.
func (s *Session) Append(n *Node) error {
if n.ParentID != "" {
if _, ok := s.Nodes[n.ParentID]; !ok {
return fmt.Errorf("session: unknown parent %q", n.ParentID)
}
}
if _, exists := s.Nodes[n.ID]; exists {
return fmt.Errorf("session: node %q already exists", n.ID)
}
s.Nodes[n.ID] = n
s.Head = n.ID
return nil
}
// Branch setzt Head auf einen früheren Knoten. Weiterarbeit danach hängt
// neue Nodes an diesem Knoten an — ein zweiter Ast entsteht implizit, weil
// mehrere Nodes denselben ParentID referenzieren können.
func (s *Session) Branch(from string) error {
if from == "" {
s.Head = ""
return nil
}
if _, ok := s.Nodes[from]; !ok {
return fmt.Errorf("session: unknown node %q", from)
}
s.Head = from
return nil
}
// PathNodes läuft von Head Richtung Wurzel und stoppt am ersten Summary-Node
// (E-08); das Ergebnis ist in Wurzel→Head-Reihenfolge. Anders als
// PathToHead liefert es die Nodes selbst — Grundlage für Compaction, die
// Node-IDs für Replaces braucht.
func (s *Session) PathNodes() []*Node {
var nodes []*Node
id := s.Head
for id != "" {
n, ok := s.Nodes[id]
if !ok {
break
}
nodes = append(nodes, n)
if n.Kind == NodeSummary {
break
}
id = n.ParentID
}
for i, j := 0, len(nodes)-1; i < j; i, j = i+1, j-1 {
nodes[i], nodes[j] = nodes[j], nodes[i]
}
return nodes
}
// PathToHead läuft von Head Richtung Wurzel und stoppt am ersten
// Summary-Node (E-08); das Ergebnis ist in Wurzel→Head-Reihenfolge.
func (s *Session) PathToHead() []llm.Message {
nodes := s.PathNodes()
messages := make([]llm.Message, len(nodes))
for i, n := range nodes {
messages[i] = n.Message
}
return messages
}

View file

@ -0,0 +1,106 @@
package session
import (
"testing"
"nub/internal/llm"
)
func textMsg(role llm.Role, text string) llm.Message {
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
}
func TestSession_PathToHead_OrdersRootToHead(t *testing.T) {
s := New("s1")
n1 := NewNode("", textMsg(llm.RoleUser, "one"), NodeMeta{})
must(t, s.Append(n1))
n2 := NewNode(n1.ID, textMsg(llm.RoleAssistant, "two"), NodeMeta{})
must(t, s.Append(n2))
n3 := NewNode(n2.ID, textMsg(llm.RoleUser, "three"), NodeMeta{})
must(t, s.Append(n3))
msgs := s.PathToHead()
if len(msgs) != 3 {
t.Fatalf("len = %d, want 3", len(msgs))
}
want := []string{"one", "two", "three"}
for i, w := range want {
if msgs[i].Content[0].Text != w {
t.Errorf("msgs[%d] = %q, want %q", i, msgs[i].Content[0].Text, w)
}
}
}
func TestSession_PathToHead_StopsAtSummary(t *testing.T) {
s := New("s1")
n1 := NewNode("", textMsg(llm.RoleUser, "old-1"), NodeMeta{})
must(t, s.Append(n1))
n2 := NewNode(n1.ID, textMsg(llm.RoleAssistant, "old-2"), NodeMeta{})
must(t, s.Append(n2))
summary := &Node{
ID: "summary-1",
ParentID: n2.ID,
Kind: NodeSummary,
Message: textMsg(llm.RoleUser, "SUMMARY of old-1/old-2"),
Replaces: []string{n1.ID, n2.ID},
}
must(t, s.Append(summary))
n3 := NewNode(summary.ID, textMsg(llm.RoleUser, "new"), NodeMeta{})
must(t, s.Append(n3))
msgs := s.PathToHead()
if len(msgs) != 2 {
t.Fatalf("len = %d, want 2 (summary + new)", len(msgs))
}
if msgs[0].Content[0].Text != "SUMMARY of old-1/old-2" {
t.Errorf("msgs[0] = %q", msgs[0].Content[0].Text)
}
if msgs[1].Content[0].Text != "new" {
t.Errorf("msgs[1] = %q", msgs[1].Content[0].Text)
}
}
func TestSession_Branch_CreatesSecondPath(t *testing.T) {
s := New("s1")
n1 := NewNode("", textMsg(llm.RoleUser, "root"), NodeMeta{})
must(t, s.Append(n1))
n2a := NewNode(n1.ID, textMsg(llm.RoleAssistant, "branch-a"), NodeMeta{})
must(t, s.Append(n2a))
must(t, s.Branch(n1.ID))
n2b := NewNode(n1.ID, textMsg(llm.RoleAssistant, "branch-b"), NodeMeta{})
must(t, s.Append(n2b))
if s.Head != n2b.ID {
t.Fatalf("head = %s, want %s", s.Head, n2b.ID)
}
must(t, s.Branch(n2a.ID))
msgsA := s.PathToHead()
if msgsA[len(msgsA)-1].Content[0].Text != "branch-a" {
t.Errorf("branch a tip = %q", msgsA[len(msgsA)-1].Content[0].Text)
}
must(t, s.Branch(n2b.ID))
msgsB := s.PathToHead()
if msgsB[len(msgsB)-1].Content[0].Text != "branch-b" {
t.Errorf("branch b tip = %q", msgsB[len(msgsB)-1].Content[0].Text)
}
}
func TestSession_Append_RejectsUnknownParent(t *testing.T) {
s := New("s1")
n := NewNode("does-not-exist", textMsg(llm.RoleUser, "x"), NodeMeta{})
if err := s.Append(n); err == nil {
t.Fatal("expected error for unknown parent")
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}

230
internal/session/store.go Normal file
View file

@ -0,0 +1,230 @@
package session
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/oklog/ulid/v2"
"nub/internal/llm"
)
const (
dirPerm = 0o700
filePerm = 0o600
)
// record ist die JSONL-Zeile: entweder ein Node oder ein Head-Pointer.
// Jeder Record trägt "v":1 (4.5) — unbekannte höhere Version ist ein Fehler,
// kein stilles Teilparsen.
type record struct {
V int `json:"v"`
Node *Node `json:"node,omitempty"`
Head string `json:"head,omitempty"`
}
// Store bindet eine Session an ihre Append-only-JSONL-Datei unter
// .nub/sessions/<id>.jsonl.
type Store struct {
*Session
file *os.File
mu sync.Mutex
}
func sessionsDir(repoRoot string) string {
return filepath.Join(repoRoot, ".nub", "sessions")
}
// Create legt eine neue Session mit frischer ULID an und öffnet ihre Datei.
func Create(repoRoot string) (*Store, error) {
dir := sessionsDir(repoRoot)
if err := os.MkdirAll(dir, dirPerm); err != nil {
return nil, fmt.Errorf("create session dir: %w", err)
}
if err := EnsureGitExclude(repoRoot); err != nil {
return nil, fmt.Errorf("ensure git exclude: %w", err)
}
id := ulid.Make().String()
path := filepath.Join(dir, id+".jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_EXCL, filePerm)
if err != nil {
return nil, fmt.Errorf("create session file: %w", err)
}
return &Store{Session: New(id), file: f}, nil
}
// Load liest eine existierende Session vollständig und hält die Datei zum
// Weiterschreiben offen.
func Load(repoRoot, id string) (*Store, error) {
dir := sessionsDir(repoRoot)
path := filepath.Join(dir, id+".jsonl")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read session %s: %w", id, err)
}
sess := New(id)
for i, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var rec record
if err := json.Unmarshal(line, &rec); err != nil {
return nil, fmt.Errorf("session %s: corrupt record at line %d: %w", id, i+1, err)
}
if rec.V != 1 {
return nil, fmt.Errorf("session %s: unsupported record version %d at line %d", id, rec.V, i+1)
}
if rec.Node != nil {
sess.Nodes[rec.Node.ID] = rec.Node
}
if rec.Head != "" {
sess.Head = rec.Head
}
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, filePerm)
if err != nil {
return nil, fmt.Errorf("open session %s for append: %w", id, err)
}
return &Store{Session: sess, file: f}, nil
}
func (st *Store) Close() error {
return st.file.Close()
}
// Append hängt den Node an den In-Memory-Baum an und schreibt ihn plus den
// neuen Head-Pointer als JSONL-Zeilen.
func (st *Store) Append(n *Node) error {
st.mu.Lock()
defer st.mu.Unlock()
if err := st.Session.Append(n); err != nil {
return err
}
if err := st.writeRecord(record{V: 1, Node: n}); err != nil {
return err
}
return st.writeRecord(record{V: 1, Head: st.Head})
}
func (st *Store) writeRecord(rec record) error {
data, err := json.Marshal(rec)
if err != nil {
return err
}
data = append(data, '\n')
_, err = st.file.Write(data)
return err
}
// Delete entfernt eine Sessiondatei unwiderruflich. Der Aufrufer muss
// sicherstellen, dass es nicht die gerade offene/aktive Session ist.
func Delete(repoRoot, id string) error {
path := filepath.Join(sessionsDir(repoRoot), id+".jsonl")
if err := os.Remove(path); err != nil {
return fmt.Errorf("delete session %s: %w", id, err)
}
return nil
}
// Info ist die Kurzübersicht für `nub sessions`.
type Info struct {
ID string
Head string
NodeCount int
Created time.Time
Summary string
}
// List liest alle Sessions unter .nub/sessions und liefert ihre Kurzübersicht,
// neueste zuerst. Beschädigte Sessiondateien werden übersprungen statt den
// gesamten Aufruf scheitern zu lassen.
func List(repoRoot string) ([]Info, error) {
dir := sessionsDir(repoRoot)
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var infos []Info
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
id := strings.TrimSuffix(e.Name(), ".jsonl")
info, err := loadInfo(dir, id)
if err != nil {
continue
}
infos = append(infos, info)
}
sort.Slice(infos, func(i, j int) bool { return infos[i].Created.After(infos[j].Created) })
return infos, nil
}
func loadInfo(dir, id string) (Info, error) {
data, err := os.ReadFile(filepath.Join(dir, id+".jsonl"))
if err != nil {
return Info{}, err
}
info := Info{ID: id}
for _, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var rec record
if err := json.Unmarshal(line, &rec); err != nil {
return Info{}, err
}
if rec.V != 1 {
return Info{}, fmt.Errorf("unsupported record version %d", rec.V)
}
if rec.Node != nil {
info.NodeCount++
if info.Created.IsZero() || rec.Node.Meta.CreatedAt.Before(info.Created) {
info.Created = rec.Node.Meta.CreatedAt
}
if info.Summary == "" && rec.Node.Message.Role == llm.RoleUser {
info.Summary = firstText(rec.Node.Message.Content)
}
}
if rec.Head != "" {
info.Head = rec.Head
}
}
return info, nil
}
func firstText(blocks []llm.Block) string {
for _, b := range blocks {
if b.Kind == llm.KindText && b.Text != "" {
return truncate(b.Text, 80)
}
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

View file

@ -0,0 +1,169 @@
package session
import (
"os"
"path/filepath"
"runtime"
"testing"
"nub/internal/llm"
)
func TestStore_CreateAppendLoad_Roundtrip(t *testing.T) {
dir := t.TempDir()
st, err := Create(dir)
if err != nil {
t.Fatal(err)
}
n1 := NewNode("", textMsg(llm.RoleUser, "hello"), NodeMeta{Model: "test-model"})
must(t, st.Append(n1))
n2 := NewNode(n1.ID, textMsg(llm.RoleAssistant, "hi there"), NodeMeta{Model: "test-model"})
must(t, st.Append(n2))
id := st.ID
must(t, st.Close())
loaded, err := Load(dir, id)
if err != nil {
t.Fatal(err)
}
defer loaded.Close()
if loaded.Head != n2.ID {
t.Errorf("head = %s, want %s", loaded.Head, n2.ID)
}
msgs := loaded.PathToHead()
if len(msgs) != 2 || msgs[1].Content[0].Text != "hi there" {
t.Errorf("unexpected path: %+v", msgs)
}
}
// TestStore_RewindAndBranch_BothPathsRemainLoadable ist das M2-Fertig-
// Kriterium aus Abschnitt 6: Rewind auf einen früheren Knoten + Weiterarbeit
// erzeugt einen zweiten Ast, beide bleiben ladbar.
func TestStore_RewindAndBranch_BothPathsRemainLoadable(t *testing.T) {
dir := t.TempDir()
st, err := Create(dir)
if err != nil {
t.Fatal(err)
}
root := NewNode("", textMsg(llm.RoleUser, "root"), NodeMeta{})
must(t, st.Append(root))
tipA := NewNode(root.ID, textMsg(llm.RoleAssistant, "original continuation"), NodeMeta{})
must(t, st.Append(tipA))
id := st.ID
must(t, st.Close())
// Rewind: erneut laden, auf root zurückspringen, neu weiterarbeiten.
resumed, err := Load(dir, id)
if err != nil {
t.Fatal(err)
}
must(t, resumed.Branch(root.ID))
tipB := NewNode(root.ID, textMsg(llm.RoleAssistant, "rewound continuation"), NodeMeta{})
must(t, resumed.Append(tipB))
must(t, resumed.Close())
// Beide Äste müssen nach erneutem Laden noch da sein.
final, err := Load(dir, id)
if err != nil {
t.Fatal(err)
}
defer final.Close()
if _, ok := final.Nodes[tipA.ID]; !ok {
t.Error("original branch tip missing after reload")
}
if _, ok := final.Nodes[tipB.ID]; !ok {
t.Error("rewound branch tip missing after reload")
}
if final.Head != tipB.ID {
t.Errorf("head after reload = %s, want %s (latest branch)", final.Head, tipB.ID)
}
must(t, final.Branch(tipA.ID))
msgsA := final.PathToHead()
if got := msgsA[len(msgsA)-1].Content[0].Text; got != "original continuation" {
t.Errorf("branch A tip = %q", got)
}
must(t, final.Branch(tipB.ID))
msgsB := final.PathToHead()
if got := msgsB[len(msgsB)-1].Content[0].Text; got != "rewound continuation" {
t.Errorf("branch B tip = %q", got)
}
}
func TestStore_FilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix-Dateirechte, siehe Abschnitt 9 (kein Windows-Pfad)")
}
dir := t.TempDir()
st, err := Create(dir)
if err != nil {
t.Fatal(err)
}
defer st.Close()
dirInfo, err := os.Stat(filepath.Join(dir, ".nub", "sessions"))
if err != nil {
t.Fatal(err)
}
if perm := dirInfo.Mode().Perm(); perm != 0o700 {
t.Errorf(".nub/sessions perm = %o, want 0700", perm)
}
fileInfo, err := os.Stat(filepath.Join(dir, ".nub", "sessions", st.ID+".jsonl"))
if err != nil {
t.Fatal(err)
}
if perm := fileInfo.Mode().Perm(); perm != 0o600 {
t.Errorf("session file perm = %o, want 0600", perm)
}
}
func TestStore_Load_RejectsUnsupportedVersion(t *testing.T) {
dir := t.TempDir()
sessDir := filepath.Join(dir, ".nub", "sessions")
must(t, os.MkdirAll(sessDir, 0o700))
path := filepath.Join(sessDir, "bad.jsonl")
must(t, os.WriteFile(path, []byte(`{"v":2,"node":{"id":"x"}}`+"\n"), 0o600))
if _, err := Load(dir, "bad"); err == nil {
t.Fatal("expected error for unsupported record version")
}
}
func TestStore_List_ReturnsSessionsNewestFirst(t *testing.T) {
dir := t.TempDir()
st1, err := Create(dir)
if err != nil {
t.Fatal(err)
}
must(t, st1.Append(NewNode("", textMsg(llm.RoleUser, "first session"), NodeMeta{})))
must(t, st1.Close())
st2, err := Create(dir)
if err != nil {
t.Fatal(err)
}
must(t, st2.Append(NewNode("", textMsg(llm.RoleUser, "second session"), NodeMeta{})))
must(t, st2.Close())
infos, err := List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 2 {
t.Fatalf("len = %d, want 2", len(infos))
}
found := map[string]bool{}
for _, info := range infos {
found[info.Summary] = true
}
if !found["first session"] || !found["second session"] {
t.Errorf("infos = %+v", infos)
}
}

23
internal/skill/index.go Normal file
View file

@ -0,0 +1,23 @@
package skill
import "strings"
// RenderIndex baut den kompakten Index-Block (~30 Tokens/Skill): nur Name
// und Description, der Body kommt erst über read_skill(name).
func RenderIndex(skills []Skill) string {
if len(skills) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Verfügbare Skills (Body per read_skill(name) laden):\n")
for _, s := range skills {
b.WriteString("- ")
b.WriteString(s.Name)
if s.Description != "" {
b.WriteString(": ")
b.WriteString(s.Description)
}
b.WriteString("\n")
}
return b.String()
}

114
internal/skill/skill.go Normal file
View file

@ -0,0 +1,114 @@
// Package skill implementiert Skill-Discovery mit Progressive Disclosure
// (5.5): Beim Start werden alle Skills gefunden, aber nur Name+Description
// in den Prompt geschrieben. Der Body kommt erst über das read_skill-Tool.
package skill
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type Skill struct {
Name string
Description string
Dir string // Verzeichnis des Skills (für weitere Dateien, die das Modell per read findet)
Path string // absoluter Pfad zu SKILL.md
Body string // Inhalt nach dem Frontmatter
}
// Discover sammelt Skills aus mehreren Verzeichnissen (4.7: skills.paths).
// Jedes Unterverzeichnis mit einer SKILL.md ist ein Skill. Spätere Pfade
// gewinnen bei Namenskollisionen (projekt-lokal überschreibt global) —
// dasselbe Präzedenzprinzip wie bei der Config (4.7).
func Discover(paths []string) ([]Skill, error) {
byName := make(map[string]Skill)
var order []string
for _, root := range paths {
root = expandHome(root)
entries, err := os.ReadDir(root)
if err != nil {
continue // Pfad fehlt -> überspringen, kein Fehler
}
for _, e := range entries {
if !e.IsDir() {
continue
}
skillPath := filepath.Join(root, e.Name(), "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
continue
}
meta, body, err := parseFrontmatter(string(data))
if err != nil {
return nil, fmt.Errorf("skill %s: %w", skillPath, err)
}
name := meta["name"]
if name == "" {
name = e.Name()
}
if _, seen := byName[name]; !seen {
order = append(order, name)
}
byName[name] = Skill{
Name: name,
Description: meta["description"],
Dir: filepath.Join(root, e.Name()),
Path: skillPath,
Body: body,
}
}
}
out := make([]Skill, 0, len(order))
for _, name := range order {
out = append(out, byName[name])
}
return out, nil
}
// parseFrontmatter liest den Bereich zwischen den ersten beiden `---`-Zeilen
// als flache key: value-Paare. Kein echter YAML-Parser — für das dokumentierte
// Format (zwei flache String-Felder) reicht das, und es spart eine
// Dependency, die nicht in der Allowlist steht (2.3).
func parseFrontmatter(content string) (map[string]string, string, error) {
lines := strings.Split(content, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return nil, "", fmt.Errorf("missing frontmatter (expected leading ---)")
}
meta := make(map[string]string)
i := 1
for ; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "---" {
break
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
meta[key] = val
}
if i >= len(lines) {
return nil, "", fmt.Errorf("unterminated frontmatter (missing closing ---)")
}
body := strings.TrimLeft(strings.Join(lines[i+1:], "\n"), "\n")
return meta, body, nil
}
func expandHome(path string) string {
if !strings.HasPrefix(path, "~") {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, strings.TrimPrefix(path, "~"))
}

View file

@ -0,0 +1,101 @@
package skill
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeSkill(t *testing.T, root, dirName, frontmatter, body string) {
t.Helper()
dir := filepath.Join(root, dirName)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
content := "---\n" + frontmatter + "---\n\n" + body
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestDiscover_ParsesFrontmatterAndBody(t *testing.T) {
root := t.TempDir()
writeSkill(t, root, "refactoring",
"name: refactoring\ndescription: Vorgehen für größere Refactorings.\n",
"# Refactoring\n\nSchritt 1: Tests lesen.\n")
skills, err := Discover([]string{root})
if err != nil {
t.Fatal(err)
}
if len(skills) != 1 {
t.Fatalf("got %d skills, want 1", len(skills))
}
s := skills[0]
if s.Name != "refactoring" {
t.Errorf("name = %q", s.Name)
}
if s.Description != "Vorgehen für größere Refactorings." {
t.Errorf("description = %q", s.Description)
}
if !strings.Contains(s.Body, "Schritt 1: Tests lesen.") {
t.Errorf("body missing expected content: %q", s.Body)
}
if strings.Contains(s.Body, "---") {
t.Errorf("body should not contain frontmatter delimiters: %q", s.Body)
}
}
func TestDiscover_LaterPathOverridesEarlierByName(t *testing.T) {
global := t.TempDir()
project := t.TempDir()
writeSkill(t, global, "refactoring", "name: refactoring\ndescription: global version\n", "global body")
writeSkill(t, project, "refactoring", "name: refactoring\ndescription: project version\n", "project body")
skills, err := Discover([]string{global, project})
if err != nil {
t.Fatal(err)
}
if len(skills) != 1 {
t.Fatalf("got %d skills, want 1 (deduped by name)", len(skills))
}
if skills[0].Description != "project version" {
t.Errorf("description = %q, want project version to win (later path)", skills[0].Description)
}
}
func TestDiscover_MissingPathIsSkippedNotError(t *testing.T) {
skills, err := Discover([]string{"/does/not/exist/at/all"})
if err != nil {
t.Fatal(err)
}
if len(skills) != 0 {
t.Errorf("got %d skills, want 0", len(skills))
}
}
// TestRenderIndex_OmitsBody ist das M5-Fertig-Kriterium aus Abschnitt 6: ein
// Skill wird erst bei Bedarf geladen — der Index darf nur Name+Description
// enthalten, ein Token-Delta zum vollen Body muss messbar sein.
func TestRenderIndex_OmitsBody(t *testing.T) {
root := t.TempDir()
longBody := strings.Repeat("Dies ist eine lange Anleitung. ", 200) // ~1400 Zeichen
writeSkill(t, root, "refactoring", "name: refactoring\ndescription: kurze Beschreibung\n", longBody)
skills, err := Discover([]string{root})
if err != nil {
t.Fatal(err)
}
index := RenderIndex(skills)
if strings.Contains(index, longBody) {
t.Fatal("index must not contain the full skill body")
}
if !strings.Contains(index, "refactoring") || !strings.Contains(index, "kurze Beschreibung") {
t.Errorf("index missing name/description: %q", index)
}
if len(index) >= len(longBody) {
t.Errorf("index (%d bytes) should be far smaller than the body (%d bytes)", len(index), len(longBody))
}
}

29
internal/tokens/blocks.go Normal file
View file

@ -0,0 +1,29 @@
package tokens
import "nub/internal/llm"
// EstimateBlocks summiert die Schätzung über Text, Tool-Input und
// (rekursiv) Tool-Result-Blöcke.
func EstimateBlocks(blocks []llm.Block) int {
total := 0
for _, b := range blocks {
total += Estimate(b.Text)
if len(b.Input) > 0 {
total += Estimate(string(b.Input))
}
if len(b.Result) > 0 {
total += EstimateBlocks(b.Result)
}
}
return total
}
// EstimateMessages schätzt die Tokenkosten eines kompletten Requests
// (System-Blöcke + Message-Historie) — Grundlage für den Compaction-Trigger.
func EstimateMessages(system []llm.Block, messages []llm.Message) int {
total := EstimateBlocks(system)
for _, m := range messages {
total += EstimateBlocks(m.Content)
}
return total
}

View file

@ -0,0 +1,46 @@
package tokens
import "sync"
// Calibrator passt die Schätzung anhand echter usage-Werte aus der API-
// Antwort an (E-09). Ein laufender Korrekturfaktor pro Prozess; Persistenz
// über Sessions hinweg ist für v1 nicht vorgesehen — jeder Lauf kalibriert
// neu, was bei einem einzelnen Modell pro Session ausreicht.
type Calibrator struct {
mu sync.Mutex
factor float64
n int
}
func NewCalibrator() *Calibrator {
return &Calibrator{factor: 1.0}
}
// Observe nimmt einen (geschätzt, tatsächlich)-Datenpunkt auf und aktualisiert
// den Korrekturfaktor per exponentiell gleitendem Mittel.
func (c *Calibrator) Observe(estimated, actual int) {
if estimated <= 0 || actual <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
ratio := float64(actual) / float64(estimated)
const alpha = 0.2
if c.n == 0 {
c.factor = ratio
} else {
c.factor = c.factor*(1-alpha) + ratio*alpha
}
c.n++
}
// Adjust wendet den aktuellen Korrekturfaktor plus einen 15%-Sicherheits-
// abstand auf eine rohe Schätzung an (E-09: konservativ schätzen ist
// billiger als ein echter Tokenizer).
func (c *Calibrator) Adjust(estimate int) int {
c.mu.Lock()
factor := c.factor
c.mu.Unlock()
return int(float64(estimate) * factor * 1.15)
}

View file

@ -0,0 +1,35 @@
package tokens
import "testing"
func TestCalibrator_ConvergesTowardObservedRatio(t *testing.T) {
c := NewCalibrator()
for i := 0; i < 50; i++ {
c.Observe(100, 200) // Modell verbraucht durchweg doppelt so viele Tokens wie geschätzt
}
adjusted := c.Adjust(100)
// Nach Konvergenz: factor ~2.0, plus 15% Sicherheitsabstand -> ~230.
if adjusted < 220 || adjusted > 240 {
t.Errorf("adjusted = %d, want ~230 after convergence to ratio 2.0", adjusted)
}
}
func TestCalibrator_IgnoresZeroObservations(t *testing.T) {
c := NewCalibrator()
c.Observe(0, 100)
c.Observe(100, 0)
// Ohne gültige Beobachtung bleibt der Faktor bei 1.0 (+15% Sicherheitsabstand).
adjusted := c.Adjust(100)
if adjusted < 110 || adjusted > 120 {
t.Errorf("adjusted = %d, want ~115 (factor still 1.0)", adjusted)
}
}
func TestEstimate_NonEmpty(t *testing.T) {
if Estimate("") != 0 {
t.Error("empty string should estimate to 0 tokens")
}
if Estimate("hello world") <= 0 {
t.Error("non-empty text should estimate to > 0 tokens")
}
}

View file

@ -0,0 +1,32 @@
// Package tokens schätzt Tokenzahlen ohne echten Tokenizer (E-09).
// Kalibrierung gegen die tatsächliche usage aus jeder API-Antwort folgt in M4.
package tokens
import "unicode"
// Estimate schätzt die Tokenzahl eines Texts: len/4 für lateinischen Text,
// len/3 für überwiegend CJK-Text (dort ist ein Token meist ~1 Zeichen).
func Estimate(s string) int {
if s == "" {
return 0
}
runes := []rune(s)
cjk := 0
for _, r := range runes {
if isCJK(r) {
cjk++
}
}
n := len(runes)
if cjk*2 > n {
return n/3 + 1
}
return n/4 + 1
}
func isCJK(r rune) bool {
return unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Hangul, r)
}

View file

@ -0,0 +1,152 @@
package builtin
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"nub/internal/tool"
)
const (
defaultBashTimeout = 120 * time.Second
maxBashOutput = 30000
)
type BashTool struct{}
func (BashTool) Name() string { return "bash" }
func (BashTool) Description() string {
return "Führt einen Shell-Befehl aus. Timeout, Output wird bei ~30k Zeichen in der Mitte gekürzt."
}
func (BashTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"command": {"type": "string"},
"timeout_seconds": {"type": "integer", "description": "Default 120"}
},
"required": ["command"]
}`)
}
type bashInput struct {
Command string `json:"command"`
TimeoutSeconds int `json:"timeout_seconds"`
}
func (BashTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in bashInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
timeout := defaultBashTimeout
if in.TimeoutSeconds > 0 {
timeout = time.Duration(in.TimeoutSeconds) * time.Second
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, "/bin/sh", "-c", in.Command)
cmd.Dir = env.RepoRoot
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// CommandContext killt bei Timeout nur den direkten Prozess; wir killen
// stattdessen die ganze Prozessgruppe (siehe Cancel unten), damit keine
// Kindprozesse überleben.
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return tool.Result{ForModel: err.Error(), IsError: true}, nil
}
cmd.Stderr = cmd.Stdout
var buf strings.Builder
var mu sync.Mutex
truncated := false
if err := cmd.Start(); err != nil {
return tool.Result{ForModel: fmt.Sprintf("bash: %v", err), IsError: true}, nil
}
done := make(chan struct{})
go func() {
defer close(done)
reader := bufio.NewReader(stdout)
chunk := make([]byte, 4096)
for {
n, rerr := reader.Read(chunk)
if n > 0 {
text := string(chunk[:n])
mu.Lock()
buf.WriteString(text)
mu.Unlock()
if env.Emit != nil {
env.Emit(tool.ToolStream{Text: text})
}
}
if rerr == io.EOF {
return
}
if rerr != nil {
return
}
}
}()
<-done
waitErr := cmd.Wait()
mu.Lock()
out := buf.String()
mu.Unlock()
if len(out) > maxBashOutput {
half := maxBashOutput / 2
out = out[:half] + "\n... [truncated] ...\n" + out[len(out)-half:]
truncated = true
}
if runCtx.Err() == context.DeadlineExceeded {
return tool.Result{
ForModel: fmt.Sprintf("command timed out after %s\n%s", timeout, out),
IsError: true,
}, nil
}
exitCode := 0
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return tool.Result{ForModel: fmt.Sprintf("bash: %v", waitErr), IsError: true}, nil
}
}
result := tool.Result{
ForModel: out,
ForUI: BashResult{Command: in.Command, ExitCode: exitCode, Truncated: truncated},
IsError: exitCode != 0,
}
return result, nil
}
type BashResult struct {
Command string
ExitCode int
Truncated bool
}

View file

@ -0,0 +1,177 @@
package builtin
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"nub/internal/tool"
)
func testEnv(t *testing.T) tool.Env {
t.Helper()
dir := t.TempDir()
return tool.Env{Cwd: dir, RepoRoot: dir}
}
func writeFile(t *testing.T, env tool.Env, name, content string) string {
t.Helper()
path := filepath.Join(env.RepoRoot, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestEdit_AmbiguousMatchErrorsWithCount(t *testing.T) {
env := testEnv(t)
writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar"})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected IsError for ambiguous match")
}
if !contains(res.ForModel, "3 matches") {
t.Errorf("error message should mention match count, got: %q", res.ForModel)
}
}
func TestEdit_ReplaceAllReplacesEveryMatch(t *testing.T) {
env := testEnv(t)
path := writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar", "replace_all": true})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
data, _ := os.ReadFile(path)
if string(data) != "bar\nbar\nbar\n" {
t.Errorf("content = %q", data)
}
}
func TestEdit_UniqueMatchSucceeds(t *testing.T) {
env := testEnv(t)
writeFile(t, env, "f.go", "unique_marker\nother\n")
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "unique_marker", "new_string": "replaced"})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
}
func TestPath_EscapeViaDotDotIsRejected(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"path": "../outside.txt", "old_string": "a", "new_string": "b"})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected path escape to be rejected")
}
}
func TestPath_EscapeViaSymlinkIsRejected(t *testing.T) {
env := testEnv(t)
outsideDir := t.TempDir()
if err := os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(env.RepoRoot, "link")
if err := os.Symlink(outsideDir, link); err != nil {
t.Skipf("symlinks not supported: %v", err)
}
in, _ := json.Marshal(map[string]any{"path": "link/secret.txt"})
res, err := ReadTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected symlink escape to be rejected")
}
}
func TestBash_TimeoutKillsProcessGroup(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"command": "sleep 30", "timeout_seconds": 1})
start := time.Now()
res, err := BashTool{}.Run(context.Background(), in, env)
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected timeout to be reported as error")
}
if elapsed > 5*time.Second {
t.Errorf("timeout took too long: %s", elapsed)
}
}
func TestBash_CapturesStdoutAndExitCode(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"command": "echo hi && exit 3"})
res, err := BashTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected non-zero exit to be reported as error")
}
if !contains(res.ForModel, "hi") {
t.Errorf("expected stdout captured, got: %q", res.ForModel)
}
}
func TestGlob_RespectsGitignore(t *testing.T) {
env := testEnv(t)
writeFile(t, env, ".gitignore", "*.log\n")
writeFile(t, env, "keep.txt", "x")
writeFile(t, env, "skip.log", "x")
in, _ := json.Marshal(map[string]any{"pattern": "**/*"})
res, err := GlobTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if contains(res.ForModel, "skip.log") {
t.Errorf("gitignored file leaked into glob result: %q", res.ForModel)
}
if !contains(res.ForModel, "keep.txt") {
t.Errorf("expected keep.txt in result: %q", res.ForModel)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}

View file

@ -0,0 +1,77 @@
package builtin
import "strings"
type DiffOp string
const (
DiffEqual DiffOp = "equal"
DiffAdd DiffOp = "add"
DiffRemove DiffOp = "remove"
)
type DiffLine struct {
Op DiffOp
Text string
}
// DiffResult ist die strukturierte UI-Repräsentation für write/edit.
type DiffResult struct {
Path string
Lines []DiffLine
}
// lineDiff berechnet einen minimalen zeilenbasierten Diff via LCS.
// Für Datei-großen Input (M1) ausreichend; kein externer Diff-Algorithmus nötig.
func lineDiff(oldText, newText string) []DiffLine {
oldLines := splitLines(oldText)
newLines := splitLines(newText)
n, m := len(oldLines), len(newLines)
lcs := make([][]int, n+1)
for i := range lcs {
lcs[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if oldLines[i] == newLines[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var out []DiffLine
i, j := 0, 0
for i < n && j < m {
switch {
case oldLines[i] == newLines[j]:
out = append(out, DiffLine{DiffEqual, oldLines[i]})
i++
j++
case lcs[i+1][j] >= lcs[i][j+1]:
out = append(out, DiffLine{DiffRemove, oldLines[i]})
i++
default:
out = append(out, DiffLine{DiffAdd, newLines[j]})
j++
}
}
for ; i < n; i++ {
out = append(out, DiffLine{DiffRemove, oldLines[i]})
}
for ; j < m; j++ {
out = append(out, DiffLine{DiffAdd, newLines[j]})
}
return out
}
func splitLines(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, "\n")
}

View file

@ -0,0 +1,86 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"nub/internal/tool"
)
type EditTool struct{}
func (EditTool) Name() string { return "edit" }
func (EditTool) Description() string {
return "Ersetzt einen exakten String in einer Datei. Bei mehreren Treffern ohne replace_all: Fehler."
}
func (EditTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string"},
"old_string": {"type": "string"},
"new_string": {"type": "string"},
"replace_all": {"type": "boolean"}
},
"required": ["path", "old_string", "new_string"]
}`)
}
type editInput struct {
Path string `json:"path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all"`
}
func (EditTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in editInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
if in.OldString == in.NewString {
return tool.Result{ForModel: "old_string and new_string are identical", IsError: true}, nil
}
path, err := resolvePath(env.RepoRoot, env.Cwd, in.Path)
if err != nil {
return tool.Result{ForModel: err.Error(), IsError: true}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return tool.Result{ForModel: fmt.Sprintf("read %s: %v", in.Path, err), IsError: true}, nil
}
old := string(data)
count := strings.Count(old, in.OldString)
if count == 0 {
return tool.Result{ForModel: fmt.Sprintf("%s: old_string not found", in.Path), IsError: true}, nil
}
if count > 1 && !in.ReplaceAll {
return tool.Result{ForModel: fmt.Sprintf("%s: old_string is ambiguous (%d matches); use replace_all or a more specific old_string", in.Path, count), IsError: true}, nil
}
var next string
if in.ReplaceAll {
next = strings.ReplaceAll(old, in.OldString, in.NewString)
} else {
next = strings.Replace(old, in.OldString, in.NewString, 1)
}
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
return tool.Result{ForModel: fmt.Sprintf("write %s: %v", in.Path, err), IsError: true}, nil
}
replaced := 1
if in.ReplaceAll {
replaced = count
}
return tool.Result{
ForModel: fmt.Sprintf("%s: %d replacement(s)", in.Path, replaced),
ForUI: DiffResult{Path: in.Path, Lines: lineDiff(old, next)},
}, nil
}

View file

@ -0,0 +1,53 @@
package builtin
import (
"os"
"path/filepath"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
// gitignore ist eine einfache, ausreichende Umsetzung: liest .gitignore im
// RepoRoot (keine verschachtelten .gitignore-Dateien, keine Negationen) und
// matcht Zeilen als doublestar-Patterns gegen den repo-relativen Pfad.
type gitignore struct {
patterns []string
}
func loadGitignore(repoRoot string) gitignore {
g := gitignore{patterns: []string{".git/**"}}
data, err := os.ReadFile(filepath.Join(repoRoot, ".gitignore"))
if err != nil {
return g
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") {
continue
}
line = strings.TrimPrefix(line, "/")
if strings.HasSuffix(line, "/") {
line += "**"
}
g.patterns = append(g.patterns, line, line+"/**")
}
return g
}
func (g gitignore) match(relPath string) bool {
relPath = filepath.ToSlash(relPath)
for _, p := range g.patterns {
if ok, _ := doublestar.Match(p, relPath); ok {
return true
}
// Auch gegen den Basename matchen, wie git es für unqualifizierte
// Patterns ohne "/" tut.
if !strings.Contains(p, "/") {
if ok, _ := doublestar.Match(p, filepath.Base(relPath)); ok {
return true
}
}
}
return false
}

View file

@ -0,0 +1,83 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"sort"
"github.com/bmatcuk/doublestar/v4"
"nub/internal/tool"
)
type GlobTool struct{}
func (GlobTool) Name() string { return "glob" }
func (GlobTool) Description() string {
return "Findet Dateien per Glob-Pattern, .gitignore respektiert, nach mtime absteigend sortiert."
}
func (GlobTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "doublestar-Pattern, z.B. **/*.go"}
},
"required": ["pattern"]
}`)
}
type globInput struct {
Pattern string `json:"pattern"`
}
func (GlobTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in globInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
ignore := loadGitignore(env.RepoRoot)
type match struct {
path string
mtime int64
}
var matches []match
err := doublestar.GlobWalk(os.DirFS(env.RepoRoot), in.Pattern, func(path string, d fs.DirEntry) error {
if d.IsDir() || ignore.match(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
matches = append(matches, match{path: path, mtime: info.ModTime().Unix()})
return nil
})
if err != nil {
return tool.Result{ForModel: fmt.Sprintf("glob %s: %v", in.Pattern, err), IsError: true}, nil
}
sort.Slice(matches, func(i, j int) bool { return matches[i].mtime > matches[j].mtime })
paths := make([]string, len(matches))
out := ""
for i, m := range matches {
paths[i] = m.path
out += m.path + "\n"
}
if len(matches) == 0 {
out = fmt.Sprintf("no matches for %s", in.Pattern)
}
return tool.Result{ForModel: out, ForUI: GlobResult{Paths: paths}}, nil
}
type GlobResult struct {
Paths []string
}

View file

@ -0,0 +1,135 @@
package builtin
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"nub/internal/tool"
)
type GrepTool struct{}
func (GrepTool) Name() string { return "grep" }
func (GrepTool) Description() string {
return "Durchsucht Dateien per Regex, in-process, mit Kontext-Zeilen."
}
func (GrepTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string", "description": "Startverzeichnis, relativ zu RepoRoot, Default '.'"},
"context": {"type": "integer", "description": "Kontext-Zeilen vor/nach jedem Treffer"}
},
"required": ["pattern"]
}`)
}
type grepInput struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
Context int `json:"context"`
}
type grepMatch struct {
path string
line int
text string
}
func (GrepTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in grepInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
re, err := regexp.Compile(in.Pattern)
if err != nil {
return tool.Result{ForModel: "invalid pattern: " + err.Error(), IsError: true}, nil
}
startRel := in.Path
if startRel == "" {
startRel = "."
}
start, err := resolvePath(env.RepoRoot, env.Cwd, startRel)
if err != nil {
return tool.Result{ForModel: err.Error(), IsError: true}, nil
}
ignore := loadGitignore(env.RepoRoot)
var matches []grepMatch
err = filepath.WalkDir(start, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
rel, relErr := filepath.Rel(env.RepoRoot, p)
if relErr == nil && ignore.match(rel) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
grepFile(p, re, in.Context, &matches)
return nil
})
if err != nil {
return tool.Result{ForModel: fmt.Sprintf("grep: %v", err), IsError: true}, nil
}
if len(matches) == 0 {
return tool.Result{ForModel: fmt.Sprintf("no matches for %q", in.Pattern)}, nil
}
var b strings.Builder
for _, m := range matches {
relPath, _ := filepath.Rel(env.RepoRoot, m.path)
fmt.Fprintf(&b, "%s:%d:%s\n", relPath, m.line, m.text)
}
return tool.Result{ForModel: b.String(), ForUI: GrepResult{Count: len(matches)}}, nil
}
func grepFile(path string, re *regexp.Regexp, contextLines int, out *[]grepMatch) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if scanner.Err() != nil {
return
}
for i, line := range lines {
if !re.MatchString(line) {
continue
}
lo := max(0, i-contextLines)
hi := min(len(lines)-1, i+contextLines)
for j := lo; j <= hi; j++ {
*out = append(*out, grepMatch{path: path, line: j + 1, text: lines[j]})
}
}
}
type GrepResult struct {
Count int
}

View file

@ -0,0 +1,39 @@
package builtin
import (
"fmt"
"path/filepath"
"strings"
)
// resolvePath erzwingt die Pfad-Regel aus Abschnitt 5.3: nur unterhalb von
// RepoRoot, Symlinks werden aufgelöst und erneut geprüft.
func resolvePath(repoRoot, cwd, path string) (string, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
clean := filepath.Clean(path)
root, err := filepath.EvalSymlinks(repoRoot)
if err != nil {
return "", fmt.Errorf("repo root nicht auflösbar: %w", err)
}
// Symlinks im Zielpfad auflösen, soweit er existiert; fehlt er (z.B. bei
// write), am nächsten existierenden Vorfahren prüfen.
resolved := clean
if real, err := filepath.EvalSymlinks(clean); err == nil {
resolved = real
} else {
dir := filepath.Dir(clean)
if real, err := filepath.EvalSymlinks(dir); err == nil {
resolved = filepath.Join(real, filepath.Base(clean))
}
}
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes repo root: %s", path)
}
return clean, nil
}

View file

@ -0,0 +1,74 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"nub/internal/tool"
)
// QuestionTool stellt eine generische Rückfrage an den Nutzer — anders als
// Permissions (system-/config-entschieden, boolesch) entscheidet hier das
// Modell selbst, dass es etwas klären will, und bekommt eine Text-Antwort
// als normales Tool-Ergebnis zurück. Kein Permission-Gate: das Tool ist
// von sich aus interaktiv, eine Rückfrage auf die Rückfrage ergäbe keinen
// Sinn.
type QuestionTool struct{}
func (QuestionTool) Name() string { return "question" }
func (QuestionTool) Description() string {
return "Stellt dem Nutzer eine Rückfrage, wenn eine Aufgabe mehrdeutig ist oder eine Entscheidung nötig ist, bevor es weitergeht."
}
func (QuestionTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"question": {"type": "string"},
"options": {
"type": "array",
"items": {"type": "string"},
"description": "Optionale Vorschläge, die dem Nutzer zusätzlich zur Frage angezeigt werden"
}
},
"required": ["question"]
}`)
}
type questionInput struct {
Question string `json:"question"`
Options []string `json:"options"`
}
func (QuestionTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in questionInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
if in.Question == "" {
return tool.Result{ForModel: "question must not be empty", IsError: true}, nil
}
if env.Ask == nil {
return tool.Result{
ForModel: "interactive questions are not supported in this mode (e.g. print mode) — proceed with your best judgment, or state your assumption in your next reply instead of asking",
IsError: true,
}, nil
}
answer, err := env.Ask(ctx, in.Question, in.Options)
if err != nil {
return tool.Result{ForModel: fmt.Sprintf("question failed: %v", err), IsError: true}, nil
}
return tool.Result{
ForModel: answer,
ForUI: QuestionResult{Question: in.Question, Options: in.Options, Answer: answer},
}, nil
}
type QuestionResult struct {
Question string
Options []string
Answer string
}

View file

@ -0,0 +1,87 @@
package builtin
import (
"context"
"encoding/json"
"errors"
"testing"
)
func TestQuestionTool_NilAskHookIsAClearError(t *testing.T) {
env := testEnv(t) // env.Ask bleibt nil, wie im Print-Modus
in, _ := json.Marshal(map[string]any{"question": "welches Format?"})
res, err := QuestionTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected IsError when env.Ask is nil")
}
if !contains(res.ForModel, "not supported") {
t.Errorf("expected a clear explanation, got: %q", res.ForModel)
}
}
func TestQuestionTool_EmptyQuestionIsRejected(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"question": ""})
res, err := QuestionTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected IsError for an empty question")
}
}
func TestQuestionTool_ReturnsTheUsersAnswer(t *testing.T) {
env := testEnv(t)
var gotQuestion string
var gotOptions []string
env.Ask = func(ctx context.Context, question string, options []string) (string, error) {
gotQuestion = question
gotOptions = options
return "JSON bitte", nil
}
in, _ := json.Marshal(map[string]any{"question": "Welches Format?", "options": []string{"JSON", "YAML"}})
res, err := QuestionTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
if res.ForModel != "JSON bitte" {
t.Errorf("ForModel = %q, want the raw answer", res.ForModel)
}
if gotQuestion != "Welches Format?" {
t.Errorf("question passed to Ask = %q", gotQuestion)
}
if len(gotOptions) != 2 || gotOptions[0] != "JSON" || gotOptions[1] != "YAML" {
t.Errorf("options passed to Ask = %+v", gotOptions)
}
ui, ok := res.ForUI.(QuestionResult)
if !ok || ui.Answer != "JSON bitte" {
t.Errorf("ForUI = %+v", res.ForUI)
}
}
func TestQuestionTool_AskErrorSurfacesAsToolError(t *testing.T) {
env := testEnv(t)
env.Ask = func(ctx context.Context, question string, options []string) (string, error) {
return "", errors.New("boom")
}
in, _ := json.Marshal(map[string]any{"question": "x"})
res, err := QuestionTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Error("expected an Ask error to surface as IsError, not a Go error")
}
}

View file

@ -0,0 +1,98 @@
package builtin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"nub/internal/tool"
)
const defaultReadLimit = 2000
type ReadTool struct{}
func (ReadTool) Name() string { return "read" }
func (ReadTool) Description() string {
return "Liest eine Datei mit Zeilennummern, optional ab einem Offset mit Limit."
}
func (ReadTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string"},
"offset": {"type": "integer", "description": "1-basierte Startzeile, Default 1"},
"limit": {"type": "integer", "description": "maximale Zeilenzahl, Default 2000"}
},
"required": ["path"]
}`)
}
type readInput struct {
Path string `json:"path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func (ReadTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in readInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
path, err := resolvePath(env.RepoRoot, env.Cwd, in.Path)
if err != nil {
return tool.Result{ForModel: err.Error(), IsError: true}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return tool.Result{ForModel: fmt.Sprintf("read %s: %v", in.Path, err), IsError: true}, nil
}
if bytes.IndexByte(data, 0) != -1 {
return tool.Result{ForModel: fmt.Sprintf("%s: binary file, not readable as text", in.Path), IsError: true}, nil
}
offset := in.Offset
if offset < 1 {
offset = 1
}
limit := in.Limit
if limit <= 0 {
limit = defaultReadLimit
}
lines := strings.Split(string(data), "\n")
if offset > len(lines) {
return tool.Result{ForModel: fmt.Sprintf("%s: offset %d beyond file end (%d lines)", in.Path, offset, len(lines)), IsError: true}, nil
}
end := offset - 1 + limit
if end > len(lines) {
end = len(lines)
}
var b strings.Builder
for i := offset - 1; i < end; i++ {
fmt.Fprintf(&b, "%6d\t%s\n", i+1, lines[i])
}
truncated := end < len(lines)
return tool.Result{
ForModel: b.String(),
ForUI: FileResult{
Path: in.Path,
Lines: end - (offset - 1),
Truncated: truncated,
},
}, nil
}
// FileResult ist die strukturierte UI-Repräsentation für read/write.
type FileResult struct {
Path string
Lines int
Truncated bool
}

View file

@ -0,0 +1,67 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"strings"
"nub/internal/skill"
"nub/internal/tool"
)
// ReadSkillTool lädt den Body eines im Index (nur Name+Description)
// angekündigten Skills nach (5.5, Progressive Disclosure).
type ReadSkillTool struct {
skills []skill.Skill
}
func NewReadSkillTool(skills []skill.Skill) *ReadSkillTool {
return &ReadSkillTool{skills: skills}
}
func (*ReadSkillTool) Name() string { return "read_skill" }
func (*ReadSkillTool) Description() string {
return "Lädt den vollständigen Inhalt eines Skills nach Name."
}
func (*ReadSkillTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
}`)
}
type readSkillInput struct {
Name string `json:"name"`
}
func (t *ReadSkillTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in readSkillInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
for _, s := range t.skills {
if s.Name == in.Name {
return tool.Result{ForModel: s.Body, ForUI: SkillResult{Name: s.Name, Dir: s.Dir}}, nil
}
}
names := make([]string, len(t.skills))
for i, s := range t.skills {
names[i] = s.Name
}
return tool.Result{
ForModel: fmt.Sprintf("unknown skill %q. Available: %s", in.Name, strings.Join(names, ", ")),
IsError: true,
}, nil
}
type SkillResult struct {
Name string
Dir string
}

View file

@ -0,0 +1,48 @@
package builtin
import (
"context"
"encoding/json"
"testing"
"nub/internal/skill"
)
func TestReadSkillTool_LoadsBodyByName(t *testing.T) {
skills := []skill.Skill{
{Name: "refactoring", Description: "d1", Body: "full refactoring body"},
{Name: "testing", Description: "d2", Body: "full testing body"},
}
rt := NewReadSkillTool(skills)
env := testEnv(t)
in, _ := json.Marshal(map[string]string{"name": "refactoring"})
res, err := rt.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
if res.ForModel != "full refactoring body" {
t.Errorf("ForModel = %q", res.ForModel)
}
}
func TestReadSkillTool_UnknownNameListsAvailable(t *testing.T) {
skills := []skill.Skill{{Name: "refactoring", Body: "x"}}
rt := NewReadSkillTool(skills)
env := testEnv(t)
in, _ := json.Marshal(map[string]string{"name": "does-not-exist"})
res, err := rt.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected error for unknown skill name")
}
if !contains(res.ForModel, "refactoring") {
t.Errorf("expected available skills listed in error, got: %q", res.ForModel)
}
}

View file

@ -0,0 +1,97 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"nub/internal/tool"
)
// TodoTool hält eine In-Memory-Todo-Liste im Session-State (5.3) — nicht im
// Baum persistiert. Zustand lebt im Tool selbst, deshalb Pointer-Registrierung
// (&TodoTool{}) statt eines Werttyps.
type TodoTool struct {
mu sync.Mutex
items []TodoItem
}
type TodoItem struct {
Content string `json:"content"`
Status string `json:"status"` // pending | in_progress | completed
}
func (*TodoTool) Name() string { return "todo" }
func (*TodoTool) Description() string {
return "Ersetzt die aktuelle Todo-Liste. Für lange Tasks: Fortschritt sichtbar halten."
}
func (*TodoTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
},
"required": ["content", "status"]
}
}
},
"required": ["items"]
}`)
}
type todoInput struct {
Items []TodoItem `json:"items"`
}
func (t *TodoTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in todoInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
for _, it := range in.Items {
switch it.Status {
case "pending", "in_progress", "completed":
default:
return tool.Result{ForModel: fmt.Sprintf("invalid status %q", it.Status), IsError: true}, nil
}
}
t.mu.Lock()
t.items = in.Items
items := make([]TodoItem, len(t.items))
copy(items, t.items)
t.mu.Unlock()
return tool.Result{ForModel: renderTodos(items), ForUI: TodoResult{Items: items}}, nil
}
type TodoResult struct {
Items []TodoItem
}
func renderTodos(items []TodoItem) string {
if len(items) == 0 {
return "todo list is empty"
}
var b strings.Builder
for _, it := range items {
mark := " "
switch it.Status {
case "in_progress":
mark = "~"
case "completed":
mark = "x"
}
fmt.Fprintf(&b, "[%s] %s\n", mark, it.Content)
}
return b.String()
}

View file

@ -0,0 +1,81 @@
package builtin
import (
"context"
"encoding/json"
"testing"
)
func TestTodoTool_ReplacesListAndRendersStatus(t *testing.T) {
tt := &TodoTool{}
env := testEnv(t)
in, _ := json.Marshal(map[string]any{
"items": []map[string]string{
{"content": "read file", "status": "completed"},
{"content": "write fix", "status": "in_progress"},
{"content": "run tests", "status": "pending"},
},
})
res, err := tt.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
if !contains(res.ForModel, "[x] read file") {
t.Errorf("expected completed marker, got: %q", res.ForModel)
}
if !contains(res.ForModel, "[~] write fix") {
t.Errorf("expected in_progress marker, got: %q", res.ForModel)
}
if !contains(res.ForModel, "[ ] run tests") {
t.Errorf("expected pending marker, got: %q", res.ForModel)
}
ui, ok := res.ForUI.(TodoResult)
if !ok || len(ui.Items) != 3 {
t.Errorf("ForUI = %+v, want TodoResult with 3 items", res.ForUI)
}
}
func TestTodoTool_RejectsInvalidStatus(t *testing.T) {
tt := &TodoTool{}
env := testEnv(t)
in, _ := json.Marshal(map[string]any{
"items": []map[string]string{{"content": "x", "status": "bogus"}},
})
res, err := tt.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected error for invalid status")
}
}
func TestTodoTool_SecondCallReplacesFirst(t *testing.T) {
tt := &TodoTool{}
env := testEnv(t)
first, _ := json.Marshal(map[string]any{
"items": []map[string]string{{"content": "a", "status": "pending"}, {"content": "b", "status": "pending"}},
})
if _, err := tt.Run(context.Background(), first, env); err != nil {
t.Fatal(err)
}
second, _ := json.Marshal(map[string]any{
"items": []map[string]string{{"content": "c", "status": "pending"}},
})
res, err := tt.Run(context.Background(), second, env)
if err != nil {
t.Fatal(err)
}
ui := res.ForUI.(TodoResult)
if len(ui.Items) != 1 || ui.Items[0].Content != "c" {
t.Errorf("expected list replaced with single item 'c', got: %+v", ui.Items)
}
}

View file

@ -0,0 +1,59 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"nub/internal/tool"
)
type WriteTool struct{}
func (WriteTool) Name() string { return "write" }
func (WriteTool) Description() string {
return "Schreibt eine Datei vollständig (legt Parent-Dirs an)."
}
func (WriteTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["path", "content"]
}`)
}
type writeInput struct {
Path string `json:"path"`
Content string `json:"content"`
}
func (WriteTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
var in writeInput
if err := json.Unmarshal(input, &in); err != nil {
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
}
path, err := resolvePath(env.RepoRoot, env.Cwd, in.Path)
if err != nil {
return tool.Result{ForModel: err.Error(), IsError: true}, nil
}
old, _ := os.ReadFile(path) // fehlt die Datei, ist old leer -> reiner Add-Diff
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return tool.Result{ForModel: fmt.Sprintf("write %s: %v", in.Path, err), IsError: true}, nil
}
if err := os.WriteFile(path, []byte(in.Content), 0o644); err != nil {
return tool.Result{ForModel: fmt.Sprintf("write %s: %v", in.Path, err), IsError: true}, nil
}
return tool.Result{
ForModel: fmt.Sprintf("wrote %s (%d bytes)", in.Path, len(in.Content)),
ForUI: DiffResult{Path: in.Path, Lines: lineDiff(string(old), in.Content)},
}, nil
}

89
internal/tool/tool.go Normal file
View file

@ -0,0 +1,89 @@
// Package tool definiert das Tool-Interface, Ergebnis-Typen und die Registry,
// über die der Agent-Loop Tools parallel ausführt.
package tool
import (
"context"
"encoding/json"
"fmt"
"sort"
)
type Tool interface {
Name() string
Description() string
Schema() json.RawMessage
Run(ctx context.Context, input json.RawMessage, env Env) (Result, error)
}
// Result ist zweigeteilt (E-05): ForModel geht knapp in den tool_result-Block,
// ForUI ist strukturiert (z.B. ein Diff) und für den jeweiligen Renderer gedacht.
type Result struct {
ForModel string
ForUI any
IsError bool
}
type Env struct {
Cwd string
RepoRoot string
Emit func(UIEvent) // für streamende Tools (bash)
// Ask stellt dem Nutzer eine Rückfrage und blockiert auf die Antwort.
// nil (typischerweise im Print-Modus) bedeutet: interaktive Rückfragen
// werden hier nicht unterstützt — das question-Tool meldet das dann als
// klaren Fehler statt zu hängen.
Ask func(ctx context.Context, question string, options []string) (string, error)
}
// Registry hält die zur Laufzeit verfügbaren Tools (built-in + MCP-Bridge).
type Registry struct {
tools map[string]Tool
}
func NewRegistry() *Registry {
return &Registry{tools: make(map[string]Tool)}
}
func (r *Registry) Register(t Tool) {
r.tools[t.Name()] = t
}
func (r *Registry) Get(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// Defs liefert die Tool-Definitionen in stabiler (nach Name sortierter)
// Reihenfolge. Wichtig für Prompt-Caching (E-06): tools steht im Request vor
// messages und ist Teil des gecachten Prefix — Go-Map-Iteration ist pro
// Aufruf zufällig, eine unsortierte Ausgabe hätte den Cache bei jedem
// einzelnen Turn neu gebrochen.
func (r *Registry) Defs() []Def {
defs := make([]Def, 0, len(r.tools))
for _, t := range r.tools {
defs = append(defs, Def{
Name: t.Name(),
Description: t.Description(),
Schema: t.Schema(),
})
}
sort.Slice(defs, func(i, j int) bool { return defs[i].Name < defs[j].Name })
return defs
}
type Def struct {
Name string
Description string
Schema json.RawMessage
}
// Run führt ein registriertes Tool aus; unbekannte Namen liefern ein
// Fehlerergebnis statt eines Panics (jeder tool_use braucht ein Ergebnis).
func (r *Registry) Run(ctx context.Context, name string, input json.RawMessage, env Env) (Result, error) {
t, ok := r.tools[name]
if !ok {
return Result{ForModel: fmt.Sprintf("unknown tool: %s", name), IsError: true}, nil
}
return t.Run(ctx, input, env)
}

View file

@ -0,0 +1,48 @@
package tool
import (
"context"
"encoding/json"
"testing"
)
type stubTool struct{ name string }
func (s stubTool) Name() string { return s.name }
func (s stubTool) Description() string { return "" }
func (s stubTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
func (s stubTool) Run(ctx context.Context, input json.RawMessage, env Env) (Result, error) {
return Result{}, nil
}
// TestRegistry_Defs_StableOrder sichert einen konkreten Bug ab: Defs()
// iterierte früher direkt über die interne Map, deren Reihenfolge Go pro
// Aufruf zufällig mischt. Da tools im Request vor messages steht und Teil
// des gecachten Prefix ist (E-06), hat das Prompt-Caching bei jedem Turn
// neu gebrochen. Defs() muss deterministisch (nach Name) sortiert sein.
func TestRegistry_Defs_StableOrder(t *testing.T) {
r := NewRegistry()
names := []string{"read", "write", "edit", "bash", "glob", "grep", "todo", "read_skill"}
for _, n := range names {
r.Register(stubTool{name: n})
}
first := r.Defs()
for i := 0; i < 20; i++ {
got := r.Defs()
if len(got) != len(first) {
t.Fatalf("Defs() length changed between calls: %d vs %d", len(got), len(first))
}
for j := range got {
if got[j].Name != first[j].Name {
t.Fatalf("Defs() order not stable across calls (iteration %d): %+v vs %+v", i, got, first)
}
}
}
for i := 1; i < len(first); i++ {
if first[i-1].Name > first[i].Name {
t.Errorf("Defs() not sorted by name: %q before %q", first[i-1].Name, first[i].Name)
}
}
}

51
internal/tool/uievent.go Normal file
View file

@ -0,0 +1,51 @@
package tool
import "nub/internal/llm"
// UIEvent ist der einzige Kanal, über den Kern-Pakete Ausgabe erzeugen (E-04).
// Print-Modus, JSON-Modus und TUI sind dünne Consumer davon.
type UIEvent interface{ isUIEvent() }
type TextDelta struct{ Text string }
type ThinkingDelta struct{ Text string }
type ToolCallStart struct {
ID string
Name string
Input []byte
}
type ToolCallOutput struct {
ID string
Name string
Result Result
}
// ToolStream trägt Zwischenausgabe eines laufenden Tools (z.B. bash-stdout).
type ToolStream struct {
ID string
Text string
}
type TurnDone struct {
Stop llm.StopReason
Usage llm.Usage
}
type ErrorEvent struct{ Err error }
// CompactionEvent meldet eine abgeschlossene Auto- oder manuelle Compaction
// (5.7): wie viele Nodes durch die Zusammenfassung ersetzt wurden.
type CompactionEvent struct {
ReplacedNodes int
SummaryTokens int
}
func (TextDelta) isUIEvent() {}
func (ThinkingDelta) isUIEvent() {}
func (ToolCallStart) isUIEvent() {}
func (ToolCallOutput) isUIEvent() {}
func (ToolStream) isUIEvent() {}
func (TurnDone) isUIEvent() {}
func (ErrorEvent) isUIEvent() {}
func (CompactionEvent) isUIEvent() {}

View file

@ -0,0 +1,61 @@
// Package plain ist der stdout-Renderer für den Print-Modus (5.8): Textdeltas
// nach stdout, Tool-Aktivität nach stderr. Reiner Consumer von tool.UIEvent.
package plain
import (
"fmt"
"io"
"nub/internal/tool"
)
type Renderer struct {
Out io.Writer
Err io.Writer
}
// Render konsumiert den Event-Channel, bis er schließt, und liefert den
// zuletzt gesehenen Fehler zurück (für die Exit-Code-Entscheidung in main).
func (r *Renderer) Render(events <-chan tool.UIEvent) error {
var lastErr error
openText := false
for ev := range events {
switch e := ev.(type) {
case tool.TextDelta:
fmt.Fprint(r.Out, e.Text)
openText = true
case tool.ThinkingDelta:
// v1: kein separates Rendering für Thinking im Print-Modus.
case tool.ToolCallStart:
if openText {
fmt.Fprintln(r.Out)
openText = false
}
fmt.Fprintf(r.Err, "→ %s %s\n", e.Name, string(e.Input))
case tool.ToolStream:
fmt.Fprint(r.Err, e.Text)
case tool.ToolCallOutput:
status := "ok"
if e.Result.IsError {
status = "error"
}
fmt.Fprintf(r.Err, "← %s [%s]\n", e.Name, status)
case tool.TurnDone:
if openText {
fmt.Fprintln(r.Out)
openText = false
}
if e.Usage.InputTokens > 0 || e.Usage.OutputTokens > 0 {
fmt.Fprintf(r.Err, "usage: input=%d output=%d cache_read=%d\n",
e.Usage.InputTokens, e.Usage.OutputTokens, e.Usage.CacheReadTokens)
}
case tool.ErrorEvent:
fmt.Fprintf(r.Err, "error: %v\n", e.Err)
lastErr = e.Err
case tool.CompactionEvent:
fmt.Fprintf(r.Err, "compacted %d nodes into a %d-token summary\n", e.ReplacedNodes, e.SummaryTokens)
}
}
return lastErr
}

563
internal/ui/tui/commands.go Normal file
View file

@ -0,0 +1,563 @@
package tui
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"nub/internal/ctxasm"
"nub/internal/llm"
"nub/internal/scaffold"
"nub/internal/session"
"nub/internal/skill"
"nub/internal/tool"
)
func (m *Model) runCommand(text string) tea.Cmd {
fields := strings.Fields(text)
cmd := fields[0]
args := fields[1:]
switch cmd {
case "/help":
m.pushCommand(m.helpText())
return nil
case "/clear":
m.entries = nil
m.renderViewport()
return nil
case "/exit", "/quit":
// Nicht hier canceln, siehe Kommentar bei KeyCtrlC in model.go.
m.quitting = true
return tea.Quit
case "/model":
return m.cmdModel(args)
case "/context":
return m.cmdContext()
case "/skills":
return m.cmdSkills()
case "/mcp":
return m.cmdMCP()
case "/tree", "/branch":
return m.cmdOpenTree()
case "/sessions":
return m.cmdSessions()
case "/compact":
return m.cmdCompact()
case "/export":
return m.cmdExport(args)
case "/init":
return m.cmdInit()
default:
if s, ok := m.findSkill(cmd); ok {
return m.cmdInvokeSkill(s, args)
}
m.pushError("unknown command: " + cmd + " (siehe /help)")
return nil
}
}
// findSkill matcht einen Slash-Command-Namen (führendes "/" entfernt) gegen
// die bekannten Skills. Eingebaute Kommandos gewinnen immer — findSkill wird
// nur im default-Fall von runCommand aufgerufen, also erst nachdem kein
// eingebautes Kommando gepasst hat.
func (m *Model) findSkill(cmd string) (skill.Skill, bool) {
name := strings.TrimPrefix(cmd, "/")
for _, s := range m.skills {
if s.Name == name {
return s, true
}
}
return skill.Skill{}, false
}
// cmdInvokeSkill schickt eine kurze Direktive statt des vollen Skill-Bodys —
// das Modell lädt den Body weiterhin selbst über read_skill nach (Progressive
// Disclosure bleibt intakt, der Slash-Command ist nur eine Abkürzung dafür,
// dem Modell zu sagen, welchen Skill es anwenden soll).
func (m *Model) cmdInvokeSkill(s skill.Skill, args []string) tea.Cmd {
instruction := fmt.Sprintf("Nutze den Skill %q (via read_skill).", s.Name)
if len(args) > 0 {
instruction += " " + strings.Join(args, " ")
}
return m.sendAsMessage(instruction)
}
// helpCommands ist die einzige Quelle für /help — Reihenfolge und Text
// bewusst hier zentralisiert statt verstreut in runCommand.
var helpCommands = []struct{ cmd, desc string }{
{"/model [name]", "aktuelles Modell anzeigen, oder für die restliche Session wechseln"},
{"/tree", "Session-Baum anzeigen, mit ↑/↓ einen Knoten wählen"},
{"/branch", "wie /tree — von einem früheren Knoten aus weiterarbeiten (Rewind)"},
{"/sessions", "Sessions auflisten, wechseln (Enter) oder löschen (Entf)"},
{"/compact", "Session sofort zusammenfassen, unabhängig vom Token-Schwellwert"},
{"/context", "geladenen Kontext mit Herkunft und Tokenkosten anzeigen"},
{"/mcp", "verbundene MCP-Server und ihre Tools auflisten"},
{"/skills", "gefundene Skills (Name + Beschreibung) auflisten"},
{"/export [pfad]", "Transkript als Markdown-Datei exportieren"},
{"/init", "Beispiel-Config (.nub/config.toml) und AGENTS.md anlegen, falls nicht vorhanden"},
{"/clear", "nur die Anzeige leeren — die Session bleibt unverändert"},
{"/exit, /quit", "nub beenden"},
{"/help", "diese Übersicht anzeigen"},
}
func (m *Model) helpText() string {
width := 0
for _, c := range helpCommands {
if len(c.cmd) > width {
width = len(c.cmd)
}
}
for _, s := range m.skills {
if l := len("/" + s.Name); l > width {
width = l
}
}
var b strings.Builder
b.WriteString("Kommandos:\n")
for _, c := range helpCommands {
fmt.Fprintf(&b, " %-*s %s\n", width, c.cmd, c.desc)
}
if len(m.skills) > 0 {
b.WriteString("\nSkills (als Kommando nutzbar):\n")
for _, s := range m.skills {
fmt.Fprintf(&b, " %-*s %s\n", width, "/"+s.Name, s.Description)
}
}
return strings.TrimRight(b.String(), "\n")
}
func (m *Model) cmdModel(args []string) tea.Cmd {
if len(args) == 0 {
m.pushCommand("current model: " + m.modelName)
return nil
}
m.modelName = args[0]
m.loop.Model = args[0]
m.pushCommand("model set to " + args[0])
return nil
}
func (m *Model) cmdContext() tea.Cmd {
system, meta, err := ctxasm.Assemble(ctxasm.Options{
RepoRoot: m.loop.Env.RepoRoot,
Cwd: m.loop.Env.RepoRoot,
Files: m.cfg.Context.Files,
WalkUp: m.cfg.Context.WalkUp,
MaxTokens: m.cfg.Context.MaxTokens,
SkillsIndex: skill.RenderIndex(m.skills),
})
if err != nil {
m.pushError("context: " + err.Error())
return nil
}
var b strings.Builder
fmt.Fprintf(&b, "context: %d Blöcke, %d Dateitokens (Budget %d)\n", len(system), meta.TotalTokens, m.cfg.Context.MaxTokens)
for _, f := range meta.Files {
trunc := ""
if f.Truncated {
trunc = " [truncated]"
}
fmt.Fprintf(&b, " %-24s %6d tokens %s%s\n", f.Path, f.Tokens, f.Source, trunc)
}
for _, w := range meta.Warnings {
fmt.Fprintf(&b, " warning: %s\n", w)
}
m.pushCommand(strings.TrimRight(b.String(), "\n"))
return nil
}
func (m *Model) cmdSkills() tea.Cmd {
if len(m.skills) == 0 {
m.pushCommand("no skills found")
return nil
}
var b strings.Builder
b.WriteString("skills:\n")
for _, s := range m.skills {
fmt.Fprintf(&b, " %-20s %s\n", s.Name, s.Description)
}
m.pushCommand(strings.TrimRight(b.String(), "\n"))
return nil
}
func (m *Model) cmdMCP() tea.Cmd {
if len(m.mcpServers) == 0 {
m.pushCommand("no mcp servers connected")
return nil
}
return func() tea.Msg {
var b strings.Builder
b.WriteString("mcp servers:\n")
for _, srv := range m.mcpServers {
var names []string
for t, err := range srv.Session.Tools(m.ctx, nil) {
if err != nil {
break
}
names = append(names, t.Name)
}
fmt.Fprintf(&b, " %s: %s\n", srv.Name, strings.Join(names, ", "))
}
return asyncCommandMsg{text: strings.TrimRight(b.String(), "\n")}
}
}
func (m *Model) cmdCompact() tea.Cmd {
return func() tea.Msg {
ch := make(chan tool.UIEvent, 4)
var got []tool.UIEvent
done := make(chan struct{})
go func() {
defer close(done)
for ev := range ch {
got = append(got, ev)
}
}()
ctx, cancel := context.WithTimeout(m.ctx, 60*time.Second)
defer cancel()
err := m.loop.Compact(ctx, ch)
close(ch)
<-done
return compactDoneMsg{events: got, err: err}
}
}
func (m *Model) cmdExport(args []string) tea.Cmd {
path := fmt.Sprintf("nub-export-%s.md", shortID(m.store.ID))
if len(args) > 0 {
path = args[0]
}
var b strings.Builder
fmt.Fprintf(&b, "# nub session %s\n\n", m.store.ID)
for _, e := range m.entries {
b.WriteString(renderEntry(e))
b.WriteString("\n\n")
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
m.pushError("export: " + err.Error())
return nil
}
m.pushCommand("exported to " + path)
return nil
}
// cmdInit legt .nub/config.toml (Beispiel-Config inkl. Permissions) und ein
// minimalistisches AGENTS.md an — nur was noch fehlt, nichts wird
// überschrieben. Wirkt erst ab dem nächsten Start von nub (die laufende
// Session hat ihre Config schon geladen).
func (m *Model) cmdInit() tea.Cmd {
res, err := scaffold.Init(m.loop.Env.RepoRoot)
if err != nil {
m.pushError("init: " + err.Error())
return nil
}
var b strings.Builder
if res.ConfigCreated {
fmt.Fprintf(&b, "angelegt: %s\n", res.ConfigPath)
} else {
fmt.Fprintf(&b, "übersprungen (existiert schon): %s\n", res.ConfigPath)
}
if res.AgentsCreated {
fmt.Fprintf(&b, "angelegt: %s\n", res.AgentsPath)
} else {
fmt.Fprintf(&b, "übersprungen (existiert schon): %s\n", res.AgentsPath)
}
if res.ConfigCreated || res.AgentsCreated {
b.WriteString("\nWirkt ab dem nächsten Start von nub — diese Session läuft mit der bereits geladenen Config weiter.")
}
m.pushCommand(strings.TrimRight(b.String(), "\n"))
return nil
}
// --- Generisches Listen-Modal: /tree, /branch, /sessions ---
// listModal ist ein wiederverwendbarer Auswahl-Dialog (↑/↓ + Enter), optional
// mit Löschen (Entf, mit Rückfrage). onSelect/onDelete laufen im Model-
// Kontext, damit sie Session/Store direkt anfassen können.
type listModal struct {
title string
items []listItem
cursor int
confirmDelete bool
onSelect func(m *Model, item listItem) tea.Cmd
onDelete func(m *Model, item listItem) tea.Cmd // nil = Löschen nicht angeboten
}
type listItem struct {
id string
label string
marker string // z.B. "(head)" oder "(aktiv)"
protect bool // z.B. aktuelle Session: nicht löschbar
}
func (m *Model) cmdOpenTree() tea.Cmd {
type row struct {
id string
created time.Time
label string
isHead bool
}
var rows []row
for id, n := range m.store.Nodes {
label := firstLine(textOf(n))
if len(label) > 60 {
label = label[:60] + "…"
}
rows = append(rows, row{id: id, created: n.Meta.CreatedAt, label: label, isHead: id == m.store.Head})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].created.Before(rows[j].created) })
items := make([]listItem, len(rows))
cursor := 0
for i, r := range rows {
marker := ""
if r.isHead {
marker = "(head)"
cursor = i
}
items[i] = listItem{id: r.id, label: fmt.Sprintf("%s %s", shortID(r.id), r.label), marker: marker}
}
m.modal = &listModal{
title: "Session-Baum",
items: items, cursor: cursor,
onSelect: func(m *Model, item listItem) tea.Cmd {
if err := m.store.Branch(item.id); err != nil {
m.pushError("branch: " + err.Error())
return nil
}
m.pushCommand(fmt.Sprintf("head set to %s", shortID(item.id)))
return nil
},
}
return nil
}
// cmdSessions listet alle Sessions im Projekt, erlaubt Wechseln (Enter) und
// Löschen (Entf, mit Rückfrage) — die aktive Session lässt sich nicht
// löschen.
func (m *Model) cmdSessions() tea.Cmd {
infos, err := session.List(m.loop.Env.RepoRoot)
if err != nil {
m.pushError("sessions: " + err.Error())
return nil
}
if len(infos) == 0 {
m.pushCommand("no sessions in this directory")
return nil
}
items := make([]listItem, len(infos))
cursor := 0
for i, info := range infos {
marker := ""
active := info.ID == m.store.ID
if active {
marker = "(aktiv)"
cursor = i
}
summary := info.Summary
if summary == "" {
summary = "(kein Titel)"
}
items[i] = listItem{
id: info.ID,
label: fmt.Sprintf("%s %-40s %d nodes %s", shortID(info.ID), summary, info.NodeCount, info.Created.Format("2006-01-02 15:04")),
marker: marker,
protect: active,
}
}
m.modal = &listModal{
title: "Sessions",
items: items, cursor: cursor,
onSelect: func(m *Model, item listItem) tea.Cmd {
if item.id == m.store.ID {
m.pushCommand("already on this session")
return nil
}
newStore, err := session.Load(m.loop.Env.RepoRoot, item.id)
if err != nil {
m.pushError("resume: " + err.Error())
return nil
}
old := m.store
cmd := m.startSession(newStore)
_ = old.Close()
m.pushCommand("switched to session " + shortID(item.id))
return cmd
},
onDelete: func(m *Model, item listItem) tea.Cmd {
if item.id == m.store.ID {
m.pushError("cannot delete the active session")
return nil
}
if err := session.Delete(m.loop.Env.RepoRoot, item.id); err != nil {
m.pushError("delete: " + err.Error())
return nil
}
m.pushCommand("deleted session " + shortID(item.id))
return nil
},
}
return nil
}
func textOf(n *session.Node) string {
for _, b := range n.Message.Content {
if b.Text != "" {
return b.Text
}
}
return string(n.Kind)
}
func (m *Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
lm := m.modal
if lm.confirmDelete {
if msg.String() == "y" || msg.String() == "Y" {
item := lm.items[lm.cursor]
lm.confirmDelete = false
m.modal = nil
return m, lm.onDelete(m, item)
}
lm.confirmDelete = false
return m, nil
}
switch msg.Type {
case tea.KeyEsc, tea.KeyCtrlC:
m.modal = nil
return m, nil
case tea.KeyUp:
if lm.cursor > 0 {
lm.cursor--
}
return m, nil
case tea.KeyDown:
if lm.cursor < len(lm.items)-1 {
lm.cursor++
}
return m, nil
case tea.KeyEnter:
item := lm.items[lm.cursor]
m.modal = nil
if lm.onSelect == nil {
return m, nil
}
return m, lm.onSelect(m, item)
case tea.KeyDelete:
if lm.onDelete == nil || len(lm.items) == 0 {
return m, nil
}
if lm.items[lm.cursor].protect {
m.pushError("cannot delete the active session")
return m, nil
}
lm.confirmDelete = true
return m, nil
}
return m, nil
}
func (m *Model) renderModal() string {
lm := m.modal
var b strings.Builder
if lm.confirmDelete {
fmt.Fprintf(&b, "%s wirklich löschen? [y/N]", lm.items[lm.cursor].label)
return styleModalBox.Render(b.String())
}
fmt.Fprintf(&b, "%s — ↑/↓ wählen, Enter bestätigen", lm.title)
if lm.onDelete != nil {
b.WriteString(", Entf löscht")
}
b.WriteString(", Esc abbrechen\n\n")
for i, it := range lm.items {
line := it.label
if it.marker != "" {
line += " " + it.marker
}
if i == lm.cursor {
b.WriteString(styleModalSel.Render("> " + line))
} else {
b.WriteString(" " + line)
}
b.WriteString("\n")
}
return styleModalBox.Render(strings.TrimRight(b.String(), "\n"))
}
// sumUsage summiert die pro Node gespeicherte Token-Nutzung (NodeMeta.Usage)
// einer Session-Historie auf — Grundlage für die Statuszeile beim Start
// oder Wechsel einer Session (sonst zeigt sie fälschlich 0/den Stand der
// vorherigen Session an, obwohl die tatsächliche Nutzung längst persistiert
// ist).
func sumUsage(nodes []*session.Node) llm.Usage {
var total llm.Usage
for _, n := range nodes {
total.InputTokens += n.Meta.Usage.InputTokens
total.OutputTokens += n.Meta.Usage.OutputTokens
total.CacheReadTokens += n.Meta.Usage.CacheReadTokens
total.CacheWriteTokens += n.Meta.Usage.CacheWriteTokens
}
return total
}
// replayEntries baut das Transkript aus der Historie einer (neu geladenen
// oder gewechselten) Session nach — sonst wäre nach /sessions oder einem
// `nub resume` in der TUI der bisherige Verlauf unsichtbar, obwohl der
// Loop ihn im Kontext hat.
func replayEntries(nodes []*session.Node) []entry {
var out []entry
toolNames := map[string]string{}
for _, n := range nodes {
if n.Kind == session.NodeSummary {
out = append(out, entry{kind: entrySystem, text: "zusammengefasste Vorgeschichte:\n" + textOf(n)})
continue
}
for _, b := range n.Message.Content {
switch b.Kind {
case llm.KindText:
if b.Text == "" {
continue
}
if n.Message.Role == llm.RoleUser {
out = append(out, entry{kind: entryUser, text: b.Text})
} else {
out = append(out, entry{kind: entryAssistant, text: b.Text})
}
case llm.KindToolUse:
toolNames[b.ID] = b.Name
out = append(out, entry{kind: entryToolCall, text: renderToolCall(b.Name, b.Input)})
case llm.KindToolResult:
name := toolNames[b.ToolUseID]
if name == "" {
name = "tool"
}
var resText strings.Builder
for _, r := range b.Result {
resText.WriteString(r.Text)
}
out = append(out, entry{
kind: entryToolResult,
text: renderToolResult(name, tool.Result{ForModel: resText.String(), IsError: b.IsError}),
})
}
}
}
return out
}

View file

@ -0,0 +1,313 @@
package tui
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"nub/internal/agent"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
func enterKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} }
func escKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} }
func deleteKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDelete} }
func yKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")} }
func nKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} }
func newTestModelWithStore(t *testing.T) *Model {
t.Helper()
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
return &Model{
ctx: context.Background(),
store: store,
textarea: textarea.New(),
viewport: viewport.New(80, 20),
spinner: spinner.New(),
}
}
func textMsg(role llm.Role, text string) llm.Message {
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
}
func findItem(items []listItem, id string) int {
for i, it := range items {
if it.id == id {
return i
}
}
return -1
}
func TestCmdOpenTree_MarksCurrentHead(t *testing.T) {
m := newTestModelWithStore(t)
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
if err := m.store.Append(n1); err != nil {
t.Fatal(err)
}
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "second"), session.NodeMeta{})
if err := m.store.Append(n2); err != nil {
t.Fatal(err)
}
m.cmdOpenTree()
if m.modal == nil {
t.Fatal("expected a modal to be opened")
}
if len(m.modal.items) != 2 {
t.Fatalf("got %d items, want 2", len(m.modal.items))
}
if m.modal.items[m.modal.cursor].id != n2.ID {
t.Errorf("cursor should default to the current head (%s), got %s", n2.ID, m.modal.items[m.modal.cursor].id)
}
}
func TestHandleModalKey_EnterBranchesToSelectedNode(t *testing.T) {
m := newTestModelWithStore(t)
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
if err := m.store.Append(n1); err != nil {
t.Fatal(err)
}
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "second"), session.NodeMeta{})
if err := m.store.Append(n2); err != nil {
t.Fatal(err)
}
m.cmdOpenTree()
m.modal.cursor = findItem(m.modal.items, n1.ID) // n1 auswählen, nicht den aktuellen Head n2
m.handleModalKey(enterKey())
if m.modal != nil {
t.Error("expected modal to close after selection")
}
if m.store.Head != n1.ID {
t.Errorf("head = %s, want %s (branch to selected node)", m.store.Head, n1.ID)
}
}
func TestHandleModalKey_EscClosesWithoutBranching(t *testing.T) {
m := newTestModelWithStore(t)
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
if err := m.store.Append(n1); err != nil {
t.Fatal(err)
}
originalHead := m.store.Head
m.cmdOpenTree()
m.handleModalKey(escKey())
if m.modal != nil {
t.Error("expected modal to close on esc")
}
if m.store.Head != originalHead {
t.Error("esc must not change the head")
}
}
func newTestModelForSessions(t *testing.T, dir string, store *session.Store) *Model {
t.Helper()
return &Model{
ctx: context.Background(),
store: store,
loop: &agent.Loop{Env: tool.Env{RepoRoot: dir}},
textarea: textarea.New(),
viewport: viewport.New(80, 20),
spinner: spinner.New(),
}
}
func TestCmdSessions_ListsAllAndMarksActive(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
other, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
other.Close()
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
if m.modal == nil {
t.Fatal("expected a modal to be opened")
}
if len(m.modal.items) != 2 {
t.Fatalf("got %d items, want 2", len(m.modal.items))
}
idx := findItem(m.modal.items, active.ID)
if idx == -1 {
t.Fatal("active session missing from list")
}
if !m.modal.items[idx].protect || m.modal.items[idx].marker != "(aktiv)" {
t.Errorf("active session item = %+v, want protected and marked", m.modal.items[idx])
}
if m.modal.cursor != idx {
t.Errorf("cursor = %d, want %d (default to active session)", m.modal.cursor, idx)
}
}
func TestHandleModalKey_DeleteRequiresConfirmation(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
other, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
other.Close()
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
m.modal.cursor = findItem(m.modal.items, other.ID)
m.handleModalKey(deleteKey())
if !m.modal.confirmDelete {
t.Fatal("expected Del to arm a confirmation, not delete immediately")
}
if _, err := session.List(dir); err != nil {
t.Fatal(err)
}
m.handleModalKey(nKey())
if m.modal == nil || m.modal.confirmDelete {
t.Error("expected 'n' to cancel the confirmation without closing the modal")
}
infos, err := session.List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 2 {
t.Errorf("declining the confirmation must not delete anything, got %d sessions", len(infos))
}
}
func TestHandleModalKey_DeleteConfirmedRemovesSession(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
other, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
other.Close()
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
m.modal.cursor = findItem(m.modal.items, other.ID)
m.handleModalKey(deleteKey())
m.handleModalKey(yKey())
if m.modal != nil {
t.Error("expected modal to close after a confirmed delete")
}
infos, err := session.List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 || infos[0].ID != active.ID {
t.Errorf("expected only the active session left, got %+v", infos)
}
}
func TestHandleModalKey_CannotDeleteActiveSession(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
// Nur eine Session vorhanden -> Cursor steht zwangsläufig auf der aktiven.
m.handleModalKey(deleteKey())
if m.modal.confirmDelete {
t.Error("expected the active session to be protected from even arming a delete")
}
infos, err := session.List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 {
t.Error("active session must not be deletable")
}
}
func TestCmdInit_CreatesFilesAndReportsResult(t *testing.T) {
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
m := newTestModelForSessions(t, dir, store)
m.runCommand("/init")
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
t.Fatalf("entries = %+v, want a single entryCommand", m.entries)
}
text := m.entries[0].text
if !strings.Contains(text, "angelegt") {
t.Errorf("expected the report to mention what was created, got: %q", text)
}
if _, err := os.Stat(filepath.Join(dir, ".nub", "config.toml")); err != nil {
t.Errorf(".nub/config.toml missing: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil {
t.Errorf("AGENTS.md missing: %v", err)
}
}
func TestCmdInit_SecondRunSkipsExistingFiles(t *testing.T) {
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
m := newTestModelForSessions(t, dir, store)
m.runCommand("/init")
m.entries = nil
m.runCommand("/init")
if len(m.entries) != 1 {
t.Fatalf("entries = %+v", m.entries)
}
if !strings.Contains(m.entries[0].text, "übersprungen") {
t.Errorf("expected the second run to report skipped files, got: %q", m.entries[0].text)
}
}

560
internal/ui/tui/model.go Normal file
View file

@ -0,0 +1,560 @@
// Package tui ist die Bubbletea-Oberfläche (M6). Reiner Consumer von
// tool.UIEvent — kein anderes Paket importiert dieses hier, das Entfernen
// von internal/ui/tui lässt den Rest kompilieren und -p weiterhin
// funktionieren.
package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"nub/internal/agent"
"nub/internal/config"
"nub/internal/llm"
"nub/internal/mcpc"
"nub/internal/session"
"nub/internal/skill"
"nub/internal/tool"
)
type Model struct {
ctx context.Context
loop *agent.Loop
store *session.Store
cfg config.Config
skills []skill.Skill
mcpServers []*mcpc.Server
// sessionGen wird bei jedem Session-Wechsel (startSession) erhöht.
// Events aus einem vorherigen out-Channel tragen die alte Generation
// und werden ignoriert, statt fälschlich die ganze TUI zu beenden.
sessionGen int
sessionCancel context.CancelFunc
in chan agent.Input
out <-chan tool.UIEvent
viewport viewport.Model
textarea textarea.Model
spinner spinner.Model
entries []entry
liveText strings.Builder
liveThink strings.Builder
hasLiveText bool
hasLiveThink bool
history []string
historyIdx int
followupQueue []string
modelName string
tokensUsed int
cacheReadTokens int // kumulierte cache_read_tokens, Nachweis für Prompt-Caching (M7)
turnActive bool
statusMsg string
modal *listModal
// permCh liefert "ask"-Rückfragen des Backend-Loops (E-11); permReq ist
// die gerade angezeigte, noch unbeantwortete Anfrage.
permCh chan permissionRequest
permReq *permissionRequest
// askCh liefert Freitext-Rückfragen des question-Tools (E-11-Erweiterung);
// askReq ist die gerade angezeigte, noch unbeantwortete Anfrage. Antworten
// laufen über das reguläre Eingabefeld, siehe submit() und question.go.
askCh chan askRequest
askReq *askRequest
width, height int
ready bool
quitting bool
fatalErr error
}
// defaultInputPlaceholder ist der Standardtext des Eingabefelds. Wird
// während einer offenen question-Tool-Rückfrage kurzzeitig überschrieben
// (siehe askUser/answerQuestion in question.go).
const defaultInputPlaceholder = "Nachricht eingeben — Enter sendet, Alt+Enter queued als Follow-up, /help für Kommandos"
const askInputPlaceholder = "Antwort eingeben — Enter sendet"
type uiEventMsg struct {
gen int
ev tool.UIEvent
}
type eventsClosedMsg struct{ gen int }
type compactDoneMsg struct {
events []tool.UIEvent
err error
}
type asyncCommandMsg struct{ text string }
func New(ctx context.Context, loop *agent.Loop, store *session.Store, cfg config.Config, skills []skill.Skill, mcpServers []*mcpc.Server) *Model {
ta := textarea.New()
ta.Placeholder = defaultInputPlaceholder
ta.ShowLineNumbers = false
ta.SetHeight(3)
vp := viewport.New(80, 20)
vp.MouseWheelEnabled = true
sp := spinner.New(spinner.WithSpinner(spinner.Dot))
m := &Model{
ctx: ctx,
loop: loop,
cfg: cfg,
skills: skills,
mcpServers: mcpServers,
viewport: vp,
textarea: ta,
spinner: sp,
modelName: loop.Model,
permCh: make(chan permissionRequest),
askCh: make(chan askRequest),
}
loop.RequestPermission = m.requestPermission
loop.AskUser = m.askUser
m.startSession(store)
return m
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(
m.textarea.Focus(),
m.spinner.Tick,
listenEvents(m.sessionGen, m.out),
listenPermissionRequests(m.permCh),
listenAskRequests(m.askCh),
)
}
func listenEvents(gen int, out <-chan tool.UIEvent) tea.Cmd {
return func() tea.Msg {
ev, ok := <-out
if !ok {
return eventsClosedMsg{gen: gen}
}
return uiEventMsg{gen: gen, ev: ev}
}
}
// startSession bindet den Loop an eine (neue oder bestehende) Session:
// stoppt den vorherigen Backend-Loop sauber (falls einer läuft), öffnet
// frische in/out-Channels, und baut das Transkript aus der Historie der
// Session neu auf. Wird sowohl beim ersten Start als auch bei /sessions
// (Wechsel) verwendet.
func (m *Model) startSession(store *session.Store) tea.Cmd {
if m.sessionCancel != nil {
m.sessionCancel()
}
sessCtx, cancel := context.WithCancel(m.ctx)
m.sessionCancel = cancel
m.sessionGen++
m.store = store
m.loop.Session = store
m.in = make(chan agent.Input)
m.out = m.loop.Run(sessCtx, m.in)
nodes := store.PathNodes()
m.entries = replayEntries(nodes)
usage := sumUsage(nodes)
m.tokensUsed = usage.InputTokens + usage.OutputTokens
m.cacheReadTokens = usage.CacheReadTokens
m.turnActive = false
m.statusMsg = ""
m.followupQueue = nil
m.liveText.Reset()
m.liveThink.Reset()
m.hasLiveText = false
m.hasLiveThink = false
m.renderViewport()
return listenEvents(m.sessionGen, m.out)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
taHeight := 3
statusHeight := 1
gapHeight := 1 // Leerzeile zwischen Nachrichtenliste und Eingabefeld
vpHeight := msg.Height - taHeight - statusHeight - gapHeight - 1
if vpHeight < 3 {
vpHeight = 3
}
m.viewport.Width = msg.Width
m.viewport.Height = vpHeight
m.textarea.SetWidth(msg.Width)
m.renderViewport()
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.MouseMsg:
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case uiEventMsg:
if msg.gen != m.sessionGen {
return m, nil // Event aus einer inzwischen gewechselten Session
}
cmd := m.handleUIEvent(msg.ev)
return m, tea.Batch(cmd, listenEvents(msg.gen, m.out))
case eventsClosedMsg:
if msg.gen != m.sessionGen {
return m, nil // alter Loop wurde bewusst wegen Session-Wechsel beendet
}
m.quitting = true
return m, tea.Quit
case compactDoneMsg:
if msg.err != nil {
m.pushError("compact: " + msg.err.Error())
}
for _, ev := range msg.events {
m.handleUIEvent(ev)
}
if len(msg.events) == 0 && msg.err == nil {
m.pushCommand("nothing to compact (session too short)")
}
return m, nil
case asyncCommandMsg:
m.pushCommand(msg.text)
return m, nil
case permissionRequestMsg:
m.permReq = &msg.req
m.entries = append(m.entries, entry{kind: entryPermission, text: renderPermissionRequest(msg.req.tool, msg.req.input)})
m.renderViewport()
return m, listenPermissionRequests(m.permCh)
case askRequestMsg:
m.askReq = &msg.req
m.textarea.Placeholder = askInputPlaceholder
m.entries = append(m.entries, entry{kind: entryQuestion, text: renderAskRequest(msg.req.question, msg.req.options)})
m.renderViewport()
return m, listenAskRequests(m.askCh)
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.permReq != nil {
return m.handlePermissionKey(msg)
}
if m.modal != nil {
return m.handleModalKey(msg)
}
switch msg.Type {
case tea.KeyCtrlC:
// Nicht hier canceln: runCtx läuft auch in tea.WithContext ein, ein
// Cancel von innen lässt Program.Run() mit "program was killed"
// zurückkehren statt sauber über tea.Quit zu beenden. run.go
// canceled den Kontext, nachdem Program.Run() zurückgekehrt ist.
m.quitting = true
return m, tea.Quit
case tea.KeyEnter:
if msg.Alt {
return m, m.submit(m.textarea.Value(), true)
}
return m, m.submit(m.textarea.Value(), false)
case tea.KeyUp:
if strings.TrimSpace(m.textarea.Value()) == "" && len(m.history) > 0 {
if m.historyIdx > 0 {
m.historyIdx--
}
m.textarea.SetValue(m.history[m.historyIdx])
return m, nil
}
case tea.KeyDown:
if len(m.history) > 0 && m.historyIdx < len(m.history) {
m.historyIdx++
if m.historyIdx == len(m.history) {
m.textarea.SetValue("")
} else {
m.textarea.SetValue(m.history[m.historyIdx])
}
return m, nil
}
case tea.KeyPgUp:
m.viewport.PageUp()
return m, nil
case tea.KeyPgDown:
m.viewport.PageDown()
return m, nil
case tea.KeyCtrlU:
m.viewport.HalfPageUp()
return m, nil
case tea.KeyCtrlD:
m.viewport.HalfPageDown()
return m, nil
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *Model) View() string {
if !m.ready {
return "initializing…"
}
if m.quitting {
return "bye\n"
}
// Eine offene Permission-Rückfrage überlagert den Bildschirm bewusst
// NICHT (mehr) — sie steht als eigener Block im Transkript (siehe
// permissionRequestMsg in Update), damit der bisherige Verlauf für die
// Entscheidung sichtbar bleibt. Nur ein /tree- o.ä. Modal ist noch ein
// echtes Vollbild-Overlay, weil es kein Teil des Gesprächsflusses ist.
if m.modal != nil {
return m.renderModal()
}
return m.viewport.View() + "\n\n" + m.textarea.View() + "\n" + m.renderStatusLine()
}
func (m *Model) renderStatusLine() string {
state := "idle"
if m.turnActive {
state = m.spinner.View() + " running"
if m.statusMsg != "" {
state += ": " + m.statusMsg
}
}
if m.permReq != nil {
state = stylePermissionPending.Render("⚠ permission required — y=allow, other=deny")
} else if m.askReq != nil {
state = styleAskPending.Render("❓ question pending — type your answer and press Enter")
}
queued := ""
if n := len(m.followupQueue); n > 0 {
queued = fmt.Sprintf(" | queued: %d", n)
}
cache := ""
if m.cacheReadTokens > 0 {
cache = fmt.Sprintf(" (cached: %d)", m.cacheReadTokens)
}
line := fmt.Sprintf(" %s | model: %s | tokens: %d%s | session: %s%s ",
state, m.modelName, m.tokensUsed, cache, shortID(m.store.ID), queued)
return styleStatusBar.Width(m.width).Render(line)
}
func shortID(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
// pushSystem meldet ein Hintergrund-Ereignis, das nicht vom Nutzer
// angestoßen wurde (z.B. Auto-Compaction).
func (m *Model) pushSystem(text string) {
m.entries = append(m.entries, entry{kind: entrySystem, text: text})
m.renderViewport()
}
// pushCommand zeigt die Ausgabe eines vom Nutzer getippten Slash-Kommandos.
func (m *Model) pushCommand(text string) {
m.entries = append(m.entries, entry{kind: entryCommand, text: text})
m.renderViewport()
}
// pushError meldet einen echten Fehler (Loop-/Provider-Fehler oder ein
// fehlgeschlagenes Kommando).
func (m *Model) pushError(text string) {
m.entries = append(m.entries, entry{kind: entryError, text: text})
m.renderViewport()
}
// renderViewport baut den sichtbaren Transkript-Text aus allen committeten
// Entries plus dem gerade laufenden Streaming-Text neu zusammen.
func (m *Model) renderViewport() {
var b strings.Builder
for i, e := range m.entries {
if i > 0 {
b.WriteString("\n\n")
}
b.WriteString(renderEntry(e))
}
if m.hasLiveThink {
if len(m.entries) > 0 {
b.WriteString("\n\n")
}
b.WriteString(styleThinking.Render(m.liveThink.String()))
}
if m.hasLiveText {
if len(m.entries) > 0 || m.hasLiveThink {
b.WriteString("\n\n")
}
b.WriteString(styleAssistant.Render(m.liveText.String()))
}
content := wrapForViewport(b.String(), m.viewport.Width)
atBottom := m.viewport.AtBottom()
m.viewport.SetContent(content)
if atBottom {
m.viewport.GotoBottom()
}
}
// commitLive schließt den aktuellen Streaming-Text als fertigen Eintrag ab
// (mit Markdown-Rendering) und leert den Live-Puffer.
func (m *Model) commitLive() {
if m.hasLiveThink {
m.entries = append(m.entries, entry{kind: entryThinking, text: m.liveThink.String()})
m.liveThink.Reset()
m.hasLiveThink = false
}
if m.hasLiveText {
m.entries = append(m.entries, entry{kind: entryAssistant, text: m.liveText.String()})
m.liveText.Reset()
m.hasLiveText = false
}
}
func (m *Model) submit(text string, alt bool) tea.Cmd {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
m.textarea.Reset()
if m.askReq != nil {
m.answerQuestion(text)
return nil
}
m.history = append(m.history, text)
m.historyIdx = len(m.history)
if strings.HasPrefix(text, "/") {
return m.runCommand(text)
}
if alt && m.turnActive {
m.followupQueue = append(m.followupQueue, text)
m.entries = append(m.entries, entry{kind: entryQueued, text: text})
m.renderViewport()
return nil
}
return m.sendAsMessage(text)
}
// sendAsMessage schickt text als echte Chat-Nachricht ab: sichtbar im
// Transkript und an den Backend-Loop. Wird sowohl für normale Eingaben als
// auch für Skill-Slash-Commands genutzt (die eine synthetische Direktive
// statt der Roheingabe senden).
func (m *Model) sendAsMessage(text string) tea.Cmd {
m.entries = append(m.entries, entry{kind: entryUser, text: text})
m.turnActive = true
m.renderViewport()
return m.sendInput(text)
}
func (m *Model) sendInput(text string) tea.Cmd {
return func() tea.Msg {
select {
case m.in <- agent.Input{Text: text}:
case <-m.ctx.Done():
}
return nil
}
}
func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd {
switch e := ev.(type) {
case tool.TextDelta:
m.turnActive = true
m.hasLiveText = true
m.liveText.WriteString(e.Text)
m.renderViewport()
case tool.ThinkingDelta:
m.turnActive = true
m.hasLiveThink = true
m.liveThink.WriteString(e.Text)
m.renderViewport()
case tool.ToolCallStart:
m.commitLive()
m.statusMsg = e.Name
m.entries = append(m.entries, entry{kind: entryToolCall, text: renderToolCall(e.Name, e.Input)})
m.renderViewport()
case tool.ToolStream:
// Kompaktes Live-Log für bash & Co.; nicht Zeile für Zeile ins
// Transkript, das würde es bei langem Output unlesbar machen.
m.statusMsg = e.ID + "…"
case tool.ToolCallOutput:
m.statusMsg = ""
m.entries = append(m.entries, entry{kind: entryToolResult, text: renderToolResult(e.Name, e.Result)})
m.renderViewport()
case tool.TurnDone:
m.commitLive()
m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens
m.cacheReadTokens += e.Usage.CacheReadTokens
if e.Stop != llm.StopToolUse {
m.turnActive = false
m.statusMsg = ""
if len(m.followupQueue) > 0 {
next := m.followupQueue[0]
m.followupQueue = m.followupQueue[1:]
return m.sendInput(next)
}
}
case tool.ErrorEvent:
m.commitLive()
m.turnActive = false
m.statusMsg = ""
m.entries = append(m.entries, entry{kind: entryError, text: e.Err.Error()})
m.renderViewport()
case tool.CompactionEvent:
m.entries = append(m.entries, entry{
kind: entrySystem,
text: fmt.Sprintf("compacted %d nodes into a %d-token summary", e.ReplacedNodes, e.SummaryTokens),
})
m.renderViewport()
}
return nil
}

View file

@ -0,0 +1,276 @@
package tui
import (
"context"
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
"nub/internal/agent"
"nub/internal/llm"
"nub/internal/skill"
"nub/internal/tool"
)
func newTestModel(t *testing.T) *Model {
t.Helper()
return &Model{
ctx: context.Background(),
in: make(chan agent.Input, 1),
textarea: textarea.New(),
viewport: viewport.New(80, 20),
spinner: spinner.New(),
}
}
func TestSubmit_SlashCommandDoesNotTouchInChannel(t *testing.T) {
m := newTestModel(t)
m.entries = []entry{{kind: entryUser, text: "old"}}
cmd := m.submit("/clear", false)
if cmd != nil {
t.Error("expected /clear to return a nil cmd (no async work)")
}
if len(m.entries) != 0 {
t.Errorf("expected entries cleared, got %d", len(m.entries))
}
select {
case v := <-m.in:
t.Errorf("slash command must not send on in channel, got %+v", v)
default:
}
}
func TestSubmit_AltEnterDuringActiveTurn_Queues(t *testing.T) {
m := newTestModel(t)
m.turnActive = true
cmd := m.submit("do this next", true)
if cmd != nil {
t.Error("expected queuing to return a nil cmd (nothing sent yet)")
}
if len(m.followupQueue) != 1 || m.followupQueue[0] != "do this next" {
t.Errorf("followupQueue = %+v", m.followupQueue)
}
select {
case v := <-m.in:
t.Errorf("queued follow-up must not be sent immediately, got %+v", v)
default:
}
if len(m.entries) != 1 || m.entries[0].kind != entryQueued || m.entries[0].text != "do this next" {
t.Errorf("entries = %+v", m.entries)
}
}
func TestSubmit_PlainMessage_SendsOnInChannel(t *testing.T) {
m := newTestModel(t)
cmd := m.submit("hello", false)
if cmd == nil {
t.Fatal("expected a cmd that sends the message")
}
cmd() // tea.Cmd ist nur func() tea.Msg
select {
case got := <-m.in:
if got.Text != "hello" {
t.Errorf("sent text = %q, want hello", got.Text)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for input to be sent")
}
if !m.turnActive {
t.Error("expected turnActive=true after sending a message")
}
}
func TestHandleUIEvent_TurnDoneFlushesFollowupQueue(t *testing.T) {
m := newTestModel(t)
m.turnActive = true
m.followupQueue = []string{"queued message"}
cmd := m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd})
if m.turnActive {
t.Error("expected turnActive=false after a terminal TurnDone")
}
if len(m.followupQueue) != 0 {
t.Errorf("expected queue drained, got %+v", m.followupQueue)
}
if cmd == nil {
t.Fatal("expected a cmd flushing the queued follow-up")
}
cmd()
select {
case got := <-m.in:
if got.Text != "queued message" {
t.Errorf("flushed text = %q", got.Text)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for queued follow-up to be sent")
}
}
func TestHandleUIEvent_ToolUseTurnDoneDoesNotFlushQueue(t *testing.T) {
m := newTestModel(t)
m.turnActive = true
m.followupQueue = []string{"queued message"}
m.handleUIEvent(tool.TurnDone{Stop: llm.StopToolUse})
if !m.turnActive {
t.Error("turn should still be active while tool_use continues")
}
if len(m.followupQueue) != 1 {
t.Errorf("queue should stay intact until the turn actually ends, got %+v", m.followupQueue)
}
}
func TestRunCommand_ExitReturnsQuitWithoutCancelingContext(t *testing.T) {
m := newTestModel(t)
cmd := m.runCommand("/exit")
if cmd == nil {
t.Fatal("expected /exit to return tea.Quit")
}
if !m.quitting {
t.Error("expected quitting=true")
}
// /exit darf den Kontext NICHT selbst canceln: der ist auch an
// tea.WithContext gebunden, ein Cancel von innen lässt Program.Run()
// mit einem "program was killed"-Fehler zurückkehren statt sauber über
// tea.Quit zu beenden (siehe run.go).
select {
case <-m.ctx.Done():
t.Error("/exit must not cancel the context itself, run.go does that after Program.Run() returns")
default:
}
}
func TestRunCommand_UnknownCommandIsAnError(t *testing.T) {
m := newTestModel(t)
m.runCommand("/bogus")
if len(m.entries) != 1 || m.entries[0].kind != entryError {
t.Errorf("entries = %+v, want a single entryError", m.entries)
}
}
func TestRunCommand_HelpListsOneCommandPerLineWithDescription(t *testing.T) {
m := newTestModel(t)
m.runCommand("/help")
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
t.Fatalf("entries = %+v, want a single entryCommand", m.entries)
}
text := m.entries[0].text
lines := strings.Split(text, "\n")
if len(lines) != len(helpCommands)+1 { // +1 für die "Kommandos:"-Kopfzeile
t.Fatalf("got %d lines, want %d (one per command + header):\n%s", len(lines), len(helpCommands)+1, text)
}
for _, c := range helpCommands {
if !strings.Contains(text, c.cmd) || !strings.Contains(text, c.desc) {
t.Errorf("help text missing %q / %q:\n%s", c.cmd, c.desc, text)
}
}
}
func TestRunCommand_SkillNameInvokesSkillAsDirective(t *testing.T) {
m := newTestModel(t)
m.skills = []skill.Skill{{Name: "refactoring", Description: "d"}}
cmd := m.runCommand("/refactoring focus on error handling")
if cmd == nil {
t.Fatal("expected a cmd sending the skill directive")
}
cmd()
if len(m.entries) != 1 || m.entries[0].kind != entryUser {
t.Fatalf("entries = %+v, want a single entryUser", m.entries)
}
got := m.entries[0].text
if !strings.Contains(got, `"refactoring"`) || !strings.Contains(got, "read_skill") {
t.Errorf("directive should name the skill and read_skill, got: %q", got)
}
if !strings.Contains(got, "focus on error handling") {
t.Errorf("directive should include the trailing args, got: %q", got)
}
select {
case sent := <-m.in:
if sent.Text != got {
t.Errorf("sent text = %q, want it to match the displayed directive %q", sent.Text, got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for the skill directive to be sent")
}
}
func TestRunCommand_BuiltinCommandWinsOverSameNamedSkill(t *testing.T) {
m := newTestModel(t)
m.skills = []skill.Skill{{Name: "clear", Description: "a skill that happens to be named like a builtin"}}
m.entries = []entry{{kind: entryUser, text: "old"}}
m.runCommand("/clear")
if len(m.entries) != 0 {
t.Errorf("expected the builtin /clear to win and wipe entries, got %+v", m.entries)
}
}
func TestHelpText_ListsSkillsWhenPresent(t *testing.T) {
m := newTestModel(t)
m.skills = []skill.Skill{{Name: "refactoring", Description: "Vorgehen für Refactorings"}}
text := m.helpText()
if !strings.Contains(text, "/refactoring") || !strings.Contains(text, "Vorgehen für Refactorings") {
t.Errorf("help text should list the skill as a command, got: %q", text)
}
}
func TestRunCommand_ClearIsCommandNotError(t *testing.T) {
m := newTestModel(t)
m.runCommand("/help")
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
t.Errorf("entries = %+v, want a single entryCommand", m.entries)
}
}
func TestHandleUIEvent_TurnDoneAccumulatesCacheReadTokens(t *testing.T) {
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd, Usage: llm.Usage{InputTokens: 1200, OutputTokens: 5, CacheReadTokens: 896}})
if m.cacheReadTokens != 896 {
t.Errorf("cacheReadTokens = %d, want 896", m.cacheReadTokens)
}
if m.tokensUsed != 1205 {
t.Errorf("tokensUsed = %d, want 1205", m.tokensUsed)
}
m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd, Usage: llm.Usage{InputTokens: 1300, OutputTokens: 8, CacheReadTokens: 1100}})
if m.cacheReadTokens != 1996 {
t.Errorf("cacheReadTokens after second turn = %d, want 1996 (cumulative)", m.cacheReadTokens)
}
if !strings.Contains(m.renderStatusLine(), "cached: 1996") {
t.Errorf("status line should surface cumulative cache_read_tokens, got: %q", m.renderStatusLine())
}
}
func TestHandleUIEvent_StreamingTextAccumulatesAndCommits(t *testing.T) {
m := newTestModel(t)
m.handleUIEvent(tool.TextDelta{Text: "hello "})
m.handleUIEvent(tool.TextDelta{Text: "world"})
if !m.hasLiveText || m.liveText.String() != "hello world" {
t.Errorf("live text = %q, hasLiveText=%v", m.liveText.String(), m.hasLiveText)
}
m.commitLive()
if m.hasLiveText {
t.Error("expected live text cleared after commit")
}
if len(m.entries) != 1 || m.entries[0].kind != entryAssistant || m.entries[0].text != "hello world" {
t.Errorf("entries = %+v", m.entries)
}
}

View file

@ -0,0 +1,82 @@
package tui
import (
"context"
"encoding/json"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// permissionRequest kommt vom Backend-Loop (E-11: "ask"-Modus) über
// Model.permCh. resp erwartet genau eine Antwort; requestPermission blockiert
// darauf im Loop-Goroutine, bis die TUI eine Taste verarbeitet hat.
type permissionRequest struct {
tool string
input json.RawMessage
resp chan bool
}
type permissionRequestMsg struct{ req permissionRequest }
func listenPermissionRequests(ch <-chan permissionRequest) tea.Cmd {
return func() tea.Msg {
req, ok := <-ch
if !ok {
return nil
}
return permissionRequestMsg{req: req}
}
}
// requestPermission ist der Hook, den loop.RequestPermission aufruft (siehe
// New()). Läuft auf dem Goroutine des Backend-Loops, nicht dem der TUI —
// deshalb reine Channel-Kommunikation statt direktem Feldzugriff auf m.
func (m *Model) requestPermission(ctx context.Context, toolName string, input json.RawMessage) bool {
resp := make(chan bool, 1)
req := permissionRequest{tool: toolName, input: input, resp: resp}
select {
case m.permCh <- req:
case <-ctx.Done():
return false
}
select {
case decision := <-resp:
return decision
case <-ctx.Done():
return false
}
}
func (m *Model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
req := m.permReq
m.permReq = nil
allow := msg.String() == "y" || msg.String() == "Y"
req.resp <- allow
verb := "denied"
if allow {
verb = "allowed"
}
m.pushCommand(fmt.Sprintf("permission %s: %s", verb, req.tool))
return m, nil
}
// renderPermissionRequest baut den Transkript-Block für eine offene
// Rückfrage — bewusst kein Vollbild-Overlay (das hat vorher den Kontext
// verdeckt, den man für die Entscheidung braucht), sondern ein eigener,
// umrandeter Eintrag im normalen Nachrichtenfluss. Viewport, Eingabefeld und
// Statuszeile bleiben sichtbar; die Statuszeile zeigt zusätzlich einen
// Hinweis (siehe renderStatusLine).
func renderPermissionRequest(toolName string, input json.RawMessage) string {
in := strings.TrimSpace(prettyJSON(input))
if len(in) > 300 {
in = in[:300] + "…"
}
body := fmt.Sprintf("⚠ Rückfrage: %s\n%s\n\n[y] erlauben [n / Esc / beliebige andere Taste] ablehnen", toolName, in)
return stylePermissionBox.Render(body)
}

View file

@ -0,0 +1,171 @@
package tui
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
func TestRequestPermission_BlocksUntilAnswered(t *testing.T) {
m := newTestModel(t)
m.permCh = make(chan permissionRequest)
resultCh := make(chan bool, 1)
go func() {
resultCh <- m.requestPermission(context.Background(), "edit", json.RawMessage(`{"path":"main.go"}`))
}()
var req permissionRequest
select {
case req = <-m.permCh:
case <-time.After(time.Second):
t.Fatal("timed out waiting for the request on permCh")
}
if req.tool != "edit" {
t.Errorf("req.tool = %q, want edit", req.tool)
}
select {
case <-resultCh:
t.Fatal("requestPermission returned before the response was sent")
case <-time.After(50 * time.Millisecond):
}
req.resp <- true
select {
case got := <-resultCh:
if !got {
t.Error("expected requestPermission to return true after resp<-true")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for requestPermission to return")
}
}
func TestRequestPermission_ContextCancelDeniesWithoutHanging(t *testing.T) {
m := newTestModel(t)
m.permCh = make(chan permissionRequest) // niemand liest daraus
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan bool, 1)
go func() {
resultCh <- m.requestPermission(ctx, "bash", json.RawMessage(`{"command":"ls"}`))
}()
cancel()
select {
case got := <-resultCh:
if got {
t.Error("expected a cancelled context to deny, got true")
}
case <-time.After(time.Second):
t.Fatal("requestPermission did not return after context cancellation")
}
}
func TestHandlePermissionKey_YesAllows(t *testing.T) {
m := newTestModel(t)
resp := make(chan bool, 1)
m.permReq = &permissionRequest{tool: "edit", input: json.RawMessage(`{}`), resp: resp}
m.handlePermissionKey(yKey())
if m.permReq != nil {
t.Error("expected permReq to be cleared")
}
select {
case got := <-resp:
if !got {
t.Error("expected 'y' to allow")
}
default:
t.Fatal("expected a response to be sent on resp")
}
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
t.Errorf("expected a recorded command entry, got %+v", m.entries)
}
}
func TestHandlePermissionKey_AnyOtherKeyDenies(t *testing.T) {
m := newTestModel(t)
resp := make(chan bool, 1)
m.permReq = &permissionRequest{tool: "bash", input: json.RawMessage(`{}`), resp: resp}
m.handlePermissionKey(escKey())
if m.permReq != nil {
t.Error("expected permReq to be cleared")
}
select {
case got := <-resp:
if got {
t.Error("expected esc to deny")
}
default:
t.Fatal("expected a response to be sent on resp")
}
}
func TestUpdate_PermissionRequestMsgOpensPromptAndReListens(t *testing.T) {
m := newTestModel(t)
m.permCh = make(chan permissionRequest, 1)
req := permissionRequest{tool: "write", input: json.RawMessage(`{"path":"x"}`), resp: make(chan bool, 1)}
_, cmd := m.Update(permissionRequestMsg{req: req})
if m.permReq == nil || m.permReq.tool != "write" {
t.Fatalf("expected permReq to be set to the incoming request, got %+v", m.permReq)
}
if cmd == nil {
t.Fatal("expected Update to re-arm listenPermissionRequests")
}
if len(m.entries) != 1 || m.entries[0].kind != entryPermission {
t.Fatalf("expected the request to appear inline in the transcript, got %+v", m.entries)
}
if !strings.Contains(m.entries[0].text, "write") {
t.Errorf("permission entry should name the tool, got: %q", m.entries[0].text)
}
}
// TestView_PermissionRequestDoesNotTakeOverTheScreen ist der eigentliche
// Regressionstest für das Overlay-Problem: eine offene Rückfrage darf die
// Nachrichtenliste/Eingabe/Statuszeile nicht verdecken, sonst sieht man
// nicht mehr, was vorher im Transkript passiert ist.
func TestView_PermissionRequestDoesNotTakeOverTheScreen(t *testing.T) {
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
m.ready = true
m.width, m.height = 80, 24
m.viewport.Width, m.viewport.Height = 80, 20
m.entries = []entry{{kind: entryUser, text: "hello from before"}}
m.renderViewport()
m.permReq = &permissionRequest{tool: "edit", input: json.RawMessage(`{}`), resp: make(chan bool, 1)}
view := m.View()
if !strings.Contains(view, m.textarea.View()) {
t.Error("expected the input box to remain visible while a permission request is pending")
}
if !strings.Contains(stripANSI(view), "hello from before") {
t.Error("expected prior transcript content to remain visible, not be replaced by a full-screen overlay")
}
}
func stripANSI(s string) string {
var b strings.Builder
inEscape := false
for _, r := range s {
if r == '\x1b' {
inEscape = true
continue
}
if inEscape {
if r == 'm' {
inEscape = false
}
continue
}
b.WriteRune(r)
}
return b.String()
}

View file

@ -0,0 +1,77 @@
package tui
import (
"context"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// askRequest kommt vom question-Tool über Model.askCh. Anders als eine
// Permission-Rückfrage (bool, Tastendruck) ist die Antwort hier Freitext —
// die Antwort läuft deshalb über das normale Eingabefeld: submit() prüft
// zuerst, ob eine Frage offen ist, und behandelt den getippten Text als
// Antwort statt als neue Chat-Nachricht/Slash-Command (siehe submit in
// model.go).
type askRequest struct {
question string
options []string
resp chan string
}
type askRequestMsg struct{ req askRequest }
func listenAskRequests(ch <-chan askRequest) tea.Cmd {
return func() tea.Msg {
req, ok := <-ch
if !ok {
return nil
}
return askRequestMsg{req: req}
}
}
// askUser ist der Hook, den loop.AskUser aufruft (siehe New()). Läuft auf
// dem Goroutine des Backend-Loops, nicht dem der TUI.
func (m *Model) askUser(ctx context.Context, question string, options []string) (string, error) {
resp := make(chan string, 1)
req := askRequest{question: question, options: options, resp: resp}
select {
case m.askCh <- req:
case <-ctx.Done():
return "", ctx.Err()
}
select {
case answer := <-resp:
return answer, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// answerQuestion schließt die offene Frage ab und schickt answer an den
// wartenden Loop-Goroutine zurück. resp ist mit Cap 1 gepuffert, der Send
// blockiert also nie. Die Bestätigung im Transkript kommt bewusst nicht von
// hier, sondern aus dem regulären ToolCallOutput-Event (renderToolResult,
// builtin.QuestionResult) — sonst erschiene die Antwort doppelt.
func (m *Model) answerQuestion(answer string) {
req := m.askReq
m.askReq = nil
m.textarea.Placeholder = defaultInputPlaceholder
req.resp <- answer
}
// renderAskRequest baut den Transkript-Block für eine offene Frage — wie
// bei Permissions kein Vollbild-Overlay, sondern ein Eintrag im normalen
// Nachrichtenfluss (siehe renderPermissionRequest).
func renderAskRequest(question string, options []string) string {
var b strings.Builder
b.WriteString("❓ Rückfrage: " + question)
if len(options) > 0 {
b.WriteString("\nVorschläge: " + strings.Join(options, " · "))
}
b.WriteString("\n\nAntwort eingeben und Enter drücken.")
return styleAskBox.Render(b.String())
}

View file

@ -0,0 +1,146 @@
package tui
import (
"context"
"strings"
"testing"
"time"
)
func TestAskUser_BlocksUntilAnswered(t *testing.T) {
m := newTestModel(t)
m.askCh = make(chan askRequest)
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
answer, err := m.askUser(context.Background(), "which format?", []string{"json", "yaml"})
resultCh <- answer
errCh <- err
}()
var req askRequest
select {
case req = <-m.askCh:
case <-time.After(time.Second):
t.Fatal("timed out waiting for the request on askCh")
}
if req.question != "which format?" {
t.Errorf("req.question = %q, want %q", req.question, "which format?")
}
if len(req.options) != 2 || req.options[0] != "json" || req.options[1] != "yaml" {
t.Errorf("req.options = %v", req.options)
}
select {
case <-resultCh:
t.Fatal("askUser returned before the response was sent")
case <-time.After(50 * time.Millisecond):
}
req.resp <- "json please"
select {
case got := <-resultCh:
if got != "json please" {
t.Errorf("askUser returned %q, want %q", got, "json please")
}
if err := <-errCh; err != nil {
t.Errorf("unexpected error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for askUser to return")
}
}
func TestAskUser_ContextCancelReturnsErrorWithoutHanging(t *testing.T) {
m := newTestModel(t)
m.askCh = make(chan askRequest) // niemand liest daraus
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, err := m.askUser(ctx, "irrelevant", nil)
errCh <- err
}()
cancel()
select {
case err := <-errCh:
if err == nil {
t.Error("expected a cancelled context to return an error")
}
case <-time.After(time.Second):
t.Fatal("askUser did not return after context cancellation")
}
}
func TestUpdate_AskRequestMsgOpensPromptAndReListens(t *testing.T) {
m := newTestModel(t)
m.askCh = make(chan askRequest, 1)
req := askRequest{question: "which format?", options: []string{"json", "yaml"}, resp: make(chan string, 1)}
_, cmd := m.Update(askRequestMsg{req: req})
if m.askReq == nil || m.askReq.question != "which format?" {
t.Fatalf("expected askReq to be set to the incoming request, got %+v", m.askReq)
}
if cmd == nil {
t.Fatal("expected Update to re-arm listenAskRequests")
}
if len(m.entries) != 1 || m.entries[0].kind != entryQuestion {
t.Fatalf("expected the request to appear inline in the transcript, got %+v", m.entries)
}
if !strings.Contains(m.entries[0].text, "which format?") {
t.Errorf("question entry should contain the question, got: %q", m.entries[0].text)
}
if m.textarea.Placeholder != askInputPlaceholder {
t.Errorf("expected the placeholder to switch to askInputPlaceholder, got %q", m.textarea.Placeholder)
}
}
func TestSubmit_WithOpenAskRequestAnswersInsteadOfSendingAMessage(t *testing.T) {
m := newTestModel(t)
resp := make(chan string, 1)
m.askReq = &askRequest{question: "which format?", resp: resp}
cmd := m.submit("json please", false)
if cmd != nil {
t.Error("expected submit to not return a chat/command cmd while answering a question")
}
if m.askReq != nil {
t.Error("expected askReq to be cleared")
}
select {
case got := <-resp:
if got != "json please" {
t.Errorf("resp received %q, want %q", got, "json please")
}
default:
t.Fatal("expected the answer to be sent on resp")
}
if m.textarea.Placeholder != defaultInputPlaceholder {
t.Errorf("expected the placeholder to reset to defaultInputPlaceholder, got %q", m.textarea.Placeholder)
}
if len(m.history) != 0 {
t.Error("expected the answer to not be recorded as chat history")
}
}
func TestView_AskRequestDoesNotTakeOverTheScreen(t *testing.T) {
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
m.ready = true
m.width, m.height = 80, 24
m.viewport.Width, m.viewport.Height = 80, 20
m.entries = []entry{{kind: entryUser, text: "hello from before"}}
m.renderViewport()
m.askReq = &askRequest{question: "which format?", resp: make(chan string, 1)}
view := m.View()
if !strings.Contains(view, m.textarea.View()) {
t.Error("expected the input box to remain visible while a question is pending")
}
if !strings.Contains(stripANSI(view), "hello from before") {
t.Error("expected prior transcript content to remain visible, not be replaced by a full-screen overlay")
}
}

200
internal/ui/tui/render.go Normal file
View file

@ -0,0 +1,200 @@
package tui
import (
"encoding/json"
"fmt"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"nub/internal/mcpc"
"nub/internal/tool"
"nub/internal/tool/builtin"
)
// wrapForViewport bricht Text auf die gegebene Breite um. bubbles/viewport
// bricht Inhalt nicht selbst um — es zeigt ihn nur an und lässt lange
// Zeilen horizontal überlaufen. lipgloss übernimmt den (ANSI-bewussten)
// Zeilenumbruch. width<=0 (z.B. vor dem ersten WindowSizeMsg) lässt den
// Text unverändert.
func wrapForViewport(content string, width int) string {
if width <= 0 {
return content
}
return lipgloss.NewStyle().Width(width).Render(content)
}
type entryKind int
const (
entryUser entryKind = iota
entryQueued
entryAssistant
entryThinking
entryToolCall
entryToolResult
entrySystem // Hintergrund-Ereignisse, nicht vom Nutzer angestoßen (z.B. Auto-Compaction)
entryCommand // Ausgabe eines Slash-Kommandos, das der Nutzer selbst getippt hat
entryError // echte Fehler (Loop-/Provider-Fehler, fehlgeschlagene Kommandos)
entryPermission // offene "ask"-Rückfrage, siehe permission.go
entryQuestion // offene Rückfrage des question-Tools, siehe question.go
)
type entry struct {
kind entryKind
text string // bereits gestylter, fertiger Text
}
var markdownRenderer, _ = glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(0), // kein Wrap hier — Model.renderViewport bricht den gesamten Inhalt einmalig auf die aktuelle Breite um
)
func renderMarkdown(s string) string {
if markdownRenderer == nil || strings.TrimSpace(s) == "" {
return s
}
out, err := markdownRenderer.Render(s)
if err != nil {
return s
}
return strings.TrimRight(out, "\n")
}
func renderEntry(e entry) string {
switch e.kind {
case entryUser:
return styleUser.Render(" "+firstLine(e.text)) + tailLines(e.text)
case entryQueued:
return styleQueued.Render("⏳ "+firstLine(e.text)) + tailLines(e.text)
case entryAssistant:
return styleAssistant.Render(renderMarkdown(e.text))
case entryThinking:
return styleThinking.Render(e.text)
case entryToolCall:
return styleToolCall.Render(e.text)
case entryToolResult:
return e.text // bereits gestylt (ok/error-Farbe je nach Ergebnis)
case entrySystem:
return styleSystem.Render("· " + e.text)
case entryCommand:
return styleCommand.Render(e.text)
case entryError:
return styleError.Render("✗ " + e.text)
case entryPermission:
return e.text // bereits als umrandeter Block gerendert (renderPermissionRequest)
case entryQuestion:
return e.text // bereits als umrandeter Block gerendert (renderAskRequest)
default:
return e.text
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func tailLines(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return "\n" + s[i+1:]
}
return ""
}
// renderToolCall zeigt Name + kompakte Eingabe.
func renderToolCall(name string, input []byte) string {
in := strings.TrimSpace(prettyJSON(input))
if len(in) > 300 {
in = in[:300] + "…"
}
return fmt.Sprintf("→ %s %s", name, in)
}
// renderToolResult wählt je nach ForUI-Typ eine spezifische Darstellung
// (Diff-Overlay für write/edit, kompakte Zusammenfassung sonst).
func renderToolResult(name string, res tool.Result) string {
status := styleToolOK.Render("✓")
if res.IsError {
status = styleToolErr.Render("✗")
}
header := fmt.Sprintf("%s %s", status, name)
switch ui := res.ForUI.(type) {
case builtin.DiffResult:
return header + "\n" + renderDiff(ui)
case builtin.BashResult:
out := res.ForModel
if len(out) > 2000 {
out = out[:2000] + "\n… [truncated in TUI, see ForModel für vollen Output]"
}
return header + fmt.Sprintf(" (exit %d)\n%s", ui.ExitCode, out)
case builtin.FileResult:
return header + fmt.Sprintf(" %s (%d Zeilen)", ui.Path, ui.Lines)
case builtin.GlobResult:
return header + fmt.Sprintf(" %d Treffer", len(ui.Paths))
case builtin.GrepResult:
return header + fmt.Sprintf(" %d Treffer", ui.Count)
case builtin.TodoResult:
return header + "\n" + renderTodos(ui)
case builtin.SkillResult:
return header + fmt.Sprintf(" %s geladen (%s)", ui.Name, ui.Dir)
case mcpc.Result:
return header + " " + res.ForModel
case builtin.QuestionResult:
return fmt.Sprintf("%s\nFrage: %s\nAntwort: %s", header, ui.Question, ui.Answer)
default:
out := res.ForModel
if len(out) > 1000 {
out = out[:1000] + "…"
}
return header + "\n" + out
}
}
func renderDiff(d builtin.DiffResult) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n", d.Path)
for _, line := range d.Lines {
switch line.Op {
case builtin.DiffAdd:
b.WriteString(styleDiffAdd.Render("+ " + line.Text))
case builtin.DiffRemove:
b.WriteString(styleDiffDel.Render("- " + line.Text))
default:
b.WriteString(" " + line.Text)
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func renderTodos(t builtin.TodoResult) string {
var b strings.Builder
for _, it := range t.Items {
mark := " "
switch it.Status {
case "in_progress":
mark = "~"
case "completed":
mark = "x"
}
fmt.Fprintf(&b, "[%s] %s\n", mark, it.Content)
}
return strings.TrimRight(b.String(), "\n")
}
func prettyJSON(raw json.RawMessage) string {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return string(raw)
}
return string(out)
}

View file

@ -0,0 +1,74 @@
package tui
import (
"strings"
"testing"
"nub/internal/tool"
"nub/internal/tool/builtin"
)
func TestRenderToolResult_DiffShowsAddAndRemove(t *testing.T) {
res := tool.Result{
ForModel: "1 replacement(s)",
ForUI: builtin.DiffResult{
Path: "f.go",
Lines: []builtin.DiffLine{
{Op: builtin.DiffRemove, Text: "old line"},
{Op: builtin.DiffAdd, Text: "new line"},
},
},
}
out := renderToolResult("edit", res)
if !strings.Contains(out, "- old line") {
t.Errorf("missing removed line in: %q", out)
}
if !strings.Contains(out, "+ new line") {
t.Errorf("missing added line in: %q", out)
}
}
func TestRenderToolResult_ErrorUsesErrorMarker(t *testing.T) {
out := renderToolResult("bash", tool.Result{ForModel: "boom", IsError: true})
if !strings.Contains(out, "✗") {
t.Errorf("expected error marker in: %q", out)
}
if strings.Contains(out, "✓") {
t.Errorf("did not expect success marker in: %q", out)
}
}
func TestRenderToolResult_OKUsesSuccessMarker(t *testing.T) {
out := renderToolResult("bash", tool.Result{ForModel: "ok"})
if !strings.Contains(out, "✓") {
t.Errorf("expected success marker in: %q", out)
}
}
func TestWrapForViewport_WrapsLongLinesToWidth(t *testing.T) {
long := strings.Repeat("word ", 40) // deutlich länger als 20 Spalten
wrapped := wrapForViewport(long, 20)
for _, line := range strings.Split(wrapped, "\n") {
if len([]rune(line)) > 20 {
t.Errorf("line exceeds width 20 (%d runes): %q", len([]rune(line)), line)
}
}
if wrapped == long {
t.Error("expected wrapping to actually change the content")
}
}
func TestWrapForViewport_ZeroWidthIsNoop(t *testing.T) {
s := "some text that would otherwise wrap"
if got := wrapForViewport(s, 0); got != s {
t.Errorf("width<=0 should leave content untouched, got %q", got)
}
}
func TestRenderToolCall_PrettyPrintsJSONInput(t *testing.T) {
out := renderToolCall("read", []byte(`{"path":"a.go"}`))
if !strings.Contains(out, "read") || !strings.Contains(out, "a.go") {
t.Errorf("unexpected render: %q", out)
}
}

41
internal/ui/tui/run.go Normal file
View file

@ -0,0 +1,41 @@
package tui
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"nub/internal/agent"
"nub/internal/config"
"nub/internal/mcpc"
"nub/internal/session"
"nub/internal/skill"
"nub/internal/tool"
)
// Run startet die TUI und blockiert, bis der Nutzer beendet oder der
// Kontext abbricht. loop.Run wird intern gestartet (auch erneut bei einem
// Session-Wechsel über /sessions); die TUI ist ein reiner Consumer des
// jeweils aktuellen UIEvent-Channels (E-04).
func Run(ctx context.Context, loop *agent.Loop, store *session.Store, cfg config.Config, skills []skill.Skill, mcpServers []*mcpc.Server) error {
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
m := New(runCtx, loop, store, cfg, skills, mcpServers)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(runCtx), tea.WithMouseCellMotion())
finalModel, err := p.Run()
// `in` bewusst nicht schließen: ein noch laufender sendInput-Cmd könnte
// gerade darauf senden; Close+Send racet in einen "send on closed
// channel"-Panic. ctx.Done() reicht, damit loop.Run() sauber zurückkehrt.
cancel()
if fm, ok := finalModel.(*Model); ok && fm.out != nil {
for range fm.out {
// Drainen, damit loop.Run() nicht an einem blockierenden Send hängen bleibt.
}
}
return err
}
var _ tool.UIEvent // Import-Anker: tool wird über model.go/render.go transitiv gebraucht

View file

@ -0,0 +1,226 @@
package tui
import (
"context"
"iter"
"testing"
"time"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
"nub/internal/agent"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
// idleProvider wird in diesen Tests nie tatsächlich gestreamt — der Loop
// läuft nur, um startSession realistisch zu testen (in/out-Channel-Handling),
// ohne dass eine Nachricht gesendet wird.
type idleProvider struct{}
func (idleProvider) Name() string { return "idle" }
func (idleProvider) Caps() llm.Caps { return llm.Caps{} }
func (idleProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
return func(yield func(llm.Event, error) bool) {}, nil
}
func newSwitchableModel(t *testing.T, dir string, store *session.Store) *Model {
t.Helper()
loop := &agent.Loop{
Provider: idleProvider{},
Tools: tool.NewRegistry(),
Model: "test-model",
Env: tool.Env{Cwd: dir, RepoRoot: dir},
}
return &Model{
ctx: context.Background(),
loop: loop,
textarea: textarea.New(),
viewport: viewport.New(80, 20),
spinner: spinner.New(),
modelName: loop.Model,
}
}
func TestStartSession_ReplaysHistoryAndBumpsGeneration(t *testing.T) {
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
n1 := session.NewNode("", textMsg(llm.RoleUser, "hello from history"), session.NodeMeta{})
if err := store.Append(n1); err != nil {
t.Fatal(err)
}
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "hi there"), session.NodeMeta{})
if err := store.Append(n2); err != nil {
t.Fatal(err)
}
m := newSwitchableModel(t, dir, store)
if m.sessionGen != 0 {
t.Fatalf("sessionGen = %d before any startSession call, want 0", m.sessionGen)
}
cmd := m.startSession(store)
t.Cleanup(func() {
if m.sessionCancel != nil {
m.sessionCancel()
}
})
if m.sessionGen != 1 {
t.Errorf("sessionGen = %d, want 1 after first startSession", m.sessionGen)
}
if cmd == nil {
t.Fatal("expected startSession to return a listen cmd")
}
if len(m.entries) != 2 {
t.Fatalf("entries = %+v, want 2 replayed messages", m.entries)
}
if m.entries[0].kind != entryUser || m.entries[0].text != "hello from history" {
t.Errorf("entries[0] = %+v", m.entries[0])
}
if m.entries[1].kind != entryAssistant || m.entries[1].text != "hi there" {
t.Errorf("entries[1] = %+v", m.entries[1])
}
// Der zurückgegebene Cmd muss tatsächlich auf dem neuen out-Channel
// lauschen (gen=1) — daran hängt die Stale-Event-Erkennung beim Wechsel.
// cmd() blockiert, bis out etwas liefert oder schließt; also im
// Hintergrund aufrufen und den Kontext canceln, um out zu schließen.
resultCh := make(chan interface{}, 1)
go func() { resultCh <- cmd() }()
m.sessionCancel()
select {
case msg := <-resultCh:
evMsg, ok := msg.(eventsClosedMsg)
if !ok {
t.Fatalf("expected eventsClosedMsg, got %T", msg)
}
if evMsg.gen != 1 {
t.Errorf("listen cmd bound to gen %d, want 1", evMsg.gen)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for the listen cmd to observe channel closure")
}
}
func TestStartSession_RestoresHistoricalTokenUsage(t *testing.T) {
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
n1 := session.NewNode("", textMsg(llm.RoleUser, "hi"), session.NodeMeta{})
if err := store.Append(n1); err != nil {
t.Fatal(err)
}
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "hello"),
session.NodeMeta{Usage: llm.Usage{InputTokens: 1200, OutputTokens: 30, CacheReadTokens: 900}})
if err := store.Append(n2); err != nil {
t.Fatal(err)
}
n3 := session.NewNode(n2.ID, textMsg(llm.RoleUser, "again"), session.NodeMeta{})
if err := store.Append(n3); err != nil {
t.Fatal(err)
}
n4 := session.NewNode(n3.ID, textMsg(llm.RoleAssistant, "again reply"),
session.NodeMeta{Usage: llm.Usage{InputTokens: 1400, OutputTokens: 20, CacheReadTokens: 1300}})
if err := store.Append(n4); err != nil {
t.Fatal(err)
}
m := newSwitchableModel(t, dir, store)
m.tokensUsed = 999999 // Altlast einer vorherigen Session, muss überschrieben werden
m.cacheReadTokens = 999999
m.startSession(store)
t.Cleanup(func() {
if m.sessionCancel != nil {
m.sessionCancel()
}
})
wantTokens := 1200 + 30 + 1400 + 20
if m.tokensUsed != wantTokens {
t.Errorf("tokensUsed = %d, want %d (sum of persisted NodeMeta.Usage)", m.tokensUsed, wantTokens)
}
wantCache := 900 + 1300
if m.cacheReadTokens != wantCache {
t.Errorf("cacheReadTokens = %d, want %d", m.cacheReadTokens, wantCache)
}
}
func TestStartSession_SwitchingCancelsPreviousLoop(t *testing.T) {
dir := t.TempDir()
storeA, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storeA.Close() })
storeB, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storeB.Close() })
m := newSwitchableModel(t, dir, storeA)
m.startSession(storeA)
oldOut := m.out
oldGen := m.sessionGen
m.startSession(storeB)
t.Cleanup(func() {
if m.sessionCancel != nil {
m.sessionCancel()
}
})
if m.sessionGen != oldGen+1 {
t.Errorf("sessionGen = %d, want %d after switching", m.sessionGen, oldGen+1)
}
if m.store != storeB {
t.Error("expected store to point at the new session")
}
if m.loop.Session != storeB {
t.Error("expected loop.Session to be rebound to the new store")
}
select {
case _, ok := <-oldOut:
if ok {
t.Error("expected the old session's out channel to close, not emit an event")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for the previous loop to shut down after cancel")
}
}
func TestUpdate_StaleSessionEventsAreIgnored(t *testing.T) {
m := newTestModel(t)
m.sessionGen = 5
m.turnActive = true
_, cmd := m.Update(uiEventMsg{gen: 3, ev: tool.TextDelta{Text: "should be ignored"}})
if cmd != nil {
t.Error("stale uiEventMsg should not schedule any follow-up cmd")
}
if m.hasLiveText {
t.Error("stale uiEventMsg must not be applied to model state")
}
_, cmd = m.Update(eventsClosedMsg{gen: 3})
if m.quitting {
t.Error("stale eventsClosedMsg from a switched-away session must not quit the TUI")
}
_ = cmd
}

66
internal/ui/tui/styles.go Normal file
View file

@ -0,0 +1,66 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Alle Farben als AdaptiveColor: lipgloss erkennt Light/Dark-Terminals
// (über termenv) und wählt automatisch die passende Variante. Feste
// ANSI-Codes wie "15" (fast weiß) oder "221" (Gelb) sind auf hellem
// Hintergrund praktisch unlesbar — das war der ursprüngliche Bug hier.
var (
styleUser = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "17", Dark: "15"}).
Bold(true)
styleAssistant = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "234", Dark: "252"})
styleThinking = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "245"}).
Italic(true)
styleToolCall = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"})
styleToolOK = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "108"})
styleToolErr = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"})
styleSystem = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"})
styleError = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"}).
Bold(true)
styleCommand = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "30", Dark: "116"})
styleDiffAdd = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "108"})
styleDiffDel = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"})
styleStatusBar = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "15", Dark: "15"}).
Background(lipgloss.AdaptiveColor{Light: "24", Dark: "237"}).
Padding(0, 1)
styleModalBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: "243", Dark: "245"}).
Padding(1, 2)
styleModalSel = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "15", Dark: "0"}).
Background(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
Bold(true)
styleQueued = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "245"}).
Italic(true)
stylePermissionBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"}).
Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"}).
Padding(0, 1)
stylePermissionPending = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"}).
Bold(true)
styleAskBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
Padding(0, 1)
styleAskPending = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
Bold(true)
)