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/streamrun 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_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).
- 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
Noneif 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_submit_query(event: SubmitQuery) None[source]¶
Submit a query posted by an app-defined widget (e.g. a selector).
- Parameters:
event – The
SubmitQuerymessage.
- 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:
ScreenThe 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
tabbinding (seeBINDINGS): 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.
- on_option_list_option_selected(event: OptionSelected) None[source]¶
Insert the chosen command into the input (autocomplete).
- Parameters:
event – The
OptionSelectedevent.
- on_query_changed(event: Changed) None[source]¶
Refresh the command suggestions for the current input.
- Parameters:
event – The
TextArea.Changedevent.
- async on_query_submitted(event: Submitted) None[source]¶
Hand the submitted input to the app (command or query).
- Parameters:
event – The
QueryTextArea.Submittedevent.
- 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
Nonewhen there is none.refresh_extras – Also rebuild the app slots (a
contextchange 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_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_progress(heading: str) None[source]¶
Show an in-flight run in the turn-status line.
- Parameters:
heading – The live progress heading.
- 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.
- 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_*_tuientry (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-idvalue; 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_idfile, 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.
Nonedisables 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:
objectConfiguration 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 tostatus_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.
Nonedisables 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>
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:
OptionListA completion list of matching commands for a
/prefixinput.- 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
/prefixvalue.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
Noneif 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
Noneif not loaded.capabilities – The TUI’s capabilities.
- klea_utils.ui.tui.components.commands.EXIT_APP_SERVICE = 'exit_app'¶
CommandContext.serviceskey 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.serviceskey 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.serviceskey 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.serviceskey 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.serviceskey reserved for an optional app notification when/storesrecords this chat’s configuration file. The handler writes the per-chat state directly; a registered one-argument callable receives the absolute path, orNonefor/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
CommandResultfor a client command (or an unknown / parse-error command) to render locally, orNonewhen 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
Noneif 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
Noneto 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 (
Trueto 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.
- can_focus = False¶
Widget may receive focus.
- can_focus_children = True¶
Widget’s children may receive focus.
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.
- can_focus = False¶
Widget may receive focus.
- can_focus_children = True¶
Widget’s children may receive focus.
- class klea_utils.ui.tui.components.credentials_dialog.CredentialsResult(changed: bool = False)[source]¶
Bases:
objectOutcome 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:
objectThe user’s answer to a HITL ask.
- Parameters:
response – The resume mapping sent to the server, or
Noneon 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:
VerticalScrollBase for the inline HITL panels.
A panel owns its form and posts
Decidedwhen 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:
MessagePosted when the user answers an ask.
- Parameters:
decision – The user’s
HitlDecision.
- 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:
HitlDecisionPanelAn inline review / input ask.
A
reviewask renders the headline and sections (the plan/digest) with an approve / request-changes / cancel row; aninputask 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.
- class klea_utils.ui.tui.components.hitl.PermissionPanel(ask: dict[str, Any])[source]¶
Bases:
HitlDecisionPanelAn 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
RadioSetdefaulting todeny; 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.
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:
VerticalThe 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.
- class klea_utils.ui.tui.components.input.QueryTextArea(*, id: str | None = None)[source]¶
Bases:
TextAreaMulti-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:
MessagePosted when the user presses Enter to submit the query.
- 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:
VerticalScrollThe 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.
- 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
InspectorMarkerfollowed 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.
- can_focus = False¶
Widget may receive focus.
- can_focus_children = True¶
Widget’s children may receive focus.
- class klea_utils.ui.tui.components.model_dialog.ModelResult(saved: list[tuple[str, str]]=<factory>, changed: bool = False, chat_id: str = '')[source]¶
Bases:
objectOutcome 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:
objectThe 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 (
Trueto 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.
- can_focus = False¶
Widget may receive focus.
- can_focus_children = True¶
Widget’s children may receive focus.
- 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.
- can_focus = False¶
Widget may receive focus.
- can_focus_children = True¶
Widget’s children may receive focus.
- on_button_pressed(event: Pressed) None[source]¶
Rename/delete the highlighted session, or cancel.
- Parameters:
event – The
Button.Pressedevent.
- on_list_view_highlighted(event: Highlighted) None[source]¶
Show the highlighted session’s first-query preview.
- Parameters:
event – The
ListView.Highlightedevent.
- class klea_utils.ui.tui.components.sessions.SessionRename(title: str)[source]¶
Bases:
ModalScreen[str|None]A small modal collecting a new session title (
Noneon 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.
- can_focus = False¶
Widget may receive focus.
- can_focus_children = True¶
Widget’s children may receive focus.
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:
VerticalThe 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 everyset_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.Toggledevent.
- 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
Nonewhen there is none.refresh_extras – Also rebuild the app slots (a
contextchange 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:
VerticalScrollThe 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.
- 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_errortint 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:
WidgetThe 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.
- 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).