Skip to content

Latest commit

 

History

History
1286 lines (1021 loc) · 40.8 KB

File metadata and controls

1286 lines (1021 loc) · 40.8 KB

Tabbed Terminal

This guide covers how to embed BossTerm's full-featured tabbed terminal component in your Compose Desktop application.

Quick Start

import ai.rever.bossterm.compose.TabbedTerminal

@Composable
fun MyApp() {
    TabbedTerminal(
        onExit = { exitApplication() },
        modifier = Modifier.fillMaxSize()
    )
}

Installation

Add BossTerm dependencies to your build.gradle.kts:

dependencies {
    implementation("com.risaboss:bossterm-core:<version>")
    implementation("com.risaboss:bossterm-compose:<version>")
}

Features

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

API Reference

TabbedTerminal

@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)

MenuActions

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: () -> Unit

TabbedTerminalState

External 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

Split Pane API (T6)

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()

Reactive State API (T7)

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
}

Keyboard Shortcuts

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)

Window Focus Tracking

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

Menu Bar Integration

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
        )
    }
}

Multiple Windows

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()
}

Window Title Updates

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)
            }
        )
    }
}

Initial Command

Run a command when the terminal starts:

TabbedTerminal(
    onExit = { /* ... */ },
    initialCommand = "echo 'Welcome!' && ls -la"
)

Initial Command Completion

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")
        }
    }
)

Callback Parameters

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)

Requirements

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.

Example: Window Title Status

@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)"
                }
            }
        )
    }
}

Context Menu Open Callback

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 = { /* ... */ }
        )
    )
)

Use Cases

  • 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.

Dynamic Context Menu Items

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.

The Problem

// 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 data

The Solution: contextMenuItemsProvider

Use 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

Execution Order

  1. User right-clicks
  2. onContextMenuOpenAsync runs (if provided), menu waits
  3. contextMenuItemsProvider called to get fresh items
  4. Menu displays with up-to-date items

Fallback Behavior

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.

Settings Override

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
    )
)

Use Cases

  • Sidebar terminals: Force alwaysShowTabBar = true so users always see tabs
  • Compact views: Reduce fontSize for space-constrained layouts
  • Different themes per instance: Override colors for specific terminals
  • Performance tuning: Different bufferMaxLines for different use cases

How It Works

settingsOverride merges with global settings from ~/.bossterm/settings.json:

  1. Global settings are loaded from SettingsManager
  2. Non-null fields in settingsOverride replace corresponding global values
  3. Null fields in settingsOverride inherit from global settings
// Example: Only override alwaysShowTabBar, inherit everything else
TabbedTerminal(
    settingsOverride = TerminalSettingsOverride(
        alwaysShowTabBar = true
    ),
    onExit = { /* ... */ }
)

Common Override Examples

// 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
)

Custom Platform Services

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.

State Persistence

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 = false when 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

Stable Tab IDs

Tab indices are unstable - they change when tabs are reordered, closed, or inserted. Use stable tab IDs for reliable tab targeting.

The Problem

// 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!

The Solution: Stable IDs

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 index

Auto-Generated IDs

If 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!!)

API Overview

// 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 ID

Example: Runner System with Cleanup

Use 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

Programmatic Input

Send text and control signals to terminal tabs programmatically.

Basic Usage

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)

Control Signals

// 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)

Raw Bytes

// 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

Example: Tab Controls

@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.

Complete Example

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:run

Custom Link Handling

By 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.

HyperlinkInfo

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
)

Basic Usage

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
    }
)

Selective Handling with Default Fallback

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
        }
    }
)

Use Cases

  • 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

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.

Custom Hyperlink Patterns

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.

AI Assistant & VCS Tool Installation API

BossTerm includes built-in support for detecting and installing AI coding assistants and VCS tools. The API provides programmatic access from TabbedTerminalState.

Available Tools

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

API Methods

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

Example: AI Toolbar

@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)
        )
    }
}

Built-in Context Menu

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.

Command Interception (OSC 133 Required)

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).

Custom Context Menu

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.

Comparison: EmbeddableTerminal vs TabbedTerminal

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.

Migration Guide

v1.0.65+ Breaking Changes

onLinkClick Signature Change

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)? = null

Migration:

// 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() }
)