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:
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
- 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?
- 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.
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?
- Custom components (
Button, Switch, Tree, CodeBlock): keep them in @devframes/tui, or propose them upstream to @json-render/ink?
- 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.
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.
@devframes/json-render(packages/json-render)@json-render/coreSpec. Base catalog v1 has 17 components with prop schemas. The node side publishes each view as shared state atdevframe:json-render:<scope>:<id>and lists views atdevframe:json-render:index.@devframes/json-render-ui(/spa,/hub)examples/custom-hub-next/src/client/json-render/react-renderer.tsxpackages/json-render-ui/src/action-bridge.tsrpc.call(action, params).setState,pushState,removeState,validateFormstay local.devframe/adapters/mcpvia@devframes/agenticagentfield become MCP tools. Shared state becomes MCP resources.devframe connect(packages/agentic/src/connect)__connection.json.devframe/rpc/client+devframe/rpc/transports/ws-clientWebSocket. 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:
node:utilstyleTextand@clack/prompts.nuxt devpaints a pinned footer panel (URLs, status, shortcuts) with logs folded above it. Single keys open full-screen overlays:llogs,nrequests,proutes,iinfo,?help.PanelSurface(dev/tui/surface.ts) splits two axes, "who owns the screen" (split-footeroralternate-screen) and "where other output goes" (passthroughorcapture). The comment credits OpenTUI'sCliRendererfor 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-tuiandNUXT_TUI=plainopt out.NUXT_TUI=1forces it on, but never over a pipe.nuxt curlandnuxt taskfind 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/corewe pin) renders a spec in the terminal with Ink. Peers:ink ^6,react ^19. It ships 24 standard components,$stateand$bindStatebindings,Select,MultiSelect,TextInput,Tabs, focus handling, andcreateRenderer(catalog, components).Proposal
1. New opt-in package
@devframes/tuiSame pattern as
@devframes/agentic:devframe/adapters/tuiis a thin re-export of an optional peer. Core stays dependency-light and headless.inkandreactare dependencies of@devframes/tuionly. The package must stay validator-neutral (nozodin runtimedependencies). It reads prop schemas from@devframes/json-render, which already carries the upstreamzoddependency.2. Rendering engine:
@json-render/inkImplement the devframe base catalog (17 components) on Ink. Reuse upstream standard components where the shape matches. Write small custom components where it does not.
StackBoxdirectionmaps toflexDirection.CardCardTextText,Headingvariant: headingusesHeading.codeuses inverse text.BadgeBadgeButton[ label ]. Enter firespress. Ink has no button.Iconph:*names.DividerDividerTextInputTextInput$bindStateworks upstream.Switch[x] label, space toggles.KeyValueTableKeyValueDataTableTableCodeBlockBox+TextProgressProgressBarTreeListTabsTabsLinkLinkSelectSelectUnknown 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:
The command lists live instances from the same registry
devframe connectuses. One instance attaches at once. Several instances show aSelectprompt. The TUI reads__connection.json, opens the RPC client over WebSocket (SSE fallback), subscribes todevframe:json-render:index, and renders the views. It reconnects when the dev server restarts.In-process mode:
createCacgains atuisubcommand next todev,build, andmcp. 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:docksshared state (HUB_EVENTS.sharedState.docks) instead of the view index. Dock entries of typejson-renderrender as tabs, in dock order. Other dock kinds (iframe,launcher,action) list their title and a link that opens them in the browser. Thedevframe:docks:activestate 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
tab,shift-tabenter,space1..9?rq,ctrl-c6. Terminal support rules
Port
resolveDevUISupportfrom 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 codedDFdiagnostic that names the reason. RespectNO_COLOR. Map semantic variants (success,warning,danger) to a small ANSI palette. The@antfu/designtokens 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 devterminal UI with little effort? Facts from the nuxt/cli source:TerminalHost, onglobalThis[Symbol.for('nuxt:terminal-host')](utils/terminal-host.ts). ItswithTerminal(work)closes any overlay, releases raw stdin, erases the pinned panel, awaitswork, then repaints (dev/tui/index.ts,lendTerminal). An Inkrender()that resolves onwaitUntilExit()fits this call with no adapter.nuxt devforks by default. Nuxt modules, so@devframes/nuxt, run in the child. The parent never loads Nuxt.useTerminalHost()returnsundefinedin the child.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-forkruns Nuxt in the parent. ThereuseTerminalHost()works from a module, but nothing gives the module a key to bind. A module could listen on stdinkeypressbeside the CLI, but both would answer the same key.Three ways to mount, in order of effort:
devframe tui --port <nuxt port> --base /__devframes/--baseexists for this since #403. tmux or a split pane.@devframes/nuxt{ type: 'nuxt:internal:dev:action', key, label, command }once ready. The parent adds the shortcut to the hint line and, on the key, runscommandinsidewithTerminalwith 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.--no-forkhook@devframes/nuxtcallsuseTerminalHost()?.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.sendcall. The design work sits upstream and helps other tools too.8. Rules this must follow
.agents/01-positioning.md).DEVFRAME_EVENTSandHUB_EVENTS, no re-typed literals.DFcodes viadevframe/utils/nostics, each with a docs page underdocs/content/6.errors/.docs/content/8.references/1.terms.md: "a devframe", "node side", "RPC client", "dock entry".terms.mdadapter list.Phases
Phase 0: spike (1 to 2 days)
Render the
examples/json-renderdashboard in-process with@json-render/inkand a hand-mapped catalog. Prove: all 17 components render,$statepatches update live, aButtonpress 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)devframe tuiattach mode with instance discovery, reconnect, and auth.json-renderdock entry, link list for other dock kinds, active dock kept in sync.DFdiagnostics.examples/custom-hub-viteandexamples/custom-hub-nextboth register a json-render dock the TUI can show (parity rule).Acceptance:
pnpm -C examples/json-render devin one terminal anddevframe tuiin another shows the dashboard with live counters and working buttons.pnpm -C examples/custom-hub-vite devin one terminal anddevframe 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)
tuisubcommand oncreateCac, reusing the attach path against the local origin.initHubhosts get the same through thedevframebin, no new API.Phase 3: nuxt/cli mount (upstream, size unknown)
@devframes/nuxtsends the action once the hub is ready. No other devframe change.Later, not designed here
my-app dev(URLs, status, log fold, shortcuts).Out of scope
@json-render/inkblocks us, we revisit.Open questions
devframe connectreads a bearer token fromDEVFRAME_MCP_AUTH_TOKEN. The TUI can do the same with its own variable, or run the interactive OTP flow fromdevframe/recipes/interactive-authin the terminal. Which one first?connectDevframe()inpackages/devframe/src/client/index.tsfetches./__connection.jsonrelative to a page. Attach mode needs a Node-side entry that takes an origin. Is that a new export ondevframe/rpc/clientor a helper inside@devframes/tui?Button,Switch,Tree,CodeBlock): keep them in@devframes/tui, or propose them upstream to@json-render/ink?NUXT_TERM_THEMEoverride. Do we want the same or a fixed palette in phase 1?References
nuxt devdocs (interactive terminal UI section): https://github.com/nuxt/cli/blob/main/docs/dev.md@json-render/ink: https://github.com/vercel-labs/json-render/tree/main/packages/inkdocs/content/1.guide/8.json-render.mddocs/content/1.guide/21.build-your-own-json-render-frontend.mdResearch and draft written with the help of an agent.