This document is a reference for looking up the Kasane plugin API. For a quickstart guide to writing a working plugin, see plugin-development.md. For composition ordering and correctness conditions, see semantics.md.
The Kasane plugin API is primarily designed for UI decoration, transformation, and extension.
Plugins construct Element trees based on state received from Kakoune and provide supplementary visual information to users.
Side effects are issued indirectly via Command, and are limited to UI-side coordination such as sending keys to Kakoune, requesting redraws, inter-plugin messages, and timers.
The following operations are currently outside the scope of the plugin API.
| Out-of-scope operation | Reason |
|---|---|
| File system access | WASM is prohibited by the sandbox. Native is technically possible but lacks an async infrastructure |
| Network communication | Same as above |
| Text input widgets | No input elements in Element. Text editing is delegated to Kakoune by design |
Native plugins run within the host process and can therefore technically use std::process, std::fs, etc. However, Plugin trait hook functions are called synchronously, so the plugin developer bears the design responsibility for avoiding main thread blocking.
For WASM-specific constraints (feature gaps, runtime limits, missing state queries), see §8 WASM Plugin Constraints.
Kasane's long-term strategy is to make WASM the first-class distribution and execution path, with capabilities as close to native as possible. Accordingly, native-only APIs are treated not as "permanent advantages" but as one of the following:
- A provisional escape hatch not yet stably exposed via WIT
- A host-integration API requiring redesign to achieve WASM parity
- An API intentionally kept native-only based on security boundary decisions
File system access is provided via WASI capability declarations (Phase P-1), and external process execution is provided via host-mediated Command + IoEvent (Phase P-2). See ADR-019 for design rationale.
The core UI is structured around surfaces. The extension points available to plugins are declared by each surface.
| SurfaceId | Surface | Description |
|---|---|---|
BUFFER (0) |
KakouneBufferSurface |
Main buffer display |
STATUS (1) |
StatusBarSurface |
Status bar (rendered once per pane in multi-pane mode) |
MENU (2) |
MenuSurface |
Menu |
INFO_BASE+ (10+) |
InfoSurface |
Info popups |
PLUGIN_BASE+ (100+) |
Plugin-defined | Plugin-provided surfaces |
| SlotId | Position | Declaring Surface |
|---|---|---|
kasane.buffer.left |
Left of buffer | KakouneBufferSurface |
kasane.buffer.right |
Right of buffer | KakouneBufferSurface |
kasane.buffer.above |
Above buffer | KakouneBufferSurface |
kasane.buffer.below |
Below buffer | KakouneBufferSurface |
kasane.buffer.overlay |
Overlay on buffer | KakouneBufferSurface |
kasane.status.above |
Above status bar | StatusBarSurface |
kasane.status.left |
Left of status bar | StatusBarSurface |
kasane.status.right |
Right of status bar | StatusBarSurface |
| Goal | Mechanism to use |
|---|---|
| Add UI at a predefined location | contribute_to() |
| Decorate individual buffer lines | annotate_line_with_ctx() |
| Apply face to individual cells, ranges, or columns; override cursor style | render_ornaments() (via OrnamentBatch) |
| Display floating UI | contribute_overlay_with_ctx() |
| Modify or replace existing UI appearance | transform() |
| Transform individual menu items | transform_menu_item() |
As a principle, prefer the least flexible mechanism that suffices. Do not use transform() if contribute_to() can achieve the goal. |
As described in P-030..P-043 of requirements.md and Display Transformations and Display Units in semantics.md, Kasane allows plugins to treat display transformations as first-class concepts.
The Display Transform API (display_directives()) provides the first concrete implementation of this direction. Plugins declare DisplayDirective values describing how buffer lines map to display lines. The core builds a DisplayMap — an O(1) bidirectional mapping between buffer lines and display lines — and integrates it throughout the rendering pipeline (paint, cursor, input, patch).
Available DisplayDirective variants (4 categories, 11 variants):
| Category | Variant | Description |
|---|---|---|
| Spatial | Fold { range, summary } |
Collapse a range of buffer lines into a single summary line |
| Spatial | Hide { range } |
Hide a range of buffer lines entirely |
| InterLine | InsertBefore { line, content, priority } |
Insert a full Element before a buffer line |
| InterLine | InsertAfter { line, content, priority } |
Insert a full Element after a buffer line |
| Inline | InsertInline { line, byte_offset, content, interaction } |
Insert inline content at a byte offset |
| Inline | HideInline { line, byte_range } |
Hide a byte range within a buffer line |
| Inline | StyleInline { line, byte_range, face } |
Apply face styling to a byte range |
| Decoration | StyleLine { line, face, z_order } |
Apply a background face to an entire line |
| Decoration | Gutter { line, side, content, priority } |
Add content to the gutter of a line |
| Decoration | VirtualText { line, position, content, priority } |
Add virtual text at end of line or right-aligned |
| Decoration | EditableVirtualText { after, content, editable_spans } |
Editable virtual text with shadow cursor support |
Key types:
DisplayMap: bidirectional buffer↔display line mapping withdisplay_to_buffer(),buffer_to_display(),entry(),is_identity()SourceMapping:BufferLine(usize),LineRange(Range),None(virtual text)InteractionPolicy:Normal,ReadOnly(clicks suppressed),Skip(navigation skips)SyntheticContent: text and face for non-buffer display lines
Multi-plugin composition (P-031):
- Multiple plugins may contribute display directives simultaneously
- Composition is deterministic via
resolve(): Hide ranges are unioned, InsertAfter and InsertBefore lines accumulate, overlapping Folds are resolved by(priority, plugin_id)(higher wins) - Plugins declare priority via
display_directive_priority()(default 0) - Folds that partially overlap hidden ranges are conservatively removed (protects summary integrity)
- InsertAfter/InsertBefore targeting hidden or folded lines are suppressed
Constraints:
- Display-oriented navigation (Display Units, P-040..P-043) is implemented via
DisplayUnit,NavigationPolicy, andNavigationAction(see §3.4.1) - Kakoune controls the viewport and cursor movement, so true code folding (where folded lines are skipped during navigation) is not possible;
Foldis best suited for read-only summaries InsertAfter/InsertBefore(virtual text) are the primary practical use cases;InsertBeforeenables Gap 0 (before the first buffer line)
See examples/virtual-text-demo/ for a working proof artifact.
For mechanisms not covered by DisplayDirective (overlay composition, element-level restructuring), plugins should use the existing combination of contribute_to(), transform(), annotate_line_with_ctx(), contribute_overlay_with_ctx(), and Surface.
Native plugins can be implemented using one of two traits:
Plugin (HandlerRegistry, recommended) |
PluginBackend (mutable, internal) |
|
|---|---|---|
| Registration | 2 methods + 1 associated type: id(), State type, register() with HandlerRegistry |
20+ trait methods with defaults |
| State ownership | Framework holds state; handlers are pure functions | Plugin holds its own state (&mut self) |
| Capabilities | Auto-inferred from registered handlers | Manual capabilities() method |
| Cache invalidation | Automatic via PartialEq comparison (generation counter) |
Manual state_hash() |
| Salsa compatibility | State transitions are pure functions; future Salsa integration path | Not directly memoizable (mutable state) |
| Use when | UI decoration/transformation with deterministic state (most plugins) | You need Surface, workspace observation, or complex host integration |
Plugin is recommended for new native plugins. In unit tests, register via PluginRuntime::register(). In a host binary, wrap it with PluginBridge::new(...) and pass it to kasane::run_with_factories(...).
use kasane_core::plugin_prelude::*;
#[derive(Clone, Debug, PartialEq, Default)]
struct MyState { counter: u32 }
struct MyPlugin;
impl Plugin for MyPlugin {
type State = MyState;
fn id(&self) -> PluginId { PluginId("my_plugin".into()) }
fn register(&self, r: &mut HandlerRegistry<MyState>) {
r.declare_interests(DirtyFlags::BUFFER);
r.on_state_changed(|state, _app, dirty| {
if dirty.intersects(DirtyFlags::BUFFER) {
(MyState { counter: state.counter + 1 }, Effects::default())
} else {
(state.clone(), Effects::default())
}
});
r.on_decorate_background(|state, line, _app, _ctx| {
// ... return Option<BackgroundLayer>
None
});
}
}
// Unit-test registration:
registry.register(MyPlugin);The composition order for extensions is as follows:
- Build the seed default elements
- Apply the transform chain in priority order (processing decoration and replacement in a unified manner)
- Compose contributions and overlays
For detailed semantics, see Plugin Composition Semantics in semantics.md.
contribute_to() is the most constrained extension, contributing Elements to framework-provided extension points (SlotId).
Native:
fn contribute_to(&self, region: &SlotId, app: &AppView<'_>, _ctx: &ContributeContext) -> Option<Contribution> {
if region != &SlotId::BUFFER_LEFT { return None; }
Some(Contribution {
element: Element::text("★", Face::default()),
priority: 0,
size_hint: ContribSizeHint::Auto,
})
}WASM:
fn contribute_to(region: SlotId, _ctx: ContributeContext) -> Option<Contribution> {
kasane_plugin_sdk::route_slot_ids!(region, {
BUFFER_LEFT => {
Some(Contribution {
element: element_builder::create_text("★", face),
priority: 0,
size_hint: ContribSizeHint::Auto,
})
},
})
}ContributeContext provides layout-aware constraints. The main fields are min_width / max_width / min_height / max_height, where None represents unbounded. Contribution consists of element, priority (composition order), and size_hint (Auto / Fixed(u16) / Flex(f32)).
The legacy u8 constants from slot::BUFFER_LEFT through slot::OVERLAY remain in the kasane_plugin_sdk::slot module, but the canonical API uses first-class SlotId. Custom slots can be specified in both Native and WASM via SlotId::new("...") / SlotId::Named("...".into()).
annotate_line_with_ctx() contributes gutter elements and backgrounds to individual buffer lines.
Native:
fn annotate_line_with_ctx(&self, line: usize, app: &AppView<'_>, _ctx: &AnnotateContext) -> Option<LineAnnotation> {
if line == app.cursor_line() as usize {
Some(LineAnnotation {
left_gutter: None,
right_gutter: None,
background: Some(BackgroundLayer {
face: Face { bg: Color::Rgb(RgbColor { r: 40, g: 40, b: 50 }), ..Face::default() },
z_order: 0,
blend: BlendMode::Opaque,
}),
priority: 0,
inline: None,
})
} else {
None
}
}LineAnnotation consists of five fields: left_gutter, right_gutter, background, priority (controls gutter element ordering), and inline (byte-range inline decoration). BackgroundLayer has face, z_order, and blend (compositing mode); background contributions from multiple plugins are composited in z_order order. Gutter contributions are composited horizontally.
The inline field provides byte-range operations applied directly to buffer line atoms. This enables styling or hiding sub-ranges of a line without replacing the entire element tree.
InlineDecoration contains a sorted list of InlineOp:
InlineOp::Insert { at, content }— Insert virtual text atoms at the given byte gap positionInlineOp::Style { range, face }— Override the face for the given byte rangeInlineOp::Hide { range }— Hide the given byte range (omit from output)
Ops are sorted by sort_key() — (position, variant_order) where Insert (0) sorts before Style/Hide (1) at the same position. Range-based ops (Style/Hide) must be non-overlapping. Multiple Insert ops at the same position are allowed and emitted in order.
Insert ops inside a Hide range are still emitted (S1 semantics): the Hide omits the original buffer text, but any Insert whose at falls within the hidden range produces its virtual content.
Replace pattern — Hide + Insert at the same position to substitute text:
// Replace "def" (bytes 3..6) with "new" in "abcdefghi"
InlineDecoration::new(vec![
InlineOp::Insert { at: 3, content: vec![Atom { face: red_face, contents: "new".into() }] },
InlineOp::Hide { range: 3..6 },
])
// Result: "abc" + "new"(red) + "ghi"Example — style bytes 6..11 ("world") in red, hide bytes 0..2 ("he"):
fn annotate_line_with_ctx(&self, line: usize, app: &AppView<'_>, _ctx: &AnnotateContext) -> Option<LineAnnotation> {
Some(LineAnnotation {
left_gutter: None,
right_gutter: None,
background: None,
priority: 0,
inline: Some(InlineDecoration::new(vec![
InlineOp::Hide { range: 0..2 },
InlineOp::Style {
range: 6..11,
face: Face { fg: Color::Named(NamedColor::Red), ..Face::default() },
},
])),
})
}Byte ranges operate on UTF-8 byte offsets within the line's atom contents. Phase 1 constraint: only one plugin may provide inline decoration per line.
contribute_overlay_with_ctx() provides floating elements that are overlaid outside the normal layout flow.
Native:
fn contribute_overlay_with_ctx(&self, app: &AppView<'_>, _ctx: &OverlayContext) -> Option<OverlayContribution> {
Some(OverlayContribution {
element: Element::container(child, style),
anchor: OverlayAnchor::AnchorPoint { coord, prefer_above: true, avoid: vec![] },
z_index: 0,
plugin_id: self.id(),
})
}WASM:
fn contribute_overlay_v2(_ctx: OverlayContext) -> Option<OverlayContribution> {
Some(OverlayContribution {
element: element_builder::create_container_styled(child, ...),
anchor: OverlayAnchor::Absolute(AbsoluteAnchor { x: 10, y: 5, w: 30, h: 10 }),
z_index: 0,
})
}OverlayContribution consists of element, anchor, z_index, and plugin_id (used for deterministic tie-breaking). There are two types of OverlayAnchor:
Absolute { x, y, w, h }: Absolute position in screen coordinatesAnchorPoint { coord, prefer_above, avoid }: Kakoune-compatible anchor-based positioning
transform() is a unified mechanism that receives a TransformSubject (either an Element or an Overlay), transforms it, and returns the result. It serves as both decoration (formerly Decorator) and replacement (formerly Replacement). For non-overlay targets (Buffer, StatusBar), the subject is Element; for overlay targets (Menu, Info), it is Overlay (Element + OverlayAnchor), allowing plugins to modify overlay position and size.
Native:
fn transform(&self, target: &TransformTarget, subject: TransformSubject, app: &AppView<'_>, _ctx: &TransformContext) -> TransformSubject {
subject.map_element(|element| {
if *target == TransformTarget::BUFFER {
Element::container(element, Style::from(Face::default()))
} else {
element
}
})
}
fn transform_priority(&self) -> i16 { 100 }WASM:
fn transform(target: TransformTarget, subject: TransformSubject, _ctx: TransformContext) -> TransformSubject {
match subject {
TransformSubject::Element(element) => {
TransformSubject::Element(container(element).border(BorderLineStyle::Single).build())
}
other => other,
}
}
fn transform_priority() -> i16 { 100 }TransformTarget includes Buffer, StatusBar, Menu, Info, and others.
Native (with target specification):
r.on_transform_for(0, &[TransformTarget::BUFFER, TransformTarget::STATUS_BAR], |state, target, app, ctx| {
if *target == TransformTarget::STATUS_BAR {
ElementPatch::Append { element: Element::text("extra", Face::default()) }
} else {
ElementPatch::Identity
}
});on_transform_for() specifies which TransformTargets the handler applies to. This populates CapabilityDescriptor::transform_targets, enabling may_interfere() to detect transform target overlap between plugins. Use on_transform() (without targets) when the handler applies to all targets.
WASM (declarative patch):
// In define_plugin!:
transform_patch(target, ctx) {
if target == "kasane.status-bar" {
vec![ElementPatchOp::Prepend(text("[K] ", default_face()))]
} else {
vec![] // empty = identity (no patch)
}
},transform_patch returns Vec<ElementPatchOp> instead of imperatively transforming the subject. Pure patches are Salsa-memoizable.
Guidelines:
- Do not assume the internal structure of the received
Element - For lightweight decoration, prefer wrapping the
Elementas-is - Full replacement is also performed via
transform()(ignore the received element and return a new one) - Use
transform_priority()to control the application order - Declare
handlers.transform_targetsin the manifest for interference detection
transform_menu_item() is a per-menu-item transformation corresponding to the MENU_TRANSFORM capability. Use it when you want to locally transform the label or style of individual items. If you need to replace the entire menu structure, use transform() with TransformTarget::MENU.
The display transformation API allows plugins to restructure the display without falsifying protocol truth. display_directives() returns a Vec<DisplayDirective> describing how buffer lines map to display lines.
Design principles:
- Transformations do not falsify protocol truth — they are display policy
- The core builds a
DisplayMapproviding source mapping and interaction policy - When the inverse mapping to source is weak, read-only or restricted interaction is applied automatically
InsertAftervirtual text lines getInteractionPolicy::ReadOnlyandSourceMapping::NoneFoldsummary lines getInteractionPolicy::ReadOnlyandSourceMapping::LineRange
fn display_directives(&self, state: &Self::State, app: &AppView<'_>) -> Vec<DisplayDirective> {
vec![DisplayDirective::InsertAfter {
after: 2,
content: " ⚠ TODO — address before merge".into(),
face: Face { fg: Color::Named(NamedColor::Yellow), ..Face::default() },
}]
}The DisplayMap is integrated into: paint (buffer rendering), cursor positioning (buffer_to_display), mouse input (display_to_buffer with interaction policy check), and the patch optimization layer.
The display unit model (P-040..P-043) is implemented; see §3.4.1 for navigation API. Future extension: WASM WIT display-directive-priority function.
Projections are named display transformation strategies registered via HandlerRegistry::define_projection(). Two categories:
| Category | Behavior | Example |
|---|---|---|
| Structural | Mutually exclusive — at most one active | Semantic Zoom, Focus Mode |
| Additive | Composable — any number active | Error Lens, Diff Marks |
Activation is managed via ProjectionPolicyState (in AppState::config). Structural projections are activated with set_structural(Some(id)), additive with toggle_additive(id). Directives from inactive projections are not collected.
r.define_projection(
ProjectionDescriptor {
id: ProjectionId::new("my-plugin.my-projection"),
name: "My Projection".to_string(),
category: ProjectionCategory::Structural,
priority: -50,
},
|state: &MyState, app: &AppView<'_>| -> Vec<DisplayDirective> {
// Return directives based on plugin state and app state
vec![]
},
);Priority bands: Structural -500..0, Ambient/Legacy 0..500, Additive 500..1000.
Per-projection fold toggle state is tracked via FoldToggleState, accessible through app.projection_policy().fold_state_for(&id). The existing BuiltinFoldPlugin handles ToggleFold interaction for any projection.
The built-in kasane.semantic-zoom projection (kasane-core/src/plugin/semantic_zoom/) demonstrates the full projection pattern: state-driven directive generation, key map bindings, dual strategy dispatch (syntax-aware with tree-sitter fallback to indent-based).
SyntaxProvider (in kasane-core/src/syntax/) provides AST-level information to plugins. The active provider is set on AppState::runtime.syntax_provider and accessible via AppView::syntax_provider().
Key trait methods:
| Method | Description |
|---|---|
generation() |
Monotonic counter, incremented on re-parse |
fold_ranges() |
Foldable line ranges from AST structure |
declarations() |
Function/struct/enum/trait declarations with name, kind, signature, and body ranges |
signature_summary(line) |
Human-readable signature text for the declaration at the given line |
scopes_at(line, byte) |
Scope chain at a position (innermost last) |
nodes_in_range(range, kind) |
Named AST nodes within a byte range |
The kasane-syntax crate (feature-gated via --features syntax) provides TreeSitterProvider, which implements SyntaxProvider backed by tree-sitter. Grammar .so files are loaded from $XDG_DATA_HOME/kasane/grammars/ or $XDG_DATA_HOME/kak-tree-sitter/grammars/. Declaration queries are bundled for Rust, Python, Go, and TypeScript.
| Type | Purpose | WASM builder | Native |
|---|---|---|---|
Text |
Text + style | create_text(content, face) |
Element::text(s, face) |
StyledLine |
Atom sequence | create_styled_line(atoms) |
Element::styled_line(line) |
Flex (Column) |
Vertical layout | create_column(children) / create_column_flex(entries, gap) |
Element::column(children) |
Flex (Row) |
Horizontal layout | create_row(children) / create_row_flex(entries, gap) |
Element::row(children) |
Grid |
2D table | create_grid(cols, children, col_gap, row_gap) |
Element::grid(columns, children) |
Container |
border/shadow/padding | create_container(...) / create_container_styled(...) |
Element::container(child, style) |
Stack |
Z-axis stacking | create_stack(base, overlays) |
Element::stack(base, overlays) |
Scrollable |
Scrollable region | create_scrollable(child, offset, vertical) |
Element::Scrollable { ... } |
Interactive |
Mouse hit test | create_interactive(child, id) |
Element::Interactive { child, id } |
Image |
Raster image | create_image(source, w, h, fit, opacity) |
Element::image(source, w, h) |
Empty |
Empty element | create_empty() |
Element::Empty |
BufferRef |
Buffer line reference | Host-internal only | Element::buffer_ref(range) |
All functions are imported from the element_builder module. The returned ElementHandle is valid only within the current plugin invocation scope.
use kasane::plugin::element_builder;
let text = element_builder::create_text("hello", face);
let col = element_builder::create_column(&[text]);
let container = element_builder::create_container(
col,
Some(BorderLineStyle::Single),
false,
Edges { top: 0, right: 1, bottom: 0, left: 1 },
);For proportional distribution, use create_column_flex / create_row_flex with FlexEntry { child, flex }.
create_image constructs a raster image element. Images are rendered natively on the GPU backend; the TUI backend renders a low-resolution approximation using Unicode halfblock characters (▀), with each cell representing two pixel rows (fg = top, bg = bottom). If image decoding fails, the TUI falls back to a text placeholder ([IMAGE: filename] or [IMAGE: W×H]).
ImageSource:
| Variant | Description |
|---|---|
file-path(String) |
Path to an image file on disk |
rgba-data { data: Vec<u8>, width: u32, height: u32 } |
Pre-decoded RGBA pixel data (4 bytes per pixel) |
ImageFit:
| Value | Description |
|---|---|
contain (default) |
Scale to fit within the area, preserving aspect ratio (letterboxing) |
cover |
Scale to cover the entire area, preserving aspect ratio (cropping) |
fill |
Stretch to fill the area exactly (may distort) |
Size is specified in cells (width, height). opacity ranges from 0.0 (transparent) to 1.0 (opaque).
use kasane_core::plugin_prelude::*;
let text = Element::text("hello", Face::default());
let col = Element::column(vec![
FlexChild::fixed(text),
FlexChild::flexible(Element::Empty, 1.0),
]);FlexChild::fixed(element) is fixed, and FlexChild::flexible(element, factor) is proportionally distributed.
Native plugins access application state through &AppView<'_>, a zero-cost wrapper providing method-based accessors (e.g. app.cursor_line(), app.lines(), app.cols()).
| Field | Type | Description |
|---|---|---|
lines |
Vec<Line> |
Buffer lines |
cursor_pos |
Coord |
Cursor position |
status_line |
Line |
Status bar |
menu |
Option<MenuState> |
Menu state |
infos |
Vec<InfoState> |
Info popups |
cols, rows |
u16 |
Terminal size |
focused |
bool |
Focus state |
Dirty flags primarily notify the following observable aspects:
| Flag | Description |
|---|---|
BUFFER |
Buffer lines and cursor |
STATUS |
Status bar |
MENU_STRUCTURE |
Menu structure |
MENU_SELECTION |
Menu selection |
INFO |
Info popups |
OPTIONS |
UI options |
For semantic classification, see semantics.md.
kasane::plugin::host_state provides a tiered read API.
Basic state (Tier 0):
| Function | Return type |
|---|---|
get_cursor_line() |
s32 |
get_cursor_col() |
s32 |
get_line_count() |
u32 |
get_cols() |
u16 |
get_rows() |
u16 |
is_focused() |
bool |
Buffer lines (Tier 0.5):
| Function | Return type |
|---|---|
get_line_text(line) |
Option<String> |
get_lines_text(start, end) |
Vec<String> |
get_lines_atoms(start, end) |
Vec<Vec<Atom>> |
is_line_dirty(line) |
bool |
get_lines_text and get_lines_atoms (WIT v0.18.0) retrieve all lines in the [start, end) range in a single host call, avoiding per-line round-trip overhead.
Status bar (Tier 1):
| Function | Return type |
|---|---|
get_status_prompt() |
Vec<Atom> |
get_status_content() |
Vec<Atom> |
get_status_line() |
Vec<Atom> |
get_status_mode_line() |
Vec<Atom> |
get_status_default_style() |
Style |
Menu/Info state (Tier 2):
| Function | Return type |
|---|---|
has_menu() |
bool |
get_menu_item_count() |
u32 |
get_menu_item(index) |
Option<Vec<Atom>> |
get_menu_selected() |
s32 |
has_info() |
bool |
get_info_count() |
u32 |
General state (Tier 3):
| Function | Return type |
|---|---|
get_ui_option(key) |
Option<String> |
get_cursor_mode() |
u8 |
get_widget_columns() |
u16 |
get_default_style() |
Style |
get_padding_style() |
Style |
Multi-cursor (Tier 4):
| Function | Return type |
|---|---|
get_cursor_count() |
u32 |
get_secondary_cursor_count() |
u32 |
get_secondary_cursor(index) |
Option<Coord> |
Typed Settings (Tier 5):
| Function | Return type |
|---|---|
get_setting_bool(key) |
Option<bool> |
get_setting_integer(key) |
Option<i64> |
get_setting_float(key) |
Option<f64> |
get_setting_string(key) |
Option<String> |
get_config_string(key) |
Option<String> (deprecated, use typed settings) |
Info details (Tier 6):
| Function | Return type |
|---|---|
get_info_title(index) |
Option<Vec<Atom>> |
get_info_content(index) |
Option<Vec<Vec<Atom>>> |
get_info_style(index) |
Option<String> |
get_info_anchor(index) |
Option<Coord> |
Menu details (Tier 7):
| Function | Return type |
|---|---|
get_menu_anchor() |
Option<Coord> |
get_menu_mode() |
Option<String> |
get_menu_style() |
Option<Style> |
get_menu_selected_style() |
Option<Style> |
Session state (Tier 8):
| Function | Return type |
|---|---|
get_session_count() |
u32 |
get_session(index) |
Option<SessionDescriptor> |
get_active_session_key() |
Option<String> |
SessionDescriptor fields:
| Field | Type | Description |
|---|---|---|
key |
String |
Stable session key within the host |
session_name |
Option<String> |
Kakoune session name (kak -c <name>) |
buffer_name |
Option<String> |
Buffer name extracted from status_content atoms (e.g. main.rs) |
mode_line |
Option<String> |
Mode line extracted from status_mode_line atoms (e.g. normal, insert) |
buffer_name and mode_line are populated from the session's AppState snapshot — for the active session from the live state, for inactive sessions from the stored snapshot. These fields enable session switcher UIs that display meaningful per-session metadata without requiring plugins to access raw AppState.
| Hook | Timing | Purpose |
|---|---|---|
on_init_effects |
Immediately after plugin activation | Bootstrap redraws and local startup effects |
on_active_session_ready_effects |
After the active session is transport-ready | Session-bound startup effects |
on_shutdown |
At application exit | Cleanup |
on_state_changed_effects(dirty) |
After AppState update |
Synchronize plugin internal state |
The processing order for key input is as follows:
- Notify all plugins via
observe_key() - Call
handle_key()in order - The first plugin to return
Some(commands)wins - If all return
None, proceed to built-in key bindings - If still unhandled, forward to Kakoune
Mouse input is passed to handle_mouse(event, id, state) after observe_mouse(), followed by InteractiveId hit testing.
Default wheel scrolling has a separate policy hook. After core classifies a wheel
event as a default buffer scroll candidate, it queries plugins with
SCROLL_POLICY via handle_default_scroll(candidate) in registration order.
The first plugin to return Some(result) wins:
None: pass to the next scroll-policy pluginSome(Pass): stop the plugin chain and use core fallback scroll behaviorSome(Suppress): consume the candidate without emitting a scroll requestSome(Immediate(resolved)): emit a single resolved scroll request immediatelySome(Plan(plan)): hand a declarative scroll plan to the host runtime
This hook only applies to default buffer scroll candidates. Core-owned paths such as info-popup scrolling and drag-scroll routing do not go through it.
The DisplayMap provides the first concrete implementation of source mapping and interaction policy for display lines:
SourceMapping::BufferLine(n): display line maps 1:1 to buffer linen—InteractionPolicy::NormalSourceMapping::LineRange(range): display line represents a folded range —InteractionPolicy::ReadOnlySourceMapping::None: virtual text with no buffer origin —InteractionPolicy::ReadOnly
Mouse clicks on ReadOnly or Skip lines are suppressed by mouse_to_kakoune() (returns None). Cursor positioning uses buffer_to_display() to translate buffer coordinates to display coordinates.
The Display Unit model (P-040..P-043) is implemented. DisplayUnit, DisplayUnitId, SemanticRole, and UnitSource provide a first-class unit abstraction. Navigation is handled via NavigationPolicy (per-unit policy resolution through plugin dispatch, FirstWins composition) and NavigationAction / ActionResult.
InteractionPolicy (rendering/cursor suppression) and NavigationPolicy (input/navigation) are orthogonal: a fold summary has InteractionPolicy::ReadOnly (σ is weak) and NavigationPolicy::Boundary { ToggleFold } (plugin-declared).
Registration methods on HandlerRegistry:
/// Declare navigation policy for display units produced by this plugin.
pub fn on_navigation_policy(
&mut self,
handler: impl Fn(&S, &DisplayUnit) -> NavigationPolicy + Send + Sync + 'static,
);
/// Handle navigation actions on this plugin's display units.
pub fn on_navigation_action(
&mut self,
handler: impl Fn(&S, &DisplayUnit, NavigationAction) -> (S, ActionResult)
+ Send + Sync + 'static,
);Default policies (when no plugin registers a policy):
| SemanticRole | Default NavigationPolicy |
|---|---|
BufferContent |
Normal |
FoldSummary |
Boundary { ToggleFold } |
EditableVirtualText |
Boundary { ActivateShadowCursor } |
Plugin(_, _) |
Skip |
Extensibility boundaries:
| Dimension | Extensible? | Mechanism |
|---|---|---|
Unit kinds (SemanticRole) |
Yes | Plugin(tag, id) variant |
| Navigation policies | Yes | on_navigation_policy registration |
| Click/activation behavior | Yes | on_navigation_action registration |
Source mapping kinds (UnitSource) |
No | Closed enum — core must determine σ strength for DU-INV-4 |
InteractionPolicy override |
No | Derived from σ strength — prevents undefined cursor placement |
| Cross-plugin policy override | Yes | FirstWins priority |
Constraints:
- Sub-line display units (
UnitSource::Span) are defined in the type but not yet produced by the builder - Plugins must not fabricate facts that Kakoune has not provided as the result of interactions
Hook functions issue side-effect requests through the unified Effects struct. The framework validates command legality per lifecycle phase via Effects::validate(phase):
- Bootstrap: only
RequestRedraw; no scroll plans. - SessionReady:
SendToKakoune,Paste,PluginMessage,RequestRedraw; scroll plans allowed. - Runtime: all commands and scroll plans allowed.
Effects carries redraw: DirtyFlags, commands: Vec<Command>, and scroll_plans: Vec<ScrollPlan>.
| Command | Description |
|---|---|
SendToKakoune(req) |
Send a request to Kakoune |
PasteClipboard |
Paste from the host system clipboard |
Quit |
Quit the application |
RequestRedraw(flags) |
Request a redraw |
ScheduleTimer { delay, target, payload } |
Send a message to target after a delay |
PluginMessage { target, payload } |
Send a message to another plugin |
SetConfig { key, value } |
Change a runtime configuration (deprecated, use SetSetting) |
SetSetting { key, value } |
Set a typed plugin setting. Value is a SettingValue variant (bool/integer/float/string). Scoped to the calling plugin. |
SpawnProcess { job_id, program, args, stdin_mode } |
Spawn an external process (Phase P-2) |
Session(SessionCommand) |
Create or close a Kakoune session managed by the host runtime |
WriteToProcess { job_id, data } |
Write to the stdin of a spawned process |
CloseProcessStdin { job_id } |
Close a process's stdin (EOF) |
KillProcess { job_id } |
Force-kill a process |
SpawnPaneClient { surface_id, placement } |
Spawn a new pane backed by an independent Kakoune client |
ClosePaneClient { surface_id } |
Close a pane and terminate its Kakoune client |
Workspace(WorkspaceCommand) |
Workspace operations |
RegisterSurface { surface, placement } |
Register a plugin-owned surface into the workspace |
UnregisterSurface { surface_id } |
Unregister a plugin-owned surface |
EditBuffer { edits } |
Apply structured buffer edits (translated to Kakoune key sequences) |
InjectInput(InputEvent) |
Re-dispatch a synthetic input event through the update system |
RegisterThemeTokens(tokens) |
Register custom theme tokens |
SessionCommand has the following variants:
Spawn { key, session, args, activate }: Open a new managed session.key: Option<String>is an optional stable key within the host,session: Option<String>is the session name corresponding tokak -c <name>, andactivate = trueimmediately switches to that session as the active session.Close { key }: Close the session with the specified key.key: Option<String> = Nonecloses the current active session. If the last session is closed, the host runtime terminates. If the active session is closed and other sessions remain, the host runtime promotes the next session in creation order to active.Switch { key }: Switch the active session to the one identified bykey: String.
The V1 session runtime can hold multiple sessions, but only one active session is rendered at a time. The Kakoune reader for inactive sessions remains alive, and its events continue to be reflected in the off-screen session snapshot. When activated, that snapshot is restored, but automatic generation of session-bound surfaces and multi-session dedicated UI are not yet implemented.
In WASM, these are represented as command variants. SpawnPaneClient, ClosePaneClient, Workspace, RegisterSurface, UnregisterSurface, and RegisterThemeTokens are currently not supported in WASM. Process execution commands (SpawnProcess, etc.), session management commands (spawn-session, close-session), edit-buffer, and inject-key have been introduced on the WIT side.
Command::EditBuffer { edits } allows plugins to apply structured edits to the buffer. Each BufferEdit specifies a range (1-indexed BufferPosition { line, column }) and a replacement string. The framework translates edits into Kakoune key sequences via edits_to_keys():
- Edits are applied bottom-up (higher lines first) to preserve line/column validity
- Zero-width range with non-empty replacement = insertion at point
- Non-zero range with empty replacement = deletion
- Non-zero range with non-empty replacement = replacement (select +
c+ text)
EditBuffer is an immediate command — it is executed inline during command processing. In WASM, use the edit-buffer(list<buffer-edit>) command variant.
Command::InjectInput(InputEvent) re-dispatches a synthetic input event through the update() state machine, as if the user had pressed the key. This enables plugins to programmatically trigger input-driven behavior.
- Injection is recursive: commands produced by the injected event are processed in the same batch
- A depth guard (
MAX_INJECT_DEPTH = 10) prevents infinite recursion - Only
InputEvent::Keyis supported via WASM (inject-key(key-event)command variant)
Plugins can observe session state and control session switching:
- Session query:
AppState.session_descriptorsprovides the list of sessions (SessionDescriptor { key, session_name, buffer_name, mode_line }), andAppState.active_session_keyidentifies the current session.buffer_nameis extracted fromstatus_contentatoms andmode_linefromstatus_mode_lineatoms of the session'sAppStatesnapshot. In WASM, Tier 8 host-state functionsget-session-count,get-session(index), andget-active-session-keyprovide equivalent access. - Session lifecycle notification:
DirtyFlags::SESSIONis set when sessions are created, closed, switched, or when a session dies. Plugins react viaon_state_changed. - Session switch command:
SessionCommand::Switch { key }(native) orcommand::switch-session(key)(WIT) requests activation of a specific session by key.
See ADR-023 for the boundary rationale and decision record.
WASM plugins are sandboxed by default. The host constructs the WASI context from the plugin manifest (kasane-plugin.toml → [capabilities].wasi) before instantiating the WASM component — plugins never participate in their own permission decisions. Without a manifest capability declaration, access to host resources such as file system and network is unavailable. The host functions available to WASM plugins are limited to the two WIT interfaces: host-state (state reading) and element-builder (element construction). Per Phase P (ADR-019), preopened_dir / env are unlocked based on manifest capability declarations (P-1), and process execution is provided via host mediation (Command::SpawnProcess + IoEvent) (P-2). Process execution requires declaring process in the manifest's [capabilities].wasi, which can be denied via deny_capabilities in kasane.kdl.
PluginCapabilities is a bitflag declaring the features a plugin implements, used to skip unnecessary method calls.
| Flag | Description |
|---|---|
CONTRIBUTOR |
contribute_to() |
TRANSFORMER |
transform() |
ANNOTATOR |
annotate_line_with_ctx() |
OVERLAY |
contribute_overlay_with_ctx() |
MENU_TRANSFORM |
transform_menu_item() |
RENDER_ORNAMENT |
render_ornaments() |
INPUT_HANDLER |
handle_key() / handle_mouse() |
SCROLL_POLICY |
handle_default_scroll() |
PANE_LIFECYCLE |
Pane lifecycle hooks |
PANE_RENDERER |
render_pane() |
SURFACE_PROVIDER |
surfaces() |
WORKSPACE_OBSERVER |
on_workspace_changed() |
IO_HANDLER |
on_io_event_effects() |
DISPLAY_TRANSFORM |
display_directives() |
For native plugins the default is all(). For WASM plugins, the authoritative source is the plugin manifest (kasane-plugin.toml → [handlers].flags); the WASM adapter receives pre-computed flags from the manifest without querying guest code.
PANE_LIFECYCLE, PANE_RENDERER, WORKSPACE_OBSERVER, and DISPLAY_TRANSFORM are currently native-only, but SURFACE_PROVIDER has also been introduced on the WIT side as hosted surface descriptors / render-surface. It is not assumed that the same trait signatures will be directly mapped to WIT.
Plugin contribution caching is handled by Salsa incremental computation. The framework tracks dependencies automatically via PartialEq-based early cutoff on Salsa inputs.
The plugin-side caching mechanisms are:
state_hash() — signals plugin-internal state changes:
// WASM
fn state_hash() -> u64 {
MY_STATE.get() as u64
}PluginBackend implementors provide state_hash() to signal state changes. Plugin (state-externalized) eliminates manual state_hash() — the framework tracks state changes automatically via PartialEq comparison on the externalized state, using a generation counter.
view_deps() — declares which DirtyFlags a plugin's view methods depend on:
fn view_deps(&self) -> DirtyFlags {
DirtyFlags::BUFFER
}When neither the plugin's state_hash() changed nor any of its declared view_deps() flags are dirty, the framework skips re-collecting that plugin's contributions, annotations, overlays, and display directives entirely. Salsa inputs retain their previous values, so downstream memoized queries remain valid.
Default: DirtyFlags::ALL (always re-collect — safe fallback for backward compatibility). Override with the narrowest set of flags your view methods actually read. For example, a line-numbers plugin that only reads buffer content should return DirtyFlags::BUFFER.
The correctness invariant is: declared deps must be a superset of actual deps. If view_deps() omits a flag that a view method actually depends on, stale contributions may persist until the next matching dirty event.
render_ornaments() returns an OrnamentBatch containing backend-independent physical ornament proposals for the current frame. This is a unified extension point covering cell-level face overrides and cursor style changes — operations that act on the rendered grid rather than the Element tree.
Register via HandlerRegistry::on_render_ornaments(). Capability flag: RENDER_ORNAMENT.
OrnamentBatch fields (see plugin/render_ornament.rs):
| Field | Type | Description |
|---|---|---|
emphasis |
Vec<CellDecoration> |
Cell-level face overrides (see §4.3.1) |
cursor_style |
Option<CursorStyleOrn> |
Cursor shape override (see §4.3.2) |
cursor_effects |
Vec<CursorEffectOrn> |
Reserved for future use |
surfaces |
Vec<SurfaceOrn> |
Reserved for future use |
Native (HandlerRegistry):
registry.on_render_ornaments(|state, app, ctx| {
OrnamentBatch {
emphasis: vec![CellDecoration {
target: DecorationTarget::Cell(Coord { line: app.cursor_line() as u32, column: 0 }),
face: Face { bg: Some(Color::Rgb(40, 40, 40)), ..Default::default() },
merge: FaceMerge::Background,
priority: 0,
}],
cursor_style: None,
..Default::default()
}
});WASM: Implement render-ornaments(ctx: ornament-context) -> ornament-batch in the guest.
The RenderOrnamentContext provides viewport information (screen_cols, screen_rows, visible_line_start, visible_line_end) so plugins can limit work to visible cells.
Cell decorations (OrnamentBatch.emphasis) apply face overrides to individual cells, cell ranges, or entire columns after paint. Unlike annotate_line_with_ctx() which operates at the line level, cell decorations target arbitrary screen coordinates and are composable across plugins.
CellDecoration fields (see plugin/context.rs):
| Field | Type | Description |
|---|---|---|
target |
DecorationTarget |
Where to apply the decoration |
face |
Face |
The face to apply |
merge |
FaceMerge |
How to merge with the existing cell face |
priority |
i16 |
Lower priority decorations are applied first |
DecorationTarget variants:
| Variant | Description |
|---|---|
Cell(coord) |
A single cell at the given buffer coordinate |
Range { start, end } |
A contiguous range of cells (inclusive) |
Column { column } |
An entire column across all visible rows |
FaceMerge modes:
| Mode | WASM value | Description |
|---|---|---|
Replace |
0 |
Completely replace the existing face |
Overlay |
1 |
Overlay non-default fields onto the existing face |
Background |
2 |
Only apply the background color |
Decorations from multiple plugins are collected, sorted by priority (ascending), and applied in order.
OrnamentBatch.cursor_style allows a plugin to change the cursor shape. When multiple plugins provide a value, resolution uses priority and OrnamentModality (Must > Approximate > May).
CursorStyleOrn fields (see plugin/render_ornament.rs):
| Field | Type | Description |
|---|---|---|
hint |
CursorStyleHint |
Cursor shape (Block, Bar, Underline, Outline) |
priority |
i16 |
Lower wins among same modality |
modality |
OrnamentModality |
Must / Approximate / May |
Cursor shapes (WASM option<u8>):
| Value | Shape |
|---|---|
0 |
Block (default) |
1 |
Bar |
2 |
Underline |
3 |
Outline (hollow block) |
The kasane_plugin_sdk::generate!() macro emits the following helper functions alongside WIT bindings, reducing boilerplate for common operations.
| Helper | Description |
|---|---|
default_face() |
Face with all colors default, no attributes |
face_fg(color) |
Face with only foreground color set |
face_bg(color) |
Face with only background color set |
face(fg, bg) |
Face with foreground and background |
face_full(fg, bg, underline, attrs) |
Face with all fields specified |
rgb(r, g, b) |
Color::Rgb(RgbColor { r, g, b }) |
named(n) |
Color::Named(n) |
// Before
let face = Face {
fg: Color::DefaultColor,
bg: Color::Rgb(RgbColor { r: 40, g: 40, b: 50 }),
underline: Color::DefaultColor,
attributes: 0,
};
// After
let face = face_bg(rgb(40, 40, 50));| Helper | Description |
|---|---|
centered_overlay(cols, rows, w_pct, h_pct, min_w, min_h) |
Compute a centered AbsoluteAnchor |
| Helper | Description |
|---|---|
redraw() |
Request a full redraw (DirtyFlags::ALL) |
redraw_flags(flags) |
Request a redraw for specific dirty flags |
send_command(cmd) |
Build Command::SendKeys(<esc>:cmd<ret>) |
paste_clipboard() |
Build Command::PasteClipboard for host system clipboard insertion |
| Function | Description |
|---|---|
keys::push_literal(keys, text) |
Push each char as an escaped key string |
keys::command(cmd) |
Build <esc>:cmd<ret> key sequence |
Helpers for MessagePack serialization at the WASM boundary. WASM plugins work with the WIT-generated ChannelValue { data, type_hint } struct; this module handles the raw bytes.
| Function | Description |
|---|---|
channel::serialize<T>(value) |
Serialize T into (Vec<u8>, String) for a WIT channel-value |
channel::deserialize<T>(data) |
Deserialize &[u8] into Option<T> |
use kasane_plugin_sdk::channel;
// Publishing
let (data, type_hint) = channel::serialize(&42u32);
// → ChannelValue { data, type_hint }
// Subscribing
let value: Option<u32> = channel::deserialize(&received.data);RPN-encoded predicate sequences for conditional transform patches. These macros use the WIT-generated PredicateOp type, so they must be called inside a plugin crate (after generate!() or define_plugin!).
| Macro | Output |
|---|---|
pred_has_focus!() |
[HasFocus] |
pred_surface_is!(id) |
[SurfaceIs(id)] |
pred_line_range!(start, end) |
[LineRange { start, end }] |
pred_not!(inner) |
[...inner, NotOp] |
pred_and!(a, b) |
[...a, ...b, AndOp] |
pred_or!(a, b) |
[...a, ...b, OrOp] |
use kasane_plugin_sdk::{pred_has_focus, pred_not, pred_and, pred_surface_is};
// "has focus AND surface is 0"
let pred = pred_and!(pred_has_focus!(), pred_surface_is!(0));
// "NOT has focus"
let pred = pred_not!(pred_has_focus!());Constants matching kasane_core::protocol::color::Attributes bitflags: UNDERLINE, BOLD, ITALIC, REVERSE, etc.
Generates a struct with a generation counter, Default impl, bump_generation() method, and thread_local! STATE.
kasane_plugin_sdk::state! {
struct PluginState {
cursor_count: u32 = 0,
active: bool = false,
}
}
// Access: STATE.with(|s| { let state = s.borrow(); ... })In define_plugin!, the state {} section supports #[bind(expr, on: flags)] attributes on fields to auto-generate sync code in on_state_changed_effects(). Mutable contexts (handle_key, overlay, on_io_event_effects, etc.) use a StateMutGuard that auto-calls bump_generation() on drop, so manual calls are no longer required.
generate!() also provides glob-imported auto-imports for common WIT types. Explicit use statements in existing code shadow these without conflict:
Guest(fromexports::kasane::plugin::plugin_api)host_state,element_builder(fromkasane::plugin)types::*(Face,Color,SlotId,Command, etc.)
StyleToken is a semantic style token that maps to a Face from the theme configuration.
| Token name | Purpose |
|---|---|
buffer.text |
Buffer text |
buffer.padding |
Buffer padding |
status.line |
Status bar |
status.mode |
Mode display |
menu.item.normal |
Normal menu item |
menu.item.selected |
Selected menu item |
menu.scrollbar / menu.scrollbar.thumb |
Scrollbar |
info.text / info.border |
Info popup |
border / shadow |
Border / shadow |
Custom tokens can be created and registered by plugins.
StyleToken::new("myplugin.highlight")
fn on_init_effects(&mut self, _app: &AppView<'_>) -> Effects {
Effects::redraw(DirtyFlags::STATUS)
}theme {
"menu.selected" fg="black" bg="blue"
"myplugin.highlight" fg="yellow"
}Plugins with the SURFACE_PROVIDER capability can provide their own surfaces. In Native, they return Box<dyn Surface>, while in WASM, they map to a hosted surface model returning static surface-descriptor groups, render-surface(surface-key, ctx), handle-surface-event(surface-key, event, ctx), and handle-surface-state-changed(surface-key, dirty-flags).
impl PluginBackend for MyPlugin {
fn capabilities(&self) -> PluginCapabilities {
PluginCapabilities::SURFACE_PROVIDER
}
fn surfaces(&mut self) -> Vec<Box<dyn Surface>> {
vec![Box::new(MySidebar::new())]
}
}| Method | Description |
|---|---|
id() -> SurfaceId |
Unique ID |
surface_key() -> CompactString |
Stable semantic key |
size_hint() -> SizeHint |
Preferred size |
initial_placement() -> Option<SurfacePlacementRequest> |
Static initial placement |
view(ctx: &ViewContext) -> Element |
Build Element tree |
handle_event(event, ctx) -> Vec<Command> |
Event handling |
on_state_changed_effects(state, dirty) -> Effects |
Shared state change notification |
state_hash() -> u64 |
Hash for view cache |
declared_slots() -> &[SlotDeclaration] |
Extension point declarations |
ViewContext provides state, rect, focused, registry, and surface_id. Collection/registration of plugin-owned surfaces and initial_placement() are evaluated during bootstrap preflight, and workspace_request() is used only as a legacy fallback during the transition period. The descriptor's initial_placement() reflects SplitFocused / SplitFrom / Tab / TabIn / Dock / Float directly from the surface path into the workspace. Dock uses SizeHint's preferred/min size to determine the ratio when the root rect is known, and falls back to a default ratio otherwise. Commands returned by handle_event() / handle-surface-event(...) / handle-surface-state-changed(...) are executed in the context of the surface owner plugin, so capability-gated deferred commands such as SpawnProcess are evaluated under the owner plugin's permissions. on_state_changed_effects(...) is called at least on shared state updates originating from the Kakoune protocol, allowing the surface owner to return additional commands based on dirty flags.
WorkspaceCommand manipulates surface placement and layout.
| WorkspaceCommand | Description |
|---|---|
AddSurface { surface_id, placement } |
Add a surface |
RemoveSurface(id) |
Remove a surface |
Focus(id) |
Move focus |
FocusDirection(dir) |
Directional focus |
Resize { delta } |
Adjust split ratio. Split divider drag also internally falls through to this command |
Swap(id1, id2) |
Swap surfaces |
Float { surface_id, rect } |
Make a surface floating |
Unfloat(id) |
Return to tiled mode. If split metadata from the previous float remains, it is preferentially used for restoration |
| Placement | Description |
|---|---|
SplitFocused { direction, ratio } |
Split the focused surface |
SplitFrom { target, direction, ratio } |
Split from a specific surface |
Tab / TabIn { target } |
Add a tab |
Dock(position) |
Dock to Left/Right/Bottom/Panel |
Float { rect } |
Add as floating |
Surfaces can define custom slots that other plugins can contribute to by returning declared_slots().
impl Surface for MySurface {
fn declared_slots(&self) -> &[SlotDeclaration] {
&[
SlotDeclaration::new("myplugin.sidebar.top", SlotKind::AboveBand),
SlotDeclaration::new("myplugin.sidebar.bottom", SlotKind::BelowBand),
]
}
}SlotDeclaration.kind is advisory metadata; the actual placement is determined by Element::SlotPlaceholder. Other plugins use contribute_to(&SlotId::new("myplugin.sidebar.top"), state, ctx). In WASM, the same slot name is specified via SlotId::Named("myplugin.sidebar.top".into()).
Command::PluginMessage { target, payload } enables inter-plugin message passing.
- Native: Downcast in
update(msg: Box<dyn Any>, state) - WASM: Receive byte array in
update(payload: Vec<u8>)
Command::ScheduleTimer { delay, target, payload } performs delayed message sending.
Plugins with the PANE_LIFECYCLE capability can observe pane creation, deletion, and focus changes.
| Hook | Description |
|---|---|
on_pane_created(pane_id, state) |
Pane creation notification |
on_pane_closed(pane_id) |
Pane deletion notification |
on_focus_changed(from, to, state) |
Focus change notification |
With the PANE_RENDERER capability, render_pane(pane_id, cols, rows) can render plugin-owned panes.
Topic-based publish/subscribe enables typed inter-plugin communication. Evaluation is two-phase: publishers produce values, then subscribers receive them.
Native (typed):
// Publisher
let topic: Topic<u32> = r.publish_typed("cursor.line", |state, _app| state.line);
// Subscriber (type mismatch = compile error)
r.subscribe_typed::<u32>("cursor.line", |state, value| {
MyState { last_line: *value, ..state.clone() }
});WASM: Declare topics in the manifest and implement the WIT exports:
[handlers]
publish_topics = ["cursor.line"]
subscribe_topics = ["theme.changed"]// WIT exports (default implementations provided by #[plugin])
fn publish_value(topic: String) -> Option<ChannelValue> { ... }
fn on_subscription(topic: String, values: Vec<ChannelValue>) -> RuntimeEffects { ... }Use kasane_plugin_sdk::channel::serialize() / deserialize() for value conversion.
Plugin-defined extension points allow plugins to define custom hooks that other plugins can contribute to.
Native:
// Definer
r.define_extension::<(), Vec<StatusItem>>(
ExtensionPointId::new("myplugin.status-items"),
CompositionRule::Merge,
);
// Contributor
r.on_extension::<(), Vec<StatusItem>>(
ExtensionPointId::new("myplugin.status-items"),
|_state, _input, _app| vec![StatusItem { ... }],
);WASM: Declare in the manifest and implement the WIT export:
[handlers]
extensions_defined = ["myplugin.status-items"]
extensions_consumed = ["other.ext"]// WIT export (default implementation provided by #[plugin])
fn evaluate_extension(id: String, input: ChannelValue) -> Option<ChannelValue> { ... }CapabilityDescriptor provides structured metadata about a plugin's transform targets, contribution slots, pub/sub topics, and extension points. The framework uses may_interfere() to warn when two plugins overlap on the same targets or slots.
Native plugins: inferred automatically from HandlerRegistry method calls (e.g., on_transform_for() populates transform_targets).
WASM plugins: declared in the manifest [handlers] section (transform_targets, publish_topics, etc.) and converted to CapabilityDescriptor at load time.
- PluginMessage: Point-to-point via
Command::PluginMessage { target, payload }. No type safety, no delivery guarantee, no RPC. - ConfigEntry: Publish via
Command::SetConfig { key, value }; read viaAppView::plugin_config(). Indirect, delayed (next frame). - Transform chain: Indirectly modify other plugins' contributions through
ElementPatch. The transformer does not know whose contribution it is transforming. - Topic-based Pub/Sub: Broadcast via
TopicBus. Two-phase evaluation (collect → deliver) with cycle prevention. Type safety is runtime-enforced (downcast). Seeplugin/pubsub.rs. - Plugin-defined Extension Points: Define
ExtensionPointIdwithCompositionRule(Merge,FirstWins,Chain). Seeplugin/extension_point.rs.
- Conditional contribution ("if Plugin B contributed to slot X, adjust mine") — impossible. Each plugin generates contributions independently.
- Relative positioning ("place my overlay next to Plugin C's") — impossible. Overlays use absolute positioning or anchors.
- Resource budget negotiation — impossible. Framework resolves via layout.
- Synchronous RPC (Plugin A → B → A) — impossible. Pub/sub is broadcast-only.
- Atomic transactions across multiple plugins — impossible. Each plugin's state transition is independent.
This section catalogs constraints of WASM plugins compared to native plugins: [By Design] (intentional boundary), [Not Yet Implemented] (planned), [Improvement] (ergonomic friction).
| Feature | Native | WASM | Gap |
|---|---|---|---|
| Dynamic surfaces | Full | Static only | Cannot add/remove/move post-init [Not Yet Implemented] |
| Pane lifecycle | Full | None | No create/close/focus hooks [Not Yet Implemented] |
| Pane rendering | Full | None | Cannot own custom panes [Not Yet Implemented] |
| Pane commands | Full | None | No split/close/focus commands [Not Yet Implemented] |
| Inter-plugin messaging | Box<dyn Any> |
Vec<u8> |
Serialization required |
| State access | Direct &AppState |
~40 getter functions | Guarded access [By Design] |
| Cache invalidation | Automatic (PartialEq) |
Manual state_hash() |
[Improvement] |
| Execution timeout | N/A | Epoch interruption (~10ms) | — |
| Memory limits | N/A | 64 MB per plugin | — |
| Environment variables | Full | Allowlisted subset | [By Design] |
| State | Native | WASM |
|---|---|---|
| Selection ranges and mode | state.selections |
Not available |
| Search state and regex | state.search |
Not available |
| Input mode | state.input_mode |
Not available |
| Named registers | state.registers |
Not available |
| Completion candidates | state.completions |
Not available |
| Pane tree structure | state.panes |
Not available |
| Full face registry | state.faces |
Not available |
- Synchronous execution [By Design]: All calls block the host thread. Long-running work should use
SpawnProcess. - No threading [By Design]: WASI does not include threading support.
- No network access [By Design]: Spawn a helper process via
SpawnProcessand communicate over stdin/stdout. - Element handle scope [By Design]: Handles are valid only within the current plugin call.
- Epoch interruption: A background ticker (10ms interval) traps runaway plugins. Runtime calls have a 1-epoch (~10ms) deadline; instantiation uses 100 epochs (~1s).
- Memory limits: Each plugin Store is limited to 64 MB memory, 10K table elements, and 10 instances via
StoreLimits. - Environment variable allowlist [By Design]: Only a safe subset (
HOME,PATH,SHELL,EDITOR,PAGER,TERM,LANG,LC_ALL,XDG_*) is exposed. Plugins can declare additional variables via manifestcapabilities.env_vars.
| Constraint | Rationale |
|---|---|
No direct AppState mutation |
Pure function semantics, deterministic rendering |
| No commands during view phase | Eliminate rendering side effects |
| No access to other plugins' state | Plugin isolation, testability |
| No WASM network I/O | Sandbox security |
- plugin-development.md — Quickstart guide
- semantics.md — Composition ordering and system boundaries
- index.md — Entry point for all docs