package cli import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "banger/internal/api" "banger/internal/model" "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-isatty" "github.com/spf13/cobra" ) const tuiRefreshInterval = 3 * time.Second type tuiMode int const ( tuiModeBrowse tuiMode = iota tuiModeForm tuiModeConfirmDelete ) type actionKind string const ( actionCreate actionKind = "create" actionEdit actionKind = "edit" actionStart actionKind = "start" actionStop actionKind = "stop" actionRestart actionKind = "restart" actionDelete actionKind = "delete" actionSSH actionKind = "ssh" actionLogs actionKind = "logs" ) type dataLoadedMsg struct { vms []model.VMRecord images []model.Image focusID string err error } type statsLoadedMsg struct { id string stats model.VMStats err error } type actionResultMsg struct { action actionRequest focusID string status string err error refresh bool } type externalPreparedMsg struct { action actionRequest command *exec.Cmd doneStatus string refresh bool err error } type sudoValidatedMsg struct { err error } type refreshTickMsg struct{} type actionRequest struct { kind actionKind id string name string create api.VMCreateParams set api.VMSetParams } type browseKeyMap struct { refresh key.Binding create key.Binding edit key.Binding start key.Binding stop key.Binding restart key.Binding delete key.Binding ssh key.Binding logs key.Binding help key.Binding quit key.Binding } func newBrowseKeyMap() browseKeyMap { return browseKeyMap{ refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")), edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), start: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start")), stop: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop")), restart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")), delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), ssh: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "ssh")), logs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")), help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), } } func (k browseKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.refresh, k.create, k.edit, k.start, k.stop, k.delete, k.ssh, k.logs, k.quit} } func (k browseKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.refresh, k.create, k.edit, k.start, k.stop, k.restart, k.delete}, {k.ssh, k.logs, k.help, k.quit}, } } type formKeyMap struct { next key.Binding prev key.Binding change key.Binding toggle key.Binding submit key.Binding cancel key.Binding } func newFormKeyMap() formKeyMap { return formKeyMap{ next: key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab", "next")), prev: key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab", "prev")), change: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("left/right", "change")), toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")), cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), } } func (k formKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.next, k.change, k.toggle, k.submit, k.cancel} } func (k formKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{{k.next, k.prev, k.change}, {k.toggle, k.submit, k.cancel}} } type confirmKeyMap struct { confirm key.Binding cancel key.Binding } func newConfirmKeyMap() confirmKeyMap { return confirmKeyMap{ confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("enter", "confirm")), cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("esc", "cancel")), } } func (k confirmKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.confirm, k.cancel} } func (k confirmKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{{k.confirm, k.cancel}} } type formFieldKind int const ( formFieldText formFieldKind = iota formFieldSelect ) type formField struct { label string kind formFieldKind input textinput.Model options []string index int } func newTextField(label, value string) formField { input := textinput.New() input.Prompt = "" input.SetValue(value) input.CharLimit = 128 return formField{ label: label, kind: formFieldText, input: input, } } func newSelectField(label string, options []string, index int) formField { if len(options) == 0 { options = []string{""} index = 0 } if index < 0 || index >= len(options) { index = 0 } return formField{ label: label, kind: formFieldSelect, options: options, index: index, } } func (f formField) value() string { if f.kind == formFieldText { return f.input.Value() } if len(f.options) == 0 { return "" } return f.options[f.index] } type vmForm struct { mode actionKind title string submitLabel string targetID string fields []formField focus int } func newCreateVMForm(images []model.Image, cfg model.DaemonConfig) *vmForm { imageOptions := imageNames(images) selectedImage := 0 if cfg.DefaultImageName != "" { for i, name := range imageOptions { if name == cfg.DefaultImageName { selectedImage = i break } } } form := &vmForm{ mode: actionCreate, title: "Create VM", submitLabel: "Create", fields: []formField{ newTextField("Name", ""), newSelectField("Image", imageOptions, selectedImage), newTextField("VCPU", strconv.Itoa(model.DefaultVCPUCount)), newTextField("Memory (MiB)", strconv.Itoa(model.DefaultMemoryMiB)), newTextField("System Overlay", model.FormatSizeBytes(model.DefaultSystemOverlaySize)), newTextField("Work Disk", model.FormatSizeBytes(model.DefaultWorkDiskSize)), newSelectField("NAT Enabled", []string{"no", "yes"}, 0), newSelectField("No Start", []string{"no", "yes"}, 0), }, } form.focusField(0) return form } func newEditVMForm(vm model.VMRecord) *vmForm { form := &vmForm{ mode: actionEdit, title: "Edit VM", submitLabel: "Save", targetID: vm.ID, fields: []formField{ newTextField("VCPU", strconv.Itoa(vm.Spec.VCPUCount)), newTextField("Memory (MiB)", strconv.Itoa(vm.Spec.MemoryMiB)), newTextField("Work Disk", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), newSelectField("NAT Enabled", []string{"no", "yes"}, boolToIndex(vm.Spec.NATEnabled)), }, } form.focusField(0) return form } func (f *vmForm) focusField(index int) tea.Cmd { if len(f.fields) == 0 { f.focus = 0 return nil } if f.focus >= 0 && f.focus < len(f.fields) && f.fields[f.focus].kind == formFieldText { f.fields[f.focus].input.Blur() } f.focus = wrapIndex(index, len(f.fields)) if f.fields[f.focus].kind == formFieldText { return f.fields[f.focus].input.Focus() } return nil } func (f *vmForm) move(delta int) tea.Cmd { return f.focusField(f.focus + delta) } func (f *vmForm) setWidth(width int) { inputWidth := maxInt(12, width-22) for i := range f.fields { if f.fields[i].kind == formFieldText { f.fields[i].input.Width = inputWidth } } } func (f *vmForm) update(msg tea.Msg) tea.Cmd { if len(f.fields) == 0 { return nil } if f.fields[f.focus].kind != formFieldText { return nil } var cmd tea.Cmd f.fields[f.focus].input, cmd = f.fields[f.focus].input.Update(msg) return cmd } func (f *vmForm) change(delta int) { if len(f.fields) == 0 { return } field := &f.fields[f.focus] if field.kind != formFieldSelect || len(field.options) == 0 { return } field.index = wrapIndex(field.index+delta, len(field.options)) } func (f *vmForm) view(width int) string { f.setWidth(width) titleStyle := lipgloss.NewStyle().Bold(true) labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) valueStyle := lipgloss.NewStyle().Bold(true) lines := []string{titleStyle.Render(f.title), ""} for i := range f.fields { marker := " " lbl := labelStyle.Render(f.fields[i].label) if i == f.focus { marker = "> " lbl = activeStyle.Render(f.fields[i].label) } value := "" if f.fields[i].kind == formFieldText { value = f.fields[i].input.View() } else { value = valueStyle.Render(f.fields[i].value()) } lines = append(lines, fmt.Sprintf("%s%-16s %s", marker, lbl, value)) } lines = append(lines, "", fmt.Sprintf("Enter %s Esc cancel", strings.ToLower(f.submitLabel))) style := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(1, 2). Width(maxInt(36, width)) return style.Render(strings.Join(lines, "\n")) } func (f *vmForm) submit() (actionRequest, error) { switch f.mode { case actionCreate: return f.createRequest() case actionEdit: return f.editRequest() default: return actionRequest{}, errors.New("unsupported form mode") } } func (f *vmForm) createRequest() (actionRequest, error) { vcpu, err := parsePositiveInt("vcpu", f.fields[2].value()) if err != nil { return actionRequest{}, err } memory, err := parsePositiveInt("memory", f.fields[3].value()) if err != nil { return actionRequest{}, err } params := api.VMCreateParams{ Name: strings.TrimSpace(f.fields[0].value()), ImageName: strings.TrimSpace(f.fields[1].value()), VCPUCount: vcpu, MemoryMiB: memory, SystemOverlaySize: strings.TrimSpace(f.fields[4].value()), WorkDiskSize: strings.TrimSpace(f.fields[5].value()), NATEnabled: isYes(f.fields[6].value()), NoStart: isYes(f.fields[7].value()), } if params.ImageName == "" || params.ImageName == "" { return actionRequest{}, errors.New("create requires an image") } return actionRequest{kind: actionCreate, create: params}, nil } func (f *vmForm) editRequest() (actionRequest, error) { vcpu, err := parsePositiveInt("vcpu", f.fields[0].value()) if err != nil { return actionRequest{}, err } memory, err := parsePositiveInt("memory", f.fields[1].value()) if err != nil { return actionRequest{}, err } params, err := vmSetParamsFromFlags( f.targetID, vcpu, memory, strings.TrimSpace(f.fields[2].value()), isYes(f.fields[3].value()), false, ) if err != nil { return actionRequest{}, err } return actionRequest{ kind: actionEdit, id: f.targetID, set: params, }, nil } type tuiModel struct { layout paths.Layout cfg model.DaemonConfig width int height int ready bool loading bool busy string sudoValidated bool mode tuiMode form *vmForm pendingAction *actionRequest vms []model.VMRecord images []model.Image selectedID string selectedStats model.VMStats statsID string statsErr string table table.Model detail viewport.Model help help.Model spinner spinner.Model browseKeys browseKeyMap formKeys formKeyMap confirmKeys confirmKeyMap lastRefresh time.Time statusText string statusErr bool } func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel { vmTable := table.New( table.WithColumns([]table.Column{ {Title: "NAME", Width: 18}, {Title: "STATE", Width: 9}, {Title: "IP", Width: 14}, {Title: "VCPU", Width: 4}, {Title: "MEM", Width: 8}, {Title: "DISK", Width: 8}, {Title: "AGE", Width: 12}, }), table.WithRows(nil), table.WithFocused(true), table.WithHeight(10), table.WithWidth(60), table.WithKeyMap(tuiTableKeyMap()), ) tableStyles := table.DefaultStyles() tableStyles.Header = lipgloss.NewStyle().Bold(true).Padding(0, 1) tableStyles.Cell = lipgloss.NewStyle().Padding(0, 1) tableStyles.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("62")) vmTable.SetStyles(tableStyles) detail := viewport.New(0, 0) detail.Style = lipgloss.NewStyle() spin := spinner.New(spinner.WithSpinner(spinner.Line)) spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) helpView := help.New() return tuiModel{ layout: layout, cfg: cfg, table: vmTable, detail: detail, help: helpView, spinner: spin, browseKeys: newBrowseKeyMap(), formKeys: newFormKeyMap(), confirmKeys: newConfirmKeyMap(), loading: true, statusText: "Loading VMs...", } } func newTUICommand() *cobra.Command { return &cobra.Command{ Use: "tui", Short: "Launch a terminal UI to manage VMs", Args: noArgsUsage("usage: banger tui"), RunE: func(cmd *cobra.Command, args []string) error { if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) { return errors.New("tui requires an interactive terminal") } layout, cfg, err := ensureDaemon(cmd.Context()) if err != nil { return err } program := tea.NewProgram(newTUIModel(layout, cfg), tea.WithAltScreen()) return program.Start() }, } } func (m tuiModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, refreshTickCmd(), fetchDataCmd(m.layout, "")) } func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd if m.loading || m.busy != "" { var spinCmd tea.Cmd m.spinner, spinCmd = m.spinner.Update(msg) cmds = append(cmds, spinCmd) } switch msg := msg.(type) { case tea.WindowSizeMsg: m.ready = true m.width = msg.Width m.height = msg.Height m.resize() case dataLoadedMsg: m.loading = false if msg.err != nil { m.setStatus(msg.err.Error(), true) break } m.images = msg.images m.vms = msg.vms targetID := m.selectedID if msg.focusID != "" { targetID = msg.focusID } m.selectedID = resolveSelectedID(targetID, msg.vms) m.lastRefresh = time.Now() m.rebuildTable() m.setStatus(fmt.Sprintf("Loaded %d VM(s)", len(msg.vms)), false) if m.selectedID != "" { cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID)) } else { m.selectedStats = model.VMStats{} m.statsID = "" m.statsErr = "" } case statsLoadedMsg: if msg.id != m.selectedID { break } if msg.err != nil { m.statsErr = msg.err.Error() m.setStatus(msg.err.Error(), true) break } m.selectedStats = msg.stats m.statsID = msg.id m.statsErr = "" case actionResultMsg: m.busy = "" if msg.err != nil { if looksLikeSudoExpiry(msg.err) { m.sudoValidated = false } m.setStatus(msg.err.Error(), true) break } if msg.action.kind == actionCreate || msg.action.kind == actionEdit { m.form = nil m.mode = tuiModeBrowse } if msg.action.kind == actionDelete { m.mode = tuiModeBrowse } m.setStatus(msg.status, false) if msg.refresh { m.loading = true cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, msg.focusID)) } case externalPreparedMsg: if msg.err != nil { m.setStatus(msg.err.Error(), true) break } cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg { return actionResultMsg{ action: msg.action, status: msg.doneStatus, err: normalizeExecError(err), refresh: msg.refresh, focusID: m.selectedID, } })) case sudoValidatedMsg: if msg.err != nil { m.pendingAction = nil m.busy = "" m.setStatus(msg.err.Error(), true) break } m.sudoValidated = true if m.pendingAction != nil { action := *m.pendingAction m.pendingAction = nil m.busy = action.activity() cmds = append(cmds, m.spinner.Tick, m.runActionCmd(action)) } case refreshTickMsg: cmds = append(cmds, refreshTickCmd()) if m.busy == "" && m.mode == tuiModeBrowse { m.loading = true cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID)) } case tea.KeyMsg: switch m.mode { case tuiModeBrowse: nextModel, extraCmds := m.updateBrowse(msg) m = nextModel cmds = append(cmds, extraCmds...) case tuiModeForm: nextModel, extraCmds := m.updateForm(msg) m = nextModel cmds = append(cmds, extraCmds...) case tuiModeConfirmDelete: nextModel, extraCmds := m.updateConfirmDelete(msg) m = nextModel cmds = append(cmds, extraCmds...) } } m.refreshDetail() return m, tea.Batch(cmds...) } func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { var cmds []tea.Cmd switch { case key.Matches(msg, m.browseKeys.quit): cmds = append(cmds, tea.Quit) case key.Matches(msg, m.browseKeys.help): m.help.ShowAll = !m.help.ShowAll case key.Matches(msg, m.browseKeys.refresh): m.loading = true cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID)) case key.Matches(msg, m.browseKeys.create): if len(m.images) == 0 { m.setStatus("Create requires at least one image", true) return m, cmds } m.form = newCreateVMForm(m.images, m.cfg) m.mode = tuiModeForm cmds = append(cmds, m.form.focusField(m.form.focus)) case key.Matches(msg, m.browseKeys.edit): vm, ok := m.selectedVM() if !ok { m.setStatus("No VM selected", true) return m, cmds } if vm.State == model.VMStateRunning { m.setStatus("Stop the VM before editing it", true) return m, cmds } m.form = newEditVMForm(vm) m.mode = tuiModeForm cmds = append(cmds, m.form.focusField(m.form.focus)) case key.Matches(msg, m.browseKeys.delete): if _, ok := m.selectedVM(); !ok { m.setStatus("No VM selected", true) return m, cmds } m.mode = tuiModeConfirmDelete case key.Matches(msg, m.browseKeys.start): vm, ok := m.selectedVM() if !ok { m.setStatus("No VM selected", true) return m, cmds } if vm.State == model.VMStateRunning { m.setStatus("VM is already running", true) return m, cmds } cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStart, id: vm.ID, name: vm.Name})) case key.Matches(msg, m.browseKeys.stop): vm, ok := m.selectedVM() if !ok { m.setStatus("No VM selected", true) return m, cmds } if vm.State != model.VMStateRunning { m.setStatus("VM is not running", true) return m, cmds } cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStop, id: vm.ID, name: vm.Name})) case key.Matches(msg, m.browseKeys.restart): vm, ok := m.selectedVM() if !ok { m.setStatus("No VM selected", true) return m, cmds } cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionRestart, id: vm.ID, name: vm.Name})) case key.Matches(msg, m.browseKeys.ssh): vm, ok := m.selectedVM() if !ok { m.setStatus("No VM selected", true) return m, cmds } if vm.State != model.VMStateRunning { m.setStatus("SSH requires a running VM", true) return m, cmds } cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionSSH, id: vm.ID, name: vm.Name})) case key.Matches(msg, m.browseKeys.logs): vm, ok := m.selectedVM() if !ok { m.setStatus("No VM selected", true) return m, cmds } cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionLogs, id: vm.ID, name: vm.Name})) default: if len(m.vms) == 0 { return m, cmds } oldCursor := m.table.Cursor() var tableCmd tea.Cmd m.table, tableCmd = m.table.Update(msg) cmds = append(cmds, tableCmd) if m.table.Cursor() != oldCursor { if vm, ok := m.selectedVMByCursor(); ok { m.selectedID = vm.ID m.selectedStats = model.VMStats{} m.statsID = "" m.statsErr = "" cmds = append(cmds, fetchStatsCmd(m.layout, vm.ID)) } } } return m, cmds } func (m tuiModel) updateForm(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { var cmds []tea.Cmd switch { case key.Matches(msg, m.formKeys.cancel): m.form = nil m.mode = tuiModeBrowse case key.Matches(msg, m.formKeys.next): cmds = append(cmds, m.form.move(1)) case key.Matches(msg, m.formKeys.prev): cmds = append(cmds, m.form.move(-1)) case key.Matches(msg, m.formKeys.change): switch msg.String() { case "left": m.form.change(-1) case "right": m.form.change(1) } case key.Matches(msg, m.formKeys.toggle): m.form.change(1) case key.Matches(msg, m.formKeys.submit): action, err := m.form.submit() if err != nil { m.setStatus(err.Error(), true) return m, cmds } cmds = append(cmds, m.dispatchAction(action)) default: cmds = append(cmds, m.form.update(msg)) } return m, cmds } func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { switch { case key.Matches(msg, m.confirmKeys.cancel): m.mode = tuiModeBrowse return m, nil case key.Matches(msg, m.confirmKeys.confirm): vm, ok := m.selectedVM() if !ok { m.mode = tuiModeBrowse m.setStatus("No VM selected", true) return m, nil } return m, []tea.Cmd{m.dispatchAction(actionRequest{kind: actionDelete, id: vm.ID, name: vm.Name})} default: return m, nil } } func (m tuiModel) View() string { if !m.ready { return "Loading..." } header := m.renderHeader() body := m.renderBody() status := m.renderStatus() m.help.Width = m.width helpView := m.help.View(m.currentKeyMap()) return lipgloss.JoinVertical(lipgloss.Left, header, body, status, helpView) } func (m tuiModel) currentKeyMap() help.KeyMap { switch m.mode { case tuiModeForm: return m.formKeys case tuiModeConfirmDelete: return m.confirmKeys default: return m.browseKeys } } func (m *tuiModel) resize() { bodyHeight := m.bodyHeight() leftWidth := maxInt(42, (m.width*55)/100) if leftWidth > m.width-24 { leftWidth = maxInt(24, m.width/2) } rightWidth := maxInt(24, m.width-leftWidth-1) leftInnerWidth := maxInt(20, leftWidth-4) rightInnerWidth := maxInt(20, rightWidth-4) panelInnerHeight := maxInt(8, bodyHeight-2) m.table.SetWidth(leftInnerWidth) m.table.SetHeight(maxInt(4, panelInnerHeight-2)) m.detail.Width = rightInnerWidth m.detail.Height = maxInt(4, panelInnerHeight-2) if m.form != nil { m.form.setWidth(maxInt(28, minInt(58, m.width-10))) } } func (m tuiModel) bodyHeight() int { return maxInt(8, m.height-4) } func (m tuiModel) renderHeader() string { status := "idle" if m.loading { status = m.spinner.View() + " loading" } else if m.busy != "" { status = m.spinner.View() + " " + m.busy } refreshed := "never" if !m.lastRefresh.IsZero() { refreshed = relativeTime(m.lastRefresh) } header := fmt.Sprintf("banger tui socket %s %s last refresh %s", filepath.Base(m.layout.SocketPath), status, refreshed) return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header) } func (m tuiModel) renderBody() string { bodyHeight := m.bodyHeight() if m.mode == tuiModeForm && m.form != nil { return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.form.view(minInt(58, m.width-10))) } if m.mode == tuiModeConfirmDelete { return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.confirmDeleteView()) } leftWidth := maxInt(42, (m.width*55)/100) if leftWidth > m.width-24 { leftWidth = maxInt(24, m.width/2) } rightWidth := maxInt(24, m.width-leftWidth-1) leftInnerWidth := maxInt(20, leftWidth-4) rightInnerWidth := maxInt(20, rightWidth-4) panelHeight := maxInt(8, bodyHeight-1) leftContent := m.table.View() if len(m.vms) == 0 { leftContent = "No VMs.\n\nPress c to create one." } leftPanel := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(0, 1). Width(leftWidth). Height(panelHeight). Render(lipgloss.NewStyle().Width(leftInnerWidth).Render(leftContent)) rightPanel := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(0, 1). Width(rightWidth). Height(panelHeight). Render(lipgloss.NewStyle().Width(rightInnerWidth).Render(m.detail.View())) return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) } func (m tuiModel) confirmDeleteView() string { vm, ok := m.selectedVM() name := "this VM" if ok { name = vm.Name } content := fmt.Sprintf("Delete %s?\n\nThis removes the VM and its persistent state.\n\nEnter confirm Esc cancel", name) return lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(1, 2). Width(54). Render(content) } func (m tuiModel) renderStatus() string { if m.statusText == "" { return " " } style := lipgloss.NewStyle().Foreground(lipgloss.Color("70")) if m.statusErr { style = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) } return style.Width(m.width).Render(m.statusText) } func (m *tuiModel) rebuildTable() { rows := make([]table.Row, 0, len(m.vms)) cursor := 0 for i, vm := range m.vms { rows = append(rows, table.Row{ vm.Name, string(vm.State), vm.Runtime.GuestIP, strconv.Itoa(vm.Spec.VCPUCount), fmt.Sprintf("%dM", vm.Spec.MemoryMiB), model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), relativeTime(vm.CreatedAt), }) if vm.ID == m.selectedID { cursor = i } } m.table.SetRows(rows) if len(rows) > 0 { m.table.SetCursor(cursor) } } func (m *tuiModel) refreshDetail() { vm, ok := m.selectedVM() if !ok { m.detail.SetContent("No VM selected.\n\nUse c to create a VM.") return } stats := vm.Stats if m.statsID == vm.ID && !m.selectedStats.CollectedAt.IsZero() { stats = m.selectedStats } lines := []string{ fmt.Sprintf("Name: %s", vm.Name), fmt.Sprintf("State: %s", vm.State), fmt.Sprintf("IP: %s", orDash(vm.Runtime.GuestIP)), fmt.Sprintf("DNS: %s", orDash(vm.Runtime.DNSName)), fmt.Sprintf("Image: %s", shortID(vm.ImageID)), "", "Config", fmt.Sprintf(" vCPU: %d", vm.Spec.VCPUCount), fmt.Sprintf(" Memory: %d MiB", vm.Spec.MemoryMiB), fmt.Sprintf(" Overlay: %s", model.FormatSizeBytes(vm.Spec.SystemOverlaySizeByte)), fmt.Sprintf(" Work disk: %s", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), fmt.Sprintf(" NAT: %s", yesNo(vm.Spec.NATEnabled)), "", "Usage", fmt.Sprintf(" Overlay: %s", formatBytes(stats.SystemOverlayBytes)), fmt.Sprintf(" Work disk: %s", formatBytes(stats.WorkDiskBytes)), fmt.Sprintf(" CPU: %.1f%%", stats.CPUPercent), fmt.Sprintf(" RSS: %s", formatBytes(stats.RSSBytes)), fmt.Sprintf(" VSZ: %s", formatBytes(stats.VSZBytes)), } if !stats.CollectedAt.IsZero() { lines = append(lines, fmt.Sprintf(" Updated: %s", stats.CollectedAt.Format(time.RFC3339))) } lines = append(lines, "", "Timestamps", fmt.Sprintf(" Created: %s", vm.CreatedAt.Format(time.RFC3339)), fmt.Sprintf(" Updated: %s", vm.UpdatedAt.Format(time.RFC3339)), fmt.Sprintf(" Touched: %s", vm.LastTouchedAt.Format(time.RFC3339)), ) if vm.Runtime.LastError != "" { lines = append(lines, "", "Last error", " "+vm.Runtime.LastError) } if m.statsErr != "" && m.statsID == vm.ID { lines = append(lines, "", "Stats error", " "+m.statsErr) } m.detail.SetContent(strings.Join(lines, "\n")) } func (m *tuiModel) setStatus(text string, isErr bool) { m.statusText = text m.statusErr = isErr } func (m tuiModel) selectedVM() (model.VMRecord, bool) { for _, vm := range m.vms { if vm.ID == m.selectedID { return vm, true } } return model.VMRecord{}, false } func (m tuiModel) selectedVMByCursor() (model.VMRecord, bool) { cursor := m.table.Cursor() if cursor < 0 || cursor >= len(m.vms) { return model.VMRecord{}, false } return m.vms[cursor], true } func (m *tuiModel) dispatchAction(action actionRequest) tea.Cmd { if action.needsSudo() && !m.sudoValidated { m.pendingAction = &action m.busy = "Authorizing sudo..." return tea.ExecProcess(exec.Command("sudo", "-v"), func(err error) tea.Msg { return sudoValidatedMsg{err: err} }) } m.busy = action.activity() return tea.Batch(m.spinner.Tick, m.runActionCmd(action)) } func (m tuiModel) runActionCmd(action actionRequest) tea.Cmd { switch action.kind { case actionCreate: return createActionCmd(m.layout, action) case actionEdit: return editActionCmd(m.layout, action) case actionStart, actionStop, actionRestart: return lifecycleActionCmd(m.layout, action) case actionDelete: return deleteActionCmd(m.layout, action) case actionSSH: return prepareSSHCmd(m.layout, m.cfg, action) case actionLogs: return prepareLogsCmd(m.layout, action) default: return func() tea.Msg { return actionResultMsg{action: action, err: fmt.Errorf("unsupported action %s", action.kind)} } } } func createActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { return func() tea.Msg { result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.create", action.create) if err != nil { return actionResultMsg{action: action, err: err} } return actionResultMsg{ action: action, focusID: result.VM.ID, status: fmt.Sprintf("created %s", result.VM.Name), refresh: true, } } } func editActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { return func() tea.Msg { result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.set", action.set) if err != nil { return actionResultMsg{action: action, err: err} } return actionResultMsg{ action: action, focusID: result.VM.ID, status: fmt.Sprintf("updated %s", result.VM.Name), refresh: true, } } } func lifecycleActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { method := "" status := "" switch action.kind { case actionStart: method = "vm.start" status = "started" case actionStop: method = "vm.stop" status = "stopped" case actionRestart: method = "vm.restart" status = "restarted" } return func() tea.Msg { result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, method, api.VMRefParams{IDOrName: action.id}) if err != nil { return actionResultMsg{action: action, err: err} } return actionResultMsg{ action: action, focusID: result.VM.ID, status: fmt.Sprintf("%s %s", status, result.VM.Name), refresh: true, } } } func deleteActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { return func() tea.Msg { _, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: action.id}) if err != nil { return actionResultMsg{action: action, err: err} } return actionResultMsg{ action: action, status: fmt.Sprintf("deleted %s", action.name), refresh: true, } } } func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd { return func() tea.Msg { if err := validateSSHPrereqs(cfg); err != nil { return externalPreparedMsg{action: action, err: err} } result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id}) if err != nil { return externalPreparedMsg{action: action, err: err} } args, err := sshCommandArgs(cfg, result.GuestIP, nil) if err != nil { return externalPreparedMsg{action: action, err: err} } return externalPreparedMsg{ action: action, command: exec.Command("ssh", args...), doneStatus: fmt.Sprintf("ssh session ended for %s", result.Name), refresh: true, } } } func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd { return func() tea.Msg { result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id}) if err != nil { return externalPreparedMsg{action: action, err: err} } if result.LogPath == "" { return externalPreparedMsg{action: action, err: errors.New("vm has no log path")} } return externalPreparedMsg{ action: action, command: system.TailCommand(result.LogPath, true), doneStatus: fmt.Sprintf("closed log view for %s", action.name), refresh: false, } } } func fetchDataCmd(layout paths.Layout, focusID string) tea.Cmd { return func() tea.Msg { vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{}) if err != nil { return dataLoadedMsg{err: err, focusID: focusID} } images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{}) if err != nil { return dataLoadedMsg{vms: vms.VMs, err: err, focusID: focusID} } return dataLoadedMsg{vms: vms.VMs, images: images.Images, focusID: focusID} } } func fetchStatsCmd(layout paths.Layout, id string) tea.Cmd { return func() tea.Msg { result, err := rpc.Call[api.VMStatsResult](context.Background(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: id}) if err != nil { return statsLoadedMsg{id: id, err: err} } return statsLoadedMsg{id: id, stats: result.Stats} } } func refreshTickCmd() tea.Cmd { return tea.Tick(tuiRefreshInterval, func(time.Time) tea.Msg { return refreshTickMsg{} }) } func resolveSelectedID(targetID string, vms []model.VMRecord) string { if len(vms) == 0 { return "" } if targetID != "" { for _, vm := range vms { if vm.ID == targetID { return targetID } } } return vms[0].ID } func imageNames(images []model.Image) []string { names := make([]string, 0, len(images)) for _, image := range images { names = append(names, image.Name) } return names } func tuiTableKeyMap() table.KeyMap { return table.KeyMap{ LineUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up", "up")), LineDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down", "down")), PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "half up")), HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "half down")), GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")), GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")), } } func (a actionRequest) needsSudo() bool { switch a.kind { case actionCreate, actionEdit, actionStart, actionStop, actionRestart, actionDelete: return true default: return false } } func (a actionRequest) activity() string { switch a.kind { case actionCreate: return "Creating VM..." case actionEdit: return "Saving VM..." case actionStart: return "Starting VM..." case actionStop: return "Stopping VM..." case actionRestart: return "Restarting VM..." case actionDelete: return "Deleting VM..." case actionSSH: return "Opening SSH..." case actionLogs: return "Opening logs..." default: return "Working..." } } func parsePositiveInt(label, raw string) (int, error) { value, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || value <= 0 { return 0, fmt.Errorf("%s must be a positive integer", label) } return value, nil } func normalizeExecError(err error) error { if err == nil { return nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 130 { return nil } return err } func looksLikeSudoExpiry(err error) bool { if err == nil { return false } text := err.Error() return strings.Contains(text, "sudo") || strings.Contains(text, "password is required") } func formatBytes(bytes int64) string { if bytes <= 0 { return "0" } const ( kib = 1024 mib = 1024 * kib gib = 1024 * mib ) switch { case bytes >= gib: return fmt.Sprintf("%.1fG", float64(bytes)/gib) case bytes >= mib: return fmt.Sprintf("%.1fM", float64(bytes)/mib) case bytes >= kib: return fmt.Sprintf("%.1fK", float64(bytes)/kib) default: return strconv.FormatInt(bytes, 10) + "B" } } func boolToIndex(value bool) int { if value { return 1 } return 0 } func yesNo(value bool) string { if value { return "yes" } return "no" } func isYes(value string) bool { return strings.EqualFold(strings.TrimSpace(value), "yes") } func orDash(value string) string { if strings.TrimSpace(value) == "" { return "-" } return value } func wrapIndex(value, length int) int { if length <= 0 { return 0 } for value < 0 { value += length } return value % length } func maxInt(a, b int) int { if a > b { return a } return b } func minInt(a, b int) int { if a < b { return a } return b }