panehistory

package
v1.45.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package panehistory stores and serves per-pane user-input history. One JSONL file per pane lives under <quilDir>/history/<paneID>.jsonl. The Claude hook subprocess appends entries; the daemon reads, previews, and compacts.

Concurrency: the hook subprocess only ever O_APPENDs single lines and the daemon only reads/compacts, so writes never interleave in practice. On Linux an O_APPEND write of one line is atomic; on Windows the guarantee is weaker for very large lines, but Read tolerates a malformed/partial trailing line and Compact's rename is best-effort (a transient Windows sharing-violation is logged by the caller and retried on the next read). Treat "concurrent" as serial-enough, not lock-free-safe.

Index

Constants

View Source
const (
	// MaxEntryBytes caps a single entry's text — generous enough for a pasted
	// stack trace, bounded so one paste can't bloat the store.
	MaxEntryBytes = 64 * 1024
	// MaxEntries is the ring cap Compact enforces.
	MaxEntries = 200
)

Variables

This section is empty.

Functions

func Append

func Append(quilDir, paneID string, e Entry) error

Append writes one entry to the pane's history file. Empty/whitespace text and synthetic harness-injected turns (see IsSyntheticPrompt) are skipped (returns nil without writing). Oversize text is truncated on a rune boundary with a trailing marker. V is forced to the current schema version. O_APPEND keeps concurrent hook invocations from clobbering each other.

func Compact

func Compact(quilDir, paneID string, keepLast int) error

Compact rewrites the pane's history file keeping only the last keepLast real entries, dropping synthetic harness-injected turns (see IsSyntheticPrompt) in the process. It rewrites ONLY when the number of REAL entries exceeds keepLast. When real entries are within the cap the file is left untouched — even if pre-existing synthetic junk is on disk — because Read-time filtering already hides that junk from every caller, and rewriting would race the Claude hook's cross-process O_APPEND (a prompt appended between readRaw and the rename would be written to the old inode and lost). Gating on the RAW line count instead would reopen that race: historical task-notifications can push a pane with few real prompts over the cap, triggering a needless rewrite. Since Append no longer records synthetic turns, that junk is a finite, non- growing residue; it is purged only as part of a genuine over-cap trim of real entries. Atomic via temp file + rename.

func Dir

func Dir(quilDir string) string

Dir returns the history directory under quilDir.

func IsSyntheticPrompt added in v1.34.2

func IsSyntheticPrompt(text string) bool

IsSyntheticPrompt reports whether text is a harness-injected turn (not real user input) that must be excluded from history. A prompt is synthetic only when, after trimming surrounding whitespace, it BOTH opens with SOME known tag AND closes with SOME known end tag — i.e. it is machine-generated blocks and nothing else. Requiring both ends means a real prompt that merely quotes or discusses a tag ("<task-notification is confusing, what is it?") is preserved; only a pure, complete block is dropped.

The two ends are matched INDEPENDENTLY rather than as a pair, because one turn can carry blocks of different kinds: with parallel subagents, a <task-notification> and an <agent-message> arrive concatenated, and a pair-wise test matches neither (the first tag's open hits but not its close, the second's close hits but not its open) — letting exactly the noisiest machine turns through. No real prompt both starts with one of these tags and ends with one of them.

func Path

func Path(quilDir, paneID string) string

Path returns the per-pane history file path.

func PreviewLine added in v1.45.3

func PreviewLine(text string, maxBytes int) string

PreviewLine renders text as a SINGLE display line for the history list: every run of whitespace (newlines and tabs included) collapses to one space, and the result is truncated rune-aware to maxBytes with a trailing "…".

Flattening happens daemon-side rather than in the TUI because the list shows exactly one row per entry: a multi-line prompt has to become one line somewhere, and doing it before the wire keeps the payload proportional to what is actually displayed.

Control characters (category Cc — C0, DEL and C1) are DROPPED, not substituted. A prompt is free text the user may have pasted into, so it can carry an ANSI escape; an ESC that reached the list would repaint or reposition inside a fixed-width bordered modal that assumes one cell per printed column. Substituting a visible placeholder would keep the row honest about length but add noise to every row for a case that is always accidental.

Unicode format characters (category Cf) go too, which IsControl does NOT cover and IsSpace does not catch either. A bidi override or isolate (U+202A–U+202E, U+2066–U+2069) renders the row reversed while still measuring its pre-override width, and the user picks which prompt to open from exactly that row — so the row could read as something other than what Enter fetches. ZWSP is the quiet half: invisible, not IsSpace, and therefore able to defeat the whitespace collapse this function is built around. Same reasoning, same categories as claudesessions.SanitizeTitle, which sanitizes the sibling data class (Claude session titles) for the same non-VT render path.

Types

type Entry

type Entry struct {
	V         int    `json:"v"`
	TsMs      int64  `json:"ts_ms"`
	SessionID string `json:"session_id,omitempty"`
	Text      string `json:"text"`
}

Entry is one recorded user input, persisted as a single JSONL line. TsMs doubles as the entry's lookup id on the fetch-one-entry IPC path; two submissions in the same millisecond would collide, but human prompt cadence makes that effectively impossible (a collision just returns the first match).

func Read

func Read(quilDir, paneID string) ([]Entry, error)

Read returns all real user entries oldest-first, excluding synthetic harness-injected turns (see IsSyntheticPrompt) so pre-existing junk written before the filter existed never surfaces. A missing file is not an error (returns nil, nil). Malformed lines — including a trailing partial line from an in-flight concurrent append — are skipped. A file larger than maxReadBytes is read from the tail so daemon memory stays bounded (the dropped first line is the oldest; Compact normally keeps the file well under the cap).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL