Last reviewed: November 15, 2026 (Asia/Shanghai). Checked baseline: Python 3.12.3, Textual 8.2.8, and HTTPX 0.28.1.

Direct answer

Build a Textual terminal client for CometAPI by handling Input.Submitted, starting the network operation in a Textual worker, and displaying only an accepted complete text response. Keep a small submission gate in the interface: one request may be pending, while the input remains available for drafting the next question. Pressing Enter during that wait leaves the draft intact and sends nothing further.

That separation matters in an interactive terminal. Calling an async function directly from a message handler can still hold up that handler’s message processing. The Textual workers guide demonstrates this problem with network requests and shows how a worker lets other interface messages continue. This tutorial applies that mechanism to one explicit text submission.

Who this is for

Use this small application when you want a keyboard-driven local prompt tool with a scrollable answer area. Each question is independent: the next request contains only its submitted text. The application holds one visible answer and creates no conversation database.

You need Python 3.11 or newer, a terminal supported by Textual, and a CometAPI key and current model identifier supplied through process configuration. The checked code uses Python 3.12.3. For a browser interface with a visible exchange log, see the Panel prompt workbench .

Key takeaways

  • Dispatch on Enter through Input.Submitted; ordinary editing makes no request.
  • Start a worker without awaiting its completion in the input handler.
  • Set the pending flag before scheduling work so a second submission cannot slip through.
  • Preserve text drafted while a reply is pending.
  • Accept one complete assistant text choice before updating the answer area.
  • Test the interface with a delayed synthetic answer before connecting a live account.

Install and configure the local application

Create an isolated environment and install the checked application dependencies:

python3 -m venv .venv
. .venv/bin/activate
python -m pip install "textual==8.2.8" "httpx==0.28.1"

Supply COMETAPI_KEY and COMETAPI_MODEL through your local secret configuration before launch. The key belongs in the Python process, outside the source file and prompt field. Choose an exact current model ID that supports ordinary text Chat Completions and is available to your account. No model name embedded in an old tutorial establishes current access.

The CometAPI chat reference documents the endpoint, bearer authentication, messages, and completion outcomes used below. The request sends only model, one user message, and stream: false. Review the selected model’s requirements before adding optional sampling, reasoning, or output parameters.

Build the request boundary

Save this first block as terminal_app.py:

import asyncio
import json
import os

import httpx
from textual import work
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Footer, Input, Static


ENDPOINT = "https://api.cometapi.com/v1/chat/completions"
MAX_PROMPT_CHARS = 4_000
MAX_RESPONSE_BYTES = 262_144


class ReplyError(Exception):
    pass


def required_setting(name):
    value = os.environ.get(name, "").strip()
    if not value:
        raise RuntimeError(f"Missing setting: {name}")
    return value


def accepted_text(data):
    choices = data.get("choices") if isinstance(data, dict) else None
    if (
        not isinstance(data, dict)
        or data.get("object") != "chat.completion"
        or not isinstance(choices, list)
        or len(choices) != 1
    ):
        raise ReplyError("response_shape")
    choice = choices[0]
    message = choice.get("message") if isinstance(choice, dict) else None
    if (
        not isinstance(choice, dict)
        or choice.get("finish_reason") != "stop"
        or not isinstance(message, dict)
        or message.get("role") != "assistant"
        or message.get("refusal") is not None
        or message.get("tool_calls") not in (None, [])
        or not isinstance(message.get("content"), str)
        or not message["content"].strip()
    ):
        raise ReplyError("response_outcome")
    return message["content"]


async def fetch_answer(prompt, key, model, transport=None):
    async with asyncio.timeout(30):
        async with httpx.AsyncClient(
            timeout=25.0, follow_redirects=False, transport=transport
        ) as client:
            async with client.stream(
                "POST",
                ENDPOINT,
                headers={
                    "Authorization": f"Bearer {key}",
                    "Accept": "application/json",
                },
                json={
                    "model": model,
                    "messages": [{"role": "user", "content": prompt}],
                    "stream": False,
                },
            ) as response:
                if response.status_code != 200:
                    raise ReplyError("http_status")
                media = response.headers.get("content-type", "")
                if media.split(";", 1)[0].strip().lower() != "application/json":
                    raise ReplyError("media_type")
                body = bytearray()
                async for chunk in response.aiter_bytes():
                    if len(body) + len(chunk) > MAX_RESPONSE_BYTES:
                        raise ReplyError("response_size")
                    body.extend(chunk)
    return accepted_text(json.loads(body))

The acceptance function requires a chat.completion object with one assistant choice, finish_reason: "stop", and nonempty text. It rejects refusals, tool calls, and length-limited output. These are application rules for a text prompt tool; the provider’s broader response format supports other outcomes.

HTTPX’s async reference documents the async streaming context, byte iterator, and automatic response closure. HTTP streaming here controls body consumption; the model request still asks for complete JSON. The byte check limits the accumulated decoded body before parsing. It does not establish a strict ceiling on transient decompression memory.

The 30-second async budget surrounds client setup, the request, body reading, and closure. JSON parsing follows it with a bounded accepted body. This local timeout cannot establish whether remote generation finished. The character and byte limits are application choices, separate from token allowances and account spending controls.

This low-volume example creates a client inside each operation and closes it on exit. For an application that needs a shared connection pool, use an explicit application lifetime as described in the Python HTTPX client tutorial .

Connect submission to a worker

Append this block to the same file:

class PromptApp(App):
    BINDINGS = [("ctrl+q", "quit", "Quit")]
    CSS = """
    #status { height: auto; padding: 1; }
    #answer-pane { height: 1fr; padding: 1; }
    #answer { height: auto; }
    #prompt { dock: bottom; }
    """

    def __init__(self, answer_fn):
        super().__init__()
        self.answer_fn = answer_fn
        self.busy = False

    def compose(self) -> ComposeResult:
        yield Static("Enter one independent question; press Enter to send.",
                     id="status", markup=False)
        with VerticalScroll(id="answer-pane"):
            yield Static("", id="answer", markup=False)
        yield Input(placeholder="Question", id="prompt",
                    max_length=MAX_PROMPT_CHARS)
        yield Footer()

    def on_mount(self) -> None:
        self.query_one("#prompt", Input).focus()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        prompt = event.value.strip()
        status = self.query_one("#status", Static)
        if self.busy:
            status.update("Still waiting. Your next draft has not been sent.")
            return
        if not prompt or len(event.value) > MAX_PROMPT_CHARS:
            status.update("Enter 1 to 4,000 characters.")
            return
        self.busy = True
        event.input.value = ""
        self.query_one("#answer", Static).update("")
        status.update("Waiting for a complete reply. You may draft another question.")
        self.generate(prompt)

    @work(group="generation", description="Single text request")
    async def generate(self, prompt):
        try:
            answer = await self.answer_fn(prompt)
            if not isinstance(answer, str) or not answer.strip():
                raise ReplyError("empty_answer")
        except asyncio.CancelledError:
            raise
        except Exception:
            self.query_one("#status", Static).update(
                "No accepted reply. Check configuration before sending again."
            )
        else:
            self.query_one("#answer", Static).update(answer)
            self.query_one("#status", Static).update("Reply ready.")
        finally:
            self.busy = False


def main():
    key = required_setting("COMETAPI_KEY")
    model = required_setting("COMETAPI_MODEL")

    async def answer_fn(prompt):
        return await fetch_answer(prompt, key, model)

    PromptApp(answer_fn).run()


if __name__ == "__main__":
    main()

The Input reference distinguishes Changed from Submitted and documents the character limit. This application handles only submission. Typing, clearing the field after an accepted send, and drafting during a wait cannot trigger generation by themselves.

The pending flag is set synchronously before generate() schedules its worker. While it is true, another Enter updates the status and returns without clearing the input. Completion changes the answer and status, leaving any new draft untouched. The finally branch releases the gate after success or failure.

Textual’s worker decorator makes the decorated method schedule work when called, so this handler calls self.generate(prompt) without await. Keep network I/O asynchronous inside the worker. Moving a blocking network library into an async def function would still block the event loop.

The work decorator reference documents a subtle diagnostic detail: its default description can include a representation of the method’s arguments. The explicit fixed description avoids putting the submitted question into that worker description. Treat debug consoles and custom logging separately; this setting is not a complete privacy policy for every framework diagnostic.

The answer uses Static(markup=False). The Static reference documents that markup parsing otherwise defaults to enabled. A reply such as [bold]example[/bold] therefore remains literal text in this widget. The application adds no Markdown renderer or ANSI conversion.

Check the interface without a live account

Save the following as test_terminal_app.py. The injected answer function waits until the test releases it, making the pending state reproducible:

import asyncio

import pytest
from textual.widgets import Input, Static
from terminal_app import PromptApp


@pytest.mark.asyncio
async def test_submission_preserves_the_next_draft():
    started = asyncio.Event()
    release = asyncio.Event()
    calls = []

    async def answer_fn(prompt):
        calls.append(prompt)
        started.set()
        await release.wait()
        return "[bold]Literal fixture[/bold]"

    app = PromptApp(answer_fn)
    async with app.run_test() as pilot:
        box = app.query_one("#prompt", Input)
        box.value = "First question"
        await pilot.pause()
        assert calls == []
        await pilot.press("enter")
        await asyncio.wait_for(started.wait(), 1)
        box.value = "Next draft"
        await pilot.press("enter")
        assert calls == ["First question"]
        assert box.value == "Next draft"
        release.set()
        await app.workers.wait_for_complete()
        await pilot.pause()
        answer = app.query_one("#answer", Static).visual
        assert answer.plain == "[bold]Literal fixture[/bold]"
        assert not answer.spans
        assert box.value == "Next draft"
        assert not app.busy

Install the test tools and run the check:

python -m pip install pytest pytest-asyncio
python -m pytest -q test_terminal_app.py

The Textual testing guide documents run_test() as a headless application context, Pilot.press() for keyboard interactions, and pause() for pending messages. A headless test exercises the message flow without taking over the terminal screen.

During preparation, 15 offline cases passed across the interface and HTTP boundary. They covered editing without dispatch, pending submission rejection, draft preservation, a subsequent send, fixed error display and recovery, shutdown cancellation, exact request construction, independent message bodies, and ten transport or completion rejection cases. A separate render check confirmed literal markup with no style spans. The included test provides the focused submission check; the broader matrix below describes the additional coverage to retain in your project. No authenticated CometAPI request or interactive terminal acceptance run was performed.

Contract details to verify

BoundaryRequired behaviorLocal evidence
EditingNo network dispatch before EnterSet or type a draft and inspect the answer-function call list.
Pending submissionOne active local attempt; second Enter preserves the draftHold the first answer with an event, then submit again.
RequestFixed POST endpoint, bearer header, one user message, complete JSONInspect a synthetic HTTPX transport request.
CompletionOne accepted assistant text resultReject empty choices, refusal, tool calls, and length.
Response readingStatus, JSON media type, and byte ceiling precede parsingReturn HTTP errors, redirects, HTML, malformed JSON, and excess bytes.
Failure recoveryFixed status text and released submission gateRaise a synthetic exception, then complete the next submission.
ExitLocal worker receives cancellationWait inside a synthetic answer function and quit the app.

Happy-path operator workflow

Run the headless check first. Then start the configured application with python terminal_app.py and submit one short, harmless question. Confirm that the input starts focused, the status changes to waiting, and a complete answer appears in the scrollable area.

While waiting, draft another question and press Enter. The draft should remain, with a status explaining that it has not been sent. Once the first result arrives, press Enter deliberately to send the draft. Its body should contain only that question; earlier answers are unavailable as context. Keep any retained verification record to dependency versions, a fixed outcome category, and the local acceptance result.

Failure modes

The input looks active but Enter does nothing. Confirm that on_mount() focuses the Input and that the handler is named on_input_submitted. Test focus through Pilot before troubleshooting credentials.

The terminal becomes unresponsive during a request. Check that the handler schedules the decorated worker without awaiting its completion. Then inspect the worker for synchronous I/O or other blocking work.

A second Enter starts another request. Check that the pending guard runs before clearing the input or scheduling work. The gate is local to this application instance. Multiple instances can still make concurrent upstream requests.

The status reports no accepted reply. An HTTP error, timeout, unexpected media type, malformed body, or unaccepted completion can reach that fixed message. Reproduce those branches with a synthetic transport. Avoid exposing raw exception strings, response bodies, authorization headers, prompts, or replies in routine diagnostics.

Quit leaves uncertainty about upstream work. Textual’s workers guide says running workers are canceled when the app exits. The code re-raises cancellation and unwinds its HTTP contexts. That establishes local cleanup behavior; it does not prove that the remote generation or its usage stopped. Reconcile uncertain attempts before deliberately repeating a request.

Error-path operator workflow

Inject an answer function that raises an exception containing a synthetic private marker. Confirm that the terminal shows only the fixed failure status, the marker is absent, the answer area remains empty, and another deliberate submission works.

Next use an HTTPX mock transport to return the rejection cases in the table. Assert one attempt for each submission and no redirect following. Finally, hold a synthetic answer open and press Ctrl+Q; verify that its cleanup runs. Keep these checks separate from account access and real network behavior.

FAQ

Why keep the input enabled while waiting?

It lets the user prepare the next independent question. The submission gate prevents that draft from becoming another local attempt before the pending operation finishes.

Does the application send conversation history?

Each request contains one user message. The application replaces the visible answer on the next accepted send and maintains no history array.

Can I switch to streaming output?

Streaming needs a separate SSE parser and a policy for incomplete answers. Keep this version’s complete-response acceptance intact until you implement and test that different contract.

Does exiting undo a request?

Exiting cancels the local worker. Remote completion and usage require their own evidence; the interface provides no rollback guarantee and makes no automatic retry.

Sources checked

These seven maintained primary references were fetched successfully on November 14, 2026 UTC, November 15 in Asia/Shanghai. They support an evergreen implementation checked against the dependency baseline above; this article makes no release or incident claim.

The single-pending-request policy, independent questions, acceptance rules, limits, and test matrix are application recommendations built on those interfaces.

Reader next step

Run the delayed-answer test, verify the fixed error path, and check worker cleanup first. Then configure a current model and make one short terminal exchange. Preserve the explicit Enter action and draft behavior as you extend the interface, and repeat the same checks whenever you change its request lifecycle.