From a5881243f9bb4642f90fa94179f7a5bff23e4657 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 7 Jun 2026 22:36:06 -0600 Subject: tui plan --- docs/archive/libpanto-cleanup.md | 711 +++++++++++++++++++++++++++++++++++++++ docs/libpanto-cleanup.md | 711 --------------------------------------- docs/tui-refactor-plan.md | 489 +++++++++++++++++++++++++++ 3 files changed, 1200 insertions(+), 711 deletions(-) create mode 100644 docs/archive/libpanto-cleanup.md delete mode 100644 docs/libpanto-cleanup.md create mode 100644 docs/tui-refactor-plan.md (limited to 'docs') diff --git a/docs/archive/libpanto-cleanup.md b/docs/archive/libpanto-cleanup.md new file mode 100644 index 0000000..7c53e02 --- /dev/null +++ b/docs/archive/libpanto-cleanup.md @@ -0,0 +1,711 @@ +# Plan: `libpanto` public API cleanup + +## Goal + +Define a small, deliberate public API for `libpanto` — the surface that the +language bindings (`libpanto-c`, `libpanto-go`, `libpanto-py`, see +`docs/libpanto-bindings.md`) and any Zig embedder wrap. Today the public +surface is *accidental*: `root.zig` re-exports ~18 modules wholesale, so every +`pub` declaration (≈180 of them) is reachable, including types that are `pub` +only to cross Zig file boundaries (`SSEParser`, `EventQueue`, the `Disk*` wire +types, provider-loop internals). The smaller the surface, the better. + +This is a **prerequisite for Phase 1** of the bindings work: you cannot wrap a +C ABI cleanly over an API whose boundary is undefined. + +> **Updated during implementation (public.zig landed):** `public.zig` is now +> the module root (`build.zig` points `panto` at it) and `root.zig` is +> deleted; its `refAllDecls` test contract moved into `public.zig`. The +> curated façade (behavioral wrappers `Agent`/`Stream`/`Conversation`, +> `ResultParts`, the data aliases) is in place. `public.zig` *also* carries a +> clearly-marked **transitional** block re-exporting the internal module +> namespaces (`agent`, `conversation`, `session_store`, etc.) and the +> freestanding `textResult`/`ownedTextResult`/`freeResultParts` that the +> `panto` CLI still imports; Phase 4 trims these as the CLI migrates onto the +> curated surface. `Stream.Phase` was made `pub` so `State` can alias it. + +## Guiding principle: the public API is additive, expressed in one file + +We do **not** rewrite the internals. Almost everything we want already exists. +The cleanup is overwhelmingly *additive*: + +- A new file, **`public.zig`**, is the sole exported root. `build.zig` points + the `panto` module at `public.zig` instead of `root.zig`. Internal modules + keep their `pub` decls (still needed for cross-file linking) but are simply + **never re-exported**. The public surface becomes an explicit allowlist in + one file, not "whatever happens to be `pub`." +- Where we want a different *name* or a *narrower* shape than the internal + type, `public.zig` provides a thin façade (see below). The internal + implementation is untouched. +- Net-new convenience functions (e.g. `panto.init` wrapping the existing + `initHttp`) live in `public.zig` and call through to the unchanged internals. + +The result: most of this plan is "write `public.zig`," with a **single genuine +internal refactor** (tool registry off `Config`, see Refactor R1) plus a +**session-format change** we've wanted anyway (Refactor R2). + +## The façade mechanism (how a type exposes 1/5 of its fields) + +Zig has no `private` keyword. The idiomatic way to hide internal fields is a +**wrapper type holding one pointer** to the internal struct, re-exposing only +the methods/fields we want: + +```zig +// public.zig +const internal = @import("agent.zig"); + +pub const Agent = struct { + inner: *internal.Agent, + pub fn run(self: Agent, msg: UserMessage) !Stream { + return .{ .inner = try self.inner.run(.{ .text = msg.text }) }; + } + // ...one forwarder per public method +}; +``` + +Consumers hold `Agent` (the wrapper); its only field is `inner`, whose type +(`internal.Agent`) is not re-exported, so the internal fields are unreachable. +Renames are free: `pub const State = internal.Stream.Phase;` or an accessor +`pub fn state(self: Stream) State { return self.inner.phase; }` renames without +touching `agent.zig`. + +### Three buckets — wrap, handle, or alias + +> **Updated during implementation:** `Conversation` moved out of the +> behavioral-wrap bucket. Its internal interface was pared down to exactly +> the intended public surface (constructors + the `add*`/`replace*` +> builders; `messages`/`allocator` are fine as public data fields for a Zig +> object), so `public.zig` now **aliases** `conversation.Conversation` +> straight through instead of pointer-wrapping it. `Agent.conversation()` +> returns a borrowed `*Conversation` for in-place surgery. Only `Agent` and +> `Stream` remain true façades — they have genuinely-internal fields +> (`config`/`registry`/`session`/`open_stream_fn`/... and +> `queue`/`response`/`pending_error`/...) plus API-shaping renames +> (`Stream.phase`→`state`, `Phase`→`State`) that an alias can't express. +> `addAssistantMessageWithUsage` was merged into +> `addAssistantMessage(blocks, ?usage)` on the real type (the separate +> method deleted), so the alias already has the intended one-method shape. + +> **Updated during implementation (impl cleanups synced the internals to the +> public names):** the public-API shaping we'd done in the façade was pushed +> *into* the internal types, so the wrappers collapsed to trivial forwarders +> (and two of the three behavioral types became plain aliases): +> - **`Stream` is now aliased** (`pub const Stream = agent_mod.Stream`). +> `Stream.phase`→`state` and `Phase`→`State` renamed in the impl; `state` +> is the one intended-public field (the transparent state machine), and +> the rest (`_agent`/`_queue`/`_response`/`_start`/`_persisted`/ +> `_pending_error`) are underscore-prefixed internal state. `Agent.run` +> returns `*Stream`. +> - **Compaction renamed in the impl:** the pure no-persist transform is now +> the private `_compactInPlace`; `compactAndPersist`→`compact` (the public +> name, persists by default). +> - **System-prompt split in the impl:** `addSystemMessage(text, mode)` +> became `addSystemMessage(text)` + `setSystemPrompt(text)` (mode is an +> internal `_persistSystemMessage` detail). +> - **`UserMessage` moved to module scope** (off `Agent`); `public.UserMessage` +> aliases it. +> - **Pure-internal `Agent` fields underscore-prefixed:** `_open_stream_fn` +> (the test seam), `_auto_compacted`, `_retry_prng`. The method-gated +> state (`config`/`registry`/`session`/`conversation`) stays unprefixed +> — it backs `setConfig`/`registerTool*`/`conversation()`/`sessionId()` +> and is heavily referenced; the door is the method. +> +> Result: the `Agent` façade is now **pure 1:1 forwarders** (no renames or +> merges left). It remains a wrapper only because `init` heap-pins the inner +> (making the handle copyable / move-safe) and `conversation()`/`sessionId()` +> are accessors. `Conversation` and `Stream` are aliases. + +| bucket | rule | types | +| --- | --- | --- | +| **behavioral (thin wrapper)** | heap-pin the inner; every method a 1:1 forwarder | `Agent` | +| **behavioral (pared + aliased)** | trim the real type to the public surface, then alias | `Conversation`, `Stream` | +| **data (read/output)** | **alias** straight through; inspected field-by-field | `Event` (+ nested), `Usage`, `Pricing`, `SessionInfo` | +| **data (constructed)** | **alias** straight through; Zig users build them with `ArrayList` directly | `ContentBlock`, `Message`, the block types, `Config` family, `ResultPart` | + +**Why pointer-wrap all three behavioral types** (not value-wrap): + +- A value wrapper containing `internal.Agent` inline makes "is the inner + movable?" an invariant you must keep true forever. It already isn't safe to + move after `run()`: `Stream` holds `*Agent`. Nothing stops a Zig embedder + from `var a2 = a1;`. +- Pointer-wrapping makes the invariant **structural**: the inner is + heap-pinned at `init`, the wrapper is a cheap copyable handle, "don't move + the Agent" stops being a rule anyone can violate. +- The indirection cost is one dereference per *coarse* call (`run`, `compact`, + `registerTool` — all do I/O or allocation). Unmeasurable. +- The C ABI heap-boxes the handle regardless, so the "save an `init` malloc" + argument for value-wrapping is illusory. + +**Handle types are minted per-call for free.** `Agent.conversation()` returns +a `Conversation` wrapper that copies one pointer (`&self.inner.conversation`) +— no rebuild, no copy of conversation data. A `Conversation` handle borrows +state owned by its `Agent` and has the agent's lifetime. + +**Why alias the constructed data types** rather than build slice-based +façades: for a *Zig* API, `ArrayList` is well understood and exposing it is +fine. Conversation surgery (custom compaction, context management) wants the +real types, transparently. The slice-based constructor ergonomics that a C +caller needs are `libpanto-c`'s job — and building them once there covers the +majority of language bindings. (`libpanto-py` is Zig too, so `ArrayList` is +natural there as well.) + +--- + +> **Updated during implementation (CLI migration landed):** the `panto` CLI +> now consumes the curated `public.zig` surface for all data types +> (`Config`/`ProviderConfig`/`ReasoningEffort`/..., `Message`/`MessageRole`/ +> `effectiveSystemBlocks`, `Event`, `Pricing`/`PricingRegistry`, +> `Session`/`SessionStore`/`WireIdentity`/`PersistentMessage`, +> `FileSystemJSONLStore`, `ContentBlockType`), for process lifecycle +> (`panto.init`/`panto.deinit`), and for result-part ergonomics +> (`ResultParts.fromText`/`fromTextOwned`/`deinit`, replacing the freestanding +> `textResult`/`ownedTextResult`/`freeResultParts`). +> +> **Holes plugged (escape hatch removed).** The deep-embedder gap is closed; +> the CLI now uses **only** the curated `public.zig` surface (zero +> `panto.agent`/`panto.conversation` reaches), and the transitional +> internal-namespace re-exports are deleted. The additions that closed it: +> - **`Agent.init` / `Agent.deinit`** on the public façade (heap-pin the +> inner; the handle is a cheap copyable value passed by-value everywhere). +> - **`Agent.addSystemMessage(text)`** (`.append`) and +> **`Agent.setSystemPrompt(text)`** (`.replace`) — the two `SystemMode`s. +> - **`CompactionConfig.compaction_prompt`** owns the compaction system +> prompt (auto-compaction reads it; the old `Agent.compaction_system_prompt` +> field is deleted). **`Agent.compact(override_system_prompt: ?[]const u8, +> extra)`** falls back to it when the override is null. +> - **`ConversationData`** = the owned conversation value type (what +> `Session.load` returns and `Agent.init` adopts), distinct from the +> borrowed `Conversation` handle returned by `Agent.conversation()`. +> - **`Agent.sessionId()`** accessor. +> +> `compactAndPersist` was an internal detail — the public `Agent.compact()` +> *is* it (renamed, persists by default); the CLI `/compact` command now +> calls `agent.compact(...)`. + +## The two jobs the API must serve + +Everything below is derived from two user jobs, not from "the CLI happens to +call it." + +1. **Run an agent loop** — configure a provider, register tools, submit a + turn, consume the pull event stream, persist. +2. **Construct & operate on conversations** — build a `Conversation` by hand, + insert/modify/delete content blocks, implement custom compaction or other + context-management surgery. + +If something serves neither job, it stays internal. + +--- + +## Target surface + +### Process lifecycle + +```zig +pub fn init(allocator: std.mem.Allocator, io: Io) void // wraps initHttp +pub fn deinit() void // wraps deinitHttp +``` + +`httpClient` is **not** exposed (internal plumbing). `initHttp`/`deinitHttp` +stay `pub` internally but are reached only through `init`/`deinit`. + +### Config (data, aliased) + +```zig +pub const Config = config.Config; // see Refactor R1 — registry leaves it +pub const ProviderConfig = config.ProviderConfig; +pub const OpenAIChatConfig = config.OpenAIChatConfig; +pub const AnthropicMessagesConfig = config.AnthropicMessagesConfig; +pub const APIStyle = config.APIStyle; +pub const ReasoningEffort = config.ReasoningEffort; +pub const CompactionConfig = config.CompactionConfig; // now owns `compaction_prompt` +pub const RetryConfig = config.RetryConfig; +``` + +### Agent (behavioral, pointer-wrapped) + +```zig +pub const Agent = struct { + inner: *internal.Agent, + + pub fn init(allocator, io, config: *const Config, store: SessionStore, maybe_conversation: ?Conversation) !Agent; + pub fn deinit(self: Agent) void; + // Note: no registry parameter (R1). A fresh Agent starts with an empty + // tool set; populate it with the register* methods below. + + pub fn registerTool(self: Agent, tool: Tool) !void; // R1: registry now on Agent + pub fn registerToolSource(self: Agent, src: ToolSource) !void; + pub fn setConfig(self: Agent, config: *const Config) void; // swap provider/model between turns + + pub fn run(self: Agent, message: UserMessage) !Stream; + // override_system_prompt falls back to config.compaction.compaction_prompt: + pub fn compact(self: Agent, override_system_prompt: ?[]const u8, extra: ?[]const u8) !CompactionResult; // persists + + pub fn addSystemMessage(self: Agent, text: []const u8) !void; // .append, persists + pub fn setSystemPrompt(self: Agent, text: []const u8) !void; // .replace, persists + + pub fn conversation(self: Agent) Conversation; // borrowed handle + pub fn sessionId(self: Agent) []const u8; +}; + +pub const UserMessage = struct { text: []const u8 }; // top-level, NOT nested in Agent +pub const CompactionResult = agent.Agent.CompactionResult; // aliased data +``` + +Changes from today: + +- **`registerTool` / `registerToolSource` move onto `Agent`** (Refactor R1). + The tool set is no longer part of `Config`; swapping provider/model no + longer means rebuilding the whole tool list. +- **`UserMessage` is top-level**, defined in `public.zig` (translated to the + internal `agent.Agent.UserMessage` inside `run`). +- **`Agent.addSystemMessage` / `setSystemPrompt` are kept on `Agent`** (they + persist through the turn-path, which a bare `Conversation` mutation does + not). *Updated during implementation:* the plan originally proposed + dropping `Agent.addSystemMessage` in favor of `Conversation`-only system + messages, but the CLI's system-prompt seeding/reconciliation needs the + agent to *persist* the system entry with its `SystemMode`, so the two + agent methods stay (façade `addSystemMessage`/`setSystemPrompt`). +- **`compactAndPersist` → `compact`** (persists by default). The pure + no-persist transform stays a private internal helper. +- **`registry()` / `httpClient`-style accessors not exposed.** + +### Stream (behavioral, pointer-wrapped) + +```zig +pub const Stream = struct { + inner: *internal.Stream, + pub fn next(self: Stream) !?Event; + pub fn deinit(self: Stream) void; + pub fn state(self: Stream) State; // renamed from internal `phase` +}; +pub const State = internal.Stream.Phase; // renamed: Phase -> State, transparent state machine +``` + +`next`/`deinit` are the streaming contract from `docs/libpanto-bindings.md` +(`!?Event`: value = progress incl. terminal, `null` = exhausted, `error` = +failure). `state` is newly *exposed* (was a private field) and renamed to make +the state machine transparent. + +### Event (data, aliased) + +```zig +pub const Event = stream.Event; // union(enum), nested structs aliased with it +``` + +Aliased wholesale — `Event` and its nested structs (`BlockStart`, +`ToolDetails`, `ContentDelta`, `BlockComplete`, `MessageComplete`, +`ToolDispatchStart`, `ToolDispatchComplete`) are read-only output, inspected +field-by-field. The nested-struct-inside-`Event` organization is good and +stays. (Wrapping a tagged union by hand would be miserable and pointless.) + +Supporting data types referenced by `Event`: + +```zig +pub const ContentBlockType = provider.ContentBlockType; +pub const ProviderRetryInfo = provider.ProviderRetryInfo; // payload of provider_retry +``` + +### Conversation construction (data, aliased — Job 2) + +The full block/message/conversation surface, aliased straight through so Zig +users can build and operate on conversations with the real `ArrayList`-backed +types: + +```zig +pub const Conversation = struct { // behavioral handle (pointer) + inner: *internal.Conversation, + // metadata: ?[]const u8 — conversation-level user bag, fixed at creation + // (init), persisted in the session header. JSON-string contract (below). + pub fn metadata(self) ?[]const u8; // read the header bag + pub fn addUserMessage(self, text) !void; + pub fn addAssistantMessage(self, blocks: []const ContentBlock, usage: ?Usage) !void; // merged + pub fn addSystemMessage(self, text) !void; + pub fn replaceSystemMessage(self, text) !void; + pub fn addCompactionSummary(self, text) !void; + pub fn messages(self) []Message; // direct access for surgery + // deinit only when standalone-owned; a borrowed handle from Agent does not own +}; + +// Block/message data types — aliased, constructed directly with ArrayList: +pub const ContentBlock = conversation.ContentBlock; +pub const Message = conversation.Message; // carries `metadata: ?[]const u8` (JSON bag) +pub const MessageRole = conversation.MessageRole; +pub const TextualBlock = conversation.TextualBlock; +pub const ThinkingBlock = conversation.ThinkingBlock; +pub const ToolUseBlock = conversation.ToolUseBlock; +pub const ToolResultBlock = conversation.ToolResultBlock; +pub const ResultPartStored = conversation.ResultPartStored; +pub const StoredMediaPart = conversation.StoredMediaPart; +pub const SystemBlock = conversation.SystemBlock; +pub const SystemMode = conversation.SystemMode; +pub const CompactionSummaryBlock = conversation.CompactionSummaryBlock; +pub const Usage = conversation.Usage; +pub const effectiveSystemBlocks = conversation.effectiveSystemBlocks; // replay helper +``` + +#### Per-message and per-conversation metadata (the JSON bag) + +To support custom history strategies (e.g. a "live compaction" scheme that +tracks a parallel side-conversation against a smaller model and dynamically +rebuilds history each turn), `libpanto` carries two opaque metadata bags it +never interprets: + +- **`Message.metadata: ?[]const u8 = null`** — attached per message, round-trips + through persistence: set it before a turn commits, read it off the `Message` + after `load`. (e.g. a live message stores the ids of the side-conversation + summary messages that represent it.) +- **`Conversation.metadata: ?[]const u8`** — conversation-scoped, **fixed at + creation time** (`Conversation.init`), stored in the session **header**. + (e.g. the pointer to the whole side-conversation.) It is set once and not + mutated, which is exactly what an append-only header supports. + +**Contract:** both are pure `[]const u8` throughout `libpanto`'s in-memory and +wire modeling — the library treats them as opaque bytes and never parses them. +The documented requirement on *users* is that, when present, the bytes are +valid JSON (so a store may store them as a JSON column and tools may +deserialize them). `libpanto` does not validate this. + +Changes: + +- **`addAssistantMessageWithUsage` → `addAssistantMessage(blocks, ?Usage)`** — + one method; pass `null` for no usage. The separate method is deleted. +- **System messages live on `Conversation` only** (not `Agent`). The old + `Agent.addSystemMessage` also persisted; persistence is now strictly the + `Agent` turn-path's job. A bare `Conversation` mutation is in-memory until a + turn commits. This is sound for the append-only log because **every + `PersistentMessage` carries the full current `Conversation`** (see Sessions), + so whatever state a turn was built on is captured at write time regardless of + prior in-memory surgery. System-prompt changes specifically are expressed via + `replaceSystemMessage` (the `.replace` `SystemMode`), which the log records + faithfully. + +### Tools (data + small façade — Job 1) + +```zig +pub const Tool = tool.Tool; +pub const ToolSource = tool_source.ToolSource; +pub const ToolDecl = tool.ToolDecl; +pub const ToolCall = tool_source.Call; +pub const ToolCallResult = tool_source.CallResult; +pub const MediaPart = tool.MediaPart; + +// Result-part ergonomics — replace the freestanding trio with a thin struct +// wrapper around the slice (a bare `[]ResultPart` alias can't carry methods): +pub const ResultPart = tool.ResultPart; +pub const ResultParts = struct { + items: []ResultPart, + pub fn fromText(alloc, text: []const u8) !ResultParts; // was textResult + pub fn fromTextOwned(alloc, text: []u8) !ResultParts; // was ownedTextResult + pub fn deinit(self: ResultParts, alloc) void; // was freeResultParts +}; +``` + +Changes: + +- **Drop the freestanding `textResult` / `ownedTextResult` / + `freeResultParts`** in favor of a `ResultParts` struct wrapping the slice, + with `fromText` / `fromTextOwned` constructors and a `deinit` method. +- **`ToolRegistry` is hidden.** It's an internal organization structure; after + R1 the only public way to populate an agent's tool set is + `Agent.registerTool` / `registerToolSource`. Users no longer pre-build a + registry to seed `Agent.init`. + +> **Deferred:** removing a tool from an active `Agent`. Today the workaround is +> to build a fresh `Agent` with the same config + conversation and re-register +> the desired tools. A future `Agent.unregisterTool(name)` convenience is out +> of scope for this project. + +### Pricing (data, aliased — Job 1) + +```zig +pub const Pricing = pricing.Pricing; +pub const PricingRegistry = pricing.Registry; +// cost becomes a method: +// Pricing.cost(self, usage: Usage) ?u64 (was freestanding costMicroCents(usage, pricing)) +``` + +Change: **`costMicroCents` → `Pricing.cost(usage)`** — namespaced on the rate +card (pricing is the rates, usage is the input). + +### Sessions (Refactor R2 — redesigned) + +A clean re-think. The interface is **asymmetric**: rich on write +(audit/provenance-capable), minimal on read (resume-oriented). The store +decides how much write-side richness it durably keeps. + +```zig +pub const PersistentMessage = struct { // the rich, audit-oriented write type + message: Message, // the message being appended (carries its own metadata) + usage: ?Usage, + // wire-format provider identity (no CLI aliases, and NO api_key material + // of any kind — not even a hash; keys never enter libpanto's records): + api_style: APIStyle, + base_url: []const u8, + model: []const u8, + reasoning: ReasoningEffort, + // full provenance context, captured every append: + conversation: []const Message, // the entire current conversation at write time + tools_available: []const ToolDecl, // the tool set offered for this turn +}; +``` + +```zig +pub const SessionStore = struct { // vtable interface; impls own their own init (DSN, dir, ...) + ptr: *anyopaque, + vtable: *const VTable, + pub const VTable = struct { + create: *const fn (ctx: *anyopaque) Session, + list: *const fn (ctx: *anyopaque) anyerror![]SessionInfo, + freeSessionInfos:*const fn (ctx: *anyopaque, infos: []SessionInfo) void, + resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session, + latest: *const fn (ctx: *anyopaque) anyerror!?Session, + load: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Conversation, + appendMessages: *const fn (ctx: *anyopaque, session_id: []const u8, messages: []PersistentMessage) anyerror!void, + }; + // thin proxies for each +}; + +pub const NullStore = ...; // persists nothing; empty/no-op impl of every method +pub const FileSystemJSONLStore = ...; // renamed from SessionManager; directory-backed JSONL + +pub const SessionInfo = struct { // pure data, aliased; for display/selection (`panto sessions`) + id: []const u8, + created: []const u8, + modified: []const u8, + message_count: usize, + last_user_message: []const u8, // may be truncated + // last-used wire identity, updated on append (for resume pre-selection): + api_style: APIStyle, + base_url: []const u8, + model: []const u8, // wire model name, NOT a CLI config alias + reasoning: ReasoningEffort, // disambiguates otherwise-identical aliases +}; + +pub const Session = struct { // pure data: SessionInfo + a store to proxy to + info: SessionInfo, + store: SessionStore, + pub fn load(self: Session) !Conversation; // by value; null->error (id came from resolve/latest) + pub fn append(self: *Session, messages: []PersistentMessage) !void; // *Session: updates info.{api_style,base_url,model,reasoning} +}; +``` + +Design decisions: + +- **No allocator/io in vtable signatures.** A store captures whatever it needs + at its own (unprescribed) `init` — a Postgres store takes a DSN, the FS store + takes a directory. Returned `Conversation` self-describes its allocator; + `[]SessionInfo` is freed via the `freeSessionInfos` vtable method (rather + than struct-wrapping the slice to carry an allocator). +- **`create` returns `Session` (not `!Session`)** — minting an in-memory handle + can't fail. Create-on-demand: nothing hits the backend until the first + `appendMessages`, preserving the "no record before the first assistant + message" invariant. +- **`Session.load` returns `!Conversation` by value, optional dropped** — the + id came from `resolve`/`latest`, so the conversation must exist; a `null` + from the store is promoted to an error. (`Conversation` is a small movable + struct; the `ArrayList` buffer is the only heap part. No `*Conversation`.) +- **No `activeModel` vtable method.** The last-used wire identity lives on + `SessionInfo` and is updated by `Session.append` after it proxies to the + store — hence `append` takes `*Session`. +- **All session identity is wire-format**, never CLI config aliases: + `{api_style, base_url, model, reasoning}`. (See R2 for the format change.) + +#### The write/read asymmetry, and provenance + +- **Write (`appendMessages` / `PersistentMessage`) is maximalist** — the rich, + audit-oriented type. It carries full wire identity, usage, content, the + **entire current conversation**, and the **tool set offered** for the turn. + The library **offers** all of it on every append; the store keeps what it + wants. This makes `libpanto` provenance-*capable* for compliance-concerned + consumers who implement their own store. +- **`api_key` material never enters a record** — not the key, not a hash of it. + Library-level guarantee, not a per-store choice. (Hashing keys is a + deliberate non-goal: it's needless risk in `libpanto` core.) +- **Read (`load`, `SessionInfo`) is minimal** — exactly what resume and + selection need. The vtable never demands a store reproduce provenance on + read, because a store may not have kept it. The read path deserializes + `Conversation` + `SessionInfo`, never a `PersistentMessage`. +- **`FileSystemJSONLStore` deliberately stays minimal.** It ignores the + `conversation` and `tools_available` provenance fields and simply appends the + latest `message` (plus wire identity + usage). The rich fields exist for + *other* stores; the built-in FS store is not an audit store. + +> **Audit-store implementation note (for `SessionStore` docs).** A serious +> audit log should **not** store the full `tools_available` (and likely not the +> full `conversation`) verbatim on every record — that re-serializes the whole +> tool manifest each turn. The recommended design is **content-addressing**: +> hash each tool's `(name, description, schema_json)`, keep a deduplicated tool +> table keyed by that hash, and store only the hash list on each record, +> normalizing the log by joining records against the tool table. The same +> applies to large repeated context. `libpanto` hands you the raw data each +> append; efficient de-duplication is the store's responsibility. + +#### Provider/model pre-selection on load + +The session log stores **wire facts only** (`{api_style, base_url, model, +reasoning}`), never config aliases. Pre-selecting "the provider/model they left +off with" is a **lookup against current config at load time**, not a stored +pointer: + +- A helper `match(config, session_info) -> []candidate` walks configured + providers/aliases and returns every alias whose `(api_style, base_url, model, + reasoning)` equals the session stamp. +- 1 candidate → pre-select. 0 → readable, no pre-selection (prompt / read-only). + N (e.g. two API keys for the same endpoint, or two reasoning levels) → + embedder picks (default / first / prompt). + +Storing the alias instead would freeze stale config state (aliases get renamed) +and *still* fail the N-match case (two keys are indistinguishable on the wire). +Recording `reasoning` as a wire field is what splits otherwise-identical +aliases. The CLI owns the pick; `libpanto` may provide `match` as a helper over +`Config`. + +--- + +## Refactors (the non-additive work) + +Everything else is `public.zig`. These two are genuine internal edits. + +### R1 — tool registry off `Config`, onto `Agent` + +Today `Config.registry: *const ToolRegistry` and `Agent.run` reads +`config.registry`. Consequence: swapping config (provider/model change) forces +rebuilding the tool set. Move tool-set ownership to `Agent`: + +- `Agent` owns/holds the `ToolRegistry` (or borrows one supplied at `init`). +- `Agent.registerTool` / `registerToolSource` mutate it. +- `Config` carries only provider/model/retry/compaction; `setConfig` swaps + those without touching tools. +- The agent loop reads the registry from `self`, not `self.config`. + +This is the one change a façade can't paper over — it relocates *where the +running loop reads the tool set from*. + +### R2 — session-log format: wire-format identity, no config aliases + +> **Updated during implementation (R2 landed):** +> - `session_manager.zig` → **`file_system_jsonl_store.zig`**; the old +> `SessionManager` single-session machinery is now the internal +> `SessionFile`, and a new directory-backed catalog struct +> **`FileSystemJSONLStore`** implements the redesigned `SessionStore` +> vtable (`create`/`list`/`freeSessionInfos`/`resolve`/`latest`/`load`/ +> `appendMessages`). It caches open `SessionFile`s by id so the +> buffer-until-first-assistant write discipline survives the separate +> user/assistant appends of one turn. +> - The on-disk *content* types keep the name **`Stored*`** (e.g. +> `StoredMessage`, `StoredContentBlock`) to avoid colliding with the rich +> store write record `PersistentMessage` (which wraps an in-memory +> `Message` + `WireIdentity` + provenance). `Disk*` → `Stored*`/ +> `Persistent*` accordingly. +> - Wire identity on disk is a `WireStamp { api_style, base_url, model, +> reasoning }` on each `MessageEntry` (null on system entries), replacing +> the single `provider` string. The agent derives it from +> `Config.provider.wireIdentity()`; **`persist_provider`/`persist_model` +> display strings are deleted** — the CLI banner stays alias-based and +> resume picks the default model (the `match` helper stays deferred). +> - **`Agent.init` now takes a `Session`** (minted via `store.create()` or +> resolved via `resolve`/`latest`) rather than a raw `SessionStore`; the +> agent adopts and frees `session.info` in `deinit`. +> - **Dangling-prompt recovery dropped** (resolved decision below). +> - `PersistentMessage` carries the provenance fields (`conversation`, +> `tools_available`); the agent currently passes an **empty +> `tools_available`** (the FS store ignores it; a real tool-manifest +> snapshot is a follow-up). `Message.metadata` round-trips through the +> `Stored*` layer; `Conversation.metadata` is not yet wired. + +The on-disk session format records **wire-format** provider/model identity +(`api_style`, `base_url`, `model`, `reasoning`), replacing today's single +`provider` string and any CLI-alias coupling. `Disk*` types are renamed to +`Persistent*` (e.g. `DiskMessage` → `PersistentMessage`), and +`PersistentMessage` carries its own `provider`/`model` wire identity rather +than the parallel `providers[]`/`models[]` slices the current +`appendMessages` takes. + +**Clean break, no version bump.** Pantograph has effectively a single user +(the author), so existing logs are simply wiped rather than migrated. No +compat shim, and `CURRENT_VERSION` need not change. + +--- + +## Internals that leave the public surface (derived deletions) + +Once `public.zig` is the allowlist, these stop being reachable (they stay `pub` +internally for linking, just un-re-exported): + +- **`provider.zig`**: `ProviderStream`, `openStream`, `OpenStreamFn`, + `ProviderDiagnostic`, `ProviderError`, `isContextOverflowBody`, + `classifyHttpStatus`, `parseRetryAfterMs`, `retryAfterFromHead`, + `isRetryableProviderError`. (`ContentBlockType`, `ProviderRetryInfo` stay — + referenced by `Event`.) +- **`stream.zig`**: `EventQueue` (internal plumbing; only `Event` escapes). +- **`sse.zig`**: `SSEParser` (pure internal). +- **`session.zig`**: the serialization layer — `serializeHeader`, + `serializeEntry`, `parseLine`, `contentBlockToDisk`, + `diskContentBlockToInternal`, `SessionHeader`, `SessionEntry`, `EntryBase`, + `MessageEntry`, `FileEntry`, `ParseError`, `CURRENT_VERSION`. The + `Persistent*` (ex-`Disk*`) content types are referenced by + `PersistentMessage` on the write path; expose only what `appendMessages` + needs. +- **`turn_persist.zig`**: `persistTurn`, `persistCompaction`, + `hasToolUseWithoutFollowingResults` (internal to the agent/session loop). +- **`compaction.zig`**: `computeSplit`, `serializeTranscript`, + `buildRequestBody`, `messageTokenEstimate`, `Split`, `word_to_token_factor`, + `latestSummaryText` (internals; `Agent.compact` is the entry). +- **`tool_registry.zig`**: name-encoding helpers (`validateName`, `encodeName`, + `decodeName`, `max_wire_name_len`, `NameError`, `Entry`, `ToolView`, + iterators) — `ToolRegistry` itself stays, its internals don't. +- **`config.zig`**: `httpClient` (and `initHttp`/`deinitHttp` only reachable via + `init`/`deinit`). +- **`image.zig`**: `process`, `maybeResize`, `detectCodec`, `detectMediaType`, + `codecForMediaType`, `Codec`, `Processed`, `max_dim` — used internally at + tool-result assembly; not a Job-1/Job-2 concern. **Hidden.** Note the + user-visible *behavior* must still be documented: tool-returned images have a + maximum longer-side dimension and `libpanto` resizes larger ones down before + storage/serialization (see the deferred config-setting item above). +- **`session_manager.zig` freestanding utils**: `newUuidV7`, `isoTimestamp` + (id/timestamp minting — internal to whichever store wants them). + +--- + +## Out of scope (deliberately deferred) + +- **A built-in audit/provenance *store*.** Provenance *capability* is in scope + — `PersistentMessage` is rich (full conversation + tool manifest + wire + identity) so a consumer's store can capture it. But `libpanto` does not ship + an audit store; `FileSystemJSONLStore` stays minimal. The content-addressing + design above is a recommendation in the `SessionStore` docs, not code we + write here. +- **Slice-based block/message constructors for non-Zig callers.** That's + `libpanto-c`'s job (covers Go and other cgo/cffi consumers in one place). +- **The provider-config `match` helper UI.** `libpanto` may ship `match`; the + pick policy (default/first/prompt) is the embedder's. +- **Configurable image max-dimension.** `libpanto` resizes oversized images + down to a maximum longer-side dimension (today a hard-coded `max_dim` in + `image.zig`). This should become a `libpanto` config setting (single int, + longer side), with the `panto` CLI supplying the value instead of the value + living in the library. Deferred; for now the behavior is documented (see + below) but the constant stays put. +- **`Agent.unregisterTool`.** Removing a tool from a live agent; today rebuild + a fresh `Agent`. Deferred convenience. + +--- + +## Resolved decisions (formerly open) + +0. **Dangling-prompt recovery — dropped (R2).** The old `LoadedSession` + split out a trailing crash-orphaned user prompt so resume wouldn't + auto-resend it. `Session.load()` returns a plain `Conversation`; a + trailing user message round-trips like any other. +1. **Conversation-level persistence — Agent-only.** A bare `Conversation` + mutation is in-memory; persistence is strictly the `Agent` turn-path's job. + Append-only provenance is preserved because every `PersistentMessage` + carries the full current conversation at write time. +2. **`image.zig` — hidden.** Resize behavior is documented; a configurable + max-dimension is deferred (see Out of Scope). +3. **R2 migration — clean break, no version bump, no shim.** Wipe old logs. +4. **`ToolRegistry` — hidden.** `Agent.registerTool` / `registerToolSource` is + the only public path; no pre-built registry seeds `Agent.init`. + `Agent.unregisterTool` deferred (see Out of Scope). +5. **`ResultParts` — thin struct wrapper** around `[]ResultPart`, carrying + `fromText` / `fromTextOwned` / `deinit`. diff --git a/docs/libpanto-cleanup.md b/docs/libpanto-cleanup.md deleted file mode 100644 index 7c53e02..0000000 --- a/docs/libpanto-cleanup.md +++ /dev/null @@ -1,711 +0,0 @@ -# Plan: `libpanto` public API cleanup - -## Goal - -Define a small, deliberate public API for `libpanto` — the surface that the -language bindings (`libpanto-c`, `libpanto-go`, `libpanto-py`, see -`docs/libpanto-bindings.md`) and any Zig embedder wrap. Today the public -surface is *accidental*: `root.zig` re-exports ~18 modules wholesale, so every -`pub` declaration (≈180 of them) is reachable, including types that are `pub` -only to cross Zig file boundaries (`SSEParser`, `EventQueue`, the `Disk*` wire -types, provider-loop internals). The smaller the surface, the better. - -This is a **prerequisite for Phase 1** of the bindings work: you cannot wrap a -C ABI cleanly over an API whose boundary is undefined. - -> **Updated during implementation (public.zig landed):** `public.zig` is now -> the module root (`build.zig` points `panto` at it) and `root.zig` is -> deleted; its `refAllDecls` test contract moved into `public.zig`. The -> curated façade (behavioral wrappers `Agent`/`Stream`/`Conversation`, -> `ResultParts`, the data aliases) is in place. `public.zig` *also* carries a -> clearly-marked **transitional** block re-exporting the internal module -> namespaces (`agent`, `conversation`, `session_store`, etc.) and the -> freestanding `textResult`/`ownedTextResult`/`freeResultParts` that the -> `panto` CLI still imports; Phase 4 trims these as the CLI migrates onto the -> curated surface. `Stream.Phase` was made `pub` so `State` can alias it. - -## Guiding principle: the public API is additive, expressed in one file - -We do **not** rewrite the internals. Almost everything we want already exists. -The cleanup is overwhelmingly *additive*: - -- A new file, **`public.zig`**, is the sole exported root. `build.zig` points - the `panto` module at `public.zig` instead of `root.zig`. Internal modules - keep their `pub` decls (still needed for cross-file linking) but are simply - **never re-exported**. The public surface becomes an explicit allowlist in - one file, not "whatever happens to be `pub`." -- Where we want a different *name* or a *narrower* shape than the internal - type, `public.zig` provides a thin façade (see below). The internal - implementation is untouched. -- Net-new convenience functions (e.g. `panto.init` wrapping the existing - `initHttp`) live in `public.zig` and call through to the unchanged internals. - -The result: most of this plan is "write `public.zig`," with a **single genuine -internal refactor** (tool registry off `Config`, see Refactor R1) plus a -**session-format change** we've wanted anyway (Refactor R2). - -## The façade mechanism (how a type exposes 1/5 of its fields) - -Zig has no `private` keyword. The idiomatic way to hide internal fields is a -**wrapper type holding one pointer** to the internal struct, re-exposing only -the methods/fields we want: - -```zig -// public.zig -const internal = @import("agent.zig"); - -pub const Agent = struct { - inner: *internal.Agent, - pub fn run(self: Agent, msg: UserMessage) !Stream { - return .{ .inner = try self.inner.run(.{ .text = msg.text }) }; - } - // ...one forwarder per public method -}; -``` - -Consumers hold `Agent` (the wrapper); its only field is `inner`, whose type -(`internal.Agent`) is not re-exported, so the internal fields are unreachable. -Renames are free: `pub const State = internal.Stream.Phase;` or an accessor -`pub fn state(self: Stream) State { return self.inner.phase; }` renames without -touching `agent.zig`. - -### Three buckets — wrap, handle, or alias - -> **Updated during implementation:** `Conversation` moved out of the -> behavioral-wrap bucket. Its internal interface was pared down to exactly -> the intended public surface (constructors + the `add*`/`replace*` -> builders; `messages`/`allocator` are fine as public data fields for a Zig -> object), so `public.zig` now **aliases** `conversation.Conversation` -> straight through instead of pointer-wrapping it. `Agent.conversation()` -> returns a borrowed `*Conversation` for in-place surgery. Only `Agent` and -> `Stream` remain true façades — they have genuinely-internal fields -> (`config`/`registry`/`session`/`open_stream_fn`/... and -> `queue`/`response`/`pending_error`/...) plus API-shaping renames -> (`Stream.phase`→`state`, `Phase`→`State`) that an alias can't express. -> `addAssistantMessageWithUsage` was merged into -> `addAssistantMessage(blocks, ?usage)` on the real type (the separate -> method deleted), so the alias already has the intended one-method shape. - -> **Updated during implementation (impl cleanups synced the internals to the -> public names):** the public-API shaping we'd done in the façade was pushed -> *into* the internal types, so the wrappers collapsed to trivial forwarders -> (and two of the three behavioral types became plain aliases): -> - **`Stream` is now aliased** (`pub const Stream = agent_mod.Stream`). -> `Stream.phase`→`state` and `Phase`→`State` renamed in the impl; `state` -> is the one intended-public field (the transparent state machine), and -> the rest (`_agent`/`_queue`/`_response`/`_start`/`_persisted`/ -> `_pending_error`) are underscore-prefixed internal state. `Agent.run` -> returns `*Stream`. -> - **Compaction renamed in the impl:** the pure no-persist transform is now -> the private `_compactInPlace`; `compactAndPersist`→`compact` (the public -> name, persists by default). -> - **System-prompt split in the impl:** `addSystemMessage(text, mode)` -> became `addSystemMessage(text)` + `setSystemPrompt(text)` (mode is an -> internal `_persistSystemMessage` detail). -> - **`UserMessage` moved to module scope** (off `Agent`); `public.UserMessage` -> aliases it. -> - **Pure-internal `Agent` fields underscore-prefixed:** `_open_stream_fn` -> (the test seam), `_auto_compacted`, `_retry_prng`. The method-gated -> state (`config`/`registry`/`session`/`conversation`) stays unprefixed -> — it backs `setConfig`/`registerTool*`/`conversation()`/`sessionId()` -> and is heavily referenced; the door is the method. -> -> Result: the `Agent` façade is now **pure 1:1 forwarders** (no renames or -> merges left). It remains a wrapper only because `init` heap-pins the inner -> (making the handle copyable / move-safe) and `conversation()`/`sessionId()` -> are accessors. `Conversation` and `Stream` are aliases. - -| bucket | rule | types | -| --- | --- | --- | -| **behavioral (thin wrapper)** | heap-pin the inner; every method a 1:1 forwarder | `Agent` | -| **behavioral (pared + aliased)** | trim the real type to the public surface, then alias | `Conversation`, `Stream` | -| **data (read/output)** | **alias** straight through; inspected field-by-field | `Event` (+ nested), `Usage`, `Pricing`, `SessionInfo` | -| **data (constructed)** | **alias** straight through; Zig users build them with `ArrayList` directly | `ContentBlock`, `Message`, the block types, `Config` family, `ResultPart` | - -**Why pointer-wrap all three behavioral types** (not value-wrap): - -- A value wrapper containing `internal.Agent` inline makes "is the inner - movable?" an invariant you must keep true forever. It already isn't safe to - move after `run()`: `Stream` holds `*Agent`. Nothing stops a Zig embedder - from `var a2 = a1;`. -- Pointer-wrapping makes the invariant **structural**: the inner is - heap-pinned at `init`, the wrapper is a cheap copyable handle, "don't move - the Agent" stops being a rule anyone can violate. -- The indirection cost is one dereference per *coarse* call (`run`, `compact`, - `registerTool` — all do I/O or allocation). Unmeasurable. -- The C ABI heap-boxes the handle regardless, so the "save an `init` malloc" - argument for value-wrapping is illusory. - -**Handle types are minted per-call for free.** `Agent.conversation()` returns -a `Conversation` wrapper that copies one pointer (`&self.inner.conversation`) -— no rebuild, no copy of conversation data. A `Conversation` handle borrows -state owned by its `Agent` and has the agent's lifetime. - -**Why alias the constructed data types** rather than build slice-based -façades: for a *Zig* API, `ArrayList` is well understood and exposing it is -fine. Conversation surgery (custom compaction, context management) wants the -real types, transparently. The slice-based constructor ergonomics that a C -caller needs are `libpanto-c`'s job — and building them once there covers the -majority of language bindings. (`libpanto-py` is Zig too, so `ArrayList` is -natural there as well.) - ---- - -> **Updated during implementation (CLI migration landed):** the `panto` CLI -> now consumes the curated `public.zig` surface for all data types -> (`Config`/`ProviderConfig`/`ReasoningEffort`/..., `Message`/`MessageRole`/ -> `effectiveSystemBlocks`, `Event`, `Pricing`/`PricingRegistry`, -> `Session`/`SessionStore`/`WireIdentity`/`PersistentMessage`, -> `FileSystemJSONLStore`, `ContentBlockType`), for process lifecycle -> (`panto.init`/`panto.deinit`), and for result-part ergonomics -> (`ResultParts.fromText`/`fromTextOwned`/`deinit`, replacing the freestanding -> `textResult`/`ownedTextResult`/`freeResultParts`). -> -> **Holes plugged (escape hatch removed).** The deep-embedder gap is closed; -> the CLI now uses **only** the curated `public.zig` surface (zero -> `panto.agent`/`panto.conversation` reaches), and the transitional -> internal-namespace re-exports are deleted. The additions that closed it: -> - **`Agent.init` / `Agent.deinit`** on the public façade (heap-pin the -> inner; the handle is a cheap copyable value passed by-value everywhere). -> - **`Agent.addSystemMessage(text)`** (`.append`) and -> **`Agent.setSystemPrompt(text)`** (`.replace`) — the two `SystemMode`s. -> - **`CompactionConfig.compaction_prompt`** owns the compaction system -> prompt (auto-compaction reads it; the old `Agent.compaction_system_prompt` -> field is deleted). **`Agent.compact(override_system_prompt: ?[]const u8, -> extra)`** falls back to it when the override is null. -> - **`ConversationData`** = the owned conversation value type (what -> `Session.load` returns and `Agent.init` adopts), distinct from the -> borrowed `Conversation` handle returned by `Agent.conversation()`. -> - **`Agent.sessionId()`** accessor. -> -> `compactAndPersist` was an internal detail — the public `Agent.compact()` -> *is* it (renamed, persists by default); the CLI `/compact` command now -> calls `agent.compact(...)`. - -## The two jobs the API must serve - -Everything below is derived from two user jobs, not from "the CLI happens to -call it." - -1. **Run an agent loop** — configure a provider, register tools, submit a - turn, consume the pull event stream, persist. -2. **Construct & operate on conversations** — build a `Conversation` by hand, - insert/modify/delete content blocks, implement custom compaction or other - context-management surgery. - -If something serves neither job, it stays internal. - ---- - -## Target surface - -### Process lifecycle - -```zig -pub fn init(allocator: std.mem.Allocator, io: Io) void // wraps initHttp -pub fn deinit() void // wraps deinitHttp -``` - -`httpClient` is **not** exposed (internal plumbing). `initHttp`/`deinitHttp` -stay `pub` internally but are reached only through `init`/`deinit`. - -### Config (data, aliased) - -```zig -pub const Config = config.Config; // see Refactor R1 — registry leaves it -pub const ProviderConfig = config.ProviderConfig; -pub const OpenAIChatConfig = config.OpenAIChatConfig; -pub const AnthropicMessagesConfig = config.AnthropicMessagesConfig; -pub const APIStyle = config.APIStyle; -pub const ReasoningEffort = config.ReasoningEffort; -pub const CompactionConfig = config.CompactionConfig; // now owns `compaction_prompt` -pub const RetryConfig = config.RetryConfig; -``` - -### Agent (behavioral, pointer-wrapped) - -```zig -pub const Agent = struct { - inner: *internal.Agent, - - pub fn init(allocator, io, config: *const Config, store: SessionStore, maybe_conversation: ?Conversation) !Agent; - pub fn deinit(self: Agent) void; - // Note: no registry parameter (R1). A fresh Agent starts with an empty - // tool set; populate it with the register* methods below. - - pub fn registerTool(self: Agent, tool: Tool) !void; // R1: registry now on Agent - pub fn registerToolSource(self: Agent, src: ToolSource) !void; - pub fn setConfig(self: Agent, config: *const Config) void; // swap provider/model between turns - - pub fn run(self: Agent, message: UserMessage) !Stream; - // override_system_prompt falls back to config.compaction.compaction_prompt: - pub fn compact(self: Agent, override_system_prompt: ?[]const u8, extra: ?[]const u8) !CompactionResult; // persists - - pub fn addSystemMessage(self: Agent, text: []const u8) !void; // .append, persists - pub fn setSystemPrompt(self: Agent, text: []const u8) !void; // .replace, persists - - pub fn conversation(self: Agent) Conversation; // borrowed handle - pub fn sessionId(self: Agent) []const u8; -}; - -pub const UserMessage = struct { text: []const u8 }; // top-level, NOT nested in Agent -pub const CompactionResult = agent.Agent.CompactionResult; // aliased data -``` - -Changes from today: - -- **`registerTool` / `registerToolSource` move onto `Agent`** (Refactor R1). - The tool set is no longer part of `Config`; swapping provider/model no - longer means rebuilding the whole tool list. -- **`UserMessage` is top-level**, defined in `public.zig` (translated to the - internal `agent.Agent.UserMessage` inside `run`). -- **`Agent.addSystemMessage` / `setSystemPrompt` are kept on `Agent`** (they - persist through the turn-path, which a bare `Conversation` mutation does - not). *Updated during implementation:* the plan originally proposed - dropping `Agent.addSystemMessage` in favor of `Conversation`-only system - messages, but the CLI's system-prompt seeding/reconciliation needs the - agent to *persist* the system entry with its `SystemMode`, so the two - agent methods stay (façade `addSystemMessage`/`setSystemPrompt`). -- **`compactAndPersist` → `compact`** (persists by default). The pure - no-persist transform stays a private internal helper. -- **`registry()` / `httpClient`-style accessors not exposed.** - -### Stream (behavioral, pointer-wrapped) - -```zig -pub const Stream = struct { - inner: *internal.Stream, - pub fn next(self: Stream) !?Event; - pub fn deinit(self: Stream) void; - pub fn state(self: Stream) State; // renamed from internal `phase` -}; -pub const State = internal.Stream.Phase; // renamed: Phase -> State, transparent state machine -``` - -`next`/`deinit` are the streaming contract from `docs/libpanto-bindings.md` -(`!?Event`: value = progress incl. terminal, `null` = exhausted, `error` = -failure). `state` is newly *exposed* (was a private field) and renamed to make -the state machine transparent. - -### Event (data, aliased) - -```zig -pub const Event = stream.Event; // union(enum), nested structs aliased with it -``` - -Aliased wholesale — `Event` and its nested structs (`BlockStart`, -`ToolDetails`, `ContentDelta`, `BlockComplete`, `MessageComplete`, -`ToolDispatchStart`, `ToolDispatchComplete`) are read-only output, inspected -field-by-field. The nested-struct-inside-`Event` organization is good and -stays. (Wrapping a tagged union by hand would be miserable and pointless.) - -Supporting data types referenced by `Event`: - -```zig -pub const ContentBlockType = provider.ContentBlockType; -pub const ProviderRetryInfo = provider.ProviderRetryInfo; // payload of provider_retry -``` - -### Conversation construction (data, aliased — Job 2) - -The full block/message/conversation surface, aliased straight through so Zig -users can build and operate on conversations with the real `ArrayList`-backed -types: - -```zig -pub const Conversation = struct { // behavioral handle (pointer) - inner: *internal.Conversation, - // metadata: ?[]const u8 — conversation-level user bag, fixed at creation - // (init), persisted in the session header. JSON-string contract (below). - pub fn metadata(self) ?[]const u8; // read the header bag - pub fn addUserMessage(self, text) !void; - pub fn addAssistantMessage(self, blocks: []const ContentBlock, usage: ?Usage) !void; // merged - pub fn addSystemMessage(self, text) !void; - pub fn replaceSystemMessage(self, text) !void; - pub fn addCompactionSummary(self, text) !void; - pub fn messages(self) []Message; // direct access for surgery - // deinit only when standalone-owned; a borrowed handle from Agent does not own -}; - -// Block/message data types — aliased, constructed directly with ArrayList: -pub const ContentBlock = conversation.ContentBlock; -pub const Message = conversation.Message; // carries `metadata: ?[]const u8` (JSON bag) -pub const MessageRole = conversation.MessageRole; -pub const TextualBlock = conversation.TextualBlock; -pub const ThinkingBlock = conversation.ThinkingBlock; -pub const ToolUseBlock = conversation.ToolUseBlock; -pub const ToolResultBlock = conversation.ToolResultBlock; -pub const ResultPartStored = conversation.ResultPartStored; -pub const StoredMediaPart = conversation.StoredMediaPart; -pub const SystemBlock = conversation.SystemBlock; -pub const SystemMode = conversation.SystemMode; -pub const CompactionSummaryBlock = conversation.CompactionSummaryBlock; -pub const Usage = conversation.Usage; -pub const effectiveSystemBlocks = conversation.effectiveSystemBlocks; // replay helper -``` - -#### Per-message and per-conversation metadata (the JSON bag) - -To support custom history strategies (e.g. a "live compaction" scheme that -tracks a parallel side-conversation against a smaller model and dynamically -rebuilds history each turn), `libpanto` carries two opaque metadata bags it -never interprets: - -- **`Message.metadata: ?[]const u8 = null`** — attached per message, round-trips - through persistence: set it before a turn commits, read it off the `Message` - after `load`. (e.g. a live message stores the ids of the side-conversation - summary messages that represent it.) -- **`Conversation.metadata: ?[]const u8`** — conversation-scoped, **fixed at - creation time** (`Conversation.init`), stored in the session **header**. - (e.g. the pointer to the whole side-conversation.) It is set once and not - mutated, which is exactly what an append-only header supports. - -**Contract:** both are pure `[]const u8` throughout `libpanto`'s in-memory and -wire modeling — the library treats them as opaque bytes and never parses them. -The documented requirement on *users* is that, when present, the bytes are -valid JSON (so a store may store them as a JSON column and tools may -deserialize them). `libpanto` does not validate this. - -Changes: - -- **`addAssistantMessageWithUsage` → `addAssistantMessage(blocks, ?Usage)`** — - one method; pass `null` for no usage. The separate method is deleted. -- **System messages live on `Conversation` only** (not `Agent`). The old - `Agent.addSystemMessage` also persisted; persistence is now strictly the - `Agent` turn-path's job. A bare `Conversation` mutation is in-memory until a - turn commits. This is sound for the append-only log because **every - `PersistentMessage` carries the full current `Conversation`** (see Sessions), - so whatever state a turn was built on is captured at write time regardless of - prior in-memory surgery. System-prompt changes specifically are expressed via - `replaceSystemMessage` (the `.replace` `SystemMode`), which the log records - faithfully. - -### Tools (data + small façade — Job 1) - -```zig -pub const Tool = tool.Tool; -pub const ToolSource = tool_source.ToolSource; -pub const ToolDecl = tool.ToolDecl; -pub const ToolCall = tool_source.Call; -pub const ToolCallResult = tool_source.CallResult; -pub const MediaPart = tool.MediaPart; - -// Result-part ergonomics — replace the freestanding trio with a thin struct -// wrapper around the slice (a bare `[]ResultPart` alias can't carry methods): -pub const ResultPart = tool.ResultPart; -pub const ResultParts = struct { - items: []ResultPart, - pub fn fromText(alloc, text: []const u8) !ResultParts; // was textResult - pub fn fromTextOwned(alloc, text: []u8) !ResultParts; // was ownedTextResult - pub fn deinit(self: ResultParts, alloc) void; // was freeResultParts -}; -``` - -Changes: - -- **Drop the freestanding `textResult` / `ownedTextResult` / - `freeResultParts`** in favor of a `ResultParts` struct wrapping the slice, - with `fromText` / `fromTextOwned` constructors and a `deinit` method. -- **`ToolRegistry` is hidden.** It's an internal organization structure; after - R1 the only public way to populate an agent's tool set is - `Agent.registerTool` / `registerToolSource`. Users no longer pre-build a - registry to seed `Agent.init`. - -> **Deferred:** removing a tool from an active `Agent`. Today the workaround is -> to build a fresh `Agent` with the same config + conversation and re-register -> the desired tools. A future `Agent.unregisterTool(name)` convenience is out -> of scope for this project. - -### Pricing (data, aliased — Job 1) - -```zig -pub const Pricing = pricing.Pricing; -pub const PricingRegistry = pricing.Registry; -// cost becomes a method: -// Pricing.cost(self, usage: Usage) ?u64 (was freestanding costMicroCents(usage, pricing)) -``` - -Change: **`costMicroCents` → `Pricing.cost(usage)`** — namespaced on the rate -card (pricing is the rates, usage is the input). - -### Sessions (Refactor R2 — redesigned) - -A clean re-think. The interface is **asymmetric**: rich on write -(audit/provenance-capable), minimal on read (resume-oriented). The store -decides how much write-side richness it durably keeps. - -```zig -pub const PersistentMessage = struct { // the rich, audit-oriented write type - message: Message, // the message being appended (carries its own metadata) - usage: ?Usage, - // wire-format provider identity (no CLI aliases, and NO api_key material - // of any kind — not even a hash; keys never enter libpanto's records): - api_style: APIStyle, - base_url: []const u8, - model: []const u8, - reasoning: ReasoningEffort, - // full provenance context, captured every append: - conversation: []const Message, // the entire current conversation at write time - tools_available: []const ToolDecl, // the tool set offered for this turn -}; -``` - -```zig -pub const SessionStore = struct { // vtable interface; impls own their own init (DSN, dir, ...) - ptr: *anyopaque, - vtable: *const VTable, - pub const VTable = struct { - create: *const fn (ctx: *anyopaque) Session, - list: *const fn (ctx: *anyopaque) anyerror![]SessionInfo, - freeSessionInfos:*const fn (ctx: *anyopaque, infos: []SessionInfo) void, - resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session, - latest: *const fn (ctx: *anyopaque) anyerror!?Session, - load: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Conversation, - appendMessages: *const fn (ctx: *anyopaque, session_id: []const u8, messages: []PersistentMessage) anyerror!void, - }; - // thin proxies for each -}; - -pub const NullStore = ...; // persists nothing; empty/no-op impl of every method -pub const FileSystemJSONLStore = ...; // renamed from SessionManager; directory-backed JSONL - -pub const SessionInfo = struct { // pure data, aliased; for display/selection (`panto sessions`) - id: []const u8, - created: []const u8, - modified: []const u8, - message_count: usize, - last_user_message: []const u8, // may be truncated - // last-used wire identity, updated on append (for resume pre-selection): - api_style: APIStyle, - base_url: []const u8, - model: []const u8, // wire model name, NOT a CLI config alias - reasoning: ReasoningEffort, // disambiguates otherwise-identical aliases -}; - -pub const Session = struct { // pure data: SessionInfo + a store to proxy to - info: SessionInfo, - store: SessionStore, - pub fn load(self: Session) !Conversation; // by value; null->error (id came from resolve/latest) - pub fn append(self: *Session, messages: []PersistentMessage) !void; // *Session: updates info.{api_style,base_url,model,reasoning} -}; -``` - -Design decisions: - -- **No allocator/io in vtable signatures.** A store captures whatever it needs - at its own (unprescribed) `init` — a Postgres store takes a DSN, the FS store - takes a directory. Returned `Conversation` self-describes its allocator; - `[]SessionInfo` is freed via the `freeSessionInfos` vtable method (rather - than struct-wrapping the slice to carry an allocator). -- **`create` returns `Session` (not `!Session`)** — minting an in-memory handle - can't fail. Create-on-demand: nothing hits the backend until the first - `appendMessages`, preserving the "no record before the first assistant - message" invariant. -- **`Session.load` returns `!Conversation` by value, optional dropped** — the - id came from `resolve`/`latest`, so the conversation must exist; a `null` - from the store is promoted to an error. (`Conversation` is a small movable - struct; the `ArrayList` buffer is the only heap part. No `*Conversation`.) -- **No `activeModel` vtable method.** The last-used wire identity lives on - `SessionInfo` and is updated by `Session.append` after it proxies to the - store — hence `append` takes `*Session`. -- **All session identity is wire-format**, never CLI config aliases: - `{api_style, base_url, model, reasoning}`. (See R2 for the format change.) - -#### The write/read asymmetry, and provenance - -- **Write (`appendMessages` / `PersistentMessage`) is maximalist** — the rich, - audit-oriented type. It carries full wire identity, usage, content, the - **entire current conversation**, and the **tool set offered** for the turn. - The library **offers** all of it on every append; the store keeps what it - wants. This makes `libpanto` provenance-*capable* for compliance-concerned - consumers who implement their own store. -- **`api_key` material never enters a record** — not the key, not a hash of it. - Library-level guarantee, not a per-store choice. (Hashing keys is a - deliberate non-goal: it's needless risk in `libpanto` core.) -- **Read (`load`, `SessionInfo`) is minimal** — exactly what resume and - selection need. The vtable never demands a store reproduce provenance on - read, because a store may not have kept it. The read path deserializes - `Conversation` + `SessionInfo`, never a `PersistentMessage`. -- **`FileSystemJSONLStore` deliberately stays minimal.** It ignores the - `conversation` and `tools_available` provenance fields and simply appends the - latest `message` (plus wire identity + usage). The rich fields exist for - *other* stores; the built-in FS store is not an audit store. - -> **Audit-store implementation note (for `SessionStore` docs).** A serious -> audit log should **not** store the full `tools_available` (and likely not the -> full `conversation`) verbatim on every record — that re-serializes the whole -> tool manifest each turn. The recommended design is **content-addressing**: -> hash each tool's `(name, description, schema_json)`, keep a deduplicated tool -> table keyed by that hash, and store only the hash list on each record, -> normalizing the log by joining records against the tool table. The same -> applies to large repeated context. `libpanto` hands you the raw data each -> append; efficient de-duplication is the store's responsibility. - -#### Provider/model pre-selection on load - -The session log stores **wire facts only** (`{api_style, base_url, model, -reasoning}`), never config aliases. Pre-selecting "the provider/model they left -off with" is a **lookup against current config at load time**, not a stored -pointer: - -- A helper `match(config, session_info) -> []candidate` walks configured - providers/aliases and returns every alias whose `(api_style, base_url, model, - reasoning)` equals the session stamp. -- 1 candidate → pre-select. 0 → readable, no pre-selection (prompt / read-only). - N (e.g. two API keys for the same endpoint, or two reasoning levels) → - embedder picks (default / first / prompt). - -Storing the alias instead would freeze stale config state (aliases get renamed) -and *still* fail the N-match case (two keys are indistinguishable on the wire). -Recording `reasoning` as a wire field is what splits otherwise-identical -aliases. The CLI owns the pick; `libpanto` may provide `match` as a helper over -`Config`. - ---- - -## Refactors (the non-additive work) - -Everything else is `public.zig`. These two are genuine internal edits. - -### R1 — tool registry off `Config`, onto `Agent` - -Today `Config.registry: *const ToolRegistry` and `Agent.run` reads -`config.registry`. Consequence: swapping config (provider/model change) forces -rebuilding the tool set. Move tool-set ownership to `Agent`: - -- `Agent` owns/holds the `ToolRegistry` (or borrows one supplied at `init`). -- `Agent.registerTool` / `registerToolSource` mutate it. -- `Config` carries only provider/model/retry/compaction; `setConfig` swaps - those without touching tools. -- The agent loop reads the registry from `self`, not `self.config`. - -This is the one change a façade can't paper over — it relocates *where the -running loop reads the tool set from*. - -### R2 — session-log format: wire-format identity, no config aliases - -> **Updated during implementation (R2 landed):** -> - `session_manager.zig` → **`file_system_jsonl_store.zig`**; the old -> `SessionManager` single-session machinery is now the internal -> `SessionFile`, and a new directory-backed catalog struct -> **`FileSystemJSONLStore`** implements the redesigned `SessionStore` -> vtable (`create`/`list`/`freeSessionInfos`/`resolve`/`latest`/`load`/ -> `appendMessages`). It caches open `SessionFile`s by id so the -> buffer-until-first-assistant write discipline survives the separate -> user/assistant appends of one turn. -> - The on-disk *content* types keep the name **`Stored*`** (e.g. -> `StoredMessage`, `StoredContentBlock`) to avoid colliding with the rich -> store write record `PersistentMessage` (which wraps an in-memory -> `Message` + `WireIdentity` + provenance). `Disk*` → `Stored*`/ -> `Persistent*` accordingly. -> - Wire identity on disk is a `WireStamp { api_style, base_url, model, -> reasoning }` on each `MessageEntry` (null on system entries), replacing -> the single `provider` string. The agent derives it from -> `Config.provider.wireIdentity()`; **`persist_provider`/`persist_model` -> display strings are deleted** — the CLI banner stays alias-based and -> resume picks the default model (the `match` helper stays deferred). -> - **`Agent.init` now takes a `Session`** (minted via `store.create()` or -> resolved via `resolve`/`latest`) rather than a raw `SessionStore`; the -> agent adopts and frees `session.info` in `deinit`. -> - **Dangling-prompt recovery dropped** (resolved decision below). -> - `PersistentMessage` carries the provenance fields (`conversation`, -> `tools_available`); the agent currently passes an **empty -> `tools_available`** (the FS store ignores it; a real tool-manifest -> snapshot is a follow-up). `Message.metadata` round-trips through the -> `Stored*` layer; `Conversation.metadata` is not yet wired. - -The on-disk session format records **wire-format** provider/model identity -(`api_style`, `base_url`, `model`, `reasoning`), replacing today's single -`provider` string and any CLI-alias coupling. `Disk*` types are renamed to -`Persistent*` (e.g. `DiskMessage` → `PersistentMessage`), and -`PersistentMessage` carries its own `provider`/`model` wire identity rather -than the parallel `providers[]`/`models[]` slices the current -`appendMessages` takes. - -**Clean break, no version bump.** Pantograph has effectively a single user -(the author), so existing logs are simply wiped rather than migrated. No -compat shim, and `CURRENT_VERSION` need not change. - ---- - -## Internals that leave the public surface (derived deletions) - -Once `public.zig` is the allowlist, these stop being reachable (they stay `pub` -internally for linking, just un-re-exported): - -- **`provider.zig`**: `ProviderStream`, `openStream`, `OpenStreamFn`, - `ProviderDiagnostic`, `ProviderError`, `isContextOverflowBody`, - `classifyHttpStatus`, `parseRetryAfterMs`, `retryAfterFromHead`, - `isRetryableProviderError`. (`ContentBlockType`, `ProviderRetryInfo` stay — - referenced by `Event`.) -- **`stream.zig`**: `EventQueue` (internal plumbing; only `Event` escapes). -- **`sse.zig`**: `SSEParser` (pure internal). -- **`session.zig`**: the serialization layer — `serializeHeader`, - `serializeEntry`, `parseLine`, `contentBlockToDisk`, - `diskContentBlockToInternal`, `SessionHeader`, `SessionEntry`, `EntryBase`, - `MessageEntry`, `FileEntry`, `ParseError`, `CURRENT_VERSION`. The - `Persistent*` (ex-`Disk*`) content types are referenced by - `PersistentMessage` on the write path; expose only what `appendMessages` - needs. -- **`turn_persist.zig`**: `persistTurn`, `persistCompaction`, - `hasToolUseWithoutFollowingResults` (internal to the agent/session loop). -- **`compaction.zig`**: `computeSplit`, `serializeTranscript`, - `buildRequestBody`, `messageTokenEstimate`, `Split`, `word_to_token_factor`, - `latestSummaryText` (internals; `Agent.compact` is the entry). -- **`tool_registry.zig`**: name-encoding helpers (`validateName`, `encodeName`, - `decodeName`, `max_wire_name_len`, `NameError`, `Entry`, `ToolView`, - iterators) — `ToolRegistry` itself stays, its internals don't. -- **`config.zig`**: `httpClient` (and `initHttp`/`deinitHttp` only reachable via - `init`/`deinit`). -- **`image.zig`**: `process`, `maybeResize`, `detectCodec`, `detectMediaType`, - `codecForMediaType`, `Codec`, `Processed`, `max_dim` — used internally at - tool-result assembly; not a Job-1/Job-2 concern. **Hidden.** Note the - user-visible *behavior* must still be documented: tool-returned images have a - maximum longer-side dimension and `libpanto` resizes larger ones down before - storage/serialization (see the deferred config-setting item above). -- **`session_manager.zig` freestanding utils**: `newUuidV7`, `isoTimestamp` - (id/timestamp minting — internal to whichever store wants them). - ---- - -## Out of scope (deliberately deferred) - -- **A built-in audit/provenance *store*.** Provenance *capability* is in scope - — `PersistentMessage` is rich (full conversation + tool manifest + wire - identity) so a consumer's store can capture it. But `libpanto` does not ship - an audit store; `FileSystemJSONLStore` stays minimal. The content-addressing - design above is a recommendation in the `SessionStore` docs, not code we - write here. -- **Slice-based block/message constructors for non-Zig callers.** That's - `libpanto-c`'s job (covers Go and other cgo/cffi consumers in one place). -- **The provider-config `match` helper UI.** `libpanto` may ship `match`; the - pick policy (default/first/prompt) is the embedder's. -- **Configurable image max-dimension.** `libpanto` resizes oversized images - down to a maximum longer-side dimension (today a hard-coded `max_dim` in - `image.zig`). This should become a `libpanto` config setting (single int, - longer side), with the `panto` CLI supplying the value instead of the value - living in the library. Deferred; for now the behavior is documented (see - below) but the constant stays put. -- **`Agent.unregisterTool`.** Removing a tool from a live agent; today rebuild - a fresh `Agent`. Deferred convenience. - ---- - -## Resolved decisions (formerly open) - -0. **Dangling-prompt recovery — dropped (R2).** The old `LoadedSession` - split out a trailing crash-orphaned user prompt so resume wouldn't - auto-resend it. `Session.load()` returns a plain `Conversation`; a - trailing user message round-trips like any other. -1. **Conversation-level persistence — Agent-only.** A bare `Conversation` - mutation is in-memory; persistence is strictly the `Agent` turn-path's job. - Append-only provenance is preserved because every `PersistentMessage` - carries the full current conversation at write time. -2. **`image.zig` — hidden.** Resize behavior is documented; a configurable - max-dimension is deferred (see Out of Scope). -3. **R2 migration — clean break, no version bump, no shim.** Wipe old logs. -4. **`ToolRegistry` — hidden.** `Agent.registerTool` / `registerToolSource` is - the only public path; no pre-built registry seeds `Agent.init`. - `Agent.unregisterTool` deferred (see Out of Scope). -5. **`ResultParts` — thin struct wrapper** around `[]ResultPart`, carrying - `fromText` / `fromTextOwned` / `deinit`. diff --git a/docs/tui-refactor-plan.md b/docs/tui-refactor-plan.md new file mode 100644 index 0000000..640db66 --- /dev/null +++ b/docs/tui-refactor-plan.md @@ -0,0 +1,489 @@ +# `panto` CLI/TUI Refactor Plan + +Status: **draft, in review**. A near-full rewrite of panto's terminal +interface, heavily influenced by [pi](https://github.com/earendil-works/pi-mono)'s +`pi-tui`. This document is the plan of record; iterate here before writing code. + +## 1. Goal & guiding principle + +Replace today's print-based `CLIRenderer` (`src/main.zig`) with a real terminal +UI that *feels native* while supporting rich, dynamic, extension-controlled +components. + +The defining principle, borrowed from pi: **a scrollback-preserving differential +line renderer, not a full-screen TUI.** + +- We never switch to the terminal's alternate screen buffer. We render into the + normal buffer. Consequence: the user's shell scrollback before launching + `panto` survives, and scrolling up in tmux/Ghostty is just the terminal's own + native scroll — not something panto implements. +- Each frame, components produce lines of text; the engine diffs against the + previously rendered lines and rewrites only what changed, moving the cursor up + with ANSI control codes and reprinting the minimal region. A spinner reprints + one line; collapsing all tool calls reprints the visible region. +- Content that grows past the top of the viewport scrolls up into real native + scrollback. The engine only ever manipulates the visible terminal height. + +This is what produces the blend the design is chasing: native, print-style feel +with full raw-mode dynamism layered on top. + +### Confidence note + +The mechanics below were derived by reading pi's compiled `pi-tui` +(`tui.js`, `terminal.js`) directly — high confidence on *what pi does*. Claims +about terminal/PTY behavior and performance ("uncapped redraw can be slower", +"IMEs anchor to the hardware cursor") are reasoning + pi's own code comments, +not independently benchmarked; flagged inline as **[verify]** where it matters. + +## 2. Architecture overview + +``` + ┌───────────────────────────────────────────────┐ + │ Streaming agent events (libpanto pull Stream) │ + └─────────────────────────┬─────────────────────┘ + │ deltas mutate component state + ▼ + ┌───────────────┐ render(width) ┌──────────────────┐ ANSI bytes ┌───────────┐ + │ Components │ ───────────────► │ TUI engine │ ───────────► │ Terminal │ + │ (built-in & │ ◄─────────────── │ (diff, viewport, │ │ (raw mode │ + │ extension) │ firstLineChanged │ cursor, write) │ ◄─ raw input │ stdin) │ + └───────────────┘ └──────────────────┘ keys/paste └───────────┘ + ▲ + │ input routing, focus, overlays + ┌─────┴───────┐ + │ Input layer │ Kitty kbd protocol + + │ │ modifyOtherKeys fallback + └─────────────┘ +``` + +Layers, bottom-up: + +1. **Terminal** — raw mode, stdin decoding, ANSI output, capability negotiation. +2. **Input layer** — Kitty keyboard protocol negotiation with `modifyOtherKeys` + fallback, bracketed paste, `Key` decoding, keybinding lookup. +3. **TUI engine** — owns the component list, runs the differential render, + tracks viewport/scrollback, composites overlays, positions the hardware + cursor for IME, routes input to the focused component. +4. **Components** — built-in and extension-supplied. Produce lines from + structured state; receive input when focused. +5. **App / chat loop** — wires libpanto's streaming events to component state + and `requestRender`. + +## 3. Rendering model (the core) + +### 3.1 Output discipline + +- **No alternate screen.** Never emit `?1049h`/`?47h`. +- **Synchronized output.** Wrap every frame in `\x1b[?2026h` … `\x1b[?2026l` + so the terminal composites the whole frame atomically (no tearing/flicker). +- **Full redraw only when forced:** first paint, terminal width change (wrapping + changes), terminal height change (viewport realignment). A full redraw clears + scrollback (`\x1b[2J\x1b[H\x1b[3J`); ordinary frames never do. +- **Width contract.** Every line a component returns must have visible width + ≤ render width. The engine validates and treats overflow as a hard error + (caught, terminal restored, diagnostic written) — components must truncate. + +### 3.2 Viewport & scrollback tracking + +The engine tracks, across renders: + +- `previous_lines` — the last rendered line array (also the diff baseline and + the cache reused for clean-below-cut reprinting; see §3.3). +- per-component **line offset and line count** (so a length change in one + component shifts everything below it, and offsets below are recomputed). +- `viewport_top` / `max_lines_rendered` — the working area. Lines above + `viewport_top` have scrolled into native scrollback and are off-limits to + differential updates. If a change lands above `viewport_top`, the engine must + fall back to a full redraw. + +### 3.3 The dirty model (`firstLineChanged`) — primary fast path + +This is the component→engine contract that makes streaming cheap. It exists at +**two layers** that optimize two different costs: + +- **Producing lines** (component CPU: markdown, highlight, wrap) — the expensive + one during streaming. +- **Writing lines** (engine: diff + cursor + write) — cheap regardless. + +**Component-side signal:** each component exposes + +``` +firstLineChanged: ?usize // lowest line index (component-local) whose + // rendered output differs from the last render, + // or null if nothing changed. +``` + +Defined as *lowest differing output line*, **not** "where text was appended." +A markdown delta that restyles earlier lines (e.g. a closing code fence) makes +those lines differ, so an honest implementation already reports the earlier +index — no special reflow case is needed. + +**Engine pass (top-to-bottom, single walk):** + +1. Accumulate each component's global offset from its (possibly cached) line + count. +2. Compute `cut = min(offset_i + firstLineChanged_i)` across **all** components + (not just the first non-null — a footer/spinner ticking mid-stream is the + common counter-example to "stop at first"). +3. Reprint from `cut` downward: + - components **above** the cut: untouched. + - the component **owning** the cut and any dirty component below it: + re-render from its local `firstLineChanged`. + - clean components **below** the cut (`firstLineChanged == null`): reuse their + **cached** lines verbatim — reprinted because they're below the rolled-back + point, but **not re-rendered** (no component CPU). The engine already holds + these lines for the diff baseline, so no new state. +4. Handle length changes: if a component now returns fewer lines (e.g. ctrl+o + collapse), clear orphaned trailing lines and recompute offsets below. +5. **Line-diff backstop.** From `cut` downward, still run the old-vs-new line + diff. `firstLineChanged` decides *where re-rendering starts*; the diff is the + *correctness floor* (handles length deltas, defends against an inaccurate + signal). The signal is the fast-path input; the diff is the guarantee. + +**Lifecycle (no desync):** `firstLineChanged` is *derived from the component's +render cache*, mirroring pi's `invalidate()` model — state mutation invalidates +the cache (dirties), a successful render re-populates it (cleans). It is not a +separately hand-managed field that can drift. + +For streaming specifically, the cut is almost always deep in the last +component, so per-frame work is: re-render one short tail + memcpy cached lines +below + write from the cut. This is the "outperform the print CLI" path. + +### 3.4 Coalescing / frame rate + +- `requestRender()` schedules a frame; multiple requests within a window + coalesce into one render. +- **Target ~120fps (≈8ms) adaptive:** render immediately when idle; coalesce + only under burst. Coalescing is a *ceiling* to avoid redrawing faster than the + terminal drains (which queues deltas and *grows* latency) — **[verify]** with + a P1 benchmark rather than removing the cap outright. + +### 3.5 Virtual cursor + hardware-cursor sync (IME) — kitchen sink + +The cursor is drawn by the component as part of its rendered output (a styled +reverse-video block), not the terminal's hardware cursor. This keeps the cursor +correct under differential rendering, lets it live inside wrapped text/overlays, +and lets us style it. + +Because IMEs (CJK input, accent/emoji composition) anchor their candidate popup +to the *hardware* cursor **[verify]**, we keep the hidden hardware cursor in sync +with the virtual one: + +- A focused component emits an invisible marker (APC escape, zero-width) at the + virtual cursor's location. +- After rendering, the engine scans the visible region for the marker, strips + it, computes its row/col, and moves the (normally hidden) hardware cursor + there. +- `Focusable` is the interface a component implements to participate. + +Full scope from day one: composite the virtual cursor, keep hardware cursor +synced. (If schedule slips, the safe deferral is to leave the hardware cursor at +end-of-content and add marker-positioning later — but the interface ships now.) + +## 4. Core interfaces + +Idiomatic Zig interfaces (vtable-over-`*anyopaque` or tagged dispatch — TBD in +§4.5). Designed to bridge cleanly to Lua now, and to a C ABI *later, elsewhere* +(out of scope here, but the shapes stay coherent: lines-out as arrays, structured +data in). + +### 4.1 `Component` + +``` +Component = struct { + // Produce lines for the given width. Each line's visible width <= width. + render: fn (self, width: usize, alloc) []const []const u8, + + // Lowest component-local line index whose output changed since last render, + // or null. Derived from the render cache. See §3.3. + firstLineChanged: fn (self) ?usize, + + // Drop cached render state (e.g. on theme change). Re-dirties. + invalidate: fn (self) void, + + // Optional: receive input when focused. + handleInput: ?fn (self, data: []const u8) void, + + // Optional: opt into key-release events (Kitty protocol). + wantsKeyRelease: bool = false, +}; +``` + +### 4.2 `Focusable` (virtual cursor / IME) + +``` +Focusable = struct { + focused: bool, // set by engine on focus change + // The component emits CURSOR_MARKER at the cursor position in render() + // output when focused; the engine handles the rest (§3.5). +}; +``` + +Container components that embed an input must propagate `focused` to the child. + +### 4.3 Input types + +``` +Key = struct { + code: KeyCode, // enum: char, enter, escape, tab, arrows, fn-keys… + mods: Mods, // packed struct { ctrl, alt, shift, super, hyper… } + event: KeyEvent, // press | repeat | release + text: ?[]const u8, // resolved text for printable keys +}; +``` + +- Rich model from day one (modifiers + press/repeat/release), even though legacy + fallback can only populate a subset. +- `Keybinding` storage: a lookup from `Key` pattern → action, owned by the app, + passed to components that need app-level bindings. + +### 4.4 Theme + +A **fresh, dedicated theme module** collecting all colors in one place (today's +CLI just emits raw ANSI dim/cyan inline). Deep configurable theming is out of +scope for this project — the goal is centralization, not a theming system. The +module exposes a theme object of named foreground/background style functions +(mirrors pi's `fg(name, text)` / `bg(name, text)`), passed to components at +render. Markdown theming hooks reserved for the deferred markdown work (§8). + +### 4.5 Dispatch mechanism — decided: vtable + +**Vtable-of-fn-pointers over `*anyopaque`.** A tagged union is rejected: it +requires knowing all component types up front, but extensions must be able to +**define their own** components, not just replace built-ins (§7). The vtable is +also the natural shape for the Lua bridge (and a future C ABI). Built-in and +extension components are indistinguishable to the engine. + +## 5. Input layer + +- **Raw mode** via termios; restore on exit (and on crash — install handlers). +- **Kitty keyboard protocol** negotiation at startup: request desired flags + (disambiguate, report-events, report-alternates), query, use if supported. + This is what enables unambiguous modifier chords (`Ctrl+I` ≠ `Tab`, lone + `Esc`, key releases). +- **`modifyOtherKeys` fallback** (`\x1b[>4;2m`) for terminals without Kitty. +- **Bracketed paste** (`\x1b[?2004h`): wrap pastes so the editor treats them as + literal text, not a stream of keypresses. +- **Stdin buffering/splitting:** batched input must be split into individual + key sequences so `matchesKey`/release detection work (pi's `StdinBuffer`). +- Disable/restore all of the above cleanly on stop and on suspend/resume. + +Platform note: panto targets Unix first (matches current build). Windows VT +input is explicitly out of scope for this refactor. + +## 6. Built-in components + +All are **public** and **replaceable via events** (§7), and their render-to-lines +logic is itself public so a handler can construct the default and wrap/filter its +output. Each takes **structured data in** and produces **lines out**. + +> **Invariant — no "active component."** The engine holds a *list* of live +> components; there is never a notion of "the current component." Multiple tool +> calls (and future subagents) render in parallel, each bound to its own +> instance/call-id. No code may stash `active_component` or reach for "the" +> component of a kind — every event and delta carries its own instance identity. +> Violating this corrupts parallel tool/subagent rendering. + +| Component | State in | Notes | +|---|---|---| +| **welcome** | version, cwd, model info | shown at session start | +| **thinking text** | accumulating thinking deltas | dimmed; streams | +| **assistant text** | accumulating assistant deltas | streams; markdown **deferred** (§8) but hosts it | +| **user text** | submitted user message | | +| **tool use** | tool name, args, status, result | **one component owns call + result**; collapsible (ctrl+o) — collapse is a length change (§3.3) | +| **compaction summary** | compaction event data | shown when context is compacted | +| **input box** | editor buffer, cursor | `Focusable`; single-row by default, **shift+enter inserts a newline** (enter submits), grows one row per line. **P1:** unbounded growth (no cap). **P2:** configurable cap (default 8) then scrolls within that window showing the last 8 contiguous lines. Raw-key editing | +| **footer** | model, git branch, token/usage stats | persistent bottom line(s); during P1 also renders a **frame-timing element** (last frame's render time shown inverted as a theoretical-max "fps"), used to validate perf then removed | + +The chat transcript is a `Container` of these in order; the input box and footer +are pinned below it. + +## 7. Extension UI surface — one event mechanism + +There is **one** mechanism for all extension UI: string-keyed events with +handlers that can set/replace the component for that event. It subsumes slot +replacement, custom tool rendering, and imperative component spawning. + +### 7.1 Events are open strings, not an enum + +Event types are **strings**. Extensions register handlers for, and **fire**, +their own event types. Built-in events are simply event strings panto emits +itself; extension events are mechanically identical. + +``` +panto.on("tool", handler) // subscribe (built-in or custom event) +panto.emit("my-event", data) // fire — from a custom tool, a keybinding, + // or inside another event's handler +``` + +This is also the **only** way a component gets on screen: pick an event string, +register a handler that sets your component, then emit the event. There is no +separate `addComponent` API — component additions are *always* tied to an event +firing. (Imperative "spawn a status panel on a timer" = define an event type and +emit it.) + +### 7.2 The event object's UI API + +Handlers receive an `event` carrying structured data plus: + +``` +event.getComponent() -> ?Component // the component currently chosen for this + // event: the built-in default at first, + // or whatever a prior handler set. +event.setComponent(c: Component) // set/replace it. +// + structured data, e.g. event.tool_name, event.args, ... +``` + +`getComponent()` is named to make clear it returns *whatever is current*, not a +frozen "default" — after the first handler runs it is no longer the default. + +**Wrapping is the documented, expected pattern:** + +``` +panto.on("tool", (e) => { + if (e.tool_name !== "skill") return; + const inner = e.getComponent(); // the default tool-use component + e.setComponent(wrapSkill(inner)); // decorate / replace +}); +``` + +### 7.3 Handler ordering & precedence + +Handlers for an event run in **registration order**. Precedence is +**last-wins-blind**: the final `setComponent` is used. This is acceptable +*because* the chain-friendly path (`getComponent` → wrap → `setComponent`) is the +documented norm — a handler that clobbers without reading the current component +is at fault, not the framework. No magic merge. + +### 7.4 Timing & lifecycle + +- An event fires **once, at its component-creation boundary**, *before* the + component first paints — so `setComponent` swaps it before anything renders. +- For `tool`, that boundary is tool-use **start** (name known). The single + `tool` event covers the whole call: the chosen component is then **driven by + panto** with the streaming args/result/status as structured deltas. The + handler does **not** re-fire per delta; it chooses the component once. +- All streamable components share this shape: fire once at start, hand over a + component, drive with deltas (§8). +- **No "active component" (§6 invariant).** Each `tool`/subagent event yields its + own component instance keyed by call-id. Parallel calls each get their own. + +### 7.5 The skills example (worked) + +A skills extension: +1. Registers the `skill` agent tool (existing tool machinery). +2. Registers a `skill` component type. +3. `panto.on("tool", ...)` and, when `event.tool_name === "skill"`, calls + `event.setComponent(skillComponent)` (optionally wrapping `getComponent()`). + +panto needs no concept of "skills." It emits `tool` at tool-use start; the +handler claims the call by name and swaps the component; panto drives that +instance with the tool's deltas. A subagents extension works identically. + +### 7.6 Zig → Lua bridge + +Implement the event system + component vtable in Zig (matching `libpanto`'s +"shape the internal type to *be* the public API, then allowlist in +`public.zig`" convention), then bridge to Lua via the existing +`lua_bridge`/`lua_runtime` machinery. A Lua handler receives a bridged `event` +object; a Lua-defined component implements the same +`render`/`firstLineChanged`/`handleInput` vtable contract across the bridge. +Native `.so`/C-ABI loading is **explicitly out of scope** for this project, but +the vtable stays C-ABI-friendly so it can be added elsewhere later. + +## 8. Streaming integration + +- **Stateful component + `requestRender`** (no per-delta "append" method on the + interface). A delta arrives → append to the owning component's internal buffer + → mark dirty → `requestRender()`. The dirty model (§3.3) makes the resulting + frame minimal. +- The app loop consumes libpanto's pull `Stream` (as `CLIRenderer.driveTurn` + does today) but, instead of writing to stdout, routes each event to component + state: + - thinking delta → thinking-text component + - text delta → assistant-text component + - tool-use start/args/result → tool-use component (one **per call**, keyed by + call-id; never "the" tool component — see §6 invariant) + - compaction/retry events → compaction component / status +- Each component-creation boundary emits the corresponding built-in event (§7) + *before* first paint, so handlers can replace/wrap before anything renders. +- **Append fast path** is handled entirely by `firstLineChanged` + cached + lines (§3.3): the streaming component re-renders only its dirty tail; the + engine reprints from the cut. No separate API surface. +- **Markdown caveat (deferred):** when markdown lands, the assistant-text + component must cache finished blocks and only re-render the last open block, so + `firstLineChanged` stays near the tail instead of jumping to line 0 each token. + The component interface already accommodates this; only the markdown renderer + is deferred. + +## 9. Phasing + +**P1 — Engine + input + chat skeleton (the core bet).** +- Terminal raw mode, ANSI output, synchronized output, capability detect. +- Differential renderer: viewport/scrollback tracking, `firstLineChanged` dirty + model + cached-line reuse + line-diff backstop, full-redraw triggers. +- Input layer: Kitty negotiation + `modifyOtherKeys` fallback + bracketed paste + + `Key` decoding. +- Minimal components: assistant text, user text, input box (single-row + + shift+enter growth, **unbounded in P1** — no cap yet), footer. +- Footer renders the frame-timing element (theoretical-max "fps" from the last + frame's render time) for perf validation; expected to sit well north of 120 + always, after which the element is removed. +- Wire libpanto streaming into component state. +- **Benchmark streaming feel** vs. current print CLI; validate the 120fps / + coalescing target and the append fast path. **[verify]** the perf assumptions. + Methodology is left to the implementation agent's discretion — assumptions are + acceptable for now; we expect to refine once the streaming TUI is exercised in + practice. + +**P2 — Full built-in set + overlays + editor.** +- Remaining built-ins: welcome, thinking, tool-use (collapsible), compaction. +- Overlay system (modal components composited over base content; positioning, + focus stack). +- Full editor: configurable line cap (default 8) with scroll-window past it + (show last 8 contiguous lines), word-nav, kill-ring/undo as warranted. +- Footer with git branch + usage stats. + +**P3 — Extension components + cursor/IME polish.** +- Event system (`on`/`emit`, `event.get/setComponent`); built-in events wired at + component-creation boundaries; public default components. +- Zig event + component API → Lua bridge. +- Virtual cursor compositing + hardware-cursor sync (IME marker positioning). + +**Deferred (post-refactor):** +- Markdown + syntax highlighting renderer (hosting components designed for it + now; see §8 caveat). +- Image rendering (Kitty/iTerm protocols). +- Windows / native `.so` C-ABI extension loading. + +## 10. Open questions / decisions still pending + +All P1-blocking questions resolved. Remaining item (5) is non-blocking and left +for the implementation agent to take a first swing at. + +5. **Built-in event taxonomy**: confirm the exact set/names of events panto + emits and their precise creation-boundary timing (draft in §8: `session_start`, + `user_message`, `thinking`, `assistant_text`, `tool`, `compaction`). The + implementation agent should take a first swing; we expect to correct it later + as the TUI streaming is put through its paces. + +*Resolved:* +- Dispatch mechanism = vtable (§4.5); extension UI = one string-event + mechanism (§7); no "active component" (§6). +- **(1) Editor depth**: single-row default; **shift+enter** inserts a newline + (enter submits); grows one row per line. **P1** ships height-1 + shift+enter + with *unbounded* growth (no cap — the cap and its scroll-window are one + feature, deferred together). **P2** adds the configurable cap (default 8) and + scroll-window showing the last 8 contiguous lines (§6 input box). +- **(2) Theme source**: fresh dedicated theme module centralizing all colors; + no configurable theming system this project (§4.4). +- **(3) App loop location**: new module; `main.zig` shrinks to wiring (§2/§9). +- **(4) Benchmark methodology**: left to the implementation agent's discretion; + assumptions acceptable for now. Footer surfaces a frame-timing/"fps" element + during P1 to validate, then it's removed (§6 footer, §9 P1). + +## 11. Non-goals + +- Full-screen TUI / alternate screen. +- Implementing our own scrollback or scroll handling (native terminal does it). +- Windows support, image rendering, markdown — all explicitly later/deferred. -- cgit v1.3