
AI chat composer UI with multi-line auto-growing input, slash-command autocomplete, @mention dropdown, attachment chips/previews, unified Send/Sending/Stop state, voice support, templates and live token counter.
The AI chat composer for Compose Multiplatform. Multi-line auto-growing textarea, slash-command
autocomplete, @-mention dropdown, attachment chips with image / file previews, a unified
Send → Sending → Stop button state machine, voice button, prompt template chips, model selector,
live token counter, keyboard shortcuts — all on every CMP target, all driven from a single
headless PromptBarState.
Pairs naturally with
llm-typewriter— see the sample app for the full "AI chat starter kit" wiring (Send/Stop button auto-syncs with the typewriter's streaming state; the demo above shows it in action on iOS).
The Compose Multiplatform ecosystem has no AI-focused chat composer today. Stream Chat ships
an Android-only MessageComposer locked inside a SaaS SDK. The de-facto React stack (Vercel AI
Elements PromptInput, OpenAI playground) leaves slash commands and @ mentions as open
issues. PromptBar is the first CMP composer that ships those features as a cohesive unit:
| Capability | PromptBar | Vercel AI Elements (React) | Stream MessageComposer
|
|---|---|---|---|
| CMP target coverage | Android · iOS · Desktop · Wasm | Web only | Android only |
| Slash-command autocomplete | ✅ built-in | open issue #179 | ❌ |
@-mention dropdown (async provider) |
✅ built-in | open issue #179 | ❌ |
| Attachment chips + previews | ✅ image / file / custom | ✅ | partial (max 3) |
| Send → Sending → Stop state machine | ✅ single button | ✅ | partial |
| Prompt template chips | ✅ | partial | ❌ |
| Voice button (BYO transcription) | ✅ | ✅ | partial |
| Live token counter | ✅ pluggable tokenizer | ❌ | ❌ |
| Keyboard shortcuts (Enter/Shift+Enter) | ✅ | ✅ | ✅ |
| Smart paste-tokenizer | ✅ comma/semicolon/newline | ❌ | ❌ |
| Headless state container | ✅ PromptBarState
|
✅ | partial |
dependencies {
implementation("io.github.nadeemiqbal:prompt-bar:0.1.0")
}@Composable
fun ChatScreen(vm: ChatViewModel) {
val state = rememberPromptBarState()
PromptBar(
state = state,
onSend = { vm.send(state.outgoing) },
onStop = { vm.cancelStream() },
onVoiceTap = { vm.startVoiceInput() },
// Optional: slash commands
slashCommands = listOf(
SlashCommand("clear", "Clear conversation") { vm.clear() },
SlashCommand("imagine", "Generate an image", hotkey = "Cmd+I"),
),
// Optional: @-mention provider (sync or async)
mentionProvider = MentionProvider.fromList(vm.workspaceMentions),
// Optional: quick-start templates above the input
templates = listOf(
PromptTemplate("Summarize", "Please summarize this conversation."),
PromptTemplate("Explain code", "Explain what this code does:\n```\n\n```"),
),
// Optional: inline model picker on the bottom bar
modelSelector = { ModelDropdown(state) },
)
}Pair with llm-typewriter to wire the
Send/Stop button to your streaming response composable:
val typewriterState = rememberStreamingTypewriterState()
val promptState = rememberPromptBarState()
// Send → Stop transitions sync automatically with typewriter streaming state.
LaunchedEffect(typewriterState.isStreaming) {
if (typewriterState.isStreaming) promptState.markStreaming() else promptState.markReady()
}PromptBarState is fully usable without composition. Mutate it from a coroutine, observe its
phases, drive integration tests directly.
val state = PromptBarState()
state.fieldValue = TextFieldValue("Hello")
state.addAttachment(PromptAttachment("a1", "report.pdf", AttachmentPreview.File("pdf")))
state.markStreaming() // Send button becomes Stop
state.markReady() // back to derived state
state.applyTemplate(PromptTemplate("Summarize", "Please summarize."))
state.pasteTokensAsAttachments(clipboardText) { tok ->
PromptAttachment(id = tok, displayName = tok, preview = AttachmentPreview.File("eml"))
}
val outgoing: PromptBarOutgoing = state.outgoing // text + attachments + modelType / at the start of a line or after whitespace → the autocomplete panel appears with
matching commands. Pick one → the command's onSelect lambda fires and the /text is replaced
with the full command name.
SlashCommand(
name = "code",
description = "Switch to coding mode",
hotkey = "Cmd+K", // shown as a chip in the suggestion
onSelect = { vm.toggleCodingMode() },
)Type @ after whitespace → the MentionProvider.suggest(query) runs and the dropdown shows
results. Selecting one inserts @display into the textfield.
// Static list — case-insensitive substring match.
mentionProvider = MentionProvider.fromList(workspace.files.map {
Mention(id = it.path, display = it.name, subtitle = it.path, category = "File")
})
// Async — call your DB/API.
mentionProvider = MentionProvider { query -> repository.suggestMentions(query) }| State | Visual | Click |
|---|---|---|
Disabled |
Send icon, dimmed | Ignored |
Ready |
Send icon, primary | Fires onSend
|
Sending |
Spinner | Ignored |
Streaming |
Stop icon | Fires onStop
|
The state is derived from the composer's content by default. Override with markSending() /
markStreaming() / markReady() when you know better than the derivation.
| Target | Status |
|---|---|
| Android (minSdk 24) | ✅ |
| iOS (x64, arm64, simulatorArm64) | ✅ |
| Desktop (JVM 11) | ✅ |
| Web (wasmJs) | ✅ |
./gradlew :sample:desktopApp:run # Desktop
./gradlew :sample:androidApp:assembleDebug # Android
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun # WebThe sample is a fake chat app that exercises every feature — slash commands, mentions, attachments via the voice button, templates, model selector cycling, Send/Stop transitions.
Apache 2.0 — see LICENSE.
The AI chat composer for Compose Multiplatform. Multi-line auto-growing textarea, slash-command
autocomplete, @-mention dropdown, attachment chips with image / file previews, a unified
Send → Sending → Stop button state machine, voice button, prompt template chips, model selector,
live token counter, keyboard shortcuts — all on every CMP target, all driven from a single
headless PromptBarState.
Pairs naturally with
llm-typewriter— see the sample app for the full "AI chat starter kit" wiring (Send/Stop button auto-syncs with the typewriter's streaming state; the demo above shows it in action on iOS).
The Compose Multiplatform ecosystem has no AI-focused chat composer today. Stream Chat ships
an Android-only MessageComposer locked inside a SaaS SDK. The de-facto React stack (Vercel AI
Elements PromptInput, OpenAI playground) leaves slash commands and @ mentions as open
issues. PromptBar is the first CMP composer that ships those features as a cohesive unit:
| Capability | PromptBar | Vercel AI Elements (React) | Stream MessageComposer
|
|---|---|---|---|
| CMP target coverage | Android · iOS · Desktop · Wasm | Web only | Android only |
| Slash-command autocomplete | ✅ built-in | open issue #179 | ❌ |
@-mention dropdown (async provider) |
✅ built-in | open issue #179 | ❌ |
| Attachment chips + previews | ✅ image / file / custom | ✅ | partial (max 3) |
| Send → Sending → Stop state machine | ✅ single button | ✅ | partial |
| Prompt template chips | ✅ | partial | ❌ |
| Voice button (BYO transcription) | ✅ | ✅ | partial |
| Live token counter | ✅ pluggable tokenizer | ❌ | ❌ |
| Keyboard shortcuts (Enter/Shift+Enter) | ✅ | ✅ | ✅ |
| Smart paste-tokenizer | ✅ comma/semicolon/newline | ❌ | ❌ |
| Headless state container | ✅ PromptBarState
|
✅ | partial |
dependencies {
implementation("io.github.nadeemiqbal:prompt-bar:0.1.0")
}@Composable
fun ChatScreen(vm: ChatViewModel) {
val state = rememberPromptBarState()
PromptBar(
state = state,
onSend = { vm.send(state.outgoing) },
onStop = { vm.cancelStream() },
onVoiceTap = { vm.startVoiceInput() },
// Optional: slash commands
slashCommands = listOf(
SlashCommand("clear", "Clear conversation") { vm.clear() },
SlashCommand("imagine", "Generate an image", hotkey = "Cmd+I"),
),
// Optional: @-mention provider (sync or async)
mentionProvider = MentionProvider.fromList(vm.workspaceMentions),
// Optional: quick-start templates above the input
templates = listOf(
PromptTemplate("Summarize", "Please summarize this conversation."),
PromptTemplate("Explain code", "Explain what this code does:\n```\n\n```"),
),
// Optional: inline model picker on the bottom bar
modelSelector = { ModelDropdown(state) },
)
}Pair with llm-typewriter to wire the
Send/Stop button to your streaming response composable:
val typewriterState = rememberStreamingTypewriterState()
val promptState = rememberPromptBarState()
// Send → Stop transitions sync automatically with typewriter streaming state.
LaunchedEffect(typewriterState.isStreaming) {
if (typewriterState.isStreaming) promptState.markStreaming() else promptState.markReady()
}PromptBarState is fully usable without composition. Mutate it from a coroutine, observe its
phases, drive integration tests directly.
val state = PromptBarState()
state.fieldValue = TextFieldValue("Hello")
state.addAttachment(PromptAttachment("a1", "report.pdf", AttachmentPreview.File("pdf")))
state.markStreaming() // Send button becomes Stop
state.markReady() // back to derived state
state.applyTemplate(PromptTemplate("Summarize", "Please summarize."))
state.pasteTokensAsAttachments(clipboardText) { tok ->
PromptAttachment(id = tok, displayName = tok, preview = AttachmentPreview.File("eml"))
}
val outgoing: PromptBarOutgoing = state.outgoing // text + attachments + modelType / at the start of a line or after whitespace → the autocomplete panel appears with
matching commands. Pick one → the command's onSelect lambda fires and the /text is replaced
with the full command name.
SlashCommand(
name = "code",
description = "Switch to coding mode",
hotkey = "Cmd+K", // shown as a chip in the suggestion
onSelect = { vm.toggleCodingMode() },
)Type @ after whitespace → the MentionProvider.suggest(query) runs and the dropdown shows
results. Selecting one inserts @display into the textfield.
// Static list — case-insensitive substring match.
mentionProvider = MentionProvider.fromList(workspace.files.map {
Mention(id = it.path, display = it.name, subtitle = it.path, category = "File")
})
// Async — call your DB/API.
mentionProvider = MentionProvider { query -> repository.suggestMentions(query) }| State | Visual | Click |
|---|---|---|
Disabled |
Send icon, dimmed | Ignored |
Ready |
Send icon, primary | Fires onSend
|
Sending |
Spinner | Ignored |
Streaming |
Stop icon | Fires onStop
|
The state is derived from the composer's content by default. Override with markSending() /
markStreaming() / markReady() when you know better than the derivation.
| Target | Status |
|---|---|
| Android (minSdk 24) | ✅ |
| iOS (x64, arm64, simulatorArm64) | ✅ |
| Desktop (JVM 11) | ✅ |
| Web (wasmJs) | ✅ |
./gradlew :sample:desktopApp:run # Desktop
./gradlew :sample:androidApp:assembleDebug # Android
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun # WebThe sample is a fake chat app that exercises every feature — slash commands, mentions, attachments via the voice button, templates, model selector cycling, Send/Stop transitions.
Apache 2.0 — see LICENSE.