TUI Primitives
indusagi.tuiis the framework-free model tier of the terminal UI: pure primitives for key decoding, ANSI/grapheme width math, fuzzy matching, editor keybinding resolution, autocomplete, and a headless line-editor core. Imported asfrom indusagi.tui import .... It deliberately imports neithertextualnorrich— rendering, scrolling, and terminal I/O live in the widget layer (react-ink).
The modules under indusagi.tui own the math and protocol logic the Textual
widgets depend on, but stay side-effect-free and unit-testable. Everything here
is the "model" half: bytes/str in, key-id strings, scores, column counts, and
buffer state out. Nothing touches a real terminal.
Table of Contents
- Public exports
- Sub-modules
- Key decoding
- Width and wrapping math
- Fuzzy matching
- Editor keybindings
- Autocomplete
- Editor core and contracts
- Narrow public surface
- Relationship to neighbors
Public exports
These names are re-exported from indusagi.tui (the package __init__). Each
submodule has a wider __all__ of its own — see Narrow public
surface.
| Name | Kind | Source | Purpose |
|---|---|---|---|
Key |
class | tui/keys.py |
Typed builder of key-id strings (Key.escape, Key.ctrl("c")) |
KeyId |
type | tui/keys.py |
Alias str; a base key optionally prefixed by ctrl+/shift+/alt+ |
KeyEventType |
type | tui/keys.py |
Literal['press','repeat','release'] — the Kitty event subfield |
parse_key |
function | tui/keys.py |
Decode raw input (`str |
matches_key |
function | tui/keys.py |
Test whether raw input corresponds to a given key id |
is_key_release |
function | tui/keys.py |
True for a Kitty :3 release event (paste-guarded) |
is_key_repeat |
function | tui/keys.py |
True for a Kitty :2 auto-repeat event (paste-guarded) |
is_kitty_protocol_active / set_kitty_protocol_active |
function | tui/keys.py |
Get/set the process-global Kitty-active flag |
visible_width |
function | tui/utils.py |
Column width: ANSI-stripped, tab-expanded, grapheme/east-asian aware |
truncate_to_width |
function | tui/utils.py |
Shorten to a max visible width with an ellipsis (ANSI-aware) |
wrap_text_with_ansi |
function | tui/utils.py |
Word-wrap preserving SGR styling across line breaks |
fuzzy_match |
function | tui/fuzzy.py |
Order-preserving subsequence match → FuzzyMatch(matches, score) |
fuzzy_filter |
function | tui/fuzzy.py |
Multi-token AND filter + ascending-score rank |
FuzzyMatch |
dataclass | tui/fuzzy.py |
Frozen result: matches: bool, score: float |
EditorAction |
type | tui/keybindings.py |
Literal union of bindable editor behaviors |
EditorKeybindingsConfig |
type | tui/keybindings.py |
`Mapping[EditorAction, KeyId |
EditorKeybindingsManager |
class | tui/keybindings.py |
Layered resolver (preset → user → per-component) |
DEFAULT_EDITOR_KEYBINDINGS |
const | tui/keybindings.py |
The base binding layer applied to every editor |
get_editor_keybindings / set_editor_keybindings |
function | tui/keybindings.py |
Accessors for the process-wide singleton manager |
AutocompleteItem / SlashCommand |
dataclass | tui/autocomplete.py |
Suggestion item and slash-command definition |
AutocompleteProvider |
type | tui/autocomplete.py |
runtime_checkable Protocol for suggestion providers |
CombinedAutocompleteProvider |
class | tui/autocomplete.py |
@file + slash + path completion |
Component / TUI |
class | tui/contracts.py |
Legacy component lifecycle/host contracts |
OverlayAnchor / SizeValue / OverlayMargin / OverlayOptions / OverlayHandle |
type | tui/contracts.py |
Overlay placement vocabulary |
EditorTheme / MarkdownTheme / SelectListTheme / SettingsListTheme |
dataclass | tui/theme_types.py |
Theme contracts (bundles of text-painter callables) |
EditorComponent |
class | tui/editor.py |
The composed editor contract |
Sub-modules
| Module | Holds |
|---|---|
keys.py |
Legacy + Kitty key decoding, the Key builder, event/paste guards |
utils.py |
ANSI/grapheme width math, truncation, ANSI-aware wrapping, slicing |
fuzzy.py |
Order-preserving fuzzy matcher and multi-token filter |
keybindings.py |
EditorAction vocabulary and the layered keybindings resolver |
autocomplete.py |
@file/slash/path completion providers (async, cached) |
contracts.py |
Legacy Component/TUI base classes and overlay placement types |
theme_types.py |
Theme contracts the rendering layer fills with painter closures |
editor.py |
The EditorComponent contract and the headless EditorCore |
Key decoding
keys.py decodes raw terminal input into canonical key-id strings. A key id
is a base key ("c", "escape", "up") optionally prefixed by any ordering of
ctrl+/shift+/alt+. The Key builder produces these strings with the right
modifier ordering, so you never hand-write them:
from indusagi.tui import Key, parse_key, matches_key, is_key_release
# parse raw terminal bytes into a key id
assert parse_key(b"\x03") == "ctrl+c" # ^C
assert parse_key(b"\x1b[A") == "up" # arrow up
# test raw input against a typed key id from the Key builder
assert matches_key(b"\x03", Key.ctrl("c"))
assert matches_key(b"\x1b", Key.escape)
# release detection only matters under the Kitty protocol
assert is_key_release("\x1b[99;1:3u") is True
assert is_key_release("\x1b[200~pasted:3F text\x1b[201~") is False # paste guard
parse_key and matches_key accept str | bytes. The decoder understands both
legacy escape sequences and the modern Kitty keyboard protocol (CSI-u
sequences carrying a modifier bitmask and a press/repeat/release subfield).
is_key_release (:3) and is_key_repeat (:2) sniff that subfield and bail
out on bracketed-paste markers (\x1b[200~ … \x1b[201~) so paste payloads are
never mistaken for events.
The Key builder exposes named keys as class attributes (Key.escape,
Key.enter, Key.return_, Key.page_up, Key.page_down, Key.backtick) and
modifier combinations as static methods (Key.ctrl, Key.shift, Key.alt,
Key.ctrl_shift, Key.shift_ctrl, Key.ctrl_alt, Key.alt_ctrl,
Key.shift_alt, Key.alt_shift, Key.ctrl_shift_alt). Note the snake-cased
Python names map onto the emitted strings: Key.return_ → "return",
Key.page_up → "pageUp".
A process-global flag, toggled by set_kitty_protocol_active(True) once the
terminal layer confirms support, gates Kitty parsing; is_kitty_protocol_active
reads it.
Width and wrapping math
utils.py is the column-width substrate. visible_width measures terminal
columns by stripping ANSI SGR codes, expanding tabs to three spaces, then
summing grapheme-cluster widths — counting east-asian wide/fullwidth glyphs (and
emoji sequences) as 2 columns and everything else as 1 (ambiguous-as-narrow).
Results are LRU-cached.
from indusagi.tui import visible_width, truncate_to_width, wrap_text_with_ansi
print(visible_width("\x1b[31mhello\x1b[0m")) # 5 (ANSI not counted)
print(visible_width("你好")) # 4 (east-asian wide = 2 each)
print(truncate_to_width("a very long line", 8)) # 'a ver...'
print(wrap_text_with_ansi("one two three four", 8)) # ['one two', 'three', 'four']
truncate_to_width shortens to a maximum visible width, appending an ellipsis
(default '...') and emitting a reset (\x1b[0m) before it; it can optionally
pad to an exact width. wrap_text_with_ansi word-wraps while re-applying active
SGR codes on continuation lines, so styling survives the break — it never pads
or paints backgrounds.
Grapheme segmentation relies on the third-party
regexmodule (for\Xcluster matching); the standard libraryreis insufficient. Install it alongsideindusagi.
Fuzzy matching
fuzzy_match is an order-preserving subsequence matcher: it locates each query
character in order within the text, then scores the placement. The score is
lower-is-better — penalties (gaps, lateness) add, rewards (consecutive runs,
word boundaries) subtract. If a direct match fails, it retries a digit/letter
swap variant (e.g. "foo12" ↔ "12foo") with a swap penalty.
from indusagi.tui import fuzzy_match, fuzzy_filter
m = fuzzy_match("abc", "alphabetic")
print(m.matches, m.score) # True, <float> (lower score = tighter)
files = ["src/main.py", "src/agent/session.py", "README.md"]
# multi-token AND filter, best matches first
print(fuzzy_filter(files, "src ses", lambda s: s))
# -> ['src/agent/session.py']
fuzzy_filter(items, query, get_text) splits the query on whitespace, keeps only
items that match every token, and sorts ascending by summed per-token score.
Matching is memoized through an internal cache (up to 2000 entries, oldest
evicted).
Editor keybindings
Physical keys are decoupled from logical behavior through EditorAction — a
Literal union covering motions and operations like cursorUp,
deleteWordBackward, submit, newLine, selectUp, yank, yankPop,
undo, expandTools, and more. EditorKeybindingsManager maps actions to one
or more KeyIds by layering, in order: a built-in preset (default or vim) →
a user EditorKeybindingsConfig → per-component overrides. Later layers win.
from indusagi.tui import (
get_editor_keybindings, EditorKeybindingsManager, DEFAULT_EDITOR_KEYBINDINGS, Key,
)
mgr = get_editor_keybindings() # process-wide singleton
assert mgr.matches("\r", "submit") # Enter triggers submit (default layer)
# custom layer + vim preset
custom = EditorKeybindingsManager(
config={"submit": [Key.ctrl("s")]},
preset="vim",
)
print(custom.get_keys("submit"))
print(custom.detect_conflicts()) # list[KeybindingConflict]
matches(data, action, component_id=None) resolves the action's keys across
layers and delegates each candidate to matches_key. Other manager methods:
get_keys, register_action, set_config(config, preset),
set_component_override(component_id, config), clear_component_override, and
detect_conflicts (which canonicalizes modifier ordering before scanning for
collisions). get_editor_keybindings / set_editor_keybindings read and replace
the lazily-created singleton.
Autocomplete
CombinedAutocompleteProvider is the concrete provider used by the editor: it
tries @file fuzzy attachment search first, then slash commands, then path
completion. It is async (backed by fd/scandir over asyncio) and caches
suggestion results.
import asyncio
from indusagi.tui import CombinedAutocompleteProvider, SlashCommand
provider = CombinedAutocompleteProvider(
commands=[SlashCommand(name="help", description="show help")],
)
async def main():
res = await provider.get_suggestions(["/he"], 0, 3)
if res:
print(res.prefix, [i.label for i in res.items])
asyncio.run(main())
get_suggestions(lines, cursor_line, cursor_col) returns a SuggestionResult
(items, prefix) or None; apply_completion(...) returns an ApplyResult
(new lines plus caret position). AutocompleteProvider is a
runtime_checkable Protocol describing that pair of methods, so you can supply
your own provider. SlashCommand carries an optional get_argument_completions
hook for completing a command's arguments.
Editor core and contracts
editor.py exports the EditorComponent contract — a composition of the legacy
Component lifecycle plus read/write access, display config, and event
callbacks (get_text, get_expanded_text, set_text,
insert_text_at_cursor, set_padding_x, set_autocomplete_provider,
get_keybinding_scope_id, …). Behind it sits a pure, headless EditorCore that
maintains the multi-line buffer, grapheme-aware cursor and word motion, prompt
history, an Emacs-style kill ring (yank / yank-pop), fish-style coalesced undo,
large-paste collapsing into [paste #N …] markers, backslash-Enter
continuation, and soft-wrap math with a sticky column. EditorCore is exercised
by tests but is not re-exported from indusagi.tui — import it from
indusagi.tui.editor if you need it directly. The Textual editor widget in
react-ink drives EditorCore and handles the actual
painting and I/O.
contracts.py defines the legacy Component/TUI base classes and the overlay
placement vocabulary (OverlayAnchor, SizeValue, OverlayMargin,
OverlayOptions, OverlayHandle). theme_types.py defines the theme contracts
(EditorTheme, MarkdownTheme, SelectListTheme, SettingsListTheme) as
bundles of text-painter callables; the rendering layer fills them with concrete
closures while this module stays framework-free.
Narrow public surface
The indusagi.tui top-level surface is intentionally narrower than each
submodule's own __all__. Many real, importable names are exported only from
their module. Import these from the submodule, not the package root:
| Submodule | Module-only names |
|---|---|
tui.keys |
decode_key, KeyEvent, normalize_key_id, MODIFIERS, CODEPOINTS, ARROW_CODEPOINTS, FUNCTIONAL_CODEPOINTS, LETTER_VALUES, SYMBOL_VALUES, SPECIAL_KEY_VALUES, KeyValidator |
tui.fuzzy |
DEFAULT_FUZZY_CONFIG, FuzzyScoringConfig, FuzzyMatcherStrategy |
tui.keybindings |
EDITOR_ACTIONS, VIM_EDITOR_KEYBINDINGS, KeybindingConflict, KeybindingPreset |
tui.utils |
slice_by_column, slice_with_width, apply_background_to_line, extract_segments, extract_ansi_code, get_segmenter, is_whitespace_char, is_punctuation_char, AnsiCode, SliceResult, ExtractedSegments, TextWidthCalculator, TextWrapper, AnsiCodeManager, GraphemeHandler, text_width_calculator, text_wrapper |
tui.autocomplete |
SuggestionResult, ApplyResult, AsyncAutocompleteProvider, AsyncAutocompleteChain |
tui.theme_types |
TextPainter, CodeHighlighter |
tui.editor |
EditorCore, EditorReadAccess, EditorWriteAccess, EditorDisplayConfig, EditorEventCallbacks |
from indusagi.tui.keys import decode_key, KeyEvent # not at tui top level
from indusagi.tui.fuzzy import DEFAULT_FUZZY_CONFIG
from indusagi.tui.editor import EditorCore
Two pieces of state are process-global and mutable:
keys._kitty_protocol_active (via set_kitty_protocol_active) and the
keybindings singleton (via set_editor_keybindings). Both mutate shared state
for the whole process.
Relationship to neighbors
indusagi.tui is the pure logic tier consumed by the Textual widget layer,
react-ink: its widgets drive the headless EditorCore,
paint what visible_width/wrap_text_with_ansi compute, and own bracketed-paste
decoding, scroll offsets, and the live Kitty protocol. The bridge that wires
these layers together is described in UI Bridge. Within
this package, autocomplete.py is the only intra-module importer (it uses
fuzzy_filter). For where the UI sits in the overall design, see
Architecture; for the full package map, see
Package Exports.
