Terminal user interfaces
Fission can render a normal Fission application inside a terminal. This is not a separate terminal UI framework bolted onto the side of the project. The terminal shell builds your widget tree, lowers it to Fission Core IR, computes layout, verifies that the result can be represented in a terminal, and then paints the frame into terminal cells.
That distinction matters. You still write state, actions, reducers, selectors, screens, routes, and widgets in the same style you would use for a desktop or mobile app. The terminal shell only changes the final presentation layer. It gives you a fast keyboard-first interface for command tools, dashboards, setup flows, diagnostics, and local developer workflows without making you learn a second application model.
When to choose the terminal shell
A terminal user interface is a good fit when the user is already working in a shell and the task benefits from a persistent interactive view. Running checks, choosing devices, selecting targets, following logs, editing a small set of settings, and launching build workflows are all better experiences when the user can see state, navigate between screens, and keep command output visible.
It is not the right target for highly visual interfaces, freeform graphics, media playback, canvas-heavy content, or flows that depend on exact pointer positioning. The terminal shell deliberately verifies the lowered output and reports unsupported Core IR instead of pretending every widget can be faithfully translated to cells.
Add the terminal shell feature
Use the Fission facade crate and enable the terminal shell feature for tools that need terminal rendering.
[dependencies]
fission = { version = "0.7.0", features = ["terminal-shell"] }
What you should have working
After this guide, you should be able to explain where Terminal user interfaces fits in the Fission lifecycle, identify the file or component you changed, run the relevant fission command, and verify the result in a real target or test.
Common mistakes to check
| |
|---|
The app compiles but nothing changes | Confirm the component is actually used by the route or shell target you are running. |
An action fires but state does not update | Confirm the reducer is registered with the same action value that the UI dispatches. |
A platform feature reports unsupported | Confirm the target has the capability in fission.toml and the shell registered the provider. |
The page works on one target but not another | Run fission doctor, then inspect target-specific configuration and feature flags. |
| Split the product rule into a reducer test first, then add a UI or shell smoke test for the integration. |
The public API is available through the facade, so app code should use fission::terminal::TerminalApp or import from the prelude when appropriate. You should not depend directly on the internal shell crate unless you are working on Fission itself.
A terminal app starts the same way as any other Fission app: define state, define actions, write reducers, and implement widgets.
use fission::prelude::*;
#[derive(Debug, Default, Clone, PartialEq)]
struct ToolState {
selected: usize,
logs: Vec<String>,
}
impl GlobalState for ToolState {}
#[fission_reducer(SelectScreen)]
fn select_screen(state: &mut ToolState, selected: usize) {
state.selected = selected;
}
#[derive(Clone)]
struct ToolApp;
impl From<ToolApp> for Widget {
fn from(component: ToolApp) -> Self {
let (ctx, view) = fission::build::current::<ToolState>();
let dashboard = with_reducer!(ctx, SelectScreen(0), select_screen);
let settings = with_reducer!(ctx, SelectScreen(1), select_screen);
Row {
children: vec![
Column {
children: vec![
Button { on_press: Some(dashboard), child: Some(Text::new("Dashboard").into()), ..Default::default() }.into(),
Button { on_press: Some(settings), child: Some(Text::new("Settings").into()), ..Default::default() }.into(),
],
..Default::default()
}.into(),
Text::new(format!("Selected screen: {}", view.state().selected)).into(),
],
..Default::default()
}.into()
}
}
fn main() -> anyhow::Result<()> {
fission::terminal::TerminalApp::with_state(ToolApp, ToolState::default())
.with_title("My Tool")
.run()
}
Real applications should not keep everything in one file. Follow the same structure you would use for a production Fission app: keep state in a state module, reducers in an actions module, screens in a screens module, reusable widgets in a components module, and route selection in a routes module.
Use the Fission command as the reference example
The interactive Fission command is a terminal Fission app. Running fission ui --project-dir . opens an app built from normal Fission widgets and hosted by TerminalApp.
The CLI UI is organised around the same patterns recommended for product applications:
| |
|---|
| Holds project state, selected route, selected target, devices, command output, theme mode, density, and settings. |
| Defines the screens the terminal app can show. |
| Defines actions and reducers for navigation, settings, toggles, and command execution. |
| Contains the route-level widgets such as dashboard, project setup, run, build, logs, and settings. |
| Contains reusable widgets such as the shell chrome, output panel, controls, and data tables. |
| Runs command-line workflows without blocking the UI and stores bounded scrollback. |
This is the important lesson from the CLI: a terminal app should still be a real app. Avoid writing one giant render function that switches over strings and hand-assembles terminal rows. Use typed state and widgets so the app remains testable, refactorable, and consistent with the rest of Fission.
Keep long-running work out of the frame loop
Terminal tools often run slow commands: build, test, doctor, deploy, logs, and server processes. Do not run that work directly inside component conversion and do not block the terminal event loop while a command is running.
The CLI starts command work on a background thread, stores progress in shared command state, and lets the terminal app poll for new output between input events. That keeps the interface responsive while the command runs. The visible output panel is just another widget reading app state.
A good command workflow has three parts:
Dispatch an action when the user presses a button or selects a command.
Start the command outside component conversion and immediately return control to the UI.
Append output to bounded scrollback and request a redraw when new data arrives.
That pattern keeps keyboard navigation, mouse clicks, scrolling, theme changes, and settings usable while the command is still running.
Terminal logs can grow forever. A production terminal app must not keep appending to an unbounded String until it exhausts memory.
The CLI uses a bounded ring buffer for command output. The default scrollback is 100,000 lines, and the settings screen lets the user change that value. Older lines are discarded once the limit is reached. This follows the same practical approach used by mature terminal applications: keep enough history for real debugging, but bound memory growth so long-running sessions remain predictable.
If your terminal app displays logs, traces, or streaming output, treat scrollback as a setting rather than an implementation detail. Decide the default, explain it in the UI, and make sure changing it trims existing output safely.
Terminal apps should work without a mouse, but they do not have to reject mouse input. The terminal shell forwards keyboard events, pointer events, and scroll events into the normal Fission input pipeline where the terminal supports them.
That means buttons can be clicked, scroll areas can receive wheel events, and keyboard navigation can remain the reliable baseline. Design the app so every important command is reachable from the keyboard, then add pointer support to make the experience feel natural in modern terminals.
Use density settings for terminal screens
A terminal has far fewer layout rows than a desktop window has pixels. Comfortable desktop spacing can feel huge in a terminal because every point maps to cell-sized space.
The CLI defaults to compact density for this reason. Buttons, navigation rows, headers, and text inputs are shorter by default, while a settings page lets users switch back to more comfortable spacing. If you build a terminal app with dense information, add an explicit density setting instead of forcing one layout on every terminal size.
What the terminal shell verifies
The terminal shell verifies the lowered Core IR before rendering. Unsupported graphical paint operations are rejected rather than silently turned into misleading terminal output. This keeps the shell honest: if an interface cannot be represented as text, boxes, borders, and terminal-compatible color, the shell should tell you early.
The support decision is based on the generated IR and semantics, not hard-coded widget names. That is why the same widget may be acceptable in one configuration and unsupported in another if the lowered output changes.
Testing terminal apps
At minimum, add tests that render every route of your terminal app through TerminalApp::render_frame(...). That catches unsupported output and layout regressions without needing a live terminal session.
The CLI has a route smoke test that creates the UI state, renders each route, and fails if the terminal shell cannot lower, layout, verify, and render it. Use that as the baseline. For command flows, test reducers and command-state updates separately so slow platform work does not make UI tests flaky.
Where to go next
Read Platform shells, command-line interface, and testing for the broader shell model. Read Testing and diagnostics for the general testing strategy. If you want to build static documentation or marketing sites instead of terminal tools, continue to Static sites. If the site needs request-time data, sessions, signed actions, cache revalidation, workers, or islands, continue to Server-rendered sites.