Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion approval_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
// inject one key, and return the decision the approval handler made.
func driveApproval(t *testing.T, key tea.KeyMsg) bool {
t.Helper()
m := newModel(&Engine{}, "ask", nil, "bar", "", "")
m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"})
// newModel sets splash=true; the splash eats the first keypress, which
// would defeat the test. Skip past it.
m.splash = false
Expand Down
43 changes: 43 additions & 0 deletions backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2026 Triple Down AB
// SPDX-License-Identifier: Apache-2.0

package main

import tea "github.com/charmbracelet/bubbletea"

// Engine is the seam between the UI and the agent subprocess it drives.
//
// Everything above this line — the transcript, the diff cards, the approval
// pane, the pickers — works in terms of `entry` values and knows nothing about
// the wire format underneath. Keeping that true is what lets the UI be tested
// without spawning a real subprocess, and what keeps one protocol's shape from
// leaking into a hundred call sites.
//
// The methods are the whole vocabulary the UI needs: one to open a turn, one to
// abort it, two to change settings mid-session, one handshake, plus the
// lifecycle pair main owns (Pipe and Close).
//
// Close has the ordering constraint documented on claudeEngine.Close: main
// calls it AFTER the Bubble Tea program returns, never from the Update loop.
type Engine interface {
// Send writes one user turn.
Send(text string) error
// Initialize runs the startup handshake that reports session capabilities.
Initialize() error
// Interrupt asks the subprocess to abort the turn in flight.
Interrupt() error
// SetPermissionMode switches permission mode without a restart.
SetPermissionMode(mode string) error
// SetModel switches the model for subsequent turns.
SetModel(model string) error
// Pipe forwards subprocess output into the program. Run it in a goroutine.
Pipe(p *tea.Program)
// Close ends the session. See the ordering constraint above.
Close()
}

// Compile-time proof that the claude backend satisfies the seam. main already
// forces this by passing one to newModel, but stating it here keeps the check
// attached to the interface rather than to whichever call site happens to
// exist.
var _ Engine = (*claudeEngine)(nil)
100 changes: 100 additions & 0 deletions backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright 2026 Triple Down AB
// SPDX-License-Identifier: Apache-2.0

package main

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
)

// fakeEngine records what the UI asked the subprocess to do. It exists so the
// send path can be tested without spawning anything: before the Engine seam
// there was no way to assert what actually reached the wire, only what the
// helpers returned in isolation.
type fakeEngine struct {
sent []string
}

func (f *fakeEngine) Send(text string) error { f.sent = append(f.sent, text); return nil }

// The rest of the seam, unrecorded: no test reads them yet, and a field nobody
// asserts is a field that can drift from what it claims to capture. Record one
// when a test needs it.
func (f *fakeEngine) Initialize() error { return nil }
func (f *fakeEngine) Interrupt() error { return nil }
func (f *fakeEngine) SetPermissionMode(mode string) error { return nil }
func (f *fakeEngine) SetModel(m string) error { return nil }
func (f *fakeEngine) Pipe(*tea.Program) {}
func (f *fakeEngine) Close() {}

var _ Engine = (*fakeEngine)(nil)

// last returns the most recent send, or "" when nothing was sent.
func (f *fakeEngine) last() string {
if len(f.sent) == 0 {
return ""
}
return f.sent[len(f.sent)-1]
}

// newTestModel builds a model around a fake engine, with state redirected to a
// temp dir so a test never appends to the real prompt history.
func newTestModel(t *testing.T, sysPrompt string) (model, *fakeEngine) {
t.Helper()
t.Setenv("XDG_STATE_HOME", t.TempDir())
f := &fakeEngine{}
return newModel(launchConfig{Engine: f, Mode: "ask", Spinner: "bar", SysPrompt: sysPrompt}), f
}

// The transcript shows what the user typed; the wire carries the reminder too.
// Nothing pinned that pairing before — remind.go's helpers were tested in
// isolation, so dropping the withReminder call from sendTurn stayed green.
func TestSendTurnAppliesTheReminderToTheWireOnly(t *testing.T) {
m, f := newTestModel(t, "Be terse.")

m.sendTurn("explain the engine")

if got := f.last(); !strings.Contains(got, "<"+reminderTag+">") {
t.Errorf("the reminder never reached the engine, sent %q", got)
}
if got := stripReminder(f.last()); got != "explain the engine" {
t.Errorf("the user's text did not survive the append, got %q", got)
}
if n := len(m.entries); n == 0 {
t.Fatal("the turn should be in the transcript")
}
last := m.entries[len(m.entries)-1]
if last.kind != entUser || last.text != "explain the engine" {
t.Errorf("transcript entry = %+v, want the typed text as entUser", last)
}
if strings.Contains(last.text, reminderTag) {
t.Error("the reminder must not be shown in the transcript")
}
}

// No style loaded means nothing to remind about, so the turn goes out as typed.
func TestSendTurnSendsVerbatimWithoutAStyle(t *testing.T) {
m, f := newTestModel(t, "")

m.sendTurn("explain the engine")

if got := f.last(); got != "explain the engine" {
t.Errorf("sent %q, want the turn unchanged", got)
}
}

// A slash command forwarded to claude is an argument list. The palette sends
// "/"+name through this same path (pickerkeys.go), so this is the real route,
// not a hypothetical one.
func TestSendTurnLeavesForwardedSlashCommandsAlone(t *testing.T) {
m, f := newTestModel(t, "Be terse.")

m.sendTurn("/compact")

if got := f.last(); got != "/compact" {
t.Errorf("sent %q, want the command unchanged", got)
}
}
4 changes: 2 additions & 2 deletions chrome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestScrollbarAutoHideRendering(t *testing.T) {
// A user scroll surfaces the scrollbar (glyphs land in the body); once the
// visibility window lapses it hides again, so the transcript copies clean.
func TestScrollbarPokeThenLapse(t *testing.T) {
m := newModel(&Engine{}, "ask", nil, "bar", "", "")
m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"})
m.splash = false
for i := 0; i < 60; i++ { // overflow the viewport so a scrollbar exists
m.add(entInfo, fmt.Sprintf("transcript line %d", i))
Expand Down Expand Up @@ -72,7 +72,7 @@ func lineCount(s string) int {
// pushes the total past the terminal.
func TestFrameFitsHeight(t *testing.T) {
for _, h := range []int{14, 16, 20, 30, 40} {
m := newModel(&Engine{}, "ask", nil, "bar", "", "")
m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"})
m.w, m.h = 80, h
m.setPromptWidth(m.w - 4)
m.resizeViewport()
Expand Down
6 changes: 5 additions & 1 deletion complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ func TestCompletionEscDismissUntilTokenLeft(t *testing.T) {
func TestCompletionMenuRendersAbovePrompt(t *testing.T) {
defer withStubFiles([]string{"go.mod", "main.go", "keys.go"})()

var tm tea.Model = func() model { m := newModel(&Engine{}, "ask", nil, "bar", "", ""); m.splash = false; return m }()
var tm tea.Model = func() model {
m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"})
m.splash = false
return m
}()
tm, _ = tm.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'@'}})
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
Expand Down
10 changes: 5 additions & 5 deletions control.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type outControl struct {
// the CLI replies with a control_response we don't block on, and an unsupported
// subtype surfaces as a "[remote] … rejected" line under -debug rather than an
// error here.
func (e *Engine) sendControl(prefix string, req map[string]string) error {
func (e *claudeEngine) sendControl(prefix string, req map[string]string) error {
m := outControl{
Type: "control_request",
RequestID: fmt.Sprintf("cathode-%s-%d", prefix, time.Now().UnixNano()),
Expand All @@ -47,7 +47,7 @@ func (e *Engine) sendControl(prefix string, req map[string]string) error {
// Initialize runs the streaming-input handshake. The success reply carries the
// session's capability snapshot; we use its model list to populate /model (see
// handleEvent). Safe to send once at startup — it doesn't disturb turns.
func (e *Engine) Initialize() error {
func (e *claudeEngine) Initialize() error {
return e.sendControl("init", map[string]string{"subtype": "initialize"})
}

Expand All @@ -57,21 +57,21 @@ func (e *Engine) Initialize() error {
// Interrupt asks the running subprocess to abort the current turn. Whether it
// lands depends on what claude was doing when it arrived (it can hit
// mid-tool-call); the UI flips busy off regardless so the prompt comes back.
func (e *Engine) Interrupt() error {
func (e *claudeEngine) Interrupt() error {
return e.sendControl("int", map[string]string{"subtype": "interrupt"})
}

// SetPermissionMode switches permission mode mid-session. mode is one of
// "default" | "plan" | "acceptEdits" | "bypassPermissions" — the same values
// --permission-mode takes.
func (e *Engine) SetPermissionMode(mode string) error {
func (e *claudeEngine) SetPermissionMode(mode string) error {
return e.sendControl("ctrl", map[string]string{"subtype": "set_permission_mode", "mode": mode})
}

// SetModel switches the model for subsequent turns. model is a CLI alias
// ("opus" | "sonnet" | "haiku") or a full model id; "" falls back to the
// account default. The CLI rejects an unknown id with a "[remote] set_model
// rejected" response (visible under -debug), leaving the model unchanged.
func (e *Engine) SetModel(model string) error {
func (e *claudeEngine) SetModel(model string) error {
return e.sendControl("mdl", map[string]string{"subtype": "set_model", "model": model})
}
16 changes: 8 additions & 8 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
)

// Engine wraps a long-lived `claude` subprocess running in bidirectional
// claudeEngine drives a long-lived `claude` subprocess in bidirectional
// stream-json mode. It is the *only* place that talks to Claude Code, which
// keeps the auth story simple: because we never set ANTHROPIC_API_KEY (we
// actively strip it), claude uses whatever `claude login` established — your
// Max subscription.
type Engine struct {
type claudeEngine struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
Expand Down Expand Up @@ -77,8 +77,8 @@ func scrubbedEnv() []string {
return out
}

// NewEngine spawns the subprocess and returns it ready to stream.
func NewEngine(cfg EngineConfig) (*Engine, error) {
// newClaudeEngine spawns the subprocess and returns it ready to stream.
func newClaudeEngine(cfg EngineConfig) (*claudeEngine, error) {
args := []string{
"-p",
"--input-format", "stream-json",
Expand Down Expand Up @@ -118,7 +118,7 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
if err := cmd.Start(); err != nil {
return nil, err
}
return &Engine{cmd: cmd, stdin: stdin, stdout: stdout}, nil
return &claudeEngine{cmd: cmd, stdin: stdin, stdout: stdout}, nil
}

// outUser is the NDJSON envelope we write to stdin for each turn. This is the
Expand All @@ -140,7 +140,7 @@ type outBlock struct {

// Send writes one user turn to the subprocess. Safe to call from the Bubble Tea
// update loop.
func (e *Engine) Send(text string) error {
func (e *claudeEngine) Send(text string) error {
var m outUser
m.Type = "user"
m.Message.Role = "user"
Expand All @@ -165,7 +165,7 @@ func (e *Engine) Send(text string) error {
// draining p.Send, Pipe stops draining stdout, claude blocks writing and never
// exits). Post-Run, p.Send is a no-op so Pipe keeps draining and an idle claude
// exits promptly on stdin EOF; a busy one is killed after a short grace.
func (e *Engine) Close() {
func (e *claudeEngine) Close() {
if e.stdin != nil {
_ = e.stdin.Close() // EOF asks an idle claude to exit cleanly
}
Expand Down Expand Up @@ -194,7 +194,7 @@ type streamClosedMsg struct{ err error }
// to the program via p.Send. Run it in its own goroutine after the program is
// constructed. Using p.Send (rather than a tea.Cmd that blocks on a channel)
// keeps backpressure simple and lets the UI stay responsive.
func (e *Engine) Pipe(p *tea.Program) {
func (e *claudeEngine) Pipe(p *tea.Program) {
sc := bufio.NewScanner(e.stdout)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // tool results can be large
for sc.Scan() {
Expand Down
26 changes: 26 additions & 0 deletions launch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026 Triple Down AB
// SPDX-License-Identifier: Apache-2.0

package main

// launchConfig is everything main resolved before the UI exists.
//
// A struct rather than a parameter list. The list had reached six positional
// arguments with four adjacent strings, where transposing two is silent: the
// compiler cannot tell a mode from a spinner name from a session id. Naming
// them at the call site makes that mistake impossible to write, and it means a
// new setting adds a field instead of shifting every caller's arguments along.
//
// SysPrompt is the one field with a rule attached. It is the standing-
// instruction text the live subprocess actually launched with, handed over by
// sysPromptArgs rather than re-read from disk, and it is empty whenever no
// style reached the agent — see sysprompt.go for why those are different
// questions.
type launchConfig struct {
Engine Engine
Approvals *Approvals // nil when nothing is gated (bypass mode)
Mode string // ask | plan | build | bypass
Spinner string // throbber style id
ResumeID string // session to replay into the transcript, or ""
SysPrompt string // standing instructions in force, or ""
}
11 changes: 9 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,21 @@ func main() {
cfg.PermissionPromptTool = approvals.permissionToolName()
}

engine, err := NewEngine(cfg)
engine, err := newClaudeEngine(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to start claude:", err)
fmt.Fprintln(os.Stderr, "is the `claude` CLI installed and on PATH, and have you run `claude login`?")
os.Exit(1)
}

m := newModel(engine, *mode, approvals, *spin, *resume, sysPrompt)
m := newModel(launchConfig{
Engine: engine,
Approvals: approvals,
Mode: *mode,
Spinner: *spin,
ResumeID: *resume,
SysPrompt: sysPrompt,
})
m.ctxLimit = parseTokenCount(*ctx)
// A resumed session may already exceed the base limit; grow it now that the
// -ctx flag has set the floor, so the gauge starts honest (see observeCtx).
Expand Down
Loading
Loading