Skip to content
Open
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
3 changes: 1 addition & 2 deletions compactstyle.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ func barItems() []pickerItem {
// commitBar persists the compact-bar animation. Chrome only — nothing in the
// transcript re-renders, so the next frame just draws the new style.
func (m *model) commitBar(id string) {
m.settings.Bar = id
saveSettings(m.settings)
m.commitSetting(func(s *settings) { s.Bar = id })
m.add(entInfo, "→ compact bar: "+barLabel(id))
}

Expand Down
3 changes: 1 addition & 2 deletions diff_split.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ func diffItems() []pickerItem {
// commitDiff applies the chosen diff style, re-renders the transcript's existing
// diff cards in it, and persists the choice.
func (m *model) commitDiff(id string) {
m.settings.Diff = id
saveSettings(m.settings)
m.commitSetting(func(s *settings) { s.Diff = id })
m.rerender()
m.add(entInfo, "→ diff: "+diffLabel(id))
}
Expand Down
82 changes: 17 additions & 65 deletions history.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
package main

import (
"bufio"
"encoding/json"
"os"
"strings"
"sync"
)
Expand All @@ -24,9 +21,13 @@ type promptEntry struct {

// history is the Ctrl-Up/Down recall buffer. Persisted as JSONL at
// $XDG_STATE_HOME/cathode/prompt-history.jsonl (or ~/.local/state/cathode/
// when XDG_STATE_HOME is unset). Reads happen once at startup; appends touch
// only the tail in the common case, so it's safe to share across runs of the
// same session.
// when XDG_STATE_HOME is unset).
//
// entries is a cache of that file, not the store: every running cathode
// instance shares it, so the write rules in historyfile.go are what keep one
// window from erasing another's prompts. This comment used to claim appends
// "touch only the tail in the common case", which is what hid the fact that past
// the cap they did not — see load.
type history struct {
mu sync.Mutex
entries []promptEntry
Expand All @@ -43,7 +44,9 @@ func openHistory() *history {
return &history{}
}
h := &history{path: path}
h.load()
h.mu.Lock()
defer h.mu.Unlock()
h.load() // held-lock, as load documents
return h
}

Expand All @@ -53,54 +56,13 @@ func historyPath() (string, error) {
return stateFilePath("prompt-history.jsonl")
}

// load reads the JSONL and silently drops malformed lines (a previous crash
// mid-write shouldn't break recall). If the file is over the cap, we rewrite it
// trimmed — opencode does the same self-heal.
func (h *history) load() {
f, err := os.Open(h.path)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var lines []promptEntry
for sc.Scan() {
var e promptEntry
if err := json.Unmarshal(sc.Bytes(), &e); err == nil && e.Input != "" {
lines = append(lines, e)
}
}
if len(lines) > maxHistoryEntries {
lines = lines[len(lines)-maxHistoryEntries:]
h.entries = lines
h.rewrite()
return
}
h.entries = lines
}

// rewrite atomically replaces the file with the in-memory entries. Only used
// for the cap-trim self-heal, never on the hot path.
func (h *history) rewrite() {
if h.path == "" {
return
}
tmp := h.path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return
}
for _, e := range h.entries {
b, _ := json.Marshal(e)
_, _ = f.Write(append(b, '\n'))
}
_ = f.Close()
_ = os.Rename(tmp, h.path)
}

// Append records a new prompt. Adjacent duplicates are dropped (re-sending the
// same prompt three times leaves one entry). Resets the walk cursor to live.
//
// "Adjacent" is now adjacent in the shared file, not in this window's own
// entries, because load reads what every window appended. So sending a prompt
// another window just sent records nothing new — the history already ends with
// that line, which is what the rule was always for.
func (h *history) Append(input string) {
if strings.TrimSpace(input) == "" {
return
Expand All @@ -113,21 +75,11 @@ func (h *history) Append(input string) {
}
h.entries = append(h.entries, promptEntry{Input: input})
h.cursor = 0
if len(h.entries) > maxHistoryEntries {
h.entries = h.entries[len(h.entries)-maxHistoryEntries:]
h.rewrite()
return
}
if h.path == "" {
return
}
f, err := os.OpenFile(h.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
b, _ := json.Marshal(promptEntry{Input: input})
_, _ = f.Write(append(b, '\n'))
h.appendLine(input)
h.load() // re-read: the file also holds what the other windows appended
}

// Rewind puts the walk cursor back at live. Call it when the prompt is cleared
Expand Down
37 changes: 37 additions & 0 deletions history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package main

import (
"fmt"
"path/filepath"
"testing"
)
Expand Down Expand Up @@ -36,6 +37,42 @@ func TestHistoryAppendCapsAtMax(t *testing.T) {
}
}

// The prompt-history file is shared by every cathode instance, and the cap is
// small enough that an established history reaches the cap self-heal on nearly
// every turn. So load must re-read the file rather than rebuild it from one
// instance's memory, or each window erases the other's prompts as fast as they
// are typed.
func TestHistoryKeepsWhatAnotherInstanceAppended(t *testing.T) {
path := filepath.Join(t.TempDir(), "prompt-history.jsonl")
a, b := &history{path: path}, &history{path: path}

// Fill past the cap, so every further append reaches the self-heal.
for i := 0; i < maxHistoryEntries; i++ {
a.Append(fmt.Sprintf("a-%02d", i))
}
b.load() // B starts up and sees A's history
a.Append("typed in window A")
b.Append("typed in window B")

fresh := &history{path: path}
fresh.load()
var seenA, seenB bool
for _, e := range fresh.entries {
switch e.Input {
case "typed in window A":
seenA = true
case "typed in window B":
seenB = true
}
}
if !seenA || !seenB {
t.Errorf("window A kept = %v, window B kept = %v; want both", seenA, seenB)
}
if len(fresh.entries) > maxHistoryEntries {
t.Errorf("entries = %d, want the cap held at %d", len(fresh.entries), maxHistoryEntries)
}
}

// TestHistoryMoveWalksAndReturnsToLive pins the cursor semantics: Ctrl-Up
// recalls the newest entry, again recalls the one before, Ctrl-Down walks back
// toward live and finally clears the input.
Expand Down
68 changes: 68 additions & 0 deletions historyfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2026 Triple Down AB
// SPDX-License-Identifier: Apache-2.0

package main

import (
"encoding/json"
"os"
)

// ---- how prompt history reaches the disk ----
//
// The file is shared with every other cathode instance, so a prompt is appended
// to it and never written as part of a rebuild of it. O_APPEND is what lets all
// of them write it with no lock and no lost line.
//
// The one write that does replace the whole file is the cap self-heal in load,
// and it re-reads under the lock first, for the reason sessionStore documents.

// load brings the cache up to date with the file, and holds the file to the cap.
// Held-lock: callers must hold h.mu.
//
// It runs after every append, not only at startup, because the file is shared: a
// prompt typed in another window belongs to this window's recall too, exactly as
// it does after a restart. The cap is small enough that an established history
// reaches this self-heal on nearly every turn, which is why it must start from
// what is on disk — writing this instance's view instead erased every prompt the
// other windows had appended since it started.
func (h *history) load() {
if h.path == "" {
return
}
if unlock, err := lockState(h.path + ".lock"); err == nil {
defer unlock()
}
lines := readHistory(h.path)
if len(lines) > maxHistoryEntries {
lines = lines[len(lines)-maxHistoryEntries:]
writeJSONL(h.path, lines)
}
h.entries = lines
}

// readHistory reads the prompts in the order they were sent. A record with no
// text is dropped — there is nothing to recall.
func readHistory(path string) []promptEntry {
var out []promptEntry
for _, e := range readJSONL[promptEntry](path) {
if e.Input != "" {
out = append(out, e)
}
}
return out
}

// appendLine writes one prompt to the end of the file. Held-lock.
func (h *history) appendLine(input string) {
f, err := os.OpenFile(h.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
b, err := json.Marshal(promptEntry{Input: input})
if err != nil {
return
}
_, _ = f.Write(append(b, '\n'))
}
36 changes: 36 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2026 Triple Down AB
// SPDX-License-Identifier: Apache-2.0

package main

import (
"fmt"
"os"
"testing"
)

// TestMain points the whole package at a throwaway state dir.
//
// Any test that builds a model opens the session store and the prompt history,
// and most of them never redirected $XDG_STATE_HOME — so `go test` read the
// developer's own state, and because an established prompt history sits at its
// cap, the cap self-heal rewrote their real prompt-history.jsonl.
//
// Doing it here rather than in each test is what makes that unreachable: a new
// test cannot touch those files by forgetting a t.Setenv. A test that needs a
// specific dir still overrides this with its own t.Setenv, which is scoped to
// that test and restored after it.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "cathode-test-state-*")
if err != nil {
fmt.Fprintln(os.Stderr, "cannot create a test state dir:", err)
os.Exit(1)
}
if err := os.Setenv("XDG_STATE_HOME", dir); err != nil {
fmt.Fprintln(os.Stderr, "cannot redirect XDG_STATE_HOME:", err)
os.Exit(1)
}
code := m.Run()
_ = os.RemoveAll(dir) // not deferred: os.Exit does not run defers
os.Exit(code)
}
68 changes: 68 additions & 0 deletions sessionfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2026 Triple Down AB
// SPDX-License-Identifier: Apache-2.0

package main

// ---- how the session store reaches the disk ----
//
// The rules that make a shared file safe live here, together, because missing
// one of them is what lost the user's session titles (sessionStore documents
// what happened). Nothing outside this file may write the store.

// refresh reloads the cache from the file. Held-lock: callers must hold s.mu.
// With no path there is no file to read, and the cache is the whole store.
func (s *sessionStore) refresh() {
if s.path == "" {
return
}
s.entries = readSessions(s.path)
}

// readSessions reads the store, keyed by id. A record with no id is dropped: it
// cannot be resumed or matched to anything, and it would render as a ghost row.
// A later line for the same id wins, so a file that was appended to rather than
// replaced still reads as one record per session.
func readSessions(path string) map[string]sessionInfo {
out := map[string]sessionInfo{}
for _, e := range readJSONL[sessionInfo](path) {
if e.ID != "" {
out[e.ID] = e
}
}
return out
}

// mutate applies fn to the store and writes the result. Every write goes
// through here; nothing else may write the file.
//
// The re-read inside the lock is the point: it carries through the rows another
// instance has written since this one loaded, instead of replacing them with a
// stale snapshot.
func (s *sessionStore) mutate(fn func(map[string]sessionInfo)) {
s.mu.Lock()
defer s.mu.Unlock()

if s.path == "" {
fn(s.entries) // no file to share, so nothing to lock or re-read
return
}
// A lock we could not take is not a reason to drop what the user did. The
// read-modify-write still runs, which leaves the far narrower race that
// Windows has all the time (statelock_windows.go).
if unlock, err := lockState(s.path + ".lock"); err == nil {
defer unlock()
}
s.refresh()
fn(s.entries)
s.write()
}

// write replaces the file with the cache. Held-lock, and called only from
// mutate, which has just re-read what this is about to replace.
func (s *sessionStore) write() {
rows := make([]sessionInfo, 0, len(s.entries))
for _, e := range s.entries {
rows = append(rows, e)
}
writeJSONL(s.path, rows)
}
Loading
Loading