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(&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
Expand Down
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(&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))
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(&Engine{}, "ask", nil, "bar", "", "")
m.w, m.h = 80, h
m.setPromptWidth(m.w - 4)
m.resizeViewport()
Expand Down
2 changes: 1 addition & 1 deletion complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'}})
Expand Down
25 changes: 25 additions & 0 deletions input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package main

import (
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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)
}
}
5 changes: 4 additions & 1 deletion keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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).
Expand Down
26 changes: 17 additions & 9 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions question_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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})
Expand Down
116 changes: 116 additions & 0 deletions remind.go
Original file line number Diff line number Diff line change
@@ -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 = "<!-- reminder -->"
reminderClose = "<!-- /reminder -->"
)

// 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</" + reminderTag + ">"
}

// 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+">", "</"+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")
}
99 changes: 99 additions & 0 deletions remind_test.go
Original file line number Diff line number Diff line change
@@ -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</" + reminderTag + "> 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)
}
}
2 changes: 1 addition & 1 deletion render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading