From c0e796166787ae14c5ad4f5899fde5a119a692f8 Mon Sep 17 00:00:00 2001 From: tdwd Date: Mon, 31 Aug 2026 17:21:41 +0200 Subject: [PATCH 1/2] prompt: remove the 99-line cap on pasted input bubbles' textarea caps content at MaxHeight (default 99) and truncates a longer paste in silence. newPromptArea already cleared CharLimit but left MaxHeight, so a paste over 99 lines lost its tail with no message. The display height is computed separately by syncPromptHeight and capped at maxPromptRows, so the content cap buys nothing. Clear it. --- input_test.go | 25 +++++++++++++++++++++++++ model.go | 4 ++++ 2 files changed, 29 insertions(+) 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/model.go b/model.go index 8165385..22c3f39 100644 --- a/model.go +++ b/model.go @@ -192,6 +192,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")) From 2d8e8fb90d4a0af66d35cdbce2dabb1fe209cd6b Mon Sep 17 00:00:00 2001 From: tdwd Date: Mon, 7 Sep 2026 10:16:18 +0200 Subject: [PATCH 2/2] sysprompt: remind claude of the output style on every turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output style delivers the user's standing instructions and the delivery was never in doubt: the text is visible in claude's system prompt and the init line names the resolved style. Adherence was the part that failed. claude's harness appends its own response-shaping sections after the output style, so the style stops holding the last word and the built-in guidance wins on length and format. Take the last word back with the one lever that is ours at runtime. The system prompt is fixed at launch, but every turn is composed here, so sendTurn now appends a two-line reminder after the prompt, where recency favours it. The reminder is not the user's words, so it rides in a tag: the transcript shows the typed text, and replay strips a trailing block back out on resume. It is skipped for anything starting with "/", because a forwarded slash command is an argument list and appending prose to /compact rewrites its arguments. The default says nothing about what to write, only which instructions govern, because that is the actual failure and cathode does not know which of the user's rules matter. A prompt needing other wording marks its own block with . Give "is a style loaded" one owner while adding its second consumer. sysPromptArgs already wrote the style file and composed the flag, but newModel re-derived the same fact by reading the prompt file from the setting. Those answer different questions and part company when writing the style file fails: main starts without the flag, yet sysPromptSeen was still set. Harmless while the only consumer was the edited-file check, not harmless once a reminder fires every turn about a style claude never loaded. sysPromptArgs now returns the text its flag selects and main hands it to newModel. --- approval_keys_test.go | 2 +- chrome_test.go | 4 +- complete_test.go | 2 +- keys.go | 5 +- main.go | 4 +- model.go | 22 ++++---- question_test.go | 4 +- remind.go | 116 ++++++++++++++++++++++++++++++++++++++++++ remind_test.go | 99 +++++++++++++++++++++++++++++++++++ render_test.go | 2 +- replay.go | 5 +- splash_test.go | 2 +- sysprompt.go | 19 +++++-- sysprompt_test.go | 46 ++++++++++++++--- toolcard_test.go | 2 +- 15 files changed, 300 insertions(+), 34 deletions(-) create mode 100644 remind.go create mode 100644 remind_test.go 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/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 22c3f39..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 @@ -210,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() @@ -243,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