Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@
pkill -9 -f "gradle" # Kill stuck gradle
```

## JVM Requirements (Java 16+)

For full functionality on Java 16+, add these JVM arguments:

```
--add-opens java.desktop/java.awt=ALL-UNNAMED # Windows: HWND access for global hotkeys
--add-opens java.desktop/sun.awt.X11=ALL-UNNAMED # Linux: WM_CLASS for desktop integration
```

Without these flags:
- **Windows**: Global hotkey window toggle falls back to standard show/hide
- **Linux**: Desktop integration (taskbar grouping) may not work correctly

Add to IDE run configurations or gradle.properties for development.

## Git Workflow

```bash
Expand Down
162 changes: 148 additions & 14 deletions bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
Expand All @@ -22,7 +26,10 @@ import ai.rever.bossterm.compose.shell.ShellCustomizationUtils
import ai.rever.bossterm.compose.update.UpdateBanner
import ai.rever.bossterm.compose.update.UpdateManager
import ai.rever.bossterm.compose.window.CustomTitleBar
import ai.rever.bossterm.compose.window.GlobalHotKeyManager
import ai.rever.bossterm.compose.window.HotKeyConfig
import ai.rever.bossterm.compose.window.WindowManager
import ai.rever.bossterm.compose.window.WindowVisibilityController
import ai.rever.bossterm.compose.window.configureWindowTransparency
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
Expand Down Expand Up @@ -58,8 +65,14 @@ fun main() {
WindowManager.createWindow()
}

// Start global hotkey manager after initial window creation
// Use LaunchedEffect to run only once
LaunchedEffect(Unit) {
startGlobalHotKeyManager()
}

// Detect platform
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
val isMacOS = ShellCustomizationUtils.isMacOS()

// Render all windows
for (window in WindowManager.windows) {
Expand Down Expand Up @@ -163,6 +176,9 @@ fun main() {
// Set initial focus state
window.isWindowFocused.value = awtWindow.isFocused

// Store AWT window reference for global hotkey toggle
window.awtWindow = awtWindow

// Configure window transparency and blur (only for custom title bar mode)
if (!useNativeTitleBar) {
configureWindowTransparency(
Expand All @@ -174,6 +190,7 @@ fun main() {

onDispose {
awtWindow.removeWindowFocusListener(focusListener)
window.awtWindow = null
}
}

Expand Down Expand Up @@ -520,6 +537,23 @@ fun main() {
shape = RoundedCornerShape(cornerRadius)
) {
Box(modifier = Modifier.fillMaxSize()) {
// Compute global hotkey hint (used for both title bar modes)
val globalHotkeyHint = remember(
windowSettings.globalHotkeyEnabled,
windowSettings.globalHotkeyCtrl,
windowSettings.globalHotkeyAlt,
windowSettings.globalHotkeyShift,
windowSettings.globalHotkeyWin,
window.windowNumber
) {
if (windowSettings.globalHotkeyEnabled && window.windowNumber in 1..9) {
val config = HotKeyConfig.fromSettings(windowSettings)
config.toWindowDisplayString(window.windowNumber, useMacSymbols = isMacOS)
} else {
null
}
}

// Background layer: either image or glass blur effect
if (backgroundImage != null) {
// Background image with blur
Expand Down Expand Up @@ -585,7 +619,8 @@ fun main() {
},
backgroundColor = windowSettings.defaultBackgroundColor.copy(
alpha = (windowSettings.backgroundOpacity * 1.1f).coerceAtMost(1f)
)
),
globalHotkeyHint = globalHotkeyHint
)
}

Expand Down Expand Up @@ -633,6 +668,23 @@ fun main() {
modifier = Modifier.fillMaxSize().weight(1f)
)
}

// Hotkey hint overlay (top-right corner, like iTerm2)
// Shows for native title bar; custom title bar shows it in the title bar itself
if (useNativeTitleBar && globalHotkeyHint != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 12.dp)
) {
Text(
text = globalHotkeyHint,
color = Color.White.copy(alpha = 0.5f),
fontSize = 11.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Normal
)
}
}
}
}

Expand Down Expand Up @@ -686,13 +738,33 @@ fun main() {
}
}

/**
* Load BossTerm settings with error handling.
* Returns null if loading fails for optional features, default settings for critical features.
*
* @param context Description of what the settings are being loaded for
* @param allowNull If true, returns null on error; if false, returns default settings
*/
private fun loadSettings(
context: String = "general",
allowNull: Boolean = false
): ai.rever.bossterm.compose.settings.TerminalSettings? {
return try {
ai.rever.bossterm.compose.settings.SettingsLoader.loadFromPathOrDefault(null)
} catch (e: Exception) {
System.err.println("Could not load settings for $context: ${e.message}")
if (!allowNull) e.printStackTrace()
if (allowNull) null else ai.rever.bossterm.compose.settings.TerminalSettings()
}
}

/**
* Set WM_CLASS for proper Linux desktop integration.
* Must be called before any windows are created.
* Requires JVM arg: --add-opens java.desktop/sun.awt.X11=ALL-UNNAMED
*/
private fun setLinuxWMClass() {
if (!System.getProperty("os.name").lowercase().contains("linux")) return
if (!ShellCustomizationUtils.isLinux()) return

try {
// Get toolkit instance (creates it if needed)
Expand All @@ -718,18 +790,11 @@ private fun setLinuxWMClass() {
* - GPU resource cache size
*/
private fun configureGpuRendering() {
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("windows")
val isMacOS = ShellCustomizationUtils.isMacOS()
val isWindows = ShellCustomizationUtils.isWindows()

// Load settings using SettingsLoader (handles JSON parsing and defaults)
val settings = try {
ai.rever.bossterm.compose.settings.SettingsLoader.loadFromPathOrDefault(null)
} catch (e: Exception) {
System.err.println("Could not load settings for GPU config, using defaults: ${e.message}")
e.printStackTrace()
ai.rever.bossterm.compose.settings.TerminalSettings()
}
// Load settings using helper function (returns defaults on failure)
val settings = loadSettings("GPU config", allowNull = false)!!

// Configure render API
val renderApi = if (!settings.gpuAcceleration) {
Expand Down Expand Up @@ -784,3 +849,72 @@ private fun configureGpuRendering() {
println("GPU: Acceleration=${settings.gpuAcceleration}, API=${settings.gpuRenderApi}, " +
"Priority=${settings.gpuPriority}, VSync=${settings.gpuVsyncEnabled}, Cache=${settings.gpuCacheSizeMb}MB")
}

/**
* Start the global hotkey manager (Windows, macOS, Linux).
* Allows summoning specific BossTerm windows from anywhere with system-wide hotkeys.
* Each window gets a unique hotkey: Modifiers+1, Modifiers+2, etc.
*/
private fun startGlobalHotKeyManager() {
// Load settings (returns null on error, skip initialization in that case)
val settings = loadSettings("global hotkey", allowNull = true) ?: return

// Check if enabled
val config = HotKeyConfig.fromSettings(settings)
if (!config.enabled) {
println("GlobalHotKey: Disabled in settings")
return
}

// Validate configuration (need at least one modifier)
if (!(config.ctrl || config.alt || config.shift || config.win)) {
println("GlobalHotKey: Invalid configuration (no modifiers)")
return
}

// Set up window lifecycle callbacks for hotkey registration
WindowManager.onWindowCreated = { window ->
// Validate window number is in valid range before registering
if (window.windowNumber in 1..9) {
GlobalHotKeyManager.registerWindow(window.windowNumber)
}
}
WindowManager.onWindowClosed = { window ->
// Validate window number is in valid range before unregistering
if (window.windowNumber in 1..9) {
GlobalHotKeyManager.unregisterWindow(window.windowNumber)
}
}

// Start the manager with window-specific callback
GlobalHotKeyManager.start(config) { windowNumber ->
// Find the window with this number
val window = WindowManager.getWindowByNumber(windowNumber)
if (window != null) {
// Window exists - toggle its visibility
val awtWindow = window.awtWindow
if (awtWindow != null) {
WindowVisibilityController.toggleWindow(listOf(awtWindow))
}
} else {
// No window with this number - create one if it's window 1
if (windowNumber == 1) {
javax.swing.SwingUtilities.invokeLater {
WindowManager.createWindow()
}
}
}
}

// Register existing windows (in case any were created before hotkey manager started)
WindowManager.windows.forEach { window ->
GlobalHotKeyManager.registerWindow(window.windowNumber)
}

// Register shutdown hook to clean up
Runtime.getRuntime().addShutdownHook(Thread {
GlobalHotKeyManager.stop()
})

println("GlobalHotKey: Started with modifiers for windows 1-9")
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ enum class SettingsCategory(
icon = Icons.Default.Face,
description = "AI coding assistant integration"
),
GLOBAL_HOTKEY(
displayName = "Global Hotkey",
icon = Icons.Default.KeyboardArrowUp,
description = "System-wide hotkey to summon BossTerm"
),
ABOUT(
displayName = "About",
icon = Icons.Default.Favorite,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ class SettingsManager(private val customSettingsPath: String? = null) {
}

/**
* Load settings from file
* Load settings from file.
* After loading, re-saves to ensure any new fields (added in updates) are persisted
* with their default values. This provides automatic settings migration.
*/
fun loadFromFile() {
try {
Expand All @@ -102,6 +104,10 @@ class SettingsManager(private val customSettingsPath: String? = null) {
val loadedSettings = json.decodeFromString<TerminalSettings>(jsonString)
_settings.value = loadedSettings
println("Settings loaded from: ${settingsFile.absolutePath}")

// Re-save to migrate settings file with any new fields added in updates
// This ensures new settings (like globalHotkey*) are written with defaults
saveToFile()
} else {
println("No settings file found, using defaults")
// Save defaults on first run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ private fun SettingsContent(
onSettingsChange = onSettingsChange,
onSettingsSave = onSettingsSave
)
SettingsCategory.GLOBAL_HOTKEY -> GlobalHotkeySection(
settings = settings,
onSettingsChange = onSettingsChange,
onSettingsSave = onSettingsSave
)
SettingsCategory.ABOUT -> AboutSection()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,42 @@ data class TerminalSettings(
*/
val onboardingCompleted: Boolean = false,

// ===== Global Hotkey Settings =====

/**
* Enable global hotkey to summon BossTerm from anywhere.
* Default: Disabled on macOS and Linux (opt-in due to desktop environment conflicts).
* Enabled on Windows.
* Hotkey: Configured via modifiers + 1-9 for window-specific summoning.
*/
val globalHotkeyEnabled: Boolean = ShellCustomizationUtils.isWindows(),

/**
* Ctrl modifier for global hotkey.
*/
val globalHotkeyCtrl: Boolean = true,

/**
* Alt modifier for global hotkey.
*/
val globalHotkeyAlt: Boolean = false,

/**
* Shift modifier for global hotkey.
*/
val globalHotkeyShift: Boolean = false,

/**
* Windows key modifier for global hotkey.
*/
val globalHotkeyWin: Boolean = false,

/**
* Key for global hotkey.
* Valid values: "GRAVE" (`), "SPACE", "ESCAPE", A-Z, 0-9, F1-F12
*/
val globalHotkeyKey: String = "GRAVE",

/**
* Automatically inject shell integration (OSC 133) into new terminal sessions.
* When enabled, BossTerm hijacks shell environment variables (ZDOTDIR for zsh,
Expand Down
Loading