Skip to content

Keybindings & the Focus Stack

This is one of the most unique parts of Tako. Instead of handling keys in a giant switch statement, you register named actions against specific keys and scopes. The router dispatches to the right action based on context — without any plugin needing to know what else is registered.

Why Not Just Use a switch?

go
// ❌ The hardcoded approach — does not scale
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+f":
            // open search
        case "ctrl+s":
            // save
        case "esc":
            // close modal? or cancel search? or quit? depends on context
        }
    }
}

As your app grows, this becomes unmanageable. You can't remap keys, you can't have different behavior for esc in different contexts, and every plugin that adds a key has to edit the central Update function.

Tako solves this with two concepts: the Key Registry and the Focus Stack.

The Focus Stack

The stack represents the "depth" of your UI. Think of it as modal layers:

Stack: ["base"]               ← normal app state
Stack: ["base", "search"]     ← search overlay is open
Stack: ["base", "search", "results"] ← results panel on top of search

Each level has a focused zone. Keys are routed to the topmost level's focused zone first, then fall through to global bindings.

Managing the Stack

Most of the time, you manage the stack via the High-Level API (see 04 — Layer Management), which handles pushing, popping, and rendering automatically.

go
// In a plugin — the high-level way (recommended)
ctx.Keys().Bind("ctrl+f", func() {
    ctx.Overlay().ShowComponent(&SearchBox{})
    ctx.Emit("search:opened", nil)
})

Under the hood, this is doing manual stack and focus manipulation. You can still do this directly if you need low-level control:

go
// The low-level way (under the hood)
ctx.Keys().Bind("ctrl+f", func() {
    ctx.Stack().Push("search")
    ctx.Emit("search:opened", nil)
})

// Pop when closing
app.Keys().Bind("esc", func() {
    if top := app.Stack().Top(); top != "" {
        app.Stack().Pop()
        app.Emit(top+":closed", nil)
    }
})

Alternative: app.Router().Stack() and ctx.Router().Stack() are equivalent — app.Stack() and ctx.Stack() are just sugar.

Auto-routing: When using manual stack pushes, focus syncs automatically. Stack().Push("search") automatically shifts focus to zone "search" at the new level — no manual Focus() call required. See 04.01 — Auto-Routing for details.

The smart ctrl+c behavior is built in as a default global binding in application.go: if the stack has more than one level, ctrl+c is silently ignored to prevent accidental exits while a modal is open. Only at the root level does it trigger shutdown. Developers can override this by binding ctrl+c in their own zones or globally.

Example: Overriding ctrl+c globally

go
// Override the default smart quit to ALWAYS quit the app, even if a modal is open:
app.Keys().Bind("ctrl+c", func() {
    app.Shutdown()
})

Example: Overriding ctrl+c in a specific zone

go
// Override `ctrl+c` to copy text instead of quitting when the "editor" zone is focused
app.Keys().Zone("editor").Bind("ctrl+c", func() {
    clipboard.WriteAll(editor.GetSelectedText())
})

Registering Keys

You can register keys either through the foundation.Application (recommended for host app logic) or through ctx (recommended for plugins — no IoC boilerplate needed).

Global Keys

Fire regardless of which zone is focused. Good for app-wide shortcuts.

Via Application (Host App)

go
// Single key
app.Keys().Bind("ctrl+f", func() {
    // open search
})

// Alternative keys (aliases) — using a slice of strings
app.Keys().Bind([]string{"ctrl+s", "f2"}, func() {
    // save
})

// Syntactic Sugar for aliases — using pipe character '|'
app.Keys().Bind("ctrl+s|f2", func() {
    // save
})

Via Context (Plugin)

go
// In a plugin's OnInit — no router boilerplate needed
ctx.Keys().Bind("ctrl+f", func() {
    // open search
})

Via Registry (Low-level, for advanced use)

go
// app.Router().Registry() gives direct access to the underlying registry
app.Router().Registry().RegisterGlobal("ctrl+f", func() {
    // open search
})

Zone Keys

Fire only when a specific zone is focused at a specific stack level.

Via Application (Host App)

go
// Zone "main" at level 0 (default)
app.Keys().Zone("main").Bind("j", func() {
    // scroll down
})

// Multiple keys for a zone
app.Keys().Zone("main").Bind([]string{"enter", "space"}, func() {
    // confirm
})

// Syntactic Sugar for multiple zone keys
app.Keys().Zone("main").Bind("enter|space", func() {
    // confirm
})

// Explicit stack level (e.g. overlay at level 1)
app.Keys().Zone("main", 1).Bind("j", func() {
    // scroll down in overlay
})

Via Context (Plugin)

go
ctx.Keys().Zone("main").Bind("j", func() {
    // scroll down
})

ctx.Keys().Zone("main", 1).Bind("j", func() {
    // scroll down in overlay
})

Setting the Focused Zone

The host app or plugin tells the router which zone is currently focused at each level:

go
app.Router().Focus(0, "main")    // base level
app.Router().Focus(1, "search")  // overlay level

This can change dynamically — for example, when the user tabs between panels.

Key Normalization

The router normalizes keys before matching, so you don't have to worry about casing or separator style:

InputNormalized
"Ctrl+F""ctrl+f"
"ctrl-f""ctrl+f"
"Escape""esc"
"Space"" "

Conflict Detection

If you register the same key twice in the same scope, the router logs an error:

[ERROR] Global key conflict: 'ctrl+f' is already registered
[ERROR] Key conflict in stackLevel 0, zone 'main': 'j' is already registered

This catches accidental shadowing early. The first registration wins.

Full Keybinding + Stack Example

State Persistence

The focus stack state (which layers are open) is automatically saved to the KV store on shutdown and restored on next launch, via the TUI Kernel:

go
// On boot:
keyRouter.Stack().RestoreState(store)

// On shutdown:
keyRouter.Stack().SaveState(store)

This means if your app crashes or is force-closed while a modal is open, the next launch will restore the correct stack state.


Up next: 03.05 — Config & Storage — how to read configuration from JSON files and environment variables, and how to persist plugin state across sessions using the KV store.