diff --git a/approval_keys_test.go b/approval_keys_test.go index cc2348d..c0b5e95 100644 --- a/approval_keys_test.go +++ b/approval_keys_test.go @@ -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 diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..25bf7c7 --- /dev/null +++ b/backend.go @@ -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) diff --git a/backend_test.go b/backend_test.go new file mode 100644 index 0000000..a7650a3 --- /dev/null +++ b/backend_test.go @@ -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) + } +} diff --git a/chrome_test.go b/chrome_test.go index e011105..d3562f0 100644 --- a/chrome_test.go +++ b/chrome_test.go @@ -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)) @@ -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() diff --git a/complete_test.go b/complete_test.go index a85f236..da9aa6a 100644 --- a/complete_test.go +++ b/complete_test.go @@ -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'}}) diff --git a/control.go b/control.go index 536507b..f5bc714 100644 --- a/control.go +++ b/control.go @@ -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()), @@ -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"}) } @@ -57,14 +57,14 @@ 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}) } @@ -72,6 +72,6 @@ func (e *Engine) SetPermissionMode(mode string) error { // ("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}) } diff --git a/engine.go b/engine.go index 4506c6b..bb5de90 100644 --- a/engine.go +++ b/engine.go @@ -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 @@ -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", @@ -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 @@ -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" @@ -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 } @@ -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() { diff --git a/launch.go b/launch.go new file mode 100644 index 0000000..af08163 --- /dev/null +++ b/launch.go @@ -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 "" +} diff --git a/main.go b/main.go index 9dddc21..24617a6 100644 --- a/main.go +++ b/main.go @@ -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). diff --git a/model.go b/model.go index 3078291..bdd55af 100644 --- a/model.go +++ b/model.go @@ -59,7 +59,7 @@ type bodyKey struct { // model is the Bubble Tea model. Field grouping mirrors the lifecycle: // external services up top, modal flags, widgets, then session/turn state. type model struct { - engine *Engine + engine Engine approvals *Approvals md *glamour.TermRenderer hist *history @@ -213,12 +213,12 @@ func (m *model) setPromptWidth(w int) { m.input.SetWidth(w) } -func newModel(e *Engine, mode string, a *Approvals, spin, resumeID, sysPrompt string) model { +func newModel(cfg launchConfig) model { ta := newPromptArea() ta.Focus() sp := spinner.New() - sp.Spinner = bbsSpinner(spin) + sp.Spinner = bbsSpinner(cfg.Spinner) // Seed a sensible default size so the splash (and the rest of the UI) // renders on the very first frame. If the initial tea.WindowSizeMsg @@ -228,12 +228,12 @@ func newModel(e *Engine, mode string, a *Approvals, spin, resumeID, sysPrompt st st := loadSettings() applyTheme(st.Theme) // re-skin all styles to the persisted theme before first paint m := model{ - engine: e, approvals: a, + engine: cfg.Engine, approvals: cfg.Approvals, hist: openHistory(), sessions: openSessionStore(), input: ta, sp: sp, settings: st, headerStyle: st.Header, - mode: mode, splash: true, + mode: cfg.Mode, splash: true, // Start at frame 1 so the wordmark is visible on the first paint; // without this the user sees ~140ms of blank screen before the first // splash tick fires. @@ -251,18 +251,18 @@ func newModel(e *Engine, mode string, a *Approvals, spin, resumeID, sysPrompt st // here instead would answer a different question — the setting is on and the // file has text — which is not the same as claude having loaded a style, and // the two part company when writing the style file fails. - m.sysPromptSeen = sysPrompt + m.sysPromptSeen = cfg.SysPrompt m.setPromptWidth(defW - 4) m.makeRenderer() // On resume, replay the last N turns from claude's own JSONL so the // transcript isn't empty after re-exec. claude itself loads the session // into context — this is purely a visual rehydrate. - if resumeID != "" { + if cfg.ResumeID != "" { // Skip the boot splash: the user already picked the session, so drop // them straight back into the transcript. m.splash = false const replayMax = 40 - prior, ctxTok := loadPriorTranscript(resumeID, replayMax) + prior, ctxTok := loadPriorTranscript(cfg.ResumeID, replayMax) if len(prior) > 0 { m.entries = append(m.entries, entry{kind: entInfo, text: fmt.Sprintf("— resumed · replaying last %d entries —", len(prior))}) m.entries = append(m.entries, prior...) diff --git a/models.go b/models.go index 5bbd756..c63b558 100644 --- a/models.go +++ b/models.go @@ -38,7 +38,7 @@ func fallbackModelItems() []pickerItem { // requestModels runs the initialize handshake so the model list is cached // before the user opens /model. The reply arrives via the stream as a // control_response (see handleEvent). Wired into model.Init(). -func requestModels(e *Engine) tea.Cmd { +func requestModels(e Engine) tea.Cmd { return func() tea.Msg { _ = e.Initialize() return nil diff --git a/question_test.go b/question_test.go index 81d7f9d..c9d8039 100644 --- a/question_test.go +++ b/question_test.go @@ -86,7 +86,7 @@ func TestAskQuestionSequence(t *testing.T) { func TestAskQuestionMultiSelect(t *testing.T) { req := askReq(`{"questions":[{"question":"Which features?","multiSelect":true,"options":[ {"label":"Auth"},{"label":"Billing"},{"label":"Search"}]}]}`) - m := newModel(&Engine{}, "ask", &Approvals{}, "bar", "", "") + m := newModel(launchConfig{Engine: &claudeEngine{}, Approvals: &Approvals{}, Mode: "ask", Spinner: "bar"}) m.splash = false // the splash would eat the first keypress next, _ := m.Update(pendingApprovalMsg{req: req}) @@ -123,7 +123,7 @@ func TestAskQuestionMultiSelect(t *testing.T) { func TestAskQuestionMultiSelectEnterTakesFocused(t *testing.T) { req := askReq(`{"questions":[{"question":"Which?","multiSelect":true,"options":[ {"label":"One"},{"label":"Two"}]}]}`) - m := newModel(&Engine{}, "ask", &Approvals{}, "bar", "", "") + m := newModel(launchConfig{Engine: &claudeEngine{}, Approvals: &Approvals{}, Mode: "ask", Spinner: "bar"}) m.splash = false next, _ := m.Update(pendingApprovalMsg{req: req}) diff --git a/render_test.go b/render_test.go index 9b0ccc4..0346089 100644 --- a/render_test.go +++ b/render_test.go @@ -11,7 +11,7 @@ import ( ) func TestRebuildRendersMarkdown(t *testing.T) { - m := newModel(&Engine{}, "ask", nil, "bar", "", "") + m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"}) m.vp = viewport.New(80, 24) m.ready = true m.makeRenderer() diff --git a/splash_test.go b/splash_test.go index 58a1a1c..56793fb 100644 --- a/splash_test.go +++ b/splash_test.go @@ -14,7 +14,7 @@ import ( // symptom where ./doorway -mode ask appears to hang at "starting…" on first // run, while the engine is healthy. func TestSplashRendersBeforeWindowSizeMsg(t *testing.T) { - m := newModel(&Engine{}, "ask", nil, "bar", "", "") + m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"}) // Fast-forward the reveal so the modem-handshake markers are present. // The test isn't about the animation; it's about the "starting…" // placeholder never leaking through before WindowSizeMsg. diff --git a/toolcard_test.go b/toolcard_test.go index 6040aa0..4df726d 100644 --- a/toolcard_test.go +++ b/toolcard_test.go @@ -13,7 +13,7 @@ import ( // toolCardModel is a model ready to take both halves of one tool call: the // assistant stream event and the approval request. func toolCardModel() *model { - m := newModel(&Engine{}, "ask", nil, "bar", "", "") + m := newModel(launchConfig{Engine: &claudeEngine{}, Mode: "ask", Spinner: "bar"}) m.vp = viewport.New(80, 24) m.ready = true return &m