e7a8edfed8
La funcion direccionaba panes por indice literal ("console.0",
"windowID.0", filtro pane_index != "0"). El socket aislado de fleetview
(tmux -L fleet) hereda ~/.tmux.conf, asi que con `pane-base-index 1`
(config muy comun) el primer pane es el indice 1 y no existe el 0:
join-pane fallaba con "can't find pane: 0" tras haber hecho ya el
break-pane, dejando la sesion fleet con las windows desperdigadas y sin
el sidebar de la TUI.
Ahora resuelve el pane sidebar como el de MENOR pane_index y opera
siempre por pane_id (estable e inmune al base-index). Helpers nuevos:
tmuxConsolePanes, tmuxFirstPaneID, tmuxPanesSorted, tmuxSidebarWidth.
Tests actualizados a base-index-agnostico (localizan el sidebar por
menor indice, no por "0") y el default de ancho del sidebar pasa de 47
a 52 para coincidir con launch_fleetclaude.
Bump v1.0.0 -> v1.0.1 + Capability growth log.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
7.8 KiB
Go
214 lines
7.8 KiB
Go
//go:build !windows
|
|
|
|
package infra
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// TmuxSwapWindowIntoConsole trae el primer pane de <windowID> al pane derecho
|
|
// de la window "console" de <session> (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 <socket>) 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 <windowID> 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 <socket>).
|
|
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 <session>.
|
|
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 <windowID>.
|
|
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 <target> 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
|
|
}
|