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

#!/usr/bin/env python3
"""
Chat input component: text area, send handling, and stream kick-off.

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

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

import logging
from datetime import datetime

import coolname
from nicegui import background_tasks, ui
from nicegui.events import GenericEventArguments

from klea_utils.ui.web.nicegui.client import create_chat_on_server
from klea_utils.ui.web.nicegui.components import stream
from klea_utils.ui.web.nicegui.components.context import PageContext
from klea_utils.ui.web.nicegui.components.storage import safe_set_user
from klea_utils.ui.web.nicegui.state import ensure_chat

logger = logging.getLogger(__name__)


[docs] def attach_input(ctx: PageContext) -> None: """Build the chat input row and wire send / Enter handling. Must be called with the chat tab panel (below the chat area) as the ambient NiceGUI context. The input is disabled until :func:`initial_load.attach_initial_load` enables it after the backend health probe succeeds. :param ctx: The shared page context; ``text`` is filled in here. """ with ui.row().classes("w-full no-wrap items-end py-4"): text = ( ui.textarea(placeholder="Start a conversation") .props("rounded outlined input-class=mx-3 autogrow") .classes("flex-grow") ) ctx.text = text def send() -> None: """Append the current input text as a user message, then stream.""" if not text.value.strip(): return stamp = datetime.now().astimezone().strftime("%X") query = text.value text.value = "" current = ctx.chat_id logger.debug("current=%s query_len=%d", current, len(query)) if not current: current = coolname.generate_slug(2) ctx.chat_id = current safe_set_user("chat_id", current) ensure_chat(ctx.user_id, current) background_tasks.create( create_chat_on_server(ctx.server_url, ctx.user_id, current) ) # Populate model_info for the newly created chat so the # "Choose models" dialog has roles to render. Without this # the status pane stays empty and the dialog silently no-ops # for chats started by typing the first message. if ctx.fetch_model_info is not None: background_tasks.create(ctx.fetch_model_info()) ctx.refresh_chat_list() ensure_chat(ctx.user_id, current)["messages"].append((query, stamp, True)) ctx.render_chat_area() ctx.refresh_chat_list() background_tasks.create(stream.run_stream(ctx, query, current)) with ( text.add_slot("append"), ui.button(icon="send", on_click=send).props( "flat dense round color=primary" ), ): ui.tooltip("Enter to send, Shift+Enter for newline") # Plain Enter sends the message and prevents the default newline def handle_enter(e: GenericEventArguments): """Send on Enter, insert newline on Shift+Enter.""" if e.args.get("shiftKey"): text.value += "\n" else: send() text.on("keydown.enter.exact.prevent", handle_enter) # Clicking the send icon inside the textarea also sends. text.on("click:append", send) # The disclaimer is a full-width line BELOW the input row. Keeping it # inside the flex row would share the line with the ``flex-grow`` # textarea: as a ``w-full`` flex sibling it wins the space and squeezes # the text box down to its content width. if ctx.disclaimer: ui.label(ctx.disclaimer).classes("text-xs text-grey-5 pb-2 w-full text-center") # Disable chat input until backend is ready; initial_load re-enables. try: text.disable() except Exception as e: # noqa: BLE001 logger.debug("disable input failed: %s", e)