@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.sp
7272import sh.sandboxed.dashboard.data.AppContainer
7373import sh.sandboxed.dashboard.data.Backend
7474import sh.sandboxed.dashboard.data.BackendAgent
75+ import sh.sandboxed.dashboard.data.BuiltinCommandsResponse
7576import sh.sandboxed.dashboard.data.ChatMessage
7677import sh.sandboxed.dashboard.data.ChatMessageKind
7778import sh.sandboxed.dashboard.data.Mission
@@ -80,6 +81,7 @@ import sh.sandboxed.dashboard.data.Provider
8081import sh.sandboxed.dashboard.data.QueuedMessage
8182import sh.sandboxed.dashboard.data.RunningMissionInfo
8283import sh.sandboxed.dashboard.data.SharedFile
84+ import sh.sandboxed.dashboard.data.SlashCommand
8385import sh.sandboxed.dashboard.data.Workspace
8486import sh.sandboxed.dashboard.ui.components.ErrorBanner
8587import sh.sandboxed.dashboard.ui.components.GlassCard
@@ -101,6 +103,14 @@ fun ControlScreen(
101103 var showNewMission by remember { mutableStateOf(false ) }
102104 var showMissionSwitcher by remember { mutableStateOf(false ) }
103105 var showWorkers by remember { mutableStateOf(false ) }
106+ val slashSuggestions = remember(state.draft, state.mission?.backend, state.slashCommands) {
107+ visibleSlashSuggestions(
108+ draft = state.draft,
109+ backend = state.mission?.backend,
110+ catalog = state.slashCommands,
111+ )
112+ }
113+ val slashPanelActive = isSlashPanelActive(state.draft)
104114
105115 LaunchedEffect (state.messages.size) {
106116 if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
@@ -122,6 +132,8 @@ fun ControlScreen(
122132 canResume = state.mission?.let { it.status.canResume || it.resumable } == true ,
123133 workerCount = state.childMissions.size,
124134 runningCount = state.parallel.size,
135+ runState = state.runState,
136+ progress = state.progress,
125137 onResume = { haptics.success(); vm.resume() },
126138 onAutomations = { state.mission?.id?.let (onOpenAutomations) },
127139 onNewMission = { showNewMission = true },
@@ -143,6 +155,16 @@ fun ControlScreen(
143155 ) {
144156 items(state.messages, key = { it.id }) { msg -> MessageRow (msg) }
145157 }
158+ if (slashPanelActive && (slashSuggestions.isNotEmpty() || state.slashCommandsLoading)) {
159+ SlashSuggestions (
160+ commands = slashSuggestions,
161+ loading = state.slashCommandsLoading && slashSuggestions.isEmpty(),
162+ onSelect = {
163+ haptics.selection()
164+ vm.applySlashCommand(it)
165+ },
166+ )
167+ }
146168 Composer (
147169 value = state.draft,
148170 onChange = vm::setDraft,
@@ -220,6 +242,8 @@ private fun TopBar(
220242 canResume : Boolean ,
221243 workerCount : Int ,
222244 runningCount : Int ,
245+ runState : ControlRunState ,
246+ progress : ExecutionProgress ? ,
223247 onResume : () -> Unit ,
224248 onAutomations : () -> Unit ,
225249 onNewMission : () -> Unit ,
@@ -231,9 +255,19 @@ private fun TopBar(
231255 Row (verticalAlignment = Alignment .CenterVertically ) {
232256 Column (Modifier .weight(1f )) {
233257 Text (mission?.title ? : " New mission" , style = MaterialTheme .typography.titleMedium, color = Palette .TextPrimary , maxLines = 1 )
234- Text (if (connected) " Connected" else " Reconnecting…" ,
235- style = MaterialTheme .typography.bodySmall,
236- color = if (connected) Palette .Success else Palette .Warning )
258+ Row (verticalAlignment = Alignment .CenterVertically , horizontalArrangement = Arrangement .spacedBy(6 .dp)) {
259+ Text (
260+ if (connected) " Connected" else " Reconnecting…" ,
261+ style = MaterialTheme .typography.bodySmall,
262+ color = if (connected) Palette .Success else Palette .Warning ,
263+ )
264+ Text (" •" , color = Palette .TextTertiary , style = MaterialTheme .typography.bodySmall)
265+ Text (runState.label, color = runStateColor(runState), style = MaterialTheme .typography.bodySmall)
266+ progress?.takeIf { it.total > 0 }?.let {
267+ Text (" •" , color = Palette .TextTertiary , style = MaterialTheme .typography.bodySmall)
268+ Text (it.displayText, color = Palette .Success , style = MaterialTheme .typography.bodySmall)
269+ }
270+ }
237271 }
238272 mission?.status?.let { StatusBadge (it) }
239273 if (canResume) {
@@ -270,6 +304,12 @@ private fun TopBar(
270304 }
271305}
272306
307+ private fun runStateColor (runState : ControlRunState ): Color = when (runState) {
308+ ControlRunState .IDLE -> Palette .TextSecondary
309+ ControlRunState .RUNNING -> Palette .Success
310+ ControlRunState .WAITING_FOR_TOOL -> Palette .Warning
311+ }
312+
273313@Composable
274314private fun Tag (text : String ) {
275315 Text (
@@ -745,6 +785,94 @@ private fun LinearLoading() {
745785 }
746786}
747787
788+ @Composable
789+ private fun SlashSuggestions (
790+ commands : List <SlashCommand >,
791+ loading : Boolean ,
792+ onSelect : (SlashCommand ) -> Unit ,
793+ ) {
794+ Column (
795+ modifier = Modifier
796+ .fillMaxWidth()
797+ .background(Palette .BackgroundSecondary )
798+ .padding(horizontal = 12 .dp, vertical = 8 .dp),
799+ verticalArrangement = Arrangement .spacedBy(6 .dp),
800+ ) {
801+ if (loading) {
802+ Row (
803+ modifier = Modifier
804+ .fillMaxWidth()
805+ .background(Palette .Card , RoundedCornerShape (10 .dp))
806+ .border(1 .dp, Palette .Border , RoundedCornerShape (10 .dp))
807+ .padding(horizontal = 12 .dp, vertical = 10 .dp),
808+ verticalAlignment = Alignment .CenterVertically ,
809+ ) {
810+ CircularProgressIndicator (strokeWidth = 2 .dp, modifier = Modifier .size(14 .dp), color = Palette .Accent )
811+ Spacer (Modifier .width(8 .dp))
812+ Text (" Loading commands…" , color = Palette .TextSecondary , style = MaterialTheme .typography.bodySmall)
813+ }
814+ } else {
815+ commands.take(8 ).forEach { command ->
816+ Row (
817+ modifier = Modifier
818+ .fillMaxWidth()
819+ .background(Palette .Card , RoundedCornerShape (10 .dp))
820+ .border(1 .dp, Palette .Border , RoundedCornerShape (10 .dp))
821+ .clickable { onSelect(command) }
822+ .padding(horizontal = 12 .dp, vertical = 10 .dp),
823+ verticalAlignment = Alignment .CenterVertically ,
824+ ) {
825+ Text (" /${command.name} " , color = Palette .AccentLight , style = MaterialTheme .typography.labelLarge, modifier = Modifier .widthIn(min = 92 .dp))
826+ Column (Modifier .weight(1f )) {
827+ command.description?.takeIf { it.isNotBlank() }?.let {
828+ Text (it, color = Palette .TextSecondary , style = MaterialTheme .typography.bodySmall, maxLines = 2 )
829+ }
830+ val hint = slashCommandHint(command)
831+ if (hint.isNotBlank()) {
832+ Text (hint, color = Palette .TextTertiary , style = MaterialTheme .typography.labelSmall)
833+ }
834+ }
835+ }
836+ }
837+ }
838+ }
839+ }
840+
841+ private fun isSlashPanelActive (draft : String ): Boolean {
842+ val trimmed = draft.trim()
843+ if (! trimmed.startsWith(" /" )) return false
844+ return ! trimmed.drop(1 ).any { it.isWhitespace() }
845+ }
846+
847+ private fun visibleSlashSuggestions (
848+ draft : String ,
849+ backend : String? ,
850+ catalog : BuiltinCommandsResponse ? ,
851+ ): List <SlashCommand > {
852+ catalog ? : return emptyList()
853+ val trimmed = draft.trim()
854+ if (! trimmed.startsWith(" /" )) return emptyList()
855+ val fragment = trimmed.drop(1 )
856+ if (fragment.any { it.isWhitespace() }) return emptyList()
857+ val pool = when (backend) {
858+ " codex" -> catalog.codex
859+ " claudecode" -> catalog.claudecode
860+ " opencode" -> catalog.opencode
861+ else -> catalog.opencode + catalog.claudecode + catalog.codex
862+ }
863+ return pool
864+ .filter { command ->
865+ fragment.isBlank() ||
866+ command.name.startsWith(fragment, ignoreCase = true )
867+ }
868+ .distinctBy { " ${it.path} :${it.name} " }
869+ }
870+
871+ private fun slashCommandHint (command : SlashCommand ): String =
872+ command.params.joinToString(" " ) { param ->
873+ if (param.required) " <${param.name} >" else " [${param.name} ]"
874+ }
875+
748876@Composable
749877private fun MessageRow (msg : ChatMessage ) {
750878 when (val k = msg.kind) {
0 commit comments