Source code for klea_utils.ui.web.nicegui.components.status_pane

#!/usr/bin/env python3
"""
Status pane component: the right drawer with chat state details.

Shows the chat name, per-role model summary (with a settings button
opening the model-config dialog), token usage totals, and the
per-node status sections streamed by the graph.  Everything up to (and
including) the token usage stays visible: it is a sticky header, so the state
sections below scroll under it (mirroring the TUI status panel).

File: klea_utils/ui/web/nicegui/components/status_pane.py

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

import json
import logging
from datetime import datetime
from typing import Any

from nicegui import ui

from klea_utils.llm import missing_required_roles, parse_model_name
from klea_utils.ui.linkify import linkify_md
from klea_utils.ui.state import ChatData, chats, format_token_usage, missing_credentials
from klea_utils.ui.web.nicegui.components.context import PageContext

logger = logging.getLogger(__name__)


[docs] def attach_status_pane(ctx: PageContext) -> None: """Build the right drawer (status pane) and register its refresh. Must be called with ``ctx.model_config_dialog`` already registered (by :func:`model_dialog.attach_model_info`), since the pane's settings button invokes it. :param ctx: The shared page context; ``refresh_status_pane`` is set here. """ with ( ui.right_drawer(value=True) .props("width=420 bordered") .classes("overflow-y-auto overflow-x-hidden status-pane") ): @ui.refreshable def _status_pane() -> None: """Render the sticky header, then the state sections below it. The app-defined context controls (mode/access) render even before a chat exists, so they can be set before the first message; they attach to the chat once it is created. """ current_chat: ChatData | dict[Any, Any] = ( chats.get(f"{ctx.user_id}:{ctx.chat_id}") or {} ) chat_scope = bool(current_chat) if current_chat: model_info = current_chat.get("model_info", {}) scope_label = current_chat.get("name", "") or "Models" else: model_info = ctx.session_model_info scope_label = "Default models" missing_models = missing_required_roles(model_info) missing_providers = sorted( { (model_info[role].get("credential") or {}).get("provider", "") for role in missing_credentials(model_info) } - {""} ) needs_attention = bool(missing_models or missing_providers) logger.debug( f"{ctx.chat_id = }\n{missing_models = }\n{missing_providers = }" ) # Sticky header: stays visible while the state sections scroll. with ui.column().classes("w-full gap-0 p-2 sticky top-0 z-20"): # App-defined context slots (e.g. operating-mode and # tool-access selectors, ADR-0030/ADR-0037). for render in ctx.status_extras: render() # The scope row (chat name + settings) is the Models group's # collapsible header, so the whole group toggles from one line. with ui.expansion().classes("w-full text-xs models-group") as models: with ( models.add_slot("header"), ui.row().classes("items-center w-full no-wrap py-1"), ): with ui.label(scope_label).classes("text-sm font-bold mb-0"): created = current_chat.get("created", 0) if created: ui.tooltip( "Created: " + datetime.fromtimestamp(created) .astimezone() .strftime("%a %d %b %Y at %X") ) ui.space() gear = ( ui.button( icon="settings", on_click=ctx.model_config_dialog, ) .props("flat dense round") .classes("text-sm icon-btn") ) if needs_attention: gear.classes("model-btn--attention") with gear: ui.tooltip( "Choose models" + (" - configuration needed" if needs_attention else "") ) if model_info: tooltip_parts: list[str] = [] with ui.element("ul").classes("models-list"): for role, cfg in model_info.items(): raw = cfg.get("model", "") provider = cfg.get("provider", "") required = cfg.get("required", True) if not raw: # No model set for this role -- show a # clear placeholder so the user knows it is # missing. display_short = "Not set" required_mark = " (required)" if required else "" tooltip_short = f"Not set{required_mark}" else: display_short = ( parse_model_name(raw).model_name or raw ) if provider: tooltip_short = f"{display_short} ({provider})" else: tooltip_short = display_short credential = cfg.get("credential") or {} if credential.get("requires_key"): if credential.get("source") == "none": tooltip_short += " [no API key]" elif credential.get("source") == "user": tooltip_short += ( f" [key {credential.get('masked', '')}]" ) if cfg.get("overridden"): tooltip_short += ( " [Chat]" if chat_scope else " [Default]" ) if chat_scope and cfg.get("session_overridden"): tooltip_short += " [Default]" with ui.element("li"): ui.label( f"{role.capitalize()}: " f"{provider or '(unknown)'}" ) ui.label(display_short).classes("pl-4") tooltip_parts.append( f"{role.capitalize()}: {tooltip_short}" ) if tooltip_parts: ui.tooltip("\n".join(tooltip_parts)).classes( "model-tooltip" ) else: ui.label("Model information is loading...").classes( "text-xs text-grey-5" ) # Two-way binding keeps the open state across pane refreshes. models.bind_value(ctx, "status_models_expanded") if missing_models: ui.label( "Select a model to start: " + ", ".join(role.capitalize() for role in missing_models) ).classes("text-xs text-negative") elif missing_providers: ui.label( "Add an API key for: " + ", ".join(missing_providers) ).classes("text-xs text-negative") if not chat_scope: ui.label( "State updates will appear here once you send a message" ).classes("text-sm text-grey-5 mt-2") return token_usage = current_chat.get("token_usage", {}) if any(token_usage.values()): ui.label(format_token_usage(token_usage)).classes("text-xs mb-0") ui.separator().classes("my-0.5") # The state sections scroll under the sticky header. with ui.column().classes("w-full gap-0 px-2 pb-2"): sections = current_chat.get("state_sections", {}) if not sections: ui.label("State updates will appear here").classes( "text-sm text-grey-5" ) return for node_label, section in sections.items(): with ( ui.element("details") .props("open") .classes("status-entry mb-2 w-full") ): with ( ui.element("summary").classes( "text-xs font-bold cursor-pointer w-full" ), ui.row().classes("w-full flex-nowrap items-center"), ): ui.label(section.get("heading", node_label)) display = section.get("display", "") if display: if section.get("preformatted"): # Aligned/literal content (e.g. the plan): a # plain code block, so columns line up and # ``_``/``*`` in tool names are not parsed as # markdown. No language is passed (no syntax # highlighting), and # ``nicegui-code-noformat`` strips the code-box # styling. ui.code(display, language=None).classes( "text-xs w-full nicegui-code-noformat" ) else: ui.markdown(linkify_md(display)).classes( "text-xs w-full" ) summary = section.get("summary", "") if summary and not display: ui.label(summary).classes("text-xs text-grey-6 mb-1 w-full") details = section.get("details", {}) if details: with ui.element("details").classes( "status-details text-xs text-grey-5 " "cursor-pointer w-full" ): with ui.element("summary").classes("text-xs w-full"): ui.label("View details") ui.code( json.dumps(details, indent=2), language="json" ).classes("text-xs") _status_pane() ctx.refresh_status_pane = _status_pane.refresh