diff --git a/crates/oxide-code/src/client/anthropic/betas.rs b/crates/oxide-code/src/client/anthropic/betas.rs index 25919a03..4c352d80 100644 --- a/crates/oxide-code/src/client/anthropic/betas.rs +++ b/crates/oxide-code/src/client/anthropic/betas.rs @@ -39,7 +39,7 @@ pub(super) fn compute_betas( .split('-') .any(|tok| tok.eq_ignore_ascii_case("haiku")); - // Order mirrors `docs/research/anthropic-api.md` → Per-model beta + // Order mirrors `docs/research/api/anthropic-api.md` → Per-model beta // sets: identity / auth → universal agentic → capability-gated. let mut out = Vec::with_capacity(8); diff --git a/crates/oxide-code/src/session/actor.rs b/crates/oxide-code/src/session/actor.rs index 54bed724..a32b00cb 100644 --- a/crates/oxide-code/src/session/actor.rs +++ b/crates/oxide-code/src/session/actor.rs @@ -8,7 +8,7 @@ //! before this drain runs; isolated writes (a text-only turn, the //! AI title append, the final summary) flush immediately because //! the drain returns `Empty` after the first cmd. No interval timer -//! — see `docs/research/session-persistence.md`. +//! — see `docs/research/design/session-persistence.md`. use std::sync::Arc; diff --git a/docs/research/README.md b/docs/research/README.md index e2f6e41a..4885bc4d 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -1,13 +1,23 @@ # Research Notes -Architecture research and API reference notes for oxide-code development. +Architecture research and API reference notes for oxide-code development. Split by direction: + +- [`api/`](api/) — outward-facing: how the Anthropic API works and what oxide-code has to send to it. +- [`design/`](design/) — inward-facing: surveys of reference projects + design choices for oxide-code. + +## API references | Document | Description | | --------------------------------------------- | ---------------------------------------------------------------------- | -| [Anthropic API](anthropic-api.md) | Anthropic API auth: OAuth flow, required headers, system prompt prefix | -| [Extended Thinking](extended-thinking.md) | Extended thinking: content block types, signatures, round-tripping | -| [System Prompt](system-prompt.md) | System prompt architecture: section assembly, CLAUDE.md, caching | -| [Session Persistence](session-persistence.md) | Session persistence: JSONL format, storage layout, listing strategy | -| [Terminal UI](tui.md) | TUI research: reference projects, flickering prevention, crate stack | -| [Tool Output Truncation](tool-truncation.md) | Tool dispatcher truncation: per-tool vs central, caps and spillover | -| [File Change Tracking](file-tracking.md) | Read-before-Edit gate, staleness detection, persistence across resume | +| [Anthropic API](api/anthropic-api.md) | Anthropic API auth: OAuth flow, required headers, system prompt prefix | +| [Extended Thinking](api/extended-thinking.md) | Extended thinking: content block types, signatures, round-tripping | +| [System Prompt](api/system-prompt.md) | System prompt architecture: section assembly, CLAUDE.md, caching | + +## Design surveys + +| Document | Description | +| ---------------------------------------------------- | --------------------------------------------------------------------- | +| [Session Persistence](design/session-persistence.md) | Session persistence: JSONL format, storage layout, listing strategy | +| [Terminal UI](design/tui.md) | TUI research: reference projects, flickering prevention, crate stack | +| [Tool Output Truncation](design/tool-truncation.md) | Tool dispatcher truncation: per-tool vs central, caps and spillover | +| [File Change Tracking](design/file-tracking.md) | Read-before-Edit gate, staleness detection, persistence across resume | diff --git a/docs/research/anthropic-api.md b/docs/research/api/anthropic-api.md similarity index 98% rename from docs/research/anthropic-api.md rename to docs/research/api/anthropic-api.md index 562fc547..446a1828 100644 --- a/docs/research/anthropic-api.md +++ b/docs/research/api/anthropic-api.md @@ -269,7 +269,7 @@ Invalidation order (from the Anthropic caching docs) is `tools → system → me ### `thinking.display` -See [Extended Thinking § Display modes (Opus 4.7+)](./extended-thinking.md#display-modes-opus-47). Opus 4.7 silently flipped the default to `"omitted"`; `show_thinking=true` in oxide-code opts back into `"summarized"`. +See [Extended Thinking § Display modes (Opus 4.7+)](extended-thinking.md#display-modes-opus-47). Opus 4.7 silently flipped the default to `"omitted"`; `show_thinking=true` in oxide-code opts back into `"summarized"`. ## Third-Party Tool Restrictions @@ -292,7 +292,7 @@ Re-distribution gateways front the upstream API and reject anything that doesn't 4. **Beta header set and order.** Match Claude Code's emit order on agentic 4.6+ requests: `claude-code-20250219, [oauth-2025-04-20,] interleaved-thinking-2025-05-14, context-management-2025-06-27, prompt-caching-scope-2026-01-05, effort-2025-11-24`. `prompt-caching-scope-2026-01-05` ships unconditionally — the matching `cache_control.scope: "global"` body field stays gated on `is_first_party_base_url` because 3P gateways reject the scope downstream of tool definitions, but the header alone keeps the wire fingerprint intact. 5. **`metadata.user_id` shape.** Field order: `device_id`, `account_uuid`, `session_id`. Empty `account_uuid` is still required as a present field, not a missing key. Use a typed struct rather than `serde_json::json!` so the wire order matches the source declaration — `json!` ships fields alphabetically without the `preserve_order` feature, which trips the verifier. `device_id` is minted as 64 lowercase hex chars (32 random bytes) and persisted at `$XDG_DATA_HOME/ox/user-id`; verifiers check shape, not whether the value round-trips to Claude Code's `~/.claude.json#userID`. -System-prompt block content is a separate axis the gateway validates — see [system-prompt § Third-Party Gateway Validation](./system-prompt.md#third-party-gateway-validation) for the prompt-content side of the same check. +System-prompt block content is a separate axis the gateway validates — see [System Prompt § Third-Party Gateway Validation](system-prompt.md#third-party-gateway-validation) for the prompt-content side of the same check. A residual rejection band remains under sampled stricter checks. Suspected vectors: TLS / HTTP/2 fingerprint (reqwest + rustls vs Node + native TLS) and request body field order — Claude Code 2.1.121 emits `model, messages, system, ...` while oxide-code's `CreateMessageRequest` puts `system` before `messages` so `inject_cch`'s `replacen` is unambiguous when tool results contain literal `cch=00000` text. Reordering would require either Bun-style byte-level placeholder discovery or accepting that user message content will never carry the exact placeholder string. diff --git a/docs/research/extended-thinking.md b/docs/research/api/extended-thinking.md similarity index 100% rename from docs/research/extended-thinking.md rename to docs/research/api/extended-thinking.md diff --git a/docs/research/system-prompt.md b/docs/research/api/system-prompt.md similarity index 97% rename from docs/research/system-prompt.md rename to docs/research/api/system-prompt.md index e0e2fda5..33813c64 100644 --- a/docs/research/system-prompt.md +++ b/docs/research/api/system-prompt.md @@ -140,7 +140,7 @@ Tool schemas are sent via the API `tools` parameter, **not** in the system promp The API supports prompt caching via `cache_control` on `TextBlockParam` blocks. Cache scopes: -- `global` — static instructions identical across all sessions. **First-party only**; 3P gateways reject a `scope: "global"` block downstream of tool definitions (they render before `system` and taint the cache prefix). See [Prompt Caching Scope](./anthropic-api.md#prompt-caching-scope) for the full invariance rule. +- `global` — static instructions identical across all sessions. **First-party only**; 3P gateways reject a `scope: "global"` block downstream of tool definitions (they render before `system` and taint the cache prefix). See [Anthropic API § Prompt Caching Scope](anthropic-api.md#prompt-caching-scope) for the full invariance rule. - _(absent)_ — default (org-scoped) ephemeral cache. Universally accepted. - `null` (no `cache_control`) — dynamic content, not cached. @@ -211,11 +211,11 @@ The URL includes a `?beta=true` query parameter. Third-party gateways validate the system-block layout in addition to wire-shape signals. Empirically, prompt-content checks are content-similarity-based and treat the static prefix as load-bearing: -- The identity prefix block (`"You are Claude Code..."` and friends, see [`CLI_SYSPROMPT_PREFIXES`](./anthropic-api.md#2-system-prompt-prefix-as-a-separate-block)) must occupy its own block. Concatenating it into the prompt body fails the same way 1P fails non-Haiku OAuth. +- The identity prefix block (`"You are Claude Code..."` and friends, see [Anthropic API § System prompt prefix](anthropic-api.md#2-system-prompt-prefix-as-a-separate-block)) must occupy its own block. Concatenating it into the prompt body fails the same way 1P fails non-Haiku OAuth. - Static section text is accepted when it closely matches the known Claude Code prompt content shipped with this version. Heavily customized static prompts can trip the verifier even when every header is correct. - Dynamic sections (after the `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` marker) are accepted alongside valid static content. The boundary itself is consumed by `splitSysPromptPrefix()` and never reaches the wire. -For the wire-shape side of the check (Stainless headers, billing attestation, beta header set, `metadata.user_id` shape, `User-Agent`), see [anthropic-api § Third-Party Gateway Validation](./anthropic-api.md#third-party-gateway-validation). +For the wire-shape side of the check (Stainless headers, billing attestation, beta header set, `metadata.user_id` shape, `User-Agent`), see [Anthropic API § Third-Party Gateway Validation](anthropic-api.md#third-party-gateway-validation). ## Sources diff --git a/docs/research/file-tracking.md b/docs/research/design/file-tracking.md similarity index 90% rename from docs/research/file-tracking.md rename to docs/research/design/file-tracking.md index 0d8a240c..7e4c155c 100644 --- a/docs/research/file-tracking.md +++ b/docs/research/design/file-tracking.md @@ -40,6 +40,8 @@ type FileState = { **Concurrency:** single-threaded JavaScript with the LRU cache as implicit serialization point. No `Mutex` / `Semaphore`. +**Sources:** `claude-code/src/tools/FileEditTool/FileEditTool.ts:275-311` (Edit-time gate + mtime / content fallback), `claude-code/src/utils/fileStateCache.ts` (LRU cache shape, eviction), `claude-code/src/utils/queryHelpers.ts:346-501` (resume rehydration via message history — the approach we explicitly do **not** take). + ### OpenAI Codex (Rust) No explicit cache. File validation is deferred to the `apply_patch` verification layer (`codex-rs/core/src/tools/handlers/apply_patch.rs:340-464`). @@ -54,9 +56,11 @@ There is no "must Read first" rule. Code can be edited that the model has never **Concurrency:** per-session `Mutex`; no per-file lock. -### opencode (TypeScript / Effect) +**Sources:** `codex-rs/core/src/tools/handlers/apply_patch.rs:340-464` (deferred apply-time validation). + +### opencode (TypeScript) -Per-file `Semaphore` lock (`packages/opencode/src/tool/edit.ts:36-46`) prevents concurrent edits to the same path; no content / timestamp cache. +Per-file `Semaphore` lock (`opencode/packages/opencode/src/tool/edit.ts:36-46`) prevents concurrent edits to the same path; no content / timestamp cache. ```typescript const locks = new Map() // module-global @@ -75,6 +79,8 @@ A stale `oldString` triggers a "string not found" error — the model must adjus **Concurrency:** per-file `Semaphore` from `effect` library (`withPermits(1)`). Different files edit in parallel; same file serializes. +**Sources:** `opencode/packages/opencode/src/tool/edit.ts:36-46` (per-file `Semaphore`), `opencode/packages/opencode/src/tool/edit.ts:115-118` (disk re-read on every Edit). + ## Comparison | Repo | Tracker | Read-before-Edit | Stale check | Resume | Concurrency | @@ -90,7 +96,7 @@ Read / Write / Edit tools are unit structs (`ReadTool`, `WriteTool`, `EditTool`) The only existing signal for external modification is Edit's `"old_string not found in {path}"` error — a false negative if the user happens to leave the matched substring intact while changing surrounding lines. The model would then edit the file based on stale context. -The session machinery already parses past tool_use / tool_result pairs via `crates/oxide-code/src/session/history.rs`. That gives us the building blocks for claude-code-style message-history rehydration if we want it. But the JSONL schema's `Entry::Unknown` `#[serde(other)]` catch-all (see `crates/oxide-code/src/session/entry.rs` and `docs/research/session-persistence.md` § Forward Compatibility) also makes it cheap to add a new entry type — explicit persistence is more direct than parsing message bodies and avoids coupling to sanitization shape. +The session machinery already parses past tool_use / tool_result pairs via `crates/oxide-code/src/session/history.rs`. That gives us the building blocks for claude-code-style message-history rehydration if we want it. But the JSONL schema's `Entry::Unknown` `#[serde(other)]` catch-all (see `crates/oxide-code/src/session/entry.rs` and [Session Persistence § Forward Compatibility](session-persistence.md#forward-compatibility)) also makes it cheap to add a new entry type — explicit persistence is more direct than parsing message bodies and avoids coupling to sanitization shape. ## Design Decisions for oxide-code @@ -107,19 +113,8 @@ The roadmap item is: skip re-reads when content hasn't changed, and guard agains ## Sources -### oxide-code - - `crates/oxide-code/src/session/entry.rs` — JSONL forward-compat (`Entry::Unknown` `#[serde(other)]` catch-all). - `crates/oxide-code/src/session/history.rs` — past tool_use / tool_result pairing (alternative resume strategy: rehydrate from message history rather than persisted snapshots). - `crates/oxide-code/src/tool/edit.rs:185-266` — read-before-replace flow (line 212 reads pre-edit content; line 256 writes post-edit). - `crates/oxide-code/src/tool/read.rs:103-195` — file open + bytes-in-memory point where post-Read hashing would slot in. - `crates/oxide-code/src/tool/write.rs:78-101` — `is_new` detection pattern (today's closest analog to "we touched this file"). - -### Reference projects - -- `claude-code/src/tools/FileEditTool/FileEditTool.ts:275-311` — Edit-time gate + mtime/content fallback. -- `claude-code/src/utils/fileStateCache.ts` — LRU cache shape, eviction. -- `claude-code/src/utils/queryHelpers.ts:346-501` — resume rehydration via message history (the approach we explicitly do **not** take). -- `codex-rs/core/src/tools/handlers/apply_patch.rs:340-464` — codex's deferred apply-time validation. -- `opencode/packages/opencode/src/tool/edit.ts:36-46` — per-file `Semaphore`. -- `opencode/packages/opencode/src/tool/edit.ts:115-118` — disk re-read on every Edit. diff --git a/docs/research/session-persistence.md b/docs/research/design/session-persistence.md similarity index 98% rename from docs/research/session-persistence.md rename to docs/research/design/session-persistence.md index 55971882..f896e586 100644 --- a/docs/research/session-persistence.md +++ b/docs/research/design/session-persistence.md @@ -1,6 +1,6 @@ # Session Persistence -Research findings for oxide-code session persistence, based on analysis of reference projects ([Claude Code](https://github.com/hakula139/claude-code) (v2.1.87), [OpenAI Codex](https://github.com/openai/codex), [learn-claude-code](https://github.com/the-pocket/learn-claude-code)), POSIX append-only semantics, and Anthropic Messages API ordering requirements. +Research findings for oxide-code session persistence, based on analysis of reference projects ([Claude Code](https://github.com/hakula139/claude-code) (v2.1.87), [OpenAI Codex](https://github.com/openai/codex)), POSIX append-only semantics, and Anthropic Messages API ordering requirements. ## Reference Implementations @@ -22,12 +22,6 @@ Research findings for oxide-code session persistence, based on analysis of refer - Bounded `mpsc` channel + `tokio::spawn`-ed `RolloutWriterTask`. Cmds: `AddItems`, `Persist`, `Flush`, `Shutdown` — each barrier carries an `oneshot::Sender>` ack. Terminal task failure is read post-mortem from a `Mutex>>` slot on the recorder. - Receive-and-drain inside the writer loop — `recv().await` for the first cmd, then `try_recv()` non-blocking to coalesce queued cmds into a single batch flush. No interval timer (Rust + mpsc subsumes claude-code's `FLUSH_INTERVAL_MS = 100` JS-event-loop workaround). -### learn-claude-code (Python) - -- Project-local `.transcripts/transcript_{TIMESTAMP}.jsonl`. -- 3-layer context compression (micro, auto, manual). -- Auto-archive full transcript before summarization. - ## Storage Format Every session is a single `.jsonl` file — one JSON object per line. Append-only (crash-safe with write-then-flush), streamable (incremental read / write), and universal (used by all reference implementations). diff --git a/docs/research/tool-truncation.md b/docs/research/design/tool-truncation.md similarity index 92% rename from docs/research/tool-truncation.md rename to docs/research/design/tool-truncation.md index 029c1081..2e33d09d 100644 --- a/docs/research/tool-truncation.md +++ b/docs/research/design/tool-truncation.md @@ -20,13 +20,17 @@ Per-tool defaults are tightened by tool config; per-message budget overrides ind No per-line truncation in the dispatcher; that's the tool schema's responsibility. +**Sources:** `claude-code/src/services/streamingToolExecutor.ts` (per-tool then per-message cap application), `claude-code/src/utils/toolLimits.ts` (tiered cap constants). + ### OpenAI Codex (Rust) No system-wide cap. Tools either bound their own output (e.g., `MATCH_LIMIT = 50` for ripgrep results in `fuzzy_file_search.rs`) or return whatever the underlying command produces. Pagination caps appear on message-level features (`THREAD_LIST_DEFAULT_LIMIT = 25`, `THREAD_TURNS_MAX_LIMIT = 100`) but those bound list responses, not tool output bytes. The implication: a `bash cat large.log` returns however many bytes ripgrep / cat printed. Codex relies on the model to ask for tighter ranges if a tool emits too much. -### opencode (TypeScript / Effect) +**Sources:** `codex-rs/core/src/tools/handlers/fuzzy_file_search.rs` (`MATCH_LIMIT = 50`, per-tool with no central layer). + +### opencode (TypeScript) Centralized via `Truncate.Service` (one truncation pass after the tool runs, before the result is appended to the message): @@ -44,6 +48,8 @@ When either limit trips, the service: The hint string adapts to agent capabilities — with a Task tool it reads `"Use the Task tool to have explore agent process this file..."`; without it, `"Use Grep / Read with offset/limit on the full content..."`. +**Sources:** `opencode/packages/opencode/src/tool/truncate.ts` (`Truncate.Service`: `MAX_LINES = 2000`, `MAX_BYTES = 50 KB`, `RETENTION = 7 days`, head / tail + file spillover with adapted hint). + ## Comparison | Repo | Cap location | Per-tool cap | System cap | Strategy | Spillover | Per-tool override | @@ -91,16 +97,7 @@ The roadmap calls for centralizing the byte-budget at the dispatcher. The decisi ## Sources -### oxide-code - - `crates/oxide-code/src/tool.rs` — `ToolRegistry::run` (dispatcher cap entry point), `cap_output()` (head-tail), `MAX_OUTPUT_BYTES`, `TRUNCATION_OVERHEAD`, `MAX_LINE_LENGTH`, `truncate_line()`. - `crates/oxide-code/src/tool/glob.rs` — `MAX_RESULTS`, view-shape `truncated_total` setter. - `crates/oxide-code/src/tool/grep.rs` — `DEFAULT_HEAD_LIMIT`, per-mode row caps, `MAX_GREP_FILE_SIZE`. - `crates/oxide-code/src/tool/read.rs` — `DEFAULT_LINE_LIMIT`, `MAX_READ_FILE_SIZE`, view-shape footer. - -### Reference projects - -- `claude-code/src/services/streamingToolExecutor.ts` — dispatcher cap application (per-tool then per-message). -- `claude-code/src/utils/toolLimits.ts` — tiered cap constants (`DEFAULT_MAX_RESULT_SIZE_CHARS`, `MAX_TOOL_RESULT_TOKENS`, `MAX_TOOL_RESULT_BYTES`, `MAX_TOOL_RESULTS_PER_MESSAGE_CHARS`, `BYTES_PER_TOKEN`). -- `codex-rs/core/src/tools/handlers/fuzzy_file_search.rs` — `MATCH_LIMIT = 50` (per-tool, no central layer). -- `opencode/packages/opencode/src/tool/truncate.ts` — `Truncate.Service` (`MAX_LINES = 2000`, `MAX_BYTES = 50 KB`, `RETENTION = 7 days`, head/tail + file spillover with adapted hint). diff --git a/docs/research/tui.md b/docs/research/design/tui.md similarity index 97% rename from docs/research/tui.md rename to docs/research/design/tui.md index b567d3b9..f804145a 100644 --- a/docs/research/tui.md +++ b/docs/research/design/tui.md @@ -1,8 +1,8 @@ # Terminal UI Research -Research findings for the oxide-code TUI, based on analysis of reference projects ([Claude Code](https://github.com/hakula139/claude-code) (v2.1.87), [opencode](https://github.com/anomalyco/opencode), [OpenAI Codex](https://github.com/openai/codex)), the Rust TUI ecosystem, and the terminal flickering problem. +Research findings for the oxide-code TUI, based on analysis of reference projects ([Claude Code](https://github.com/hakula139/claude-code) (v2.1.87), [OpenAI Codex](https://github.com/openai/codex), [opencode](https://github.com/anomalyco/opencode)), the Rust TUI ecosystem, and the terminal flickering problem. -## Reference Projects +## Reference Implementations ### Claude Code (TypeScript / Ink) @@ -63,6 +63,25 @@ Full-screen redraw on every React state change causes severe flickering in long - `React.memo` on `LogoHeader` prevents dirty-flag cascade through all `MessageRow` siblings (critical for long sessions — without it, 150K+ writes per frame). - `OffscreenFreeze` wraps static content to prevent re-renders. `useDeferredValue` for non-critical state updates. +### OpenAI Codex (Rust / ratatui) + +OpenAI Codex's Rust TUI lives in `codex-rs/`. Its markdown renderer (`tui/markdown_render.rs`) was the primary reference for oxide-code's custom pulldown-cmark renderer. + +#### Markdown Rendering — `pending_marker` Pattern + +`tui/markdown_render.rs` + +The key insight is a **deferred list marker** approach for correct list item rendering. When a `Start(Item)` event arrives, the renderer does not emit the marker (`1.`, `-`) immediately. Instead, it stores the marker in a `pending_marker` field and waits for the next content event (`Text`, `Code`, etc.) to emit both the marker and content on the same line. This solves the "loose list" problem where pulldown-cmark wraps list item content in `

` tags, causing naïve renderers to place the marker and content on separate lines. + +State management: + +- `list_stack` — tracks nesting depth and item counters (ordered vs. unordered). +- `indent_stack` — accumulated indent string per nesting level (e.g., `" "` for each level). +- `inline_styles` — stack of active `Style` modifiers, pushed on `Start(Emphasis)` / `Start(Strong)` / etc., popped on corresponding `End`. +- `pending_marker` — `Option` holding the deferred list marker. Consumed and prepended when the next text-bearing event arrives. + +Other patterns: fenced code blocks are buffered entirely and syntax-highlighted on `End(CodeBlock)` via syntect. Inline code uses a distinct foreground color. Headings are styled per level (H1–H6). Blockquotes use a `▎` left border with dimmed style. + ### opencode (TypeScript / @opentui + Solid.js) opencode uses **@opentui/core** with **Solid.js** for fine-grained reactive terminal rendering. (Note: despite early documentation suggesting Go / Bubble Tea, the current implementation is a TypeScript monorepo.) @@ -122,25 +141,6 @@ opencode uses **@opentui/core** with **Solid.js** for fine-grained reactive term - Left: working directory. Right: LSP count (`• N LSP`), MCP count (`⊙ N MCP`) with error coloring, permission warnings, `/status` hint. - Subagent footer shows agent label, sibling index (e.g., "3 of 5"), token usage, parent / prev / next navigation. -### OpenAI Codex (Rust / ratatui) - -The [OpenAI Codex](https://github.com/openai/codex) Rust TUI lives in `codex-rs/`. Its markdown renderer (`tui/markdown_render.rs`) was the primary reference for oxide-code's custom pulldown-cmark renderer. - -#### Markdown Rendering — `pending_marker` Pattern - -`tui/markdown_render.rs` - -The key insight is a **deferred list marker** approach for correct list item rendering. When a `Start(Item)` event arrives, the renderer does not emit the marker (`1.`, `-`) immediately. Instead, it stores the marker in a `pending_marker` field and waits for the next content event (`Text`, `Code`, etc.) to emit both the marker and content on the same line. This solves the "loose list" problem where pulldown-cmark wraps list item content in `

` tags, causing naïve renderers to place the marker and content on separate lines. - -State management: - -- `list_stack` — tracks nesting depth and item counters (ordered vs. unordered). -- `indent_stack` — accumulated indent string per nesting level (e.g., `" "` for each level). -- `inline_styles` — stack of active `Style` modifiers, pushed on `Start(Emphasis)` / `Start(Strong)` / etc., popped on corresponding `End`. -- `pending_marker` — `Option` holding the deferred list marker. Consumed and prepended when the next text-bearing event arrives. - -Other patterns: fenced code blocks are buffered entirely and syntax-highlighted on `End(CodeBlock)` via syntect. Inline code uses a distinct foreground color. Headings are styled per level (H1–H6). Blockquotes use a `▎` left border with dimmed style. - ## Reference Apps Actively maintained, visually impressive ratatui apps to study for patterns.