Building an AI Chat Starter Kit for CMP: ~20 Lines from Empty Screen to ChatGPT-Quality Streaming

PromptBar and LlmTypewriter working together in an iPhone simulator — slash commands, @-mentions, attachment chips, and a streaming assistant reply with live Markdown and syntax-highlighted code

TL;DR

I open-sourced two Compose Multiplatform libraries that pair into an AI chat starter kit you can drop into any Android / iOS / Desktop / Web app:

  • 🤖 prompt-bar — the composer (slash commands, mentions, attachments, send/stop)
  • 💬 llm-typewriter — the renderer (Flow<String> streaming, live markdown, progressive syntax highlighting)

The point isn’t either lib in isolation. The point is they’re built to wire together so you get a ChatGPT-quality streaming chat UI in ~20 lines on every CMP target.

implementation("io.github.nadeemiqbal:prompt-bar:0.1.0")
implementation("io.github.nadeemiqbal:llm-typewriter:0.1.1")

The problem

I was working on a side project — a Kotlin Multiplatform app that wraps an LLM API — and I needed a chat UI. The kind you’ve used a hundred times: textfield at the bottom with slash commands and @-mentions, attachment chips above it, a Send button that becomes Stop while the assistant streams a reply, markdown-rich responses with syntax-highlighted code blocks.

Two days later I had a half-working prototype, no tests, and a growing TODO list. The CMP ecosystem just didn’t have these pieces wired together.

Here’s what I found when I went looking:

What I needed What exists today
Chat composer Stream Chat ships a polished AI composer for Android, tied to their commercial backend.
Slash commands + @ mentions in a composer No CMP option I could find. React has good references (Vercel AI Elements is actively working on these).
Streaming typewriter (Flow-based) Typist-CMP and Texty cover typewriter animation for static strings. I needed a Flow-of-String source so tokens paint the moment they arrive from an LLM SDK.
Live progressive markdown rendering No CMP option I could find. Even in React, Vercel’s streamdown is the only popular one.
Syntax-highlighted code blocks (built up live) No CMP option I could find — needs an incremental tokenizer.

The existing libraries are good at what they do. What I needed for my project was a different combination — these five things working together as one experience, on every CMP target. So I built it.

What’s in prompt-bar

PromptBar is one composable plus a headless PromptBarState.

Inputs you wire:

  • slashCommands: List<SlashCommand> — name + description + hotkey + onSelect lambda. Type / and an autocomplete dropdown opens.
  • mentionProvider: MentionProvider — suspend fun suggest(query: String): List<Mention>. Plug in your contact / file / symbol source. Async by design so you can hit Room / Ktor / system contacts.
  • templates: List<PromptTemplate> — quick-prompt chips above the input. Tap to populate.
  • modelSelector: @Composable () -> Unit — slot for whatever model picker UI fits your app.
  • onVoiceTap: () -> Unit — mic button slot. Library doesn’t decode audio (BYO).

State the library owns:

  • text / fieldValue — the textfield content (live token counter / char count derived)
  • attachments: List<PromptAttachment> — chips above the input; addAttachment / removeAttachment
  • sendState: SendState — Disabled / Ready / Sending / Streaming — derived from content, overridable with markSending() / markStreaming() / markReady()
  • selectedModel: ModelOption? — currently-selected model
  • activeTrigger: ActiveTrigger — what autocomplete is currently open

Key design decisions:

  • / and @ only open the dropdown at the start of a line or after whitespace — so email@domain doesn’t spuriously trigger mentions.
  • The Send button is one button that morphs visually based on sendState. No “Send disabled until text” + “separate Stop button” UX — one button, four visual states.
  • Smart paste tokenizer for blobs: paste [email protected], [email protected] (comma- or newline-separated) and pasteTokensAsAttachments splits it into chips.
  • Headless state. PromptBarState can be constructed without composition (handy for ViewModels and tests).

What’s in llm-typewriter

StreamingTypewriter takes a Flow<String> of tokens — typically straight from your LLM SDK’s streaming API — and reveals them at the cadence dictated by a SpeedCurve.

The Flow-of-String API matters. A typewriter that takes a static String means you have to buffer the entire LLM response before showing anything. With a Flow, the first token paints the moment it arrives — exactly what you want for an LLM chat.

Live progressive Markdown. The renderer re-parses the revealed text every frame using a prefix-stable parser — the same prefix of input always yields the same prefix of tokens. So:

  • **bold mid-stream renders as plain text. The moment ** closes, it flips to bold.
  • Headings (# Title) render once the line completes.
  • Fenced code blocks (the triple-backtick kotlin kind) render progressively as the lines arrive — with syntax highlighting. Code keywords / strings / numbers / comments highlight live, line by line, as the model emits them.

Three speed curves. A fun interface SpeedCurve lets you tune the cadence:

SpeedCurve.Linear    // constant — every char takes the same time
SpeedCurve.EaseOut   // slight stretch on whitespace
SpeedCurve.Natural   // pauses on .!?,;:n like a human typist

Or write your own:

val excitedTypist = SpeedCurve { base, _, next ->
    if (next == '!') base * 8 else base
}

Other table-stakes things: tap-to-skip (reveal everything immediately on tap), graceful stop-mid-stream (state.stop() shows a “(stopped)” ghost indicator), custom @Composable cursor (block, line, underscore, or anything you want), screen-reader-friendly live region.

The integration — why these are pitched as a pair

Either library alone is useful. Together, they cover a workflow:

@Composable
fun ChatScreen(vm: ChatViewModel) {
    val prompt = rememberPromptBarState()
    val typewriter = rememberStreamingTypewriterState()

    // Send/Stop button auto-syncs with the typewriter's lifecycle.
    LaunchedEffect(typewriter.isStreaming) {
        if (typewriter.isStreaming) prompt.markStreaming()
        else prompt.markReady()
    }

    Column {
        // Your message list ... assistant bubble uses StreamingTypewriter:
        StreamingTypewriter(
            tokens = vm.responseFlow,
            state = typewriter,
            renderer = rememberMarkdownTypewriterRenderer(),
        )

        // The composer:
        PromptBar(
            state = prompt,
            onSend = { vm.send(prompt.outgoing) },
            onStop = { typewriter.stop(); vm.cancelStream() },
            slashCommands = listOf(
                SlashCommand("clear", "Clear conversation") { vm.clear() },
            ),
            mentionProvider = MentionProvider.fromList(vm.contacts),
        )
    }
}

That’s the whole integration. Tap Send → message goes out → assistant bubble starts streaming → button morphs to Stop → tap Stop → typewriter freezes mid-token + shows “(stopped)” + button flips back to Send. The polish layer that usually takes a weekend from scratch.

What’s next

Both are 0.1.x. Backlog:

  • prompt-bar: server-driven prompt templates, image attachment previews (composable slot for actual bitmap), full prompt-history scrollback, drag-to-reorder chips, command palette mode.
  • llm-typewriter: more languages in the highlighter (Rust, Go, Swift), table rendering, footnotes, custom thinking-block recognition, voice-of-thought style cycling.

Issues / PRs / feedback very welcome.

Try them

implementation("io.github.nadeemiqbal:prompt-bar:0.1.0")
implementation("io.github.nadeemiqbal:llm-typewriter:0.1.1")

Apache 2.0. Android (minSdk 24) · iOS (x64/arm64/sim) · Desktop (JVM 11) · Web (wasmJs).

Built because I needed it. Hopefully saves you a weekend.