diff --git a/.tmux.conf b/.tmux.conf index f9f87fa..abf88d8 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -26,9 +26,6 @@ setw -g monitor-bell off set -g history-limit 10000 -# reload configuration -bind r source-file ~/.tmux.conf \; display '~/.tmux.conf sourced' - set -ga update-environment '\ DISPLAY DBUS_SESSION_BUS_ADDRESS \ QT_IM_MODULE QT_QPA_PLATFORMTHEME \ diff --git a/agent-tracker/cmd/agent/main.go b/agent-tracker/cmd/agent/main.go index e82d92d..4cc7a83 100644 --- a/agent-tracker/cmd/agent/main.go +++ b/agent-tracker/cmd/agent/main.go @@ -75,7 +75,8 @@ type statusRightConfig struct { Memory *bool `json:"memory,omitempty"` MemoryTotals *bool `json:"memory_totals,omitempty"` Agent *bool `json:"agent,omitempty"` - Notes *bool `json:"notes,omitempty"` + TodoPreview *bool `json:"todo_preview,omitempty"` + Todos *bool `json:"todos,omitempty"` FlashMoe *bool `json:"flash_moe,omitempty"` Host *bool `json:"host,omitempty"` } @@ -2611,7 +2612,17 @@ func loadRegistry() (*registry, error) { return nil, err } if err := json.Unmarshal(data, reg); err != nil { - return nil, err + fallback := ®istry{Agents: map[string]*agentRecord{}} + dec := json.NewDecoder(strings.NewReader(string(data))) + if decodeErr := dec.Decode(fallback); decodeErr != nil { + return nil, err + } + trailing := strings.TrimSpace(string(data[int(dec.InputOffset()):])) + if trailing == "" || strings.Trim(trailing, "}") != "" { + return nil, err + } + reg = fallback + _ = saveRegistry(reg) } if reg.Agents == nil { reg.Agents = map[string]*agentRecord{} diff --git a/agent-tracker/cmd/agent/palette_bubbletea.go b/agent-tracker/cmd/agent/palette_bubbletea.go index ec964ed..6ca0e3a 100644 --- a/agent-tracker/cmd/agent/palette_bubbletea.go +++ b/agent-tracker/cmd/agent/palette_bubbletea.go @@ -45,18 +45,19 @@ type paletteRuntime struct { } type paletteModel struct { - runtime *paletteRuntime - state paletteUIState - actions []paletteAction - openedAt time.Time - width int - height int - result paletteResult - todo *todoPanelModel - activity *activityMonitorBT - devices *devicePanelModel - status *statusRightPanelModel - tracker *trackerPanelModel + runtime *paletteRuntime + state paletteUIState + actions []paletteAction + openedAt time.Time + quickSecondaryEscCloses bool + width int + height int + result paletteResult + todo *todoPanelModel + activity *activityMonitorBT + devices *devicePanelModel + status *statusRightPanelModel + tracker *trackerPanelModel } type paletteStyles struct { @@ -396,7 +397,7 @@ func (r *paletteRuntime) buildActions() []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", "notes", "host", "flash"}, + Keywords: []string{"tmux", "status", "status-right", "bottom-right", "control", "center", "istat", "cpu", "network", "memory", "todos", "host", "flash"}, Kind: paletteActionOpenStatusRight, }, ) @@ -584,8 +585,10 @@ func statusRightModuleLabel(module string) string { return "Tmux Memory" case statusRightModuleAgent: return "Agent" - case statusRightModuleNotes: - return "Notes" + case statusRightModuleTodoPreview: + return "Todo Preview" + case statusRightModuleTodos: + return "Todos" case statusRightModuleFlashMoe: return "Flash-MoE" case statusRightModuleHost: @@ -607,7 +610,9 @@ func statusRightModuleDescription(module string) string { return "window, session, and total tmux memory" case statusRightModuleAgent: return "active agent device" - case statusRightModuleNotes: + case statusRightModuleTodoPreview: + return "append the first open window todo to Todos" + case statusRightModuleTodos: return "todo count" case statusRightModuleFlashMoe: return "Flash-MoE status" @@ -657,7 +662,17 @@ func (m *paletteModel) Init() tea.Cmd { 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) @@ -670,7 +685,7 @@ func (m *paletteModel) openTodosPanel() error { m.todo.windowID = strings.TrimSpace(windowID) m.todo.reloadEntries() m.todo.clampSelections() - m.todo.focusedScope = todoScopeWindow + m.todo.setFocusedPane(todoPanelPaneWindow) m.todo.mode = todoPanelModeList } m.todo.showAltHints = false @@ -681,6 +696,7 @@ func (m *paletteModel) openTodosPanel() error { } func (m *paletteModel) openSnippetsPanel() { + m.noteSecondaryPageOpen() m.state.Mode = paletteModeSnippets m.state.Filter = nil m.state.FilterCursor = 0 @@ -691,6 +707,7 @@ func (m *paletteModel) openSnippetsPanel() { } func (m *paletteModel) openActivityPanel() (tea.Cmd, error) { + m.noteSecondaryPageOpen() if m.activity == nil { m.activity = newActivityMonitorModel(m.runtime.windowID, true) } else { @@ -714,6 +731,7 @@ func (m *paletteModel) openActivityPanel() (tea.Cmd, error) { } func (m *paletteModel) openDevicesPanel() { + m.noteSecondaryPageOpen() if m.devices == nil { m.devices = newDevicePanelModel() } else { @@ -728,6 +746,7 @@ func (m *paletteModel) openDevicesPanel() { } func (m *paletteModel) openStatusRightPanel() { + m.noteSecondaryPageOpen() if m.status == nil { m.status = newStatusRightPanelModel() } else { @@ -741,6 +760,7 @@ func (m *paletteModel) openStatusRightPanel() { } func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) { + m.noteSecondaryPageOpen() if m.tracker == nil { m.tracker = newTrackerPanelModel(m.runtime) } else { @@ -791,8 +811,27 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if time.Since(m.openedAt) < 250*time.Millisecond { return m, nil } - m.result = paletteResult{Kind: paletteResultClose, State: m.state} - return m, tea.Quit + 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 paletteModeSnippets: + return m.closePalette() + } } if m.state.Mode == paletteModeActivity { if m.activity == nil { @@ -1021,7 +1060,13 @@ func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) { m.state.Selected = 0 return } - m.state.Selected = clampInt(m.state.Selected+delta, 0, len(actions)-1) + 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": @@ -1961,7 +2006,7 @@ func (m *paletteModel) filteredActions() []paletteAction { parts := strings.Fields(query) filtered := make([]paletteAction, 0, len(m.actions)) for _, action := range m.actions { - haystack := strings.ToLower(action.Title + " " + action.Subtitle + " " + strings.Join(action.Keywords, " ")) + haystack := strings.ToLower(action.Title) matched := true for _, part := range parts { if !strings.Contains(haystack, part) { diff --git a/agent-tracker/cmd/agent/status_right_panel.go b/agent-tracker/cmd/agent/status_right_panel.go index 48b2333..81ff22c 100644 --- a/agent-tracker/cmd/agent/status_right_panel.go +++ b/agent-tracker/cmd/agent/status_right_panel.go @@ -10,10 +10,12 @@ import ( ) type statusRightPanelEntry struct { - Module string - Title string - Subtitle string - Enabled bool + Module string + Title string + Subtitle string + Enabled bool + Available bool + Indented bool } type statusRightPanelModel struct { @@ -36,11 +38,19 @@ func newStatusRightPanelModel() *statusRightPanelModel { func (m *statusRightPanelModel) reload() { entries := make([]statusRightPanelEntry, 0, len(statusRightModules())) for _, module := range statusRightModules() { + available := true + indented := false + if module == statusRightModuleTodoPreview { + available = statusRightModuleEnabled(statusRightModuleTodos) + indented = true + } entries = append(entries, statusRightPanelEntry{ - Module: module, - Title: statusRightModuleLabel(module), - Subtitle: capitalizeStatusRightDescription(statusRightModuleDescription(module)), - Enabled: statusRightModuleEnabled(module), + Module: module, + Title: statusRightModuleLabel(module), + Subtitle: capitalizeStatusRightDescription(statusRightModuleDescription(module)), + Enabled: statusRightModuleEnabled(module), + Available: available, + Indented: indented, }) } m.entries = entries @@ -90,6 +100,10 @@ func (m *statusRightPanelModel) toggleSelected() { if !ok { return } + if !entry.Available { + m.setStatus("Enable Todos first", 1500*time.Millisecond) + return + } if err := togglePaletteStatusRightModule(entry.Module); err != nil { m.setStatus(err.Error(), 1500*time.Millisecond) return @@ -130,7 +144,7 @@ func (m *statusRightPanelModel) render(styles paletteStyles, width, height int) styles.meta.Render("Current layout: "+truncate(m.layoutSummary(), maxInt(28, width-2))), ) - lines := []string{styles.meta.Render(fmt.Sprintf("%d modules", len(m.entries))), ""} + lines := []string{styles.meta.Render(fmt.Sprintf("%d controls", len(m.entries))), ""} for idx, entry := range m.entries { rowStyle := styles.item.Width(maxInt(24, width-2)) titleStyle := styles.itemTitle @@ -143,6 +157,12 @@ func (m *statusRightPanelModel) render(styles paletteStyles, width, height int) badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("150")).Padding(0, 1).Bold(true) badgeLabel = "ON" } + if !entry.Available { + titleStyle = titleStyle.Foreground(lipgloss.Color("245")) + metaStyle = metaStyle.Foreground(lipgloss.Color("242")) + detailStyle = detailStyle.Foreground(lipgloss.Color("242")) + badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Background(lipgloss.Color("236")).Padding(0, 1).Bold(true) + } if idx == m.selected { selectedBG := lipgloss.Color("238") rowStyle = styles.selectedItem.Width(maxInt(24, width-2)) @@ -150,24 +170,41 @@ func (m *statusRightPanelModel) render(styles paletteStyles, width, height int) metaStyle = styles.selectedSubtle.Background(selectedBG) detailStyle = styles.selectedSubtle.Background(selectedBG) fillStyle = fillStyle.Background(selectedBG) + if !entry.Available { + titleStyle = titleStyle.Foreground(lipgloss.Color("250")) + metaStyle = metaStyle.Foreground(lipgloss.Color("247")) + detailStyle = detailStyle.Foreground(lipgloss.Color("247")) + badgeStyle = badgeStyle.Background(selectedBG).Foreground(lipgloss.Color("247")) + } } badge := badgeStyle.Render(badgeLabel) innerWidth := maxInt(22, width-2) - titleText := truncate(entry.Title, maxInt(8, innerWidth-lipgloss.Width(badge)-1)) + titleText := entry.Title + if entry.Indented { + titleText = " " + titleText + } + titleText = truncate(titleText, maxInt(8, innerWidth-lipgloss.Width(badge)-1)) gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(badge)) titleRow := lipgloss.JoinHorizontal(lipgloss.Left, titleStyle.Render(titleText), fillStyle.Render(strings.Repeat(" ", gapWidth)), badge, ) - detail := entry.Subtitle + ". " + statusRightVisibilityText(entry.Enabled) + detail := entry.Subtitle + ". " + statusRightVisibilityText(entry.Enabled, entry.Available) + if entry.Indented { + detail = " " + detail + } detailText := truncate(detail, innerWidth) detailGap := maxInt(0, innerWidth-lipgloss.Width(detailText)) detailRow := lipgloss.JoinHorizontal(lipgloss.Left, metaStyle.Render(detailText), fillStyle.Render(strings.Repeat(" ", detailGap)), ) - orderText := truncate(statusRightOrderHint(entry.Module, entry.Enabled), innerWidth) + orderText := statusRightOrderHint(entry.Module, entry.Enabled, entry.Available) + if entry.Indented { + orderText = " " + orderText + } + orderText = truncate(orderText, innerWidth) orderGap := maxInt(0, innerWidth-lipgloss.Width(orderText)) orderRow := lipgloss.JoinHorizontal(lipgloss.Left, detailStyle.Render(orderText), @@ -182,14 +219,29 @@ func (m *statusRightPanelModel) render(styles paletteStyles, width, height int) return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view) } -func statusRightVisibilityText(enabled bool) string { +func statusRightVisibilityText(enabled, available bool) string { + if !available { + return "Disabled until Todos is enabled" + } if enabled { return "Visible in the tmux bottom-right status" } return "Hidden from the tmux bottom-right status" } -func statusRightOrderHint(module string, enabled bool) string { +func statusRightOrderHint(module string, enabled, available bool) string { + if module == statusRightModuleTodoPreview { + if !available { + if enabled { + return "Will appear inside Todos when Todos is re-enabled" + } + return "Enable Todos to configure this preview" + } + if enabled { + return "Shown inside Todos when Todos is enabled" + } + return "Hidden inside Todos until re-enabled" + } prefix := "Order slot: " + statusRightModuleLabel(module) if enabled { return prefix + " follows the fixed module order" @@ -200,7 +252,7 @@ func statusRightOrderHint(module string, enabled bool) string { func (m *statusRightPanelModel) layoutSummary() string { labels := make([]string, 0, len(m.entries)) for _, entry := range m.entries { - if entry.Enabled { + if entry.Enabled && entry.Module != statusRightModuleTodoPreview { labels = append(labels, entry.Title) } } diff --git a/agent-tracker/cmd/agent/tmux_status.go b/agent-tracker/cmd/agent/tmux_status.go index 615d156..769aebf 100644 --- a/agent-tracker/cmd/agent/tmux_status.go +++ b/agent-tracker/cmd/agent/tmux_status.go @@ -21,7 +21,8 @@ const ( statusRightModuleMemory = "memory" statusRightModuleMemoryTotals = "memory_totals" statusRightModuleAgent = "agent" - statusRightModuleNotes = "notes" + statusRightModuleTodoPreview = "todo_preview" + statusRightModuleTodos = "todos" statusRightModuleFlashMoe = "flash_moe" statusRightModuleHost = "host" ) @@ -34,7 +35,7 @@ const ( statusIconSession = "" statusIconTotal = "󰍛" statusIconAgent = "󰚩" - statusIconNotes = "󰎚" + statusIconTodos = "󰎚" statusIconFlashMoe = "󱙺" ) @@ -45,7 +46,8 @@ func statusRightModules() []string { statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, - statusRightModuleNotes, + statusRightModuleTodos, + statusRightModuleTodoPreview, statusRightModuleFlashMoe, statusRightModuleHost, } @@ -111,11 +113,14 @@ type statusMemoryCache struct { } type statusTodoCache struct { - Windows map[string][]statusTodoItem `json:"windows"` + Global []statusTodoItem `json:"global"` + Sessions map[string][]statusTodoItem `json:"sessions"` + Windows map[string][]statusTodoItem `json:"windows"` } type statusTodoItem struct { - Done bool `json:"done"` + Title string `json:"title"` + Done bool `json:"done"` } type statusNetworkCounter struct { @@ -191,8 +196,8 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string { segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#81a1c1", Text: label, Bold: true}) } } - if statusRightModuleEnabled(statusRightModuleNotes) { - if label := loadNotesStatusLabel(args.WindowID); label != "" { + if statusRightModuleEnabled(statusRightModuleTodos) { + if label := loadTodosStatusLabel(args.WindowID); label != "" { segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true}) } } @@ -535,20 +540,11 @@ func refreshMemoryUsageCache() { _ = statusCommandStart("python3", script) } -func loadNotesStatusLabel(windowID string) string { - windowID = strings.TrimSpace(windowID) - if windowID == "" { +func loadTodosStatusLabel(windowID string) string { + items, ok := statusTodoItemsForWindow(windowID) + if !ok { return "" } - data, err := os.ReadFile(statusTodoFilePath()) - if err != nil { - return "" - } - var cache statusTodoCache - if err := json.Unmarshal(data, &cache); err != nil { - return "" - } - items := cache.Windows[windowID] count := 0 for _, item := range items { if !item.Done { @@ -558,7 +554,72 @@ func loadNotesStatusLabel(windowID string) string { if count == 0 { return "" } - return fmt.Sprintf(" %s %d ", statusIconNotes, count) + if !statusRightModuleEnabled(statusRightModuleTodoPreview) { + return fmt.Sprintf(" %s %d ", statusIconTodos, count) + } + title := firstOpenStatusTodoTitle(items) + if title != "" { + return fmt.Sprintf(" %s %d %s ", statusIconTodos, count, truncate(title, statusTodoMaxChars())) + } + return fmt.Sprintf(" %s %d ", statusIconTodos, count) +} + +func loadStatusTodoCache() (statusTodoCache, bool) { + data, err := os.ReadFile(statusTodoFilePath()) + if err != nil { + return statusTodoCache{}, false + } + var cache statusTodoCache + if err := json.Unmarshal(data, &cache); err != nil { + return statusTodoCache{}, false + } + if cache.Global == nil { + cache.Global = []statusTodoItem{} + } + if cache.Sessions == nil { + cache.Sessions = map[string][]statusTodoItem{} + } + if cache.Windows == nil { + cache.Windows = map[string][]statusTodoItem{} + } + return cache, true +} + +func statusTodoItemsForWindow(windowID string) ([]statusTodoItem, bool) { + windowID = strings.TrimSpace(windowID) + if windowID == "" { + return nil, false + } + cache, ok := loadStatusTodoCache() + if !ok { + return nil, false + } + return cache.Windows[windowID], true +} + +func firstOpenStatusTodoTitle(items []statusTodoItem) string { + for _, item := range items { + if item.Done { + continue + } + title := strings.Join(strings.Fields(strings.TrimSpace(item.Title)), " ") + if title != "" { + return title + } + } + return "" +} + +func statusTodoMaxChars() int { + value := strings.TrimSpace(os.Getenv("TMUX_TODO_MAX_CHARS")) + if value == "" { + return 32 + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 8 { + return 32 + } + return parsed } func loadFlashMoeStatusSegment() (statusSegment, bool) { @@ -629,7 +690,7 @@ func loadHostStatusLabel() string { func defaultStatusRightModuleEnabled(module string) bool { switch module { - case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleNotes, statusRightModuleFlashMoe, statusRightModuleHost: + case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost: return true case statusRightModuleMemoryTotals: return true @@ -640,7 +701,7 @@ func defaultStatusRightModuleEnabled(module string) bool { func isValidStatusRightModule(module string) bool { switch module { - case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleNotes, statusRightModuleFlashMoe, statusRightModuleHost: + case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost: return true default: return false @@ -670,8 +731,10 @@ func (cfg statusRightConfig) moduleEnabled(module string) bool { return derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module)) case statusRightModuleAgent: return derefBool(cfg.Agent, defaultStatusRightModuleEnabled(module)) - case statusRightModuleNotes: - return derefBool(cfg.Notes, defaultStatusRightModuleEnabled(module)) + case statusRightModuleTodoPreview: + return derefBool(cfg.TodoPreview, defaultStatusRightModuleEnabled(module)) + case statusRightModuleTodos: + return derefBool(cfg.Todos, defaultStatusRightModuleEnabled(module)) case statusRightModuleFlashMoe: return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module)) case statusRightModuleHost: @@ -713,8 +776,10 @@ func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) { cfg.MemoryTotals = value case statusRightModuleAgent: cfg.Agent = value - case statusRightModuleNotes: - cfg.Notes = value + case statusRightModuleTodoPreview: + cfg.TodoPreview = value + case statusRightModuleTodos: + cfg.Todos = value case statusRightModuleFlashMoe: cfg.FlashMoe = value case statusRightModuleHost: @@ -726,7 +791,7 @@ func (cfg *statusRightConfig) isDefault() bool { if cfg == nil { return true } - return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.Notes == nil && cfg.FlashMoe == nil && cfg.Host == nil + return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil } func derefBool(value *bool, fallback bool) bool { diff --git a/agent-tracker/cmd/agent/todo_panel.go b/agent-tracker/cmd/agent/todo_panel.go index 813b6d4..1e3fb97 100644 --- a/agent-tracker/cmd/agent/todo_panel.go +++ b/agent-tracker/cmd/agent/todo_panel.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os/exec" + "sort" "strings" "time" @@ -21,12 +22,23 @@ const ( todoPanelModeConfirmDelete ) +type todoPanelPane int + +const ( + todoPanelPaneWindow todoPanelPane = iota + todoPanelPaneAllWindows + todoPanelPaneGlobal +) + type todoPanelModel struct { entries []tmuxTodoEntry - focusedScope todoScope + focusedPane todoPanelPane + lastWindowPane todoPanelPane selectedWindow int + selectedAllWin int selectedGlobal int windowOffset int + allWinOffset int globalOffset int mode todoPanelMode sessionID string @@ -138,7 +150,8 @@ func newTodoPanelModel(sessionID, windowID string) (*todoPanelModel, error) { model := &todoPanelModel{ sessionID: strings.TrimSpace(sessionID), windowID: strings.TrimSpace(windowID), - focusedScope: todoScopeWindow, + focusedPane: todoPanelPaneWindow, + lastWindowPane: todoPanelPaneWindow, keepVisibleDone: map[string]bool{}, styles: newTodoPanelStyles(), mode: todoPanelModeList, @@ -147,12 +160,20 @@ func newTodoPanelModel(sessionID, windowID string) (*todoPanelModel, error) { return model, nil } -func collectTodoPanelEntries(currentWindowID string) []tmuxTodoEntry { +func collectTodoPanelEntries(currentSessionID, currentWindowID string) []tmuxTodoEntry { store, err := loadTmuxTodoStore() if err != nil { return nil } - entries := make([]tmuxTodoEntry, 0, len(store.Global)+len(store.Windows[currentWindowID])) + windowLabels := todoPanelWindowLabels(currentSessionID) + allWindowCount := 0 + for windowID, items := range store.Windows { + if strings.TrimSpace(windowID) == strings.TrimSpace(currentWindowID) { + continue + } + allWindowCount += len(items) + } + entries := make([]tmuxTodoEntry, 0, len(store.Global)+len(store.Windows[currentWindowID])+allWindowCount) for idx, item := range store.Windows[currentWindowID] { entries = append(entries, tmuxTodoEntry{ Title: item.Title, @@ -163,8 +184,32 @@ func collectTodoPanelEntries(currentWindowID string) []tmuxTodoEntry { ScopeName: "Window", IsCurrent: true, ItemIndex: idx, + PanelPane: todoPanelPaneWindow, }) } + windowIDs := make([]string, 0, len(store.Windows)) + for windowID := range store.Windows { + if strings.TrimSpace(windowID) == strings.TrimSpace(currentWindowID) { + continue + } + windowIDs = append(windowIDs, windowID) + } + sort.Strings(windowIDs) + for _, windowID := range windowIDs { + for idx, item := range store.Windows[windowID] { + entries = append(entries, tmuxTodoEntry{ + Title: item.Title, + Done: item.Done, + Priority: item.Priority, + Scope: todoScopeWindow, + ScopeID: windowID, + ScopeName: "Window", + ItemIndex: idx, + PanelPane: todoPanelPaneAllWindows, + Detail: windowLabels[windowID], + }) + } + } for idx, item := range store.Global { entries = append(entries, tmuxTodoEntry{ Title: item.Title, @@ -175,17 +220,50 @@ func collectTodoPanelEntries(currentWindowID string) []tmuxTodoEntry { ScopeName: "Global", IsCurrent: true, ItemIndex: idx, + PanelPane: todoPanelPaneGlobal, }) } return entries } func (m *todoPanelModel) reloadEntries() { - m.entries = collectTodoPanelEntries(m.windowID) + m.entries = collectTodoPanelEntries(m.sessionID, m.windowID) m.pruneKeepVisibleDone() m.clampSelections() } +func todoPanelWindowLabels(currentSessionID string) map[string]string { + labels := map[string]string{} + out, err := runTmuxOutput("list-windows", "-a", "-F", "#{session_id}\t#{window_id}\t#{session_name}\t#{window_index}\t#{window_name}") + if err != nil { + return labels + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) != 5 { + continue + } + sessionID := strings.TrimSpace(parts[0]) + windowID := strings.TrimSpace(parts[1]) + sessionName := strings.TrimSpace(parts[2]) + windowIndex := strings.TrimSpace(parts[3]) + windowName := strings.TrimSpace(parts[4]) + label := strings.TrimSpace(strings.TrimSpace(windowIndex+" ") + windowName) + if label == "" { + label = windowID + } + if sessionID != strings.TrimSpace(currentSessionID) && sessionName != "" { + label = sessionName + " · " + label + } + labels[windowID] = label + } + return labels +} + func (m *todoPanelModel) Init() tea.Cmd { return nil } @@ -235,13 +313,34 @@ func (m *todoPanelModel) updateList(key string) (tea.Model, tea.Cmd) { case "ctrl+e": return m.moveSelectedTodo(1) case "n", "left": - m.focusedScope = todoScopeWindow + m.setFocusedPane(m.lastWindowPane) m.clampSelections() case "i", "right": - m.focusedScope = todoScopeGlobal + m.setFocusedPane(todoPanelPaneGlobal) m.clampSelections() - case "enter", " ": - entry, ok := m.selectedEntry(m.focusedScope) + case "tab", "shift+tab": + m.toggleWindowPaneFocus() + m.clampSelections() + case "N": + if m.focusedPane == todoPanelPaneGlobal { + return m.transferSelectedTodo(todoScopeWindow) + } + case "I": + if m.focusedPane == todoPanelPaneWindow || m.focusedPane == todoPanelPaneAllWindows { + return m.transferSelectedTodo(todoScopeGlobal) + } + case "enter": + entry, ok := m.selectedEntry(m.focusedPane) + if !ok { + return m, nil + } + if err := focusTodoEntry(entry); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + return m, nil + } + return m, tea.Quit + case " ": + entry, ok := m.selectedEntry(m.focusedPane) if !ok { return m, nil } @@ -261,9 +360,9 @@ func (m *todoPanelModel) updateList(key string) (tea.Model, tea.Cmd) { m.mode = todoPanelModeAdd m.addText = nil m.addCursor = 0 - m.addScope = m.focusedScope + m.addScope = m.defaultAddScope() case "E": - if entry, ok := m.selectedEntry(m.focusedScope); ok { + if entry, ok := m.selectedEntry(m.focusedPane); ok { entryCopy := entry m.editEntry = &entryCopy m.mode = todoPanelModeEdit @@ -272,7 +371,7 @@ func (m *todoPanelModel) updateList(key string) (tea.Model, tea.Cmd) { m.addScope = entry.Scope } case "y": - entry, ok := m.selectedEntry(m.focusedScope) + entry, ok := m.selectedEntry(m.focusedPane) if !ok { return m, nil } @@ -282,7 +381,7 @@ func (m *todoPanelModel) updateList(key string) (tea.Model, tea.Cmd) { m.setStatus("Copied todo", 1500*time.Millisecond) } case "d", "x": - if entry, ok := m.selectedEntry(m.focusedScope); ok { + if entry, ok := m.selectedEntry(m.focusedPane); ok { m.deleteEntry = &entry m.mode = todoPanelModeConfirmDelete } @@ -314,9 +413,10 @@ func (m *todoPanelModel) updateAdd(key string) (tea.Model, tea.Cmd) { if err := addTmuxTodo(m.addScope, scopeID, title); err != nil { m.setStatus(err.Error(), 1500*time.Millisecond) } else { - m.focusedScope = m.addScope m.reloadEntries() - m.setSelectedIndex(m.addScope, maxInt(0, len(m.visibleEntries(m.addScope))-1)) + targetPane := m.paneForScope(m.addScope) + m.setFocusedPane(targetPane) + m.setSelectedIndex(targetPane, maxInt(0, len(m.visibleEntries(targetPane))-1)) } m.mode = todoPanelModeList return m, nil @@ -360,9 +460,10 @@ func (m *todoPanelModel) updateEdit(key string) (tea.Model, tea.Cmd) { if err := updateTmuxTodoTitleByIndex(m.editEntry.Scope, m.editEntry.ScopeID, m.editEntry.ItemIndex, title); err != nil { m.setStatus(err.Error(), 1500*time.Millisecond) } else { - selected := m.selectedIndex(m.focusedScope) + focusedPane := m.focusedPane + selected := m.selectedIndex(focusedPane) m.reloadEntries() - m.setSelectedIndex(m.focusedScope, selected) + m.setSelectedIndex(focusedPane, selected) } m.editEntry = nil m.mode = todoPanelModeList @@ -373,7 +474,7 @@ func (m *todoPanelModel) updateEdit(key string) (tea.Model, tea.Cmd) { } func (m *todoPanelModel) setSelectedPriority(priority int) (tea.Model, tea.Cmd) { - entry, ok := m.selectedEntry(m.focusedScope) + entry, ok := m.selectedEntry(m.focusedPane) if !ok { return m, nil } @@ -381,15 +482,20 @@ func (m *todoPanelModel) setSelectedPriority(priority int) (tea.Model, tea.Cmd) m.setStatus(err.Error(), 1500*time.Millisecond) return m, nil } - selected := m.selectedIndex(m.focusedScope) + focusedPane := m.focusedPane + selected := m.selectedIndex(focusedPane) m.reloadEntries() - m.setSelectedIndex(m.focusedScope, selected) + m.setSelectedIndex(focusedPane, selected) return m, nil } func (m *todoPanelModel) moveSelectedTodo(delta int) (tea.Model, tea.Cmd) { - entries := m.visibleEntries(m.focusedScope) - selected := m.selectedIndex(m.focusedScope) + if m.focusedPane == todoPanelPaneAllWindows { + m.setStatus("Reorder unavailable in all windows", 1500*time.Millisecond) + return m, nil + } + entries := m.visibleEntries(m.focusedPane) + selected := m.selectedIndex(m.focusedPane) if len(entries) == 0 || selected < 0 || selected >= len(entries) { return m, nil } @@ -404,7 +510,52 @@ func (m *todoPanelModel) moveSelectedTodo(delta int) (tea.Model, tea.Cmd) { return m, nil } m.reloadEntries() - m.setSelectedIndex(m.focusedScope, target) + m.setSelectedIndex(m.focusedPane, target) + return m, nil +} + +func (m *todoPanelModel) transferSelectedTodo(targetScope todoScope) (tea.Model, tea.Cmd) { + entry, ok := m.selectedEntry(m.focusedPane) + if !ok { + return m, nil + } + if entry.Scope == targetScope { + return m, nil + } + targetScopeID := m.scopeID(targetScope) + if targetScope == todoScopeWindow && strings.TrimSpace(targetScopeID) == "" { + m.setStatus("window todo scope unavailable", 1500*time.Millisecond) + return m, nil + } + sourcePane := m.focusedPane + targetPane := m.paneForScope(targetScope) + sourceSelected := m.selectedIndex(sourcePane) + targetItemIndex := len(m.allEntries(targetPane)) + if err := moveTmuxTodoToScopeByIndex(entry.Scope, entry.ScopeID, entry.ItemIndex, targetScope, targetScopeID); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + return m, nil + } + m.reloadEntries() + if entry.Done && !m.showCompleted { + movedEntry := entry + movedEntry.Scope = targetScope + movedEntry.ScopeID = targetScopeID + movedEntry.ItemIndex = targetItemIndex + movedEntry.PanelPane = targetPane + m.keepVisibleDone[todoEntryKey(movedEntry)] = true + } + m.setSelectedIndex(sourcePane, sourceSelected) + targetSelected := 0 + for idx, candidate := range m.visibleEntries(targetPane) { + if candidate.Scope == targetScope && candidate.ScopeID == targetScopeID && candidate.ItemIndex == targetItemIndex { + targetSelected = idx + break + } + } + m.setSelectedIndex(targetPane, targetSelected) + m.clampSelections() + m.setFocusedPane(targetPane) + m.setStatus(fmt.Sprintf("Moved todo to %s", strings.ToLower(todoScopeLabel(targetScope))), 1500*time.Millisecond) return m, nil } @@ -432,7 +583,7 @@ func (m *todoPanelModel) updateConfirmDelete(key string) (tea.Model, tea.Cmd) { func (m *todoPanelModel) View() string { w := m.width h := m.height - if w < 52 || h < 12 { + if w < 52 || h < 14 { return m.styles.title.Render("Window too small") } @@ -463,15 +614,25 @@ func (m *todoPanelModel) renderList(w, h int) string { completedLabel = "shown" } headerLine := m.styles.title.Render(fmt.Sprintf("Todo Panel %d open %d done", openCount, doneCount)) - metaLine := m.styles.meta.Render(fmt.Sprintf("Window %d Global %d Completed %s", len(m.visibleEntries(todoScopeWindow)), len(m.visibleEntries(todoScopeGlobal)), completedLabel)) + metaLine := m.styles.meta.Render(fmt.Sprintf("Window %d All Windows %d Global %d Completed %s", len(m.visibleEntries(todoPanelPaneWindow)), len(m.visibleEntries(todoPanelPaneAllWindows)), len(m.visibleEntries(todoPanelPaneGlobal)), completedLabel)) contentH := h - 4 leftW := maxInt(24, (w-1)/2) rightW := maxInt(24, w-leftW-1) + upperLeftH := maxInt(4, (contentH-1)/2) + lowerLeftH := maxInt(4, contentH-upperLeftH-1) + if upperLeftH+lowerLeftH+1 > contentH { + lowerLeftH = maxInt(1, contentH-upperLeftH-1) + } + leftColumn := lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Width(leftW).Height(upperLeftH).Render(m.renderPane(todoPanelPaneWindow, leftW, upperLeftH)), + m.renderHorizontalDivider(leftW), + lipgloss.NewStyle().Width(leftW).Height(lowerLeftH).Render(m.renderPane(todoPanelPaneAllWindows, leftW, lowerLeftH)), + ) body := lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Width(leftW).Height(contentH).Render(m.renderColumn(todoScopeWindow, leftW, contentH)), + lipgloss.NewStyle().Width(leftW).Height(contentH).Render(leftColumn), m.renderDivider(contentH), - lipgloss.NewStyle().Width(rightW).Height(contentH).Render(m.renderColumn(todoScopeGlobal, rightW, contentH)), + lipgloss.NewStyle().Width(rightW).Height(contentH).Render(m.renderPane(todoPanelPaneGlobal, rightW, contentH)), ) footer := m.renderFooter(w) @@ -485,40 +646,35 @@ func (m *todoPanelModel) renderList(w, h int) string { return lipgloss.NewStyle().Width(w).Height(h).Padding(0, 1).Render(view) } -func (m *todoPanelModel) renderColumn(scope todoScope, width, height int) string { - entries := m.visibleEntries(scope) - selected := m.selectedIndex(scope) +func (m *todoPanelModel) renderPane(pane todoPanelPane, width, height int) string { + entries := m.visibleEntries(pane) + selected := m.selectedIndex(pane) labelStyle := m.styles.mutedLabel - if m.focusedScope == scope { + if m.focusedPane == pane { labelStyle = m.styles.currentLabel } - label := labelStyle.Render(todoScopeLabel(scope)) + label := labelStyle.Render(todoPanelPaneLabel(pane)) header := lipgloss.JoinHorizontal(lipgloss.Left, label, " ", m.styles.meta.Render(fmt.Sprintf("%d items", len(entries)))) lines := []string{header, ""} if len(entries) == 0 { - message := "No open todos" - if m.showCompleted { - message = "No todos" - } + message, hint := m.emptyPaneState(pane) lines = append(lines, m.styles.itemMuted.Width(width).Render(message)) - if !m.showCompleted && len(m.scopeEntries(scope)) > 0 { - lines = append(lines, m.styles.subtle.Width(width).Render("Press c to show completed todos.")) - } else { - lines = append(lines, m.styles.subtle.Width(width).Render("Press a to add a todo here.")) + if hint != "" { + lines = append(lines, m.styles.subtle.Width(width).Render(hint)) } return lipgloss.NewStyle().Width(width).Height(height).Render(strings.Join(lines, "\n")) } visibleRows := maxInt(1, height-2) - offset := stableListOffset(m.selectedOffset(scope), selected, visibleRows, len(entries)) - m.setSelectedOffset(scope, offset) + offset := stableListOffset(m.selectedOffset(pane), selected, visibleRows, len(entries)) + m.setSelectedOffset(pane, offset) usedRows := 0 for idx := offset; usedRows < visibleRows; idx++ { if idx >= len(entries) { break } entry := entries[idx] - isSelected := m.focusedScope == scope && idx == selected + isSelected := m.focusedPane == pane && idx == selected entryLines := m.renderTodoEntryLines(entry, width, isSelected) remaining := visibleRows - usedRows if len(entryLines) > remaining { @@ -571,6 +727,18 @@ func (m *todoPanelModel) renderTodoEntryLines(entry tmuxTodoEntry, width int, is padding := maxInt(0, innerWidth-prefixWidth-lipgloss.Width(line)) rowLines = append(rowLines, boxStyle.Render(continuationPrefix+titleStyle.Render(line)+fillStyle.Render(strings.Repeat(" ", padding)))) } + if detail := strings.TrimSpace(entry.Detail); detail != "" { + metaStyle := m.styles.itemMeta + if entry.Done { + metaStyle = metaStyle.Foreground(lipgloss.Color("242")) + } + if isSelected { + metaStyle = metaStyle.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("247")) + } + detail = truncate(detail, maxInt(8, innerWidth-prefixWidth)) + padding := maxInt(0, innerWidth-prefixWidth-lipgloss.Width(detail)) + rowLines = append(rowLines, boxStyle.Render(continuationPrefix+metaStyle.Render(detail)+fillStyle.Render(strings.Repeat(" ", padding)))) + } return rowLines } @@ -630,11 +798,11 @@ func (m *todoPanelModel) renderFooter(w int) string { ) } else { footer = pickRenderedShortcutFooter(contentWidth, renderSegments, - [][2]string{{"u/e", "move"}, {"Ctrl-U/E", "reorder"}, {"n/i", "column"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "delete"}, {"1/2/3", "priority"}, {"c", "completed"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, - [][2]string{{"u/e", "move"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"c", "done"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, - [][2]string{{"u/e", "move"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, - [][2]string{{"u/e", "move"}, {"n/i", "col"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, - [][2]string{{"u/e", "move"}, {"n/i", "col"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Ctrl-U/E", "reorder"}, {"n/i", "column"}, {"Tab", "window"}, {"Shift-N/I", "scope"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "delete"}, {"1/2/3", "priority"}, {"c", "completed"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"c", "done"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, [][2]string{{"Esc", "close"}, {footerHintToggleKey, "more"}}, ) } @@ -662,20 +830,20 @@ func (m *todoPanelModel) scopeID(scope todoScope) string { return m.windowID } -func (m *todoPanelModel) scopeEntries(scope todoScope) []tmuxTodoEntry { +func (m *todoPanelModel) allEntries(pane todoPanelPane) []tmuxTodoEntry { entries := make([]tmuxTodoEntry, 0, len(m.entries)) for _, entry := range m.entries { - if entry.Scope == scope { + if entry.PanelPane == pane { entries = append(entries, entry) } } return entries } -func (m *todoPanelModel) visibleEntries(scope todoScope) []tmuxTodoEntry { +func (m *todoPanelModel) visibleEntries(pane todoPanelPane) []tmuxTodoEntry { entries := make([]tmuxTodoEntry, 0, len(m.entries)) for _, entry := range m.entries { - if entry.Scope != scope { + if entry.PanelPane != pane { continue } if entry.Done && !m.showCompleted && !m.keepVisibleDone[todoEntryKey(entry)] { @@ -686,42 +854,56 @@ func (m *todoPanelModel) visibleEntries(scope todoScope) []tmuxTodoEntry { return entries } -func (m *todoPanelModel) selectedIndex(scope todoScope) int { - if scope == todoScopeGlobal { +func (m *todoPanelModel) selectedIndex(pane todoPanelPane) int { + switch pane { + case todoPanelPaneAllWindows: + return m.selectedAllWin + case todoPanelPaneGlobal: return m.selectedGlobal + default: + return m.selectedWindow } - return m.selectedWindow } -func (m *todoPanelModel) setSelectedIndex(scope todoScope, index int) { - if scope == todoScopeGlobal { +func (m *todoPanelModel) setSelectedIndex(pane todoPanelPane, index int) { + switch pane { + case todoPanelPaneAllWindows: + m.selectedAllWin = index + case todoPanelPaneGlobal: m.selectedGlobal = index - return + default: + m.selectedWindow = index } - m.selectedWindow = index } -func (m *todoPanelModel) selectedOffset(scope todoScope) int { - if scope == todoScopeGlobal { +func (m *todoPanelModel) selectedOffset(pane todoPanelPane) int { + switch pane { + case todoPanelPaneAllWindows: + return m.allWinOffset + case todoPanelPaneGlobal: return m.globalOffset + default: + return m.windowOffset } - return m.windowOffset } -func (m *todoPanelModel) setSelectedOffset(scope todoScope, offset int) { - if scope == todoScopeGlobal { +func (m *todoPanelModel) setSelectedOffset(pane todoPanelPane, offset int) { + switch pane { + case todoPanelPaneAllWindows: + m.allWinOffset = offset + case todoPanelPaneGlobal: m.globalOffset = offset - return + default: + m.windowOffset = offset } - m.windowOffset = offset } func (m *todoPanelModel) clampSelections() { - for _, scope := range []todoScope{todoScopeWindow, todoScopeGlobal} { - entries := m.visibleEntries(scope) - selected := m.selectedIndex(scope) + for _, pane := range []todoPanelPane{todoPanelPaneWindow, todoPanelPaneAllWindows, todoPanelPaneGlobal} { + entries := m.visibleEntries(pane) + selected := m.selectedIndex(pane) if len(entries) == 0 { - m.setSelectedIndex(scope, 0) + m.setSelectedIndex(pane, 0) continue } if selected < 0 { @@ -730,13 +912,13 @@ func (m *todoPanelModel) clampSelections() { if selected >= len(entries) { selected = len(entries) - 1 } - m.setSelectedIndex(scope, selected) + m.setSelectedIndex(pane, selected) } } -func (m *todoPanelModel) selectedEntry(scope todoScope) (tmuxTodoEntry, bool) { - entries := m.visibleEntries(scope) - selected := m.selectedIndex(scope) +func (m *todoPanelModel) selectedEntry(pane todoPanelPane) (tmuxTodoEntry, bool) { + entries := m.visibleEntries(pane) + selected := m.selectedIndex(pane) if len(entries) == 0 || selected < 0 || selected >= len(entries) { return tmuxTodoEntry{}, false } @@ -744,12 +926,12 @@ func (m *todoPanelModel) selectedEntry(scope todoScope) (tmuxTodoEntry, bool) { } func (m *todoPanelModel) moveSelection(delta int) { - entries := m.visibleEntries(m.focusedScope) + entries := m.visibleEntries(m.focusedPane) if len(entries) == 0 { return } - selected := clampInt(m.selectedIndex(m.focusedScope)+delta, 0, len(entries)-1) - m.setSelectedIndex(m.focusedScope, selected) + selected := clampInt(m.selectedIndex(m.focusedPane)+delta, 0, len(entries)-1) + m.setSelectedIndex(m.focusedPane, selected) } func (m *todoPanelModel) renderDivider(height int) string { @@ -760,6 +942,73 @@ func (m *todoPanelModel) renderDivider(height int) string { return strings.Join(lines, "\n") } +func (m *todoPanelModel) renderHorizontalDivider(width int) string { + return m.styles.divider.Render(strings.Repeat("─", maxInt(1, width))) +} + +func (m *todoPanelModel) emptyPaneState(pane todoPanelPane) (message, hint string) { + if m.showCompleted { + switch pane { + case todoPanelPaneAllWindows: + return "No todos in other windows", "" + case todoPanelPaneGlobal: + return "No todos", "Press a to add a todo here." + default: + return "No todos", "Press a to add a todo here." + } + } + if len(m.allEntries(pane)) > 0 { + return map[bool]string{true: "No open todos in other windows", false: "No open todos"}[pane == todoPanelPaneAllWindows], "Press c to show completed todos." + } + if pane == todoPanelPaneAllWindows { + return "No todos in other windows", "Tab switches the window panes." + } + return "No open todos", "Press a to add a todo here." +} + +func (m *todoPanelModel) setFocusedPane(pane todoPanelPane) { + m.focusedPane = pane + if pane == todoPanelPaneWindow || pane == todoPanelPaneAllWindows { + m.lastWindowPane = pane + } +} + +func (m *todoPanelModel) toggleWindowPaneFocus() { + if m.focusedPane == todoPanelPaneGlobal { + return + } + if m.focusedPane == todoPanelPaneWindow { + m.setFocusedPane(todoPanelPaneAllWindows) + return + } + m.setFocusedPane(todoPanelPaneWindow) +} + +func (m *todoPanelModel) defaultAddScope() todoScope { + if m.focusedPane == todoPanelPaneGlobal { + return todoScopeGlobal + } + return todoScopeWindow +} + +func (m *todoPanelModel) paneForScope(scope todoScope) todoPanelPane { + if scope == todoScopeGlobal { + return todoPanelPaneGlobal + } + return todoPanelPaneWindow +} + +func todoPanelPaneLabel(pane todoPanelPane) string { + switch pane { + case todoPanelPaneAllWindows: + return "ALL WINDOWS" + case todoPanelPaneGlobal: + return "GLOBAL" + default: + return "WINDOW" + } +} + func todoEntryKey(entry tmuxTodoEntry) string { return fmt.Sprintf("%d|%s|%d|%s", entry.Scope, entry.ScopeID, entry.ItemIndex, entry.Title) } @@ -830,6 +1079,33 @@ func (m *todoPanelModel) currentStatus() string { return m.status } +func focusTodoEntry(entry tmuxTodoEntry) error { + switch entry.Scope { + case todoScopeGlobal: + return fmt.Errorf("global todo has no tmux target") + case todoScopeSession: + sessionID := strings.TrimSpace(entry.ScopeID) + if sessionID == "" { + return fmt.Errorf("session todo has no tmux target") + } + return runTmux("switch-client", "-t", sessionID) + case todoScopeWindow: + windowID := strings.TrimSpace(entry.ScopeID) + if windowID == "" { + return fmt.Errorf("window todo has no tmux target") + } + sessionID, _, err := tmuxSessionForWindow(windowID) + if err == nil && strings.TrimSpace(sessionID) != "" { + if err := runTmux("switch-client", "-t", strings.TrimSpace(sessionID)); err != nil { + return err + } + } + return selectTmuxWindow(windowID) + default: + return fmt.Errorf("todo has no tmux target") + } +} + func renderTodoInputValue(text []rune, cursor int, styles todoPanelStyles) string { if cursor < 0 { cursor = 0 diff --git a/agent-tracker/cmd/agent/todos.go b/agent-tracker/cmd/agent/todos.go index 1b16526..3326209 100644 --- a/agent-tracker/cmd/agent/todos.go +++ b/agent-tracker/cmd/agent/todos.go @@ -49,6 +49,8 @@ type tmuxTodoEntry struct { ScopeName string IsCurrent bool ItemIndex int + PanelPane todoPanelPane + Detail string } func tmuxTodoStorePath() string { @@ -376,6 +378,24 @@ func moveTmuxTodoByIndex(scope todoScope, scopeID string, fromIndex, toIndex int return saveTmuxTodoStore(store) } +func moveTmuxTodoToScopeByIndex(scope todoScope, scopeID string, index int, targetScope todoScope, targetScopeID string) error { + store, err := loadTmuxTodoStore() + if err != nil { + return err + } + items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...) + if index < 0 || index >= len(items) { + return fmt.Errorf("index out of range") + } + item := items[index] + items = append(items[:index], items[index+1:]...) + targetItems := append([]tmuxTodoItem(nil), todoItemsForScope(store, targetScope, targetScopeID)...) + targetItems = append(targetItems, item) + setTodoItemsForScope(store, scope, scopeID, items) + setTodoItemsForScope(store, targetScope, targetScopeID, targetItems) + return saveTmuxTodoStore(store) +} + func countOpenTmuxTodos(scope todoScope, scopeID string) (int, error) { store, err := loadTmuxTodoStore() if err != nil { diff --git a/tmux/scripts/pane_starship_title.sh b/tmux/scripts/pane_starship_title.sh index a05de74..cb3a971 100755 --- a/tmux/scripts/pane_starship_title.sh +++ b/tmux/scripts/pane_starship_title.sh @@ -104,6 +104,11 @@ if [[ -z "$theme" ]]; then fi now=$(load_option "@op_work_now") question_pending=$(load_option "@op_question_pending") +pane_watching=$(load_option "@pane_watching") + +if [[ "$pane_watching" == "1" ]]; then + title="⏳ $title" +fi if ! opencode_active; then if [[ -n "$theme" || -n "$now" || -n "$question_pending" ]]; then @@ -130,6 +135,11 @@ if [[ -z "$summary_display" ]]; then exit 0 fi +if [[ "$pane_watching" == "1" ]]; then + summary_display="⏳ $summary_display" + title=${title#⏳ } +fi + reserved_width=$((${#summary_display} + 3)) prompt_width=$((width - reserved_width)) if (( prompt_width < 16 )); then diff --git a/tmux/scripts/watch_pane.sh b/tmux/scripts/watch_pane.sh index eee4c3e..f069f96 100755 --- a/tmux/scripts/watch_pane.sh +++ b/tmux/scripts/watch_pane.sh @@ -15,6 +15,12 @@ pane_shell=$(ps -o comm= -p "$pane_pid" 2>/dev/null | sed 's|.*/||; s/^-//') current_cmd=$(tmux display-message -p -t "$pane_id" '#{pane_current_command}' 2>/dev/null || true) [[ -z "$current_cmd" ]] && exit 0 +clear_pane_watch() { + tmux set-option -p -u -t "$pane_id" @pane_watching 2>/dev/null || true +} + +trap clear_pane_watch EXIT + notify_completion() { local tracker_bin message tracker_bin="$HOME/.config/agent-tracker/bin/agent" @@ -28,6 +34,7 @@ if [[ "$current_cmd" == "$pane_shell" ]]; then fi tmux set -w -t "$window_id" @watching 1 2>/dev/null || true +tmux set-option -p -t "$pane_id" @pane_watching 1 2>/dev/null || true tmux refresh-client -S while true; do