[GH-ISSUE #553] Proposal: The Grid™ #918

Closed
opened 2026-03-14 09:02:21 +03:00 by kerem · 5 comments
Owner

Originally created by @necropheus on GitHub (Jan 19, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/553

Idea

This is an idea I've been working on over the weekend, a grid component that has baked-in logic for keyboard-based layout navigation.

Problem: navigating the UI with your keyboard

Right now, if you want to navigate through the UI with your keyboard, you have to do 2 things:

  1. Implement your own focus system so you know where you're focusing
  2. Navigate the UI with useKeyboard

But that comes with some problems (in my opinion):

Tracking

Tracking your own focus system is not trivial. The more things you have in your UI, the more complex it gets: if focus is "foo" but not "bar" and flag "qux" === "mom", etc. It's basically a global state that grows in complexity as your app grows.

Key handling

You have to have multiple scattered useKeyboard calls through your project to manage their respective UIs, but that potentially becomes a performance issue, and it's hard to track (if you change something regarding your focus system, you have to remember to change the useKeyboard calls that use said system), not to mention you probably have to repeat logic everywhere, meaning, each useKeyboard has to go if (globalFocus === myContext).
An alternative would be to have just one giant useKeyboard call. That would solve the scattered calls and the repeated logic, but it would grow in complexity as your app grows, making it again hard to maintain.

Logical UI bugs

It's prone to bugs. The combination of multiple useKeyboard calls and the custom focus system has a high potential for logical bugs where a specific combination of keys and focus states breaks your whole UI

Partial solution (that should also be implemented in general, not just for the grid): focusable boxes

Now, there's one thing that could solve some of these problems: boxes being able to be focused, thus being able to receive key events.

Why would this solve some of these issues?

No more useKeyboard

No need for multiple useKeyboard calls. Each box has onKeyDown, and acts as its own focus system. Let's imagine this:

These are boxes. Arrays represent the fact that the UI has multiple elements:

A => [B, ...] => [C, ...] => [D, ...]
  • D has onKeyDown to capture left and right key events, because its UI goes left to right
  • C has onKeyDown to capture up and down events, because its UI goes up and down
  • B has onKeyDown to capture left and right key events, same as D, because its UI goes left to right too
  • A has onKeyDown to capture ctrl+{arrowKey} because its UI goes in all directions
  1. Now, if I'm focusing D and press the left key, D captures that event and moves its internal component's focus (which would be yet other focusable boxes), and stops propagation.
  2. Then, if I press left again, but this time there's nothing to the left of my current focused box inside of D's UI, D can bubble that event to C => C doesn't have anything to do with that key so it bubbles it to B => B captures that key and moves the focus away from C into one of its siblings and stops the event's propagation
  3. If now I press ctrl+left, it bubbles up all the way to A so it can focus one of B's siblings

And so on so forth, you get the idea

But, that also has a big problem: the focus model is too limited. Let's take point 2 as an example:

The things it can't solve easily

  • what happens if, when I press left and there's nothing to my left, instead of focusing one of C's siblings, I want to focus one of its children?
  • what happens if I want C to have some styling if suddenly one of its children gets focused without going through C?
  • what happens if, when I move my focus away from C, I want C to keep some of its styles, or some of its behaviors?
  • what happens if, when I go back to C, I want to go back to focusing the child it was focusing previously?

To do all of these things I have to start throwing refs everywhere, keep flags, add extra logic, etc.

So, a solution to that would be a component that manages all of that by itself. A component that natively has internal logic to be used as a UI navigation system...

Enter: The Matr-I(x) mean, The Grid™ (plus a new feature for focus that I'll explain later)

What is The Grid™ component? It's very straightforward, it behaves similarly to what I just described:

  • It's focusable, so it can capture keyboard events and/or bubble them up
  • It keeps an internal focus state for its immediate children
  • It keeps an internal layout logic so it knows what goes where

First, let's just look at a simple example of how it could work (keep in mind this is just a POC, it can probably look prettier and have better ergonomics):

<grid
    id="opencode"
    layout={[
        ["sessions", "chat",  "sidebar"],
        ["sessions", "chat",  "sidebar"],
        ["sessions", "chat",  "sidebar"],
        ["sessions", "input", "sidebar"]
    ]}
    keys={{
        left:  { name: "left",  conditions: ["ctrl"] },
        right: { name: "right", conditions: ["ctrl"] },
        up:    { name: "up",    conditions: ["ctrl"] },
        down:  { name: "down",  conditions: ["ctrl"] },
    }}
>
    <grid
        id="sessions"
        layout={[sessions.map((session) => sessions.id)]}
        align="column"
        keys={{
            up:    { name: "up" },
            down:  { name: "down"},
        }}
        onFocus={(selfRef) => {
            // I don't want to retain focus, I'll automagically pass it to my current locally focused child
            selfRef.focusCurrentLocal();
        }}
        onFocusWithin={(selfRef) => {
            selfRef.backgroundColor = selectedButNotReally;
        }}
        onBlurWithin={(selfRef) => {
            selfRef.backgroundColor = actuallyNotSelectedAtAll;
        }}
    >
        <For each={session}>
            {(session) => (
                <grid_node
                    id={session.id}
                    onKeyDown={(keyEv) => {
                        if (keyEv.name === "enter") {
                            openSession(session.id)
                        }
                    }}
                    onFocusLocal={(selfRef) => {
                        selfRef.backgroundColor = selected;
                    }}
                    onBlurLocal={(selfRef) => {
                        selfRef.backgroundColor = unselected;
                    }}
                >
                    ...
                </grid_node>
            )}
        </For>
    </grid>

    <grid
        id="chat"
        layout={[outputs.map((output) => output.id)]}
        align="column"
        keys={{
            up:      { name: "up"     },
            down:    { name: "down"   },
            focus:   { name: "enter"  },
            unfocus: { name: "escape" }
        }}
        onFocus={(selfRef) => {
            // I don't want to retain focus, I'll automagically pass it to the closest thing to the input
            selfRef.focusLastChild();
        }}
        onFocusWithin={(selfRef) => {
            selfRef.backgroundColor = selectedButNotReally;
        }}
        onBlurWithin={(selfRef) => {
            selfRef.backgroundColor = actuallyNotSelectedAtAll;
        }}
    >
        <For each={outputs}>
            {(output) => (
                <grid_node
                    id={output.id}
                    onFocus={() => enterVimMode(output.id)}
                    onKeyDown={(keyEv) => {
                        if (key === "escape" && vimMode && visualMode) {
                            exitVisualMode()
                            keyEv.stopPropagation()
                        }

                        // if not in vimMoed && visual mode then it simply propagates the event to its parent
                    }}
                >
                    <text>{output.content}</text>
                </grid_node>
            )}
        </For>
    </grid_node>

    <input id="input" ref={inputRef}/>

    <grid
        id="status"
        layout={[["context", "mcp", "LSP", "TODO", "files"]]}
        align="column"
        keys={{
            up:      { name: "up"     },
            down:    { name: "down"   },
            focus:   { name: "enter"  },
            unfocus: { name: "escape" }
        }}
        onFocus={(selfRef) => {
            // I don't want to retain focus, I'll automagically pass it to the closest thing to the input
            // If you came from the chat, I'm sorry lol
            selfRef.focusLastChild();
        }}
        onFocusWithin={(selfRef) => {
            selfRef.backgroundColor = selectedButNotReally;
        }}
        onBlurWithin={(selfRef) => {
            selfRef.backgroundColor = actuallyNotSelectedAtAll;
        }}
    >
        <grid_node id="context">...</grid_node>
        <grid_node id="MCP"    >...</grid_node>
        <grid_node id="LSP"    >...</grid_node>
        <grid_node id="TODO"   >...</grid_node>
        <grid_node id="files"  >...</grid_node>
    </grid>
</grid>

The Grid™ Breakdance

  1. We first have the general UI, which is inside a <grid id="opencode" ...> component. In there you can also notice that a grid receives a layout prop, which behaves very much like a CSS grid (except this one does not apply styles, you have to do that yourself. It could have styles but that's not relevant for this proposal).
    It's basically a 2D matrix that defines what's where. It must match the inner children's IDs so it knows what's what.
    You might already have noticed that some layouts receive just one level. That's why we have the align prop. It tells the grid if it's just one column (the most common case probably), or if it's a row (maybe a rail? It can have some use cases)

  2. It also receives a keys prop that tells it how to navigate its inner components, how to focus something, and how to unfocus (gain focus back) something (you might notice that this differs from my example files because I just had the idea of focus and unfocus)

  3. Then, inside that main grid there are other grids that do other things. For example:

  • sessions, which navigates through all of your sessions in a column fashion, and when you press enter it opens said session in your current UI
  • then there's the chat, where you can navigate each output with your arrow keys, again in a column fashion, and then enter vim mode to select some text to maybe cite it in a new message (damn I'm just giving you guys ideas for the TUI app)
    • you will notice that it captures the "escape" key to first check if you are in visual mode. If you are, it exits visual mode. If not, then it gives the event to its parent and then the parent would simply take focus back (which would exit vim mode entirely in this hypothetical scenario)
  • then the most exciting one: the input. Which... it's just that, the input. Not very exciting. But it shows that you don't need a grid or grid_node. As long as your component is focusable, it will focus it
  • Finally the status section which is there just for show, you already know by now everything it can do
  1. You also have the onFocus, onFocusWithin, onFocusLocal and its blur counterparts obviously, and each one of them has its uses. I'll give you some examples:
  • onFocus could most commonly be used to pass focus from intermediate UI elements to actual elements you want to focus inside of it. It can also be used to visually tell the user where they're actually focusing, to distinguish from the local focus of any other component; the actual, main focus.
  • onFocusWithin can be used by intermediate UI components again, so they can know when something inside of them is being focused without having to keep any global state of the thing focused right now is one of my descendants
  • onFocusLocal can be used similarly to the last part of the onFocus above: to signal which thing is selected but in a more global state. For example: to tell the user hey, you are looking at your "How to center a div" session, even if they are right now copying the code from the terminal for how to center a div, which is obviously another branch in the node tree that is not inside nor is the sessions branch
  • The blur counterparts complement those obviously
  1. Finally, the grid_node component is just a focusable box with extra features to interact with a grid

Inside The Grid™

Now, how does the grid work inside? It relies on 2 things:

  1. An upgrade of the current focus system (right now I just mimicked it but it could be native to every component because it has other usecases): focusWithin, which tells a component if any descendant (or itself) is focused.
    IMHO this feature should be baked into OpenTUI. If you want to navigate your UI with your keyboard (like everyone wants in a terminal), you must have a deeper understanding of focus.
    The web implementation right now lacks depth, it's very simple: we have one focus, for one thing only, and in most websites by default you can only navigate it with TAB which is just not very useful for this kind of thing.
  2. An inner focus tracking: focusLocal, a system for the grid to always tracks which component it focused last, and that focus doesn't go away when the grid or its entire branch loses focus. You can manually do it though if for some reason you want to

General The Grid™ stuff

The rest of the grid is just logic to know where id="thing-2" lives relative to the current focused id="thing-1" when it receives a keymap, and logic for giving and taking focus

There's also some stuff on inter-grid focus and blur. For instance: what happens when you suddenly focus something else, like with your mouse or with a command? Well, the grid or whichever thing you had focused will trigger its blur thing. Then, with the new focusWithin logic, it should tell its parent hey, I just blurred, you should do the same, and that chain repeats until we find something that can't be focused (thus can't be blurred).
This chain does not blur the focusLocal, that stays right where it is, so the UI can keep selections and stuff.

Final notes

There's files attached here if you want to try it. You'll notice some things are different because I had some ideas while writing this lol, but the core concept is there.
It's somewhat broken though because it was mainly done by Codex, so it has some rough edges with managing focus, but the general idea is there. It needs to be properly re-implemented from scratch to fix those rough edges and to make code cleaner (and probably more performant since right now it has some very naive algorithms I think)

Some enhancements that could be done:

  • The grid could hold an inner 2D graph for fast navigation. It's useful when you have a dynamic UI that grows.
    For example, let's say you have a slightly modified version of the previous example:
layout={[
    ["burger-menu", "header", "sidebar"],
    ...chat.map(output => (["sessions", output.id, "sidebar"])),
    ["sessions", "input", "sidebar"]
]}

In this case, if you are focusing the chat and you press left, you go to the sessions menu, but what happens if you then press up? With the current algorithm it needs to iterate over every "session" in every array inside it until it finds something different (the "burger-menu"). Navigation would then be O(chat.length), which is not ideal for those long running sessions.
If you instead had a graph, it could simply do currentLocalFocus.topElement.focus(). It's probably more complex to implement since you have to build that graph from its children (which is not trivial at all) and then update it (again, not trivial), but it would make navigation faster

  • There's also enhancements and edge cases for focus stuff, like deciding helpers or automating focus for most common cases, but once implemented and tested they should cover 99% of layout navigation needs

Summary

You now solved all of the problems I mentioned:

  1. No need to have a complex global focus tracking. Each The Grid™ handles focus locally, internally
  2. No need to have useKeyboard scattered all over the place with complex if key is "right" and focus is "something" but focus isn't "something else" and the sun is doing whatever then you can focus this component. Each grid has its own directional movement keys. They can be repeated through multiple grids without worrying about stepping each other because it all relies in the focus system and onKeyDown events.

Final note (this time for real)

I think key events should also expose the event target, just like the web does. I don't know if other events do, but if they don't I think they should too, it's a very useful feature (even for The Grid™)

Originally created by @necropheus on GitHub (Jan 19, 2026). Original GitHub issue: https://github.com/anomalyco/opentui/issues/553 # Idea This is an idea I've been working on over the weekend, a grid component that has baked-in logic for keyboard-based layout navigation. # Problem: navigating the UI with your keyboard Right now, if you want to navigate through the UI with your keyboard, you have to do 2 things: 1. Implement your own focus system so you know *where* you're focusing 2. Navigate the UI with `useKeyboard` But that comes with some problems (in my opinion): ## Tracking Tracking your own focus system is not trivial. The more things you have in your UI, the more complex it gets: `if focus is "foo" but not "bar" and flag "qux" === "mom"`, etc. It's basically a global state that grows in complexity as your app grows. ## Key handling You have to have multiple scattered `useKeyboard` calls through your project to manage their respective UIs, but that potentially becomes a performance issue, and it's hard to track (if you change something regarding your focus system, you have to remember to change the `useKeyboard` calls that use said system), not to mention you probably have to repeat logic everywhere, meaning, each `useKeyboard` has to go `if (globalFocus === myContext)`. An alternative would be to have just one giant `useKeyboard` call. That would solve the scattered calls and the repeated logic, but it would grow in complexity as your app grows, making it again hard to maintain. ## Logical UI bugs It's prone to bugs. The combination of multiple `useKeyboard` calls and the custom focus system has a high potential for logical bugs where a specific combination of keys and focus states breaks your whole UI # Partial solution (that should also be implemented in general, not just for the grid): focusable boxes Now, there's one thing that could solve some of these problems: boxes being able to be focused, thus being able to receive key events. Why would this solve some of these issues? ## No more `useKeyboard` No need for multiple `useKeyboard` calls. Each box has `onKeyDown`, and acts as its own focus system. Let's imagine this: These are boxes. Arrays represent the fact that the UI has multiple elements: ``` A => [B, ...] => [C, ...] => [D, ...] ``` - **D** has `onKeyDown` to capture left and right key events, because its UI goes left to right - **C** has `onKeyDown` to capture up and down events, because its UI goes up and down - **B** has `onKeyDown` to capture left and right key events, same as **D**, because its UI goes left to right too - **A** has `onKeyDown` to capture ctrl+{arrowKey} because its UI goes in all directions 1. Now, if I'm focusing **D** and press the left key, **D** captures that event and moves its internal component's focus (which would be yet other focusable boxes), and stops propagation. 2. Then, if I press left again, but this time there's nothing to the left of my current focused box inside of **D**'s UI, **D** can bubble that event to **C** => **C** doesn't have anything to do with that key so it bubbles it to **B** => **B** captures that key and moves the focus away from **C** into one of its siblings and stops the event's propagation 3. If now I press `ctrl+left`, it bubbles up all the way to **A** so it can focus one of **B**'s siblings And so on so forth, you get the idea But, that also has a big problem: the focus model is too limited. Let's take point 2 as an example: ## The things it can't solve easily - what happens if, when I press `left` and there's nothing to my left, instead of focusing one of **C**'s siblings, I want to focus one of its children? - what happens if I want C to have some styling if suddenly one of its children gets focused without going through **C**? - what happens if, when I move my focus away from **C**, I want **C** to keep some of its styles, or some of its behaviors? - what happens if, when I go back to **C**, I want to go back to focusing the child it was focusing previously? To do all of these things I have to start throwing refs everywhere, keep flags, add extra logic, etc. So, a solution to that would be a component that manages all of that by itself. A component that natively has internal logic to be used as a UI navigation system... # Enter: **The Matr**-I(x) mean, **The Grid™** (plus a new feature for `focus` that I'll explain later) What is **The `Grid™`** component? It's very straightforward, it behaves similarly to what I just described: - It's focusable, so it can capture keyboard events and/or bubble them up - It keeps an internal focus state for its immediate children <!-- Failed to upload "grid.tsx" --> <!-- Failed to upload "box_focusable.ts" --> <!-- Failed to upload "grid.ts" --> <!-- Failed to upload "components.d.ts" --> <!-- Failed to upload "index.ts" --> <!-- Failed to upload "grid.tsx" --> - It keeps an internal *layout* logic so it knows what goes where First, let's just look at a simple example of how it could work (keep in mind this is just a POC, it can probably look prettier and have better ergonomics): ```tsx <grid id="opencode" layout={[ ["sessions", "chat", "sidebar"], ["sessions", "chat", "sidebar"], ["sessions", "chat", "sidebar"], ["sessions", "input", "sidebar"] ]} keys={{ left: { name: "left", conditions: ["ctrl"] }, right: { name: "right", conditions: ["ctrl"] }, up: { name: "up", conditions: ["ctrl"] }, down: { name: "down", conditions: ["ctrl"] }, }} > <grid id="sessions" layout={[sessions.map((session) => sessions.id)]} align="column" keys={{ up: { name: "up" }, down: { name: "down"}, }} onFocus={(selfRef) => { // I don't want to retain focus, I'll automagically pass it to my current locally focused child selfRef.focusCurrentLocal(); }} onFocusWithin={(selfRef) => { selfRef.backgroundColor = selectedButNotReally; }} onBlurWithin={(selfRef) => { selfRef.backgroundColor = actuallyNotSelectedAtAll; }} > <For each={session}> {(session) => ( <grid_node id={session.id} onKeyDown={(keyEv) => { if (keyEv.name === "enter") { openSession(session.id) } }} onFocusLocal={(selfRef) => { selfRef.backgroundColor = selected; }} onBlurLocal={(selfRef) => { selfRef.backgroundColor = unselected; }} > ... </grid_node> )} </For> </grid> <grid id="chat" layout={[outputs.map((output) => output.id)]} align="column" keys={{ up: { name: "up" }, down: { name: "down" }, focus: { name: "enter" }, unfocus: { name: "escape" } }} onFocus={(selfRef) => { // I don't want to retain focus, I'll automagically pass it to the closest thing to the input selfRef.focusLastChild(); }} onFocusWithin={(selfRef) => { selfRef.backgroundColor = selectedButNotReally; }} onBlurWithin={(selfRef) => { selfRef.backgroundColor = actuallyNotSelectedAtAll; }} > <For each={outputs}> {(output) => ( <grid_node id={output.id} onFocus={() => enterVimMode(output.id)} onKeyDown={(keyEv) => { if (key === "escape" && vimMode && visualMode) { exitVisualMode() keyEv.stopPropagation() } // if not in vimMoed && visual mode then it simply propagates the event to its parent }} > <text>{output.content}</text> </grid_node> )} </For> </grid_node> <input id="input" ref={inputRef}/> <grid id="status" layout={[["context", "mcp", "LSP", "TODO", "files"]]} align="column" keys={{ up: { name: "up" }, down: { name: "down" }, focus: { name: "enter" }, unfocus: { name: "escape" } }} onFocus={(selfRef) => { // I don't want to retain focus, I'll automagically pass it to the closest thing to the input // If you came from the chat, I'm sorry lol selfRef.focusLastChild(); }} onFocusWithin={(selfRef) => { selfRef.backgroundColor = selectedButNotReally; }} onBlurWithin={(selfRef) => { selfRef.backgroundColor = actuallyNotSelectedAtAll; }} > <grid_node id="context">...</grid_node> <grid_node id="MCP" >...</grid_node> <grid_node id="LSP" >...</grid_node> <grid_node id="TODO" >...</grid_node> <grid_node id="files" >...</grid_node> </grid> </grid> ``` ## **The Grid™** Breakdance 1. We first have the general UI, which is inside a `<grid id="opencode" ...>` component. In there you can also notice that a grid receives a `layout` prop, which behaves very much like a CSS grid (except this one does not apply styles, you have to do that yourself. It could have styles but that's not relevant for this proposal). It's basically a 2D matrix that defines what's where. It must match the inner children's IDs so it knows what's what. You might already have noticed that some layouts receive just one level. That's why we have the `align` prop. It tells the grid if it's just one column (the most common case probably), or if it's a row (maybe a rail? It can have some use cases) 2. It also receives a `keys` prop that tells it how to navigate its inner components, how to focus something, and how to unfocus (gain focus back) something (you might notice that this differs from my example files because I just had the idea of focus and unfocus) 3. Then, inside that main grid there are other grids that do other things. For example: - sessions, which navigates through all of your sessions in a column fashion, and when you press enter it opens said session in your current UI - then there's the chat, where you can navigate each output with your arrow keys, again in a column fashion, and then enter vim mode to select some text to maybe cite it in a new message (damn I'm just giving you guys ideas for the TUI app) - you will notice that it captures the "escape" key to first check if you are in visual mode. If you are, it exits visual mode. If not, then it gives the event to its parent and then the parent would simply take focus back (which would exit vim mode entirely in this hypothetical scenario) - then the most exciting one: the input. Which... it's just that, the input. Not very exciting. But it shows that you don't need a `grid` or `grid_node`. As long as your component is focusable, it will focus it - Finally the status section which is there just for show, you already know by now everything it can do 4. You also have the `onFocus`, `onFocusWithin`, `onFocusLocal` and its `blur` counterparts obviously, and each one of them has its uses. I'll give you some examples: - `onFocus` could most commonly be used to pass focus from intermediate UI elements to actual elements you want to focus inside of it. It can also be used to visually tell the user where they're actually focusing, to distinguish from the local focus of any other component; the actual, main focus. - `onFocusWithin` can be used by intermediate UI components again, so they can know when something inside of them is being focused without having to keep any global state of *the thing focused right now is one of my descendants* - `onFocusLocal` can be used similarly to the last part of the `onFocus` above: to signal which thing is selected but in a more global state. For example: to tell the user *hey, you are looking at your "How to center a div" session*, even if they are right now copying the code from the terminal for how to center a div, which is obviously another branch in the node tree that is not inside nor *is* the `sessions` branch - The `blur` counterparts complement those obviously 5. Finally, the `grid_node` component is just a focusable box with extra features to interact with a grid ## Inside **The Grid™** Now, how does the grid work inside? It relies on 2 things: 1. An upgrade of the current focus system (right now I just mimicked it but it could be native to every component because it has other usecases): `focusWithin`, which tells a component if any descendant (or itself) is focused. IMHO this feature should be baked into OpenTUI. If you want to navigate your UI with your keyboard (like everyone wants in a terminal), you **must** have a deeper understanding of focus. The web implementation right now lacks depth, it's very simple: we have one focus, for one thing only, and in most websites by default you can only navigate it with `TAB` which is just not very useful for this kind of thing. 2. An inner focus tracking: `focusLocal`, a system for the grid to always tracks which component it focused last, and that focus **doesn't go away** when the grid or its entire branch loses focus. You can manually do it though if for some reason you want to ## General **The Grid™** stuff The rest of the grid is just logic to know where `id="thing-2"` lives relative to the current focused `id="thing-1"` when it receives a keymap, and logic for giving and taking focus There's also some stuff on inter-grid focus and blur. For instance: what happens when you suddenly focus something else, like with your mouse or with a command? Well, the grid or whichever thing you had focused will trigger its `blur` thing. Then, with the new `focusWithin` logic, it should tell its parent *hey, I just blurred, you should do the same*, and that chain repeats until we find something that can't be focused (thus can't be blurred). This chain **does not** blur the `focusLocal`, that stays right where it is, so the UI can keep selections and stuff. # Final notes There's files attached here if you want to try it. You'll notice some things are different because I had some ideas while writing this lol, but the core concept is there. It's somewhat broken though because it was mainly done by Codex, so it has some rough edges with managing focus, but the general idea is there. It needs to be properly re-implemented from scratch to fix those rough edges and to make code cleaner (and probably more performant since right now it has some very naive algorithms I think) Some enhancements that could be done: - The grid could hold an inner 2D graph for fast navigation. It's useful when you have a dynamic UI that grows. For example, let's say you have a slightly modified version of the previous example: ```ts layout={[ ["burger-menu", "header", "sidebar"], ...chat.map(output => (["sessions", output.id, "sidebar"])), ["sessions", "input", "sidebar"] ]} ``` In this case, if you are focusing the chat and you press left, you go to the sessions menu, but what happens if you then press up? With the current algorithm it needs to iterate over every "session" in every array inside it until it finds something different (the "burger-menu"). Navigation would then be `O(chat.length)`, which is not ideal for those long running sessions. If you instead had a graph, it could simply do `currentLocalFocus.topElement.focus()`. It's probably more complex to implement since you have to build that graph from its children (which is not trivial at all) and then update it (again, not trivial), but it would make navigation faster - There's also enhancements and edge cases for focus stuff, like deciding helpers or automating focus for most common cases, but once implemented and tested they should cover 99% of layout navigation needs ## Summary You now solved all of the problems I mentioned: 1. No need to have a complex global focus tracking. Each **The Grid™** handles focus locally, internally 2. No need to have `useKeyboard` scattered all over the place with complex `if key is "right" and focus is "something" but focus isn't "something else" and the sun is doing whatever then you can focus this component`. Each grid has its own directional movement keys. They can be repeated through multiple grids without worrying about stepping each other because it all relies in the focus system and `onKeyDown` events. # Final note (this time for real) I think key events should also expose the event target, just like the web does. I don't know if other events do, but if they don't I think they should too, it's a very useful feature (even for **The Grid™**)
kerem 2026-03-14 09:02:21 +03:00
  • closed this issue
  • added the
    feature
    label
Author
Owner

@necropheus commented on GitHub (Jan 19, 2026):

Oh, I can't attach files. I'll have to make a draft PR and link it here I guess

Edit: here it is https://github.com/anomalyco/opentui/pull/554
The grid.tsx example was made for solid, but you can translate it to react and it should work too
You can just copy the files into a folder, import that folder inside of your index.tsx file so it can catch the components.d.ts file and stuff, and then you have the grid.tsx file to try it out.

You might notice that some tiny stuff with focus seems to be broken but it works in general

A quick explanation of the UI:

F = Focused (native)
W = Focused Within
L = Focused Local

<!-- gh-comment-id:3768685816 --> @necropheus commented on GitHub (Jan 19, 2026): Oh, I can't attach files. I'll have to make a draft PR and link it here I guess Edit: here it is https://github.com/anomalyco/opentui/pull/554 The `grid.tsx` example was made for solid, but you can translate it to react and it should work too You can just copy the files into a folder, import that folder inside of your `index.tsx` file so it can catch the `components.d.ts` file and stuff, and then you have the `grid.tsx` file to try it out. You might notice that some tiny stuff with focus seems to be broken but it works in general A quick explanation of the UI: F = Focused (native) W = Focused Within L = Focused Local
Author
Owner

@remorses commented on GitHub (Jan 19, 2026):

I think this adds unnecessary complexity. The problems you describe are already solved elegantly with simple React/Solid patterns using state + the focused prop.

Here's your opencode example rewritten:

const SECTIONS = ["sessions", "chat", "input", "sidebar"] as const

const App = () => {
  const [section, setSection] = useState(0)
  const [sessionIndex, setSessionIndex] = useState(0)
  const [chatIndex, setChatIndex] = useState(0)

  useKeyboard((key) => {
    // ctrl+arrow for section navigation
    if (key.ctrl) {
      if (key.name === "left") setSection(i => (i - 1 + SECTIONS.length) % SECTIONS.length)
      if (key.name === "right") setSection(i => (i + 1) % SECTIONS.length)
      return
    }

    // arrow keys within sections
    if (SECTIONS[section] === "sessions") {
      if (key.name === "up") setSessionIndex(i => (i - 1 + sessions.length) % sessions.length)
      if (key.name === "down") setSessionIndex(i => (i + 1) % sessions.length)
      if (key.name === "return") openSession(sessions[sessionIndex].id)
    }
    
    if (SECTIONS[section] === "chat") {
      if (key.name === "up") setChatIndex(i => (i - 1 + outputs.length) % outputs.length)
      if (key.name === "down") setChatIndex(i => (i + 1) % outputs.length)
    }
  })

  const isFocused = (name: string) => SECTIONS[section] === name

  return (
    <box flexDirection="row">
      <box backgroundColor={isFocused("sessions") ? selectedBg : normalBg}>
        {sessions.map((session, i) => (
          <box 
            key={session.id}
            backgroundColor={isFocused("sessions") && i === sessionIndex ? activeBg : undefined}
          >
            {session.name}
          </box>
        ))}
      </box>

      <box flexDirection="column">
        <scrollbox focused={isFocused("chat")}>
          {outputs.map((output, i) => (
            <text 
              key={output.id}
              style={{ bg: isFocused("chat") && i === chatIndex ? activeBg : undefined }}
            >
              {output.content}
            </text>
          ))}
        </scrollbox>
        
        <input focused={isFocused("input")} />
      </box>

      <Sidebar focused={isFocused("sidebar")} />
    </box>
  )
}

This solves all the same problems:

  1. No complex global focus tracking - just simple indices
  2. No scattered useKeyboard calls - one central handler
  3. Focus retention - indices preserved in state automatically
  4. Visual feedback for parent focus - isFocused("sessions") for container styling
  5. Wrap-around navigation - modulo handles cycling

Why this is simpler:

  • No new primitives or special components
  • Works with existing React/Solid patterns
  • Easy to understand, debug, and extend
  • The focused prop already handles keyboard input routing
<!-- gh-comment-id:3769416510 --> @remorses commented on GitHub (Jan 19, 2026): I think this adds unnecessary complexity. The problems you describe are already solved elegantly with simple React/Solid patterns using state + the `focused` prop. Here's your opencode example rewritten: ```tsx const SECTIONS = ["sessions", "chat", "input", "sidebar"] as const const App = () => { const [section, setSection] = useState(0) const [sessionIndex, setSessionIndex] = useState(0) const [chatIndex, setChatIndex] = useState(0) useKeyboard((key) => { // ctrl+arrow for section navigation if (key.ctrl) { if (key.name === "left") setSection(i => (i - 1 + SECTIONS.length) % SECTIONS.length) if (key.name === "right") setSection(i => (i + 1) % SECTIONS.length) return } // arrow keys within sections if (SECTIONS[section] === "sessions") { if (key.name === "up") setSessionIndex(i => (i - 1 + sessions.length) % sessions.length) if (key.name === "down") setSessionIndex(i => (i + 1) % sessions.length) if (key.name === "return") openSession(sessions[sessionIndex].id) } if (SECTIONS[section] === "chat") { if (key.name === "up") setChatIndex(i => (i - 1 + outputs.length) % outputs.length) if (key.name === "down") setChatIndex(i => (i + 1) % outputs.length) } }) const isFocused = (name: string) => SECTIONS[section] === name return ( <box flexDirection="row"> <box backgroundColor={isFocused("sessions") ? selectedBg : normalBg}> {sessions.map((session, i) => ( <box key={session.id} backgroundColor={isFocused("sessions") && i === sessionIndex ? activeBg : undefined} > {session.name} </box> ))} </box> <box flexDirection="column"> <scrollbox focused={isFocused("chat")}> {outputs.map((output, i) => ( <text key={output.id} style={{ bg: isFocused("chat") && i === chatIndex ? activeBg : undefined }} > {output.content} </text> ))} </scrollbox> <input focused={isFocused("input")} /> </box> <Sidebar focused={isFocused("sidebar")} /> </box> ) } ``` **This solves all the same problems:** 1. **No complex global focus tracking** - just simple indices 2. **No scattered useKeyboard calls** - one central handler 3. **Focus retention** - indices preserved in state automatically 4. **Visual feedback for parent focus** - `isFocused("sessions")` for container styling 5. **Wrap-around navigation** - modulo handles cycling **Why this is simpler:** - No new primitives or special components - Works with existing React/Solid patterns - Easy to understand, debug, and extend - The `focused` prop already handles keyboard input routing
Author
Owner

@necropheus commented on GitHub (Jan 19, 2026):

I get the point, and it would only work for a small/static UI, but I don’t think it actually addresses the general problem I'm describing.
It solves the demo by turning navigation into an app-level keyboard router + a global mode state, which is exactly what starts to collapse as your app grows (both in size and complexity).

  1. The “one giant useKeyboard” becomes an app-level state machine
    In your example, the handler is manageable because the number of sections is fixed and small, each section is basically a linear list + an index and there are no nested focus modes

But in real case scenarios you quickly add features that force layered branching. I'll give you some examples:

  • Command palette: captures all input until closed. How do you give focus back when you close it? How do you rember where you where focusing before? You need another layer of branch checking
  • Modal dialogs: same thing
  • Search mode: typing affects filtering, arrows navigate results. How do you pick between down meaning navigating your results or moving to the section below your search bar? It's another layer of checking
  • Vim/visual mode inside chat output: escape has different meaning.
    • how do you know that escape means exiting normal mode or visual mode and not exiting the chat?
    • what if you have a global escape keymap? What about 3 escape keymaps? How do you order the if checks? First check the focus and then the key? The other way? What if this pattern repeats for like 10 keys? Which order do you pick for which keys? It's yet another layer of checkings that becomes increasily dangerous
  • Sub-focus inside a panel (tabs, nested lists, tree views, inputs): how does a child tell 3 parents above that it just got randomly blurred or randomly focused (either via mouse interaction or programmatically)? You have 2 options
    • you either add to every component onBlur={() => tellTheGlobalFocusStateThatIBlurred()}
    • or you add to every component onFocus={() => tellTheGlobalFocusStateThatIFocused()}

That turns the handler into something like:

  • if commandPaletteOpen -> handle keys, return
  • else if modalOpen -> handle keys, return
  • else if globalShortcut (ctrl+p, ctrl+k, ?) -> handle, return
  • else switch(activeSection):
    • sessions: handle arrows, enter, type-to-search
    • chat: handle arrows, but if vimMode then arrows mean something else, also escape means something else, etc.
    • sidebar: handle its own nested focus (tabs + list + tree)

This is exactly the global focus complexity grows with the app issue. The code isn’t “wrong”, it just accumulates complexity in one place. And it accumulates unnecessary checks on every keydown that could be avoided by using a focus system

  1. Expanding on something above, the issue with the “single central useKeyboard router” pattern is handler precedence / ordering.

With keys like escape (or enter, ctrl+k, etc.) you don’t just need logic for “what does this key do?”, you need a consistent rule for “who gets to handle it first?”.

Example: escape can mean different things depending on where you are:

  • In Chat visual mode: exit visual mode
  • In Chat vim mode (but not visual): exit vim mode / return to normal chat navigation
  • In an input/searchbar: clear input / cancel search / blur input
  • In a modal: close modal
  • Globally: “go back” / close the current panel / cancel the current operation

In a central handler, this becomes an ordering problem:
Do you check if inputFocused before if chatMode?
What if a global escape should override local ones?
What if a local escape should override the global one only in some states?

Once you add more global shortcuts that also exist locally (ctrl+k, ctrl+p, enter, tab, / for search, etc.), you get either:

  • Repeated checks everywhere because you either first check the focus and then the key, or check the key and then the focus:

    • if (paletteOpen) { if (key.name === "down") }
    • and then in another place if (modalOpen) { if (key.name === "down") }
    • or if (searchbarFocused) { if (key.name === "escape")}
    • and then in the chat if (chatFocused && vimMode && visualMode) { if (key.name === "escape) }
      And you have to do all of this for each key that is used multiple times through your app, maintaining coherence and correctness, and creating a lot of duplicated code
  • Or you encode a manual priority system (basically reinventing event propagation):

    • components can claim a key based on current focus + state
    • highest priority wins
    • everyone else ignores

But that’s exactly my point: with a central router you inevitably rebuild a propagation model by hand, and as you add 10 global keymaps that also repeat in 3–4 components, you start duplicating logic and fighting precedence bugs.

In contrast, with focusable nodes + bubbling:

  • The currently focused node gets first shot at the event
  • If it can’t handle it (or decides not to), it bubbles to its parent
  • Parents can implement “global” behavior naturally (top-level can treat escape as “cancel/back”)
  • You get a deterministic precedence rule without inventing a bespoke ordering in one big switch statement
  1. “Simple indices” stops being simple once you have nested focus
    Your demo has a section (which panel is active), a sessionIndex and a chatIndex

But as soon as “chat” has an internal sub-mode (like your vim mode example), you get extra state:

  • chatMode: normal | vim | visual | search
  • selectionStart/End (visual mode)
  • cursor position for text navigation
  • maybe “active widget” inside the output (copy button, link list, code block, etc.)

Same with sidebar:

  • activeTabIndex
  • activeTreeNodeId
  • activeTreeDepth / expanded nodes

Now the “global focus” is no longer “just indices”. It’s a global state machine describing many independent components’ internals.

With the grid, those states can be encapsulated into the component that actually use them, and without having to worry about focus or external events, they just act. If they aren't on focus, they simply don't get the event at all, they don't have to check, the grid does it for them

  1. focused / isFocused(section) is not the same as focusWithin
    Your argument says “The focused prop already handles keyboard routing”.
    That helps decide “who receives key input”, but it doesn’t provide the behavior I’m asking for: parents reacting to descendant focus without manual wiring.

Concrete examples where focus within matters:

a) Styling parents when a deep child is focused
Say you have:

<SessionsPanel>
  <SessionItem>
    <Button/>
    <Input/>
  </SessionItem>
</SessionsPanel>

If the user focuses the Input inside SessionItem (mouse or programmatically), the SessionsPanel often wants a “focus-within” style (like :focus-within on CSS).
With section === "sessions", you’re not expressing a descendant is focused, you’re expressing this panel is the active navigation mode.
Those are different. You can be in sessions mode but focused on a nested input in a different branch, or you can programmatically focus something without updating section.
To solve this, you'd have to call setSection("sessions") on every component that could potentially be focused in your UI that's inside the same branch as the "sessions" component

With the grid you don't need to, the grid does that internally and tells the component "hey, you are focused within, do your thing". No need to track global focus for every component

b) Programmatic focus / mouse focus
If some command does inputRef.focus() or the user clicks into chat, your keyboard router must update section and any relevant sub-state to match reality, otherwise your global state is out of sync with actual focus.
So you either:

  • add onFocus/onBlur hooks everywhere to keep the global router in sync, or
  • build a registry system, or
  • accept inconsistent UI states (highlight says “sessions” but actual focus is in input)

The grid handles this too: focus is the source of truth, and focusWithin lets ancestors react without the app-level router needing to know the entire subtree.

  1. Reusability/composition: app-level routing forces prop drilling or context
    In your snippet you render the session item inline:
sessions.map((session, i) => (
  <box backgroundColor={...}> ... </box>
))

But in a real project, you don’t keep it inline. You do:

sessions.map(session => <SessionItem session={session} />)

Now SessionItem needs to know whether it’s focused, whether its parent panel is focused, and how to change the local index on arrow keys (or at least which index is active)

So you either:

  • prop drill: focused, activeIndex, setActiveIndex, section, setSection, etc.
  • or create contexts: SessionsContext, FocusContext, KeyboardContext
  • or keep pushing logic upward: SessionItem becomes “dumb”, App owns everything

All three are workable, but they’re exactly what I’m trying to avoid: navigation logic leaking across component boundaries

With a component-local navigation model, SessionItem can encapsulate:

  • local focus behavior
  • onFocusLocal/onBlurLocal styling
  • key handling only when it is actually focused
    and the parent doesn’t need to know its internal structure, nor does it need to keep checking if it's children are actually being focused on every keystroke.
  1. Bubbling/delegation: in a composable navigation model, a child can attempt to handle a key and, if it can’t, bubble it to the parent.

Concrete example:

  • Chat panel shows outputs in a column.
  • Inside each output, there is a small horizontal “actions row” (copy, cite, open, etc.)

So we have:

ChatPanel (vertical navigation)
  L OutputItem (vertical list item)
    L ActionsRow (horizontal navigation)

What you'd want:

  • left/right moves inside ActionsRow if you’re in it
  • If you press left on the leftmost action, the event bubbles:
    • ActionsRow can’t move further -> bubble to OutputItem / ChatPanel
    • ChatPanel decides that left means “move to Sessions panel” (it doesn't even decide because it doesn't need to check for anything, it just has keys={{ left: { name: "left" }}}) and when it gets a left event it moves to the left

In a central router, this becomes:

  • global code has to know whether you’re inside ActionsRow
  • global code has to know whether ActionsRow is at an edge
  • global code has to know the layout relationship (chat’s left neighbor is sessions)

That is basically manually encoding a focus graph and edge rules at the app level. And adds the same problems I've been saying: growing complexity of branch checking, if statements, repeated logic, etc.

The grid approach encodes the adjacency rules once (layout) and lets local components manage their own internal navigation, while still having a consistent escape hatch (bubbling) when a local move is impossible.

Again, I'm not saying your point isn't valid, it works, it's a good minimal pattern for small TUIs, but it doesn’t provide:

  • composition (component-local navigation)
  • focusWithin semantics for ancestor reactions
  • layout-aware navigation without hardcoding graphs
  • bubbling/delegation for “can’t handle this key, parent decide”
  • a way to avoid unnecessary checks every time you press a key
  • retaining focus information between focus and blurring
  • and many more

As soon as it starts growing, it starts to increase in complexity, thus making it harder to maintain and increases the chances of global focus state sync bugs, and those are the problems I’m proposing to solve with the grid

  1. Finally, there’s also a more fundamental design level aspect to this.

OpenTUI follows React/Solid patterns, which come from the web, but the web was designed around navigating with the mouse.
The keyboard is a secondary input there: tab order is linear, navigation is limited, and anything more complex is implemented manually in userland.

A terminal is different: the keyboard is not a secondary input, it is the primary one.

That means we have a different playground here: keyboard navigation as a first class native concept, the same way mouse navigation is native on the web.

Today, using a central useKeyboard router is effectively treating the keyboard like a low-level event stream: “a key was pressed, now decide what to do”.

But that’s analogous to handling mouse navigation on the web by listening to global mouse events and manually deciding which component is under the cursor.
We don’t do that because the DOM already encodes structure, hierarchy, and spatial relationships.

My proposal is about doing the same for keyboard navigation: focus and navigation should follow your keyboard naturally, by design, because you are in a keyboard first environment.
To me it should be something that you shouldn't have to implement manually every time you make a TUI. It should just be the default, and then you can add on top of it.

Damn that was a long yap yap session

<!-- gh-comment-id:3769738879 --> @necropheus commented on GitHub (Jan 19, 2026): I get the point, and it would only work for a small/static UI, but I don’t think it actually addresses the general problem I'm describing. It solves the demo by turning navigation into an app-level keyboard router + a global *mode* state, which is exactly what starts to collapse as your app grows (both in size and complexity). 1) The “one giant `useKeyboard`” becomes an app-level state machine In your example, the handler is manageable because the number of sections is fixed and small, each section is basically a linear list + an index and there are no nested focus modes But in real case scenarios you quickly add features that force layered branching. I'll give you some examples: - Command palette: captures **all** input until closed. How do you give focus back when you close it? How do you rember where you where focusing before? You need another layer of branch checking - Modal dialogs: same thing - Search mode: typing affects filtering, arrows navigate results. How do you pick between `down` meaning navigating your results or moving to the section below your search bar? It's another layer of checking - Vim/visual mode inside chat output: `escape` has different meaning. - how do you know that `escape` means exiting normal mode or visual mode and not exiting the chat? - what if you have a global `escape` keymap? What about 3 `escape` keymaps? How do you order the if checks? First check the focus and then the key? The other way? What if this pattern repeats for like 10 keys? Which order do you pick for which keys? It's yet another layer of checkings that becomes increasily dangerous - Sub-focus inside a panel (tabs, nested lists, tree views, inputs): how does a child tell 3 parents above that it just got randomly blurred or randomly focused (either via mouse interaction or programmatically)? You have 2 options - you either add to every component `onBlur={() => tellTheGlobalFocusStateThatIBlurred()}` - or you add to every component `onFocus={() => tellTheGlobalFocusStateThatIFocused()}` That turns the handler into something like: - if commandPaletteOpen -> handle keys, return - else if modalOpen -> handle keys, return - else if globalShortcut (ctrl+p, ctrl+k, ?) -> handle, return - else switch(activeSection): - sessions: handle arrows, enter, type-to-search - chat: handle arrows, but if `vimMode` then arrows mean something else, also `escape` means something else, etc. - sidebar: handle its own nested focus (tabs + list + tree) This is exactly the *global focus complexity grows with the app* issue. The code isn’t “wrong”, it just accumulates complexity in one place. And it accumulates unnecessary checks on every keydown that could be avoided by using a focus system 2) Expanding on something above, the issue with the “single central useKeyboard router” pattern is handler precedence / ordering. With keys like `escape` (or `enter`, `ctrl+k`, etc.) you don’t just need logic for “what does this key do?”, you need a consistent rule for “who gets to handle it first?”. Example: `escape` can mean different things depending on where you are: - In Chat visual mode: exit visual mode - In Chat vim mode (but not visual): exit vim mode / return to normal chat navigation - In an input/searchbar: clear input / cancel search / blur input - In a modal: close modal - Globally: “go back” / close the current panel / cancel the current operation In a central handler, this becomes an ordering problem: Do you check `if inputFocused` before `if chatMode`? What if a global `escape` should override local ones? What if a local `escape` should override the global one only in some states? Once you add more global shortcuts that also exist locally (`ctrl+k`, `ctrl+p`, `enter`, `tab`, `/` for search, etc.), you get either: - Repeated checks everywhere because you either first check the focus and then the key, or check the key and then the focus: - `if (paletteOpen) { if (key.name === "down") }` - and then in another place `if (modalOpen) { if (key.name === "down") }` - or `if (searchbarFocused) { if (key.name === "escape")}` - and then in the chat `if (chatFocused && vimMode && visualMode) { if (key.name === "escape) }` And you have to do all of this for each key that is used multiple times through your app, maintaining coherence and correctness, and creating a lot of duplicated code - Or you encode a manual priority system (basically reinventing event propagation): - *components can claim a key* based on current focus + state - highest priority wins - everyone else ignores But that’s exactly my point: with a central router you inevitably rebuild a propagation model by hand, and as you add 10 global keymaps that also repeat in 3–4 components, you start duplicating logic and fighting precedence bugs. In contrast, with focusable nodes + bubbling: - The currently focused node gets first shot at the event - If it can’t handle it (or decides not to), it bubbles to its parent - Parents can implement “global” behavior naturally (top-level can treat `escape` as “cancel/back”) - You get a deterministic precedence rule without inventing a bespoke ordering in one big switch statement 2) “Simple indices” stops being simple once you have nested focus Your demo has a section (which panel is active), a sessionIndex and a chatIndex But as soon as “chat” has an internal sub-mode (like your vim mode example), you get extra state: - chatMode: normal | vim | visual | search - selectionStart/End (visual mode) - cursor position for text navigation - maybe “active widget” inside the output (copy button, link list, code block, etc.) Same with sidebar: - activeTabIndex - activeTreeNodeId - activeTreeDepth / expanded nodes Now the “global focus” is no longer “just indices”. It’s a global state machine describing many independent components’ internals. With the grid, those states can be encapsulated into the component that actually use them, and without having to worry about focus or external events, they just act. If they aren't on focus, they simply don't get the event at all, they don't have to check, the grid does it for them 3) `focused` / `isFocused(section)` is not the same as `focusWithin` Your argument says “The focused prop already handles keyboard routing”. That helps decide “who receives key input”, but it doesn’t provide the behavior I’m asking for: parents reacting to descendant focus without manual wiring. Concrete examples where focus within matters: a) Styling parents when a deep child is focused Say you have: ```ts <SessionsPanel> <SessionItem> <Button/> <Input/> </SessionItem> </SessionsPanel> ``` If the user focuses the Input inside SessionItem (mouse or programmatically), the SessionsPanel often wants a “focus-within” style (like `:focus-within` on CSS). With `section === "sessions"`, you’re not expressing *a descendant is focused*, you’re expressing *this panel is the active navigation mode*. Those are different. You can be *in sessions mode* but focused on a nested input in a different branch, or you can programmatically focus something without updating `section`. To solve this, you'd have to call `setSection("sessions")` on every component that could potentially be focused in your UI that's inside the same branch as the "sessions" component With the grid you don't need to, the grid does that internally and tells the component "hey, you are focused within, do your thing". No need to track global focus for every component b) Programmatic focus / mouse focus If some command does `inputRef.focus()` or the user clicks into chat, your keyboard router must update `section` and any relevant sub-state to match reality, otherwise your global state is out of sync with actual focus. So you either: - add onFocus/onBlur hooks everywhere to keep the global router in sync, or - build a registry system, or - accept inconsistent UI states (highlight says “sessions” but actual focus is in input) The grid handles this too: focus is the source of truth, and `focusWithin` lets ancestors react without the app-level router needing to know the entire subtree. 4) Reusability/composition: app-level routing forces prop drilling or context In your snippet you render the session item inline: ```ts sessions.map((session, i) => ( <box backgroundColor={...}> ... </box> )) ``` But in a real project, you don’t keep it inline. You do: ```ts sessions.map(session => <SessionItem session={session} />) ``` Now `SessionItem` needs to know whether it’s focused, whether its parent panel is focused, and how to change the local index on arrow keys (or at least which index is active) So you either: - prop drill: focused, activeIndex, setActiveIndex, section, setSection, etc. - or create contexts: `SessionsContext`, `FocusContext`, `KeyboardContext` - or keep pushing logic upward: `SessionItem` becomes “dumb”, `App` owns everything All three are workable, but they’re exactly what I’m trying to avoid: navigation logic leaking across component boundaries With a component-local navigation model, `SessionItem` can encapsulate: - local focus behavior - `onFocusLocal`/`onBlurLocal` styling - key handling only when it is actually focused and the parent doesn’t need to know its internal structure, nor does it need to keep checking if it's children are actually being focused on every keystroke. 5) Bubbling/delegation: in a composable navigation model, a child can attempt to handle a key and, if it can’t, bubble it to the parent. Concrete example: - Chat panel shows outputs in a column. - Inside each output, there is a small horizontal “actions row” (copy, cite, open, etc.) So we have: ``` ChatPanel (vertical navigation) L OutputItem (vertical list item) L ActionsRow (horizontal navigation) ``` What you'd want: - `left`/`right` moves inside `ActionsRow` if you’re in it - If you press `left` on the leftmost action, the event bubbles: - `ActionsRow` can’t move further -> bubble to `OutputItem` / `ChatPanel` - `ChatPanel` decides that `left` means “move to Sessions panel” (it doesn't even decide because it doesn't need to check for anything, it just has `keys={{ left: { name: "left" }}}`) and when it gets a left event it moves to the left In a central router, this becomes: - global code has to know whether you’re inside `ActionsRow` - global code has to know whether `ActionsRow` is at an edge - global code has to know the layout relationship (chat’s left neighbor is sessions) That is basically manually encoding a focus graph and edge rules at the app level. And adds the same problems I've been saying: growing complexity of branch checking, if statements, repeated logic, etc. The grid approach encodes the adjacency rules once (layout) and lets local components manage their own internal navigation, while still having a consistent escape hatch (bubbling) when a local move is impossible. Again, I'm not saying your point isn't valid, **it works**, it's a good minimal pattern for small TUIs, but it doesn’t provide: - composition (component-local navigation) - `focusWithin` semantics for ancestor reactions - layout-aware navigation without hardcoding graphs - `bubbling`/`delegation` for “can’t handle this key, parent decide” - a way to avoid unnecessary checks every time you press a key - retaining focus information between focus and blurring - and many more As soon as it starts growing, it starts to increase in complexity, thus making it harder to maintain and increases the chances of global focus state sync bugs, and those are the problems I’m proposing to solve with the grid 6) Finally, there’s also a more fundamental design level aspect to this. OpenTUI follows React/Solid patterns, which come from the web, but the web was designed around navigating with the mouse. The keyboard is a secondary input there: tab order is linear, navigation is limited, and anything more complex is implemented manually in userland. A terminal is different: the keyboard is not a secondary input, it is the primary one. That means we have a different playground here: keyboard navigation as a first class native concept, the same way mouse navigation is native on the web. Today, using a central `useKeyboard` router is effectively treating the keyboard like a low-level event stream: “a key was pressed, now decide what to do”. But that’s analogous to handling mouse navigation on the web by listening to global mouse events and manually deciding which component is under the cursor. We don’t do that because the DOM already encodes structure, hierarchy, and spatial relationships. My proposal is about doing the same for keyboard navigation: focus and navigation should follow your keyboard naturally, by design, because you are in a keyboard first environment. To me it should be something that you shouldn't have to implement manually every time you make a TUI. It should just be the default, and then you can add on top of it. Damn that was a long yap yap session
Author
Owner

@necropheus commented on GitHub (Jan 24, 2026):

Ok so, I obviously wasn't going to let this die, so I spent like 5 days refactoring the code, basically doing it from scratch all by hand (it went from like +1.2k LOC to barely 500, amazing).

As I was doing it, I had some ideas on how to make the grid better. Here are most of them:

Layout? More Like Gone-ayout

I scratched the whole layout thing away: now everything relies on node coordinates. Why?

  • layout only makes sense if you are going to add native styles to the grid, because it gives you a visual idea of how much a section will span, but in this case, the grid has no styling opinions, it just knows left-right-up-down, nothing else
  • it was adding extra complexity for nothing (given it wasn't applying any styling)
  • it's much faster to just have a coords prop on every child and move around based on that
  • I realized something too: in terms of navigation, the CSS-like layout doesn't make any sense. Let's take for example this:
layout={[
    ["sessions", "chat",  "sidebar"],
    ["sessions", "chat",  "sidebar"],
    ["sessions", "chat",  "sidebar"],
    ["sessions", "input", "sidebar"]
]}

Let's say you are focusing sessions and press right. How does The Grid™ decide what to focus? How does it know, on mount, what is right of sessions? You have 2 ways:

  1. Each node should have an internal variable like lastFocusedNeighbor... toMyRight, then lastFocusedNeighborToMyLeft, and so on. You get the idea: it's a waste of memory and it adds a whole new class of complexity
  2. The Grid™ saves references of what is in what direction of what, but that's just the same problem as above (or even worse)

So, I made a decision: your grid should be rectangular in terms of nodes. Meaning: for every node, there's at most 4 neighbors (left, right, up, down).

But, Gus, what do we do if we want something like the layout?
That's quite simple, friend: just slap another The Grid™ lol. It would look something like this (conceptually):

[["sessions", "mid", "sidebar"]]

and then, "mid" is just another grid

[
  ["chat"],
  ["input"]
]

Of course, those are not IDs. Now The Grid™ just saves an internal matrix of node references and when it needs to move it's local focus, it just checks the current locally focused element's coords props and moves to whatever node reference is in the direction you just asked it to move (plus some nice features that I'll explain below)

Getting rid of the layout was a good decision in my opinion.

All Hail Coordinates

They are quite simple to understand: each grid_node (or grid, which is just an extension of a grid_node) receives a coords: [number, number] prop. With that, when a child is added to a The Grid™, The Grid™ does all the funny things to add its reference based on its coordinates to the internal matrix. It relies mostly on overriding add and remove since these are the times where your The Grid™ changes... except for, you know, when it doesn't.

The Solid Problem

The coordinates are fun and all, but they come with a trade-off (only for dynamic grids tho, static grids are fine): Solid is very clever on fine-grained updates, and it messes with ids, which in turn messes with The Grid™ references. It's not an unsolvable one tho, for now you can work around it by simply using crypto.randomUUID() or createUniqueID() (from solidjs package) directly called inside the <Index> or <For> component (there's an example in the files)

As I said, this only happens in dynamic grids because Solid, instead of behaving like react and just nuking the entire The Grid™ and recreating it, it does some quick tricks like copying the last element, then doing identity shifting and weird stuff, changing ids, whatever whatever, so it ends up poluting your internal references. You can force it to not do that and instead use a combination of adds and removes if you do as I said.

It can be fixed tho, this is just a working prototype, but it can be better optimized for these kinds of things

Extra features

  1. You can now set movement overflow behavior:
  • bubble (default): if there's nothing in that direction, bubble the event to your parent
  • wrap-around: if there's nothing in that direction, go to the opposite side
  • stop: if there's nothing in tha direction, don't do anything and don't bubble the event
  1. Better focus/blur behavior (it was kinda buggy, it's now mostly stable, you can probably still break it tho because, you know, The Grid™ is 5 days old)
  2. I wanted to change the focusLocal name to selected but that's already taken so that's a feature we won't have lol

Summary

There are more stuff here and there but those are the 2 main things. I went through quite a lot of iterations and this is the best I could do for now.

You'll notice I tried my best to leave comments clarifying some stuff and generally explaining the code.

Also, as I said at the end of my previous comment, this isn't just about making a grid. It's about having the same default the web has for mouse navigation.

On the web, you don't need to constantly capture the mouse position, then caputer its clicks, then search through the dom until you find whatever is under the mouse with the highest z-index and only then you know what you are focusing or clicking. You jus add onClick to something and you interact with it.

A TUI should have the same thing but for keyboard. You should just be able to navigate it, focus things, interact with things, without having to specify all the internal logic of navigating your TUI, it should be as simple as this is how I move, this is how I select something, now let me actually program my thing and the engine/component should handle the entire navigation for you without it being in the way

Edit: the updated files https://github.com/anomalyco/opentui/pull/554

<!-- gh-comment-id:3795556384 --> @necropheus commented on GitHub (Jan 24, 2026): Ok so, I obviously wasn't going to let this die, so I spent like 5 days refactoring the code, basically doing it from scratch all by hand (it went from like +1.2k LOC to barely 500, amazing). As I was doing it, I had some ideas on how to make the grid better. Here are most of them: ## Layout? More Like Gone-ayout I scratched the whole `layout` thing away: now everything relies on node coordinates. Why? - `layout` only makes sense if you are going to add native styles to the grid, because it gives you a visual idea of how much a section will span, but in this case, the grid has no styling opinions, it just knows left-right-up-down, nothing else - it was adding extra complexity for nothing (given it wasn't applying any styling) - it's much faster to just have a `coords` prop on every child and move around based on that - I realized something too: in terms of navigation, the CSS-like layout doesn't make any sense. Let's take for example this: ```ts layout={[ ["sessions", "chat", "sidebar"], ["sessions", "chat", "sidebar"], ["sessions", "chat", "sidebar"], ["sessions", "input", "sidebar"] ]} ``` Let's say you are focusing `sessions` and press right. How does The Grid™ decide what to focus? How does it know, on mount, what is right of `sessions`? You have 2 ways: 1. Each node should have an internal variable like `lastFocusedNeighbor`... `toMyRight`, then `lastFocusedNeighborToMyLeft`, and so on. You get the idea: it's a waste of memory and it adds a whole new class of complexity 2. The Grid™ saves references of what is in what direction of what, but that's just the same problem as above (or even worse) So, I made a decision: your grid should be rectangular in terms of nodes. Meaning: for every node, there's at most 4 neighbors (left, right, up, down). _But, Gus, what do we do if we want something like the layout?_ That's quite simple, friend: just slap another The Grid™ lol. It would look something like this (conceptually): ```ts [["sessions", "mid", "sidebar"]] ``` and then, "mid" is just another grid ```ts [ ["chat"], ["input"] ] ``` Of course, those are not IDs. Now The Grid™ just saves an internal matrix of node references and when it needs to move it's local focus, it just checks the current locally focused element's `coords` props and moves to whatever node reference is in the direction you just asked it to move (plus some nice features that I'll explain below) Getting rid of the layout was a good decision in my opinion. ## All Hail Coordinates They are quite simple to understand: each `grid_node` (or `grid`, which is just an extension of a `grid_node`) receives a `coords: [number, number]` prop. With that, when a child is added to a The Grid™, The Grid™ does all the funny things to add its reference based on its coordinates to the internal matrix. It relies mostly on overriding `add` and `remove` since these are the times where your The Grid™ changes... except for, you know, when it doesn't. ## The Solid Problem The coordinates are fun and all, but they come with a trade-off (only for dynamic grids tho, static grids are fine): Solid is very clever on fine-grained updates, and it messes with ids, which in turn messes with The Grid™ references. It's not an unsolvable one tho, for now you can work around it by simply using `crypto.randomUUID()` or `createUniqueID()` (from solidjs package) directly called inside the `<Index>` or `<For>` component (there's an example in the files) As I said, this only happens in dynamic grids because Solid, instead of behaving like react and just nuking the entire The Grid™ and recreating it, it does some quick tricks like copying the last element, then doing identity shifting and weird stuff, changing ids, whatever whatever, so it ends up poluting your internal references. You can force it to **not** do that and instead use a combination of adds and removes if you do as I said. It can be fixed tho, this is just a working prototype, but it can be better optimized for these kinds of things ## Extra features 1. You can now set movement overflow behavior: - bubble (default): if there's nothing in that direction, bubble the event to your parent - wrap-around: if there's nothing in that direction, go to the opposite side - stop: if there's nothing in tha direction, don't do anything and don't bubble the event 2. Better focus/blur behavior (it was kinda buggy, it's now mostly stable, you can probably still break it tho because, you know, The Grid™ is 5 days old) 3. I wanted to change the `focusLocal` name to `selected` but that's already taken so that's a feature we won't have lol ## Summary There are more stuff here and there but those are the 2 main things. I went through quite a lot of iterations and this is the best I could do for now. You'll notice I tried my best to leave comments clarifying some stuff and generally explaining the code. Also, as I said at the end of my [previous comment](https://github.com/anomalyco/opentui/issues/553#issuecomment-3769738879), this isn't just about making a grid. It's about having the same default the web has for mouse navigation. On the web, you don't need to constantly capture the mouse position, then caputer its clicks, then search through the dom until you find whatever is under the mouse with the highest z-index and only then you know what you are focusing or clicking. You jus add `onClick` to something and you interact with it. A TUI should have the same thing but for keyboard. You should just be able to navigate it, focus things, interact with things, without having to specify all the internal logic of navigating your TUI, it should be as simple as _this is how I move, this is how I select something, now let me actually program my thing_ and the engine/component should handle the entire navigation for you without it being in the way Edit: the updated files https://github.com/anomalyco/opentui/pull/554
Author
Owner

@simonklee commented on GitHub (Mar 9, 2026):

Thanks for the thoughts and the writeup @necropheus. At this moment we're not moving forward with this so I'm closing the issue.

<!-- gh-comment-id:4026094787 --> @simonklee commented on GitHub (Mar 9, 2026): Thanks for the thoughts and the writeup @necropheus. At this moment we're not moving forward with this so I'm closing the issue.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#918
No description provided.