diff --git a/approval_keys_test.go b/approval_keys_test.go index a44e26b..cc2348d 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(&Engine{}, "ask", nil, "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/chrome_test.go b/chrome_test.go index 3a0f9f8..e011105 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(&Engine{}, "ask", nil, "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(&Engine{}, "ask", nil, "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 f6a5f1b..a85f236 100644 --- a/complete_test.go +++ b/complete_test.go @@ -163,7 +163,7 @@ 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(&Engine{}, "ask", nil, "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/input_test.go b/input_test.go index cbe0305..ee13ffd 100644 --- a/input_test.go +++ b/input_test.go @@ -4,6 +4,7 @@ package main import ( + "fmt" "strings" "testing" "time" @@ -170,3 +171,27 @@ func TestUpdatePasteGrowsPrompt(t *testing.T) { t.Fatalf("pasted 3 lines should grow the prompt to 3 rows, got %d (value=%q)", nm.promptRows(), nm.input.Value()) } } + +// A big paste must arrive whole. bubbles caps textarea *content* at MaxHeight +// (default 99) and truncates a longer paste in silence, so newPromptArea clears +// it — the display height is capped separately, by syncPromptHeight. +func TestUpdateLargePasteIsNotTruncated(t *testing.T) { + const lines = 500 + body := make([]string, lines) + for i := range body { + body[i] = fmt.Sprintf("line %d", i) + } + want := strings.Join(body, "\n") + + m := inputModel("") + m.vp = newTranscriptViewport(40, 6) + m.lastActivity = time.Now() + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(want), Paste: true}) + nm := next.(model) + if got := nm.input.Value(); got != want { + t.Fatalf("paste of %d lines was truncated to %d lines", lines, strings.Count(got, "\n")+1) + } + if got := nm.promptRows(); got != maxPromptRows { + t.Fatalf("the visible prompt should stay at the %d-row cap, got %d", maxPromptRows, got) + } +} diff --git a/keys.go b/keys.go index 417d725..c859b79 100644 --- a/keys.go +++ b/keys.go @@ -338,7 +338,10 @@ func (m *model) sendTurn(text string) tea.Cmd { m.hist.Append(text) steering := m.busy m.add(entUser, text) - if err := m.engine.Send(text); err != nil { + // The transcript above shows what the user typed. What claude receives also + // carries the standing-instruction reminder (remind.go), which is why the + // entry is added before this and not from the sent text. + if err := m.engine.Send(withReminder(text, m.sysPromptSeen)); err != nil { m.add(entError, "send error: "+err.Error()) return nil } diff --git a/main.go b/main.go index 1b3a313..9dddc21 100644 --- a/main.go +++ b/main.go @@ -105,7 +105,7 @@ func main() { // flag with no runtime equivalent, which is why /sysprompt restarts to // change it (sysprompt.go). A failure to write the style file costs the // style, not the session, so report it and keep going. - sysArgs, err := sysPromptArgs(loadSettings().SysPrompt) + sysArgs, sysPrompt, err := sysPromptArgs(loadSettings().SysPrompt) if err != nil { fmt.Fprintln(os.Stderr, "extra system prompt disabled:", err) } @@ -129,7 +129,7 @@ func main() { os.Exit(1) } - m := newModel(engine, *mode, approvals, *spin, *resume) + m := newModel(engine, *mode, approvals, *spin, *resume, 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 8165385..3078291 100644 --- a/model.go +++ b/model.go @@ -175,9 +175,12 @@ type model struct { outTokens int ctxLimit int resumeID string // set via restartResuming (session picker, /sysprompt); main.go re-execs on it after p.Run() - // sysPromptSeen is the appended prompt text as claude got it at launch, or - // "" when the toggle was off. The flag reads the file once, at startup, so - // this is what the live subprocess is running with (sysprompt.go). + // sysPromptSeen is the standing-instruction text as claude got it at launch, + // and "" whenever no style reached claude: the toggle off, an empty prompt + // file, or a style file that could not be written. The flag reads the file + // once, at startup, so this is what the live subprocess is running with + // (sysprompt.go). Two consumers depend on that: sysPromptEdited compares it + // against the file, and remind.go gates the per-turn reminder on it. sysPromptSeen string ready bool w, h int @@ -192,6 +195,10 @@ func newPromptArea() textarea.Model { ta.Placeholder = "Ask Claude… (enter sends · alt+enter / ctrl+j / \\↵ for a new line)" ta.Prompt = "› " ta.CharLimit = 0 + // MaxHeight caps the *content*, not the display: bubbles defaults it to 99 + // and silently truncates a longer paste. The display height is ours + // (syncPromptHeight, capped at maxPromptRows), so drop the cap entirely. + ta.MaxHeight = 0 ta.ShowLineNumbers = false ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // no current-line highlight bar ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter", "ctrl+j")) @@ -206,7 +213,7 @@ func (m *model) setPromptWidth(w int) { m.input.SetWidth(w) } -func newModel(e *Engine, mode string, a *Approvals, spin, resumeID string) model { +func newModel(e *Engine, mode string, a *Approvals, spin, resumeID, sysPrompt string) model { ta := newPromptArea() ta.Focus() @@ -239,11 +246,12 @@ func newModel(e *Engine, mode string, a *Approvals, spin, resumeID string) model mouse: true, // started with tea.WithMouseCellMotion in main.go lastActivity: time.Now(), } - // Record the prompt text this process launched with, so a later edit to the - // file is detectable (sysprompt.go:sysPromptEdited). - if st.SysPrompt { - m.sysPromptSeen = loadSysPrompt() - } + // The prompt text this process launched with, straight from the function + // that composed the flag (sysprompt.go:sysPromptArgs). Re-reading the file + // 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.setPromptWidth(defW - 4) m.makeRenderer() // On resume, replay the last N turns from claude's own JSONL so the diff --git a/question_test.go b/question_test.go index c550f7d..81d7f9d 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(&Engine{}, "ask", &Approvals{}, "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(&Engine{}, "ask", &Approvals{}, "bar", "", "") m.splash = false next, _ := m.Update(pendingApprovalMsg{req: req}) diff --git a/remind.go b/remind.go new file mode 100644 index 0000000..0663a68 --- /dev/null +++ b/remind.go @@ -0,0 +1,116 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "strings" + +// ---- the per-turn reminder ---- +// +// outputstyle.go delivers the user's standing instructions, and the delivery is +// not in doubt: the text is visible in claude's system prompt, and the init line +// names the style claude resolved. Adherence is the part that fails. claude's +// own harness appends further response-shaping sections *after* the output +// style, so by the time a reply is composed the style no longer holds the last +// word, and the built-in guidance wins on length and format. +// +// This file gives the style the last word again. Every user turn carries a +// short reminder appended after the prompt, where recency favours it. That is +// the one lever cathode owns at runtime — the system prompt is fixed at launch +// (see sysprompt.go), but each turn is ours to compose. +// +// Three constraints shape it: +// +// - It is not the user's words. So it is wrapped in a tag, which marks it as +// out-of-band to the model and lets replay.go take it back out of the +// transcript on the next resume. +// - It rides the user turn. So it must never touch a slash command being +// forwarded to claude: appending prose to "/compact" rewrites the command's +// arguments. +// - It repeats on every turn of a long session. So the default is two lines, +// and a prompt that overrides it should stay about that size. + +// reminderTag wraps the appended text. Both halves of the round trip +// (withReminder, stripReminder) derive from this one name. +const reminderTag = "cathode-reminder" + +// Markers for an optional override block inside the prompt file. HTML comments +// because the prompt is markdown, and a comment renders as nothing in the +// output style claude reads. +const ( + reminderOpen = "" + reminderClose = "" +) + +// defaultReminder is what a prompt gets when it marks no block of its own. It +// does not restate the user's rules — cathode does not know which of them +// matter. It settles the precedence question instead, which is the actual +// failure: the style and the harness's later sections both describe how to +// reply, and the model needs to know which one governs. +const defaultReminder = "Your cathode output style governs this reply.\n" + + "Where later instructions conflict with it on length, format or tone, the output style wins." + +// reminderText returns the text to append for a given prompt, never "". A +// marked block in +// the prompt wins, so a user whose standing instructions are not about length +// or format can write their own one-liner. An unterminated marker falls back to +// the default: taking "the rest of the file" would repeat the whole prompt on +// every turn. +func reminderText(prompt string) string { + i := strings.Index(prompt, reminderOpen) + if i < 0 { + return defaultReminder + } + rest := prompt[i+len(reminderOpen):] + j := strings.Index(rest, reminderClose) + if j < 0 { + return defaultReminder + } + if body := strings.TrimSpace(rest[:j]); body != "" { + return body + } + return defaultReminder +} + +// withReminder returns the text to send for one user turn. +// +// prompt is model.sysPromptSeen: the standing instructions as the live claude +// got them at launch. It is empty unless the toggle is on *and* the file had +// text, which is exactly when an output style was selected — so there is no +// second setting to read here, and no way to remind about a style that claude +// never loaded. +func withReminder(text, prompt string) string { + if strings.TrimSpace(prompt) == "" { + return text + } + // A forwarded slash command is an argument list, not prose. Leave it alone. + if strings.HasPrefix(strings.TrimSpace(text), "/") { + return text + } + body := reminderText(prompt) + return text + "\n\n<" + reminderTag + ">\n" + body + "\n" +} + +// stripReminder removes a trailing reminder block. Replay reads back the user +// record claude stored, which is the text cathode sent, reminder included. The +// resumed transcript has to show what the user typed. +// +// Only a block at the very end is ours. A tag with the user's own prose after +// it is the user quoting the tag, and it survives intact — the same rule +// replay.go:tagWrapped applies to claude's bookkeeping tags. +func stripReminder(text string) string { + open, closer := "<"+reminderTag+">", "" + i := strings.LastIndex(text, open) + if i < 0 { + return text + } + rest := text[i+len(open):] + j := strings.Index(rest, closer) + if j < 0 { + return text + } + if strings.TrimSpace(rest[j+len(closer):]) != "" { + return text + } + return strings.TrimRight(text[:i], " \t\r\n") +} diff --git a/remind_test.go b/remind_test.go new file mode 100644 index 0000000..2dff8ec --- /dev/null +++ b/remind_test.go @@ -0,0 +1,99 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +const testPrompt = "Be terse. Answer the question that was asked." + +// The reminder exists only to back up a style claude actually loaded. An empty +// prompt means sysPromptArgs selected nothing, so there is nothing to remind +// about and the turn must go out untouched. +func TestWithReminderAppendsOnlyWhenAStyleIsLoaded(t *testing.T) { + const turn = "explain the engine" + + if got := withReminder(turn, ""); got != turn { + t.Errorf("no prompt: got %q, want the turn unchanged", got) + } + if got := withReminder(turn, " \n\t "); got != turn { + t.Errorf("blank prompt: got %q, want the turn unchanged", got) + } + + got := withReminder(turn, testPrompt) + if got == turn { + t.Fatal("a loaded prompt appended nothing") + } + if stripReminder(got) != turn { + t.Errorf("the user's text did not survive: got %q", stripReminder(got)) + } + if !strings.Contains(got, defaultReminder) { + t.Errorf("default reminder missing from %q", got) + } +} + +// A forwarded slash command is an argument list. Appending prose to it rewrites +// the arguments, so "/compact" would compact with our reminder as its +// instruction (see remind.go). +func TestWithReminderLeavesSlashCommandsAlone(t *testing.T) { + for _, turn := range []string{"/compact", "/mcp", "/code-review high", " /clear"} { + if got := withReminder(turn, testPrompt); got != turn { + t.Errorf("%q: got %q, want it unchanged", turn, got) + } + } +} + +// A prompt that is not about length or format needs its own wording, so a +// marked block wins. Both malformed cases fall back rather than repeating the +// whole prompt on every turn. +func TestReminderTextPrefersAMarkedBlock(t *testing.T) { + cases := []struct { + name string + prompt string + want string + }{ + {"no marker", testPrompt, defaultReminder}, + {"marked block", "Be terse.\n" + reminderOpen + "\nStay under four sentences.\n" + reminderClose + "\nMore prose.", "Stay under four sentences."}, + {"unterminated", "Be terse.\n" + reminderOpen + "\nStay under four sentences.", defaultReminder}, + {"empty block", "Be terse.\n" + reminderOpen + "\n \n" + reminderClose, defaultReminder}, + } + for _, c := range cases { + if got := reminderText(c.prompt); got != c.want { + t.Errorf("%s: got %q, want %q", c.name, got, c.want) + } + } +} + +// The tag is structural, not a keyword. Text that quotes it mid-prompt is the +// user's own writing and must survive — the same rule replay.go applies to +// claude's bookkeeping tags. +func TestStripReminderKeepsTextThatQuotesTheTag(t *testing.T) { + quoted := "why does <" + reminderTag + ">hi show up in my transcript?" + if got := stripReminder(quoted); got != quoted { + t.Errorf("got %q, want it unchanged", got) + } + plain := "no tags here at all" + if got := stripReminder(plain); got != plain { + t.Errorf("got %q, want it unchanged", got) + } + unclosed := "text with an <" + reminderTag + "> that never closes" + if got := stripReminder(unclosed); got != unclosed { + t.Errorf("got %q, want it unchanged", got) + } +} + +// Replay reads back the record claude stored, which is the text cathode sent. +// A resumed transcript has to show the prompt, not the reminder. +func TestReplayHidesTheReminder(t *testing.T) { + const turn = "explain the engine" + e, kind := replayUserText(withReminder(turn, testPrompt)) + if kind != replayShow { + t.Fatalf("kind = %v, want replayShow", kind) + } + if e.kind != entUser || e.text != turn { + t.Errorf("entry = %+v, want an entUser of %q", e, turn) + } +} diff --git a/render_test.go b/render_test.go index 0516ed4..9b0ccc4 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(&Engine{}, "ask", nil, "bar", "", "") m.vp = viewport.New(80, 24) m.ready = true m.makeRenderer() diff --git a/replay.go b/replay.go index 002dc0f..784f39f 100644 --- a/replay.go +++ b/replay.go @@ -29,7 +29,10 @@ const ( // replayUserText projects one text block of a user record into a transcript // entry, and reports which kind of record it came from. func replayUserText(text string) (entry, replayKind) { - t := strings.TrimSpace(text) + // Our own per-turn reminder was appended to the prompt on the way out + // (remind.go), so claude stored it as part of the user record. Take it back + // off before anything else looks at the text. + t := strings.TrimSpace(stripReminder(text)) if t == "" { return entry{}, replaySkip } diff --git a/splash_test.go b/splash_test.go index bfa7e32..58a1a1c 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(&Engine{}, "ask", nil, "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/sysprompt.go b/sysprompt.go index 145d4a7..323f800 100644 --- a/sysprompt.go +++ b/sysprompt.go @@ -98,18 +98,27 @@ func sysPromptSummary() string { // cannot disagree. The error is the style file's — main reports it and starts // without the prompt, because a session without the user's style still works. // Called once from main. -func sysPromptArgs(on bool) ([]string, error) { +// +// applied is the text the returned flag actually selects, and "" whenever the +// flag is not returned — including the write failure, where the toggle is on +// and the file has text but no style reached claude. It is the single answer to +// "what standing instructions is the live subprocess running with", which is +// why main hands it straight to newModel rather than letting the model re-derive +// it from the setting. Two places deciding that separately is how the reminder +// (remind.go) and the edited-file check (sysPromptEdited) would come to +// disagree with the launch. +func sysPromptArgs(on bool) (args []string, applied string, err error) { if !on { - return nil, nil + return nil, "", nil } text := loadSysPrompt() if text == "" { - return nil, nil + return nil, "", nil } if err := writeOutputStyle(text); err != nil { - return nil, err + return nil, "", err } - return []string{"--settings", outputStyleSettings()}, nil + return []string{"--settings", outputStyleSettings()}, text, nil } // Toggle ids for the picker. diff --git a/sysprompt_test.go b/sysprompt_test.go index 7834361..e5ff7ed 100644 --- a/sysprompt_test.go +++ b/sysprompt_test.go @@ -128,19 +128,24 @@ func TestSysPromptArgs(t *testing.T) { t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) noDefaultPrompt(t) - got, err := sysPromptArgs(false) - if got != nil || err != nil { - t.Errorf("toggle off: got %v (err %v), want no flag", got, err) + got, applied, err := sysPromptArgs(false) + if got != nil || applied != "" || err != nil { + t.Errorf("toggle off: got %v/%q (err %v), want no flag and no applied text", got, applied, err) } - got, err = sysPromptArgs(true) - if got != nil || err != nil { - t.Errorf("toggle on but no prompt file: got %v (err %v), want no flag", got, err) + got, applied, err = sysPromptArgs(true) + if got != nil || applied != "" || err != nil { + t.Errorf("toggle on but no prompt file: got %v/%q (err %v), want no flag and no applied text", got, applied, err) } writePrompt(t, "Be terse.") - got, err = sysPromptArgs(true) + got, applied, err = sysPromptArgs(true) if err != nil { t.Fatalf("writing the style file failed: %v", err) } + // applied is what the live model reminds against (remind.go), so it has to + // be the text the flag selects, not just any non-empty string. + if applied != "Be terse." { + t.Errorf("applied = %q, want the prompt text the flag selects", applied) + } if len(got) != 2 || got[0] != "--settings" { t.Fatalf("got %v, want --settings and a JSON value", got) } @@ -158,6 +163,33 @@ func TestSysPromptArgs(t *testing.T) { } } +// The toggle can be on, the file can have text, and still no style reaches +// claude — writing the style file is allowed to fail, and main starts without +// the flag. applied has to report that, because it is what newModel stamps into +// sysPromptSeen: a non-empty value here would have the model remind every turn +// about an output style claude never loaded (remind.go). +func TestSysPromptArgsReportsNothingAppliedWhenTheStyleFileFails(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + noDefaultPrompt(t) + writePrompt(t, "Be terse.") + + // A regular file where the config dir should be: creating output-styles/ + // under it fails, which is the failure main reports and keeps going past. + blocked := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_CONFIG_DIR", blocked) + + args, applied, err := sysPromptArgs(true) + if err == nil { + t.Fatal("writing the style file into a regular file should fail") + } + if args != nil || applied != "" { + t.Errorf("got %v/%q, want no flag and no applied text", args, applied) + } +} + // Toggling persists and asks for the restart that actually applies it — but // only when a restart would achieve something. func TestCommitSysPrompt(t *testing.T) { diff --git a/toolcard_test.go b/toolcard_test.go index 38bf3aa..6040aa0 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(&Engine{}, "ask", nil, "bar", "", "") m.vp = viewport.New(80, 24) m.ready = true return &m