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 +onSelectlambda. 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 withmarkSending()/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 โ soemail@domaindoesn'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) andpasteTokensAsAttachmentssplits it into chips. - Headless state.
PromptBarStatecan 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:
-
**boldmid-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
kotlinkind) 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).
- ๐ github.com/NadeemIqbal/prompt-bar
- ๐ github.com/NadeemIqbal/llm-typewriter
- ๐ฆ Maven Central โ prompt-bar
- ๐ฆ Maven Central โ llm-typewriter
Built because I needed it. Hopefully saves you a weekend.

Top comments (0)