DEV Community

Nadeem Iqbal
Nadeem Iqbal

Posted on

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")
Enter fullscreen mode Exit fullscreen mode

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 a@x.com, b@y.com (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
Enter fullscreen mode Exit fullscreen mode

Or write your own:

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

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),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)