feat(workspace): command palette + font architecture + plan-31 block refactor (G–K) + image protocols#40
Open
nyxb wants to merge 33 commits into
Open
feat(workspace): command palette + font architecture + plan-31 block refactor (G–K) + image protocols#40nyxb wants to merge 33 commits into
nyxb wants to merge 33 commits into
Conversation
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).
- `.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.
This was referenced Apr 28, 2026
Scroll viewport jumps up/down on its own — scrolling down inside a block group inverts direction
#48
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Cmd+P/Cmd+O/Cmd+Shift+P/Cmd+Ropen the same modal pre-filtered by category. Compact-mode prefix (files:,actions:, …) renders in bold + italic via the new Comic Stack BoldItalic face.[theme.fonts.ui]/[theme.fonts.mono]schema. Old flattheme.ui_font_*,theme.buffer_font_*,appearance.font_*,theme.agent_*_font_sizefields are gone.terminal.ansi.*palette).htop,vim,less) stay anchored at the viewport bottom viaPinnedFooterwhile shell blocks scroll above.How
Themes in this branch (one section ≈ one logical task):
carrot-command-palette. Single registration, category pre-filter, scoped to focused terminal's project root.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/MonoFontSizezoom globals,FontStretchaxis for cross-platform Font matcher.inazuma::color::types,bold-as-brightincarrot-block-render::palette, saturatedterminal.ansi.*inassets/themes/carrot-dark/theme.toml. Mac font loader fix (load_familynow enumerates every face in the family).carrot-grid::GridBounds(single coordinate truth),BlockSnapshot(UI-free, owned),image_protocols.rs(iTerm2 OSC 1337, Sixel viaicy_sixel, Kitty Graphics APC).BlockKind { Shell, Tui }with sticky promotion.BlockIdlayer-bridge centralised inFrom<block::BlockId>impl. PinnedFooter routing forBlockKind::Tui.ReplayBufferonActiveBlock+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).multi_workspace→workspace_windowrename,.github/PULL_REQUEST_TEMPLATE.mdexpanded,CARROT-GITHUB-MIGRATION.mdadded.Pre-launch trade-off:
mono.line_heightdefaults tocomfortable(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 -- --checkpassescargo clippy --workspace --all-targets -- -D warningspassescargo test -ppasses 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)carrot— pending; bug fixes will land as follow-up PRs against the relevant issues.FontStretchaxis. Linux + Windows mirror the Mac/wgpu pattern.Status of referenced issues
#38 — Block-system gaps (4 items):
BlockListState::pin()/unpin()/pinned_id()are public;block_list.rs:336consumes them)let_cxx_string!uses uninitialized value due to exception safety violations #68)#39 — Wagers (differentiator features):
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.Breaking changes
theme.ui_font_*,theme.buffer_font_*,appearance.font_*,theme.agent_*_font_sizeare 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.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):
m=1, MIME detection on iTerm2inline=1without explicit type, cursor-relative placement.vim,htop,less,topneed a side-by-side check against a baseline terminal. Sticky promotion fires correctly in unit tests; real-shell behaviour still to be confirmed.Pre-existing test failures fixed in passing (commit
e5c05b8):inazuma-refineablemacro: refineable sub-fields now carry#[serde(default)]when onlyDeserializeis derived, fixing sparse-JSON deserialisation ofThemeColorsRefinement.inazuma-settings-content:PrettierSettingsContent.optionscollects 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):