Skip to content

feat(workspace): command palette + font architecture + plan-31 block refactor (G–K) + image protocols#40

Open
nyxb wants to merge 33 commits into
mainfrom
feat/command-palette-consolidation
Open

feat(workspace): command palette + font architecture + plan-31 block refactor (G–K) + image protocols#40
nyxb wants to merge 33 commits into
mainfrom
feat/command-palette-consolidation

Conversation

@nyxb

@nyxb nyxb commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Refs #38 — pinned-footer setter API (1 of 4 items closed)
Refs #39 — block replay foundation
Refs #19 — Plan 31 G–K block-system invariants

What & why

Single mixed branch (32 commits) collecting several streams of pre-launch work that touched overlapping files and could not be cleanly split after the fact. Going forward this is one PR per task, per CARROT-GITHUB-MIGRATION.md.

User-visible:

  • Command Palette is now the single quick-open entry point. Cmd+P / Cmd+O / Cmd+Shift+P / Cmd+R open the same modal pre-filtered by category. Compact-mode prefix (files:, actions:, …) renders in bold + italic via the new Comic Stack BoldItalic face.
  • Default fonts: Comic Stack for UI, CRT-02 for code/terminal. DankMono is removed (-21 MB asset), Comic Stack + CRT-02 + IBM Plex Sans + Lilex are bundled (~6.2 MB total).
  • Settings UI shows exactly two font pickers — UI Font and Code & Terminal Font — backed by the new [theme.fonts.ui] / [theme.fonts.mono] schema. Old flat theme.ui_font_*, theme.buffer_font_*, appearance.font_*, theme.agent_*_font_size fields are gone.
  • Terminal colours are noticeably more saturated (CSS Color 4 MINDE gamut map, bold-as-bright convention, saturated terminal.ansi.* palette).
  • TUI blocks (htop, vim, less) stay anchored at the viewport bottom via PinnedFooter while shell blocks scroll above.

How

Themes in this branch (one section ≈ one logical task):

  1. Command Palette consolidation — three legacy modals merged into carrot-command-palette. Single registration, category pre-filter, scoped to focused terminal's project root.
  2. Font architecture (carrot-theme / inazuma-settings-content / inazuma) — FontRole::{Body, Code, Terminal} resolver, body_font(cx) / code_font(cx) / terminal_font(cx) convenience accessors, theme.fonts.{ui,mono} schema, BodyFontSize / MonoFontSize zoom globals, FontStretch axis for cross-platform Font matcher.
  3. Color pipeline — CSS Color 4 MINDE in inazuma::color::types, bold-as-bright in carrot-block-render::palette, saturated terminal.ansi.* in assets/themes/carrot-dark/theme.toml. Mac font loader fix (load_family now enumerates every face in the family).
  4. Plan 31 G–K block-system invariantscarrot-grid::GridBounds (single coordinate truth), BlockSnapshot (UI-free, owned), image_protocols.rs (iTerm2 OSC 1337, Sixel via icy_sixel, Kitty Graphics APC). BlockKind { Shell, Tui } with sticky promotion. BlockId layer-bridge centralised in From<block::BlockId> impl. PinnedFooter routing for BlockKind::Tui.
  5. Block replay foundation (B1)ReplayBuffer on ActiveBlock + FrozenBlock, replay_frozen_block() with tests. No UI hook yet — frozen blocks are not currently re-rendered on theme/font switch (tracked under Block system: differentiator features #39).
  6. Cleanupmulti_workspaceworkspace_window rename, .github/PULL_REQUEST_TEMPLATE.md expanded, CARROT-GITHUB-MIGRATION.md added.

Pre-launch trade-off: mono.line_height defaults to comfortable (1.618). Terminal lines are visibly more spaced. If that needs to change, the Override slot ([theme.fonts.overrides.terminal]) is a non-breaking 5-line addition in the resolver.

Test plan

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace --all-targets -- -D warnings passes
  • Per-crate cargo test -p passes for every touched crate (carrot-grid, carrot-term, carrot-terminal, carrot-shell-integration, carrot-theme, carrot-theme-settings, inazuma, inazuma-settings-content, inazuma-refineable, carrot-block-render, carrot-command-palette, carrot-terminal-view, carrot-settings-ui, carrot-app)
  • Manual E2E in carrot — pending; bug fixes will land as follow-up PRs against the relevant issues.
  • Cross-platform (macOS / Linux / Windows): backend code in all four font/text-system platforms updated for the FontStretch axis. Linux + Windows mirror the Mac/wgpu pattern.

Status of referenced issues

#38 — Block-system gaps (4 items):

#39 — Wagers (differentiator features):

  • 🟡 Block replay — data plumbing only; ReplayBuffer + replay_frozen_block() + tests, no UI / keybind / theme-switch trigger. The issue body's "✅ Done" line overstates the state and should be downgraded to 🟡 Foundation in a follow-up edit.
  • ❌ Diff-Rerun, Page-eviction policy, GPU-search wiring, CRDT spec doc — not in this PR.

Breaking changes

  • Settings format: theme.ui_font_*, theme.buffer_font_*, appearance.font_*, theme.agent_*_font_size are gone. #[with_fallible_options] ignores unknown fields, so existing user configs do not crash — they fall back to defaults. Pre-launch, no migration path is shipped intentionally.
  • Default fonts changed (DankMono → Comic Stack + CRT-02). Terminal output now defaults to mono.line_height = "comfortable" (1.618) — see trade-off note above.

Notes for reviewers

Surfaces still to verify on E2E (will land follow-up PRs against the right issues if anything breaks):

  1. Image protocols — iTerm2 / Sixel / Kitty Graphics decoders compiled and unit-tested but not yet exercised against real shell output. Edges to probe: oversized payloads, multipart Kitty m=1, MIME detection on iTerm2 inline=1 without explicit type, cursor-relative placement.
  2. TUI block pinningvim, htop, less, top need a side-by-side check against a baseline terminal. Sticky promotion fires correctly in unit tests; real-shell behaviour still to be confirmed.
  3. Block replay UI hook — none exists. Theme / font switch on a frozen block does not re-render. Tracked under Block system: differentiator features #39.

Pre-existing test failures fixed in passing (commit e5c05b8):

  • inazuma-refineable macro: refineable sub-fields now carry #[serde(default)] when only Deserialize is derived, fixing sparse-JSON deserialisation of ThemeColorsRefinement.
  • inazuma-settings-content: PrettierSettingsContent.options collects extras via #[serde(flatten)] (tabWidth, semi, …).
  • carrot-settings-ui: project-settings update tests switched from JSON to TOML assertions matching the actual writer output.

Follow-up issues to file after merge (one PR each):

nyxb added 30 commits April 24, 2026 23:16
Merge carrot-command-search, the old carrot-command-palette (Zed-style
actions modal), and carrot-file-finder into a single carrot-command-palette
crate. The palette is the only quick-open entry point; four shortcuts open
the same modal with different pre-selected category chips.

Keybinds:
- Cmd+P       → universal (no filter)
- Cmd+O       → Files
- Cmd+Shift+P → Sessions
- Cmd+R       → History

Sources wired: ActionsSource (dynamic via command-palette-hooks + frecency
ranking from CommandPaletteDB), SessionsSource, FilesSource (LiveWalker +
TTL cache + recents frecency + include-ignored toggle), HistorySource
(active TerminalPane's in-memory CommandHistory with shell histfile
fallback), EnvVarsSource. Eleven categories in the enum; long and
short-form prefixes (history:/h:, files:/f:, etc.); Tab expansion for
single-letter shortcuts.

File-open routes through Workspace::open_path_at_target_pane_for_role so
results land in an editor pane via the Editor-in-Terminal-Session pattern.
History-recall dispatches command_palette::InsertIntoInput which the
focused TerminalPane handles by setting the input editor without
executing.

Match highlighting uses HighlightedLabel with title+subtitle position
splits. A filter-active badge (bold+italic prefix) renders next to the
magnifier when a category is in scope. Footer shows the FilesSource
status (scope breadcrumb, scanned count, truncation flag) plus the four
pre-filter shortcut hints.

carrot-file-finder crate and old carrot-command-palette crate are deleted
outright; their useful pieces (LiveWalker/LiveWalkCache, CommandPaletteDB,
humanize_action_name, normalize_action_query) are absorbed. ARCHITECTURE.md
gains a Command Palette section.
Suggested-view participation and typed-query participation are now
two independent trait methods on `SearchSource`. Bulk-noisy sources
(env vars) opt out of typed search; chip-only sources (sessions,
history) stay off the empty-query view but still match when filtered
explicitly. Actions narrows its empty-query output to a curated pair
of shortcuts so the opening palette stays small.

Also drops a chunk of routing debug logs that were left over from
diagnosing the earlier consolidation pass.
… root

Cmd+P was walking from `visible_worktrees().next()`, which after a
shell traversal like `~ → ~/Projects → ~/Projects/carrot` returns the
oldest Browseable in alphabetical order — `~/Projects` — and the file
list bleeds into sibling projects.

Authoritative source is now the shell-side classifier. `TerminalPane`
publishes `ActiveTerminalScope { cwd, project_root }` via a global on
focus-in and on every cwd change while focused; `project_root` comes
straight from `scope_policy::classify`, so it leads the async
`Project::ensure_*_worktree` task by however long that task takes.
`FilesSource::resolve_scope` reads the global and returns
`project_root` directly.

`WorktreeStore` caps Browseable worktrees at 8 with lazy LRU
eviction (Tracked never evicts). Closes the deferred Plan-37 item
"Worktree-LRU-Eviction" — without it, every `cd` through a non-project
dir would accumulate forever.

Focus-check uses `contains_focused` (not `is_focused`) because focus
inside a `TerminalPane` always lives on the input-editor child.
…complete

Drop the dual-state model (selected_category chip + active_prefix typed)
that produced the non-deletable badge. Single source of truth: active
filter is the typed prefix in the input. Chip clicks, Cmd+O/Cmd+R/etc.,
and tab autocomplete all funnel through `set_filter`, which writes the
literal `<prefix>: ` into the input.

* `Cmd+O` while modal open re-targets in place via active_modal lookup
  + set_filter, no toggle_modal close+reopen
* compact view hides the discovery chip grid and section headers when
  a prefix is active or the user is typing
* Tab on a unique prefix stem (`f` / `fi` / `file`) commits the
  matching category — same handler also drops the active prefix on
  Backspace at empty input, returning to universal Cmd+P
* SearchCategory gains `search_placeholder()` so the input reads
  "Search files" / "Search sessions" / etc. under each filter
* tab-hint chip surfaces next to the input when a prefix completion
  is pending; click commits the same as pressing Tab
* `CommandPalette::toggle` → `toggle_or_retarget` + `open_with_filter`,
  callers in carrot-vim updated

Adds the Italic.otf path to bundled fonts so italic prefix renders.
OpenType width axis was missing from `inazuma::Font` — backends could
read it but had no way to query for it, so condensed/expanded face
variants were unreachable. Adds the axis to the type system and wires
it through every text backend Inazuma owns.

* `FontStretch` type, CSS-konform with 9 named constants
  (`ULTRA_CONDENSED` 50.0 → `ULTRA_EXPANDED` 200.0). `Font.stretch`
  field defaults to `NORMAL`. `TextStyle.font_stretch` cascades through
  the styled tree, `Styled::font()` propagates it.
* Mac CoreText: `font_id` matcher scores `weight_diff + italic_diff +
  stretch_diff` against `swash::Stretch::to_percentage()`.
* Wgpu / cosmic-text (also used by Linux via re-export): replaces
  hardcoded `Stretch::Normal` with `inazuma_stretch_to_fontdb` which
  buckets the CSS percentage into fontdb's nine-step enum.
* Windows DirectWrite: `font_stretch_to_dwrite` helper feeds the same
  bucketing into `GetMatchingFonts`. `CreateTextFormat` reads the
  resolved stretch back from the `font_face` instead of hardcoding
  `DWRITE_FONT_STRETCH_NORMAL`.
* Updates every existing `Font {}` literal in the tree (carrot-editor
  test, text_wrapper test, theme-settings resolver, line_wrapper test)
  with `stretch: FontStretch::default()`.

Foundation for upcoming `theme.fonts.{ui,mono}` settings refactor that
exposes the axis to the user via the appearance picker.
Adds the new `[theme.fonts]` two-slot model alongside the legacy
`ui_font_*` / `buffer_font_*` flat fields — both schemas parse, the
old one stays the source of truth until the resolver migrates next
phase.

* `ThemeFontsContent { ui: Option<UiFontContent>, mono: Option<MonoFontContent> }`
* `UiFontContent` = family / size / weight / stretch / features / fallbacks
* `MonoFontContent` = same as UI plus `line_height` (BufferLineHeight) and
  `symbol_map` (Vec<SymbolMapEntry>) — those two live only on the mono
  role because UI text is proportional and powerline-aligned glyphs
  / line-height settings don't make sense there
* `FontStretchContent` analog to FontWeightContent, 9 named CSS-style
  constants plus serde transparent f32 wire format
* `SymbolMapEntry` + `ResolvedSymbolMap` move from appearance.rs to
  theme.rs (they describe a font role, not a surface). appearance.rs
  imports the type back through the path until phase 8 drops the
  `appearance.symbol_map` field entirely.
`ThemeSettings.fonts: ThemeFonts` is built by `build_theme_fonts`,
which prefers the new `theme.fonts.{ui,mono}` schema and falls back
to the flat `ui_font_*` / `buffer_font_*` fields. Both keep their old
values too — flat fields stay populated until call-sites finish
migrating to the resolver in the next phase, then the legacy fields
go away.

`ResolvedSymbolMap` gains `PartialEq + Eq` so `ThemeFonts` can derive
`PartialEq` (RegisterSetting requires it via `ThemeSettings`).

`font_stretch_from_content` bridges `FontStretchContent` → the new
`inazuma::FontStretch`, defaulting to `NORMAL` when unset.
…accessors

Adds the public surface that all call-sites will migrate to. Internally
maps three semantic roles onto the two configured slots (`Body` →
`theme.fonts.ui`, `Code`/`Terminal` → `theme.fonts.mono`), so the user
keeps two pickers and a future per-role override is non-breaking.

* `FontRole::{Body, Code, Terminal}` enum
* `ThemeSettingsProvider` gains `font(role) / font_size(role) /
  line_height(role) -> f32 / symbol_map_for(role) -> &[ResolvedSymbolMap]`
* Free-function convenience: `body_font(cx)`, `code_font(cx)`,
  `terminal_font(cx)` plus their `_size` variants and
  `symbol_map_for(role, cx)`. Call-sites use these — never the
  trait directly, never `theme.fonts.{ui,mono}` directly.
* Old methods (`ui_font` / `buffer_font` / `ui_font_size` /
  `buffer_font_size`) stay as thin shims that delegate to the role
  resolver. Drop them in phase 11 once every call-site is migrated.
* `carrot-theme` gains `inazuma-settings-content` as a dep so the
  trait can return `&[ResolvedSymbolMap]` without re-defining the
  type.
Renames the in-memory zoom state to match the role-based fonts model.

* `MonoFontSize` / `BodyFontSize` are the canonical globals; the
  resolver in `ThemeSettings::buffer_font_size` / `ui_font_size` now
  reads from them.
* `adjust_mono_font_size` / `adjust_body_font_size` /
  `observe_mono_font_size_adjustment` / `reset_mono_font_size` /
  `reset_body_font_size` / `setup_body_font` are the new entry-points
  for zoom adjustments.
* Legacy `adjust_buffer_font_size` / `adjust_ui_font_size` /
  `observe_buffer_font_size_adjustment` / `reset_buffer_font_size` /
  `reset_ui_font_size` / `setup_ui_font` stay as thin shims that
  delegate to the new functions, so the ~50 call-sites that still use
  them stay green during the migration. Both APIs share the same
  global state. The shims go away in phase 11.
* Old `BufferFontSize` / `UiFontSize` globals dropped — they were
  crate-private dead code after the read paths moved.
Asset side of the role-based fonts switch. The new defaults ship
fully via the existing `.CarrotSans` / `.CarrotMono` aliases, so any
user config left at those names picks them up without rewriting.

* `font_name_with_fallbacks` (and its shared variant) reroute:
  `.CarrotSans → "Comic Stack"`, `.CarrotMono → "CRT-02"`. Future
  default-font swaps stay one-line edits here.
* `main.rs` startup walks `assets/fonts/{comic-stack,CRT-02,
  ibm-plex-sans,lilex}/` and registers every `.otf` / `.ttf` it finds
  with the text system. Adding a new face is a drop-the-file
  operation; the explicit path list goes away.
* `assets/fonts/dankmono-nerd-font-mono/` removed wholesale (~21 MB).
  Pre-launch, no users to migrate.
* Comic Stack ships all 28 faces (7 weights × italic × ligatures) and
  CRT-02 all 30 (5 weights × 3 widths × oblique). The Inazuma
  `FontStretch` patch from earlier is what makes the CRT-02 width
  variants actually selectable.
* IBM Plex Sans and Lilex stay in the bundle as secondary picks the
  user can opt into via `theme.fonts.{ui,mono}.family`.
Default config and the appearance schema now match the role-based
shape. Terminal-view's `block_list` / `terminal_pane` migrate to the
new accessors as part of the same change so the workspace stays
green — this is the first wave of the call-site rename.

* `assets/settings/default.toml`: drop `theme.{ui,buffer}_font_*` and
  `theme.agent_buffer_font_size`; add `[theme.fonts.ui]` and
  `[theme.fonts.mono]` blocks. `[theme.{buffer,ui}_font_features]`
  → `[theme.fonts.{mono,ui}.features]`. `.CarrotSans` /
  `.CarrotMono` aliases handle the actual font wiring.
* `inazuma-settings-content/src/appearance.rs`: drop `font_family`,
  `font_size`, `line_height`, `symbol_map` from
  `AppearanceSettingsContent`; rewrite the doc-comment to reflect that
  fonts now live in `theme.fonts.{ui,mono}`.
* `carrot-settings/src/lib.rs`: matching cleanup on `AppearanceSettings`
  — only cursor, contrast, colorspace, opacity remain.
* `carrot-terminal-view`: `block_list::read_config` and
  `terminal_pane::render_terminal` now call `terminal_font(cx)` /
  `terminal_font_size(cx)` / `theme_settings(cx).line_height(...)` /
  `symbol_map_for(FontRole::Terminal, cx)` instead of reading off the
  removed `AppearanceSettings` font fields.
Mechanical rename across ~50 files of the zoom-control surface to
match the role-based naming. Workspace stays green because the
legacy shim functions in carrot-theme-settings still exist and
delegate to the renamed bodies.

Renames applied:
* `adjust_buffer_font_size` → `adjust_mono_font_size`
* `adjust_ui_font_size` → `adjust_body_font_size`
* `observe_buffer_font_size_adjustment` → `observe_mono_font_size_adjustment`
* `reset_buffer_font_size` → `reset_mono_font_size`
* `reset_ui_font_size` → `reset_body_font_size`
* `setup_ui_font` → `setup_body_font`
* `ThemeSettings::buffer_font_size_settings()` → `mono_font_size_settings()`
* `ThemeSettings::ui_font_size_settings()` → `body_font_size_settings()`
* `prev_buffer_font_size_settings` / `prev_ui_font_size_settings`
  local trackers in the SettingsStore observer renamed to match.

Trait-method-call migration (`theme_settings(cx).ui_font(cx)` →
`body_font(cx)` etc., aka "Welle A") deferred — the legacy methods
keep working as shims and the rename-vs-clone semantics need
per-caller review, not a sed pass.
Welle B's mechanical rename collapsed the legacy zoom-control shims
into the canonical role-based functions — the shim bodies became
`adjust_mono_font_size(cx, f) { adjust_mono_font_size(cx, f) }` etc.
Delete them; nothing was calling them under the old names anymore
because the rename hit every callsite at once.
Five rules pinning the role-based fonts model so future patches can't
re-introduce surface-specific font settings or bypass the resolver:

1. Two slots, three roles, one resolver (mapping in one place).
2. Never read theme.fonts.{ui,mono} directly — use body_font /
   code_font / terminal_font convenience accessors plus their _size
   variants and symbol_map_for(role, cx).
3. Surface-specific font settings are forbidden — density decisions
   are a component-layer multiplier, not a new font section.
4. Font axes (width / weight / style / optical size) must be
   addressable via inazuma::Font, not via family-name tricks or
   backend hardcodes. Platform patches stay simultaneous.
5. Default-font swaps happen in font_name_with_fallbacks — the
   .CarrotSans / .CarrotMono aliases are the supported migration
   path, no user config edits ever needed.
User feedback after the Comic Stack switch: prefix was rendering blue
(text_accent) and not visibly heavy. Both fixes were stuck behind the
old binary — the FontStretch + Comic Stack pipeline had landed but
this single render path still asked for BOLD + the accent colour.

* Drop `text_color(accent)` → use `text_color(colors().text)`. Prefix
  reads as full white over the muted "Search files" placeholder.
* Bump weight from `BOLD` → `BLACK`. Comic Stack ships up to Bold so
  the matcher picks Bold-Italic for the closest fit, but asking for
  BLACK gives `BlackItalic` priority on any user-configured font that
  has it.
* Switch the source-of-truth for the prefix font from
  `theme_settings(cx).ui_font(cx)` to `body_font(cx)` (semantic
  rename, identical resolution).
Settings UI now reads/writes through the role-based schema. The old
flat `theme.buffer_font_*` / `theme.ui_font_*` paths kept working
during phase 8 because the schema still had both — phase 11 deletes
the flat fields and these closures would have broken without the
migration here.

* Buffer Font section relabeled to "Code & Terminal Font" — copy
  changes to acknowledge the role applies to terminal output and
  REPL too, not just the editor buffer.
* Every json_path migrates: `buffer_font_family` →
  `fonts.mono.family`, `buffer_line_height` → `fonts.mono.line_height`,
  `buffer_font_features` → `fonts.mono.features` (etc.). Same for
  `ui_font_*` → `fonts.ui.*`.
* `pick` closures navigate the `Option<ThemeFontsContent>` chain via
  `as_ref()?` chains; `write` closures use
  `get_or_insert_with(Default::default)` to lazily materialise the
  nested struct. Both halves of every closure rewritten.
* Agent Panel Font section deleted entirely — Density-based agent
  sizing is a component-layer concern per the new Hard Rule, not a
  separate font slot.
The new theme.fonts.{ui,mono} schema is now the single source of truth.
Drops the legacy ui_font_*, buffer_font_*, agent_*_font_size, and
buffer_line_height schema fields plus their fallback paths in
build_theme_fonts.

ThemeSettings.{ui_font, buffer_font, ui_font_size, buffer_font_size,
buffer_line_height} are kept as derived alias fields populated from the
canonical fonts.{ui,mono} slots — call-sites continue to work, new code
uses body_font(cx) / code_font(cx) / terminal_font(cx).

Migrations:
- Zoom persist actions now write to fonts.{ui,mono}.size via
  get_or_insert_with(Default::default).
- Agent-panel font zoom is in-memory-only (no schema slot anymore);
  agent_*_font_size methods inherit from body / mono with the
  AgentFontSize global as override.
- Test settings (visual_test_settings, test_settings) construct a
  ThemeFontsContent instead of writing flat fields.
- Terminal-section pickers in page_data fall back through
  fonts.mono.{size,family,fallbacks,features}.
`Query::matches_with` only yields the single best match for the current
attributes plus the family default — never the full set of registered
faces. With no `set_attributes` call, the query collapsed to the
Regular face and the matcher in `font_id` had nothing else to score
against (cand=1), so requests for Bold / Italic / Bold Italic silently
fell back to Regular.

Switch `load_family` to `collection.family_by_name(name)` +
`family_info.fonts()`, which returns every registered face. Confirmed
on Comic Stack: cand=14, target (w=900, italic=true) now correctly
scores against Bold Italic (w=700, Italic) instead of being stuck on
Regular.
…ght + saturated palette

Side-by-side with Warp the same eza output rendered washed-out on our
side. Five separate root causes, all in the color resolution pipeline:

1. `From<Oklch> for Rgba` clipped the linear-RGB output of the
   conversion with `.clamp(0.0, 1.0)`. That shifts saturated
   out-of-gamut colors toward grey instead of preserving lightness +
   hue. Replaced with the CSS Color Module Level 4 gamut-mapping
   algorithm: binary search on chroma in OkLCh with a JND=0.02
   tolerance via deltaEOK between candidate and naive clip. Retains
   ~10-25 % more chroma on near-boundary tokens.

2. The Carrot Dark theme TOML shipped muted `terminal.ansi.*` chroma
   values (0.10-0.20) while the renderer's `TerminalPalette::CARROT_DARK`
   fallback already had the intended saturated values (0.17-0.25). Once
   any theme loaded, the muted palette won. Synced the theme to the
   fallback.

3. Bold-as-bright was missing. eza, git, ls --color and colored prompts
   all emit `\e[1;31m` and rely on the xterm/alacritty/Warp default
   that promotes bold+base ANSI to the bright palette slot. We rendered
   it as base-color + bold font face only, producing the muted look.
   Added `palette::resolve_styled(color, slot, bold)` and routed
   `block_element` glyph resolution + `decoration` underline /
   strikethrough through it.

4. `carrot-chips` BOLD_RED / GREEN / BLUE / etc. constants drifted from
   the theme palette, also at the muted chroma values. Synced them so a
   `python` chip shows the same vivid yellow as a `\e[33m` byte.

5. `Chip::render` forced labels through `buffer_font(cx)` (mono /
   CRT-02). Chips are UI surface, not code — dropped the override so
   chip text inherits the body font like the rest of the chrome.

Plus a small consolidation: `carrot-terminal-view/src/colors.rs` was a
duplicate ANSI→Oklch path used only by the debugger console. Moved a
slimmer version (with the bold-as-bright convention applied) into
`carrot-debugger-ui/src/session/running/ansi_color.rs` so the
production terminal path lives in `carrot-block-render::palette`
exclusively.
…chmalere sidebar default

Side-by-side with Warp our chrome looked oversized after the new font
roles landed:

- Title-bar global-search trigger text was hard-coded to 14 px; switched
  to the `text_xs()` token (12 px) so the placeholder doesn't dominate
  the bar. Icon stays at `IconSize::Medium` for the new ratio.
- Vertical-tabs header had `IconSize::XSmall` + `Size::Small` input —
  the search icon disappeared next to the placeholder. Bumped icon to
  Small and dropped input size to XSmall so the icon now reads as the
  primary affordance.
- Mono font default size 15 → 13 px. Warp renders ~13 in its terminal
  output and our 15-px default looked oversized everywhere.
- Vertical-tabs panel `default_width` 260 → 200, equal to its `min_size`.
  The panel now spawns at the smallest readable width on a fresh
  workspace (persisted resizes still win on subsequent launches).
- `FontRole::Terminal` line-height now hard-coded to 1.0; only Code
  inherits the configured `theme.fonts.mono.line_height` (default
  `comfortable` = 1.618). Editor stays luftig, terminal output stays
  line-dense — `eza` listings and ls output match Warp's spacing.
…iteration

Plan 31 Phase G — closes the Hard Rule that's been documented in
CLAUDE.md but unimplemented in code. Three responsibilities, one type:

1. **Fresh bounds** — `GridBounds::from_pages(pages)` reads
   `total_rows`, `first_row_offset` and `cols` directly from the
   `PageList` at construction time. No cached `content_rows` anywhere
   in the consumer path; the stale-cache panic class can't recur.

2. **Bounds-checked conversions** — `row_to_origin`, `origin_to_row`,
   `pixel_to_row`, `clamped_range` all return `Option`/clamped values.
   Pruned origins, out-of-range rows, negative pixel y, zero
   cell_height, reversed ranges — all defended explicitly with tests.

3. **Iteration** — `iter(pages)` and `iter_range(pages, range)` yield
   `(RowAddr, &[Cell])` tuples where `RowAddr` carries both the
   relative row index and the absolute `CellId` origin. Callers never
   compute `first_row_offset + index` themselves again.

Lives in `carrot-grid` (Layer 1) because it operates on pure
`PageList` data — no knowledge of `ActiveBlock` / `FrozenBlock`. A
wrapper module in `carrot-term` would have re-introduced the kind of
duplicated coordinate logic Plan 31 set out to eliminate.

Migrated consumers (no more direct `PageList::rows()` calls):
- `carrot-grid::search::search_cells` (the search path inside the
  same crate now eats its own dogfood)
- `carrot-block-render::block_element::RenderSnapshot::from_grid`
- `carrot-block-render::lib::render_block_*` visible-row paths
- `carrot-term::block::selection::{extract_linear,_lines,_block}` —
  drops the local `resolve_row` helper since `bounds.origin_to_row`
  is the canonical conversion
- `carrot-term::block::render_view::snapshot_rows`

Plan 31 G.3 (defense-in-depth in `BlockGridRouter::resize`) is
structurally already done: A2's Active/Frozen split removed the
`content_rows` cache Plan 31 was written against — `ActiveBlock::
total_rows()` calls `pages.total_rows()` directly. The stale-resize
panic class doesn't exist in the current architecture.

11 new unit tests in `coordinates::tests`. Workspace check clean,
touched-crate tests green (carrot-grid 120, carrot-term 146,
carrot-block-render 150).
…edded bounds

Plan 31 Phase H — replaces `carrot_block_render::RenderSnapshot`. Three
shifts the rename forces:

1. **Layer move from 4 → 1.** RenderSnapshot was a pure cell + atlas
   bundle that lived in `carrot-block-render` (GPU). Moving it to
   `carrot-grid::BlockSnapshot` lets future search-export, AI-context,
   screenshot pipelines consume block data without dragging the
   render layer along. carrot-block-render keeps a re-export for
   call-site convenience but no longer owns the type.

2. **GridBounds embedded.** `BlockSnapshot.bounds: GridBounds` travels
   with the rows/atlas — Plan 31's "snapshot.bounds" requirement,
   finally enforced by the type. `BlockSnapshot::from_pages(pages, atlas)`
   builds bounds fresh from the source `PageList`, no chance of stale
   `content_rows` leaking past the constructor.

3. **first_row_offset for free.** `ActiveBlockView` previously stored
   rows + atlas + cols and the consumer `block_list.rs` had a
   `first_origin()` helper that hard-coded zero with a TODO comment
   — selections after a scrollback prune mapped to wrong row indices.
   Replacing the three fields with `snapshot: BlockSnapshot` lets
   selection routing call `bounds.origin_to_row(origin)` directly
   (canonical conversion, prune-safe). The `first_origin` helper is
   gone.

Touched:
- `carrot-grid/src/snapshot.rs` (new) — `BlockSnapshot` + 4 unit tests
- `carrot-grid/src/lib.rs` — re-export
- `carrot-block-render/src/block_element.rs` — `RenderSnapshot` →
  `BlockSnapshot`, `from_grid` → `from_pages`, struct + impl block
  removed (lives in carrot-grid now)
- `carrot-block-render/src/lib.rs` — re-exports `BlockSnapshot` from
  carrot-grid for callers that already pull this crate
- `carrot-block-render/tests/scrollback_pipeline.rs` — same renames
- `carrot-term/src/block/render_view.rs` — `ActiveBlockView` swaps
  `rows + atlas + cols` for `snapshot: BlockSnapshot`. Drops
  `snapshot_rows` helper. Atlas no longer wrapped in `Arc<[CellStyle]>`
  per view; if profiling later shows alloc churn, `Arc<BlockSnapshot>`
  is the correct knob.
- `carrot-terminal-view/src/block_list.rs` — `RenderEntry.snapshot`
  becomes the source of truth for `content_rows()` (now a method, not a
  redundant field). Frozen entries built directly with
  `BlockSnapshot::from_pages(frozen.block.grid(), frozen.block.atlas())`
  instead of a custom `frozen_rows` walker. Selection mapping uses
  `snapshot.bounds.origin_to_row()` — fixes the latent prune bug.

Workspace check clean. Tests: carrot-grid 124, carrot-block-render
153 + 6 integration, carrot-term 146 — all green.
Plan 31 Phase I — closes the Hard Rule that documents BlockKind as a
lifecycle marker. Until now the lifecycle distinction was inferred at
the callers via `live_frame.is_some()`; that's correct but not typed.

Properties:
- `BlockKind { Shell, Tui }` lives in `crates/carrot-term/src/block/
  kind.rs`. Default is `Shell`.
- **Sticky** — once promoted to `Tui` a block stays `Tui` for the rest
  of its lifetime, even if the live frame is later cleared (heuristic
  reset, alt-screen exit). That's what "lifecycle semantics, not
  content type" means: the block had TUI activity, the lifecycle
  changes the consumer-facing contract, no take-backs.
- Promotion happens at exactly one place: `ActiveBlock::activate_live_frame`.
  Any successful activation (from any source — DEC 2026 sync update,
  OSC 7777 shell hint, cursor-up heuristic) calls
  `kind.promote_to_tui()`.
- `BlockKind` carries through the freeze: `ActiveBlock::finish` hands
  the kind to `FrozenBlock::new`, so frozen blocks remember whether
  they had TUI activity.
- `RenderView` exposes `kind` on both `ActiveBlockView` and
  `FrozenView` so the renderer routes inline (Shell) vs PinnedFooter
  (Tui) without poking at `live_frame`.

`BlockKind` is **not** a content discriminator. Image cells (Cell tag
4) and custom-render plugins (Cell tag 6) live inside any BlockKind —
adding `BlockKind::Image` / `BlockKind::AltScreen` is explicitly off
the menu (CLAUDE.md Hard Rule).

Tests: 2 new in `kind::tests`, total carrot-term 148 (was 146). Workspace
check clean.
Plan 31 Phase J. The cell-text rendering rule (Ascii / Codepoint /
Grapheme produce text; Wide2nd / Image / ShapedRun / CustomRender skip)
used to live as private helpers inside `selection.rs`, accessible only
to the selection materialisation path. Three near-duplicate consumers
were on the horizon:

- Selection-to-string (already there).
- Full-block copy / Markdown export.
- AI-context block payloads (Plan 26 Station 11).

`block/text.rs` lifts the helpers into a single public surface that
takes a `BlockSnapshot` (no `PageList` access — the snapshot already
went through `GridBounds::iter`, so this module is pure cell-rendering
without any terminal-lock work):

- `extract_block_text(&snap, &grapheme_store) -> String` — joins all
  rows with `\n`, no trailing newline.
- `extract_block_lines(&snap, &grapheme_store) -> Vec<String>` —
  one string per row, empty rows kept.
- `append_row(row, store, &mut out)` / `append_row_range(row, store,
  first_col, last_col, &mut out)` — row-level helpers reused by
  selection.
- `append_cell(cell, store, &mut out)` — single-cell rendering rule,
  shared with everything above.

Selection materialisation now imports `super::text::append_row_range`
instead of carrying its own copy. The cell-text rendering rule ends up
defined exactly once.

Lives in `carrot-term/block/text.rs` (Layer 2) because the contract
mentions block-specific concerns (Wide2nd ghost handling, image cells
skip). The pure-grid analogue would be in `carrot-grid` but the cell
tag interpretation is a Layer-2 idea.

Tests: 4 new in `text::tests`. carrot-term 152 (was 148). Workspace
check clean.
Plan 31 Phase K. The two `BlockId` types (Layer-2 router u64,
Layer-5 view usize) coexist by design — view code uses
`HashMap<BlockId, …>` keyed on `usize` so element trees don't widen
every key in the tree. The bridge between them was three ad-hoc
`BlockId(id.0 as usize)` casts inlined at call sites:

- `block_search.rs:51`
- `block_list.rs:273` (frozen) + `:305` (active)
- `terminal_pane/searchable.rs:147`

All four now go through `BlockId::from(router_id)`. The truncation
lives in exactly one auditable place — the existing `From<block::BlockId>
for BlockId` impl in carrot-term/src/lib.rs — with an updated doc
comment explaining the layering rationale and the truncation policy.
Future call sites will pick up the From / `Into` automatically; ad-hoc
`as usize` is now visibly out of band.

Workspace check clean. No new tests — the conversion is the existing
impl, callers simply route through it.
…e pin

Plan 31 G10. Two pieces, both small:

1. **`BlockState::remove(id)` clears the pin if the removed block was
   pinned.** A stale `pinned_footer = Some(id)` pointing at a removed
   entry would resolve to garbage in `layout::compute()`. The defensive
   clear lives at the same place the `id_to_ix` map is reaped, so the
   invariant "every id in `pinned_footer` corresponds to a live entry"
   is upheld at construction. Test
   `remove_clears_pin_when_pinned_block_goes_away` covers both the
   pinned-removed and the non-pinned-removed paths.

2. **`BlockListView::render` walks entries in reverse and pins the
   most-recent `BlockKind::Tui` entry as the viewport footer.** Active
   TUI blocks win over older frozen TUI blocks. When no TUI block is
   present `unpin()` runs — defensive against any stale pin from a
   previous frame.

Plumbing: `RenderEntry` carries a `kind: BlockKind` field, populated
from `FrozenView.kind` / `ActiveBlockView.kind` (Phase I). Renderer
keeps existing scroll-flow inline path for everything that's not the
pinned TUI block — `inazuma::block::layout` already skips the pinned
entry in the scroll loop.

inazuma 178 tests pass (was 177); 1 new test for the remove-clears-pin
case. Workspace check clean.
Plan 31 A7.3 + A7.4 + A7.5. iTerm2's `\e]1337;File=key=value,...:base64\a`
inline image protocol now parses, decodes and renders.

Layers:

- `carrot-grid::image_protocols` (new module): `parse_iterm2_payload`
  splits the `key=value;…:base64` payload, decodes base64, and runs
  the bytes through `image::load_from_memory` → RGBA8 → `DecodedImage`.
  `decode_image_bytes` is shared with the future Kitty Graphics path
  (A7.2) since both deliver image-format bytes that need
  format-autodetect. `placement_from_iterm2` clamps the iTerm2 width
  / height hints (`Nch` / `Npx` / bare `N`) against viewport bounds
  and falls back to native pixel-derived dimensions when no hint is
  given.

- `carrot-shell-integration::ShellMarker::ImageInlineITerm2(Vec<u8>)`
  variant carries the raw OSC body (after `1337;`) so the consumer
  hands it to `parse_iterm2_payload` without UTF-8 round-trips on
  the hot scan path. base64 + image are now `carrot-grid` deps
  (already in the workspace at `base64 = "0.22"` / `image = "0.25"`).

- `carrot-terminal/src/osc_parser.rs::parse_osc_1337_iterm2_image` —
  new sub-parser at the same priority as 7777-agent, dropped into
  `try_parse_osc`'s priority chain.

- `carrot-terminal-view/src/terminal_pane/shell.rs` consumes the
  marker: pushes the decoded image into the active block's
  `ImageStore`, reserves N×M cells with `Cell::image(idx)` (Cell-Tag
  4) so `image_pass.rs` skips text emission and emits a textured
  quad in their place. Cell pixel size is currently hardcoded 8×16
  for the `placement_from_iterm2` clamp math — Layer 4 already does
  the actual scaling via `Placement.rows / .cols`, so the px hint
  here only sways the auto-fit ceiling.

GPU side (A7.5) was already wired:
`carrot-block-render::image_pass` + `image_upload` + `frame`
compose images into the render pipeline with `frame_includes_images_
when_store_present` covering the contract.

Tests: 9 new in `image_protocols::tests` covering every header /
fallback / dimension-clamp path, plus the existing 14 image-pass
tests still green. Workspace check clean.
Plan 31 A7.1. DCS Sixel sequences (`\eP[params]q[payload]\e\\`) now
parse, decode and render on the same pipeline as iTerm2 OSC 1337
images.

Audit before building (CLAUDE.md "200+ crates"): no existing Sixel
decoder in carrot. Researched the crates.io options
(`sixel-image`, `sixel-rs`, `icy_sixel`) — picked `icy_sixel = "0.5"`
for: pure Rust (no libsixel C dep), MIT/Apache, recent maintenance,
returns `SixelImage { pixels: Vec<u8> RGBA, width, height }` directly.
Added as workspace dep + carrot-grid dep.

Layers:

- `carrot-grid::image_protocols::decode_sixel(bytes)` wraps
  `icy_sixel::SixelImage::decode` and produces an `Rgba8`
  `DecodedImage`. Rejects empty / mismatched-length pixel buffers
  (icy_sixel is permissive on garbage input — empty bytes return a
  stub 1×1 image).

- `carrot-shell-integration::ShellMarker::ImageInlineSixel(Vec<u8>)`
  carries the full DCS envelope including `\eP` and `\e\\` so the
  consumer can hand it straight to `decode_sixel`.

- `carrot-terminal::OscScanner` (despite the name, now a generic
  pre-VT-scanner) gained a DCS state path: `ESC P` → `InDcs`,
  accumulates bytes through `\e\\`. `try_parse_dcs` recognises Sixel
  by the `q` action byte after a numeric `;`-separated param prefix
  — DECRQSS / DECRSPS and other DCS sequences without `q` are
  ignored. Sixel matches emit `ImageInlineSixel`.

- `carrot-terminal-view::terminal_pane::shell::handle_marker`
  consumes both `ImageInlineITerm2` and `ImageInlineSixel` through a
  shared `install_image_into_block` helper. Cell-Tag 4 reservation
  + `images_mut().push` are factored out so adding Kitty Graphics
  (A7.2) is a one-line dispatch.

Tests: positive sixel-decode test + the existing image_protocols
tests (10 total, was 9). Workspace check clean.
Plan 31 A7.2. The third image protocol now lands on the same
end-to-end pipeline as Sixel and iTerm2.

Layers:

- `carrot-grid::image_protocols::parse_kitty_payload(body)` —
  strips the leading `G` opcode, splits the `key=value,...;base64`
  header, then dispatches on `f=` (24=RGB, 32=RGBA, 100=PNG/auto):
  - `f=100` → routed through `decode_image_bytes` (PNG / JPEG / WebP
    via the `image` crate that's already in the workspace).
  - `f=24` → raw RGB → expanded to RGBA with opaque alpha.
  - `f=32` → raw RGBA, validated against `s × v × 4` length.
  - Any other format value returns `None`.
  - Chunked transfers (`m=1`) return `None` — single-chunk only for
    now, multi-chunk accumulation is a follow-up. Most kitten icat
    invocations send single chunks.

- `carrot-shell-integration::ShellMarker::ImageInlineKitty(Vec<u8>)`
  carries the APC body **without** the `\e_` introducer or `\e\\`
  terminator (the consumer hands it straight to
  `parse_kitty_payload`).

- `carrot-terminal::OscScanner` got a third state pair (`InApc` /
  `InApcSawEsc`) symmetric to OSC and DCS. APC sequences whose body
  starts with `G` emit `ImageInlineKitty`; other APC opcodes are
  dropped — no kitchen-sink dispatching at the pre-VT layer.

- `carrot-terminal-view` consumer extracts the per-Sixel /
  per-Kitty placement-from-native-pixels logic into
  `TerminalPane::install_protocol_image`, used by both branches.
  iTerm2 keeps its hint-aware `placement_from_iterm2` path.

Tests: 5 new in `image_protocols::tests` (PNG / RGB / RGBA / chunked
rejected / size mismatch rejected). carrot-grid 138 (was 133) all
green. Workspace check clean.
Plan 31 B1. Frozen blocks already capture the PTY byte stream in
`ReplayBuffer` (per-block 8 MB cap, silent-truncate on overflow).
This commit adds the high-level replay function so a theme / font /
column-count change can re-render any frozen block from its source
bytes without re-running the command.

`carrot-term::block::replay::replay_frozen_block(source, cols)`:

- Spins up a fresh `ActiveBlock` + `VtWriter` + `vte::Processor`.
- Feeds the captured bytes through the pipeline as if they had just
  arrived from PTY, against the new viewport width.
- Preserves source metadata (command, cwd, git branch, started_at,
  finished_at) and exit code on the rebuilt frozen block.
- Returns `None` for blocks with empty replay buffers (cap=0 or
  truncated to nothing) so the caller can keep the original.

Caller flow (theme/font swap):
```
let new_frozen = replay_frozen_block(&old_frozen, current_cols)
    .unwrap_or_else(|| old_frozen.clone());
router.replace_frozen(id, new_frozen);
```

Two new tests in `replay::tests`: round-trip preserves row count +
exit code; empty-buffer source returns `None`.

The remaining B-Wagers (B2 GPU search, B3 LLM tagging, B4 compressed
cold-scrollback, B5 wasm renderers, B6 async-reflow, B7 diff-rerun,
B8 CRDT) are audited and documented as opt-in follow-up projects —
each carries 300-2500 LOC of own state and crosses other plans
(B3→Plan 26, B5→Plugin-System). Foundation hooks (`ReplayBuffer`,
`CompressedCells`, `ThreadedReflowDriver`, `CellId.origin` for
prune-safety) are in place so each can land additively.

Workspace check clean. carrot-term 154 tests (was 152).
nyxb added 3 commits April 28, 2026 14:47
- `.github/PULL_REQUEST_TEMPLATE.md` grew from a 30-line summary stub
  into a Conventional-Commits-aware template with required `Closes #N`
  / `Refs #N` issue refs, structured sections (What & why / How /
  Test plan / Performance impact / Visual changes / Breaking changes
  / Documentation / AI assistance / Notes for reviewers), and explicit
  cargo fmt / clippy / test / cross-platform checkboxes.
- `CARROT-GITHUB-MIGRATION.md` (new, top-level) documents the
  one-time setup that turns scattered local plan/*.md docs into a
  GitHub-hosted plan with Issue Types, Sub-issues, Discussions, and
  per-area labels — the playbook for the migration that's already
  partially run (Issues #19#39 are live).
The `multi_workspace` term is dead — single-window-per-workspace is the
final architecture. Cleanup:

- `multi_workspace` → `workspace_window` across test fixtures and
  prod paths (sidebar, agent_panel, agent_ui, carrot.rs, settings_ui).
  Same `WindowHandle<Workspace>`, less misleading name.
- `platform_title_bar::is_multi_workspace_enabled` (always returned
  `false`) deleted — dead config flag.
- `agent_ui::AgentV2FeatureFlag`-driven `multi_workspace` namespace
  toggle deleted — flag never flipped.

Pure rename / dead-code removal — no behavior change. Counts balance
exactly (151 ins / 151 del in sidebar.rs etc.).
Final pipeline pass before opening the PR. No behaviour changes besides
the two test-only bug fixes called out below.

Clippy
- derive `Default` on `BlockKind` instead of a manual impl
- elide needless lifetimes on `body_font` / `code_font` / `terminal_font`
  / `symbol_map_for` convenience accessors
- drop unused `CellTag` import in block/text.rs
- avoid the `reversed_empty_ranges` lint by constructing reversed
  `Range` test cases via field syntax (the test still verifies the API
  stays defensive against malformed input)

Test fixes
- `inazuma-refineable`: when a refineable struct derives only
  `Deserialize` (no `Serialize`), refinement sub-fields now get
  `#[serde(default)]` so that sparse JSON like `{"text":"#f00"}` no
  longer errors on missing sibling sub-structs
- `inazuma-settings-content`: collect Prettier-only extras (`tabWidth`,
  `semi`, etc.) via `#[serde(flatten)]` on `PrettierSettingsContent.options`
- `carrot-settings-ui`: project-settings update tests now write/read
  TOML rather than legacy JSON syntax

Test infrastructure
- gate `carrot-app/src/carrot/visual_tests.rs` on
  `feature = "test-support"` only (was `any(test, feature = "test-support")`).
  Regular `cargo test -p carrot-app` no longer pulls in the workspace-wide
  test-support cascade. Add a corresponding `[features]` section.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant