mirror of
https://github.com/theniceboy/.config.git
synced 2026-07-16 22:01:21 +08:00
3031 lines
95 KiB
Go
3031 lines
95 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const paletteNoDeviceOption = "__no_device__"
|
|
|
|
var paletteModalBorder = lipgloss.Border{
|
|
Top: "─",
|
|
Bottom: "─",
|
|
Left: "│",
|
|
Right: "│",
|
|
TopLeft: "┌",
|
|
TopRight: "┐",
|
|
BottomLeft: "└",
|
|
BottomRight: "┘",
|
|
}
|
|
|
|
var paletteDestroyLauncher = launchPaletteDestroyWithConfirm
|
|
var paletteTmuxRunner = runTmux
|
|
var paletteTmuxOutput = runTmuxOutput
|
|
|
|
type paletteRuntime struct {
|
|
sessionID string
|
|
windowID string
|
|
paneID string
|
|
agentID string
|
|
reg *registry
|
|
record *agentRecord
|
|
startupMessage string
|
|
currentPath string
|
|
currentSessionName string
|
|
currentWindowName string
|
|
currentWindowIndex string
|
|
currentPaneIndex string
|
|
mainRepoRoot string
|
|
startMode paletteMode
|
|
}
|
|
|
|
type paletteModel struct {
|
|
runtime *paletteRuntime
|
|
state paletteUIState
|
|
actions []paletteAction
|
|
openedAt time.Time
|
|
quickSecondaryEscCloses bool
|
|
singlePanelMode bool
|
|
width int
|
|
height int
|
|
result paletteResult
|
|
todo *todoPanelModel
|
|
activity *activityMonitorBT
|
|
devices *devicePanelModel
|
|
status *statusRightPanelModel
|
|
tracker *trackerPanelModel
|
|
goals *goalPanelModel
|
|
agentList *actionListPanel
|
|
opencodeForkList *actionListPanel
|
|
}
|
|
|
|
type paletteStyles struct {
|
|
title lipgloss.Style
|
|
meta lipgloss.Style
|
|
searchBox lipgloss.Style
|
|
searchPrompt lipgloss.Style
|
|
input lipgloss.Style
|
|
inputCursor lipgloss.Style
|
|
item lipgloss.Style
|
|
selectedItem lipgloss.Style
|
|
sectionLabel lipgloss.Style
|
|
selectedLabel lipgloss.Style
|
|
itemTitle lipgloss.Style
|
|
itemSubtitle lipgloss.Style
|
|
selectedSubtle lipgloss.Style
|
|
panelTitle lipgloss.Style
|
|
panelText lipgloss.Style
|
|
muted lipgloss.Style
|
|
footer lipgloss.Style
|
|
keyword lipgloss.Style
|
|
modal lipgloss.Style
|
|
modalTitle lipgloss.Style
|
|
modalBody lipgloss.Style
|
|
modalHint lipgloss.Style
|
|
statusBad lipgloss.Style
|
|
statLabel lipgloss.Style
|
|
statValue lipgloss.Style
|
|
todoCheck lipgloss.Style
|
|
todoCheckDone lipgloss.Style
|
|
panelTextDone lipgloss.Style
|
|
shortcutKey lipgloss.Style
|
|
shortcutText lipgloss.Style
|
|
}
|
|
|
|
type paletteTodoPreviewItem struct {
|
|
Title string
|
|
Done bool
|
|
}
|
|
|
|
type paletteTodoPreviewSection struct {
|
|
Title string
|
|
Lead string
|
|
Items []paletteTodoPreviewItem
|
|
Empty string
|
|
}
|
|
|
|
func runBubbleTeaPalette(args []string) error {
|
|
runtime, err := loadPaletteRuntime(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state := paletteUIState{Mode: runtime.startMode, Message: runtime.startupMessage}
|
|
for {
|
|
model := newPaletteModel(runtime, state)
|
|
finalModel, err := tea.NewProgram(model, tea.WithoutBracketedPaste()).Run()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
final, ok := finalModel.(*paletteModel)
|
|
if !ok {
|
|
return fmt.Errorf("unexpected palette model type")
|
|
}
|
|
state = final.result.State
|
|
switch final.result.Kind {
|
|
case paletteResultClose:
|
|
return nil
|
|
case paletteResultOpenActivityMonitor:
|
|
err := runtime.runActivityMonitor()
|
|
if errors.Is(err, errClosePalette) {
|
|
return nil
|
|
}
|
|
state.Mode = paletteModeList
|
|
state.Message = paletteMessageForError(err)
|
|
continue
|
|
case paletteResultOpenSnippets:
|
|
state.Mode = paletteModeSnippets
|
|
state.Filter = nil
|
|
state.FilterCursor = 0
|
|
state.Selected = 0
|
|
state.Message = ""
|
|
continue
|
|
case paletteResultRunAction:
|
|
reopen, message, err := runtime.execute(final.result)
|
|
if err != nil {
|
|
if reopen {
|
|
state.Mode = paletteModeList
|
|
state.Message = err.Error()
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
if !reopen {
|
|
return nil
|
|
}
|
|
state.Mode = paletteModeList
|
|
state.Message = message
|
|
continue
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
|
|
fs := flag.NewFlagSet("agent palette", flag.ContinueOnError)
|
|
var windowID string
|
|
var sessionID string
|
|
var paneID string
|
|
var agentID string
|
|
var currentPath string
|
|
var currentSessionName string
|
|
var currentWindowName string
|
|
var currentWindowIndex string
|
|
var currentPaneIndex string
|
|
var modeFlag string
|
|
fs.StringVar(&windowID, "window", "", "window id")
|
|
fs.StringVar(&sessionID, "session-id", "", "session id")
|
|
fs.StringVar(&paneID, "pane-id", "", "pane id")
|
|
fs.StringVar(&agentID, "agent-id", "", "agent id")
|
|
fs.StringVar(¤tPath, "path", "", "current pane path")
|
|
fs.StringVar(¤tSessionName, "session-name", "", "current session name")
|
|
fs.StringVar(¤tWindowName, "window-name", "", "current window name")
|
|
fs.StringVar(¤tWindowIndex, "window-index", "", "current window index")
|
|
fs.StringVar(¤tPaneIndex, "pane-index", "", "current pane index")
|
|
fs.StringVar(&modeFlag, "mode", "", "initial panel mode (goals, tracker, todos, activity, status)")
|
|
fs.SetOutput(nil)
|
|
if err := fs.Parse(args); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
runtime := &paletteRuntime{
|
|
sessionID: firstNonEmpty(sessionID, os.Getenv("AGENT_PALETTE_SESSION_ID")),
|
|
windowID: firstNonEmpty(windowID, os.Getenv("AGENT_PALETTE_WINDOW_ID")),
|
|
paneID: firstNonEmpty(paneID, os.Getenv("AGENT_PALETTE_PANE_ID")),
|
|
agentID: firstNonEmpty(agentID, os.Getenv("AGENT_PALETTE_AGENT_ID")),
|
|
currentPath: firstNonEmpty(currentPath, os.Getenv("AGENT_PALETTE_PATH")),
|
|
currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")),
|
|
currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_NAME")),
|
|
currentWindowIndex: firstNonEmpty(currentWindowIndex, os.Getenv("AGENT_PALETTE_WINDOW_INDEX")),
|
|
currentPaneIndex: firstNonEmpty(currentPaneIndex, os.Getenv("AGENT_PALETTE_PANE_INDEX")),
|
|
}
|
|
switch strings.ToLower(modeFlag) {
|
|
case "goals":
|
|
runtime.startMode = paletteModeGoals
|
|
case "tracker":
|
|
runtime.startMode = paletteModeTracker
|
|
case "todos":
|
|
runtime.startMode = paletteModeTodos
|
|
case "activity":
|
|
runtime.startMode = paletteModeActivity
|
|
case "status", "status-right", "bottom-right":
|
|
runtime.startMode = paletteModeStatusRight
|
|
default:
|
|
runtime.startMode = paletteModeList
|
|
}
|
|
logPaletteLaunchIfMalformed(runtime)
|
|
if looksLikeTmuxFormatLiteral(runtime.agentID) {
|
|
runtime.agentID = ""
|
|
}
|
|
if runtime.agentID == "" && runtime.windowID != "" {
|
|
if ctx, err := detectCurrentAgentFromTmux(runtime.windowID); err == nil {
|
|
runtime.agentID = ctx.ID
|
|
}
|
|
} else if runtime.agentID == "" {
|
|
if ctx, err := detectCurrentAgentFromTmux(""); err == nil {
|
|
runtime.agentID = ctx.ID
|
|
}
|
|
}
|
|
if err := runtime.reload(); err != nil {
|
|
return nil, err
|
|
}
|
|
return runtime, nil
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func logPaletteLaunchIfMalformed(runtime *paletteRuntime) {
|
|
if runtime == nil {
|
|
return
|
|
}
|
|
values := []string{
|
|
runtime.sessionID,
|
|
runtime.windowID,
|
|
runtime.paneID,
|
|
runtime.agentID,
|
|
runtime.currentPath,
|
|
runtime.currentSessionName,
|
|
runtime.currentWindowName,
|
|
runtime.currentWindowIndex,
|
|
runtime.currentPaneIndex,
|
|
}
|
|
for _, value := range values {
|
|
if strings.Contains(value, "#{") {
|
|
file, err := os.OpenFile("/tmp/agent-palette-launch.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer file.Close()
|
|
_, _ = fmt.Fprintf(file, "%s window=%q agent=%q path=%q session=%q window_name=%q args=%q\n",
|
|
time.Now().Format(time.RFC3339Nano),
|
|
runtime.windowID,
|
|
runtime.agentID,
|
|
runtime.currentPath,
|
|
runtime.currentSessionName,
|
|
runtime.currentWindowName,
|
|
os.Args,
|
|
)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *paletteRuntime) reload() error {
|
|
reg, err := loadRegistry()
|
|
if err != nil {
|
|
r.startupMessage = fmt.Sprintf("Ignoring malformed registry: %v", err)
|
|
reg = ®istry{Agents: map[string]*agentRecord{}}
|
|
} else {
|
|
r.startupMessage = ""
|
|
}
|
|
r.reg = reg
|
|
r.record = nil
|
|
if looksLikeTmuxFormatLiteral(r.agentID) {
|
|
r.agentID = ""
|
|
}
|
|
if r.agentID != "" {
|
|
r.record = reg.Agents[r.agentID]
|
|
}
|
|
tmuxValue := func(target string, format string) string {
|
|
args := []string{"display-message", "-p"}
|
|
if strings.TrimSpace(target) != "" {
|
|
args = append(args, "-t", strings.TrimSpace(target))
|
|
}
|
|
args = append(args, format)
|
|
out, err := runTmuxOutput(args...)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(out)
|
|
}
|
|
if strings.TrimSpace(r.windowID) == "" {
|
|
r.windowID = tmuxValue("", "#{window_id}")
|
|
}
|
|
if strings.TrimSpace(r.sessionID) == "" {
|
|
r.sessionID = tmuxValue(r.windowID, "#{session_id}")
|
|
}
|
|
if strings.TrimSpace(r.paneID) == "" {
|
|
r.paneID = tmuxValue(r.windowID, "#{pane_id}")
|
|
}
|
|
if strings.TrimSpace(r.currentPath) == "" {
|
|
r.currentPath = tmuxValue(r.windowID, "#{pane_current_path}")
|
|
}
|
|
if strings.TrimSpace(r.currentSessionName) == "" {
|
|
r.currentSessionName = tmuxValue(r.windowID, "#{session_name}")
|
|
}
|
|
if strings.TrimSpace(r.currentWindowName) == "" {
|
|
r.currentWindowName = tmuxValue(r.windowID, "#{window_name}")
|
|
}
|
|
if strings.TrimSpace(r.currentWindowIndex) == "" {
|
|
r.currentWindowIndex = tmuxValue(r.windowID, "#{window_index}")
|
|
}
|
|
if strings.TrimSpace(r.currentPaneIndex) == "" {
|
|
r.currentPaneIndex = tmuxValue(r.windowID, "#{pane_index}")
|
|
}
|
|
if inferredAgentID := detectPaletteAgentIDFromPath(r.currentPath); inferredAgentID != "" {
|
|
if strings.TrimSpace(r.agentID) == "" || r.record == nil {
|
|
r.agentID = inferredAgentID
|
|
}
|
|
if r.record == nil {
|
|
r.record = reg.Agents[inferredAgentID]
|
|
}
|
|
}
|
|
if r.record != nil {
|
|
r.agentID = r.record.ID
|
|
}
|
|
r.mainRepoRoot = detectPaletteMainRepoRoot(r.currentPath, r.record)
|
|
return nil
|
|
}
|
|
|
|
func (r *paletteRuntime) effectiveAgentID() string {
|
|
if r.record != nil && strings.TrimSpace(r.record.ID) != "" {
|
|
return strings.TrimSpace(r.record.ID)
|
|
}
|
|
if inferred := detectPaletteAgentIDFromPath(r.currentPath); inferred != "" {
|
|
return inferred
|
|
}
|
|
if looksLikeTmuxFormatLiteral(r.agentID) {
|
|
return ""
|
|
}
|
|
return sanitizeFeatureName(r.agentID)
|
|
}
|
|
|
|
func (r *paletteRuntime) workspaceForBrowser() string {
|
|
if r.record != nil && r.record.BrowserEnabled {
|
|
if ws := strings.TrimSpace(r.record.WorkspaceRoot); ws != "" {
|
|
return ws
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *paletteRuntime) opencodePaneLocator() string {
|
|
sessionName := strings.TrimSpace(r.currentSessionName)
|
|
windowIndex := strings.TrimSpace(r.currentWindowIndex)
|
|
paneIndex := strings.TrimSpace(r.currentPaneIndex)
|
|
if sessionName == "" || windowIndex == "" || paneIndex == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s:%s.%s", sessionName, windowIndex, paneIndex)
|
|
}
|
|
|
|
func (r *paletteRuntime) currentOpenCodeSessionID() string {
|
|
stateDir := os.Getenv("XDG_STATE_HOME")
|
|
if stateDir == "" {
|
|
home, _ := os.UserHomeDir()
|
|
stateDir = filepath.Join(home, ".local", "state")
|
|
}
|
|
paths := []string{}
|
|
if paneID := strings.TrimSpace(r.paneID); paneID != "" {
|
|
paths = append(paths, filepath.Join(stateDir, "op", "pane_"+sanitizeStateKey(paneID)))
|
|
}
|
|
if locator := r.opencodePaneLocator(); locator != "" {
|
|
paths = append(paths, filepath.Join(stateDir, "op", "loc_"+sanitizeStateKey(locator)))
|
|
}
|
|
for _, path := range paths {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fields := strings.Fields(string(data))
|
|
if len(fields) > 0 && strings.TrimSpace(fields[0]) != "" {
|
|
return strings.TrimSpace(fields[0])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *paletteRuntime) canForkCurrentOpenCode() bool {
|
|
return r.currentOpenCodeSessionID() != ""
|
|
}
|
|
|
|
func (r *paletteRuntime) persistRecord(update func(*agentRecord) error) error {
|
|
if r.record == nil {
|
|
return fmt.Errorf("no agent found for this tmux window")
|
|
}
|
|
if err := update(r.record); err != nil {
|
|
return err
|
|
}
|
|
r.record.UpdatedAt = time.Now()
|
|
r.reg.Agents[r.record.ID] = r.record
|
|
if err := saveRegistry(r.reg); err != nil {
|
|
return err
|
|
}
|
|
return r.reload()
|
|
}
|
|
|
|
func (r *paletteRuntime) buildActions() []paletteAction {
|
|
actions := []paletteAction{
|
|
{
|
|
Section: "Agent",
|
|
Title: "Start agent",
|
|
Subtitle: startAgentSubtitle(r.mainRepoRoot, r.currentPath),
|
|
Keywords: []string{"agent", "start", "new", "feature", "repo"},
|
|
Kind: paletteActionPromptStartAgent,
|
|
RepoRoot: r.mainRepoRoot,
|
|
},
|
|
}
|
|
if strings.TrimSpace(r.agentID) != "" {
|
|
actions = append(actions, paletteAction{
|
|
Section: "Agent",
|
|
Title: "Destroy agent",
|
|
Subtitle: "Delete the workspace and close its tmux window",
|
|
Keywords: []string{"agent", "destroy", "remove", "delete"},
|
|
Kind: paletteActionConfirmDestroy,
|
|
})
|
|
}
|
|
if r.canForkCurrentOpenCode() {
|
|
actions = append(actions, paletteAction{
|
|
Section: "Opencode",
|
|
Title: "Fork current opencode",
|
|
Subtitle: "Open this session in another tmux pane or window",
|
|
Keywords: []string{"opencode", "op", "fork", "session", "pane", "window"},
|
|
Kind: paletteActionOpenOpencodeFork,
|
|
})
|
|
}
|
|
actions = append(actions,
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Scratch terminal",
|
|
Subtitle: "Open the persistent scratch shell",
|
|
Keywords: []string{"scratch", "terminal", "shell", "popup", "tmux"},
|
|
Kind: paletteActionOpenScratch,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Goals",
|
|
Subtitle: "Goals, threads and todos",
|
|
Keywords: []string{"goal", "goals", "thread", "threads", "tracker", "tasks", "status"},
|
|
Kind: paletteActionOpenGoals,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Tracker",
|
|
Subtitle: "Live tasks and completion status",
|
|
Keywords: []string{"tracker", "tasks", "activity", "status"},
|
|
Kind: paletteActionOpenTracker,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Activity Monitor",
|
|
Subtitle: "View CPU, memory and process usage",
|
|
Keywords: []string{"activity", "monitor", "cpu", "memory", "processes", "top", "ps"},
|
|
Kind: paletteActionOpenActivityMonitor,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Paste snippet",
|
|
Subtitle: "Search and paste a snippet into the current pane",
|
|
Keywords: []string{"snippet", "paste", "template", "text", "insert"},
|
|
Kind: paletteActionOpenSnippets,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Todos",
|
|
Subtitle: "Manage window/global todos",
|
|
Keywords: []string{"todo", "task", "checklist", "manage"},
|
|
Kind: paletteActionOpenTodos,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Edit devices",
|
|
Subtitle: "Add or remove global launch devices",
|
|
Keywords: []string{"devices", "device", "edit", "manage", "web-server"},
|
|
Kind: paletteActionOpenDevices,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Reload tmux config",
|
|
Subtitle: "Source ~/.config/.tmux.conf",
|
|
Keywords: []string{"tmux", "reload", "config", "source", "refresh"},
|
|
Kind: paletteActionReloadTmuxConfig,
|
|
},
|
|
paletteAction{
|
|
Section: "System",
|
|
Title: "Bottom-right status",
|
|
Subtitle: "Open control center for tmux right-side status modules",
|
|
Keywords: []string{"tmux", "status", "status-right", "bottom-right", "control", "center", "istat", "cpu", "network", "memory", "todos", "host", "flash"},
|
|
Kind: paletteActionOpenStatusRight,
|
|
},
|
|
)
|
|
if strings.TrimSpace(r.agentID) == "" {
|
|
return actions
|
|
}
|
|
if r.record == nil {
|
|
return actions
|
|
}
|
|
if r.record.BrowserEnabled {
|
|
actions = append(actions,
|
|
paletteAction{
|
|
Section: "Browser",
|
|
Title: "Copy console logs",
|
|
Subtitle: "Copy browser dev console to clipboard",
|
|
Keywords: []string{"browser", "console", "logs", "copy", "clipboard", "devtools"},
|
|
Kind: paletteActionBrowserCopyLogs,
|
|
},
|
|
paletteAction{
|
|
Section: "Browser",
|
|
Title: "Paste console logs",
|
|
Subtitle: "Read browser dev console buffer and paste into pane",
|
|
Keywords: []string{"browser", "console", "logs", "paste", "devtools", "chrome"},
|
|
Kind: paletteActionBrowserLogs,
|
|
},
|
|
paletteAction{
|
|
Section: "Browser",
|
|
Title: "Clear console logs",
|
|
Subtitle: "Clear the browser dev console buffer",
|
|
Keywords: []string{"browser", "console", "logs", "clear", "devtools"},
|
|
Kind: paletteActionBrowserClearLogs,
|
|
},
|
|
paletteAction{
|
|
Section: "Browser",
|
|
Title: "Hot reload",
|
|
Subtitle: "Analyze + flutter reload",
|
|
Keywords: []string{"browser", "reload", "hot", "flutter", "refresh"},
|
|
Kind: paletteActionBrowserReload,
|
|
},
|
|
)
|
|
}
|
|
return actions
|
|
}
|
|
|
|
func (r *paletteRuntime) runAgentStart(repoRoot, feature, device string, keepWorktree, pull bool) error {
|
|
repoRoot = r.resolveStartRepoRoot(repoRoot)
|
|
feature = sanitizeFeatureName(feature)
|
|
if !isPaletteNoDeviceOption(device) {
|
|
device = normalizeManagedDeviceID(device)
|
|
}
|
|
if repoRoot == "" {
|
|
return fmt.Errorf("main repo not found")
|
|
}
|
|
if feature == "" {
|
|
return fmt.Errorf("feature name is required")
|
|
}
|
|
agentBin := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "bin", "agent")
|
|
args := buildAgentStartArgs(feature, device, keepWorktree, pull)
|
|
cmd := exec.Command(agentBin, args...)
|
|
cmd.Dir = repoRoot
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Env = os.Environ()
|
|
if strings.TrimSpace(r.windowID) != "" {
|
|
cmd.Env = append(cmd.Env, "AGENT_TMUX_TARGET_WINDOW="+strings.TrimSpace(r.windowID))
|
|
}
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
message := strings.TrimSpace(string(output))
|
|
if message != "" {
|
|
return fmt.Errorf("%s", message)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func launchPaletteDestroy(agentID string) error {
|
|
return launchPaletteDestroyWithConfirm(agentID, "")
|
|
}
|
|
|
|
func launchPaletteDestroyWithConfirm(agentID string, confirmText string) error {
|
|
agentID = strings.TrimSpace(agentID)
|
|
if agentID == "" {
|
|
return fmt.Errorf("no agent found for this tmux window")
|
|
}
|
|
if _, err := loadDestroyTarget(agentID); err != nil {
|
|
return err
|
|
}
|
|
extraArgs := ""
|
|
if strings.TrimSpace(confirmText) != "" {
|
|
extraArgs = fmt.Sprintf(" --confirm %s", shellQuote(strings.TrimSpace(confirmText)))
|
|
}
|
|
if os.Getenv("TMUX") != "" {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return runTmux("run-shell", "-b", fmt.Sprintf("%s destroy --id %s%s", shellQuote(exe), shellQuote(agentID), extraArgs))
|
|
}
|
|
args := []string{"destroy", "--id", agentID}
|
|
if strings.TrimSpace(confirmText) != "" {
|
|
args = append(args, "--confirm", strings.TrimSpace(confirmText))
|
|
}
|
|
return spawnDetachedAgentCommand(args...)
|
|
}
|
|
|
|
func buildAgentStartArgs(feature, device string, keepWorktree, pull bool) []string {
|
|
args := []string{"start"}
|
|
if keepWorktree {
|
|
args = append(args, "--keep-worktree")
|
|
}
|
|
if pull {
|
|
args = append(args, "--pull")
|
|
}
|
|
if isPaletteNoDeviceOption(device) {
|
|
args = append(args, "--no-device")
|
|
} else if device != "" {
|
|
args = append(args, "-d", device)
|
|
}
|
|
return append(args, feature)
|
|
}
|
|
|
|
func isPaletteNoDeviceOption(device string) bool {
|
|
return strings.TrimSpace(device) == paletteNoDeviceOption
|
|
}
|
|
|
|
func startAgentPromptDevices(repoRoot string) ([]string, int) {
|
|
repoRoot = strings.TrimSpace(repoRoot)
|
|
devices := loadManagedDevices()
|
|
if len(devices) == 0 {
|
|
devices = []string{defaultManagedDeviceID}
|
|
}
|
|
isFlutter := repoRoot != "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml"))
|
|
if isFlutter {
|
|
devices = append([]string{paletteNoDeviceOption}, devices...)
|
|
}
|
|
repoCfg, _ := loadRepoConfigOrDefault(repoRoot)
|
|
preferred := resolveDefaultDevice(repoCfg)
|
|
if isPaletteNoDeviceOption(preferred) || preferred == "" {
|
|
preferred = paletteNoDeviceOption
|
|
}
|
|
for i, d := range devices {
|
|
if d == preferred {
|
|
return devices, i
|
|
}
|
|
}
|
|
if isFlutter {
|
|
return devices, 1
|
|
}
|
|
return devices, 0
|
|
}
|
|
|
|
func (r *paletteRuntime) resolveStartRepoRoot(repoRoot string) string {
|
|
tryPaths := []string{
|
|
strings.TrimSpace(repoRoot),
|
|
strings.TrimSpace(r.mainRepoRoot),
|
|
strings.TrimSpace(r.currentPath),
|
|
}
|
|
if strings.TrimSpace(r.windowID) != "" {
|
|
if out, err := runTmuxOutput("display-message", "-p", "-t", strings.TrimSpace(r.windowID), "#{pane_current_path}"); err == nil {
|
|
tryPaths = append(tryPaths, strings.TrimSpace(out))
|
|
}
|
|
}
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
tryPaths = append(tryPaths, strings.TrimSpace(cwd))
|
|
}
|
|
for _, path := range tryPaths {
|
|
if resolved := detectPaletteMainRepoRoot(path, r.record); strings.TrimSpace(resolved) != "" {
|
|
return strings.TrimSpace(resolved)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *paletteRuntime) startSourceBranch(repoRoot string) string {
|
|
repoRoot = r.resolveStartRepoRoot(repoRoot)
|
|
if repoRoot == "" {
|
|
return ""
|
|
}
|
|
repoCfg, err := loadRepoConfigOrDefault(repoRoot)
|
|
if err != nil {
|
|
return detectDefaultBaseBranch(repoRoot)
|
|
}
|
|
return resolveStartSourceBranch(repoRoot, repoCfg)
|
|
}
|
|
|
|
func (r *paletteRuntime) canStartAgent(repoRoot string) bool {
|
|
return strings.TrimSpace(r.resolveStartRepoRoot(repoRoot)) != ""
|
|
}
|
|
|
|
func (r *paletteRuntime) runActivityMonitor() error {
|
|
return runBubbleTeaActivityMonitor(r.windowID)
|
|
}
|
|
|
|
func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
|
|
action := result.Action
|
|
text := strings.TrimSpace(result.Input)
|
|
switch action.Kind {
|
|
case paletteActionPromptStartAgent:
|
|
if err := r.runAgentStart(action.RepoRoot, text, result.Device, result.KeepWorktree, result.Pull); err != nil {
|
|
return true, "", err
|
|
}
|
|
return false, "", nil
|
|
case paletteActionConfirmDestroy:
|
|
agentID := r.effectiveAgentID()
|
|
if agentID == "" {
|
|
return true, "", fmt.Errorf("no agent found for this tmux window")
|
|
}
|
|
confirmText := ""
|
|
if result.State.ConfirmRequiresText {
|
|
confirmText = strings.TrimSpace(result.Input)
|
|
}
|
|
err := paletteDestroyLauncher(agentID, confirmText)
|
|
if err != nil {
|
|
return true, "", err
|
|
}
|
|
return false, "", nil
|
|
case paletteActionReloadTmuxConfig:
|
|
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
|
|
case paletteActionOpenScratch:
|
|
return false, "", launchScratchTerminalFromPalette(r.currentPath)
|
|
case paletteActionForkOpencodeHorizontal:
|
|
if err := r.launchOpenCodeFork("horizontal"); err != nil {
|
|
return true, "", err
|
|
}
|
|
return false, "", nil
|
|
case paletteActionForkOpencodeVertical:
|
|
if err := r.launchOpenCodeFork("vertical"); err != nil {
|
|
return true, "", err
|
|
}
|
|
return false, "", nil
|
|
case paletteActionForkOpencodeWindow:
|
|
if err := r.launchOpenCodeFork("window"); err != nil {
|
|
return true, "", err
|
|
}
|
|
return false, "", nil
|
|
case paletteActionBrowserClearLogs:
|
|
workspace := r.workspaceForBrowser()
|
|
if workspace == "" {
|
|
return true, "", fmt.Errorf("no browser-enabled agent in this window")
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
cmd := exec.Command(exe, "browser", "clear-logs", "--workspace", workspace)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return true, "", fmt.Errorf("%s", firstNonEmpty(strings.TrimSpace(string(output)), err.Error()))
|
|
}
|
|
return false, "Console logs cleared", nil
|
|
case paletteActionBrowserReload:
|
|
workspace := r.workspaceForBrowser()
|
|
if workspace == "" {
|
|
return true, "", fmt.Errorf("no browser-enabled agent in this window")
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
cmd := exec.Command(exe, "hot-reload", "--workspace", workspace)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return true, "", err
|
|
}
|
|
return false, "", nil
|
|
default:
|
|
return false, "", nil
|
|
}
|
|
}
|
|
|
|
func launchScratchTerminalFromPalette(currentPath string) error {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd := fmt.Sprintf("sleep 0.1; %s tmux scratch --path %s", shellQuote(exe), shellQuote(currentPath))
|
|
return runTmux("run-shell", "-b", cmd)
|
|
}
|
|
|
|
func (r *paletteRuntime) launchOpenCodeFork(kind string) error {
|
|
sessionID := r.currentOpenCodeSessionID()
|
|
if sessionID == "" {
|
|
return fmt.Errorf("no opencode session found for the current pane")
|
|
}
|
|
path := strings.TrimSpace(r.currentPath)
|
|
target := firstNonEmpty(r.paneID, r.windowID)
|
|
var args []string
|
|
switch kind {
|
|
case "horizontal":
|
|
if target == "" {
|
|
return fmt.Errorf("current tmux pane is unknown")
|
|
}
|
|
args = []string{"split-window", "-h", "-P", "-F", "#{pane_id}", "-t", target}
|
|
case "vertical":
|
|
if target == "" {
|
|
return fmt.Errorf("current tmux pane is unknown")
|
|
}
|
|
args = []string{"split-window", "-v", "-P", "-F", "#{pane_id}", "-t", target}
|
|
case "window":
|
|
forkName := nextForkWindowName(r.currentWindowName, r.sessionID)
|
|
args = []string{"new-window", "-a", "-P", "-F", "#{pane_id}", "-n", forkName}
|
|
if strings.TrimSpace(r.windowID) != "" {
|
|
args = append(args, "-t", strings.TrimSpace(r.windowID))
|
|
} else if strings.TrimSpace(r.sessionID) != "" {
|
|
args = append(args, "-t", strings.TrimSpace(r.sessionID))
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown fork target")
|
|
}
|
|
if path != "" {
|
|
args = append(args, "-c", path)
|
|
}
|
|
out, err := paletteTmuxOutput(args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
paneID := strings.TrimSpace(out)
|
|
if paneID == "" {
|
|
return fmt.Errorf("tmux did not return a new pane")
|
|
}
|
|
if err := waitForShellPane(paneID, 2*time.Second); err != nil {
|
|
return err
|
|
}
|
|
if err := paletteTmuxRunner("send-keys", "-t", paneID, "-l", "op -s "+sessionID); err != nil {
|
|
return err
|
|
}
|
|
return paletteTmuxRunner("send-keys", "-t", paneID, "Enter")
|
|
}
|
|
|
|
func nextForkWindowName(sourceName, sessionID string) string {
|
|
sourceName = strings.TrimSpace(sourceName)
|
|
if sourceName == "" {
|
|
sourceName = "op"
|
|
}
|
|
base := sourceName
|
|
if idx := strings.LastIndex(sourceName, "-"); idx > 0 {
|
|
suffix := sourceName[idx+1:]
|
|
if _, err := strconv.Atoi(suffix); err == nil {
|
|
base = sourceName[:idx]
|
|
}
|
|
}
|
|
|
|
maxN := 0
|
|
if session := strings.TrimSpace(sessionID); session != "" {
|
|
out, err := paletteTmuxOutput("list-windows", "-t", session, "-F", "#{window_name}")
|
|
if err == nil {
|
|
prefix := base + "-"
|
|
for _, name := range strings.Split(out, "\n") {
|
|
name = strings.TrimSpace(name)
|
|
if strings.HasPrefix(name, prefix) {
|
|
if n, err := strconv.Atoi(strings.TrimPrefix(name, prefix)); err == nil && n > maxN {
|
|
maxN = n
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s-%d", base, maxN+1)
|
|
}
|
|
|
|
func waitForShellPane(paneID string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
out, err := paletteTmuxOutput("display-message", "-p", "-t", paneID, "#{pane_current_command}")
|
|
if err == nil {
|
|
switch strings.TrimSpace(out) {
|
|
case "zsh", "bash", "sh", "fish":
|
|
return nil
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func statusRightModuleLabel(module string) string {
|
|
switch module {
|
|
case statusRightModuleCPU:
|
|
return "CPU"
|
|
case statusRightModuleNetwork:
|
|
return "Network"
|
|
case statusRightModuleMemory:
|
|
return "Tmux Pane Memory"
|
|
case statusRightModuleWindowMemory:
|
|
return "Tmux Window Memory"
|
|
case statusRightModuleSessionMemory:
|
|
return "Tmux Session Memory"
|
|
case statusRightModuleTotalMemory:
|
|
return "Tmux Total Memory"
|
|
case statusRightModuleScratch:
|
|
return "Scratch"
|
|
case statusRightModuleFlashMoe:
|
|
return "Flash-MoE"
|
|
case statusRightModuleHost:
|
|
return "Host"
|
|
default:
|
|
return module
|
|
}
|
|
}
|
|
|
|
func statusRightModuleDescription(module string) string {
|
|
switch module {
|
|
case statusRightModuleCPU:
|
|
return "CPU usage"
|
|
case statusRightModuleNetwork:
|
|
return "network throughput"
|
|
case statusRightModuleMemory:
|
|
return "tmux pane memory"
|
|
case statusRightModuleWindowMemory:
|
|
return "tmux window memory"
|
|
case statusRightModuleSessionMemory:
|
|
return "tmux session memory"
|
|
case statusRightModuleTotalMemory:
|
|
return "total tmux memory"
|
|
case statusRightModuleScratch:
|
|
return "hidden scratch terminal bell"
|
|
case statusRightModuleFlashMoe:
|
|
return "Flash-MoE status"
|
|
case statusRightModuleHost:
|
|
return "hostname"
|
|
default:
|
|
return module
|
|
}
|
|
}
|
|
|
|
func togglePaletteStatusRightModule(module string) error {
|
|
if err := toggleStatusRightModule(module); err != nil {
|
|
return err
|
|
}
|
|
return paletteTmuxRunner("refresh-client", "-S")
|
|
}
|
|
|
|
func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteModel {
|
|
if state.Mode == 0 {
|
|
state.Mode = paletteModeList
|
|
}
|
|
state.FilterCursor = clampInt(state.FilterCursor, 0, len(state.Filter))
|
|
state.PromptCursor = clampInt(state.PromptCursor, 0, len(state.PromptText))
|
|
if len(state.PromptDevices) > 0 {
|
|
state.PromptDeviceIndex = clampInt(state.PromptDeviceIndex, 0, len(state.PromptDevices)-1)
|
|
}
|
|
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now(), singlePanelMode: runtime.startMode != paletteModeList}
|
|
if state.Mode == paletteModeTodos {
|
|
_ = model.openTodosPanel()
|
|
}
|
|
if state.Mode == paletteModeActivity {
|
|
_, _ = model.openActivityPanel()
|
|
}
|
|
if state.Mode == paletteModeDevices {
|
|
model.openDevicesPanel()
|
|
}
|
|
if state.Mode == paletteModeStatusRight {
|
|
model.openStatusRightPanel()
|
|
}
|
|
if state.Mode == paletteModeTracker {
|
|
_, _ = model.openTrackerPanel()
|
|
}
|
|
if state.Mode == paletteModeGoals {
|
|
_, _ = model.openGoalsPanel()
|
|
}
|
|
return model
|
|
}
|
|
|
|
func (m *paletteModel) Init() tea.Cmd {
|
|
if m.goals != nil {
|
|
return goalPanelTickCmd()
|
|
}
|
|
if m.tracker != nil {
|
|
return trackerPanelTickCmd()
|
|
}
|
|
if m.activity != nil {
|
|
return activityTickCmd()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *paletteModel) noteSecondaryPageOpen() {
|
|
m.quickSecondaryEscCloses = time.Since(m.openedAt) <= 800*time.Millisecond
|
|
}
|
|
|
|
func (m *paletteModel) closePalette() (tea.Model, tea.Cmd) {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
|
|
func (m *paletteModel) openTodosPanel() error {
|
|
m.noteSecondaryPageOpen()
|
|
sessionID, windowID := getCurrentTmuxScopeInfo()
|
|
if m.todo == nil {
|
|
panel, err := newTodoPanelModel(sessionID, windowID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.todo = panel
|
|
} else {
|
|
m.todo.sessionID = strings.TrimSpace(sessionID)
|
|
m.todo.windowID = strings.TrimSpace(windowID)
|
|
m.todo.reloadEntries()
|
|
m.todo.clampSelections()
|
|
m.todo.setFocusedPane(todoPanelPaneWindow)
|
|
m.todo.mode = todoPanelModeList
|
|
}
|
|
m.todo.showAltHints = false
|
|
m.state.Mode = paletteModeTodos
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
return nil
|
|
}
|
|
|
|
func (m *paletteModel) openSnippetsPanel() {
|
|
m.noteSecondaryPageOpen()
|
|
m.state.Mode = paletteModeSnippets
|
|
m.state.Filter = nil
|
|
m.state.FilterCursor = 0
|
|
m.state.Selected = 0
|
|
m.state.SnippetOffset = 0
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
}
|
|
|
|
func (m *paletteModel) openActivityPanel() (tea.Cmd, error) {
|
|
m.noteSecondaryPageOpen()
|
|
if m.activity == nil {
|
|
m.activity = newActivityMonitorModel(m.runtime.windowID, true)
|
|
} else {
|
|
m.activity.windowID = strings.TrimSpace(m.runtime.windowID)
|
|
m.activity.requestBack = false
|
|
m.activity.requestClose = false
|
|
}
|
|
m.activity.width = m.width
|
|
m.activity.height = m.height
|
|
m.activity.showAltHints = false
|
|
m.state.Mode = paletteModeActivity
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
if !m.activity.refreshInFlight {
|
|
return tea.Batch(
|
|
activityRequestRefreshBT(true, m.activity.refreshedAt.IsZero(), m.activity),
|
|
activityTickCmd(),
|
|
), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *paletteModel) openDevicesPanel() {
|
|
m.noteSecondaryPageOpen()
|
|
if m.devices == nil {
|
|
m.devices = newDevicePanelModel()
|
|
} else {
|
|
m.devices.reload()
|
|
m.devices.mode = devicePanelModeList
|
|
m.devices.requestBack = false
|
|
}
|
|
m.devices.showAltHints = false
|
|
m.state.Mode = paletteModeDevices
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
}
|
|
|
|
func (m *paletteModel) openStatusRightPanel() {
|
|
m.noteSecondaryPageOpen()
|
|
if m.status == nil {
|
|
m.status = newStatusRightPanelModel()
|
|
} else {
|
|
m.status.reload()
|
|
m.status.requestBack = false
|
|
}
|
|
m.status.showAltHints = false
|
|
m.state.Mode = paletteModeStatusRight
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
}
|
|
|
|
func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
|
|
m.noteSecondaryPageOpen()
|
|
if m.tracker == nil {
|
|
m.tracker = newTrackerPanelModel(m.runtime)
|
|
} else {
|
|
m.tracker.runtime = m.runtime
|
|
m.tracker.requestBack = false
|
|
m.tracker.requestClose = false
|
|
}
|
|
m.tracker.width = m.width
|
|
m.tracker.height = m.height
|
|
m.tracker.showAltHints = false
|
|
m.state.Mode = paletteModeTracker
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
return m.tracker.activate(), nil
|
|
}
|
|
|
|
func (m *paletteModel) openGoalsPanel() (tea.Cmd, error) {
|
|
m.noteSecondaryPageOpen()
|
|
if m.goals == nil {
|
|
m.goals = newGoalPanelModel(m.runtime)
|
|
} else {
|
|
m.goals.runtime = m.runtime
|
|
m.goals.requestBack = false
|
|
m.goals.requestClose = false
|
|
}
|
|
m.goals.width = m.width
|
|
m.goals.height = m.height
|
|
m.goals.showAltHints = false
|
|
m.state.Mode = paletteModeGoals
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
return m.goals.activate(), nil
|
|
}
|
|
|
|
func agentPanelActions() []paletteAction {
|
|
return []paletteAction{
|
|
{Section: "Browser", Title: "Hot Reload", Subtitle: "Analyze + flutter reload", Keywords: []string{"reload", "hot", "flutter", "analyze"}, Kind: paletteActionBrowserReload},
|
|
{Section: "Browser", Title: "Copy Logs", Subtitle: "Copy browser console to clipboard", Keywords: []string{"copy", "logs", "console", "clipboard"}, Kind: paletteActionBrowserCopyLogs},
|
|
{Section: "Browser", Title: "Paste Logs", Subtitle: "Paste browser console into pane", Keywords: []string{"paste", "logs", "console", "pane"}, Kind: paletteActionBrowserLogs},
|
|
{Section: "Browser", Title: "Clear Logs", Subtitle: "Clear browser console buffer", Keywords: []string{"clear", "logs", "console", "buffer"}, Kind: paletteActionBrowserClearLogs},
|
|
}
|
|
}
|
|
|
|
func agentPanelHotKeys() map[string]int {
|
|
return map[string]int{
|
|
"alt+r": 0,
|
|
"alt+y": 1,
|
|
"alt+p": 2,
|
|
"alt+c": 3,
|
|
}
|
|
}
|
|
|
|
func opencodeForkPanelActions() []paletteAction {
|
|
return []paletteAction{
|
|
{Section: "Opencode", Title: "Fork in horizontal pane", Subtitle: "Split right and run op -s session_id", Keywords: []string{"opencode", "fork", "horizontal", "right", "split"}, Kind: paletteActionForkOpencodeHorizontal},
|
|
{Section: "Opencode", Title: "Fork in vertical pane", Subtitle: "Split below and run op -s session_id", Keywords: []string{"opencode", "fork", "vertical", "below", "split"}, Kind: paletteActionForkOpencodeVertical},
|
|
{Section: "Opencode", Title: "Fork in new window", Subtitle: "Create a tmux window and run op -s session_id", Keywords: []string{"opencode", "fork", "window", "new"}, Kind: paletteActionForkOpencodeWindow},
|
|
}
|
|
}
|
|
|
|
func opencodeForkPanelHotKeys() map[string]int {
|
|
return map[string]int{
|
|
"alt+h": 0,
|
|
"alt+v": 1,
|
|
"alt+w": 2,
|
|
}
|
|
}
|
|
|
|
func (m *paletteModel) openAgentPanel() {
|
|
m.noteSecondaryPageOpen()
|
|
m.agentList = newActionListPanel(agentPanelActions(), agentPanelHotKeys())
|
|
m.state.Mode = paletteModeAgent
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
}
|
|
|
|
func (m *paletteModel) openOpencodeForkPanel() {
|
|
m.noteSecondaryPageOpen()
|
|
m.opencodeForkList = newActionListPanel(opencodeForkPanelActions(), opencodeForkPanelHotKeys())
|
|
m.state.Mode = paletteModeOpencodeFork
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
}
|
|
|
|
func (m *paletteModel) updateAgentPanel(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" || key == "ctrl+c" || key == "alt+n" {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = ""
|
|
return m, nil
|
|
}
|
|
if m.agentList == nil {
|
|
m.agentList = newActionListPanel(agentPanelActions(), agentPanelHotKeys())
|
|
}
|
|
action, consumed := m.agentList.handleKey(key)
|
|
if consumed && action != nil {
|
|
return m.selectAction(*action)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) updateOpencodeForkPanel(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" || key == "ctrl+c" || key == "alt+n" {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = ""
|
|
return m, nil
|
|
}
|
|
if m.opencodeForkList == nil {
|
|
m.opencodeForkList = newActionListPanel(opencodeForkPanelActions(), opencodeForkPanelHotKeys())
|
|
}
|
|
action, consumed := m.opencodeForkList.handleKey(key)
|
|
if consumed && action != nil {
|
|
return m.selectAction(*action)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) agentPanelCopyLogs() (tea.Model, tea.Cmd) {
|
|
workspace := m.runtime.workspaceForBrowser()
|
|
if workspace == "" {
|
|
m.state.Message = "No browser-enabled agent"
|
|
return m, nil
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
cmd := exec.Command(exe, "browser", "logs", "--workspace", workspace, "--tail", "50", "--keep")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
m.state.Message = firstNonEmpty(strings.TrimSpace(string(output)), err.Error())
|
|
return m, nil
|
|
}
|
|
text := strings.TrimSpace(string(output))
|
|
if text == "" {
|
|
m.state.Message = "No console output captured"
|
|
return m, nil
|
|
}
|
|
clip := exec.Command("pbcopy")
|
|
clip.Stdin = strings.NewReader(text)
|
|
if err := clip.Run(); err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
|
|
func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
if m.todo != nil {
|
|
m.todo.width = msg.Width
|
|
m.todo.height = msg.Height
|
|
}
|
|
if m.activity != nil {
|
|
m.activity.width = msg.Width
|
|
m.activity.height = msg.Height
|
|
}
|
|
if m.tracker != nil {
|
|
m.tracker.width = msg.Width
|
|
m.tracker.height = msg.Height
|
|
}
|
|
if m.goals != nil {
|
|
m.goals.width = msg.Width
|
|
m.goals.height = msg.Height
|
|
}
|
|
if m.status != nil {
|
|
m.status.width = msg.Width
|
|
m.status.height = msg.Height
|
|
}
|
|
case tea.KeyMsg:
|
|
if msg.Paste {
|
|
if m.state.Mode == paletteModePrompt {
|
|
pasted := strings.ReplaceAll(string(msg.Runes), "\n", " ")
|
|
runes := []rune(pasted)
|
|
m.state.PromptText = append(m.state.PromptText[:m.state.PromptCursor], append(runes, m.state.PromptText[m.state.PromptCursor:]...)...)
|
|
m.state.PromptCursor += len(runes)
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker && m.state.Mode != paletteModeGoals && m.state.Mode != paletteModeAgent && m.state.Mode != paletteModeOpencodeFork {
|
|
if isAltFooterToggleKey(msg) {
|
|
m.state.ShowAltHints = !m.state.ShowAltHints
|
|
return m, nil
|
|
}
|
|
m.state.ShowAltHints = false
|
|
}
|
|
key := msg.String()
|
|
if key == "alt+s" {
|
|
if time.Since(m.openedAt) < 250*time.Millisecond {
|
|
return m, nil
|
|
}
|
|
return m.closePalette()
|
|
}
|
|
if key == "esc" && m.quickSecondaryEscCloses {
|
|
switch m.state.Mode {
|
|
case paletteModeTodos:
|
|
if m.todo != nil && m.todo.mode == todoPanelModeList {
|
|
return m.closePalette()
|
|
}
|
|
case paletteModeActivity:
|
|
return m.closePalette()
|
|
case paletteModeDevices:
|
|
if m.devices != nil && m.devices.mode == devicePanelModeList {
|
|
return m.closePalette()
|
|
}
|
|
case paletteModeStatusRight:
|
|
return m.closePalette()
|
|
case paletteModeTracker:
|
|
return m.closePalette()
|
|
case paletteModeGoals:
|
|
if m.goals != nil && m.goals.mode == goalModeList {
|
|
return m.closePalette()
|
|
}
|
|
case paletteModeSnippets:
|
|
return m.closePalette()
|
|
case paletteModeOpencodeFork:
|
|
return m.closePalette()
|
|
}
|
|
}
|
|
if m.state.Mode == paletteModeAgent {
|
|
return m.updateAgentPanel(key)
|
|
}
|
|
if m.state.Mode == paletteModeOpencodeFork {
|
|
return m.updateOpencodeForkPanel(key)
|
|
}
|
|
if m.state.Mode == paletteModeActivity {
|
|
if m.activity == nil {
|
|
cmd, err := m.openActivityPanel()
|
|
if err != nil {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
model, cmd := m.activity.Update(msg)
|
|
if updated, ok := model.(*activityMonitorBT); ok {
|
|
m.activity = updated
|
|
}
|
|
if m.activity.requestClose {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.activity.requestBack {
|
|
m.activity.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.activity.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeTodos {
|
|
if key == "esc" && m.todo != nil && m.todo.mode == todoPanelModeList {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.todo.currentStatus()
|
|
return m, nil
|
|
}
|
|
if m.todo == nil {
|
|
if err := m.openTodosPanel(); err != nil {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
}
|
|
model, cmd := m.todo.Update(msg)
|
|
if updated, ok := model.(*todoPanelModel); ok {
|
|
m.todo = updated
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeDevices {
|
|
if m.devices == nil {
|
|
m.openDevicesPanel()
|
|
}
|
|
model, cmd := m.devices.Update(msg)
|
|
if updated, ok := model.(*devicePanelModel); ok {
|
|
m.devices = updated
|
|
}
|
|
if m.devices.requestBack {
|
|
m.devices.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.devices.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeStatusRight {
|
|
if m.status == nil {
|
|
m.openStatusRightPanel()
|
|
}
|
|
model, cmd := m.status.Update(msg)
|
|
if updated, ok := model.(*statusRightPanelModel); ok {
|
|
m.status = updated
|
|
}
|
|
if m.status.requestBack {
|
|
m.status.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.status.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeTracker {
|
|
if m.tracker == nil {
|
|
cmd, err := m.openTrackerPanel()
|
|
if err != nil {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
model, cmd := m.tracker.Update(msg)
|
|
if updated, ok := model.(*trackerPanelModel); ok {
|
|
m.tracker = updated
|
|
}
|
|
if m.tracker.requestClose {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.tracker.requestBack {
|
|
if m.singlePanelMode {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
m.tracker.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.tracker.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeGoals {
|
|
if m.goals == nil {
|
|
cmd, err := m.openGoalsPanel()
|
|
if err != nil {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
model, cmd := m.goals.Update(msg)
|
|
if updated, ok := model.(*goalPanelModel); ok {
|
|
m.goals = updated
|
|
}
|
|
if m.goals.requestClose {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.goals.requestBack {
|
|
if m.singlePanelMode {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
m.goals.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.goals.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
switch m.state.Mode {
|
|
case paletteModePrompt:
|
|
return m.updatePrompt(key)
|
|
case paletteModeConfirmDestroy:
|
|
return m.updateConfirm(key)
|
|
case paletteModeSnippets:
|
|
return m.updateSnippets(key)
|
|
case paletteModeSnippetVars:
|
|
return m.updateSnippetVars(key)
|
|
default:
|
|
return m.updateList(key)
|
|
}
|
|
}
|
|
if m.state.Mode == paletteModeActivity && m.activity != nil {
|
|
model, cmd := m.activity.Update(msg)
|
|
if updated, ok := model.(*activityMonitorBT); ok {
|
|
m.activity = updated
|
|
}
|
|
if m.activity.requestClose {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.activity.requestBack {
|
|
m.activity.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.activity.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeTodos && m.todo != nil {
|
|
model, cmd := m.todo.Update(msg)
|
|
if updated, ok := model.(*todoPanelModel); ok {
|
|
m.todo = updated
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeDevices && m.devices != nil {
|
|
model, cmd := m.devices.Update(msg)
|
|
if updated, ok := model.(*devicePanelModel); ok {
|
|
m.devices = updated
|
|
}
|
|
if m.devices.requestBack {
|
|
m.devices.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.devices.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeStatusRight && m.status != nil {
|
|
model, cmd := m.status.Update(msg)
|
|
if updated, ok := model.(*statusRightPanelModel); ok {
|
|
m.status = updated
|
|
}
|
|
if m.status.requestBack {
|
|
m.status.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.status.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeTracker && m.tracker != nil {
|
|
model, cmd := m.tracker.Update(msg)
|
|
if updated, ok := model.(*trackerPanelModel); ok {
|
|
m.tracker = updated
|
|
}
|
|
if m.tracker.requestClose {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.tracker.requestBack {
|
|
m.tracker.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.tracker.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if m.state.Mode == paletteModeGoals && m.goals != nil {
|
|
model, cmd := m.goals.Update(msg)
|
|
if updated, ok := model.(*goalPanelModel); ok {
|
|
m.goals = updated
|
|
}
|
|
if m.goals.requestClose {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.goals.requestBack {
|
|
m.goals.requestBack = false
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = m.goals.currentStatus()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" || key == "ctrl+c" || key == "alt+n" {
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if key == "alt+a" {
|
|
m.openAgentPanel()
|
|
return m, nil
|
|
}
|
|
if key == "alt+f" {
|
|
if !m.runtime.canForkCurrentOpenCode() {
|
|
m.state.Message = "No opencode session in current pane"
|
|
return m, nil
|
|
}
|
|
m.openOpencodeForkPanel()
|
|
return m, nil
|
|
}
|
|
if key == "alt+w" {
|
|
cmd, err := m.openActivityPanel()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if key == "alt+p" {
|
|
m.openSnippetsPanel()
|
|
return m, nil
|
|
}
|
|
if key == "alt+r" {
|
|
cmd, err := m.openGoalsPanel()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if key == "alt+d" {
|
|
cmd, err := m.openTrackerPanel()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
}
|
|
if key == "alt+t" {
|
|
if err := m.openTodosPanel(); err != nil {
|
|
m.state.Message = err.Error()
|
|
}
|
|
return m, nil
|
|
}
|
|
if key == "alt+c" {
|
|
m.openPrompt(palettePromptStartAgent, "", m.runtime.mainRepoRoot)
|
|
return m, nil
|
|
}
|
|
actions := m.filteredActions()
|
|
navigate := func(delta int) {
|
|
if len(actions) == 0 {
|
|
m.state.Selected = 0
|
|
return
|
|
}
|
|
next := clampInt(m.state.Selected, 0, len(actions)-1) + delta
|
|
if next < 0 {
|
|
next = len(actions) - 1
|
|
} else if next >= len(actions) {
|
|
next = 0
|
|
}
|
|
m.state.Selected = next
|
|
}
|
|
switch key {
|
|
case "ctrl+u", "alt+u", "up":
|
|
navigate(-1)
|
|
return m, nil
|
|
case "ctrl+e", "alt+e", "down":
|
|
navigate(1)
|
|
return m, nil
|
|
case "ctrl+n", "left":
|
|
m.state.FilterCursor = clampInt(m.state.FilterCursor-1, 0, len(m.state.Filter))
|
|
return m, nil
|
|
case "ctrl+i", "tab", "right":
|
|
m.state.FilterCursor = clampInt(m.state.FilterCursor+1, 0, len(m.state.Filter))
|
|
return m, nil
|
|
case "enter", "alt+i":
|
|
if len(actions) == 0 || m.state.Selected < 0 || m.state.Selected >= len(actions) {
|
|
return m, nil
|
|
}
|
|
return m.selectAction(actions[m.state.Selected])
|
|
}
|
|
if applyPaletteInputKey(key, &m.state.Filter, &m.state.FilterCursor, false) {
|
|
m.state.Selected = 0
|
|
m.state.ActionOffset = 0
|
|
m.state.Message = ""
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
|
|
switch action.Kind {
|
|
case paletteActionPromptStartAgent:
|
|
m.openPrompt(palettePromptStartAgent, "", action.RepoRoot)
|
|
return m, nil
|
|
case paletteActionConfirmDestroy:
|
|
target, err := loadDestroyTarget(m.runtime.effectiveAgentID())
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
m.state.Mode = paletteModeList
|
|
return m, nil
|
|
}
|
|
m.state.Mode = paletteModeConfirmDestroy
|
|
m.state.Message = ""
|
|
m.state.ShowAltHints = false
|
|
m.state.ConfirmRequiresText = target.RequiresExplicitConfirm
|
|
m.state.PromptText = nil
|
|
m.state.PromptCursor = 0
|
|
return m, nil
|
|
case paletteActionOpenActivityMonitor:
|
|
cmd, err := m.openActivityPanel()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
case paletteActionOpenSnippets:
|
|
m.openSnippetsPanel()
|
|
return m, nil
|
|
case paletteActionOpenTracker:
|
|
cmd, err := m.openTrackerPanel()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
case paletteActionOpenGoals:
|
|
cmd, err := m.openGoalsPanel()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
case paletteActionOpenTodos:
|
|
if err := m.openTodosPanel(); err != nil {
|
|
m.state.Message = err.Error()
|
|
}
|
|
return m, nil
|
|
case paletteActionOpenDevices:
|
|
m.openDevicesPanel()
|
|
return m, nil
|
|
case paletteActionOpenStatusRight:
|
|
m.openStatusRightPanel()
|
|
return m, nil
|
|
case paletteActionOpenOpencodeFork:
|
|
m.openOpencodeForkPanel()
|
|
return m, nil
|
|
case paletteActionBrowserLogs:
|
|
return m.runBrowserLogsPaste()
|
|
case paletteActionBrowserCopyLogs:
|
|
return m.agentPanelCopyLogs()
|
|
default:
|
|
m.state.Mode = paletteModeList
|
|
m.result = paletteResult{Kind: paletteResultRunAction, Action: action, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
func (m *paletteModel) runBrowserLogsPaste() (tea.Model, tea.Cmd) {
|
|
workspace := m.runtime.workspaceForBrowser()
|
|
if workspace == "" {
|
|
m.state.Message = "No browser-enabled agent in this window"
|
|
return m, nil
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
cmd := exec.Command(exe, "browser", "logs", "--workspace", workspace, "--tail", "50")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
m.state.Message = firstNonEmpty(strings.TrimSpace(string(output)), err.Error())
|
|
return m, nil
|
|
}
|
|
text := strings.TrimSpace(string(output))
|
|
if text != "" {
|
|
if err := pasteToTmuxPane(text); err != nil {
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
} else {
|
|
m.state.Message = "No console output captured"
|
|
return m, nil
|
|
}
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
|
|
func (m *paletteModel) openPrompt(kind palettePromptKind, initial string, repoRoot string) {
|
|
devices := []string(nil)
|
|
deviceIndex := 0
|
|
if kind == palettePromptStartAgent {
|
|
resolvedRepoRoot := strings.TrimSpace(m.runtime.resolveStartRepoRoot(repoRoot))
|
|
devices, deviceIndex = startAgentPromptDevices(resolvedRepoRoot)
|
|
}
|
|
m.state.Mode = paletteModePrompt
|
|
m.state.PromptKind = kind
|
|
m.state.PromptField = palettePromptFieldName
|
|
m.state.PromptText = []rune(initial)
|
|
m.state.PromptCursor = len(m.state.PromptText)
|
|
m.state.PromptRepoRoot = strings.TrimSpace(repoRoot)
|
|
m.state.PromptDevices = devices
|
|
m.state.PromptDeviceIndex = deviceIndex
|
|
m.state.PromptKeepWorktree = false
|
|
m.state.PromptPull = true
|
|
m.state.ShowAltHints = false
|
|
m.state.Message = ""
|
|
}
|
|
|
|
func (m *paletteModel) updatePrompt(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = ""
|
|
return m, nil
|
|
}
|
|
if m.state.PromptKind == palettePromptStartAgent {
|
|
if !m.runtime.canStartAgent(m.state.PromptRepoRoot) {
|
|
return m, nil
|
|
}
|
|
if key == "alt+d" || key == "alt+D" || key == "alt+shift+d" {
|
|
deviceCount := len(m.state.PromptDevices)
|
|
if deviceCount == 0 {
|
|
resolvedRepoRoot := strings.TrimSpace(m.runtime.resolveStartRepoRoot(m.state.PromptRepoRoot))
|
|
m.state.PromptDevices, m.state.PromptDeviceIndex = startAgentPromptDevices(resolvedRepoRoot)
|
|
deviceCount = len(m.state.PromptDevices)
|
|
}
|
|
if deviceCount > 0 {
|
|
selected := clampInt(m.state.PromptDeviceIndex, 0, deviceCount-1)
|
|
if key == "alt+d" {
|
|
m.state.PromptDeviceIndex = (selected + 1) % deviceCount
|
|
} else {
|
|
m.state.PromptDeviceIndex = (selected - 1 + deviceCount) % deviceCount
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
switch key {
|
|
case "tab", "ctrl+i":
|
|
switch m.state.PromptField {
|
|
case palettePromptFieldName:
|
|
m.state.PromptField = palettePromptFieldDevice
|
|
case palettePromptFieldDevice:
|
|
m.state.PromptField = palettePromptFieldWorktree
|
|
case palettePromptFieldWorktree:
|
|
m.state.PromptField = palettePromptFieldPull
|
|
default:
|
|
m.state.PromptField = palettePromptFieldName
|
|
}
|
|
return m, nil
|
|
case "shift+tab":
|
|
switch m.state.PromptField {
|
|
case palettePromptFieldPull:
|
|
m.state.PromptField = palettePromptFieldWorktree
|
|
case palettePromptFieldWorktree:
|
|
m.state.PromptField = palettePromptFieldDevice
|
|
case palettePromptFieldDevice:
|
|
m.state.PromptField = palettePromptFieldName
|
|
default:
|
|
m.state.PromptField = palettePromptFieldPull
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.state.PromptField == palettePromptFieldDevice {
|
|
deviceCount := len(m.state.PromptDevices)
|
|
if deviceCount == 0 {
|
|
m.state.PromptDevices = []string{defaultManagedDeviceID}
|
|
deviceCount = 1
|
|
}
|
|
switch key {
|
|
case "ctrl+n", "left", "n":
|
|
m.state.PromptDeviceIndex = clampInt(m.state.PromptDeviceIndex-1, 0, deviceCount-1)
|
|
return m, nil
|
|
case "right", "i":
|
|
m.state.PromptDeviceIndex = clampInt(m.state.PromptDeviceIndex+1, 0, deviceCount-1)
|
|
return m, nil
|
|
}
|
|
}
|
|
if m.state.PromptField == palettePromptFieldWorktree {
|
|
switch key {
|
|
case "ctrl+n", "left", "n":
|
|
m.state.PromptKeepWorktree = false
|
|
return m, nil
|
|
case "right", "i":
|
|
m.state.PromptKeepWorktree = true
|
|
return m, nil
|
|
case " ", "space":
|
|
m.state.PromptKeepWorktree = !m.state.PromptKeepWorktree
|
|
return m, nil
|
|
}
|
|
}
|
|
if m.state.PromptField == palettePromptFieldPull {
|
|
switch key {
|
|
case "ctrl+n", "left", "n":
|
|
m.state.PromptPull = false
|
|
return m, nil
|
|
case "right", "i":
|
|
m.state.PromptPull = true
|
|
return m, nil
|
|
case " ", "space":
|
|
m.state.PromptPull = !m.state.PromptPull
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
if key == "enter" {
|
|
text := strings.TrimSpace(string(m.state.PromptText))
|
|
if m.state.PromptKind == palettePromptStartAgent && text == "" {
|
|
m.state.Message = "Feature name is required"
|
|
m.state.Mode = paletteModeList
|
|
return m, nil
|
|
}
|
|
action := paletteAction{}
|
|
switch m.state.PromptKind {
|
|
case palettePromptStartAgent:
|
|
action = paletteAction{Kind: paletteActionPromptStartAgent, RepoRoot: m.state.PromptRepoRoot}
|
|
}
|
|
device := ""
|
|
if m.state.PromptKind == palettePromptStartAgent && m.state.PromptDeviceIndex >= 0 && m.state.PromptDeviceIndex < len(m.state.PromptDevices) {
|
|
device = m.state.PromptDevices[m.state.PromptDeviceIndex]
|
|
}
|
|
m.state.Mode = paletteModeList
|
|
m.result = paletteResult{Kind: paletteResultRunAction, Action: action, Input: text, Device: device, KeepWorktree: m.state.PromptKeepWorktree, Pull: m.state.PromptPull, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if m.state.PromptKind == palettePromptStartAgent && m.state.PromptField != palettePromptFieldName {
|
|
return m, nil
|
|
}
|
|
applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true)
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) updateConfirm(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" {
|
|
m.state.Mode = paletteModeList
|
|
m.state.ConfirmRequiresText = false
|
|
m.state.PromptText = nil
|
|
m.state.PromptCursor = 0
|
|
return m, nil
|
|
}
|
|
if m.state.ConfirmRequiresText {
|
|
if key == "enter" {
|
|
if strings.TrimSpace(string(m.state.PromptText)) != "destroy" {
|
|
m.state.Message = "Type destroy to confirm"
|
|
return m, nil
|
|
}
|
|
m.state.Mode = paletteModeList
|
|
m.result = paletteResult{Kind: paletteResultRunAction, Action: paletteAction{Kind: paletteActionConfirmDestroy}, Input: strings.TrimSpace(string(m.state.PromptText)), State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true)
|
|
return m, nil
|
|
}
|
|
if key == "y" || key == "Y" {
|
|
m.state.Mode = paletteModeList
|
|
m.state.ConfirmRequiresText = false
|
|
m.result = paletteResult{Kind: paletteResultRunAction, Action: paletteAction{Kind: paletteActionConfirmDestroy}, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
m.state.Mode = paletteModeList
|
|
m.state.ConfirmRequiresText = false
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) updateSnippets(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" || key == "ctrl+c" {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = ""
|
|
return m, nil
|
|
}
|
|
snippets := m.filteredSnippets()
|
|
navigate := func(delta int) {
|
|
if len(snippets) == 0 {
|
|
m.state.Selected = 0
|
|
return
|
|
}
|
|
m.state.Selected = clampInt(m.state.Selected+delta, 0, len(snippets)-1)
|
|
}
|
|
switch key {
|
|
case "ctrl+u", "up":
|
|
navigate(-1)
|
|
return m, nil
|
|
case "ctrl+e", "down":
|
|
navigate(1)
|
|
return m, nil
|
|
case "ctrl+n", "left":
|
|
m.state.FilterCursor = clampInt(m.state.FilterCursor-1, 0, len(m.state.Filter))
|
|
return m, nil
|
|
case "ctrl+i", "tab", "right":
|
|
m.state.FilterCursor = clampInt(m.state.FilterCursor+1, 0, len(m.state.Filter))
|
|
return m, nil
|
|
case "enter":
|
|
if len(snippets) == 0 || m.state.Selected < 0 || m.state.Selected >= len(snippets) {
|
|
return m, nil
|
|
}
|
|
snippet := snippets[m.state.Selected]
|
|
if len(snippet.Vars) > 0 {
|
|
m.state.SnippetName = snippet.Name
|
|
m.state.SnippetContent = snippet.Content
|
|
m.state.SnippetVars = snippet.Vars
|
|
m.state.SnippetVarIndex = 0
|
|
m.state.SnippetVarValues = make(map[string]string)
|
|
m.state.PromptText = nil
|
|
m.state.PromptCursor = 0
|
|
m.state.Mode = paletteModeSnippetVars
|
|
return m, nil
|
|
}
|
|
if err := pasteToTmuxPane(snippet.Content); err != nil {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
if applyPaletteInputKey(key, &m.state.Filter, &m.state.FilterCursor, false) {
|
|
m.state.Selected = 0
|
|
m.state.SnippetOffset = 0
|
|
m.state.Message = ""
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) updateSnippetVars(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" {
|
|
m.state.Mode = paletteModeSnippets
|
|
m.state.Message = ""
|
|
return m, nil
|
|
}
|
|
if key == "enter" {
|
|
varName := m.state.SnippetVars[m.state.SnippetVarIndex]
|
|
m.state.SnippetVarValues[varName] = string(m.state.PromptText)
|
|
m.state.SnippetVarIndex++
|
|
if m.state.SnippetVarIndex >= len(m.state.SnippetVars) {
|
|
rendered := renderSnippet(m.state.SnippetContent, m.state.SnippetVarValues)
|
|
if err := pasteToTmuxPane(rendered); err != nil {
|
|
m.state.Mode = paletteModeList
|
|
m.state.Message = err.Error()
|
|
return m, nil
|
|
}
|
|
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
|
return m, tea.Quit
|
|
}
|
|
m.state.PromptText = nil
|
|
m.state.PromptCursor = 0
|
|
return m, nil
|
|
}
|
|
applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true)
|
|
return m, nil
|
|
}
|
|
|
|
func (m *paletteModel) filteredSnippets() []snippet {
|
|
snippets := loadSnippets()
|
|
query := strings.ToLower(strings.TrimSpace(string(m.state.Filter)))
|
|
if query == "" {
|
|
return snippets
|
|
}
|
|
parts := strings.Fields(query)
|
|
filtered := make([]snippet, 0, len(snippets))
|
|
for _, s := range snippets {
|
|
haystack := strings.ToLower(s.Name)
|
|
matched := true
|
|
for _, part := range parts {
|
|
if !strings.Contains(haystack, part) {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (m *paletteModel) View() string {
|
|
width := m.width
|
|
height := m.height
|
|
if width <= 0 {
|
|
width = 96
|
|
}
|
|
if height <= 0 {
|
|
height = 28
|
|
}
|
|
if width < 48 || height < 14 {
|
|
return "Window too small for command palette"
|
|
}
|
|
styles := newPaletteStyles()
|
|
if m.state.Mode == paletteModePrompt {
|
|
return m.renderPrompt(styles, width, height)
|
|
}
|
|
if m.state.Mode == paletteModeConfirmDestroy {
|
|
return m.renderConfirm(styles, width, height)
|
|
}
|
|
if m.state.Mode == paletteModeActivity {
|
|
if m.activity != nil {
|
|
m.activity.width = width
|
|
m.activity.height = height
|
|
return m.activity.View()
|
|
}
|
|
return styles.muted.Render("Activity monitor unavailable")
|
|
}
|
|
if m.state.Mode == paletteModeSnippets {
|
|
return m.renderSnippets(styles, width, height)
|
|
}
|
|
if m.state.Mode == paletteModeSnippetVars {
|
|
return m.renderSnippetVars(styles, width, height)
|
|
}
|
|
if m.state.Mode == paletteModeTodos {
|
|
if m.todo != nil {
|
|
m.todo.width = width
|
|
m.todo.height = height
|
|
return m.todo.View()
|
|
}
|
|
return styles.muted.Render("Todo panel unavailable")
|
|
}
|
|
if m.state.Mode == paletteModeDevices {
|
|
if m.devices != nil {
|
|
m.devices.width = width
|
|
m.devices.height = height
|
|
return m.devices.render(styles, width, height)
|
|
}
|
|
return styles.muted.Render("Device panel unavailable")
|
|
}
|
|
if m.state.Mode == paletteModeStatusRight {
|
|
if m.status != nil {
|
|
m.status.width = width
|
|
m.status.height = height
|
|
return m.status.render(styles, width, height)
|
|
}
|
|
return styles.muted.Render("Status panel unavailable")
|
|
}
|
|
if m.state.Mode == paletteModeTracker {
|
|
if m.tracker != nil {
|
|
m.tracker.width = width
|
|
m.tracker.height = height
|
|
return m.tracker.render(styles, width, height)
|
|
}
|
|
return styles.muted.Render("Tracker unavailable")
|
|
}
|
|
if m.state.Mode == paletteModeGoals {
|
|
if m.goals != nil {
|
|
m.goals.width = width
|
|
m.goals.height = height
|
|
return m.goals.View()
|
|
}
|
|
return styles.muted.Render("Goals unavailable")
|
|
}
|
|
if m.state.Mode == paletteModeAgent {
|
|
return m.renderAgentPanel(styles, width, height)
|
|
}
|
|
if m.state.Mode == paletteModeOpencodeFork {
|
|
return m.renderOpencodeForkPanel(styles, width, height)
|
|
}
|
|
return m.renderListView(styles, width, height)
|
|
}
|
|
|
|
func (m *paletteModel) renderListView(styles paletteStyles, width, height int) string {
|
|
actions := m.filteredActions()
|
|
if len(actions) == 0 {
|
|
m.state.Selected = 0
|
|
} else {
|
|
m.state.Selected = clampInt(m.state.Selected, 0, len(actions)-1)
|
|
}
|
|
title := "Command Palette"
|
|
if m.runtime.record != nil {
|
|
title = title + " " + styles.keyword.Render(m.runtime.record.ID)
|
|
}
|
|
metaParts := []string{}
|
|
if m.runtime.currentSessionName != "" {
|
|
metaParts = append(metaParts, m.runtime.currentSessionName)
|
|
}
|
|
if m.runtime.currentWindowName != "" {
|
|
metaParts = append(metaParts, m.runtime.currentWindowName)
|
|
}
|
|
if m.runtime.mainRepoRoot != "" {
|
|
metaParts = append(metaParts, filepathBaseOrFull(m.runtime.mainRepoRoot))
|
|
}
|
|
header := styles.title.Render(title)
|
|
if len(metaParts) > 0 {
|
|
header = lipgloss.JoinVertical(lipgloss.Left, header, styles.meta.Render(strings.Join(metaParts, " · ")))
|
|
}
|
|
innerWidth := width - 2
|
|
filterLine := styles.searchBox.Width(innerWidth).Render(
|
|
lipgloss.JoinHorizontal(lipgloss.Center,
|
|
styles.searchPrompt.Render(">"),
|
|
" ",
|
|
styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)),
|
|
),
|
|
)
|
|
contentHeight := maxInt(8, height-7)
|
|
listWidth := maxInt(34, innerWidth*48/100)
|
|
sidebarWidth := maxInt(28, innerWidth-listWidth-3)
|
|
list := m.renderActions(styles, actions, listWidth, contentHeight)
|
|
sidebar := m.renderSidebar(styles, sidebarWidth, contentHeight)
|
|
body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), sidebar)
|
|
footer := renderPaletteFooter(styles, width, m.state.Message, m.state.ShowAltHints)
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *paletteModel) renderActions(styles paletteStyles, actions []paletteAction, width, height int) string {
|
|
return renderActionItems(styles, actions, &m.state.Selected, &m.state.ActionOffset, width, height)
|
|
}
|
|
|
|
func (m *paletteModel) renderSidebar(styles paletteStyles, width, height int) string {
|
|
lines := []string{}
|
|
trackerContext, trackerAgent, trackerBootstrap := m.runtime.sidebarTrackerStatus()
|
|
lines = append(lines, styles.panelTitle.Render("Tracker Status"))
|
|
lines = append(lines, renderPaletteStat(styles, "Context", trackerContext, width, 9))
|
|
lines = append(lines, renderPaletteStat(styles, "Agent", trackerAgent, width, 9))
|
|
lines = append(lines, renderPaletteStat(styles, "Bootstrap", trackerBootstrap, width, 9))
|
|
lines = append(lines, "")
|
|
lines = append(lines, styles.panelTitle.Render("Todo Preview"))
|
|
previewLimit := clampInt((height-6)/4, 1, 3)
|
|
sections := m.runtime.sidebarTodoPreviewSections()
|
|
for idx, section := range sections {
|
|
if idx > 0 {
|
|
lines = append(lines, "")
|
|
}
|
|
lines = append(lines, renderPaletteTodoPreviewSection(styles, section, width, previewLimit)...)
|
|
}
|
|
content := strings.Join(lines, "\n")
|
|
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
|
}
|
|
|
|
func (r *paletteRuntime) sidebarTrackerStatus() (contextSummary, agentSummary, bootstrapSummary string) {
|
|
contextParts := []string{}
|
|
if r.currentSessionName != "" {
|
|
contextParts = append(contextParts, r.currentSessionName)
|
|
}
|
|
if r.currentWindowName != "" {
|
|
contextParts = append(contextParts, r.currentWindowName)
|
|
}
|
|
if r.mainRepoRoot != "" {
|
|
contextParts = append(contextParts, filepathBaseOrFull(r.mainRepoRoot))
|
|
} else if r.currentPath != "" {
|
|
contextParts = append(contextParts, filepathBaseOrFull(r.currentPath))
|
|
}
|
|
if len(contextParts) == 0 {
|
|
contextSummary = "No tmux context detected"
|
|
} else {
|
|
contextSummary = strings.Join(contextParts, " · ")
|
|
}
|
|
if r.record == nil {
|
|
agentID := r.effectiveAgentID()
|
|
if agentID == "" {
|
|
return contextSummary, "No active agent", "No active agent"
|
|
}
|
|
return contextSummary, fmt.Sprintf("%s not loaded", agentID), "No active agent"
|
|
}
|
|
agentSummary = r.record.ID
|
|
if r.record.Branch != "" {
|
|
agentSummary = agentSummary + " on " + r.record.Branch
|
|
}
|
|
bootstrapSummary = paletteBootstrapStatus(r.record)
|
|
return contextSummary, agentSummary, bootstrapSummary
|
|
}
|
|
|
|
func (r *paletteRuntime) sidebarTodoPreviewSections() []paletteTodoPreviewSection {
|
|
sections := []paletteTodoPreviewSection{}
|
|
store, err := loadTmuxTodoStore()
|
|
windowID := strings.TrimSpace(r.windowID)
|
|
if err != nil {
|
|
sections = append(sections, paletteTodoPreviewSection{Title: "Window", Empty: "Todo store unavailable"})
|
|
sections = append(sections, paletteTodoPreviewSection{Title: "Global", Empty: "Todo store unavailable"})
|
|
} else {
|
|
windowSection := paletteTodoPreviewSection{Title: "Window", Empty: "No window todos"}
|
|
if windowID == "" {
|
|
windowSection.Empty = "No window context"
|
|
} else {
|
|
windowSection.Items = paletteTmuxTodoPreviewItems(todoItemsForScope(store, todoScopeWindow, windowID))
|
|
}
|
|
sections = append(sections, windowSection)
|
|
sections = append(sections, paletteTodoPreviewSection{
|
|
Title: "Global",
|
|
Items: paletteTmuxTodoPreviewItems(todoItemsForScope(store, todoScopeGlobal, "")),
|
|
Empty: "No global todos",
|
|
})
|
|
}
|
|
return sections
|
|
}
|
|
|
|
func paletteBootstrapStatus(record *agentRecord) string {
|
|
if record == nil {
|
|
return "No active agent"
|
|
}
|
|
workspaceRoot := strings.TrimSpace(record.WorkspaceRoot)
|
|
if workspaceRoot == "" {
|
|
return "No workspace"
|
|
}
|
|
if fileExists(bootstrapRepoReadyPath(workspaceRoot)) {
|
|
return paletteBootstrapLabel("ready", paletteBootstrapPID(workspaceRoot))
|
|
}
|
|
if fileExists(bootstrapFailedPath(workspaceRoot)) {
|
|
message := firstPaletteLine(readPaletteBootstrapFailure(workspaceRoot))
|
|
if message == "" {
|
|
return "failed"
|
|
}
|
|
return "failed: " + message
|
|
}
|
|
pid := paletteBootstrapPID(workspaceRoot)
|
|
if fileExists(bootstrapGitReadyPath(workspaceRoot)) {
|
|
return paletteBootstrapLabel("copying repo", pid)
|
|
}
|
|
if pid > 0 {
|
|
return paletteBootstrapLabel("preparing git", pid)
|
|
}
|
|
return "preparing git"
|
|
}
|
|
|
|
func paletteBootstrapPID(workspaceRoot string) int {
|
|
data, err := os.ReadFile(bootstrapPIDPath(workspaceRoot))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
if err != nil || !processRunning(pid) {
|
|
return 0
|
|
}
|
|
return pid
|
|
}
|
|
|
|
func paletteBootstrapLabel(status string, pid int) string {
|
|
if pid <= 0 {
|
|
return status
|
|
}
|
|
return fmt.Sprintf("%s (pid %d)", status, pid)
|
|
}
|
|
|
|
func readPaletteBootstrapFailure(workspaceRoot string) string {
|
|
data, err := os.ReadFile(bootstrapFailedPath(workspaceRoot))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func (m *paletteModel) renderPrompt(styles paletteStyles, width, height int) string {
|
|
title := "Input"
|
|
detail := "Enter a value"
|
|
if m.state.PromptKind == palettePromptStartAgent {
|
|
title = "Start agent"
|
|
repoRoot := blankIfEmpty(m.runtime.resolveStartRepoRoot(m.state.PromptRepoRoot), "Main repo not found")
|
|
if repoRoot == "Main repo not found" {
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(title),
|
|
styles.statusBad.Render(repoRoot),
|
|
"",
|
|
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{{{"Esc", "back"}, {footerHintToggleKey, "more"}}},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)),
|
|
)
|
|
box := styles.modal.Width(minInt(72, maxInt(36, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
sourceBranch := blankIfEmpty(m.runtime.startSourceBranch(m.state.PromptRepoRoot), "Unavailable")
|
|
devices := m.state.PromptDevices
|
|
if len(devices) == 0 {
|
|
devices = []string{defaultManagedDeviceID}
|
|
}
|
|
nameLabel := styles.modalHint.Render("NAME")
|
|
deviceLabel := styles.modalHint.Render("DEVICE")
|
|
worktreeLabel := styles.modalHint.Render("WORKTREE")
|
|
pullLabel := styles.modalHint.Render("PULL")
|
|
if m.state.PromptField == palettePromptFieldName {
|
|
nameLabel = styles.selectedLabel.Render("NAME")
|
|
} else if m.state.PromptField == palettePromptFieldDevice {
|
|
deviceLabel = styles.selectedLabel.Render("DEVICE")
|
|
} else if m.state.PromptField == palettePromptFieldWorktree {
|
|
worktreeLabel = styles.selectedLabel.Render("WORKTREE")
|
|
} else {
|
|
pullLabel = styles.selectedLabel.Render("PULL")
|
|
}
|
|
deviceChips := make([]string, 0, len(devices))
|
|
for idx, deviceID := range devices {
|
|
deviceChips = append(deviceChips, renderPaletteDeviceChip(styles, deviceID, idx == clampInt(m.state.PromptDeviceIndex, 0, len(devices)-1)))
|
|
}
|
|
worktreeChips := []string{
|
|
renderPaletteDeviceChip(styles, "CLEAR", !m.state.PromptKeepWorktree),
|
|
renderPaletteDeviceChip(styles, "KEEP", m.state.PromptKeepWorktree),
|
|
}
|
|
pullChips := []string{
|
|
renderPaletteDeviceChip(styles, "SKIP", !m.state.PromptPull),
|
|
renderPaletteDeviceChip(styles, "PULL", m.state.PromptPull),
|
|
}
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(title),
|
|
styles.modalBody.Render(repoRoot),
|
|
"",
|
|
styles.modalHint.Render("BRANCH"),
|
|
styles.modalBody.Render(sourceBranch),
|
|
"",
|
|
nameLabel,
|
|
styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)),
|
|
"",
|
|
deviceLabel,
|
|
styles.modalBody.Render(strings.Join(deviceChips, " ")),
|
|
"",
|
|
worktreeLabel,
|
|
styles.modalBody.Render(strings.Join(worktreeChips, " ")),
|
|
"",
|
|
pullLabel,
|
|
styles.modalBody.Render(strings.Join(pullChips, " ")),
|
|
"",
|
|
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(64, maxInt(28, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{
|
|
{{"Enter", "create"}, {"Tab", "focus"}, {"n/i", "choose"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
{{"Enter", "create"}, {"n/i", "choose"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
{{"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
},
|
|
[][][2]string{
|
|
{{"Alt-D", "next"}, {"Alt-Shift-D", "prev"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
|
{{"Alt-D", "next"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
|
{{"Alt-S", "close"}},
|
|
},
|
|
)),
|
|
)
|
|
box := styles.modal.Width(minInt(84, maxInt(40, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(title),
|
|
styles.modalBody.Render(detail),
|
|
"",
|
|
styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)),
|
|
"",
|
|
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{{{"Enter", "save"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, {{"Esc", "back"}, {footerHintToggleKey, "more"}}},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)),
|
|
)
|
|
box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *paletteModel) renderConfirm(styles paletteStyles, width, height int) string {
|
|
agentID := "this agent"
|
|
detail := "Remove " + agentID + " and close its tmux window?"
|
|
hint := renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{{{"y", "confirm"}, {"Esc", "cancel"}, {footerHintToggleKey, "more"}}, {{"Esc", "cancel"}, {footerHintToggleKey, "more"}}},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)
|
|
if m.runtime.record != nil {
|
|
agentID = m.runtime.record.ID
|
|
detail = "Remove " + agentID + " and close its tmux window?"
|
|
if windowID := activeAgentWindowID(m.runtime.record); windowID != "" {
|
|
if openTodos, err := countOpenTmuxTodos(todoScopeWindow, windowID); err == nil && openTodos > 0 {
|
|
label := "todos"
|
|
if openTodos == 1 {
|
|
label = "todo"
|
|
}
|
|
detail = fmt.Sprintf("Close %d open window %s before destroying %s.", openTodos, label, agentID)
|
|
hint = renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{{{"Esc", "cancel"}, {footerHintToggleKey, "more"}}},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if m.state.ConfirmRequiresText {
|
|
detail = detail + " Uncommitted changes detected; type destroy to continue."
|
|
hint = renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{{{"Enter", "confirm"}, {"Esc", "cancel"}, {footerHintToggleKey, "more"}}, {{"Esc", "cancel"}, {footerHintToggleKey, "more"}}},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)
|
|
}
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render("Destroy agent"),
|
|
styles.modalBody.Render(detail),
|
|
func() string {
|
|
if !m.state.ConfirmRequiresText {
|
|
return ""
|
|
}
|
|
return styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles))
|
|
}(),
|
|
"",
|
|
styles.modalHint.Render(hint),
|
|
)
|
|
box := styles.modal.Width(minInt(72, maxInt(36, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *paletteModel) renderAgentPanel(styles paletteStyles, width, height int) string {
|
|
if m.agentList == nil {
|
|
m.agentList = newActionListPanel(agentPanelActions(), agentPanelHotKeys())
|
|
}
|
|
m.state.Filter = m.agentList.filter
|
|
m.state.FilterCursor = m.agentList.cursor
|
|
innerWidth := width - 2
|
|
title := "Agent Actions"
|
|
header := styles.title.Render(title)
|
|
metaParts := []string{}
|
|
if m.runtime.currentSessionName != "" {
|
|
metaParts = append(metaParts, m.runtime.currentSessionName)
|
|
}
|
|
if m.runtime.currentWindowName != "" {
|
|
metaParts = append(metaParts, m.runtime.currentWindowName)
|
|
}
|
|
if m.runtime.record != nil && m.runtime.record.ID != "" {
|
|
metaParts = append(metaParts, m.runtime.record.ID)
|
|
}
|
|
if m.runtime.record != nil && m.runtime.record.BrowserEnabled {
|
|
metaParts = append(metaParts, "browser")
|
|
}
|
|
if len(metaParts) > 0 {
|
|
header = lipgloss.JoinVertical(lipgloss.Left, header, styles.meta.Render(strings.Join(metaParts, " · ")))
|
|
}
|
|
filterLine := styles.searchBox.Width(innerWidth).Render(
|
|
lipgloss.JoinHorizontal(lipgloss.Center,
|
|
styles.searchPrompt.Render(">"),
|
|
" ",
|
|
styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)),
|
|
),
|
|
)
|
|
contentHeight := maxInt(8, height-7)
|
|
list := m.agentList.renderList(styles, innerWidth, contentHeight)
|
|
footer := renderPaletteModeFooter(styles, width, m.state.Message, m.state.ShowAltHints,
|
|
[][][2]string{
|
|
{{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "run"}, {"Alt-R", "reload"}, {"Alt-Y", "copy"}, {"Alt-P", "paste"}, {"Alt-C", "clear"}, {"Esc", "back"}},
|
|
},
|
|
[][][2]string{
|
|
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-R", "reload"}, {"Alt-Y", "copy"}, {"Alt-P", "paste"}, {"Alt-C", "clear"}, {"Esc", "back"}},
|
|
},
|
|
)
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", list, "", footer)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *paletteModel) renderOpencodeForkPanel(styles paletteStyles, width, height int) string {
|
|
if m.opencodeForkList == nil {
|
|
m.opencodeForkList = newActionListPanel(opencodeForkPanelActions(), opencodeForkPanelHotKeys())
|
|
}
|
|
m.state.Filter = m.opencodeForkList.filter
|
|
m.state.FilterCursor = m.opencodeForkList.cursor
|
|
innerWidth := width - 2
|
|
header := styles.title.Render("Fork Opencode")
|
|
metaParts := []string{}
|
|
if m.runtime.currentSessionName != "" {
|
|
metaParts = append(metaParts, m.runtime.currentSessionName)
|
|
}
|
|
if m.runtime.currentWindowName != "" {
|
|
metaParts = append(metaParts, m.runtime.currentWindowName)
|
|
}
|
|
if sessionID := m.runtime.currentOpenCodeSessionID(); sessionID != "" {
|
|
metaParts = append(metaParts, sessionID)
|
|
}
|
|
if len(metaParts) > 0 {
|
|
header = lipgloss.JoinVertical(lipgloss.Left, header, styles.meta.Render(strings.Join(metaParts, " · ")))
|
|
}
|
|
filterLine := styles.searchBox.Width(innerWidth).Render(
|
|
lipgloss.JoinHorizontal(lipgloss.Center,
|
|
styles.searchPrompt.Render(">"),
|
|
" ",
|
|
styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)),
|
|
),
|
|
)
|
|
contentHeight := maxInt(8, height-7)
|
|
list := m.opencodeForkList.renderList(styles, innerWidth, contentHeight)
|
|
footer := renderPaletteModeFooter(styles, width, m.state.Message, m.state.ShowAltHints,
|
|
[][][2]string{
|
|
{{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "fork"}, {"Esc", "back"}},
|
|
},
|
|
[][][2]string{
|
|
{{"Alt-H", "horizontal"}, {"Alt-V", "vertical"}, {"Alt-W", "window"}, {"Alt-I", "fork"}, {"Esc", "back"}},
|
|
},
|
|
)
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", list, "", footer)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *paletteModel) renderSnippets(styles paletteStyles, width, height int) string {
|
|
snippets := m.filteredSnippets()
|
|
if len(snippets) == 0 {
|
|
m.state.Selected = 0
|
|
} else {
|
|
m.state.Selected = clampInt(m.state.Selected, 0, len(snippets)-1)
|
|
}
|
|
|
|
title := "Paste Snippet"
|
|
header := styles.title.Render(title)
|
|
|
|
filterLine := styles.searchBox.Width(width).Render(
|
|
lipgloss.JoinHorizontal(lipgloss.Center,
|
|
styles.searchPrompt.Render(">"),
|
|
" ",
|
|
styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)),
|
|
),
|
|
)
|
|
|
|
contentHeight := maxInt(8, height-7)
|
|
listWidth := maxInt(34, width*52/100)
|
|
previewWidth := maxInt(28, width-listWidth-3)
|
|
|
|
list := m.renderSnippetList(styles, snippets, listWidth, contentHeight)
|
|
preview := m.renderSnippetPreview(styles, snippets, previewWidth, contentHeight)
|
|
body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), preview)
|
|
|
|
footer := renderPaletteModeFooter(styles, width, m.state.Message, m.state.ShowAltHints,
|
|
[][][2]string{
|
|
{{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
{{"Ctrl-U/E", "move"}, {"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
{{"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)
|
|
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *paletteModel) renderSnippetList(styles paletteStyles, snippets []snippet, width, height int) string {
|
|
entriesPerPage := maxInt(1, (height-2)/2)
|
|
selected := clampInt(m.state.Selected, 0, maxInt(0, len(snippets)-1))
|
|
offset := stableListOffset(m.state.SnippetOffset, selected, entriesPerPage, len(snippets))
|
|
m.state.SnippetOffset = offset
|
|
|
|
blocks := []string{styles.meta.Render(fmt.Sprintf("%d snippets", len(snippets))), ""}
|
|
if len(snippets) == 0 {
|
|
blocks = append(blocks, styles.muted.Width(width).Render("No matching snippets"))
|
|
} else {
|
|
for row := 0; row < entriesPerPage; row++ {
|
|
idx := offset + row
|
|
if idx >= len(snippets) {
|
|
break
|
|
}
|
|
snippet := snippets[idx]
|
|
selectedBG := lipgloss.Color("238")
|
|
titleStyle := styles.itemTitle
|
|
subtitleStyle := styles.itemSubtitle
|
|
rowStyle := lipgloss.NewStyle().Width(maxInt(16, width-2))
|
|
fillStyle := lipgloss.NewStyle()
|
|
varLabelStyle := styles.sectionLabel
|
|
if idx == selected {
|
|
titleStyle = styles.itemTitle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
|
subtitleStyle = styles.selectedSubtle.Background(selectedBG)
|
|
rowStyle = rowStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
|
fillStyle = fillStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
|
varLabelStyle = styles.selectedLabel.Background(selectedBG)
|
|
}
|
|
|
|
varLabel := ""
|
|
if len(snippet.Vars) > 0 {
|
|
varLabel = varLabelStyle.Render(fmt.Sprintf(" %d vars", len(snippet.Vars)))
|
|
}
|
|
|
|
innerWidth := maxInt(16, width-2)
|
|
titleText := truncate(snippet.Name, innerWidth-lipgloss.Width(varLabel)-1)
|
|
gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(varLabel))
|
|
|
|
titleRow := rowStyle.Render(
|
|
titleStyle.Render(titleText) +
|
|
fillStyle.Render(strings.Repeat(" ", gapWidth)) +
|
|
varLabel,
|
|
)
|
|
desc := snippet.Description
|
|
if desc == "" {
|
|
desc = truncate(snippet.Content, 40)
|
|
}
|
|
subtitleRow := rowStyle.Render(fillStyle.Render(" ") + subtitleStyle.Render(truncate(desc, innerWidth-2)))
|
|
block := lipgloss.JoinVertical(lipgloss.Left, titleRow, subtitleRow)
|
|
blocks = append(blocks, styles.item.Width(width).Render(block))
|
|
}
|
|
}
|
|
|
|
content := strings.Join(blocks, "\n")
|
|
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
|
}
|
|
|
|
func (m *paletteModel) renderSnippetPreview(styles paletteStyles, snippets []snippet, width, height int) string {
|
|
lines := []string{}
|
|
if len(snippets) > 0 && m.state.Selected >= 0 && m.state.Selected < len(snippets) {
|
|
snippet := snippets[m.state.Selected]
|
|
lines = append(lines, styles.panelTitle.Render("Preview"))
|
|
lines = append(lines, styles.title.Render(snippet.Name))
|
|
if snippet.Description != "" {
|
|
lines = append(lines, styles.muted.Render(snippet.Description))
|
|
}
|
|
lines = append(lines, "")
|
|
if len(snippet.Vars) > 0 {
|
|
chips := []string{}
|
|
for _, v := range snippet.Vars {
|
|
chips = append(chips, styles.keyword.Render("{{"+v+"}}"))
|
|
}
|
|
lines = append(lines, "Variables: "+strings.Join(chips, " "))
|
|
lines = append(lines, "")
|
|
}
|
|
lines = append(lines, styles.panelTitle.Render("Content"))
|
|
for _, l := range wrapText(snippet.Content, maxInt(10, width-2)) {
|
|
lines = append(lines, styles.panelText.Render(truncate(l, width)))
|
|
}
|
|
}
|
|
content := strings.Join(lines, "\n")
|
|
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
|
}
|
|
|
|
func (m *paletteModel) renderSnippetVars(styles paletteStyles, width, height int) string {
|
|
varName := m.state.SnippetVars[m.state.SnippetVarIndex]
|
|
progress := fmt.Sprintf("(%d/%d)", m.state.SnippetVarIndex+1, len(m.state.SnippetVars))
|
|
title := fmt.Sprintf("Enter %s %s", varName, progress)
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(title),
|
|
styles.modalBody.Render("Value for {{"+varName+"}}"),
|
|
"",
|
|
styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)),
|
|
"",
|
|
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
|
[][][2]string{{{"Enter", "continue"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, {{"Esc", "back"}, {footerHintToggleKey, "more"}}},
|
|
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
|
)),
|
|
)
|
|
box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *paletteModel) filteredActions() []paletteAction {
|
|
return filterActionsByQuery(m.actions, m.state.Filter)
|
|
}
|
|
|
|
func newPaletteStyles() paletteStyles {
|
|
return paletteStyles{
|
|
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")),
|
|
meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
searchBox: lipgloss.NewStyle().Background(lipgloss.Color("236")).Padding(0, 1),
|
|
searchPrompt: lipgloss.NewStyle().Foreground(lipgloss.Color("223")).Bold(true),
|
|
input: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
|
inputCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("223")).Bold(true),
|
|
item: lipgloss.NewStyle().Padding(0, 1).MarginBottom(1),
|
|
selectedItem: lipgloss.NewStyle().Padding(0, 1).MarginBottom(1).Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230")),
|
|
sectionLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("180")).Bold(true),
|
|
selectedLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("223")).Bold(true),
|
|
itemTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")),
|
|
itemSubtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
selectedSubtle: lipgloss.NewStyle().Foreground(lipgloss.Color("251")),
|
|
panelTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("223")),
|
|
panelText: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
|
muted: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")),
|
|
keyword: lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("237")).Padding(0, 1),
|
|
modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(lipgloss.Color("223")).Padding(1, 2),
|
|
modalTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")),
|
|
modalBody: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
|
modalHint: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
statusBad: lipgloss.NewStyle().Foreground(lipgloss.Color("203")),
|
|
statLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
statValue: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
|
todoCheck: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
todoCheckDone: lipgloss.NewStyle().Foreground(lipgloss.Color("150")),
|
|
panelTextDone: lipgloss.NewStyle().Foreground(lipgloss.Color("246")),
|
|
shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("223")).Padding(0, 1).Bold(true),
|
|
shortcutText: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
|
}
|
|
}
|
|
|
|
func renderInputValue(text []rune, cursor int, styles paletteStyles) string {
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor > len(text) {
|
|
cursor = len(text)
|
|
}
|
|
left := string(text[:cursor])
|
|
right := string(text[cursor:])
|
|
cursorChar := " "
|
|
if cursor < len(text) {
|
|
cursorChar = string(text[cursor])
|
|
right = string(text[cursor+1:])
|
|
}
|
|
if len(text) == 0 && cursor == 0 {
|
|
cursorChar = " "
|
|
}
|
|
return left + styles.inputCursor.Render(cursorChar) + right
|
|
}
|
|
|
|
func applyPaletteInputKey(key string, text *[]rune, cursor *int, allowEnter bool) bool {
|
|
if text == nil || cursor == nil {
|
|
return false
|
|
}
|
|
switch key {
|
|
case "left":
|
|
*cursor = clampInt(*cursor-1, 0, len(*text))
|
|
return true
|
|
case "right":
|
|
*cursor = clampInt(*cursor+1, 0, len(*text))
|
|
return true
|
|
case "backspace", "ctrl+h":
|
|
if *cursor > 0 {
|
|
*text = append((*text)[:*cursor-1], (*text)[*cursor:]...)
|
|
*cursor--
|
|
}
|
|
return true
|
|
case "delete":
|
|
if *cursor < len(*text) {
|
|
*text = append((*text)[:*cursor], (*text)[*cursor+1:]...)
|
|
}
|
|
return true
|
|
case "ctrl+a", "home":
|
|
*cursor = 0
|
|
return true
|
|
case "ctrl+e", "end":
|
|
*cursor = len(*text)
|
|
return true
|
|
case "ctrl+u":
|
|
*text = (*text)[*cursor:]
|
|
*cursor = 0
|
|
return true
|
|
case "ctrl+w":
|
|
start := previousWordBoundary(*text, *cursor)
|
|
*text = append((*text)[:start], (*text)[*cursor:]...)
|
|
*cursor = start
|
|
return true
|
|
case "enter":
|
|
return allowEnter
|
|
}
|
|
runes, ok := paletteRunesFromKey(key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
*text = append((*text)[:*cursor], append(runes, (*text)[*cursor:]...)...)
|
|
*cursor += len(runes)
|
|
return true
|
|
}
|
|
|
|
func readClipboardPaste() string {
|
|
cmd := exec.Command("pbpaste")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func paletteRunesFromKey(key string) ([]rune, bool) {
|
|
if key == "space" {
|
|
return []rune{' '}, true
|
|
}
|
|
runes := []rune(key)
|
|
if len(runes) >= 1 {
|
|
return runes, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func paletteRuneFromKey(key string) (rune, bool) {
|
|
if key == "space" {
|
|
return ' ', true
|
|
}
|
|
runes := []rune(key)
|
|
if len(runes) == 1 {
|
|
return runes[0], true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func renderVerticalDivider(height int) string {
|
|
lines := make([]string, maxInt(1, height))
|
|
for i := range lines {
|
|
lines[i] = "│"
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderPaletteStat(styles paletteStyles, label, value string, width int, labelWidth int) string {
|
|
parts := wrapText(value, maxInt(10, width-labelWidth-3))
|
|
if len(parts) == 0 {
|
|
parts = []string{"-"}
|
|
}
|
|
lines := []string{styles.statLabel.Width(labelWidth).Render(label+":") + " " + styles.statValue.Render(parts[0])}
|
|
for _, part := range parts[1:] {
|
|
lines = append(lines, strings.Repeat(" ", labelWidth+1)+styles.statValue.Render(part))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderPaletteModeFooter(styles paletteStyles, width int, message string, showAltHints bool, normalCandidates [][][2]string, altCandidates [][][2]string) string {
|
|
message = strings.TrimSpace(message)
|
|
if message != "" {
|
|
style := styles.footer
|
|
lower := strings.ToLower(message)
|
|
if strings.Contains(lower, "error") || strings.Contains(lower, "required") || strings.Contains(lower, "unknown") {
|
|
style = styles.statusBad
|
|
}
|
|
return style.Width(width).Render(truncate(message, width))
|
|
}
|
|
renderSegments := func(pairs [][2]string) string {
|
|
return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs)
|
|
}
|
|
candidates := normalCandidates
|
|
if showAltHints {
|
|
candidates = altCandidates
|
|
}
|
|
footer := pickRenderedShortcutFooter(width, renderSegments, candidates...)
|
|
return lipgloss.NewStyle().Width(width).Render(footer)
|
|
}
|
|
|
|
func renderPaletteFooter(styles paletteStyles, width int, message string, showAltHints bool) string {
|
|
return renderPaletteModeFooter(styles, width, message, showAltHints,
|
|
[][][2]string{
|
|
{{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
|
{{"Ctrl-U/E", "move"}, {"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
|
{{"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
|
},
|
|
[][][2]string{
|
|
{{"Alt-C", "create"}, {"Alt-F", "fork"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "agent"}, {"Alt-W", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
|
{{"Alt-C", "create"}, {"Alt-F", "fork"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "agent"}, {"Alt-W", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
|
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-S", "close"}},
|
|
},
|
|
)
|
|
}
|
|
|
|
func renderPaletteHintLine(styles paletteStyles, width int, showAltHints bool, normalCandidates [][][2]string, altCandidates [][][2]string) string {
|
|
return pickRenderedShortcutFooter(width, func(pairs [][2]string) string {
|
|
return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs)
|
|
}, func() [][][2]string {
|
|
if showAltHints {
|
|
return altCandidates
|
|
}
|
|
return normalCandidates
|
|
}()...)
|
|
}
|
|
|
|
func paletteTmuxTodoPreviewItems(items []tmuxTodoItem) []paletteTodoPreviewItem {
|
|
rows := make([]paletteTodoPreviewItem, 0, len(items))
|
|
for _, item := range items {
|
|
title := firstPaletteLine(item.Title)
|
|
if title == "" || item.Done {
|
|
continue
|
|
}
|
|
rows = append(rows, paletteTodoPreviewItem{Title: title, Done: item.Done})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func renderPaletteTodoPreviewSection(styles paletteStyles, section paletteTodoPreviewSection, width int, previewLimit int) []string {
|
|
lines := []string{styles.statLabel.Render(section.Title)}
|
|
if section.Lead != "" {
|
|
lines = append(lines, renderPalettePreviewValue(styles, section.Lead, width, 2)...)
|
|
}
|
|
if len(section.Items) == 0 {
|
|
if section.Lead == "" {
|
|
lines = append(lines, styles.muted.Render(" "+section.Empty))
|
|
}
|
|
return lines
|
|
}
|
|
limit := clampInt(previewLimit, 1, len(section.Items))
|
|
for _, item := range section.Items[:limit] {
|
|
lines = append(lines, renderPaletteTodoPreviewItem(styles, item, width, 2)...)
|
|
}
|
|
hidden := len(section.Items) - limit
|
|
if hidden > 0 {
|
|
lines = append(lines, styles.muted.Render(fmt.Sprintf(" +%d more", hidden)))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderPaletteTodoPreviewItem(styles paletteStyles, item paletteTodoPreviewItem, width int, indent int) []string {
|
|
title := strings.TrimSpace(item.Title)
|
|
if title == "" {
|
|
return nil
|
|
}
|
|
check := "○"
|
|
checkStyle := styles.todoCheck
|
|
textStyle := styles.panelText
|
|
if item.Done {
|
|
check = "●"
|
|
checkStyle = styles.todoCheckDone
|
|
textStyle = styles.panelTextDone
|
|
}
|
|
indentPrefix := strings.Repeat(" ", maxInt(0, indent))
|
|
textPrefix := indentPrefix + check + " "
|
|
available := maxInt(10, width-lipgloss.Width(textPrefix))
|
|
parts := wrapText(title, available)
|
|
if len(parts) == 0 {
|
|
parts = []string{title}
|
|
}
|
|
lines := []string{indentPrefix + checkStyle.Render(check) + " " + textStyle.Render(truncate(parts[0], available))}
|
|
continuationPrefix := strings.Repeat(" ", lipgloss.Width(textPrefix))
|
|
for _, part := range parts[1:] {
|
|
lines = append(lines, continuationPrefix+textStyle.Render(truncate(part, available)))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderPalettePreviewValue(styles paletteStyles, value string, width int, indent int) []string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
prefix := strings.Repeat(" ", maxInt(0, indent))
|
|
available := maxInt(10, width-len([]rune(prefix)))
|
|
parts := wrapText(value, available)
|
|
if len(parts) == 0 {
|
|
parts = []string{value}
|
|
}
|
|
lines := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
lines = append(lines, prefix+styles.panelText.Render(truncate(part, available)))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderPaletteDeviceChip(styles paletteStyles, deviceID string, active bool) string {
|
|
chipStyle := styles.keyword
|
|
if active {
|
|
chipStyle = styles.keyword.Copy().Foreground(lipgloss.Color("223")).Background(lipgloss.Color("238")).Bold(true)
|
|
}
|
|
label := deviceID
|
|
if isPaletteNoDeviceOption(deviceID) {
|
|
label = "NONE"
|
|
}
|
|
return chipStyle.Render(label)
|
|
}
|
|
|
|
func firstPaletteLine(value string) string {
|
|
parts := strings.Split(strings.TrimSpace(value), "\n")
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
func paletteMessageForError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func paletteSuccessMessage(err error, success string) string {
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return success
|
|
}
|
|
|
|
func filepathBaseOrFull(path string) string {
|
|
base := strings.TrimSpace(filepath.Base(path))
|
|
if base == "" || base == "." || base == string(filepath.Separator) {
|
|
return path
|
|
}
|
|
return base
|
|
}
|
|
|
|
func clampInt(value, low, high int) int {
|
|
if high < low {
|
|
return low
|
|
}
|
|
if value < low {
|
|
return low
|
|
}
|
|
if value > high {
|
|
return high
|
|
}
|
|
return value
|
|
}
|