Skip to content

Proposal: terminal UI (TUI) target that renders json-render views #414

Description

@antfubot

Summary

Add a terminal UI (TUI) target for devframe. A devframe author defines a view once with createJsonRenderView(). Today that view renders in a browser (Vue SPA, hub dock, React host). With this proposal the same view also renders in a terminal, with live state and working actions.

Idea credit: @danielroe. Reference: the interactive terminal UI in nuxt/cli v4 (packages/nuxt-cli/src/dev/tui/).

What exists today

The "define once" layer is already in the repo. The TUI is one more renderer of it.

Layer Where Status
View protocol @devframes/json-render (packages/json-render) A view is an @json-render/core Spec. Base catalog v1 has 17 components with prop schemas. The node side publishes each view as shared state at devframe:json-render:<scope>:<id> and lists views at devframe:json-render:index.
Browser renderer (Vue) @devframes/json-render-ui (/spa, /hub) Shipped.
Browser renderer (React) examples/custom-hub-next/src/client/json-render/react-renderer.tsx Shipped as a reference.
Actions packages/json-render-ui/src/action-bridge.ts An element event dispatches rpc.call(action, params). setState, pushState, removeState, validateForm stay local.
Coding agent access devframe/adapters/mcp via @devframes/agentic RPC functions with an agent field become MCP tools. Shared state becomes MCP resources.
Instance discovery devframe connect (packages/agentic/src/connect) Finds running dev servers through the instance registry and __connection.json.
RPC from Node devframe/rpc/client + devframe/rpc/transports/ws-client The WebSocket channel uses the global WebSocket. The hub tests already drive it from Node.

Nothing in the repo mentions a TUI, Ink, or OpenTUI today.

What nuxt/cli v4 does

Facts from the source, for reference:

  • The TUI is hand-rolled. It has no TUI framework dependency. It uses node:util styleText and @clack/prompts.
  • nuxt dev paints a pinned footer panel (URLs, status, shortcuts) with logs folded above it. Single keys open full-screen overlays: l logs, n requests, p routes, i info, ? help.
  • PanelSurface (dev/tui/surface.ts) splits two axes, "who owns the screen" (split-footer or alternate-screen) and "where other output goes" (passthrough or capture). The comment credits OpenTUI's CliRenderer for this shape.
  • resolveDevUISupport (dev/tui/support.ts) refuses the TUI when stdout or stdin is not a TTY, TERM=dumb, CI, test, an inspector is attached, or the terminal is under 40x10. --no-tui and NUXT_TUI=plain opt out. NUXT_TUI=1 forces it on, but never over a pipe.
  • nuxt curl and nuxt task find the running dev server through a lock file. devframe has the same need and already solves it with the instance registry.

What upstream json-render offers

@json-render/ink (version 0.21.0, same line as the @json-render/core we pin) renders a spec in the terminal with Ink. Peers: ink ^6, react ^19. It ships 24 standard components, $state and $bindState bindings, Select, MultiSelect, TextInput, Tabs, focus handling, and createRenderer(catalog, components).

Proposal

1. New opt-in package @devframes/tui

Same pattern as @devframes/agentic: devframe/adapters/tui is a thin re-export of an optional peer. Core stays dependency-light and headless. ink and react are dependencies of @devframes/tui only. The package must stay validator-neutral (no zod in runtime dependencies). It reads prop schemas from @devframes/json-render, which already carries the upstream zod dependency.

2. Rendering engine: @json-render/ink

Implement the devframe base catalog (17 components) on Ink. Reuse upstream standard components where the shape matches. Write small custom components where it does not.

devframe component Ink component Note
Stack Box direction maps to flexDirection.
Card Card
Text Text, Heading variant: heading uses Heading. code uses inverse text.
Badge Badge
Button custom Focusable [ label ]. Enter fires press. Ink has no button.
Icon custom Name in brackets, or a glyph map for the common ph:* names.
Divider Divider
TextInput TextInput Two-way $bindState works upstream.
Switch custom [x] label, space toggles.
KeyValueTable KeyValue
DataTable Table
CodeBlock custom on Box + Text No highlighting in phase 1.
Progress ProgressBar
Tree custom on List Rendered expanded to a depth limit in phase 1.
Tabs Tabs
Link Link OSC 8 hyperlink when the terminal supports it.
Select Select

Unknown component types render a placeholder, as the guide for alternative renderers already requires (docs/content/1.guide/21.build-your-own-json-render-frontend.md).

3. Two ways to start it

Attach mode, the primary path:

devframe tui [--port <n>] [--base <path>] [--instances-dir <dir>]

The command lists live instances from the same registry devframe connect uses. One instance attaches at once. Several instances show a Select prompt. The TUI reads __connection.json, opens the RPC client over WebSocket (SSE fallback), subscribes to devframe:json-render:index, and renders the views. It reconnects when the dev server restarts.

In-process mode:

my-app tui

createCac gains a tui subcommand next to dev, build, and mcp. It starts the devframe and the TUI in one process. It reuses the attach code path against the local origin, so there is one renderer and one action bridge.

4. Hub support (first class)

A hub unites many devframes behind one origin. The TUI must work against a hub from the start, because that is how the framework kits (@devframes/vite/hub, @devframes/nuxt/hub, @devframes/next/hub) run.

When the attached instance is a hub, the TUI reads the devframe:docks shared state (HUB_EVENTS.sharedState.docks) instead of the view index. Dock entries of type json-render render as tabs, in dock order. Other dock kinds (iframe, launcher, action) list their title and a link that opens them in the browser. The devframe:docks:active state follows the selected tab so the browser and the terminal agree.

A single devframe with no hub uses the view index (devframe:json-render:index) as before. The attach code detects which one it talks to from __connection.json.

5. Keys

Key Action
tab, shift-tab Move focus
enter, space Press or toggle the focused element
1..9 Switch view or dock tab
? Show all shortcuts
r Reconnect
q, ctrl-c Quit

6. Terminal support rules

Port resolveDevUISupport from nuxt/cli: refuse when stdout or stdin is not a TTY, TERM=dumb, CI, or the terminal is under 40x10. The TUI command has no plain fallback because the TUI is the whole command. It exits with a coded DF diagnostic that names the reason. Respect NO_COLOR. Map semantic variants (success, warning, danger) to a small ANSI palette. The @antfu/design tokens have no terminal form, so this palette is the terminal edition of the same vocabulary.

7. Mounting into the nuxt/cli v4 TUI

Question researched: can the devframe TUI open inside the nuxt dev terminal UI with little effort? Facts from the nuxt/cli source:

  • nuxt/cli publishes one in-process contract, TerminalHost, on globalThis[Symbol.for('nuxt:terminal-host')] (utils/terminal-host.ts). Its withTerminal(work) closes any overlay, releases raw stdin, erases the pinned panel, awaits work, then repaints (dev/tui/index.ts, lendTerminal). An Ink render() that resolves on waitUntilExit() fits this call with no adapter.
  • The contract is in-process only. nuxt dev forks by default. Nuxt modules, so @devframes/nuxt, run in the child. The parent never loads Nuxt. useTerminalHost() returns undefined in the child.
  • Child to parent IPC is a closed union of nuxt:internal:dev:* messages (dev/utils.ts, NuxtDevIPCMessage). No message registers a shortcut or an action. There is no public shortcuts API.
  • nuxt dev --no-fork runs Nuxt in the parent. There useTerminalHost() works from a module, but nothing gives the module a key to bind. A module could listen on stdin keypress beside the CLI, but both would answer the same key.

Three ways to mount, in order of effort:

Option Effort Works today Note
A. Second terminal: devframe tui --port <nuxt port> --base /__devframes/ None beyond phase 1 Yes Attach mode over WebSocket. --base exists for this since #403. tmux or a split pane.
B. Upstream PR to nuxt/cli: a generic "dev action" Small, about 50 lines upstream, one IPC send in @devframes/nuxt No The child sends { type: 'nuxt:internal:dev:action', key, label, command } once ready. The parent adds the shortcut to the hint line and, on the key, runs command inside withTerminal with inherited stdio. Generic: any module can offer a terminal action. The devframe TUI stays a separate process, so no React or Ink enters the CLI module graph.
C. In-process --no-fork hook Small on our side, fragile Partly @devframes/nuxt calls useTerminalHost()?.withTerminal(() => renderTui()). Needs a trigger the CLI does not offer. Not recommended.

Recommendation: ship A with phase 1, and open option B upstream as a proposal to nuxt/cli after phase 1 proves the TUI. Option B is the "minimal effort" mount. The devframe side is one process.send call. The design work sits upstream and helps other tools too.

8. Rules this must follow

  • Headless core: the TUI is an explicit command, never default stdout output (.agents/01-positioning.md).
  • Event and state names come from DEVFRAME_EVENTS and HUB_EVENTS, no re-typed literals.
  • Warnings and errors are DF codes via devframe/utils/nostics, each with a docs page under docs/content/6.errors/.
  • Terms follow docs/content/8.references/1.terms.md: "a devframe", "node side", "RPC client", "dock entry".
  • Docs land only when the feature ships: a guide page, an adapter page, and the terms.md adapter list.

Phases

Phase 0: spike (1 to 2 days)

Render the examples/json-render dashboard in-process with @json-render/ink and a hand-mapped catalog. Prove: all 17 components render, $state patches update live, a Button press reaches the RPC function. No package yet. Result: a short report on this issue with gaps found.

Phase 1: @devframes/tui, attach mode, single devframe and hub (2 to 3 weeks)

  • Package with the full base catalog on Ink.
  • devframe tui attach mode with instance discovery, reconnect, and auth.
  • Single devframe: views from the view index. Hub: tabs per json-render dock entry, link list for other dock kinds, active dock kept in sync.
  • Action bridge with the same reserved names as the Vue renderer.
  • Terminal support rules and DF diagnostics.
  • Tests: catalog components render each prop schema. Action dispatch reaches a fake RPC host. Support rules return the right refusal. Hub and single devframe both attach in an integration test.
  • Docs: guide page and adapter page, with the nuxt/cli second-terminal recipe (option A above).
  • examples/custom-hub-vite and examples/custom-hub-next both register a json-render dock the TUI can show (parity rule).

Acceptance: pnpm -C examples/json-render dev in one terminal and devframe tui in another shows the dashboard with live counters and working buttons. pnpm -C examples/custom-hub-vite dev in one terminal and devframe tui --base /__devframes/ in another shows one tab per json-render dock and a link list for the iframe docks.

Phase 2: in-process command (about 1 week)

  • tui subcommand on createCac, reusing the attach path against the local origin.
  • initHub hosts get the same through the devframe bin, no new API.

Phase 3: nuxt/cli mount (upstream, size unknown)

  • Open a proposal on nuxt/cli for the generic "dev action" IPC message and shortcut (option B in section 7).
  • When it lands, @devframes/nuxt sends the action once the hub is ready. No other devframe change.

Later, not designed here

  • A nuxt/cli style status panel for my-app dev (URLs, status, log fold, shortcuts).
  • Built-in TUI views that need no json-render view from the author: a shared-state tree and an RPC function list. This makes the TUI useful for any devframe.

Out of scope

  • MCP changes. The coding agent path already exists and does not change.
  • A hand-rolled renderer. If @json-render/ink blocks us, we revisit.

Open questions

  1. Auth in attach mode. devframe connect reads a bearer token from DEVFRAME_MCP_AUTH_TOKEN. The TUI can do the same with its own variable, or run the interactive OTP flow from devframe/recipes/interactive-auth in the terminal. Which one first?
  2. Runtime matrix. CI runs Node, Bun, and Deno. Ink runs on Node and Bun. Deno support for Ink 6 needs a check before phase 1.
  3. connectDevframe() in packages/devframe/src/client/index.ts fetches ./__connection.json relative to a page. Attach mode needs a Node-side entry that takes an origin. Is that a new export on devframe/rpc/client or a helper inside @devframes/tui?
  4. Custom components (Button, Switch, Tree, CodeBlock): keep them in @devframes/tui, or propose them upstream to @json-render/ink?
  5. Light and dark terminals. nuxt/cli detects the background and has a NUXT_TERM_THEME override. Do we want the same or a fixed palette in phase 1?

References

Research and draft written with the help of an agent.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions