agent tracker + tmux config update

This commit is contained in:
David Chen 2026-04-17 11:18:20 -07:00
parent 83d01d4a91
commit cf365fc124
9 changed files with 628 additions and 145 deletions

View file

@ -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 \

View file

@ -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 := &registry{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{}

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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