//go:build !windows package infra import ( "fmt" "sort" "strconv" "strings" ) // TmuxSwapWindowIntoConsole trae el primer pane de al pane derecho // de la window "console" de (al lado del pane sidebar = la TUI), // parkeando el Claude que estuviera a la derecha en su propia window (detached, // sin robar foco), y re-fija el ancho del pane sidebar al que tuviera antes. // // Contrato de la window console: // - pane MAS A LA IZQUIERDA (menor pane_index) = siempre la TUI fleetview. // - cualquier otro pane en console = el Claude activo (puede no haber ninguno). // // NOTA base-index: el socket aislado (tmux -L ) sigue leyendo // ~/.tmux.conf, asi que si el usuario tiene `pane-base-index 1` (muy comun) el // primer pane es el indice 1, no 0. Por eso esta funcion NUNCA referencia panes // por indice literal "0": resuelve el pane sidebar como el de MENOR pane_index y // opera siempre por pane_id, que es estable e inmune al base-index. Targetear // "console.0" rompia con "can't find pane: 0" en esas configuraciones. // // Idempotente: si ES ya la window console, no hace nada salvo // re-fijar el ancho del sidebar. Si el Claude objetivo ya esta en console, // tampoco rompe nada. Opera SIEMPRE sobre el socket aislado pasado como // parametro (tmux -L ). func TmuxSwapWindowIntoConsole(socket, session, windowID string) error { if socket == "" { return fmt.Errorf("tmux_swap_window_into_console: socket vacio") } if session == "" { return fmt.Errorf("tmux_swap_window_into_console: session vacia") } if windowID == "" { return fmt.Errorf("tmux_swap_window_into_console: windowID vacio") } // Capturar el ancho ACTUAL del pane sidebar (la TUI) antes de tocar nada, // para preservarlo tras el break/join (que redistribuyen el espacio). Asi el // ancho del sidebar lo decide quien creo la sesion (launch_fleetclaude), no un // valor fijo aqui. width := tmuxSidebarWidth(socket, session) // Caso borde: si windowID ya ES la window console, no hay nada que hacer. // Resolvemos el window_id real de console y lo comparamos con el pedido. consoleID, err := tmuxConsoleWindowID(socket, session) if err != nil { return err } if consoleID == windowID { // El objetivo ya es console. Solo re-fijamos el ancho de la TUI. return tmuxResizeConsoleTUI(socket, session, width) } // 1. Localiza el pane sidebar (TUI, menor indice) y el pane derecho actual // (cualquier otro) de console, ambos por pane_id. tuiPaneID, rightPaneID, err := tmuxConsolePanes(socket, session) if err != nil { return err } if tuiPaneID == "" { return fmt.Errorf("tmux_swap_window_into_console: console sin panes en %q", session) } // 2. Si existe un pane no-sidebar en console, sacarlo a su propia window // (parking), detached y sin cambiar foco. if rightPaneID != "" { if _, stderr, err := runTmux(socket, "break-pane", "-d", "-s", rightPaneID); err != nil { return fmt.Errorf("tmux_swap_window_into_console: break-pane de %q: %w (%s)", rightPaneID, err, stderr) } } // 3. Traer el primer pane de windowID a la derecha de la TUI (-h = split // horizontal, lado a lado). join-pane requiere que origen y destino sean // windows distintas (ya garantizado: consoleID != windowID arriba). srcPaneID, err := tmuxFirstPaneID(socket, windowID) if err != nil { return err } if _, stderr, err := runTmux(socket, "join-pane", "-h", "-s", srcPaneID, "-t", tuiPaneID); err != nil { return fmt.Errorf("tmux_swap_window_into_console: join-pane %q -> %q: %w (%s)", srcPaneID, tuiPaneID, err, stderr) } // 4. Re-fijar el ancho del pane sidebar (TUI) al que tenia antes del swap. if _, stderr, err := runTmux(socket, "resize-pane", "-t", tuiPaneID, "-x", strconv.Itoa(width)); err != nil { return fmt.Errorf("tmux_swap_window_into_console: resize-pane de %q a %d col: %w (%s)", tuiPaneID, width, err, stderr) } return nil } // tmuxConsoleWindowID resuelve el window_id (ej "@3") de la window llamada // "console" en . func tmuxConsoleWindowID(socket, session string) (string, error) { out, stderr, err := runTmux(socket, "list-windows", "-t", session, "-F", "#{window_id} #{window_name}") if err != nil { return "", fmt.Errorf("tmux_swap_window_into_console: list-windows de %q: %w (%s)", session, err, stderr) } for _, line := range strings.Split(strings.TrimSpace(out), "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { continue } if fields[1] == "console" { return fields[0], nil } } return "", fmt.Errorf("tmux_swap_window_into_console: window 'console' no encontrada en %q", session) } // tmuxConsolePanes devuelve el pane_id del sidebar (pane de MENOR pane_index = // la TUI) y el pane_id del primer pane no-sidebar (el Claude actual, si lo hay) // de la window console. rightPaneID es "" si console solo tiene el sidebar. // Inmune al base-index porque ordena por pane_index numerico, no asume "0". func tmuxConsolePanes(socket, session string) (tuiPaneID, rightPaneID string, err error) { panes, err := tmuxPanesSorted(socket, session+":console") if err != nil { return "", "", fmt.Errorf("tmux_swap_window_into_console: %w", err) } if len(panes) == 0 { return "", "", nil } tuiPaneID = panes[0].id if len(panes) > 1 { rightPaneID = panes[1].id } return tuiPaneID, rightPaneID, nil } // tmuxFirstPaneID devuelve el pane_id del primer pane (menor pane_index) de la // window . func tmuxFirstPaneID(socket, windowID string) (string, error) { panes, err := tmuxPanesSorted(socket, windowID) if err != nil { return "", fmt.Errorf("tmux_swap_window_into_console: %w", err) } if len(panes) == 0 { return "", fmt.Errorf("tmux_swap_window_into_console: window %q sin panes", windowID) } return panes[0].id, nil } type tmuxPaneRef struct { index int id string width int } // tmuxPanesSorted lista los panes de ordenados por pane_index // ascendente. El primero es el mas a la izquierda/arriba (el sidebar en // console). func tmuxPanesSorted(socket, target string) ([]tmuxPaneRef, error) { out, stderr, err := runTmux(socket, "list-panes", "-t", target, "-F", "#{pane_index} #{pane_id} #{pane_width}") if err != nil { return nil, fmt.Errorf("list-panes de %q: %w (%s)", target, err, stderr) } var panes []tmuxPaneRef for _, line := range strings.Split(strings.TrimSpace(out), "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { continue } idx, e := strconv.Atoi(fields[0]) if e != nil { continue } ref := tmuxPaneRef{index: idx, id: fields[1]} if len(fields) >= 3 { if w, e := strconv.Atoi(fields[2]); e == nil { ref.width = w } } panes = append(panes, ref) } sort.Slice(panes, func(i, j int) bool { return panes[i].index < panes[j].index }) return panes, nil } // tmuxSidebarWidth devuelve el ancho a preservar para el pane sidebar (la TUI). // Solo tiene sentido leer el ancho actual si console ya tiene >1 pane (TUI + // Claude); con un unico pane, el sidebar es full-width y no representa el ancho // real del sidebar, asi que se usa el default. func tmuxSidebarWidth(socket, session string) int { const def = 52 panes, err := tmuxPanesSorted(socket, session+":console") if err != nil || len(panes) <= 1 { return def } if w := panes[0].width; w > 0 { return w } return def } // tmuxResizeConsoleTUI fija el ancho del pane sidebar de console a width // columnas, resolviendo su pane_id (no asume el indice 0). func tmuxResizeConsoleTUI(socket, session string, width int) error { tuiPaneID, _, err := tmuxConsolePanes(socket, session) if err != nil { return err } if tuiPaneID == "" { return nil // console sin panes: nada que redimensionar } if _, stderr, err := runTmux(socket, "resize-pane", "-t", tuiPaneID, "-x", strconv.Itoa(width)); err != nil { return fmt.Errorf("tmux_swap_window_into_console: resize-pane de %q a %d col: %w (%s)", tuiPaneID, width, err, stderr) } return nil }