Skip to content

TUI: adopt a consistent layout and keybinding baseline #87

Description

@jimisola

The TUI works, but it does not read as one application. Each screen invented its own header height, its own footer format, and its own key set; navigation keys were hand-rolled as literal characters instead of going through TamboUI's binding layer; and the same key means different things on different screens. This issue collects the specific defects, a baseline drawn from TamboUI's own code and from established TUIs, and an ordered plan.

Everything below was checked against the code on main/feat/lst-daemon-11 and against sources I actually fetched (listed at the end).


1. What's wrong today

1.1 Vim navigation is hand-coded, against the framework's own default

Every list screen re-implements j/k by testing raw characters:

  • atunko-tui/src/main/java/io/github/atunkodev/tui/view/BrowserView.java:162-169if (event.isDown() || event.isChar('j'))
  • .../view/ConfirmRunView.java:151-156
  • .../view/LoadConfigView.java:87-92
  • .../view/RecipeOptionsView.java:155-160
  • .../view/TagBrowserView.java:130-135 and :147-152
  • .../view/ExecutionResultsView.java:164-169

TamboUI's default binding set is deliberately not vim. From dev/tamboui/tui/bindings/BindingSets.java in tamboui-tui-0.4.0-sources.jar:

standard() — Arrow keys only, no vim/emacs bindings (default) […] No vim or emacs-style bindings. This is the default set, safe for use with text input (no letter keys bound to navigation).

and standard.properties in the same artifact is exactly moveUp = Up, moveDown = Down, select = Enter, Space, cancel = Escape, focusNext = Tab, focusPrevious = Shift+Tab, quit = q, Q, Ctrl+c. A separate vim.properties (moveUp = Up, k, K, …) exists precisely so that apps that want vim opt in. atunko opts in by hand, in seven places, and the owner does not want vim bindings.

event.isUp()/isDown() already delegate to the configured bindings (KeyEvent.java:388-398, return matches(Actions.MOVE_UP)), so the || event.isChar('j') half is pure added vim. Similarly BrowserView.java:229 is event.isQuit() || event.isChar('q')isQuit() already covers q, Q and Ctrl+C.

1.2 No central binding configuration exists

AtunkoTui.java:76-79 builds the runner without .bindings(...):

try (ToolkitRunner r = ToolkitRunner.builder()
        .config(configure())
        .styleEngine(styleEngine)
        .build()) {

ToolkitRunner.Builder.bindings(Bindings) exists (ToolkitRunner.java:505 in the 0.4.0 sources) and is the intended place to declare an app-wide key map. Because atunko never uses it, there is no single list of what the app binds — the answer is spread across 65 isChar(...) calls across ten view classes.

Worth noting: .bindings() used to be silently ignored (tamboui#382). That was fixed by tamboui#383, merged 2026-06-18 20:06 UTC, and v0.4.0 was released 2026-06-18 22:09 UTC — so the version atunko already depends on (gradle/libs.versions.toml:5, tamboui = "0.4.0") has the fix. This is safe to adopt now.

(Aside: CLAUDE.md still says TamboUI 0.2.0-SNAPSHOT under "Key Dependencies". That's two minor versions stale.)

1.3 Tab and Shift-Tab are dead keys, and there is no panel focus model

Each screen marks exactly one element focusable — the root column (BrowserView.java:72, ConfirmRunView.java:71, DetailView.java:122, ExecutionResultsView.java:63,100,143, LoadConfigView.java:61, RecipeOptionsView.java:64, TagBrowserView.java:87, FileDiffView.java:68, ExportConfigView.java:56). The search input is explicitly opted out (BrowserView.java:293, .focusable(false)), as is the tag filter input (TagBrowserView.java:70).

The browser is a genuine two-pane screen — recipe list plus detail pane (BrowserView.java:52) — but there is nothing to move focus between them and nothing rendering which pane is active. focusNext/focusPrevious are bound by default to Tab/Shift-Tab and go nowhere. The one screen where Tab does something is ExportConfigView.java:69, where it toggles Minimal/Full — a meaning that exists on exactly one screen.

Neither themes/dark.tcss nor themes/light.tcss has a focus rule; the only highlight styling is the list row cursor (dark.tcss:69).

1.4 Help is not reachable from most screens

? is handled in three of ten screens: BrowserView.java:258, ConfirmRunView.java:146, DetailView.java:128. It is not handled in TagBrowserView, ExecutionResultsView, FileDiffView, LoadConfigView, RecipeOptionsView or ExportConfigView, and those screens' footers do not mention it either (FileDiffView.java:65 is the whole footer: " Esc/q:back").

1.5 The help "overlay" is not an overlay, and eats the next keystroke

BrowserView.java:48-54 and ConfirmRunView.java:39-41 replace the center content with the help panel. The recipe list disappears while you read about it, which is the opposite of what help is for.

Then BrowserView.java:108-111:

if (controller.isShowHelp()) {
    controller.toggleHelp();
    return EventResult.HANDLED;
}

Any key closes help and is swallowed. Read the help, learn that r opens the run dialog, press r — help closes and nothing else happens.

TamboUI supports real overlays: the Clear widget exists to blank an area "before rendering overlays" (widgets reference), and the filemanager demo renders its dialogs on top of the finished UI tree rather than in place of it (FileManagerView.java:63-75).

1.6 Key hints are hardcoded strings, triplicated, and already wrong

The same binding is written out in the footer, in HelpOverlay, in README.md and in docs/antora/.../cli.adoc. They have drifted:

  • README.md:101 — "a | Cycle selection (none → all → none)". The code has a = select all and A = deselect all (BrowserView.java:178-185); cycleSelection() is not bound to anything in the browser.
  • README.md:106 — "/c | Collapse composite recipe". c is not bound in the browser (BrowserView.java:217 accepts only and <). It is bound in the run dialog (ConfirmRunView.java:183).
  • BrowserView.java:369 — the browser status bar advertises o:options ?:help q:quit. Roughly a dozen other browser bindings are invisible unless the user finds ?.
  • There is no TUI page in docs/antora/modules/ROOT/pages/ at all; the binding tables live inside cli.adoc:71-152.

TamboUI upstream has an open issue about exactly this duplication — tamboui#168, "Reverse bindings and display":

In many demos, we actually use hardcoded strings showing [q] Quit or [F1] Something. However, these are directly related to the Bindings in use. There should be a way to get these descriptions from the bindings rather than duplicate.

1.7 The same key means different things on different screens

  • f = toggle favorite in the browser (BrowserView.java:250), = flatten composite in the run dialog (ConfirmRunView.java:187).
  • F = cycle favorites filter (BrowserView.java:254), = flatten all (ConfirmRunView.java:191).
  • e = expand (BrowserView.java:209) while E = expand all and W = collapse all (BrowserView.java:221-228). W has no mnemonic connection to "collapse"; it is E's neighbour on the keyboard.
  • Case-sensitive pairs carry meaning throughout — a/A, f/F, n/N, E/W, S/L. Shift-as-modifier is invisible in a footer that renders a and A identically to a fast reader.
  • Ctrl+↑/Ctrl+↓ reorder in the run dialog (ConfirmRunView.java:159-165) — a chord where +/- already exist for the same action.

1.8 Chrome is inconsistent screen to screen

  • Header height: the browser docks a 3-line header (BrowserView.java:66, Constraint.length(3)); every other screen docks 1 (ConfirmRunView.java:65, ExecutionResultsView.java:133, TagBrowserView.java:84, …). The title bar jumps two rows on every navigation.
  • Footer formatting is ad hoc: " Esc/q:back" (FileDiffView.java:65), " j/k:navigate Enter:load Esc/q:back" (LoadConfigView.java:54), " g:Gradle m:Maven Tab:Minimal/Full Esc:close" (ExportConfigView.java:51), " ↑↓/jk:navigate Enter:diff Esc/q:back" (ExecutionResultsView.java:130), "… o:options f:flatten F:flatten-all x:export ?:help Esc:back" (ConfirmRunView.java:57). Different separators, different orderings, key:action with no visual distinction between the key and its label.
  • Two overlays (RecipeOptionsView, ExportConfigView) are dispatched by boolean flags checked at the top of two different render methods (BrowserView.java:41-43, ConfirmRunView.java:27-32) rather than being part of the Screen enum (Screen.java), so "what is on screen" has two competing sources of truth.

2. Best-practice baseline

Marked [TamboUI] where it is framework idiom and [general] where it is a cross-ecosystem TUI convention.

2.1 Layout

Convention Source
One dock() per screen: .top(header, length(1)), .center(content), .bottom(keyHints, length(1)) — same heights on every screen [TamboUI] filemanager demo, FileManagerView.java:63-66: dock().top(header(), Constraint.length(1)).center(browserRow(context)).bottom(helpBar(), Constraint.length(1)); jtop demo JTopDemo.java:63-71 uses the same shape
Footer is context-sensitive: it shows information about the current selection and the keys valid right now [TamboUI] FileManagerView.java:319-336row(text(info).fill(), text("[Enter] Open [v] View [Backspace] Up [+] Mark All [-] Unmark").dim())
Key hints styled so key and label are visually separate (key bright/bold, label dim) [TamboUI] jbang's TamboUI search UI, ArtifactSearchWidget.java:241-260: text(" ↑↓").yellow().bold(), text(" nav ").dim(), text("⏎").yellow().bold(), text(" select ").dim(), …
Dialogs/overlays render on top of the screen, not instead of it [TamboUI] FileManagerView.java:69-75 renders the dialog after the main tree; Clear widget documented for exactly this
Persistent focus indication when more than one pane can take focus [general] lazygit's multi-panel model; Textual's footer/focus model
? opens a keybindings screen, from anywhere [general] k9s: "? — Show active keyboard mnemonics and help"; lazygit Global section: "? Open keybindings menu"

2.2 Bindings

Declare one Bindings object from BindingSets.standard() and handle events with event.matches(ACTION)[TamboUI], and exactly what jbang (Max Andersen's own TamboUI consumer) does in ArtifactSearchWidget.java:114-124:

Bindings bindings = BindingSets.standard()
    .toBuilder()
    .unbind(Actions.FOCUS_NEXT)
    .unbind(Actions.FOCUS_PREVIOUS)
    .unbind(Actions.QUIT)
    .bind(KeyTrigger.ctrl('c'), Actions.QUIT)
    .bind(KeyTrigger.key(KeyCode.TAB), ACTION_SEARCH_CENTRAL)
    .bind(KeyTrigger.key(KeyCode.F5), ACTION_SEARCH_CENTRAL)
    .build();

ToolkitRunner.Builder builder = ToolkitRunner.builder().bindings(bindings);

Note what is absent: no j, no k, no hjkl, no chords beyond Ctrl+C. Arrows, Enter, Esc, Tab, F5.

Proposed baseline for atunko — all of it satisfies the no-vim/no-emacs constraint:

Key Action Scope Rationale
Move within list global standard.properties [TamboUI]
PgUp PgDn Home End Page / jump global standard.properties; currently unbound anywhere in atunko [TamboUI]
Expand composite lists already bound; keep
Collapse composite lists already bound; keep
Tab / Shift+Tab Move focus between panes global focusNext/focusPrevious [TamboUI]; makes the browser's two panes real
Enter Primary action (open detail / load / confirm) global confirm [TamboUI]
Space Toggle selection lists select [TamboUI]
Esc Back / cancel — one level at a time global cancel [TamboUI]; today Esc in the browser clears everything (TuiController.java:779-787), which is not a cancel
? or F1 Keybindings screen every screen k9s, lazygit [general]
q / Ctrl+C Quit global standard.properties quit [TamboUI]
/ Search / filter browser, tag browser k9s /, near-universal [general]
F2F8 Screen-level verbs: run, dry-run, options, save, load, export, tags per screen function keys are self-documenting in a footer and never collide with typing; the filemanager demo and jbang both use them
single letters only as additional accelerators, never the sole way to reach an action, and never case-sensitive pairs [general]

Two consequences worth calling out:

  • Retire case-sensitive pairs. a/A, f/F, E/W, n/N, S/L should become distinct, spelled-out actions in the help screen with non-shifted or function-key accelerators.
  • Same verb, same key, every screen. f cannot be "favorite" here and "flatten" there.

2.3 Discoverability

The strong pattern across ecosystems is that key hints are generated from the binding table, not typed twice:

  • Textual: "The Footer widget can inspect bindings to display available keys"; each binding carries "a short human readable description" [general]
  • Bubble Tea / bubbles help: "A customizable horizontal mini help view that automatically generates itself from your keybindings", with short and full modes; key.Binding pairs WithKeys(...) and WithHelp(...) [general]
  • TamboUI has no such widget yet, and knows it — tamboui#168 [TamboUI]

So atunko should build the small version of this itself: one binding registry per screen, with a description per entry; the footer renders a subset, the ? screen renders all of it. That kills the drift in §1.6 by construction.


3. Proposed changes

Cheap wins (no architectural change)

  1. Drop the hand-rolled vim keys. Delete every || event.isChar('j') / isChar('k') and the redundant || event.isChar('q').
    BrowserView.java:162-169,229, ConfirmRunView.java:151-156, LoadConfigView.java:87-92, RecipeOptionsView.java:155-160, TagBrowserView.java:130-135,147-152, ExecutionResultsView.java:164-169
  2. Make ? work on every screen, and add ?:help to every footer.
    TagBrowserView, ExecutionResultsView, FileDiffView, LoadConfigView, RecipeOptionsView, ExportConfigView + HelpOverlay.java (needs the missing per-screen sections)
  3. Stop swallowing the keystroke that closes help. Close on Esc/? only; let other keys fall through to the normal handler.
    BrowserView.java:108-111, ConfirmRunView.java:146
  4. Unify the header to Constraint.length(1) so the title bar stops jumping.
    BrowserView.java:66
  5. One footer helper — a single KeyHints.render(List<Entry>) producing bright-key/dim-label pairs in the jbang style, used by all ten screens.
    new .../view/KeyHints.java; call sites in all view/*.java
  6. Fix the drifted docs: README.md:98-126 and docs/antora/modules/ROOT/pages/cli.adoc:71-152 (a, c, and the j/k column all wrong once (1) lands). A dedicated docs/antora/modules/ROOT/pages/tui.adoc would be the right home.
  7. Update CLAUDE.md "Key Dependencies" — TamboUI is 0.4.0, not 0.2.0-SNAPSHOT.

Larger restructuring

  1. Introduce an app-wide Bindings from BindingSets.standard(), wired through ToolkitRunner.builder().bindings(...), with atunko's own action constants (atunko.run, atunko.dryRun, atunko.options, atunko.flatten, …). Views switch from isChar('x') to event.matches(Actions.RUN).
    AtunkoTui.java:74-85 + new .../tui/AtunkoBindings.java + every view/*.java
  2. A binding registry with descriptions, one per screen, as the single source for (a) dispatch, (b) the footer, (c) the ? screen. Replaces the hand-written HelpOverlay tables.
    HelpOverlay.java (rewritten) + AtunkoBindings.java
  3. Resolve the cross-screen collisions using the table in §2.2: f/F split, E/W retired, Ctrl+↑/↓ retired in favour of +/-, function keys for screen verbs.
    BrowserView.java:178-270, ConfirmRunView.java:146-216
  4. Real focus model in the browser: make the recipe list and the detail pane independently focusable, wire Tab/Shift-Tab, and add a focus style to themes/dark.tcss / themes/light.tcss.
    BrowserView.java:52,72, RecipeListRenderer.java, both .tcss files
  5. Render overlays as overlays (help, recipe options, export) on top of the current screen instead of replacing the center, and fold them into Screen or an explicit overlay stack so there is one source of truth for what is displayed.
    BrowserView.java:41-54, ConfirmRunView.java:27-41, Screen.java, AtunkoTui.java:45-56
  6. Bind the currently unreachable navigation: PgUp/PgDn/Home/End on every list.
    TuiController.java (new page/jump methods) + list views

Per project convention this needs an OpenSpec change proposed and approved before any of it is implemented.


4. Explicitly out of scope

  • Vim-style bindings — no hjkl, no gg/G, no : command line, no modal normal/insert distinction. BindingSets.vim() will not be used.
  • Emacs-style bindings — no Ctrl+n/Ctrl+p/Ctrl+f/Ctrl+b, no Ctrl+g. BindingSets.emacs() will not be used.
  • Chorded or multi-key sequences generally. Ctrl+C for quit is the one accepted exception because it is a terminal convention, not an app binding.

The existing j/k bindings are the concrete violation and are removed by item (1).


5. How much real-world TamboUI usage exists — honestly

More than I expected, but the sample is still small and none of it is a published style guide.

Found and read:

  • TamboUI's own demos: filemanager-demo (best layout exemplar), jtop-demo, action-handler-demo — the last shows the @OnAction annotation route
  • jbang deps search (dev.jbang.search.ArtifactSearchWidget) — Max Andersen's own consumer, and the single best model for what atunko should do with bindings
  • Apache Maven Pilot — a TamboUI TUI; the announcement blog confirms it exists and uses context-sensitive help on every screen (bound to h), but shows no layout code
  • Apache Camel TUI — announced, TamboUI-based
  • Spring Initializr TUI (Dan Vega) — a TamboUI consumer; the write-up describes keys (Enter, c, x, Tab, Esc) but no layout code

Did not find:

  • Any showcase/gallery page or layout/keybinding style guide in the TamboUI docs. The docs index has bindings.html and layouts.html but no "how to structure an app screen" guidance and no key-hints or footer widget in the widget reference.
  • Any Max Andersen post specifically about TUI layout or keybinding design. His announcement post covers motivation and architecture only; the Devoxx France 2026 talk "TamboUI: making 2026 the Year of Java in the Terminal" exists in the schedule but I found no recording or slides. I did not find LinkedIn/Mastodon/Bluesky posts on the topic. What I did find that is directly useful is his code (jbang) and his bug report (tamboui#382), both cited above.

So the §2 baseline is deliberately weighted: TamboUI's own demos and jbang for framework idiom, and k9s / lazygit / Textual / Bubble Tea for the general conventions that TamboUI has no opinion on yet.


6. Sources

TamboUI project:

TamboUI consumers:

General TUI prior art:

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions