Skip to content

Full-screen & inline UIs

A terminal UI can become the user’s workspace or help them finish one step in a command. Fleury supports both: full-screen uses the terminal viewport; inline reserves a smaller region beneath the current cursor.

The examples below run a real Fleury form inside illustrated terminal buffers. In the full-screen tab, run the command to watch the form take over. Finish or cancel to return to the shell; Run again resets the browser illustration.

Live · interactive
click & type to interact ⓘ how this demo runs

Notice how the earlier output stays above the form. Review grows the live region; finishing clears it and leaves a lasting summary.

The form uses the terminal’s own text and background colors, with accents and focus highlights. Inline mode sets the UI’s space; it doesn’t require a background fill or a particular color theme.

Full-screenInline
Good fitEditors, dashboards, file managers, multi-pane workspacesPickers, setup forms, confirmations, bounded progress displays
SpaceThe terminal’s width and heightFull width and an explicit number of rows, clamped to the terminal height
Earlier shell outputHidden while the app runs; restored on exitRemains in the main buffer; reserving space can move it into scrollback
When Fleury exitsRestores the previous screenClears the live region
Lasting resultPrint after awaiting runAppPrint after awaiting runApp

Choose full-screen when users need room to explore, navigate between views, or keep several things in view. Choose inline when the UI is one step in a shell workflow and the surrounding output provides useful context. Duration alone doesn’t decide it: a compact status display can run for a long time.

For ordinary command output, a report, or a single text prompt, plain CLI output may be enough. Before you design around inline mode, check what it doesn’t support yet.

Most modern terminal emulators provide a main screen buffer, where your shell and command output live, and an alternate screen buffer for interactive apps. Entering the alternate screen hides the main screen; leaving it reveals the previous contents. This is the mechanism behind Fleury’s default full-screen mode. It fills the terminal viewport, not the desktop display.

Inline stays in the main buffer. Fleury asks the terminal for the cursor’s position, reserves rows there, and confines painting and clearing to that region. It translates mouse and text-caret positions into the widget tree’s coordinates. The widgets receive a smaller viewport and use the same layout and input APIs.

Full-screen
Before · main bufferEarlier output
$ project-setup
Alternate bufferProject setup
[ Your interactive UI ]
Main buffer restoredEarlier output
$ project-setup
Setup complete.
$ ▌
Inline
Before · main bufferEarlier output
$ project-setup
Main buffer + owned regionEarlier output
$ project-setup
[ Your interactive UI ]
Live region clearedEarlier output
$ project-setup
Setup complete.
$ ▌
In both cases, the command prints “Setup complete.” after the UI exits.

Screen placement is separate from input handling. Both modes use raw input so Fleury can react to individual keys, manage focus, and edit text. The shell waits while the app runs. Mouse reporting is optional in both modes; enabling it affects native terminal selection throughout the window. Terminal-specific selection modifiers and scrollback behavior still apply.

The alternate screen normally has no scrollback of its own. A ScrollView or ListView scrolls content inside your UI, independently of terminal scrollback. Redrawing frames does not create a durable history of the interaction.

Use the same form in either mode. Inline asks for a row count; full-screen uses the terminal’s size. Mouse input is optional in both.

import 'package:fleury/fleury.dart';
import 'package:fleury_samples/samples.dart';
Future<void> main() async {
await runApp(
FleuryApp(
title: 'Project setup',
home: InlineSetup(onComplete: (_) => exitApp()),
),
mode: const TerminalMode.inline(rows: 21, mouse: true),
enableHotReload: false,
);
}

InlineSetup is the sample form used above. These commands disable hot reload so their startup and completion code run once (why).

The form calls exitApp() when it finishes. Fleury removes the UI, restores terminal input, and completes runApp. Print the command’s result after awaiting it so the summary stays in the terminal.

Shutdown and signals shows cleanup and interrupt handling, including how to preserve the command’s exit status.

Choose Review →: the form requests 21 rows, up from 17. Choose Back to shrink it again. The earlier output stays above the live region.

Live · interactive Ready
~/projects $ project-setup

runApp provides the terminal session automatically. Read it from the build context, then request a new height from a callback:

View resize.dart
import 'package:fleury/fleury.dart';
import 'package:fleury_samples/samples.dart';
Future<void> main() async {
await runApp(
const FleuryApp(title: 'Project setup', home: ResizingSetup()),
mode: const TerminalMode.inline(rows: 17, mouse: true),
enableHotReload: false,
);
}
class ResizingSetup extends StatelessWidget {
const ResizingSetup({super.key});
@override
Widget build(BuildContext context) {
final session = context.scope<TerminalSession>();
return InlineSetup(
onComplete: (_) => exitApp(),
onStepChanged: (step) async {
await session.resizeInline(step.rows);
},
);
}
}

The browser resizes the host element around the real form. In a terminal, session.resizeInline(rows) reallocates the live region and repaints it.

Run setup, finish the form, then type y at the text prompt to open a fresh form. Complete that one and answer n to finish the command. Both summaries stay in the output.

Live · interactive Ready
~/projects $ project-setup --repeat

Each form here is a separate Fleury browser mount. Between forms, the page owns the text prompt. The native equivalent awaits runApp, reads ordinary CLI input, then calls runApp again:

View repeat.dart
import 'dart:io';
import 'package:fleury/fleury.dart';
import 'package:fleury_samples/samples.dart';
Future<void> main() => repeatSetup();
Future<void> repeatSetup() async {
while (true) {
InlineSetupResult? result;
final outcome = await runApp(
FleuryApp(
title: 'Project setup',
home: InlineSetup(
onComplete: (value) {
result = value;
exitApp();
},
),
),
mode: const TerminalMode.inline(rows: 21, mouse: true),
enableHotReload: false,
);
if (outcome.signal case final signal?) {
exitCode = switch (signal) {
AppSignal.interrupt => 130,
AppSignal.terminate => 143,
AppSignal.hangup => 129,
};
return;
}
if (result == null) return;
stdout.writeln(result!.summary);
stdout.write('Open setup again? [y/N] ');
await stdout.flush();
if (stdin.readLineSync()?.trim().toLowerCase() != 'y') return;
}
}

Try the native flow from the packages/samples directory of a Fleury checkout:

Terminal window
cd packages/samples
dart run bin/samples.dart inline --repeat

Await each UI before opening the next. Keep shared data in the command’s host; each UI starts fresh. To return to the same UI after another program runs, use handoff.

On macOS and Linux, runApp captures stray output by default while it owns the terminal and replays the retained output after exit. Calling print during the UI is not a way to append permanent lines above it. Render live messages as widgets; print the command’s final result after awaiting runApp.

Suppose the setup form offers View in pager. Selecting it opens the generated configuration in less, a separate terminal pager. Pressing q closes it and brings you back to the same form, with your name, template, and review step preserved.

This is a recording of the real native app launching less, not a browser simulation. Try it yourself from packages/samples:

Terminal window
dart run bin/samples.dart inline --handoff

Choose Review →, then View in pager. The preview uses a temporary file and removes it when the pager closes; no project files are created. Add --full-screen to try the same handoff from the alternate screen.

runWithHandoff makes this work by temporarily giving the child control of the terminal. Fleury pauses its painting and output capture, restores normal input, and releases its screen. Without the handoff, the two programs would compete for input and the child’s output could disappear into Fleury’s captured logs. When the child exits, Fleury reclaims the terminal and redraws the existing UI.

await session.runWithHandoff(() async {
final pager = await Process.start(
'less', ['--', previewFile.path],
mode: ProcessStartMode.inheritStdio,
);
await pager.exitCode;
});

The native host reads session from context.scope<TerminalSession>() and creates previewFile before the handoff. The form itself needs no process or terminal code. This also works for an editor or an interactive Git command. In inline mode, child output can remain above the resumed UI; full-screen mode re-enters the alternate screen. On macOS and Linux, a Ctrl+Z that nothing in the app handles suspends the app through the same release-and-resume lifecycle, and fg brings it back. That happens only when a job-control shell, such as your interactive shell, started the app, since only the shell can bring it back. A terminal profile, tmux pane, or ssh -t host app that runs the app directly leaves Ctrl+Z an ordinary key. A few launchers look like a shell’s job without being one, such as fish’s exec app in a macOS terminal tab; there a suspended app stays stopped until you continue its process group from another terminal: kill -CONT -- -<pid>, with the pid of the process the launcher started.

Your code can ask for the same suspension with session.suspend(): for example, from a key of its own when a focused text field takes every Ctrl+Z for undo (see Key handling). The returned future completes once fg has brought the app back. Where the app isn’t a job the shell can suspend (no job-control shell started it, or it runs under fleury serve, fleury shell, or Windows), session.supportsSuspend is false and suspend() does nothing.

The browser examples illustrate the buffers around a real shared Fleury form. They cannot exercise a terminal emulator’s cursor reports, scrollback, or raw input. From packages/samples in a Fleury checkout, compare the actual host modes:

Terminal window
dart run bin/samples.dart inline
dart run bin/samples.dart inline --full-screen

Neither command writes project files. Resize the terminal during review, go back, and finish. Earlier output should remain available and ordinary shell input should work again. This recording captures the inline run on a native PTY:

Inline mode currently runs on macOS and Linux. It needs terminal input/output and cursor position reporting. If the terminal cannot report its cursor position, Fleury restores its modes and reports the error.

Inline mode does not yet support:

  • Natural content height. The UI asks for an explicit number of rows and can change it with session.resizeInline.
  • Permanent lines above a running region, such as a transcript that grows above a live prompt. Render live messages as widgets, and print lasting output after runApp returns.
  • Keeping the final frame. Fleury clears the live region on exit, so print the command’s result after awaiting runApp.
  • Native image protocols. Images fall back to glyph rendering.

When exit overlaps a resize, Fleury gets a fresh cursor report before clearing the region. If the terminal stops answering, cleanup still restores terminal modes and leaves uncertain rows alone to protect earlier output.

Browser embeds take their size from the containing element. A served native preview also has a host-controlled viewport; neither demonstrates a native inline allocation. See Serving and embedding.