This guide covers how to embed BossTerm's full-featured tabbed terminal component in your Compose Desktop application.
import ai.rever.bossterm.compose.TabbedTerminal
@Composable
fun MyApp() {
TabbedTerminal(
onExit = { exitApplication() },
modifier = Modifier.fillMaxSize()
)
}Add BossTerm dependencies to your build.gradle.kts:
dependencies {
implementation("com.risaboss:bossterm-core:<version>")
implementation("com.risaboss:bossterm-compose:<version>")
}TabbedTerminal provides a complete terminal experience:
- Multiple Tabs - Create, switch, and close tabs
- Tab Bar - Visual tab bar (auto-hides with single tab)
- Split Panes - Horizontal and vertical splits
- State Persistence - Preserve sessions across recomposition with
TabbedTerminalState - Working Directory Inheritance - New tabs/splits inherit CWD
- Command Notifications - System notifications for long commands
- Window Focus Tracking - Overlay when window unfocused
- Menu Integration - Wire up application menu bar
- Keyboard Shortcuts - Full keyboard navigation
@Composable
fun TabbedTerminal(
state: TabbedTerminalState? = null,
onExit: () -> Unit,
onTabClose: ((tabId: String) -> Unit)? = null,
onWindowTitleChange: (String) -> Unit = {},
onNewWindow: () -> Unit = {},
onShowSettings: () -> Unit = {},
menuActions: MenuActions? = null,
isWindowFocused: () -> Boolean = { true },
initialCommand: String? = null,
onLinkClick: ((HyperlinkInfo) -> Boolean)? = null,
contextMenuItems: List<ContextMenuElement> = emptyList(),
contextMenuItemsProvider: (() -> List<ContextMenuElement>)? = null,
onContextMenuOpen: (() -> Unit)? = null,
onContextMenuOpenAsync: (suspend () -> Unit)? = null,
settingsOverride: TerminalSettingsOverride? = null,
hyperlinkRegistry: HyperlinkRegistry = HyperlinkDetector.registry,
modifier: Modifier = Modifier,
platformServices: PlatformServices = getPlatformServices()
)| Parameter | Type | Description |
|---|---|---|
state |
TabbedTerminalState? |
External state holder for persistence across recomposition |
onExit |
() -> Unit |
Required. Called when last tab closes |
onTabClose |
((tabId: String) -> Unit)? |
Called before a tab closes. Receives the stable tab ID for cleanup |
onWindowTitleChange |
(String) -> Unit |
Called when active tab's title changes |
onNewWindow |
() -> Unit |
Called when user requests new window (Cmd/Ctrl+N) |
onShowSettings |
() -> Unit |
Called when user opens settings |
menuActions |
MenuActions? |
Callbacks for menu bar integration |
isWindowFocused |
() -> Boolean |
Returns window focus state (for notifications) |
workingDirectory |
String? |
Initial working directory for first tab (defaults to user home) |
initialCommand |
String? |
Command to run in first tab after startup |
onInitialCommandComplete |
(Boolean, Int) -> Unit |
Callback when initial command finishes (see Initial Command Completion) |
onLinkClick |
(HyperlinkInfo) -> Boolean |
Custom link click handler with rich metadata; return true if handled, false for default behavior (see Custom Link Handling) |
contextMenuItems |
List<ContextMenuElement> |
Custom context menu items (see Custom Context Menu) |
contextMenuItemsProvider |
(() -> List<ContextMenuElement>)? |
Lambda to get fresh menu items after async callback (see Dynamic Context Menu Items) |
onContextMenuOpen |
() -> Unit |
Callback before context menu displays (sync) |
onContextMenuOpenAsync |
suspend () -> Unit |
Async callback before context menu displays - menu waits for completion |
settingsOverride |
TerminalSettingsOverride? |
Per-instance settings overrides (see Settings Override) |
hyperlinkRegistry |
HyperlinkRegistry |
Custom hyperlink patterns for this instance (see Custom Hyperlink Patterns) |
modifier |
Modifier |
Compose modifier |
platformServices |
PlatformServices |
Custom platform services for process spawning, clipboard, etc. (see Custom Platform Services) |
Wire up your application's menu bar to terminal actions:
import ai.rever.bossterm.compose.menu.MenuActions
val menuActions = remember { MenuActions() }
// MenuActions properties (set by TabbedTerminal):
// - onNewTab: () -> Unit
// - onCloseTab: () -> Unit
// - onNextTab: () -> Unit
// - onPreviousTab: () -> Unit
// - onSplitVertical: () -> Unit
// - onSplitHorizontal: () -> Unit
// - onClosePane: () -> UnitExternal state holder that enables terminal sessions to survive recomposition. Use this when embedding TabbedTerminal within another tab system or navigation framework.
import ai.rever.bossterm.compose.TabbedTerminalState
import ai.rever.bossterm.compose.rememberTabbedTerminalState
// Create state that survives navigation
val terminalState = rememberTabbedTerminalState(autoDispose = false)
// Use in composable that may unmount
when (selectedView) {
"terminal" -> TabbedTerminal(state = terminalState, onExit = { ... })
"editor" -> EditorPane() // Terminal sessions preserved!
}
// Manual cleanup when truly done
DisposableEffect(Unit) { onDispose { terminalState.dispose() } }Properties:
| Property | Type | Description |
|---|---|---|
tabs |
List<TerminalTab> |
All open terminal tabs |
tabCount |
Int |
Number of open tabs |
activeTabIndex |
Int |
Index of active tab (0-based) |
activeTab |
TerminalTab? |
Currently active tab |
activeTabId |
String? |
Stable ID of active tab (survives reordering) |
isInitialized |
Boolean |
Whether state has been initialized |
isDisposed |
Boolean |
Whether state has been disposed |
Methods:
| Method | Description |
|---|---|
createTab(workingDir?, initialCommand?, tabId?): String? |
Create tab, optionally with stable ID. Returns the tab ID |
getTabById(tabId): TerminalTab? |
Find tab by stable ID |
closeTab(index) |
Close tab at index |
closeTab(tabId): Boolean |
Close tab by stable ID |
closeActiveTab() |
Close the active tab |
switchToTab(index) |
Switch to tab at index |
switchToTab(tabId): Boolean |
Switch to tab by stable ID |
nextTab() |
Switch to next tab (wraps) |
previousTab() |
Switch to previous tab (wraps) |
getActiveWorkingDirectory() |
Get active tab's working directory (OSC 7) |
write(text) |
Send text to active tab |
write(text, tabIndex) |
Send text to specific tab by index |
write(text, tabId): Boolean |
Send text to specific tab by stable ID |
sendInput(bytes) |
Send raw bytes to active tab |
sendInput(bytes, tabIndex) |
Send raw bytes to specific tab by index |
sendInput(bytes, tabId): Boolean |
Send raw bytes to specific tab by stable ID |
sendCtrlC() / sendCtrlC(tabIndex) / sendCtrlC(tabId) |
Send Ctrl+C (interrupt) |
sendCtrlD() / sendCtrlD(tabIndex) / sendCtrlD(tabId) |
Send Ctrl+D (EOF) |
sendCtrlZ() / sendCtrlZ(tabIndex) / sendCtrlZ(tabId) |
Send Ctrl+Z (suspend) |
addSessionListener(listener) |
Add session lifecycle listener |
removeSessionListener(listener) |
Remove session listener |
dispose() |
Dispose all sessions and cleanup |
Programmatic control over split panes within tabs. All methods accept an optional tabId parameter — if null, the active tab is used.
| Method | Description |
|---|---|
splitVertical(tabId?): String? |
Split focused pane vertically (left/right). Returns new session ID |
splitHorizontal(tabId?): String? |
Split focused pane horizontally (top/bottom). Returns new session ID |
closeFocusedPane(tabId?): Boolean |
Close the focused pane. Closes entire tab if it's the last pane |
navigatePaneFocus(direction, tabId?) |
Move focus using spatial navigation (UP, DOWN, LEFT, RIGHT) |
navigateToNextPane(tabId?) |
Move focus to the next pane (sequential) |
navigateToPreviousPane(tabId?) |
Move focus to the previous pane (sequential) |
getPaneCount(tabId?): Int |
Number of panes in the tab (1 if no splits) |
hasSplitPanes(tabId?): Boolean |
Whether the tab has more than one pane |
getSplitSessionIds(tabId?): List<String> |
Session IDs of all panes in the tab |
getFocusedSplitSession(tabId?): TerminalSession? |
Get the focused pane's session |
writeToFocusedPane(text, tabId?): Boolean |
Send text to the focused pane specifically |
val state = rememberTabbedTerminalState()
// Split the active tab
val newPaneId = state.splitVertical()
// Navigate between panes
state.navigatePaneFocus(NavigationDirection.RIGHT)
// Query pane state
println("Panes: ${state.getPaneCount()}")
// Write to the focused pane
state.writeToFocusedPane("echo 'Hello from API!'\n")
// Close focused pane (closes tab if last pane)
state.closeFocusedPane()Observable flows for building reactive UIs that update automatically when tab/pane state changes.
Flows:
| Property | Type | Description |
|---|---|---|
tabsFlow |
StateFlow<List<TerminalTabInfo>> |
Observable list of all tabs with metadata |
activeTabIndexFlow |
StateFlow<Int> |
Observable active tab index |
settingsFlow |
StateFlow<TerminalSettings> |
Observable terminal settings |
TerminalTabInfo:
@Immutable
data class TerminalTabInfo(
val id: String, // Stable tab ID
val title: String, // Current tab title
val isConnected: Boolean, // PTY process is connected
val workingDirectory: String?, // CWD from OSC 7 (null if not reported)
val paneCount: Int // Number of panes (always >= 1)
)Example: Reactive Status Bar
@Composable
fun TerminalStatusBar(terminalState: TabbedTerminalState) {
val tabs by terminalState.tabsFlow.collectAsState()
val activeIndex by terminalState.activeTabIndexFlow.collectAsState()
val activeTab = tabs.getOrNull(activeIndex)
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("Tabs: ${tabs.size}")
Spacer(Modifier.width(16.dp))
activeTab?.let { tab ->
Text(tab.title)
if (tab.paneCount > 1) {
Text(" (${tab.paneCount} panes)")
}
}
}
}NavigationDirection:
enum class NavigationDirection {
UP, DOWN, LEFT, RIGHT
}TabbedTerminal includes built-in keyboard shortcuts:
| Shortcut | Action |
|---|---|
| Ctrl/Cmd+T | New tab |
| Ctrl/Cmd+W | Close tab/pane |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl/Cmd+1-9 | Jump to tab |
| Ctrl/Cmd+D | Split vertically |
| Ctrl/Cmd+Shift+D | Split horizontally |
| Ctrl/Cmd+Option+Arrow | Navigate between panes |
| Ctrl/Cmd+F | Search |
| Ctrl/Cmd+, | Settings (triggers onShowSettings) |
For command completion notifications to work, you need to track window focus:
import java.awt.event.WindowEvent
import java.awt.event.WindowFocusListener
@Composable
fun MyWindow() {
var isWindowFocused by remember { mutableStateOf(true) }
Window(onCloseRequest = { /* ... */ }) {
// Track focus via AWT
LaunchedEffect(Unit) {
val focusListener = object : WindowFocusListener {
override fun windowGainedFocus(e: WindowEvent?) {
isWindowFocused = true
}
override fun windowLostFocus(e: WindowEvent?) {
isWindowFocused = false
}
}
window.addWindowFocusListener(focusListener)
}
TabbedTerminal(
onExit = { /* ... */ },
isWindowFocused = { isWindowFocused }
)
}
}When enabled and properly configured (see Shell Integration), users receive system notifications when:
- A command takes longer than 5 seconds (configurable)
- The window is not focused when the command completes
Wire up your menu bar to terminal actions:
@Composable
fun MyWindow() {
val menuActions = remember { MenuActions() }
Window(onCloseRequest = { /* ... */ }) {
MenuBar {
Menu("File") {
Item("New Tab", onClick = { menuActions.onNewTab?.invoke() })
Item("New Window", onClick = { /* your window creation */ })
Separator()
Item("Close Tab", onClick = { menuActions.onCloseTab?.invoke() })
}
Menu("View") {
Item("Split Vertically", onClick = { menuActions.onSplitVertical?.invoke() })
Item("Split Horizontally", onClick = { menuActions.onSplitHorizontal?.invoke() })
}
Menu("Window") {
Item("Next Tab", onClick = { menuActions.onNextTab?.invoke() })
Item("Previous Tab", onClick = { menuActions.onPreviousTab?.invoke() })
}
}
TabbedTerminal(
onExit = { /* ... */ },
menuActions = menuActions
)
}
}Support multiple terminal windows in your application:
fun main() = application {
val windows = remember { mutableStateListOf(WindowId()) }
fun createWindow() {
windows.add(WindowId())
}
fun closeWindow(index: Int) {
if (windows.size > 1) {
windows.removeAt(index)
} else {
exitApplication()
}
}
windows.forEachIndexed { index, windowId ->
Window(
onCloseRequest = { closeWindow(index) },
title = "Terminal ${index + 1}"
) {
TabbedTerminal(
onExit = { closeWindow(index) },
onNewWindow = { createWindow() }
)
}
}
}
private class WindowId {
val id = System.currentTimeMillis()
}Update your window title bar from the active terminal tab:
@Composable
fun MyWindow() {
var windowTitle by remember { mutableStateOf("My App") }
Window(
onCloseRequest = { /* ... */ },
title = windowTitle // Dynamic title
) {
TabbedTerminal(
onExit = { /* ... */ },
onWindowTitleChange = { title ->
windowTitle = title // Updates from shell (via OSC sequences)
}
)
}
}Run a command when the terminal starts:
TabbedTerminal(
onExit = { /* ... */ },
initialCommand = "echo 'Welcome!' && ls -la"
)The onInitialCommandComplete callback fires when the initial command finishes executing. This is useful for:
- Triggering the next step in a workflow after setup completes
- Updating UI status based on command success/failure
- Error handling when initial setup fails
TabbedTerminal(
onExit = { exitApplication() },
initialCommand = "npm install && npm run build",
onInitialCommandComplete = { success, exitCode ->
if (success) {
println("Setup complete!")
} else {
println("Setup failed with exit code: $exitCode")
}
}
)| Parameter | Type | Description |
|---|---|---|
success |
Boolean |
true if exit code is 0, false otherwise |
exitCode |
Int |
The command's exit code (0 = success, non-zero = failure) |
This callback requires OSC 133 shell integration to detect command completion. Without shell integration, the callback will not fire.
To enable OSC 133 in your shell, see Shell Integration.
@Composable
fun TerminalWindowWithStatus() {
var windowTitle by remember { mutableStateOf("Terminal") }
Window(
onCloseRequest = { /* ... */ },
title = windowTitle
) {
TabbedTerminal(
onExit = { /* ... */ },
initialCommand = "./setup.sh",
onInitialCommandComplete = { success, exitCode ->
windowTitle = if (success) {
"Terminal - Setup Complete"
} else {
"Terminal - Setup Failed (exit: $exitCode)"
}
}
)
}
}The onContextMenuOpen callback fires immediately before the context menu is displayed. This is useful for:
- Refreshing dynamic menu item state (e.g., checking if a tool is installed)
- Analytics tracking
- Updating UI state based on menu visibility
TabbedTerminal(
onExit = { exitApplication() },
onContextMenuOpen = {
// Refresh menu item state before display
refreshAIAssistantStatus()
},
contextMenuItems = listOf(
ContextMenuItem(
id = "ai_assist",
label = if (isAIInstalled) "Ask AI" else "Install AI Assistant",
action = { /* ... */ }
)
)
)- Dynamic menu items: Check installation status, connection state, or permissions before showing menu
- Analytics: Track how often users open the context menu
- State refresh: Update menu item labels or enabled states based on current context
The callback is invoked for all tabs and split panes within the terminal.
The contextMenuItemsProvider lambda solves a timing problem with dynamic context menus. When you use contextMenuItems with onContextMenuOpenAsync, the menu shows stale data because Compose captures the items at composition time, before the async callback runs.
// This doesn't work as expected!
var installStatus by remember { mutableStateOf("checking...") }
TabbedTerminal(
onExit = { exitApplication() },
onContextMenuOpenAsync = {
installStatus = checkInstallation() // Updates state
},
contextMenuItems = listOf(
ContextMenuItem(
id = "status",
label = installStatus, // Captured at composition time!
action = { }
)
)
)
// Menu shows old value - requires TWO right-clicks to see fresh dataUse contextMenuItemsProvider to get fresh items after the async callback completes:
var installStatus by remember { mutableStateOf("checking...") }
TabbedTerminal(
onExit = { exitApplication() },
onContextMenuOpenAsync = {
installStatus = checkInstallation() // Updates state
},
contextMenuItemsProvider = {
// Called AFTER onContextMenuOpenAsync completes, BEFORE menu shows
listOf(
ContextMenuItem(
id = "status",
label = installStatus, // Always fresh!
action = { }
)
)
}
)
// Menu always shows current value on first right-click- User right-clicks
onContextMenuOpenAsyncruns (if provided), menu waitscontextMenuItemsProvidercalled to get fresh items- Menu displays with up-to-date items
contextMenuItemsProvider |
contextMenuItems |
Result |
|---|---|---|
null |
[...] |
Uses static contextMenuItems |
{ [...] } |
[...] |
Uses contextMenuItemsProvider result |
{ [...] } |
emptyList() |
Uses contextMenuItemsProvider result |
For a detailed example with AI assistant status detection, see Dynamic Context Menu Items in EmbeddableTerminal.
Override specific global settings for a particular TabbedTerminal instance without affecting other instances or the global settings file.
import ai.rever.bossterm.compose.settings.TerminalSettingsOverride
TabbedTerminal(
onExit = { /* ... */ },
settingsOverride = TerminalSettingsOverride(
alwaysShowTabBar = true, // Always show tab bar (useful for sidebars)
fontSize = 12f // Smaller font for compact view
)
)- Sidebar terminals: Force
alwaysShowTabBar = trueso users always see tabs - Compact views: Reduce
fontSizefor space-constrained layouts - Different themes per instance: Override colors for specific terminals
- Performance tuning: Different
bufferMaxLinesfor different use cases
settingsOverride merges with global settings from ~/.bossterm/settings.json:
- Global settings are loaded from
SettingsManager - Non-null fields in
settingsOverridereplace corresponding global values - Null fields in
settingsOverrideinherit from global settings
// Example: Only override alwaysShowTabBar, inherit everything else
TabbedTerminal(
settingsOverride = TerminalSettingsOverride(
alwaysShowTabBar = true
),
onExit = { /* ... */ }
)// Always show tab bar (for sidebar integration)
TerminalSettingsOverride(alwaysShowTabBar = true)
// Compact terminal
TerminalSettingsOverride(
fontSize = 11f,
lineSpacing = 1.0f,
showScrollbar = false
)
// Different split behavior
TerminalSettingsOverride(
splitDefaultRatio = 0.3f, // 30/70 splits
splitFocusBorderEnabled = false
)Override the default platform services to customize process spawning, clipboard, filesystem, and other platform-specific behavior. This is useful for remote terminal connections (e.g., SSH, custom protocols) where you need to send I/O to a remote server instead of spawning a local PTY.
import ai.rever.bossterm.compose.PlatformServices
import ai.rever.bossterm.compose.getPlatformServices
// Create custom services (e.g., override process spawning for remote connections)
class RemotePlatformServices(
private val defaults: PlatformServices = getPlatformServices()
) : PlatformServices by defaults {
override fun getProcessService(): PlatformServices.ProcessService {
return MyRemoteProcessService() // Custom SSH/remote process spawning
}
}
// Use in TabbedTerminal
TabbedTerminal(
onExit = { exitApplication() },
platformServices = RemotePlatformServices()
)
// Or in EmbeddableTerminal
EmbeddableTerminal(
platformServices = RemotePlatformServices()
)The PlatformServices interface provides six services that can be individually overridden:
| Service | Interface | Purpose |
|---|---|---|
getProcessService() |
ProcessService |
PTY/process spawning and I/O |
getClipboardService() |
ClipboardService |
Clipboard copy/paste |
getFileSystemService() |
FileSystemService |
File operations and paths |
getPlatformInfo() |
PlatformInfo |
OS/platform detection |
getBrowserService() |
BrowserService |
URL opening |
getNotificationService() |
NotificationService |
System notifications |
Use Kotlin's by delegation to override only the services you need while keeping defaults for the rest.
When embedding TabbedTerminal within another navigation or tab system, terminal sessions are normally lost when the composable unmounts. Use TabbedTerminalState to preserve sessions:
@Composable
fun MyApp() {
var currentView by remember { mutableStateOf("terminal") }
// State survives when TabbedTerminal unmounts
val terminalState = rememberTabbedTerminalState(autoDispose = false)
// Manual cleanup
DisposableEffect(Unit) {
onDispose { terminalState.dispose() }
}
Column {
// View switcher
Row {
Button(onClick = { currentView = "terminal" }) { Text("Terminal") }
Button(onClick = { currentView = "editor" }) { Text("Editor") }
}
// Content - terminal state persists across view switches
when (currentView) {
"terminal" -> TabbedTerminal(
state = terminalState,
onExit = { /* ... */ }
)
"editor" -> Text("Editor view - switch back to see your terminals!")
}
}
}Key points:
- Use
autoDispose = falsewhen state should survive navigation - Call
terminalState.dispose()manually when truly done - State is automatically initialized on first composition
- All tabs, sessions, and split states are preserved
Tab indices are unstable - they change when tabs are reordered, closed, or inserted. Use stable tab IDs for reliable tab targeting.
// Create tabs
state.createTab() // Index 0
state.createTab() // Index 1
// Later, user closes tab 0...
// Now the second tab is at index 0!
state.write("command\n", tabIndex = 1) // Wrong tab or no-op!Each tab has a unique, stable ID (UUID) that survives reordering:
// Create tabs with stable IDs
val configA = state.createTab(tabId = "config-A") // Returns "config-A"
val configB = state.createTab(tabId = "config-B") // Returns "config-B"
// Later, even if tabs are reordered or closed:
state.sendCtrlC("config-A") // Always targets the right tab
state.write("restart\n", "config-A") // Reliable!
state.closeTab("config-B") // Works regardless of indexIf you don't specify a tabId, a UUID is generated automatically:
// Get the auto-generated ID
val tabId = state.createTab() // Returns something like "a1b2c3d4-..."
// Use it later
state.write("command\n", tabId!!)// Properties
state.activeTabId // Stable ID of active tab
// Lookup
state.getTabById(tabId) // Find tab by ID
// Tab management (by ID)
state.createTab(tabId = "my-id") // Create with custom ID
state.closeTab(tabId) // Close by ID
state.switchToTab(tabId) // Switch by ID
// Input (by ID)
state.write(text, tabId) // Send text by ID
state.sendInput(bytes, tabId) // Send bytes by ID
state.sendCtrlC(tabId) // Ctrl+C by ID
state.sendCtrlD(tabId) // Ctrl+D by ID
state.sendCtrlZ(tabId) // Ctrl+Z by IDUse onTabClose to clean up associated resources when tabs are closed:
// Track config → tab mappings
val configTabs = mutableMapOf<String, String>()
val runnerStates = mutableMapOf<String, RunnerState>()
@Composable
fun MyApp() {
val state = rememberTabbedTerminalState()
TabbedTerminal(
state = state,
onExit = { exitApplication() },
onTabClose = { tabId ->
// Clean up runner state when tab closes
val configId = configTabs.entries
.find { it.value == tabId }?.key
if (configId != null) {
configTabs.remove(configId)
runnerStates.remove(configId)?.cleanup()
println("Cleaned up runner for config: $configId")
}
}
)
}
fun runConfig(configId: String, command: String) {
val existingTabId = configTabs[configId]
if (existingTabId != null && state.getTabById(existingTabId) != null) {
// Re-run in existing tab
state.sendCtrlC(existingTabId)
state.write("$command\n", existingTabId)
state.switchToTab(existingTabId)
} else {
// Create new tab for this config
val newTabId = state.createTab(tabId = "config-$configId")!!
configTabs[configId] = newTabId
runnerStates[configId] = RunnerState(/* ... */)
state.write("$command\n", newTabId)
}
}The onTabClose callback is invoked before the tab is removed, allowing you to:
- Clean up associated resources (runner states, file watchers, etc.)
- Log tab closure for analytics
- Update UI state that depends on tab count
Send text and control signals to terminal tabs programmatically.
val state = rememberTabbedTerminalState()
// Send to active tab
state.write("ls -la\n")
state.sendCtrlC()
// Send to specific tab by index
state.write("pwd\n", tabIndex = 1)
state.sendCtrlC(tabIndex = 0)// Interrupt running process
state.sendCtrlC() // Active tab
state.sendCtrlC(tabIndex) // Specific tab
// Send EOF
state.sendCtrlD()
state.sendCtrlD(tabIndex)
// Suspend process
state.sendCtrlZ()
state.sendCtrlZ(tabIndex)// Send raw bytes to active tab
state.sendInput(byteArrayOf(0x03)) // Ctrl+C
// Send to specific tab
state.sendInput(byteArrayOf(0x04), tabIndex = 1) // Ctrl+D to tab 1@Composable
fun TerminalWithTabControls() {
val state = rememberTabbedTerminalState()
Column {
Row {
Button(onClick = { state.write("sleep 30\n") }) {
Text("Run Sleep")
}
Button(onClick = { state.sendCtrlC() }) {
Text("Stop Active Tab")
}
// Stop all tabs
Button(onClick = {
for (i in 0 until state.tabCount) {
state.sendCtrlC(i)
}
}) {
Text("Stop All Tabs")
}
}
TabbedTerminal(
state = state,
onExit = { /* ... */ }
)
}
}All input methods are asynchronous and share the same FIFO queue per tab, ensuring ordered delivery. See Embedding Guide - Programmatic Input for more details.
See the tabbed-example module for a full working example with:
- Multiple windows support
- Menu bar integration
- Window focus tracking
- Settings panel overlay
- State persistence demo (view switching)
- Plugin API demo — interactive Split Pane (T6) and Reactive State (T7) controls
- Reactive status bar — live tab/pane info powered by T7 flows
Run the example:
./gradlew :tabbed-example:runBy default, clicking links in the terminal (with Ctrl/Cmd+Click or via the context menu "Open Link") opens them in the system's default browser. You can intercept these clicks with the onLinkClick callback, which receives rich metadata about the link.
The callback receives a HyperlinkInfo object with detailed link metadata:
data class HyperlinkInfo(
val url: String, // The resolved URL or file path
val type: HyperlinkType, // HTTP, FILE, FOLDER, EMAIL, FTP, or CUSTOM
val patternId: String, // Pattern that matched (e.g., "builtin:http", "jira")
val matchedText: String, // Original text that was matched
val isFile: Boolean, // True if path points to existing file
val isFolder: Boolean, // True if path points to existing directory
val scheme: String?, // URL scheme (http, https, file, mailto, etc.)
val isBuiltin: Boolean // True if matched by built-in pattern
)Return true if you handled the link, false to use default behavior:
TabbedTerminal(
onExit = { exitApplication() },
onLinkClick = { info ->
myInAppBrowser.openUrl(info.url)
true // We handled it
}
)Handle only specific link types and let others use the default behavior:
TabbedTerminal(
onExit = { exitApplication() },
onLinkClick = { info ->
when {
info.patternId == "jira" -> {
openJiraTicket(info.matchedText)
true // Handled
}
info.type == HyperlinkType.FILE -> {
openInEditor(info.url)
true // Handled
}
else -> false // Use default behavior
}
}
)- File handling: Open files in your app's editor instead of system default
- Folder handling: Open directories in your app's file browser
- In-app browser: Open URLs in a browser tab within your application
- Custom patterns: Handle JIRA tickets, PR links, or other custom patterns
- Analytics: Track link clicks while still using default behavior
onLinkClick |
Return Value | Result |
|---|---|---|
null (default) |
N/A | Opens in system browser/finder |
| Provided | true |
Your callback handles it |
| Provided | false |
Falls back to system browser/finder |
The callback is invoked for all tabs and split panes within the terminal.
Add custom hyperlink patterns (e.g., JIRA tickets, GitHub issues) using HyperlinkRegistry:
import ai.rever.bossterm.compose.hyperlinks.HyperlinkRegistry
import ai.rever.bossterm.compose.hyperlinks.HyperlinkPattern
val customRegistry = HyperlinkRegistry().apply {
// JIRA ticket pattern (e.g., PROJ-123)
register(HyperlinkPattern(
id = "jira",
regex = Regex("""\b([A-Z]+-\d+)\b"""),
priority = 10,
urlTransformer = { match -> "https://jira.company.com/browse/$match" }
))
}
TabbedTerminal(
onExit = { exitApplication() },
hyperlinkRegistry = customRegistry,
onLinkClick = { info ->
if (info.patternId == "jira") {
openJiraInApp(info.matchedText)
true
} else false
}
)See Embedding Guide - Custom Hyperlink Patterns for full HyperlinkPattern documentation.
BossTerm includes built-in support for detecting and installing AI coding assistants and VCS tools. The API provides programmatic access from TabbedTerminalState.
AI Assistants:
| ID | Name | Description |
|---|---|---|
claude-code |
Claude Code | Anthropic's AI coding assistant |
codex |
Codex CLI | OpenAI's coding assistant |
gemini-cli |
Gemini CLI | Google's AI assistant |
opencode |
OpenCode | Open-source AI coding assistant |
VCS Tools:
| ID | Name | Description |
|---|---|---|
git |
Git | Distributed version control system |
gh |
GitHub CLI | GitHub's official CLI |
val state = rememberTabbedTerminalState()
// === AI Assistants ===
// List all available AI assistant IDs
val assistants = state.getAvailableAIAssistants()
// Get assistant definition by ID
val claude = state.getAIAssistant("claude-code")
// Check if installed (suspend function)
val isInstalled = state.isAIAssistantInstalled("claude-code")
// Trigger installation in active tab
state.installAIAssistant("claude-code")
// Install in specific tab by index
state.installAIAssistant("claude-code", tabIndex = 0)
// Install in specific tab by stable ID
state.installAIAssistant("claude-code", tabId = "my-tab")
// Use npm instead of script installation
state.installAIAssistant("claude-code", useNpm = true)
// Cancel pending installation
state.cancelAIInstallation()
// === VCS Tools ===
// Check if Git/GitHub CLI is installed (uses same API as AI assistants)
val gitInstalled = state.isAIAssistantInstalled("git")
val ghInstalled = state.isAIAssistantInstalled("gh")
// Install Git
state.installGit() // Active tab
state.installGit(tabIndex = 0) // By index
state.installGit(tabId = "my-tab") // By stable ID
// Install GitHub CLI
state.installGitHubCLI() // Active tab
state.installGitHubCLI(tabIndex = 0) // By index
state.installGitHubCLI(tabId = "my-tab") // By stable ID@Composable
fun TerminalWithAIToolbar() {
val state = rememberTabbedTerminalState()
var claudeInstalled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
claudeInstalled = state.isAIAssistantInstalled("claude-code")
}
Column {
Row {
if (claudeInstalled) {
Button(onClick = { state.write("claude\n") }) {
Text("Launch Claude")
}
} else {
Button(onClick = { state.installAIAssistant("claude-code") }) {
Text("Install Claude")
}
}
}
TabbedTerminal(
state = state,
onExit = { /* ... */ },
modifier = Modifier.weight(1f)
)
}
}When aiAssistantsEnabled is true in settings (default), the context menu includes:
- AI Assistants submenu: Install/launch options for all supported AI assistants
- Version Control submenu: Git commands, branch switching, and VCS tool installation
Note: The aiAssistantsEnabled setting controls both AI assistants and VCS tools. There is no separate VCS-specific setting.
When OSC 133 shell integration is configured, BossTerm detects when you type an AI assistant or VCS tool command (claude, codex, gemini, opencode, git, gh) and shows an install prompt before the command runs (if not installed).
VCS Tools Note: Command interception works identically for VCS tools (git, gh) as it does for AI assistants. They use the same detection and dialog mechanism.
Requirements: OSC 133 shell integration configured in .bashrc/.zshrc. See embedding.md for setup instructions.
Without OSC 133: Command interception is automatically disabled (graceful fallback to context menu only).
Add custom items to the right-click context menu using the contextMenuItems parameter.
import ai.rever.bossterm.compose.ContextMenuItem
import ai.rever.bossterm.compose.ContextMenuSection
import ai.rever.bossterm.compose.ContextMenuSubmenu
TabbedTerminal(
onExit = { exitApplication() },
contextMenuItems = listOf(
ContextMenuSection(id = "custom_section", label = "Quick Commands"),
ContextMenuItem(
id = "run_build",
label = "Run Build",
action = { /* your action */ }
),
ContextMenuSubmenu(
id = "git_menu",
label = "Git Commands",
items = listOf(
ContextMenuItem(id = "git_status", label = "Status", action = { /* ... */ }),
ContextMenuItem(id = "git_log", label = "Log", action = { /* ... */ })
)
)
)
)Custom items appear below the built-in items (Copy, Paste, Clear, Select All) and apply to all tabs and split panes within the terminal.
For detailed context menu element types and examples, see Custom Context Menu in EmbeddableTerminal.
| Feature | EmbeddableTerminal | TabbedTerminal |
|---|---|---|
| Single terminal | Yes | Yes |
| Multiple tabs | No | Yes |
| Tab bar | No | Yes (auto-hide) |
| Split panes | No | Yes |
| External state holder | EmbeddableTerminalState |
TabbedTerminalState |
| State persistence | Yes | Yes |
| Custom context menu | Yes | Yes |
| Custom link handling | Yes | Yes |
| Menu bar integration | No | Yes |
| Window management | No | Yes |
| Command notifications | No | Yes |
| Use case | Simple embedding | Full terminal app |
Choose EmbeddableTerminal for simple use cases where you need a single terminal instance with custom context menus. Choose TabbedTerminal when building a full-featured terminal application with tabs, splits, and window management. Both support external state holders for persistence across recomposition.
The onLinkClick callback now returns Boolean to support fallback behavior:
// Before (v1.0.64 and earlier)
onLinkClick: ((HyperlinkInfo) -> Unit)? = null
// After (v1.0.65+)
onLinkClick: ((HyperlinkInfo) -> Boolean)? = nullMigration:
// Before
TabbedTerminal(
onLinkClick = { info ->
openCustomHandler(info.url)
},
onExit = { exitApplication() }
)
// After - return true if handled, false for default behavior
TabbedTerminal(
onLinkClick = { info ->
openCustomHandler(info.url)
true // Handled - skip default behavior
},
onExit = { exitApplication() }
)Why this change? Previously, providing onLinkClick completely replaced default behavior. If your callback didn't handle all link types, unhandled links did nothing. Now you can return false to fall back to default behavior (open in browser/finder):
TabbedTerminal(
onLinkClick = { info ->
when {
info.patternId == "jira" -> {
openJiraTicket(info.matchedText)
true // Handled
}
info.type == HyperlinkType.FILE -> {
openInEditor(info.url)
true // Handled
}
else -> false // Not handled - use default behavior
}
},
onExit = { exitApplication() }
)