Fission for Terminal UI developers

Terminal UI developers already care about keyboard-first flows, focus, layout constraints, logs, progress, and deterministic rendering. Those instincts fit Fission well.
The main shift is that terminal is one Fission shell, not a separate app architecture. The same state, reducers, widgets, semantics, and tests can also run through desktop, web, mobile, static, and SSR targets where appropriate.
This page uses the same todo app shape as the React guide: a checklist with a draft field, add/toggle actions, loading from an outside source, token-driven styling, routing, and tests.

The state ownership rule

Before translating syntax, decide where state belongs:
Use #[fission_component] and #[local_state] for retained UI memory owned by one widget identity.
Use GlobalState for product truth: todos, routing, persistence, sync state, user preferences, and shared filters.
Use reducers for named user intent and state transitions.
Use jobs, services, resources, capabilities, or runtime effects for outside work.
Add semantic identifiers to interactive widgets and meaningful regions so tests and accessibility tools can target product meaning instead of coordinates.

1. Static UI

{"id":"fission-tabs-1","tabs":[{"index":0,"label":"Terminal UI","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-1","index":0,"label":"Terminal UI","value":"source"}
pub struct App {
    todos: Vec<Todo>,
}

impl Widget for TodoView<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let block = Block::bordered().title("Ship checklist");
        let rows = self.todos.iter().map(|todo| {
            Line::from(vec![Span::raw(if todo.done { "Done" } else { "Open" }), Span::raw(&todo.title)])
        });
        Paragraph::new(rows.collect::<Vec<_>>()).block(block).render(area, buf);
    }
}
{"id":"fission-tabs-1","index":1,"label":"Fission","value":"fission"}
use fission::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Todo {
    pub id: u64,
    pub title: String,
    pub done: bool,
}

pub struct TodoApp;

impl From<TodoApp> for Widget {
    fn from(_app: TodoApp) -> Widget {
        let todos = vec![
            Todo { id: 1, title: "Write release notes".into(), done: false },
            Todo { id: 2, title: "Test Android package".into(), done: true },
        ];

        let rows = todos.into_iter().map(|todo| {
            let status = if todo.done { "Done" } else { "Open" };
            Row {
                gap: Some(12.0),
                children: widgets![Text::new(status), Text::new(todo.title)],
                ..Default::default()
            }
            .into()
        });

        Container::new(Column {
            gap: Some(12.0),
            children: std::iter::once(Text::new("Ship checklist").size(28.0).weight(800).into())
                .chain(rows)
                .collect(),
            ..Default::default()
        })
        .padding_all(24.0)
        .into()
    }
}
{"id":"fission-tabs-1"}
The Fission side is a concrete widget value. Conversion reads inputs and returns a widget tree; it is not the place to start outside work or hide product state.

2. Product state and interaction

{"id":"fission-tabs-2","tabs":[{"index":0,"label":"Terminal UI","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-2","index":0,"label":"Terminal UI","value":"source"}
match event::read()? {
    Event::Key(key) if key.code == KeyCode::Enter => app.add_draft_todo(),
    Event::Key(key) if key.code == KeyCode::Char(' ') => app.toggle_selected(),
    Event::Key(key) if key.code == KeyCode::Char(c) => app.draft.push(c),
    _ => {}
}
{"id":"fission-tabs-2","index":1,"label":"Fission","value":"fission"}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TodoState {
    pub draft: String,
    pub todos: Vec<Todo>,
    pub next_id: u64,
    pub current_path: String,
}

impl Default for TodoState {
    fn default() -> Self {
        Self {
            draft: String::new(),
            todos: Vec::new(),
            next_id: 1,
            current_path: "/".into(),
        }
    }
}

impl GlobalState for TodoState {}

#[fission_reducer(SetDraft)]
fn set_draft(state: &mut TodoState, draft: String) {
    state.draft = draft;
}

#[fission_reducer(AddDraftTodo)]
fn add_draft_todo(state: &mut TodoState) {
    let title = state.draft.trim();
    if title.is_empty() {
        return;
    }

    state.todos.push(Todo {
        id: state.next_id,
        title: title.to_string(),
        done: false,
    });
    state.next_id += 1;
    state.draft.clear();
}

#[fission_reducer(ToggleTodo)]
fn toggle_todo(state: &mut TodoState, id: u64) {
    if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == id) {
        todo.done = !todo.done;
    }
}
pub struct TodoScreen;

impl From<TodoScreen> for Widget {
    fn from(_screen: TodoScreen) -> Widget {
        let (ctx, view) = fission::build::current::<TodoState>();
        let update_draft = with_reducer!(ctx, SetDraft(String::new()), set_draft);
        let add_todo = with_reducer!(ctx, AddDraftTodo, add_draft_todo);

        let rows = view.state().todos.iter().map(|todo| {
            let toggle = with_reducer!(ctx, ToggleTodo(todo.id), toggle_todo);
            Row {
                gap: Some(12.0),
                children: widgets![
                    Checkbox {
                        checked: todo.done,
                        on_toggle: Some(toggle),
                        ..Default::default()
                    }
                    .semantics_identifier(format!("todo.toggle.{}", todo.id)),
                    Text::new(todo.title.clone()),
                ],
                ..Default::default()
            }
            .semantics_identifier(format!("todo.row.{}", todo.id))
            .into()
        });

        Column {
            gap: Some(16.0),
            children: widgets![
                Text::new("Ship checklist").size(28.0).weight(800),
                Row {
                    gap: Some(12.0),
                    children: widgets![
                        TextInput {
                            id: Some(WidgetId::explicit("todo-title")),
                            value: view.state().draft.clone(),
                            label: Some("New task".into()),
                            placeholder: Some("Add a task".into()),
                            on_change: Some(update_draft),
                            ..Default::default()
                        }
                        .semantics_identifier("todo.title"),
                        Button {
                            child: Some(Text::new("Add task").into()),
                            on_press: Some(add_todo),
                            ..Default::default()
                        }
                        .semantics_identifier("todo.add"),
                    ],
                    ..Default::default()
                },
                Column { gap: Some(10.0), children: rows.collect(), ..Default::default() },
            ],
            ..Default::default()
        }
        .into()
    }
}
{"id":"fission-tabs-2"}
Fission makes the state transition explicit. The button does not mutate a captured local variable; it dispatches a named action and the reducer updates product state.

3. Outside work

{"id":"fission-tabs-3","tabs":[{"index":0,"label":"Terminal UI","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-3","index":0,"label":"Terminal UI","value":"source"}
if app.should_load {
    let todos = repository.load_todos()?;
    app.todos = todos;
    app.should_load = false;
}
{"id":"fission-tabs-3","index":1,"label":"Fission","value":"fission"}
use fission::core::{JobRef, JobSpec};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LoadTodosRequest;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiError {
    pub message: String,
}

pub struct LoadTodosJob;

impl JobSpec for LoadTodosJob {
    type Request = LoadTodosRequest;
    type Ok = Vec<Todo>;
    type Err = ApiError;

    const NAME: &'static str = "todo.load";
}

pub const LOAD_TODOS_JOB: JobRef<LoadTodosJob> = JobRef::new(LoadTodosJob::NAME);
#[fission_reducer(LoadTodos)]
fn load_todos(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
    state.loading = true;
    ctx.effects
        .app(LOAD_TODOS_JOB, LoadTodosRequest)
        .on_ok(ctx.effects.bind(TodosLoaded, reduce_with!(todos_loaded)))
        .on_err(ctx.effects.bind(TodosFailed, reduce_with!(todos_failed)));
}
{"id":"fission-tabs-3"}
Do not perform outside work during component conversion. Reducers request work; jobs, services, resources, or capabilities perform it; results return as actions.

4. Styling and theme

{"id":"fission-tabs-4","tabs":[{"index":0,"label":"Terminal UI","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-4","index":0,"label":"Terminal UI","value":"source"}
Paragraph::new(todo.title.as_str())
    .style(Style::default().fg(Color::White).bg(Color::Black))
    .block(Block::bordered().border_style(Style::new().fg(Color::DarkGray)))
{"id":"fission-tabs-4","index":1,"label":"Fission","value":"fission"}
pub struct TodoRow {
    pub todo: Todo,
}

impl From<TodoRow> for Widget {
    fn from(row: TodoRow) -> Widget {
        let (ctx, view) = fission::build::current::<TodoState>();
        let tokens = &view.env().theme.tokens;
        let toggle = with_reducer!(ctx, ToggleTodo(row.todo.id), toggle_todo);

        Container::new(Row {
            gap: Some(tokens.spacing.s),
            children: widgets![
                Checkbox {
                    checked: row.todo.done,
                    on_toggle: Some(toggle),
                    ..Default::default()
                }
                .semantics_identifier(format!("todo.toggle.{}", row.todo.id)),
                Text::new(row.todo.title)
                    .size(tokens.typography.font_size_base)
                    .color(tokens.colors.text_primary),
            ],
            ..Default::default()
        })
        .bg(tokens.colors.surface)
        .border(tokens.colors.border, 1.0)
        .border_radius(tokens.radii.large)
        .padding([tokens.spacing.m, tokens.spacing.m, tokens.spacing.s, tokens.spacing.s])
        .into()
    }
}
{"id":"fission-tabs-4"}
In Fission, the active theme is runtime environment. Read token values from view.env().theme.tokens and apply them through widget fields. That keeps the UI target-lowerable across native, web, mobile, terminal, static, and SSR shells.

5. Routing

{"id":"fission-tabs-5","tabs":[{"index":0,"label":"Terminal UI","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-5","index":0,"label":"Terminal UI","value":"source"}
enum Screen {
    Todos,
    TodoDetail(u64),
    Settings,
}

app.screen = Screen::TodoDetail(selected_id);
{"id":"fission-tabs-5","index":1,"label":"Fission","value":"fission"}
use fission::widgets::{Route, Router};
use std::sync::Arc;

#[fission_reducer(NavigateTo)]
fn navigate_to(state: &mut TodoState, path: String) {
    state.current_path = path;
}

pub struct TodoRoutes;

impl From<TodoRoutes> for Widget {
    fn from(_routes: TodoRoutes) -> Widget {
        let (_, view) = fission::build::current::<TodoState>();

        Router::<TodoState> {
            current_path: view.state().current_path.clone(),
            routes: vec![
                Route {
                    path: "/".into(),
                    builder: Arc::new(|_, _, _| TodoScreen.into()),
                },
                Route {
                    path: "/todos/:id".into(),
                    builder: Arc::new(|_, _, params| TodoDetail {
                        id: params["id"].parse().unwrap_or(0),
                    }
                    .into()),
                },
            ],
            not_found: Some(Arc::new(|_, _, _| Text::new("Not found").into())),
        }
        .into()
    }
}
{"id":"fission-tabs-5"}
Use Fission's native Router and RouterParams. Shell URLs, deep links, static routes, server routes, and terminal route-like state should normalize into the same app-owned route state instead of each target inventing a different navigation model.

6. Testing

{"id":"fission-tabs-6","tabs":[{"index":0,"label":"Terminal UI","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-6","index":0,"label":"Terminal UI","value":"source"}
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
terminal.draw(|frame| render_app(frame, &app))?;
insta::assert_snapshot!(terminal.backend());
{"id":"fission-tabs-6","index":1,"label":"Fission","value":"fission"}
use fission_test_driver::{LiveTestClient, SelectorQuery};

#[test]
#[ignore]
fn user_can_add_a_todo_in_a_real_shell() {
    let control_port = reserve_control_port();
    let mut child = launch_todo_app(control_port);
    let client = LiveTestClient::connect(control_port);

    client.wait_for_ready(15_000).expect("app ready");
    client
        .fill_text_semantic_identifier("todo.title", "Publish release")
        .expect("fill draft");
    client
        .tap_semantic_identifier("todo.add")
        .expect("add todo");
    client
        .wait_for_visible(SelectorQuery::label("Publish release"), 5_000)
        .expect("todo visible");
    client
        .screenshot(".artifacts/screenshots/todos/added.png")
        .expect("screenshot");

    client.quit().expect("quit app");
    let _ = child.wait();
}
{"id":"fission-tabs-6"}
Keep fast reducer tests for business rules. Use headless fission-test for widget/layout/semantics assertions. Use LiveTestClient and /cmd for real shell input, focus, screenshots, selectors, and target behavior.

Vocabulary map

Terminal UI concept
Closest Fission concept
Important difference
App struct
GlobalState
Product truth stays visible and reducer-owned.
Event loop match
Bound actions and reducers
Raw input becomes named user intent.
Ratatui widget
Fission widget
The same widget model can lower to terminal and other shells.
Style
Design-system token fields
Terminal gets a target-specific theme without hard-coded duplicated sizes.
Screen enum
Fission Router or route state
Routing remains app-owned.
Snapshot test
fission-test and LiveTest screenshots
Use semantics and display output, not only terminal cell snapshots.

Common instincts to adjust

Do not build a terminal-only product model unless the product is truly terminal-only. Keep reusable state, reducers, resources, and widgets shared.
Do not hard-code every terminal dimension into the UI. Use responsive layout and target-aware theme tokens.
Keep the keyboard-first and deterministic-testing instincts. Add semantic identifiers so terminal workflows can be tested through meaning, not only cell coordinates.
Read Testing Fission apps for unit, headless integration, LiveTest, and /cmd workflows.
Read State, handles, and providers for the durable-state, local-state, provider, and environment decision model.
Read Resources and async for jobs, services, resources, capabilities, and reducer effects.
Fission
A cross-platform, GPU-accelerated user interface framework for Rust. MIT licensed.
Copyright (c) 2026 Fission
Ready to use today. Widget APIs are expected to remain stable; some runtime and shell APIs may change before 1.0.0.
Fission 0.9.0