Build a terminal-based OpenFGA dashboard using OpenTUI (@opentui/react) with tree-sitter-fga for FGA DSL syntax highlighting. The TUI replicates the core functionality of the existing web dashboard (openfga-dashboard) in a keyboard-driven terminal interface.
| Layer | Technology | Notes |
|---|---|---|
| Runtime | Bun | Required by OpenTUI — no Node.js support |
| TUI Framework | @opentui/react | React reconciler over OpenTUI's native renderer |
| Layout | Yoga (flexbox) | CSS-like flexbox via OpenTUI |
| Syntax Highlighting | tree-sitter-fga | Custom WASM grammar + highlight queries |
| API Layer | Direct REST | Reuse existing OpenFGAClient class (zero dependencies) |
| State | React hooks | useState, useReducer — no external state library needed |
| Config Persistence | JSON file | ~/.config/openfga-tui/config.json |
These files can be copied directly with minimal or no changes:
| File | Lines | Changes Needed |
|---|---|---|
lib/openfga/client.ts |
221 | None — pure fetch() calls, no browser APIs |
lib/openfga/types.ts |
261 | None — pure TypeScript interfaces |
lib/openfga/endpoints.ts |
27 | None — pure string functions |
lib/openfga/dsl-converter.ts |
357 | None — pure string parsing, no DOM dependencies |
Total reusable: ~866 lines of API, types, and DSL conversion logic carried over unchanged.
openfga-tui/
├── package.json
├── tsconfig.json
├── bunfig.toml
│
├── src/
│ ├── index.tsx # Entry: createCliRenderer, createRoot, CLI arg parsing
│ ├── app.tsx # Root component: navigation state machine, global keyboard
│ │
│ ├── lib/
│ │ ├── openfga/
│ │ │ ├── client.ts # [REUSE] OpenFGAClient — direct REST calls
│ │ │ ├── types.ts # [REUSE] All TypeScript interfaces
│ │ │ ├── endpoints.ts # [REUSE] API endpoint path builders
│ │ │ └── dsl-converter.ts # [REUSE] modelToDsl / dslToModel converters
│ │ ├── config.ts # File-based config persistence (~/.config/openfga-tui/)
│ │ ├── playground.ts # Playground sample data (model, store, tuples)
│ │ └── hooks.ts # React hooks wrapping OpenFGAClient methods
│ │
│ ├── views/
│ │ ├── connect.tsx # Connection form view
│ │ ├── stores.tsx # Store list + create/delete
│ │ ├── store-overview.tsx # Store detail with stats
│ │ ├── model-viewer.tsx # Model DSL viewer with syntax highlighting
│ │ ├── model-editor.tsx # [v2] Inline model editor with tree-sitter
│ │ ├── tuples.tsx # Tuple list + add/delete
│ │ ├── queries.tsx # Query tab container
│ │ ├── query-check.tsx # Check query panel
│ │ ├── query-expand.tsx # Expand query with tree rendering
│ │ ├── query-list-objects.tsx # List Objects query panel
│ │ └── query-list-users.tsx # List Users query panel
│ │
│ ├── components/
│ │ ├── table.tsx # Reusable data table (Box + Text rows)
│ │ ├── form-field.tsx # Label + Input wrapper
│ │ ├── status-bar.tsx # Bottom bar: connection info, current store, keybinds
│ │ ├── header.tsx # Top bar: title, breadcrumb, mode indicator
│ │ ├── confirm.tsx # Inline confirmation prompt
│ │ ├── toast.tsx # Temporary success/error message
│ │ ├── spinner.tsx # Loading indicator (text-based)
│ │ ├── tree-view.tsx # Recursive tree renderer (for Expand results)
│ │ └── keybind-help.tsx # [v2] Overlay showing all keybindings
│ │
│ └── tree-sitter/
│ ├── tree-sitter-fga.wasm # Compiled grammar (from tree-sitter-fga repo)
│ ├── highlights.scm # Highlight queries mapping AST nodes → theme scopes
│ └── setup.ts # Grammar registration via addDefaultParsers()
│
└── assets/
└── sample-model.fga # Sample model for playground mode
V1 delivers a fully functional, keyboard-driven OpenFGA management tool covering all day-to-day operations: connecting to servers, managing stores, viewing models with syntax highlighting, managing tuples, and running queries.
Goal: Set up the project, install dependencies, configure TypeScript and Bun, get a "Hello World" rendering in the terminal.
Tasks:
- Initialize project with
bun init - Install dependencies:
bun add @opentui/core @opentui/react - Configure
tsconfig.jsonwith JSX support for OpenTUI React ("jsx": "react-jsx") - Create entry point
src/index.tsx:import { createCliRenderer } from "@opentui/core" import { createRoot } from "@opentui/react" import { App } from "./app" const renderer = await createCliRenderer({ exitOnCtrlC: true }) createRoot(renderer).render(<App />)
- Create
src/app.tsxwith a minimal<text>element to verify rendering - Add
scriptstopackage.json:"start": "bun run src/index.tsx","dev": "bun --watch src/index.tsx" - Copy reusable files from existing dashboard:
lib/openfga/client.tslib/openfga/types.tslib/openfga/endpoints.tslib/openfga/dsl-converter.ts
Acceptance: Running bun run start renders text in the terminal and exits cleanly with Ctrl+C.
Goal: Build the app shell with header, content area, status bar, and a navigation state machine driven by keyboard shortcuts.
Tasks:
- Define the navigation state type:
type View = | { kind: "connect" } | { kind: "stores" } | { kind: "store-overview"; storeId: string } | { kind: "model"; storeId: string } | { kind: "tuples"; storeId: string } | { kind: "queries"; storeId: string }
- Build
Appcomponent withuseReducerfor navigation transitions - Create
<Header>component:- Renders at top of screen (
<box>with fixed height) - Shows: app title, current view breadcrumb, connection status indicator
- Uses
useTerminalDimensions()for full-width rendering
- Renders at top of screen (
- Create
<StatusBar>component:- Renders at bottom of screen
- Shows: server URL (truncated), current store name, available keyboard shortcuts for current view
- Color-coded connection status (green=connected, yellow=playground, red=disconnected)
- Register global keyboard shortcuts via
useKeyboard():Ctrl+C— exit (handled by renderer)Esc— go back (to parent view)?— toggle keybind help overlay (v2)
- Content area:
<box flexGrow={1}>between header and status bar, renders current view component
Layout structure:
┌─────────────────────────────────────────┐
│ OpenFGA TUI stores > my-store > model│ ← Header
├─────────────────────────────────────────┤
│ │
│ (current view) │ ← Content (flexGrow=1)
│ │
├─────────────────────────────────────────┤
│ http://localhost:8080 │ my-store │ ?help │ ← StatusBar
└─────────────────────────────────────────┘
Acceptance: App renders the three-section layout, keyboard shortcuts switch between placeholder views, breadcrumb updates accordingly.
Goal: Persist connection configuration to disk so users don't re-enter credentials on every launch.
Tasks:
- Create
src/lib/config.ts:interface TuiConfig { serverUrl?: string auth?: AuthConfig lastStoreId?: string }
- Config file location:
~/.config/openfga-tui/config.json- Use
Bun.file()andBun.write()for I/O - Create directory if it doesn't exist (
mkdir -pequivalent)
- Use
- Implement functions:
loadConfig(): Promise<TuiConfig>— read and parse, return empty object on missing/corruptsaveConfig(config: TuiConfig): Promise<void>— write JSON with 2-space indent
- Also support CLI arguments that override config file:
--server-url <url>--api-key <key>- Parse via
Bun.argvorprocess.argv
- On app start: load config → if valid connection info exists, auto-connect → navigate to stores view
Acceptance: After connecting once, restarting the app auto-connects without re-entering credentials.
Goal: Build the connection form — the first screen users see when not connected.
Tasks:
- Create
src/views/connect.tsx - Form fields using OpenTUI's
<input>:- Server URL —
<input placeholder="http://localhost:8080" /> - Auth Type —
<select>with options: None, API Key, OIDC - Conditional fields based on auth type:
- API Key:
<input>for token - OIDC: three
<input>fields (Token URL, Client ID, Client Secret)
- API Key:
- Server URL —
- Actions:
- Test Connection (
Enteron form orCtrl+T): callsclient.testConnection(), shows success/error inline - Connect (
Ctrl+Enter): saves config, establishes connection, navigates to stores - Playground Mode (
Ctrl+P): enters playground with mock data, navigates to stores
- Test Connection (
- Form navigation:
Tab/Shift+Tabto move between fields (OpenTUI handles focus management) - Inline error display: red
<text>below form for connection errors - Inline success display: green
<text>for "Connection successful" - State management: local
useStatefor form values,useReducerfor form status (idle/testing/connecting/error/success)
Acceptance: User can fill in connection details, test connection, connect, and be navigated to the stores list. Playground mode enters with sample data.
Goal: List, create, and delete OpenFGA stores.
Tasks:
- Create
src/views/stores.tsx - On mount: call
client.listStores(), display results in a selectable list - Store list rendering:
- Each row:
<box>with store name, store ID (truncated), created date - Highlight currently selected row (background color change)
- Arrow keys (
Up/Down) to navigate,Enterto select → navigate to store-overview - Use
<scrollbox>if list exceeds terminal height
- Each row:
- Keyboard actions:
c— Create store: show inline<input>at top for store name,Enterto confirm,Escto canceld— Delete selected store: show<Confirm>component ("Delete store 'name'? [y/N]")r— Refresh store list
- Create
src/components/confirm.tsx:- Inline text prompt: "Are you sure? [y/N]"
yconfirms, any other key cancels- Returns result via callback
- Empty state: centered text "No stores found. Press 'c' to create one."
- Loading state:
<Spinner>component while fetching - Error state: red text with error message,
rto retry
Acceptance: User can browse stores, create new ones, delete existing ones, and navigate into a store.
Goal: Show store details and provide navigation to model, tuples, and queries sub-views.
Tasks:
- Create
src/views/store-overview.tsx - On mount: fetch store details, model count, and tuple count in parallel
- Display layout:
Store: my-store ID: 01HXYZ... Created: 2024-01-15 Updated: 2024-01-20 ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ [m] Models │ │ [t] Tuples │ │ [q] Queries │ │ 3 models │ │ 42 tuples │ │ 4 operations│ └─────────────┘ └─────────────┘ └─────────────┘ - Keyboard shortcuts:
m— navigate to model viewert— navigate to tuplesq— navigate to queriesEsc— back to stores list
- Stats are fetched once on mount; display "..." while loading
Acceptance: User sees store summary and can navigate to any sub-view via single keypress.
Goal: Compile tree-sitter-fga to WASM, write highlight queries, and register the grammar with OpenTUI.
Tasks:
- Clone
matoous/tree-sitter-fgaand build the WASM binary:cd tree-sitter-fga tree-sitter build --wasm # Produces tree-sitter-fga.wasm
- Copy
tree-sitter-fga.wasmtosrc/tree-sitter/ - Write
src/tree-sitter/highlights.scm— mapping grammar nodes to highlight scopes:; Keywords (["model" "schema" "type" "define" "relations" "condition" "with" "from" "extend" "module"] @keyword) ; Operators (["or" "and" "but not"] @keyword.operator) ; Type references in brackets (direct_relationship) @type ; Relation names (in define statements) (relation_declaration name: (identifier) @function) ; Type declarations (type_declaration name: (identifier) @type.definition) ; Condition declarations (condition_declaration name: (identifier) @function.definition) ; Condition parameter types (["string" "int" "bool" "uint" "timestamp" "duration" "double" "ipaddress" "map" "list"] @type.builtin) ; Comments (comment) @comment ; Schema version (schema_version) @string ; Identifiers (general) (identifier) @variable ; Punctuation (["[" "]" "(" ")"] @punctuation.bracket) ([":" ","] @punctuation.delimiter) ; Numbers (number) @number ; Strings (string) @string
- Create
src/tree-sitter/setup.ts:import { addDefaultParsers } from "@opentui/core" export async function setupFgaParser() { await addDefaultParsers({ filetype: "fga", wasm: new URL("./tree-sitter-fga.wasm", import.meta.url).href, queries: { highlights: [new URL("./highlights.scm", import.meta.url).href], }, }) }
- Call
setupFgaParser()insrc/index.tsxbeforecreateRoot().render() - Verify by rendering a
<code filetype="fga">component with sample FGA DSL
Note: The highlights.scm file will need iterative refinement based on the actual AST node names produced by tree-sitter-fga's grammar.js. Use tree-sitter parse on a sample .fga file to inspect the concrete syntax tree and adjust capture names accordingly.
Acceptance: FGA DSL renders in the terminal with colored keywords, types, relations, and comments.
Goal: Display the current authorization model with syntax highlighting and support editing via $EDITOR.
Tasks:
- Create
src/views/model-viewer.tsx - On mount: fetch models via
client.listAuthorizationModels(storeId), take the latest model - Convert model JSON to DSL using
modelToDsl()from the reuseddsl-converter.ts - Render DSL using OpenTUI's
<code>component:This automatically uses the registered tree-sitter-fga parser for highlighting.<code filetype="fga" width="100%" height="100%"> {dslContent} </code>
- Wrap in
<scrollbox>for models that exceed terminal height - Model version selector:
- Show current model ID at the top
[/]keys to cycle through model versions (older/newer)- Model list shown as
<select>triggered byvkey
- Keyboard actions:
e— Edit model:- Write current DSL to a temp file (
/tmp/openfga-model-XXXX.fga) - Spawn
$EDITOR(orvifallback) as a child process viaBun.spawn() - On editor exit: read temp file, parse with
dslToModel(), validate - If valid: prompt "Save model? [y/N]" → call
client.writeAuthorizationModel() - If invalid: show parse error, offer to re-edit or discard
- Clean up temp file
- Write current DSL to a temp file (
r— Refresh (re-fetch from server)y— Yank/copy DSL to clipboard (pbcopyon macOS,xclip/xselon Linux)Esc— back to store overview
- Playground mode: show the sample model,
eedits in-memory (no server call)
Editor integration detail:
async function openInEditor(content: string): Promise<string | null> {
const tmpPath = `/tmp/openfga-model-${Date.now()}.fga`
await Bun.write(tmpPath, content)
const editor = Bun.env.EDITOR || Bun.env.VISUAL || "vi"
const proc = Bun.spawn([editor, tmpPath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
await proc.exited
const result = await Bun.file(tmpPath).text()
await unlink(tmpPath) // clean up
return result
}Acceptance: User sees the FGA model with syntax highlighting. Pressing e opens the model in their editor; on save, the model is validated and pushed to the server.
Goal: List, add, and delete relationship tuples.
Tasks:
- Create
src/views/tuples.tsx - On mount: fetch tuples via
client.read(storeId, { page_size: 50 }) - Table display using the reusable
<Table>component:┌──────────────────┬──────────┬────────────────────┐ │ User │ Relation │ Object │ ├──────────────────┼──────────┼────────────────────┤ │ user:anne │ reader │ document:budget │ │ user:bob │ writer │ document:budget │ │ group:eng#member │ viewer │ folder:root │ └──────────────────┴──────────┴────────────────────┘ - Create
src/components/table.tsx:- Renders header row + data rows using
<box>and<text> - Column widths calculated from content or fixed proportions
- Selected row highlighted with background color
- Unicode box-drawing characters for borders:
│,─,┌,┐,└,┘,├,┤,┬,┴,┼ - Wrap in
<scrollbox>for pagination
- Renders header row + data rows using
- Navigation:
Up/Downarrows,PageUp/PageDownfor fast scroll - Keyboard actions:
a— Add tuple: show inline form with three<input>fields (user, relation, object)Tabbetween fields,Enterto submit,Escto cancel- On submit:
client.write(storeId, { writes: { tuple_keys: [{ user, relation, object }] } }) - On success: refresh list, show green toast "Tuple added"
- On error: show red toast with error message
d— Delete selected tuple: confirm prompt →client.write(storeId, { deletes: { tuple_keys: [...] } })r— Refresh listn— Next page (ifcontinuation_tokenexists)/— Filter tuples:<input>for filter text, filters client-side on user/relation/objectEsc— back to store overview
- Empty state: "No tuples found. Press 'a' to add one."
- Pagination: show "Page 1 | n for next" in status area when continuation token present
Acceptance: User can view tuples in a table, add new tuples inline, delete selected tuples, and paginate through results.
Goal: Implement the four query operations (Check, Expand, List Objects, List Users) in a tabbed interface.
Tasks:
- Create
src/views/queries.tsx - Use OpenTUI's
<tab-select>for switching between query types:<tab-select items={["Check", "Expand", "List Objects", "List Users"]} onSelect={setActiveTab} />
- Render the active panel below the tab bar
Esc— back to store overview
- Create
src/views/query-check.tsx - Three input fields: User, Relation, Object
EnterorCtrl+Enterto run- Result display:
- Allowed:
<text fg="#22c55e">ALLOWED</text>(green) - Denied:
<text fg="#ef4444">DENIED</text>(red) - Resolution string shown below if present
- Allowed:
- Error handling: red text with API error message
- Create
src/views/query-expand.tsx - Two input fields: Relation, Object
Enterto run- Result: recursive tree rendered via
<TreeView>component - Create
src/components/tree-view.tsx:- Renders
Nodetype fromtypes.tsrecursively - Uses Unicode tree characters:
├──,└──,│ - Color-coded node types:
union→ blueintersection→ greendifference→ orangeleaf/users→ defaultleaf/computed→ cyan
document:budget#reader └── union ├── Users: user:anne, user:bob ├── Computed: writer └── TupleToUserset: parent → viewer - Renders
- Wrap tree in
<scrollbox>for large expansion results
- Create
src/views/query-list-objects.tsx - Three input fields: User, Relation, Type
Enterto run- Result: scrollable list of object IDs
- Create
src/views/query-list-users.tsx - Four input fields: Object Type, Object ID, Relation, User Filter Type
Enterto run- Result: table of users with columns for type (object/userset/wildcard), type name, ID, relation
Acceptance: All four query types work. Results display clearly with appropriate formatting.
Goal: Allow exploring the TUI without a running OpenFGA server.
Tasks:
- Create
src/lib/playground.ts:- Export
PLAYGROUND_STORE,PLAYGROUND_SAMPLE_MODEL,PLAYGROUND_TUPLES(port from existingconnection-store.ts) PLAYGROUND_TUPLES: array of sample tuples matching the sample model:[ { key: { user: "user:anne", relation: "owner", object: "folder:root" }, timestamp: "..." }, { key: { user: "user:bob", relation: "writer", object: "document:budget" }, timestamp: "..." }, { key: { user: "user:anne", relation: "reader", object: "document:budget" }, timestamp: "..." }, { key: { user: "group:eng#member", relation: "viewer", object: "folder:root" }, timestamp: "..." }, ]
- Export
- Create a
PlaygroundClientclass that implements the same interface asOpenFGAClientbut operates on in-memory data:listStores()→ returns[PLAYGROUND_STORE]listAuthorizationModels()→ returns[PLAYGROUND_SAMPLE_MODEL]read()→ returns playground tuples (with client-side filtering)write()→ adds/removes from in-memory tuple arraywriteAuthorizationModel()→ updates in-memory modelcheck(),expand(),listObjects(),listUsers()→ return informative "not available in playground" responses
- When playground mode is active:
- Header shows
[PLAYGROUND]badge in yellow - Status bar shows "Playground Mode — no server connection"
- Query operations show a notice: "Queries require a live server connection"
- Header shows
- Playground entered via
Ctrl+Pon connect screen or--playgroundCLI flag
Acceptance: Launching with --playground enters a fully navigable app with sample data. Store browsing, model viewing, and tuple management work against in-memory data.
Goal: Robust error handling, edge cases, and UX polish.
Tasks:
- Create
src/components/toast.tsx:- Temporary message overlay (auto-dismiss after 3 seconds)
- Variants: success (green), error (red), info (blue)
- Positioned at top-right of content area
- Create
src/components/spinner.tsx:- Text-based spinner animation:
⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏cycling viauseTimeline() - Shows next to "Loading..." text
- Text-based spinner animation:
- Global error boundary:
- Catch unhandled errors, display "Something went wrong" with error message
- Offer
rto retry,Escto go back
- Network error handling:
- Detect connection failures (server unreachable)
- Show "Connection lost" in status bar (red)
- Auto-retry with exponential backoff (optional)
Ctrl+Rto force reconnect
- Terminal resize handling:
useOnResize()hook — re-render layout on terminal resize- Minimum terminal size check (80x24) — show warning if too small
- Graceful exit:
Ctrl+C— clean up temp files, save config, exit
- Help text per view:
- Each view shows contextual keyboard shortcuts in the status bar
- Format:
[a]dd [d]elete [r]efresh [/]filter [Esc]back
Acceptance: App handles network errors gracefully, shows loading states, auto-saves config, and provides clear contextual keyboard hints.
V2 adds advanced editing, visualization, and quality-of-life features for heavy users.
Goal: Replace $EDITOR workflow with an in-TUI editor that has live syntax highlighting via tree-sitter-fga.
Tasks:
- Create
src/views/model-editor.tsx - Architecture approach: combine OpenTUI's
EditBufferRenderable+TextBufferRenderable+ tree-sitter pipeline:- Use the
<textarea>component for editing input (cursor movement, selections, undo/redo) - On every content change, run tree-sitter-fga highlighting asynchronously
- Apply highlights to the rendered text buffer
- Use the
- Editor layout — split pane:
┌─────────────────────┬─────────────────────┐ │ EDITOR (editable) │ PREVIEW (read-only) │ │ │ │ │ model │ model │ │ schema 1.1 │ schema 1.1 │ │ │ │ │ type user │ type user │ │ type document │ type document │ │ relations │ relations │ │ define owner... │ define owner... │ │ │ │ │ (plain text input) │ (syntax highlighted)│ └─────────────────────┴─────────────────────┘- Left pane:
<textarea>— editable, no highlighting - Right pane:
<code filetype="fga">— read-only, highlighted, updates on each keystroke (debounced 300ms)
- Left pane:
- Alternative approach (preferred if feasible): build a custom
HighlightedTextareacomponent:- Extend
TextareaRenderableto accept styled text from tree-sitter - Intercept the rendering pipeline to apply highlight styles before drawing
- This gives single-pane editing with highlighting — better UX
- Investigate OpenCode's source for reference (it achieves this for code editing)
- Extend
- Validation:
- On each change (debounced 500ms): parse DSL with
dslToModel() - If invalid: show error line/message below editor
- If valid: update preview pane
- On each change (debounced 500ms): parse DSL with
- Save workflow:
Ctrl+S— save to server: parse → validate →client.writeAuthorizationModel()→ refresh- Show validation errors if parse fails
- Show confirmation toast on success
- Keyboard bindings:
- Standard text editing (arrows, Home/End, Ctrl+A, Ctrl+Z/Y for undo/redo) — provided by
<textarea> Ctrl+S— saveEsc— exit editor (prompt to save if changes exist)
- Standard text editing (arrows, Home/End, Ctrl+A, Ctrl+Z/Y for undo/redo) — provided by
Tradeoffs:
- Split-pane approach is simpler to implement but uses more screen space
- Single-pane highlighted editor is ideal UX but requires digging into OpenTUI internals
- Recommend: ship split-pane first, iterate to single-pane
Acceptance: User can edit FGA models inline with real-time syntax highlighting (at minimum in preview pane), validate, and save.
Goal: Render the authorization model as a text-based graph showing type→relation relationships.
Tasks:
- Create a
renderModelGraph()function that takes anAuthorizationModeland produces a string - Layout algorithm — hierarchical tree with box drawing:
┌──────────────────────────────────────────────────┐ │ my-store │ └──────┬───────────────┬───────────────┬───────────┘ │ │ │ ┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐ │ user │ │ folder │ │ document │ └─────────────┘ └──────┬──────┘ └──────┬──────┘ │ │ ┌──────┼──────┐ ┌──────┼──────┐ │ │ │ │ │ │ owner parent viewer owner parent writer reader - Alternative: indented tree format (simpler, more terminal-friendly):
my-store ├── user (no relations) ├── group │ └── member: [user] ├── folder │ ├── owner: [user] │ ├── parent: [folder] │ └── viewer: [user, user:*, group#member] or owner or parent->viewer └── document ├── owner: [user] ├── parent: [folder] ├── writer: [user, group#member] or owner └── reader: [user, user:*, group#member] or writer or parent->viewer - Color coding:
- Type names: bold cyan
- Relation names: green
- Direct type references: yellow
- Operators (or/and/but not): magenta
- Computed references: blue
- Integration:
- Accessible from model viewer via
gkey (toggle graph/code view) - Wrap in
<scrollbox>for large models
- Accessible from model viewer via
- The indented tree format is recommended — it's more information-dense, easier to render, and works well at any terminal width
Acceptance: Pressing g on the model view toggles to a colored ASCII representation of the model graph.
Goal: Add vim motion support throughout the app for power users.
Tasks:
- Create a keybinding mode system:
- Normal mode: single-key actions (
j/kfor up/down,Gfor bottom,ggfor top) - Insert mode: text input in forms/editor (activated on focus of
<input>/<textarea>) - Visual mode: (v2 stretch) selection in lists
- Normal mode: single-key actions (
- List navigation (stores, tuples):
j/Down— move downk/Up— move upG— jump to last itemgg— jump to first itemCtrl+D/Ctrl+U— half-page down/up/— search/filtern/N— next/previous search match
- Model viewer navigation:
j/k— scroll line by lineCtrl+D/Ctrl+U— half-page scrollG/gg— top/bottom
- Model editor (if single-pane):
- Full vim insert/normal mode for text editing
- This is a significant undertaking; consider using OpenTUI's built-in Emacs bindings as the base and adding vim as an opt-in layer
- Configuration:
~/.config/openfga-tui/config.jsongets a"keymap": "vim" | "default"setting--vimCLI flag to enable
Acceptance: Users with "keymap": "vim" can navigate lists with j/k, jump with G/gg, and search with /.
Goal: ? toggles a full-screen overlay showing all available keybindings for the current view.
Tasks:
- Create
src/components/keybind-help.tsx - Overlay rendered as a
<box>with absolute positioning covering the content area - Content: two-column layout of keybinding → description, grouped by category
- Example for the tuples view:
╔══════════════════════════════════════════╗ ║ Keyboard Shortcuts ║ ╠══════════════════════════════════════════╣ ║ ║ ║ Navigation ║ ║ ↑/k Move up ║ ║ ↓/j Move down ║ ║ Esc Go back ║ ║ ║ ║ Actions ║ ║ a Add tuple ║ ║ d Delete selected tuple ║ ║ r Refresh list ║ ║ / Filter tuples ║ ║ n Next page ║ ║ ║ ║ Global ║ ║ Ctrl+C Exit ║ ║ ? Toggle this help ║ ║ ║ ╚══════════════════════════════════════════╝ ?toggles the overlay on/offEscalso dismisses the overlay- Each view registers its keybindings via a context/hook so the overlay auto-populates
Acceptance: Pressing ? on any view shows a complete, contextual keybinding reference.
Goal: Enhance tuple management with filtering, bulk operations, and contextual tuples for queries.
Tasks:
- Server-side tuple filtering:
fkey opens filter mode with three optional fields (user, relation, object)- Sends filters via
ReadRequest.tuple_keypartial match - Active filters shown as badges below the header
Ctrl+Fto clear all filters
- Bulk delete:
- Visual selection mode:
Spaceto toggle selection on current row Shift+Dto delete all selected tuples- Selection count shown in status bar
- Visual selection mode:
- Tuple import/export (stretch):
Ctrl+E— export tuples to JSON file (viaBun.write())Ctrl+I— import tuples from JSON file (viaBun.file())
- Contextual tuples for queries:
- In query views,
Ctrl+Topens a mini tuple editor - User adds temporary tuples that are included in the query's
contextual_tuplesfield - These aren't persisted to the server
- Show count of contextual tuples in query panel header
- In query views,
Acceptance: Users can filter tuples server-side, bulk-select and delete, and attach contextual tuples to queries.
Goal: Allow working with multiple stores simultaneously, and store-aware command history.
Tasks:
- Store switcher:
Ctrl+Sopens a quick-switch overlay listing all stores- Fuzzy search by store name
Enterto switch,Escto cancel- Avoids navigating back to the stores list
- Per-store query history:
- Save last 10 queries per store in config file under
queryHistory[storeId] - In query views,
Ctrl+Hshows history,Enterto re-run
- Save last 10 queries per store in config file under
- Store bookmarks:
bon a store to bookmark it- Bookmarked stores appear at the top of the store list with a marker
Acceptance: Quick-switch between stores without leaving the current context.
| Key | Action |
|---|---|
Ctrl+C |
Exit application |
Esc |
Go back / cancel current action |
? |
Show keybind help (v2) |
| Key | Action |
|---|---|
Tab / Shift+Tab |
Next / previous field |
Enter |
Test connection |
Ctrl+Enter |
Connect |
Ctrl+P |
Enter playground mode |
| Key | Action |
|---|---|
Up / Down |
Navigate store list |
Enter |
Select store |
c |
Create new store |
d |
Delete selected store |
r |
Refresh list |
| Key | Action |
|---|---|
m |
Go to Models |
t |
Go to Tuples |
q |
Go to Queries |
| Key | Action |
|---|---|
e |
Edit in $EDITOR |
v |
Select model version |
[ / ] |
Previous / next model version |
y |
Copy DSL to clipboard |
r |
Refresh |
g |
Toggle graph view (v2) |
| Key | Action |
|---|---|
Up / Down |
Navigate tuple list |
a |
Add tuple |
d |
Delete selected tuple |
r |
Refresh |
n |
Next page |
/ |
Filter |
| Key | Action |
|---|---|
Tab |
Next input field |
Enter |
Run query |
1-4 or Tab-Select |
Switch query type |
| Risk | Severity | Impact | Mitigation |
|---|---|---|---|
| OpenTUI "not production ready" | High | Breaking changes, API instability | Pin exact version, vendor critical code, track OpenTUI releases |
| Bun-only runtime | Medium | Users must install Bun | Document clearly, provide install script, consider single binary via bun build --compile |
tree-sitter-fga missing highlights.scm |
Medium | Must write + maintain highlight queries | Write it ourselves (~60 lines), contribute upstream |
| tree-sitter-fga WASM compilation | Low | Build toolchain requirement | Pre-build and commit the .wasm binary |
No <textarea> syntax highlighting |
Medium | v2 inline editor limited to split-pane | Split-pane is functional; single-pane requires custom component work |
| OpenTUI native Zig dependency | Medium | Cross-platform build complexity | Use bun build --compile for distribution; test on macOS/Linux |
| Small terminal sizes | Low | Layout breaks below 80x24 | Detect and warn; degrade gracefully |
| FGA DSL parser limitations | Low | dslToModel() is simplified |
Works for common patterns; recommend @openfga/syntax-transformer for production |
- Development:
bun run src/index.tsx— requires Bun + Zig toolchain - Binary:
bun build --compile src/index.tsx --outfile openfga-tui— single binary, no runtime dependencies - npm:
bunx openfga-tui— requires Bun installed - Homebrew (stretch): tap formula pointing to compiled binaries per platform
The bun build --compile approach is the recommended primary distribution method — it produces a single ~50MB binary with the Bun runtime embedded, eliminating all user-side dependency requirements.