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?
// ❌ 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 searchEach 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.
// 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:
// 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()andctx.Router().Stack()are equivalent —app.Stack()andctx.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 manualFocus()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
// 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
// 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)
// 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)
// In a plugin's OnInit — no router boilerplate needed
ctx.Keys().Bind("ctrl+f", func() {
// open search
})Via Registry (Low-level, for advanced use)
// 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)
// 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)
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:
app.Router().Focus(0, "main") // base level
app.Router().Focus(1, "search") // overlay levelThis 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:
| Input | Normalized |
|---|---|
"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 registeredThis 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:
// 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.
