UI - terminal client

The Textual terminal TUI is the tui subcommand (see Terminal interface); it shares the klea_utils.ui helpers (UI — NiceGUI). Bare klea / klea-rag also launches it.

The Textual application

Shared Textual terminal client.

The Klea TUI is an in-process Textual app that consumes the backend’s /query/stream SSE endpoint, one active session at a time (ADR-0050, devdocs/system/tui.md). This module holds the shared App/Screen, the two-panel layout, and the single run worker that feeds the shared stream reducer. App-specific composition enters through klea_utils.ui.tui.context.TuiContext.

Streaming currently renders progress (the turn-status line) and complete (the final message); richer rendering, turn-status states, HITL and session management land in later steps.

File: klea_utils/ui/tui/app.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.app.KleaTuiApp(ctx: TuiContext)[source]

Bases: App[None]

The shared Klea Textual application.

Owns the single run slot (invariant I1): at most one /query/stream run is in flight, and a second submit is refused while one streams.

BINDINGS: ClassVar[list[Binding]] = [Binding(key='ctrl+c', action='copy_selection', description='Copy', show=False, key_display=None, priority=False, tooltip='', id=None, system=False, group=None), Binding(key='ctrl+g', action='stop', description='Stop', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None), Binding(key='ctrl+r', action='retry', description='Retry', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None), Binding(key='ctrl+i', action='toggle_view', description='Inspector', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None), Binding(key='ctrl+s', action='open_sessions', description='Sessions', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None), Binding(key='ctrl+m', action='open_models', description='Models', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None), Binding(key='ctrl+q', action='confirm_exit', description='Quit', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

The default key bindings.

CSS = "\n/* Keep selected text readable; Textual's default selection foreground is\n * transparent, which resolves to the same colour as the selection background\n * for plain Content visuals. */\nScreen > .screen--selection {\n    color: $text;\n}\n#root {\n    height: 100%;\n}\n#chat-panel {\n    width: 3fr;\n    height: 100%;\n    background: $surface;\n    padding: 0 1;\n}\n#status-panel {\n    width: 1fr;\n    height: 100%;\n    background: $panel;\n    padding: 0 1;\n}\n#chat-title {\n    height: 1;\n    color: $text-muted;\n    text-overflow: ellipsis;\n}\n#chat-name {\n    height: 1;\n    color: $text-muted;\n    text-overflow: ellipsis;\n    margin-bottom: 1;\n}\n#transcript {\n    height: 1fr;\n}\n#view-toggle {\n    height: 1;\n    text-align: center;\n}\n#inspector {\n    height: 1fr;\n}\n#key-hints {\n    height: auto;\n    color: $text-muted;\n}\n#status {\n    height: 1fr;\n}\n.actions {\n    height: auto;\n    margin-top: 1;\n}\n.actions Button {\n    margin-right: 2;\n}\n"

Inline CSS, useful for quick scripts. This is loaded after CSS_PATH, and therefore takes priority in the event of a specificity clash.

action_confirm_exit() → None[source]

Open the exit confirmation (Ctrl+Q).

action_copy_selection() → None[source]

Handle Ctrl+C when no text is selected.

The Screen copies the current selection before this app-level binding runs. With nothing to copy, this action stops the base App’s help/quit binding from firing; Ctrl+Shift+C or F12 stops a run.

async action_open_models() → None[source]

Open the model dialog (Ctrl+M); refused while streaming (I2).

async action_open_sessions() → None[source]

Open the session picker (Ctrl+S); refused while streaming (I2).

action_retry() → None[source]

Resume the chat’s last failed run from its checkpoint (Retry).

async action_stop() → None[source]

Stop the active run: cancel locally and ask the server to cancel.

async action_toggle_view() → None[source]

Switch the chat panel between the transcript and inspector views.

property catalogue: CommandRegistry | None

Return the merged command catalogue, or None if not loaded.

copy_to_clipboard(text: str) → None[source]

Copy text to the app clipboard and, if possible, the OS clipboard.

Textual writes OSC 52 and keeps an internal copy. Some terminals do not support OSC 52, so also try a platform clipboard tool; the app still works when no tool is available.

Parameters:

text – Text to copy.

hydrate_active_chat() → None[source]

Resolve the initial active chat (resume/continue/mint) and load it.

async load_chat(chat_id: str, *, hydrate: bool = True) → None[source]

Make chat_id the active session and render it.

Refused while a run is streaming (invariant I2). Hydration restores the chat’s messages, session context and any pending HITL ask.

Parameters:
  • chat_id – The chat to activate.

  • hydrate – Whether to (re)fetch the chat from the server.

on_mount() → None[source]

Mount the two-panel screen and load the command catalogue.

on_submit_query(event: SubmitQuery) → None[source]

Submit a query posted by an app-defined widget (e.g. a selector).

Parameters:

event – The SubmitQuery message.

start_run(query: str, *, resume: bool = False, interrupt_response: dict[str, Any] | None = None, interrupt_id: str | None = None, interrupt_cancel: bool = False) → None[source]

Start the single run for query, refusing a concurrent submit.

Parameters:
  • query – The user’s query text (unused on a resume or an answer).

  • resume – Resume the chat’s last failed run from its checkpoint instead of starting a new turn.

  • interrupt_response – Answer a pending HITL interrupt (ADR-0046).

  • interrupt_id – Id of the interrupt being answered.

  • interrupt_cancel – Cancel the pending interrupt instead of answering.

async submit(text: str) → None[source]

Route submitted text: an escaped slash, a command, or a query.

A //-escaped input is sent as a literal / query. A client command runs locally and its output is rendered; a server command (or a non-command) is forwarded as the query, unless a required model or API key is missing (then the send is blocked with a warning).

Parameters:

text – The raw input.

watch_theme(old_theme: str, new_theme: str) → None[source]

Persist the theme whenever the user changes it.

The write is skipped during start-up (while the saved preference is being restored) and when the theme did not actually change (Textual invokes watchers once at mount with the current value), so merely launching the app never rewrites the settings file.

Parameters:
  • old_theme – The previous theme name.

  • new_theme – The new theme name.

class klea_utils.ui.tui.app.KleaTuiScreen(ctx: TuiContext)[source]

Bases: Screen

The single screen: a chat panel and a status panel side by side.

BINDINGS: ClassVar[list[Binding]] = [Binding(key='tab', action='toggle_view', description='Inspector', show=True, key_display=None, priority=True, tooltip='', id=None, system=False, group=None)]

A terminal sends Ctrl+I as a Tab character, and Screen’s default Tab binding (focus_next) would otherwise swallow it. Bind Tab here (priority) to always toggle the transcript/inspector view, so Ctrl+I / Tab works from anywhere on the screen rather than cycling focus.

async action_toggle_view() → None[source]

Toggle the transcript/inspector view.

Backs the priority tab binding (see BINDINGS): a terminal sends Ctrl+I as Tab, so Tab toggles the view from anywhere on the screen (rather than cycling focus).

async append_message(message: MessageData) → None[source]

Append one MIME-aware message to the transcript.

Parameters:

message – A message dict (see state.MessageData).

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the two-panel layout.

property inspector: Inspector

Return the inspector widget.

async on_mount() → None[source]

Focus the query input, show the transcript, hydrate the chat.

on_option_list_option_selected(event: OptionSelected) → None[source]

Insert the chosen command into the input (autocomplete).

Parameters:

event – The OptionSelected event.

on_query_changed(event: Changed) → None[source]

Refresh the command suggestions for the current input.

Parameters:

event – The TextArea.Changed event.

async on_query_submitted(event: Submitted) → None[source]

Hand the submitted input to the app (command or query).

Parameters:

event – The QueryTextArea.Submitted event.

async refresh_inspector(entries: Sequence[Mapping[str, Any]]) → None[source]

Refresh the inspector from the chat’s inspector_entries.

Rebuilds immediately when the inspector is the active view; otherwise the entries are kept and the view is marked dirty for the next switch.

Parameters:

entries – The chat’s inspector_entries (shared reference).

async refresh_status(chat: Mapping[str, Any] | None, *, refresh_extras: bool = False) → None[source]

Rebuild the status panel from the active chat’s state.

Parameters:
  • chat – The chat’s data dict, or None when there is none.

  • refresh_extras – Also rebuild the app slots (a context change can alter what a selector should show).

async render_chat(chat: Mapping[str, Any]) → None[source]

Rebuild the transcript and panes from chat (on load/switch).

Parameters:

chat – The chat’s data dict.

set_chat_header(chat: Mapping[str, Any]) → None[source]

Show the active chat’s name and id in the status-panel header.

Parameters:

chat – The chat’s data dict.

set_key_hints(text: str) → None[source]

Replace the key-hints line.

Parameters:

text – The hint text to show.

show_awaiting_input() → None[source]

Show that the run paused for HITL input.

show_composer(visible: bool) → None[source]

Show or hide the query composer (input + suggestions).

While a HITL ask is active the composer is replaced by the ask panel (ADR-0046), so the two never compete for input.

Parameters:

visible – Whether the composer should be shown.

show_error(message: str, resumable: bool) → None[source]

Show a failed run, with a Retry hint when resumable.

Parameters:
  • message – The error message.

  • resumable – Whether a Retry is available (Ctrl+R).

show_idle() → None[source]

Clear the turn-status line (turn finished).

show_progress(heading: str) → None[source]

Show an in-flight run in the turn-status line.

Parameters:

heading – The live progress heading.

show_stopped() → None[source]

Show that the user stopped the run.

async show_view(view: str) → None[source]

Show view in the chat panel, hiding the other.

Parameters:

view – "transcript" or "inspector".

property status_panel: StatusPanel

Return the status-panel widget.

async toggle_view() → None[source]

Switch the chat panel between the transcript and inspector views.

property transcript: Transcript

Return the transcript widget.

property turn_status: TurnStatus

Return the turn-status widget.

klea_utils.ui.tui.app.run_tui(*, url: str, title: str, app_prefix: str = 'klea', app_name: str = 'klea-tui', user_id: str = '', resume: str = '', continue_session: bool = False, identity_app_name: str = '', client_context: ClientContext | None = None, status_extras: Iterable[Callable[[TuiContext], Any]] | None = None) → None[source]

Launch the shared Klea Textual TUI.

Called by each app’s run_*_tui entry (which supplies any app-specific status slots); the CLI resolves that entry and calls it with the server URL, title and log identity.

Parameters:
  • url – Base URL of the backend API server.

  • title – Application title shown in the chat panel header.

  • app_prefix – Prompt label prefix (e.g. "klea-rag").

  • app_name – Log identity for this frontend (e.g. "klea-rag-tui").

  • user_id – The --user-id value; resolved through the ADR-0050 seam (--user-id > KLEA_USER_ID > a persisted local UUID).

  • resume – Chat id to resume on launch (--resume).

  • continue_session – Resume the most recently updated chat (--continue).

  • identity_app_name – Canonical identity key for the persisted user_id file, shared with the web frontend of the same app (e.g. "klea-agent"); falls back to app_name when empty.

  • client_context – Client environment (cwd/env/interpreter) forwarded with each query for the session’s bundled tool server. None disables forwarding.

  • status_extras – App-defined status-panel renderers, in order.

Shared context for the Klea Textual terminal client.

A TuiContext carries the configuration the shared widgets need and the app-specific slots the per-app entries register (mirroring the web’s PageContext, ADR-0031/ADR-0050). Shared widgets under klea_utils/ui/tui/ import no app package (invariant I8); per-app controls enter only through the documented slots here.

File: klea_utils/ui/tui/context.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.context.TuiContext(server_url: str, title: str = 'Klea', app_prefix: str = 'klea', app_name: str = 'klea-tui', user_id: str = '', chat_id: str = '', resume_chat_id: str = '', continue_session: bool = False, client_context: ~klea_utils.client_context.ClientContext | None = None, status_extras: list[~collections.abc.Callable[[~klea_utils.ui.tui.context.TuiContext], ~typing.Any]] = <factory>)[source]

Bases: object

Configuration and app slots for a Klea Textual app.

The per-app entry builds one context and hands it to the shared klea_utils.ui.tui.app.run_tui(), which constructs the Textual app from it. App-specific status-panel controls (the agent’s operating-mode and tool-access selectors, ADR-0030/0037) are appended to status_extras; RAG registers none.

Parameters:
  • server_url – Base URL of the backend API server.

  • title – Application title shown in the chat panel header.

  • app_prefix – Prompt label prefix (e.g. "klea-rag").

  • app_name – Log identity for this frontend (e.g. "klea-rag-tui").

  • user_id – Opaque persistent user identifier (interim: the resolve_user_id() seam and the session picker land in Phase 5).

  • chat_id – Active chat conversation identifier (interim: minted at launch; the keyed store + active pointer land in Phase 5).

  • resume_chat_id – Chat to resume on launch (--resume <chat_id>).

  • continue_session – Resume the most recently updated chat on launch (--continue).

  • client_context – Client-supplied environment (cwd/env/interpreter) sent with each query for the session’s bundled tool server. None disables forwarding (e.g. a remote server).

status_extras: list[Callable[[TuiContext], Any]]

App-defined status-panel renderers, called with this context in registration order (e.g. mode/access selectors). Empty for RAG.

Shared Textual messages for the Klea TUI.

App-defined widgets (e.g. the agent’s mode/access selectors) need to ask the shared app to run a query without importing it. They post a SubmitQuery, which KleaTuiApp handles by routing the text through submit() (so commands and the setup gating still apply).

File: klea_utils/ui/tui/events.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.events.SubmitQuery(text: str)[source]

Bases: Message

Ask the app to submit text (a query or a /command).

Parameters:

text – The text to submit.

handler_name: ClassVar[str] = 'on_submit_query'

Name of the default message handler.

Components

Session-command handling for the TUI frontend (ADR-0047).

The TUI owns its client-side commands and merges them with the server catalogue fetched from GET /commands. An input beginning with / is validated against the merge: a client command runs locally and its output is rendered as a system transcript message; a server command is forwarded as the query (the graph’s command node runs it); an unknown command is rejected locally and never sent. A leading // escapes a literal slash.

Capabilities start empty (devdocs/system/tui.md): the TUI has no browser file picker, and local-filesystem/shell capabilities are deferred with the session-workspace work.

File: klea_utils/ui/tui/components/commands.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.commands.CommandSuggestions(*content: OptionListContent, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, markup: bool = True, compact: bool = False)[source]

Bases: OptionList

A completion list of matching commands for a /prefix input.

DEFAULT_CSS = '\n    CommandSuggestions {\n        display: none;\n        max-height: 6;\n        border: solid $panel;\n    }\n    '

Default TCSS.

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

static matches(value: str, catalogue: CommandRegistry | None, capabilities: frozenset[str] = frozenset({})) → list[Command][source]

Return the available commands matching a /prefix value.

Only a leading / with no space matches (the space ends the completion, mirroring the web).

Parameters:
  • value – The current input value.

  • catalogue – The merged catalogue, or None if not loaded.

  • capabilities – The TUI’s capabilities.

Returns:

The matching commands, or [].

update_for(value: str, catalogue: CommandRegistry | None, capabilities: frozenset[str] = frozenset({})) → None[source]

Refresh the list for value; hide it when there is no match.

Parameters:
  • value – The current input value.

  • catalogue – The merged catalogue, or None if not loaded.

  • capabilities – The TUI’s capabilities.

klea_utils.ui.tui.components.commands.EXIT_APP_SERVICE = 'exit_app'

CommandContext.services key for the app hook that quits the application (used by /exit). The app registers a no-argument callable that stops any active run, then exits.

klea_utils.ui.tui.components.commands.NEW_CHAT_SERVICE = 'new_chat'

CommandContext.services key for the app hook that starts a new chat (used by /new). The app registers a no-argument callable.

klea_utils.ui.tui.components.commands.OPEN_MODELS_SERVICE = 'open_models'

CommandContext.services key for the app hook that opens the model dialog (used by /model). The app registers a no-argument callable.

klea_utils.ui.tui.components.commands.OPEN_SESSIONS_SERVICE = 'open_sessions'

CommandContext.services key for the app hook that opens the session picker (used by /sessions). The app registers a no-argument callable.

klea_utils.ui.tui.components.commands.STORES_CONFIG_SERVICE = 'stores_config'

CommandContext.services key reserved for an optional app notification when /stores records this chat’s configuration file. The handler writes the per-chat state directly; a registered one-argument callable receives the absolute path, or None for /stores clear.

klea_utils.ui.tui.components.commands.TUI_CAPABILITIES: frozenset[str] = frozenset({})

no file picker, and local-fs/shell capabilities are deferred with the session-workspace work.

Type:

Capabilities the TUI has (ADR-0047). Empty for now

klea_utils.ui.tui.components.commands.build_catalogue(server_commands: list[dict[str, Any]]) → CommandRegistry[source]

Merge the client commands with the server catalogue (ADR-0047).

Parameters:

server_commands – Command metadata dicts from GET /commands.

Returns:

The merged registry (client + server).

klea_utils.ui.tui.components.commands.client_commands() → CommandRegistry[source]

Return the TUI’s own client-side commands (ADR-0047).

Returns:

A registry holding the commands the TUI handles itself.

klea_utils.ui.tui.components.commands.handle_command(text: str, catalogue: CommandRegistry | None, capabilities: frozenset[str] = frozenset({}), services: Mapping[str, Any] | None = None, user_id: str | None = None, chat_id: str | None = None) → CommandResult | None[source]

Handle text as a command if it is one.

Returns a CommandResult for a client command (or an unknown / parse-error command) to render locally, or None when the caller should forward the input as a query (a server command, or a non-command).

Parameters:
  • text – The raw input.

  • catalogue – The merged command catalogue, or None if not loaded.

  • capabilities – The TUI’s capabilities.

  • services – Extra frontend/app hooks for the handler context.

  • user_id – The active user id, if a chat is open (handlers may need it).

  • chat_id – The active chat id, if a chat is open.

Returns:

A local result, or None to forward the input as a query.

Exit-confirmation modal for the Klea TUI.

Ctrl+Q opens this before quitting (mirroring the web header’s exit button, which confirms first), because a keypress is easy to hit by accident. The /exit command skips it: typing the command is deliberate.

File: klea_utils/ui/tui/components/confirm.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.confirm.ExitConfirm(name: str | None = None, id: str | None = None, classes: str | None = None)[source]

Bases: ModalScreen[bool]

A confirmation modal for quitting the app (True to exit).

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Cancel', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

A list of key bindings.

DEFAULT_CSS = '\n    ExitConfirm {\n        align: center middle;\n    }\n    #exit-dialog {\n        width: 60%;\n        height: auto;\n        border: round $error;\n        padding: 1 2;\n        background: $surface;\n    }\n    #exit-title {\n        text-style: bold;\n        height: 1;\n    }\n    #exit-body {\n        height: auto;\n        padding-bottom: 1;\n    }\n    '

Default TCSS.

action_cancel() → None[source]

Dismiss without quitting (Escape or Cancel).

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the prompt and Cancel / Exit.

on_button_pressed(event: Pressed) → None[source]

Confirm or cancel the exit.

Parameters:

event – The Button.Pressed event.

on_mount() → None[source]

Focus Cancel, so a stray Enter does not quit.

Provider-credentials modal for the Klea TUI.

A masked, write-only editor for the provider API keys the active chat’s models need, mirroring the web’s credentials dialog: one section per required provider(+endpoint) scope with a stored-key indicator, a password field for a new key, and Save / Clear. Keys are stored per provider on the server; the dialog only ever sends them (they are truncated in API responses).

File: klea_utils/ui/tui/components/credentials_dialog.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.credentials_dialog.CredentialsDialog(*, server_url: str, user_id: str, info: dict[str, dict[str, Any]], id: str | None = None)[source]

Bases: ModalScreen[CredentialsResult | None]

Masked, write-only editor for the required provider API keys.

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Close', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

A list of key bindings.

DEFAULT_CSS = '\n    CredentialsDialog {\n        align: center middle;\n    }\n    CredentialsDialog > VerticalScroll {\n        width: 90%;\n        max-width: 80;\n        height: auto;\n        max-height: 90%;\n        border: thick $primary;\n        background: $surface;\n        padding: 1 2;\n    }\n    CredentialsDialog Input {\n        width: 100%;\n    }\n    CredentialsDialog .cred-scope {\n        height: auto;\n        padding-top: 1;\n    }\n    CredentialsDialog #cred-list {\n        height: auto;\n    }\n    CredentialsDialog #cred-actions {\n        height: auto;\n        align-horizontal: right;\n        padding-top: 1;\n    }\n    CredentialsDialog #cred-actions Button {\n        margin-left: 1;\n    }\n    '

Default TCSS.

action_cancel() → None[source]

Dismiss, reporting whether anything changed.

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the scope list container and the close button.

on_button_pressed(event: Pressed) → None[source]

Dispatch the dialog’s buttons.

on_mount() → None[source]

Load the stored credentials and render the scopes.

class klea_utils.ui.tui.components.credentials_dialog.CredentialsResult(changed: bool = False)[source]

Bases: object

Outcome of the credentials dialog.

Parameters:

changed – Whether a key was saved or cleared.

HITL review/input/permission panels for the Klea TUI (ADR-0046).

When a run pauses at an interrupt, the app mounts an inline panel in the chat panel rather than a modal, so the transcript stays scrollable while the user reads the ask and decides. A review ask offers Approve / Request changes / Cancel; an input ask offers one field per question plus Answer / Cancel; a permission ask offers once/session/not-a-path/deny per requested path (ADR-0007).

Each panel posts HitlDecisionPanel.Decided (carrying a HitlDecision) instead of dismissing; the app removes the panel and resumes the same run.

File: klea_utils/ui/tui/components/hitl.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail dot com>

class klea_utils.ui.tui.components.hitl.HitlDecision(response: dict[str, Any] | None, cancel: bool = False)[source]

Bases: object

The user’s answer to a HITL ask.

Parameters:
  • response – The resume mapping sent to the server, or None on a cancel.

  • cancel – Whether the user cancelled the interrupt instead of answering.

class klea_utils.ui.tui.components.hitl.HitlDecisionPanel(*children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, can_focus: bool | None = None, can_focus_children: bool | None = None, can_maximize: bool | None = None)[source]

Bases: VerticalScroll

Base for the inline HITL panels.

A panel owns its form and posts Decided when the user answers; the app removes it and resumes the run. It is a sibling of the transcript (not a modal), so the chat stays scrollable while the ask is open. It is bounded (max-height) and scrolls internally so a long plan/digest does not push the transcript off screen.

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Cancel', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

Keyboard bindings for scrollable containers.

Key(s) | Description |
:- | :- |
up | Scroll up, if vertical scrolling is available. |
down | Scroll down, if vertical scrolling is available. |
left | Scroll left, if horizontal scrolling is available. |
right | Scroll right, if horizontal scrolling is available. |
home | Scroll to the home position, if scrolling is available. |
end | Scroll to the end position, if scrolling is available. |
pageup | Scroll up one page, if vertical scrolling is available. |
pagedown | Scroll down one page, if vertical scrolling is available. |
ctrl+pageup | Scroll left one page, if horizontal scrolling is available. |
ctrl+pagedown | Scroll right one page, if horizontal scrolling is available. |
DEFAULT_CSS = '\n    HitlDecisionPanel {\n        height: auto;\n        max-height: 60%;\n        border: round $primary;\n        padding: 0 2;\n        background: $surface;\n    }\n    HitlDecisionPanel #hitl-title,\n    HitlDecisionPanel #perm-title {\n        text-style: bold;\n        height: 1;\n    }\n    HitlDecisionPanel #hitl-headline {\n        text-style: bold;\n        height: auto;\n    }\n    HitlDecisionPanel .hitl-section {\n        height: auto;\n        margin-top: 1;\n    }\n    HitlDecisionPanel .hitl-section-title {\n        text-style: bold;\n        color: $text-muted;\n        height: auto;\n        margin-top: 0;\n    }\n    HitlDecisionPanel .hitl-section-body {\n        height: auto;\n        padding-left: 2;\n    }\n    HitlDecisionPanel Input {\n        width: 1fr;\n        margin-top: 1;\n    }\n    HitlDecisionPanel RadioSet {\n        margin-top: 1;\n    }\n    HitlDecisionPanel .hitl-hint {\n        color: $text-muted;\n        height: auto;\n    }\n    HitlDecisionPanel .perm-request {\n        height: auto;\n        margin-top: 1;\n    }\n    '

Default TCSS.

class Decided(decision: HitlDecision)[source]

Bases: Message

Posted when the user answers an ask.

Parameters:

decision – The user’s HitlDecision.

handler_name: ClassVar[str] = 'on_hitl_decision_panel_decided'

Name of the default message handler.

action_cancel() → None[source]

Cancel the ask (Escape or the Cancel button).

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

class klea_utils.ui.tui.components.hitl.HitlPanel(ask: dict[str, Any])[source]

Bases: HitlDecisionPanel

An inline review / input ask.

A review ask renders the headline and sections (the plan/digest) with an approve / request-changes / cancel row; an input ask renders one field per question with an answer / cancel row.

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the review or input form.

on_button_pressed(event: Pressed) → None[source]

Post the decision for the pressed button.

Parameters:

event – The Button.Pressed event.

on_mount() → None[source]

Focus the first input field.

class klea_utils.ui.tui.components.hitl.PermissionPanel(ask: dict[str, Any])[source]

Bases: HitlDecisionPanel

An inline ask collecting a once/session/deny decision per requested path.

Each request (a sensitive file or an outside-project path, ADR-0007) gets a RadioSet defaulting to deny; Submit posts the per-key decisions, Cancel (or Escape) cancels the ask.

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose one radio group per requested path, plus Submit / Cancel.

on_button_pressed(event: Pressed) → None[source]

Submit the decisions or cancel.

Parameters:

event – The Button.Pressed event.

Query input widget for the Klea TUI.

The chat panel’s query entry: a multi-line, soft-wrapping text area that grows with its content up to a capped height (then scrolls), mirroring the web client’s autogrowing textarea. The entry sits at the bottom of a themed frame (so the cursor is on the bottom line and the box grows upward, like the web composer). Enter submits the query; Ctrl+J (Textual’s newline key) or Shift+Enter inserts a newline, so a pasted multi-line block can be edited before sending. Ctrl+J is the portable choice: it is a distinct key (\x0a) sent by every terminal, whereas Shift+Enter is only distinguishable on terminals that implement the extended (Kitty) keyboard protocol.

File: klea_utils/ui/tui/components/input.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.input.QueryInput(*children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, markup: bool = True)[source]

Bases: Vertical

The query composer: a themed, padded frame around the entry.

The frame has one row of vertical padding and the entry starts at a single row, growing with the text (then scrolling at max-height); this makes a compact box that expands as needed. The background lives on the frame ($primary 15%, theme-aware, matching the user transcript blocks) rather than as a border, so the whole padded area reads as the input surface.

DEFAULT_CSS = '\n    QueryInput {\n        height: auto;\n        padding: 1 0;\n        background: $primary 15%;\n        margin-bottom: 1;\n    }\n    QueryInput QueryTextArea {\n        height: auto;\n        /* The entry grows with the text and starts scrolling past this many\n         * rows; the frame (auto height) just wraps it plus the padding. */\n        max-height: 8;\n        border: none;\n        background: transparent;\n    }\n    QueryInput QueryTextArea:focus {\n        border: none;\n    }\n    '

Default TCSS.

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the bottom-aligned query entry.

class klea_utils.ui.tui.components.input.QueryTextArea(*, id: str | None = None)[source]

Bases: TextArea

Multi-line, autogrowing query entry.

Soft-wraps and grows with its text up to max-height (then scrolls). Enter submits; Ctrl+J (or Shift+Enter on a supporting terminal) inserts a newline.

class Submitted(value: str)[source]

Bases: Message

Posted when the user presses Enter to submit the query.

handler_name: ClassVar[str] = 'on_query_text_area_submitted'

Name of the default message handler.

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

Inspector view for the Klea TUI.

The chat panel’s second view (toggled from the transcript): the streamed inspect entries for the active chat, grouped into a collapsible section per query (marked by the query text and time) and rendered with klea_utils.ui.inspect_format.format_details() so multi-line values such as prompts read as text. Mirrors the web inspector pane (devdocs/system/tui.md).

File: klea_utils/ui/tui/components/inspector.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.inspector.Inspector(*, id: str | None = None)[source]

Bases: VerticalScroll

The inspector view: per-query sections of streamed inspect entries.

DEFAULT_CSS = '\n    Inspector .inspector-entry-scroll {\n        height: auto;\n        max-height: 20;\n        overflow-y: auto;\n    }\n    '

Default TCSS.

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the empty placeholder (replaced on the first refresh).

async set_entries(entries: Sequence[Mapping[str, Any]]) → None[source]

Update the inspector from the chat’s inspector_entries.

New entries are appended incrementally where possible: an update that only adds entries (or opens a new query section) mounts just the tail, so already-mounted entries keep their collapsed/expanded state. A chat switch (a different marker sequence) or an untrustworthy mounted state falls back to a full rebuild.

Parameters:

entries – The chat’s inspector_entries (markers + entries).

klea_utils.ui.tui.components.inspector.group_sections(entries: Sequence[Mapping[str, Any]]) → list[tuple[Mapping[str, Any], list[Mapping[str, Any]]]][source]

Group a flat entry/marker list into per-query sections.

The list is a per-query InspectorMarker followed by that query’s entries; a leading marker-less run is placed in a synthetic section so no entry is dropped.

Parameters:

entries – The chat’s inspector_entries.

Returns:

(marker, [entry, ...]) pairs in order.

Model-configuration modal for the Klea TUI.

A per-role editor for the active chat’s models, mirroring the web’s model dialog: each role (chat / plan / guard / …) gets provider, model, a HuggingFace backend + inference provider, and an optional custom URL; the fields join back into a model string via the shared klea_utils.ui.model_config helpers. The fields are seeded from the effective (precedence-resolved) models with their provenance, so an inherited model is shown as it will actually be used; Save persists every shown modifiable role as a per-chat override (promoting a genuine change into the session defaults so new chats inherit it), Reset clears one role’s override, and Use-for-all copies a model onto the other modifiable roles of the same kind. Catalogue providers/models and the stored credentials are fetched through the same shared helpers the web dialog uses.

File: klea_utils/ui/tui/components/model_dialog.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.model_dialog.ModelDialog(*, server_url: str, user_id: str, chat_id: str, info: dict[str, dict[str, Any]], id: str | None = None)[source]

Bases: ModalScreen[ModelResult | None]

Per-role model editor for the active chat.

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Close', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

A list of key bindings.

DEFAULT_CSS = '\n    ModelDialog {\n        align: center middle;\n    }\n    ModelDialog > VerticalScroll {\n        width: 90%;\n        max-width: 90;\n        height: auto;\n        max-height: 90%;\n        border: thick $primary;\n        background: $surface;\n        padding: 1 2;\n    }\n    ModelDialog Input {\n        width: 100%;\n    }\n    /* The per-role button row is a Horizontal, which otherwise defaults to\n     * height: 1fr and balloons each role panel open. */\n    ModelDialog Collapsible Horizontal {\n        height: auto;\n    }\n    ModelDialog Collapsible > Contents {\n        padding: 0 0 0 1;\n    }\n    ModelDialog #model-actions {\n        height: auto;\n        align-horizontal: right;\n        padding-top: 1;\n    }\n    ModelDialog #model-actions Button {\n        margin-left: 1;\n    }\n    '

Default TCSS.

action_cancel() → None[source]

Dismiss without saving models (credentials may have changed).

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the per-role editors and the action row.

on_button_pressed(event: Pressed) → None[source]

Dispatch the dialog’s buttons.

on_input_changed(event: Changed) → None[source]

React to a field edit (visibility, credentials, provider models).

on_mount() → None[source]

Set field visibility, credential status and load the effective models.

on_select_changed(event: Changed) → None[source]

React to the HuggingFace backend change.

class klea_utils.ui.tui.components.model_dialog.ModelResult(saved: list[tuple[str, str]]=<factory>, changed: bool = False, chat_id: str = '')[source]

Bases: object

Outcome of the model dialog.

Parameters:
  • saved – The (role, model) pairs persisted before the dialog closed.

  • changed – Whether anything changed (a saved model or a credential), so the app re-fetches the active models and re-evaluates gating.

  • chat_id – The chat the dialog edited, so the app refreshes that chat’s cache even if the active session changed meanwhile.

Session picker modal for the Klea TUI (ADR-0050).

Summoned with Ctrl+S (or later /sessions), it lists the user’s chats (most recently updated first) and switches to the chosen one. Rename/delete land in later steps. The app fetches the list and passes it in; the modal returns a SessionAction.

File: klea_utils/ui/tui/components/sessions.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.sessions.SessionAction(kind: str, chat_id: str = '', title: str = '')[source]

Bases: object

The picker’s outcome.

Parameters:
  • kind – "switch", "rename", "delete" or "cancel".

  • chat_id – The chosen chat id (for "switch"/"rename"/ "delete").

  • title – The new title (for "rename").

class klea_utils.ui.tui.components.sessions.SessionDelete(title: str)[source]

Bases: ModalScreen[bool]

A confirmation modal for deleting a session (True to delete).

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Cancel', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

A list of key bindings.

DEFAULT_CSS = '\n    SessionDelete {\n        align: center middle;\n    }\n    #delete-dialog {\n        width: 60%;\n        height: auto;\n        border: round $error;\n        padding: 1 2;\n        background: $surface;\n    }\n    #delete-title {\n        text-style: bold;\n        height: 1;\n    }\n    #delete-body {\n        height: auto;\n        padding-bottom: 1;\n    }\n    '

Default TCSS.

action_cancel() → None[source]

Dismiss without deleting (Escape or Cancel).

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the warning and Cancel / Delete.

on_button_pressed(event: Pressed) → None[source]

Confirm or cancel the deletion.

Parameters:

event – The Button.Pressed event.

on_mount() → None[source]

Focus Cancel, so a stray Enter does not delete.

class klea_utils.ui.tui.components.sessions.SessionPicker(sessions: list[dict[str, Any]], active_id: str)[source]

Bases: ModalScreen[SessionAction]

A modal listing the user’s chats; Enter/click switches to one.

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Cancel', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

A list of key bindings.

DEFAULT_CSS = '\n    SessionPicker {\n        align: center middle;\n    }\n    #sessions-dialog {\n        width: 80%;\n        height: auto;\n        max-height: 80%;\n        border: round $primary;\n        padding: 1 2;\n        background: $surface;\n    }\n    #sessions-title {\n        text-style: bold;\n        height: 1;\n    }\n    #sessions-list {\n        height: auto;\n        max-height: 16;\n    }\n    #sessions-preview {\n        height: auto;\n        max-height: 4;\n        padding-top: 1;\n        color: $text-muted;\n    }\n    '

Default TCSS.

action_cancel() → None[source]

Dismiss without switching (Escape or Cancel).

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the session list and Cancel.

on_button_pressed(event: Pressed) → None[source]

Rename/delete the highlighted session, or cancel.

Parameters:

event – The Button.Pressed event.

on_list_view_highlighted(event: Highlighted) → None[source]

Show the highlighted session’s first-query preview.

Parameters:

event – The ListView.Highlighted event.

on_list_view_selected(event: Selected) → None[source]

Switch to the selected session.

Parameters:

event – The ListView.Selected event.

on_mount() → None[source]

Focus the list (or the Cancel button when empty).

class klea_utils.ui.tui.components.sessions.SessionRename(title: str)[source]

Bases: ModalScreen[str | None]

A small modal collecting a new session title (None on cancel).

BINDINGS: ClassVar[list[Binding]] = [Binding(key='escape', action='cancel', description='Cancel', show=True, key_display=None, priority=False, tooltip='', id=None, system=False, group=None)]

A list of key bindings.

DEFAULT_CSS = '\n    SessionRename {\n        align: center middle;\n    }\n    #rename-dialog {\n        width: 60%;\n        height: auto;\n        border: round $primary;\n        padding: 1 2;\n        background: $surface;\n    }\n    #rename-title {\n        text-style: bold;\n        height: 1;\n    }\n    '

Default TCSS.

action_cancel() → None[source]

Dismiss without renaming (Escape or Cancel).

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the title input and Save / Cancel.

on_button_pressed(event: Pressed) → None[source]

Save the new title, or cancel.

Parameters:

event – The Button.Pressed event.

on_mount() → None[source]

Focus the input.

Status panel for the Klea TUI.

The full-height right panel: the chat’s streamed state (devdocs/system/tui.md) - the per-node state sections keyed by section key and the accumulated token usage. Each state section renders like the web status pane: a collapsible heading with the node’s display (Markdown, or a literal block when preformatted), its summary and its details via klea_utils.ui.inspect_format.format_details().

File: klea_utils/ui/tui/components/status_panel.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.status_panel.StatusPanel(*, ctx: TuiContext | None = None, id: str | None = None)[source]

Bases: Vertical

The right panel: fixed app slots/info above a scrolling state body.

The top is fixed so it is always visible: the app-defined controls registered on TuiContext.status_extras (e.g. the agent’s operating mode and tool-access rows), then the selected models and the token usage. Only the streamed state sections below (the plan etc.) scroll; they are reconciled by key on every set_state().

DEFAULT_CSS = "\n    StatusPanel #status-extras {\n        height: auto;\n        margin-bottom: 1;\n    }\n    StatusPanel #status-fixed {\n        height: auto;\n    }\n    StatusPanel #status-scroll {\n        height: 1fr;\n    }\n    StatusPanel #status-models {\n        height: auto;\n        margin-bottom: 1;\n        /* Left-align with the row above (the Collapsible default indents). */\n        padding-left: 0;\n    }\n    /* Flush every status-pane collapsible (models + state sections) left, so\n     * the titles line up with the rows above (the Collapsible default indents\n     * both the box and its title/contents). */\n    StatusPanel Collapsible {\n        padding-left: 0;\n        /* Restore the space below each section (setting an edge here resets\n         * the Collapsible's own padding). */\n        padding-bottom: 1;\n    }\n    StatusPanel CollapsibleTitle {\n        padding-left: 0;\n        padding-right: 0;\n    }\n    StatusPanel Collapsible > Contents {\n        padding-left: 0;\n    }\n    StatusPanel #status-usage {\n        margin-bottom: 1;\n    }\n    StatusPanel .status-missing {\n        color: $error;\n    }\n    /* State sections sit on the status pane's own surface, not a distinct\n     * Collapsible ``$surface`` box. */\n    StatusPanel Collapsible,\n    StatusPanel CollapsibleTitle {\n        background: transparent;\n    }\n    "

Default TCSS.

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the app slots, the fixed info, and the scrolling sections.

on_collapsible_toggled(event: Toggled) → None[source]

Persist the Models group’s open/closed state across refreshes.

The fixed widgets are rebuilt on every status update, so the collapsed state is remembered on the chat rather than on the widget.

Parameters:

event – The Collapsible.Toggled event.

async set_state(chat: Mapping[str, Any] | None, *, refresh_extras: bool = False) → None[source]

Rebuild the fixed info and reconcile the scrolling state body.

The fixed slots are rebuilt wholesale; the state sections are reconciled by key (_reconcile_sections()), so an unchanged section is refreshed in place rather than rebuilt.

Parameters:
  • chat – The chat’s data dict, or None when there is none.

  • refresh_extras – Also rebuild the app slots (a context change can alter what a selector should show).

Transcript widget for the Klea TUI.

The scrolling chat transcript: each message is rendered MIME-aware, mirroring the web’s chat_bubble rules (devdocs/system/tui.md, ADR-0040): Markdown for prose, a syntax-highlighted block for diffs and text/x-<lang> code, an optional header for tool blocks, and a plain text fallback whenever a MIME cannot be rendered (invariant I7).

File: klea_utils/ui/tui/components/transcript.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.transcript.Transcript(*, id: str | None = None)[source]

Bases: VerticalScroll

The scrolling transcript of chat messages.

async append(message: MessageData) → None[source]

Append one message and scroll to the bottom.

Parameters:

message – A message dict (see state.MessageData).

can_focus = True

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the empty placeholder (removed on the first message).

async set_messages(messages: Sequence[Mapping[str, Any]]) → None[source]

Rebuild the transcript from a chat’s messages (on load/switch).

Parameters:

messages – The chat’s messages list.

klea_utils.ui.tui.components.transcript.render_message(message: Mapping[str, Any], *, error_style: str = 'bold', glyphs: GlyphSet | None = None, tints: Mapping[str, str] | None = None) → ConsoleRenderable | RichCast | str[source]

Build the Rich renderable for one transcript message.

A thin, themeless wrapper around _render_message() for callers that build a single renderable directly (for example tests). The transcript itself resolves the glyph table and the role tints once and passes them in.

Parameters:
  • message – A message dict (see state.MessageData).

  • error_style – Style for an errored tool block’s header when no resolved tool_error tint is supplied (defaults to plain bold).

  • glyphs – The glyph table to draw markers from; the terminal’s table is detected when omitted.

  • tints – Role -> colour resolved from the active theme; omitted roles render with no colour.

Returns:

A Rich renderable for the transcript.

Turn-status widget for the Klea TUI.

The line under the transcript shows the active turn’s state (ADR-0050, devdocs/system/tui.md): an animated spinner and heading while a run streams, an error with a Retry hint, a “stopped” note after a user cancel, or an awaiting-input note while the run is paused for HITL. It mirrors the web chat-area turn-status region.

File: klea_utils/ui/tui/components/turn_status.py

Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>

class klea_utils.ui.tui.components.turn_status.TurnStatus(*, id: str | None = None)[source]

Bases: Widget

The per-turn status line under the transcript.

DEFAULT_CSS = '\n    TurnStatus {\n        layout: horizontal;\n        height: 1;\n    }\n    TurnStatus #turn-spinner {\n        width: 2;\n    }\n    TurnStatus #turn-label {\n        width: 1fr;\n        color: $text-muted;\n    }\n    '

Default TCSS.

can_focus = False

Widget may receive focus.

can_focus_children = True

Widget’s children may receive focus.

compose() → Iterable[Widget][source]

Compose the spinner cell and the label.

show_awaiting_input() → None[source]

Show that the run paused for HITL input (ADR-0046).

show_error(message: str, resumable: bool) → None[source]

Show a failed run, with a Retry hint when it can be resumed.

Parameters:
  • message – The error message.

  • resumable – Whether a Retry is available (Ctrl+R).

show_idle() → None[source]

Clear the status line (turn finished).

show_progress(heading: str) → None[source]

Show an in-flight run: spinner plus live heading.

Parameters:

heading – The live progress heading.

show_stopped() → None[source]

Show that the user stopped the run.