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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes
│ Params: showBackground=true, backgroundColor=0xFF111111 │
╰─────────────────────────────────────────────────────────────────────────────────────╯
● Launched: LoginScreenEmptyDarkPreview (com.example.app.dev)
enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · q quit
enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · x kill emu · q quit
```

## What it does
Expand All @@ -49,7 +49,8 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes
- **Crash detection** — Automatically detects when a preview crashes and shows the root cause error in the preview panel
- **HD Web Preview** — Press `w` to open a local web viewer in your browser with full-quality preview rendering
- **Install** — Trigger Gradle install tasks (`i`) with automatic variant detection (dev, qa, accept, production)
- **Device / Emulator picker** — Press `d` to select a connected device or launch an AVD emulator
- **Device / Emulator picker** — Press `d` to select a connected device or launch an AVD emulator, with optional fast mode (headless + Quick Boot)
- **Kill emulator** — Press `x` to list running emulators and kill one (useful for headless emulators)
- **Details** — See fully qualified name, file path, line number, and `@Preview` parameters
- **Composable count** — Shows total `@Composable` functions per module, helping identify preview coverage gaps
- **Stale detection** — Warns when source files are newer than the installed APK
Expand Down Expand Up @@ -130,7 +131,7 @@ compose-preview --dismiss-dialog
├──────────────────────────────────────────────────────────────────────────────┤
│ ⚠ sources changed since last build — press 'i' to install │ status
├──────────────────────────────────────────────────────────────────────────────┤
│ enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · q quit │ help
│ enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · x kill emu · q quit │ help
└──────────────────────────────────────────────────────────────────────────────┘
```

Expand All @@ -147,7 +148,8 @@ compose-preview --dismiss-dialog
| `f` | Fullscreen HD preview with native terminal graphics — `↑/↓` to navigate between previews |
| `w` | Toggle HD web preview viewer in browser |
| `i` | Install APK via Gradle (auto-detects build variants) |
| `d` | Open device / emulator picker |
| `d` | Open device / emulator picker (press `f` in picker to toggle fast mode) |
| `x` | Open kill emulator picker — select a running emulator to stop it |
| `R` | Refresh project scan |
| `1` / `2` | Focus Modules / Previews panel directly |
| `q` | Quit |
Expand Down Expand Up @@ -194,6 +196,20 @@ The terminal screenshot is low resolution. For full-quality rendering, press `w`

Press `d` to open a modal listing connected devices and available AVD emulators. Select a device to target, or pick an emulator to launch it.

Press `f` inside the picker to toggle **fast mode** — launches the emulator headless (no window, no audio) with Quick Boot snapshots and host GPU acceleration. This is significantly faster than a cold boot and ideal for CI or when you don't need the emulator UI.

### Kill emulator

Press `x` to open a modal listing all running emulators. Select one and press `Enter` to kill it. This is especially useful for headless emulators launched in fast mode, which have no window to close.

```
╭ Kill Emulator ───────────────────────╮
│ ▸ Pixel_6_API_34 (emulator-5554) │
│ Pixel_7_API_35 (emulator-5556) │
│ ↑↓ navigate · enter kill · esc cancel│
╰──────────────────────────────────────╯
```

### Install variants

When you press `i`, compose-preview queries Gradle for all available install tasks. If your project has multiple build variants (dev, qa, accept, production), a picker modal appears:
Expand Down
66 changes: 64 additions & 2 deletions internal/adb/adb.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,20 @@ func ListAVDs() []AVD {

// StartEmulator launches an emulator AVD in the background.
// Returns immediately — the emulator boots asynchronously.
func StartEmulator(avdName string) error {
// If fastMode is true, runs headless with Quick Boot and host GPU for faster startup.
func StartEmulator(avdName string, fastMode bool) error {
emulator, err := findEmulator()
if err != nil {
return err
}
cmd := exec.Command(emulator, "-avd", avdName, "-no-snapshot-load")
args := []string{"-avd", avdName}
if fastMode {
// Headless: no window, no audio, use host GPU acceleration, Quick Boot (snapshot) enabled
args = append(args, "-no-window", "-no-audio", "-gpu", "auto")
} else {
args = append(args, "-no-snapshot-load")
}
cmd := exec.Command(emulator, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Start()
Expand All @@ -131,6 +139,60 @@ func findEmulator() (string, error) {
return "", fmt.Errorf("emulator not found — set ANDROID_HOME or add emulator to PATH")
}

// RunningEmulator represents a currently running emulator instance.
type RunningEmulator struct {
Serial string // e.g. "emulator-5554"
AVD string // AVD name if available
}

// ListRunningEmulators returns emulator devices that are currently running.
func ListRunningEmulators() ([]RunningEmulator, error) {
devices, err := DetectDevices()
if err != nil {
return nil, err
}
var emulators []RunningEmulator
for _, d := range devices {
if !strings.HasPrefix(d.Serial, "emulator-") {
continue
}
if d.State != "device" {
continue
}
avdName := getEmulatorAVDName(d.Serial)
emulators = append(emulators, RunningEmulator{
Serial: d.Serial,
AVD: avdName,
})
}
return emulators, nil
}

// getEmulatorAVDName queries the emulator for its AVD name via adb.
func getEmulatorAVDName(serial string) string {
out, err := exec.Command("adb", "-s", serial, "emu", "avd", "name").Output()
if err != nil {
return ""
}
// First line is the AVD name
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "OK" {
return line
}
}
return ""
}

// KillEmulator kills a running emulator by serial.
func KillEmulator(serial string) error {
out, err := exec.Command("adb", "-s", serial, "emu", "kill").CombinedOutput()
if err != nil {
return fmt.Errorf("kill emulator %s: %s", serial, strings.TrimSpace(string(out)))
}
return nil
}

// WaitForDevice waits for a device to come online (up to timeout).
func WaitForDevice(timeout int) error {
args := []string{"wait-for-device"}
Expand Down
94 changes: 92 additions & 2 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ type emulatorReadyMsg struct {
device adb.Device
}

// emulatorKilledMsg is sent when an emulator has been killed.
type emulatorKilledMsg struct {
serial string
err error
}

// installTasksMsg is sent when gradle install task discovery completes.
type installTasksMsg struct {
tasks []string
Expand Down Expand Up @@ -101,6 +107,10 @@ type Model struct {
devicePickerSel int // cursor in the picker
devicePickerItems []devicePickerItem // combined list of devices + AVDs
emulatorBooting bool
emulatorFastMode bool // headless + Quick Boot
showKillPicker bool // kill emulator modal
killPickerSel int // cursor in the kill picker
killPickerItems []adb.RunningEmulator // running emulators

// Build status
statusMsg string
Expand Down Expand Up @@ -222,6 +232,7 @@ func NewModel(result scanner.ScanResult, projectRoot string, opts ...Options) Mo
searchInput: si,
avds: avds,
showDevicePicker: showPicker,
emulatorFastMode: true,
panelRegions: make(map[types.PanelID]panel.Region),
}
if showPicker {
Expand Down Expand Up @@ -329,6 +340,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.errorMsg = ""
return m, nil

case emulatorKilledMsg:
if msg.err != nil {
m.errorMsg = fmt.Sprintf("Failed to kill emulator: %v", msg.err)
} else {
m.statusMsg = fmt.Sprintf("Emulator %s killed", msg.serial)
// Remove killed device from list
var remaining []adb.Device
for _, d := range m.devices {
if d.Serial != msg.serial {
remaining = append(remaining, d)
}
}
m.devices = remaining
if len(m.devices) > 0 {
m.deviceStatus = m.devices[0].Model
if m.deviceStatus == "" {
m.deviceStatus = m.devices[0].Serial
}
} else {
m.deviceStatus = "no device"
}
}
return m, nil

case adbLaunchMsg:
if msg.err != nil {
m.errorMsg = fmt.Sprintf("Launch failed: %v", msg.err)
Expand Down Expand Up @@ -356,6 +391,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}

// Kill emulator picker modal is active
if m.showKillPicker {
switch msg.String() {
case "up", "k":
if m.killPickerSel > 0 {
m.killPickerSel--
}
return m, nil
case "down", "j":
if m.killPickerSel < len(m.killPickerItems)-1 {
m.killPickerSel++
}
return m, nil
case "enter":
if m.killPickerSel < len(m.killPickerItems) {
emu := m.killPickerItems[m.killPickerSel]
m.showKillPicker = false
serial := emu.Serial
m.statusMsg = fmt.Sprintf("Killing emulator %s...", serial)
return m, func() tea.Msg {
err := adb.KillEmulator(serial)
return emulatorKilledMsg{serial: serial, err: err}
}
}
return m, nil
case "esc", "q":
m.showKillPicker = false
return m, nil
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}

// Device picker modal is active
if m.showDevicePicker {
switch msg.String() {
Expand Down Expand Up @@ -392,6 +461,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
return m, nil
case "f":
m.emulatorFastMode = !m.emulatorFastMode
return m, nil
case "esc", "q":
m.showDevicePicker = false
return m, nil
Expand Down Expand Up @@ -479,6 +551,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

// "x" opens kill emulator picker
if key == "x" {
emulators, err := adb.ListRunningEmulators()
if err != nil || len(emulators) == 0 {
m.errorMsg = "No running emulators found"
return m, nil
}
m.killPickerItems = emulators
m.killPickerSel = 0
m.showKillPicker = true
return m, nil
}

// "w" toggles web preview viewer
if key == "w" {
if m.webServer == nil {
Expand Down Expand Up @@ -1156,10 +1241,15 @@ func (m *Model) buildPickerItems() []devicePickerItem {

// launchEmulator starts an emulator AVD asynchronously.
func (m *Model) launchEmulator(avdName string) tea.Cmd {
m.statusMsg = fmt.Sprintf("Launching emulator %s...", avdName)
fastMode := m.emulatorFastMode
mode := ""
if fastMode {
mode = " (fast/headless)"
}
m.statusMsg = fmt.Sprintf("Launching emulator %s%s...", avdName, mode)
m.errorMsg = ""
return func() tea.Msg {
err := adb.StartEmulator(avdName)
err := adb.StartEmulator(avdName, fastMode)
return emulatorStartedMsg{avdName: avdName, err: err}
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func (m Model) renderHelp() string {
if m.webServer != nil {
webLabel = "w stop web"
}
parts := []string{"enter run", "s screenshot", "f fullscreen HD", webLabel, "i install", "/ filter", "d device", "q quit"}
parts := []string{"enter run", "s screenshot", "f fullscreen HD", webLabel, "i install", "/ filter", "d device", "x kill emu", "q quit"}
return helpStyle.Render(" " + strings.Join(parts, " · "))
}

Expand Down
60 changes: 59 additions & 1 deletion internal/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ func (m Model) View() string {
layout = m.overlayModal(layout, modal)
}

// Overlay kill emulator picker modal if active
if m.showKillPicker {
modal := m.renderKillPickerModal()
layout = m.overlayModal(layout, modal)
}

return layout
}

Expand Down Expand Up @@ -187,6 +193,17 @@ func (m Model) renderDevicePickerModal() string {
lines = append(lines, statusBarStyle.Render(" No devices or emulators found"))
}

// Show fast mode toggle if there are AVDs
if hasAVDs {
lines = append(lines, "")
fastStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
fastLabel := "[ ] Fast mode (headless, Quick Boot)"
if m.emulatorFastMode {
fastLabel = "[✓] Fast mode (headless, Quick Boot)"
}
lines = append(lines, fastStyle.Render(" "+fastLabel))
}

content := ""
for _, l := range lines {
content += l + "\n"
Expand All @@ -204,7 +221,7 @@ func (m Model) renderDevicePickerModal() string {
}

modalH := len(lines)
help := helpStyle.Render(" ↑↓ navigate · enter select · esc cancel")
help := helpStyle.Render(" ↑↓ navigate · enter select · f fast mode · esc cancel")

return panel.Box(0, "Select Device / Emulator", content+help, modalW, modalH+1, true,
panel.BoxOpts{Accent: selectedAccent})
Expand Down Expand Up @@ -251,6 +268,47 @@ func (m Model) renderInstallPickerModal() string {
panel.BoxOpts{Accent: selectedAccent})
}

// renderKillPickerModal renders the kill emulator selection modal.
func (m Model) renderKillPickerModal() string {
var lines []string

for i, emu := range m.killPickerItems {
cursor := " "
style := normalItemStyle
if i == m.killPickerSel {
cursor = "▸ "
style = selectedItemStyle
}
label := emu.Serial
if emu.AVD != "" {
label = fmt.Sprintf("%s (%s)", emu.AVD, emu.Serial)
}
lines = append(lines, cursor+style.Render(label))
}

content := ""
for _, l := range lines {
content += l + "\n"
}

modalW := 40
for _, emu := range m.killPickerItems {
w := len(emu.Serial) + len(emu.AVD) + 7
if w > modalW {
modalW = w
}
}
if modalW > m.width-4 {
modalW = m.width - 4
}

modalH := len(lines)
help := helpStyle.Render(" ↑↓ navigate · enter kill · esc cancel")

return panel.Box(0, "Kill Emulator", content+help, modalW, modalH+1, true,
panel.BoxOpts{Accent: selectedAccent})
}

// overlayModal places a modal string centered on top of the base layout.
func (m Model) overlayModal(base, modal string) string {
baseLines := strings.Split(base, "\n")
Expand Down
Loading