membangun plugin Zellij pertama dengan Rust dan WASM: struktur crate, dependency zellij-tile, target wasm32-wasi, trait ZellijTile untuk render dan handle event, mengirim actions, plus status widget nyata.

Di episode 15, kalian mempelajari peta konsep sistem plugin Zellij: arsitektur WASM, Zellij Message Protocol, siklus hidup load → event → render → action, dan Plugin Manager. Teori itu sekarang akan kalian bayar lunas. Episode 16 adalah episode paling praktis di series Belajar Zellij: kalian akan membangun plugin Zellij pertama dengan Rust dan WebAssembly, dari nol sampai benar-benar berjalan di dalam session.
Target kita sederhana tapi nyata: sebuah status widget yang menampilkan mode Zellij aktif, tab yang sedang fokus, dan penghitung keypress — ditaruh sebagai bar kecil di bawah workspace. Kecil, tetapi mencakup semua konsep inti pengembangan plugin: struktur crate, dependency zellij-tile, target kompilasi wasm32-wasi, implementasi trait ZellijTile, handle event, render UI, dan mengirim action balik ke host. Yang kalian perlukan hanya tiga hal: Rust toolchain yang terpasang, Zellij 0.44.x yang sudah berjalan (episode 0), dan kesabaran untuk membaca pesan error kompilasi pertama kalian.
Mengapa ini penting? Karena plugin adalah jalan menuju Zellij yang benar-benar milik kalian. Tab-bar dan status-bar bawaan hanyalah titik awal — begitu kalian bisa membangun widget sendiri, kalian bisa menambahkan apa saja ke workspace: indikator git branch, metrik build, notifikasi dari proses luar, hingga kontrol custom yang tidak ada di Zellij bawaan. Sekaligus, proses ini melatih mental model arsitektur Zellij yang sudah kita bangun selama lima belas episode. Mari mulai dari struktur project.
wasm32-wasiBuat crate baru sebagai library — plugin Zellij bukan binary, melainkan modul yang dipanggil host:
cargo new --lib status-widget
cd status-widgetKemudian isi Cargo.toml dengan dependency dan konfigurasi output yang tepat:
[package]
name = "status-widget"
version = "0.1.0"
edition = "2021"
[dependencies]
zellij-tile = "0.44"
[lib]
crate-type = ["cdylib", "rlib"]Dua bagian yang wajib diperhatikan. zellij-tile adalah pustaka resmi yang menyediakan trait, prelude, dan semua fungsi helper — versi majornya mengikuti versi Zellij. crate-type = ["cdylib"] membuat kompilasi menghasilkan modul dengan ekspor simbol yang bisa dipanggil host WASM, bukan sekadar pustaka internal.
Selanjutnya tambahkan target kompilasi WASM ke toolchain Rust:
rustup target add wasm32-wasi
rustup show active-toolchainAgar tidak perlu menyebut target setiap kali memanggil cargo build, buat berkas konfigurasi cargo yang mengunci target secara default:
[build]
target = "wasm32-wasi"Dengan konfigurasi ini, semua cargo build dan cargo test otomatis menargetkan wasm32-wasi. Ini keputusan yang membebaskan kalian dari typo dan menjadikan build plugin se-setara build biasa.
Ada satu konvensi yang layak kalian pegang sejak awal: jalur hasil build. Dengan target yang dikunci di konfigurasi, artefak plugin selalu berada di target/wasm32-wasi/release/, dan nama filenya mengikuti nama crate dengan garis bawah — status-widget menghasilkan status_widget.wasm. Jalur yang stabil ini akan kalian pakai berulang kali untuk memuat plugin, jadi pahami lokasinya sekali sekarang dan hemat waktu di kemudian hari. Jika kalian bekerja di mesin dengan RAM terbatas, bangun tanpa --release untuk iterasi yang lebih cepat; gunakan --release saat plugin sudah stabil.
Note
wasm32-wasi adalah target WASM yang menyediakan akses ke stdin, stdout, dan lingkungan host — persis yang dibutuhkan plugin untuk berbicara dengan Zellij melalui Zellij Message Protocol. Jangan tertukar dengan wasm32-unknown-unknown yang lebih terbatas dan tidak punya akses stdin/stdout standar.
ZellijTile: Siklus Hidup PluginSemua logika plugin hidup di dalam implementasi trait ZellijTile. Trait ini mendefinisikan kontrak antara plugin dan host: host memanggil metode tertentu pada titik-titik siklus hidup, dan plugin mengisi perilakunya. Ada tiga metode yang menjadi tulang punggung plugin:
| Metode | Dipanggil Ketika | Tugas |
|---|---|---|
load | Plugin dimuat | Menyiapkan state, subscribe event, minta permission |
handle_event | Ada event dari host | Memperbarui state berdasarkan event |
render | Layar akan digambar | Menggambar UI lewat teks ke stdout |
Struktur plugin selalu sama: sebuah struct dengan #[derive(Default)] untuk menyimpan state, dipasangkan ke trait lewat impl ZellijTile, lalu didaftarkan dengan makro register_tile!. Makro itulah yang mengekspor fungsi-fungsi load, update, dan render yang dipanggil host WASM — tanpa makro ini, host tidak akan menemukan pintu masuk ke plugin kalian.
Aturan praktisnya: load sekali, handle_event berkali-kali, render setiap kali state berubah. Kalian tidak perlu menggambar ulang secara manual — cukup perbarui state di handle_event, dan Zellij akan memanggil render saat waktunya menggambar. Hubungan sebab-akibat ini adalah inti mental model plugin.
Penting untuk dipahami bahwa handle_event dan render bukanlah satu lingkaran besar yang berjalan sendiri. Host memanggil keduanya secara asinkron sesuai kejadian: sebuah keypress memicu handle_event, sebuah perubahan tab memicu handle_event lain, dan Zellij yang memutuskan kapan layar perlu digambar ulang. Plugin kalian hanya menyimpan state dan menunggu panggilan — mental model ini mencegah kalian menulis logika loop yang justru memperlambat render dan membuat widget kalian tidak responsif.
Sekarang kita implementasikan semuanya. Tulis kode berikut ke src/lib.rs:
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
#[derive(Default)]
struct StatusWidget {
mode: String,
tab: String,
counter: u32,
}
register_tile!(StatusWidget);
impl ZellijTile for StatusWidget {
fn load(&mut self, _config: BTreeMap<String, String>) {
request_permission(&[PermissionType::ReadApplicationState]);
subscribe(&[EventType::ModeUpdate, EventType::TabUpdate, EventType::Key]);
}
fn handle_event(&mut self, event: Event) {
match event {
Event::ModeUpdate(mode_info) => {
self.mode = format!("{:?}", mode_info.mode);
}
Event::TabUpdate(tabs) => {
if let Some(active) = tabs.iter().find(|tab| tab.active) {
self.tab = active.name.clone();
}
}
Event::Key(key) => {
if key == Key::Char('c') {
self.counter += 1;
}
}
_ => {}
}
}
fn render(&mut self, _rows: usize, cols: usize) {
let line = format!(
"{} | tab: {} | tombol c ditekan: {} kali",
self.mode, self.tab, self.counter
);
println!("{:width$}{}", "", line, width = 0);
}
}Mari bedah apa yang terjadi. Di load, kita meminta permission ReadApplicationState — tanpa ini, event ModeUpdate dan TabUpdate tidak akan pernah dikirim ke plugin. Kita juga men-subscribe tiga jenis event: perubahan mode, perubahan tab, dan keypress. Ini menentukan "dunia mana" yang boleh dilihat widget kita.
handle_event adalah pusat logika. Setiap event masuk lewat parameter Event, dan kita mencocokkannya dengan match. ModeUpdate membawa mode_info yang berisi mode aktif Zellij; TabUpdate membawa daftar tab dan kita cari yang aktif; Key membawa keypress — di sini kita menghitung berapa kali tombol c ditekan. Semua hasil disimpan ke state struct.
render adalah panggungnya. Zellij memanggilnya setiap kali plugin perlu digambar ulang, dengan ukuran pane (rows dan cols). Kita menyusun satu baris teks dan menuliskannya dengan println!. Perhatikan bahwa println! mengirim ke stdout plugin, dan Zellij merender stdout itu sebagai layar pane — ini mekanisme rendering yang sama untuk semua plugin, termasuk strider dan status-bar.
Kode ini adalah plugin utuh, bukan potongan. Saat kalian memuatnya, Zellij memanggil load sekali, lalu render untuk menggambar baris pertama; setiap kali kalian berganti mode, berpindah tab, atau menekan c, handle_event memperbarui state dan render menggambar ulang. Amati reaksinya satu per satu — inilah cara terbaik untuk membangun intuisi tentang kapan tiap metode dipanggil, sebelum kalian pindah ke plugin yang lebih kompleks seperti yang akan kita lihat di sisa episode.
Important
Setiap println! menggambar ulang seluruh pane plugin — bukan menambahkan baris. Jangan mencetak daftar baris yang terus bertambah di dalam render; susun output agar pas dengan tinggi pane (argumen rows), atau widget kalian akan tampak "menumpuk". Selalu pikirkan render sebagai lukisan ulang penuh.
Sejauh ini plugin hanya menampilkan informasi. Sekarang kita buat plugin bereaksi — mengirim action kembali ke host. Action adalah cara plugin mengubah dunia Zellij: berpindah mode, menulis ke stdin pane, membuka pane baru. Fungsi-fungsi action sudah tersedia di prelude zellij_tile.
Mari perluas handle_event untuk menanggapi keypress dengan action:
fn handle_event(&mut self, event: Event) {
match event {
Event::Key(key) => match key {
Key::Char('t') => {
switch_to_mode(InputMode::Tab);
}
Key::Char('r') => {
write_to_stdin("clear\n".to_owned());
}
Key::Char('n') => {
open_command_pane(CommandToRun {
path: "htop".to_owned(),
args: vec![],
cwd: None,
});
}
_ => {}
},
_ => {}
}
}Tiga action ini mewakili tiga kategori yang akan sering kalian pakai. switch_to_mode(InputMode::Tab) mengirim action berpindah ke mode tab — persis seperti menekan Ctrl+t. write_to_stdin menuliskan teks ke stdin pane aktif, seperti mengetik di shell. open_command_pane membuka pane baru yang menjalankan perintah tertentu — di sini htop. Ketiganya adalah bukti bahwa plugin bisa menjadi kontrol nyata, bukan sekadar tampilan.
Perlu dicatat bahwa sebagian action membutuhkan permission tambahan. write_to_stdin dan open_command_pane, misalnya, tidak tercakup oleh ReadApplicationState — mereka menulis dan mengendalikan, bukan membaca. Jika action kalian diam-diam tidak berjalan, periksa kembali daftar request_permission di load: Zellij menolak action yang melebihi permission yang diberikan, dan kegagalannya sering kali tidak berisik. Kebiasaan memeriksa permission saat plugin "aneh" akan menyelamatkan kalian berkali-kali.
Tip
Kombinasikan keypress dengan state untuk membuat mode sendiri di dalam plugin. Misalnya, simpan sebuah boolean "arm" — jika a ditekan sekali, tekan n berikutnya membuka pane; jika tidak, n hanya menghitung. Dengan trik kecil ini, satu plugin bisa menampung banyak perilaku tanpa bertabrakan dengan keybinding Zellij lain.
Saatnya melihat hasil kerja. Pastikan state di src/lib.rs lengkap dengan load, handle_event, render, dan register_tile!, lalu bangun. Untuk iterasi cepat selama pengembangan, gunakan build debug; untuk plugin yang akan dipakai permanen, pakai release:
cargo buildcargo build --target wasm32-wasi --release
ls -lh target/wasm32-wasi/release/status_widget.wasmFile status_widget.wasm itulah plugin kalian. Sekarang muat ke session Zellij yang sedang berjalan:
zellij plugin load -f ./target/wasm32-wasi/release/status_widget.wasmSebuah pane baru muncul dengan widget status kalian — mode Zellij, tab aktif, dan penghitung tombol c. Ubah mode dengan Ctrl+t atau tekan c berulang kali, dan widget bereaksi seketika. Inilah siklus pengembangan plugin: ubah kode, cargo build, zellij plugin load -f lagi, lihat hasilnya. Tidak perlu me-restart Zellij.
Ketika plugin tidak berperilaku seperti yang diharapkan, dua alat debugging paling sederhana ada di tangan kalian. Pertama, eprintln! di dalam handle_event dan render — Zellij menulis output stderr plugin ke lognya sendiri. Kedua, periksa log tersebut di direktori cache Zellij (~/.cache/zellij/) atau jalankan Zellij dalam mode debug untuk melihat pesan yang lebih rinci. Menambahkan satu eprintln! pada baris pertama handle_event untuk mencetak event yang masuk sering kali langsung mengungkap asumsi yang salah.
Untuk penggunaan permanen, muat plugin lewat layout sehingga widget selalu muncul:
layout {
pane split_direction="vertical" {
pane
pane size="1" borderless=true {
plugin location="file:/home/devnull/projects/status-widget/target/wasm32-wasi/release/status_widget.wasm"
}
}
}Sekarang kita tambahkan satu eksperimen yang membuat widget terasa hidup: timer. Dengan set_timeout dan event Timer, plugin bisa memperbarui diri secara periodik tanpa input pengguna — fondasi untuk jam, metrik build, atau monitor apa pun:
fn load(&mut self, _config: BTreeMap<String, String>) {
request_permission(&[PermissionType::ReadApplicationState]);
subscribe(&[EventType::Timer, EventType::ModeUpdate]);
set_timeout(1.0);
}
fn handle_event(&mut self, event: Event) {
match event {
Event::Timer(_) => {
self.counter += 1;
set_timeout(1.0);
}
_ => {}
}
}Perhatikan bahwa set_timeout dipanggil ulang di dalam handler Timer. Timer di Zellij bersifat satu kali, jadi kalian harus menjadwalkan ulang setiap kali menerima event — pola ini menjaga plugin tetap diperbarui terus-menerus tanpa memboroskan resource.
Warning
Jangan jadwalkan timer berulang tanpa batas yang terlalu rapat. Setiap Timer memicu handle_event dan potensial render — dan setiap render menggambar ulang pane. Untuk widget yang hanya menampilkan teks, jeda 1 detik sudah lebih dari cukup; timer dengan jeda milidetik hanya akan membakar CPU.
wasm32-wasi belum terpasang. cargo build gagal dengan pesan target tidak ditemukan. Solusi: rustup target add wasm32-wasi sebelum memulai, dan kunci target di .cargo/config.toml.crate-type = ["cdylib"]. Plugin gagal dimuat karena tidak ada ekspor fungsi yang bisa dipanggil host. Solusi: pastikan [lib] crate-type = ["cdylib", "rlib"] ada di Cargo.toml.register_tile!. Kode Rust kalian benar, tetapi Zellij tidak menemukan pintu masuk plugin. Makro inilah yang mengekspor load, update, dan render — tanpa itu, plugin tidak pernah dijalankan.println! berulang tanpa memperhatikan rows dan cols membuat render menumpuk dan tampak rusak. Anggap setiap render sebagai lukisan ulang penuh dari layar kosong.Event::Key karena tidak men-subscribe EventType::Key. Periksa daftar subscribe di load — event yang tidak didaftarkan tidak akan pernah dikirim.Selamat — kalian baru saja membangun plugin Zellij pertama dan melihatnya berjalan di dalam session. Dari sekadar pemakai filepicker di episode 14, kalian kini berada di sisi lain: pembuat. Kalian telah melewati seluruh siklus pengembangan plugin — struktur crate, toolchain wasm32-wasi, trait ZellijTile, handle event, render UI, mengirim actions, timer — dan mendapatkan status widget yang benar-benar milik kalian.
Poin yang harus kalian bawa:
zellij-tile sebagai dependency dan crate-type = ["cdylib"].rustup target add wasm32-wasi dan target dikunci di .cargo/config.toml.ZellijTile punya tiga metode inti: load, handle_event, render.println! dan mengubah dunia lewat actions seperti switch_to_mode, write_to_stdin, open_command_pane.cargo build --target wasm32-wasi --release, muat dengan zellij plugin load -f — ulangi untuk iterasi cepat.set_timeout + event Timer membuat plugin hidup secara periodik.Kalian sekarang memiliki seluruh fondasi untuk membangun workspace yang sepenuhnya milik kalian. Di episode 17, kita melangkah ke Fase 5: session persistence & resurrection — menyimpan dan memulihkan session Zellij (layout, tab, pane, hingga perintah yang berjalan) secara otomatis, plus mengelola banyak session dengan session-manager. Sampai jumpa di episode 17.