diff --git a/functions/infra/tmux_swap_window_into_console.go b/functions/infra/tmux_swap_window_into_console.go index cd52cd98..849a9f98 100644 --- a/functions/infra/tmux_swap_window_into_console.go +++ b/functions/infra/tmux_swap_window_into_console.go @@ -4,23 +4,31 @@ 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 0 = 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 0 (TUI) a 40 columnas. +// 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 indice 0 = siempre la TUI fleetview (no se toca). +// - pane MAS A LA IZQUIERDA (menor pane_index) = siempre la TUI fleetview. // - cualquier otro pane en console = el Claude activo (puede no haber ninguno). // -// Idempotente: si ES ya la window console, no hace nada. Si el Claude -// objetivo ya esta en console, tampoco rompe nada (el break-pane se aplica al -// pane derecho que estuviera; si no lo hay, se salta). Opera SIEMPRE sobre el -// socket aislado pasado como parametro (tmux -L ). +// 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") @@ -32,13 +40,11 @@ func TmuxSwapWindowIntoConsole(socket, session, windowID string) error { return fmt.Errorf("tmux_swap_window_into_console: windowID vacio") } - consoleTarget := session + ":console" - - // Capturar el ancho ACTUAL del pane 0 (la TUI) antes de tocar nada, para - // preservarlo tras el break/join (que redistribuyen el espacio). Así el ancho - // del sidebar lo decide quien creó la sesión (launch_kittyclaude), no un valor - // fijo aquí. - width := tmuxPane0Width(socket, session) + // 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. @@ -51,30 +57,18 @@ func TmuxSwapWindowIntoConsole(socket, session, windowID string) error { return tmuxResizeConsoleTUI(socket, session, width) } - // 1. Localiza el pane derecho actual de console (cualquier pane con indice != 0). - out, stderr, err := runTmux(socket, "list-panes", "-t", consoleTarget, "-F", "#{pane_index} #{pane_id}") + // 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 fmt.Errorf("tmux_swap_window_into_console: list-panes de %q: %w (%s)", consoleTarget, err, stderr) + return err + } + if tuiPaneID == "" { + return fmt.Errorf("tmux_swap_window_into_console: console sin panes en %q", session) } - var rightPaneID string - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - if fields[0] != "0" { - rightPaneID = fields[1] - break - } - } - - // 2. Si existe un pane no-0 en console, sacarlo a su propia window (parking), - // detached y sin cambiar foco. + // 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) @@ -84,14 +78,19 @@ func TmuxSwapWindowIntoConsole(socket, session, windowID string) error { // 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). - src := windowID + ".0" - dst := consoleTarget + ".0" - if _, stderr, err := runTmux(socket, "join-pane", "-h", "-s", src, "-t", dst); err != nil { - return fmt.Errorf("tmux_swap_window_into_console: join-pane %q -> %q: %w (%s)", src, dst, err, stderr) + 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 0 (TUI) al que tenia antes del swap. - return tmuxResizeConsoleTUI(socket, session, width) + // 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 @@ -113,36 +112,102 @@ func tmuxConsoleWindowID(socket, session string) (string, error) { return "", fmt.Errorf("tmux_swap_window_into_console: window 'console' no encontrada en %q", session) } -// tmuxPane0Width devuelve el ancho a preservar para el pane 0 (la TUI). Solo -// tiene sentido leer el ancho actual si console ya tiene >1 pane (TUI + Claude); -// con un único pane, el pane 0 es full-width y no representa el sidebar, así que -// se usa el default (47 columnas). -func tmuxPane0Width(socket, session string) int { - const def = 52 - out, _, err := runTmux(socket, "list-panes", "-t", session+":console", "-F", "#{pane_index} #{pane_width}") +// 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 def + return "", "", fmt.Errorf("tmux_swap_window_into_console: %w", err) } - lines := strings.Split(strings.TrimSpace(out), "\n") - if len(lines) <= 1 { - return def + if len(panes) == 0 { + return "", "", nil } - for _, l := range lines { - f := strings.Fields(strings.TrimSpace(l)) - if len(f) >= 2 && f[0] == "0" { - if n, e := strconv.Atoi(f[1]); e == nil && n > 0 { - return n + 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 0 de console a width columnas. +// 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 { - target := session + ":console.0" - if _, stderr, err := runTmux(socket, "resize-pane", "-t", target, "-x", strconv.Itoa(width)); err != nil { - return fmt.Errorf("tmux_swap_window_into_console: resize-pane de %q a %d col: %w (%s)", target, width, err, stderr) + 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 } diff --git a/functions/infra/tmux_swap_window_into_console.md b/functions/infra/tmux_swap_window_into_console.md index f9d115fa..a3ba1696 100644 --- a/functions/infra/tmux_swap_window_into_console.md +++ b/functions/infra/tmux_swap_window_into_console.md @@ -3,10 +3,10 @@ name: tmux_swap_window_into_console kind: function lang: go domain: infra -version: "1.0.0" +version: "1.0.1" purity: impure signature: "func TmuxSwapWindowIntoConsole(socket, session, windowID string) error" -description: "Conmuta que Claude esta a la derecha de la TUI fleetview en una sesion tmux de un socket aislado (tmux -L ). Trae el primer pane de al pane derecho de la window 'console' (al lado del pane 0 = la TUI), parkea en su propia window el Claude que estuviera a la derecha (detached, sin robar foco) y re-fija el ancho del pane 0 a 40 columnas. Idempotente: si el objetivo ya es la window console no hace nada. Capa de control tmux de la app TUI fleetview." +description: "Conmuta que Claude esta a la derecha de la TUI fleetview en una sesion tmux de un socket aislado (tmux -L ). Trae el primer pane de al pane derecho de la window 'console' (al lado del pane sidebar = la TUI), parkea en su propia window el Claude que estuviera a la derecha (detached, sin robar foco) y re-fija el ancho del sidebar al que tuviera antes (default 52 col). El sidebar se resuelve como el pane de MENOR pane_index y se opera por pane_id, NO por indice literal 0: inmune a `pane-base-index 1` del ~/.tmux.conf del usuario. Idempotente: si el objetivo ya es la window console solo re-aplica el ancho. Capa de control tmux de la app TUI fleetview." tags: [claude-fleet, infra, tmux, claude, fleet, tui] uses_functions: [] uses_types: [] @@ -21,12 +21,12 @@ params: desc: "Sesion tmux que contiene la window 'console' (ej 'fleet'). El pane 0 de console es la TUI; el resto, el Claude activo." - name: "windowID" desc: "window_id (@N) de la window cuyo primer pane se quiere traer a la derecha de la TUI. Tipicamente el devuelto por tmux_new_claude_window o por tmux_map_claude_panes." -output: "nil en exito. Error si socket/session/windowID vienen vacios, si la window 'console' no existe en la sesion, o si alguno de los comandos tmux (list-panes, break-pane, join-pane, resize-pane) falla. El estado final de console: pane 0 = TUI (40 col) + pane derecho = el Claude de windowID." +output: "nil en exito. Error si socket/session/windowID vienen vacios, si la window 'console' no existe en la sesion, o si alguno de los comandos tmux (list-panes, break-pane, join-pane, resize-pane) falla. El estado final de console: pane sidebar (menor indice) = TUI (52 col por default) + pane derecho = el Claude de windowID." tested: true tests: ["TestTmuxSwapWindowIntoConsole", "TestTmuxSwapWindowIntoConsoleParksPrevious", "TestTmuxSwapWindowIntoConsoleEmptyArgs"] test_file_path: "functions/infra/tmux_swap_window_into_console_test.go" file_path: "functions/infra/tmux_swap_window_into_console.go" -notes: "Build tag //go:build !windows. Comparte runTmux con tmux_new_claude_window y tmux_map_claude_panes (mismo paquete infra). Secuencia interna: (1) list-panes de console y localiza el pane no-0 actual; (2) break-pane -d de ese pane si existe (parking); (3) join-pane -h del primer pane de windowID a console.0 (lado a lado); (4) resize-pane -x 40 del pane 0. Caso borde: si windowID ya ES la window console, solo re-aplica el resize. break-pane requiere que la window destino sea distinta del origen, garantizado por la comprobacion consoleID != windowID." +notes: "Build tag //go:build !windows. Comparte runTmux con tmux_new_claude_window y tmux_map_claude_panes (mismo paquete infra). Secuencia interna: (1) list-panes de console ordenados por pane_index, sidebar = menor indice (TUI), right = primer pane no-sidebar; (2) break-pane -d del right si existe (parking); (3) join-pane -h del primer pane de windowID a la derecha del sidebar (por pane_id); (4) resize-pane -x del sidebar por pane_id. Caso borde: si windowID ya ES la window console, solo re-aplica el resize. TODO targeting es por pane_id, NUNCA por indice literal 0 (rompia con 'can't find pane: 0' bajo pane-base-index 1 que el socket aislado hereda de ~/.tmux.conf). break-pane requiere que la window destino sea distinta del origen, garantizado por la comprobacion consoleID != windowID." --- ## Ejemplo @@ -52,9 +52,14 @@ Cada vez que el usuario conmuta en fleetview que Claude quiere ver a la derecha. ## Gotchas -- Idempotente: si el Claude objetivo ya es la window console, solo re-aplica el ancho de 40 col; no rompe nada. -- El pane indice 0 de console es SIEMPRE la TUI y nunca se mueve ni se parkea: la funcion solo toca el pane derecho (indice != 0). +- Idempotente: si el Claude objetivo ya es la window console, solo re-aplica el ancho del sidebar; no rompe nada. +- El pane sidebar de console (el de MENOR pane_index) es SIEMPRE la TUI y nunca se mueve ni se parkea: la funcion solo toca el pane derecho (cualquier otro pane). +- NO asume que el sidebar es el indice 0. El socket aislado (`tmux -L `) hereda `~/.tmux.conf`, asi que con `pane-base-index 1` (muy comun) el primer pane es el indice 1. Targetear `console.0` rompia con `can't find pane: 0` y dejaba console a medias (break ya hecho, join fallido). Por eso todo el targeting es por `pane_id`. - `join-pane` exige que la window origen sea distinta de console; la funcion lo comprueba (consoleID != windowID) y si coinciden no hace el join. - `break-pane -d` saca el Claude anterior a su propia window detached: sigue vivo y parkeado, no se mata. -- El ancho de 40 col se re-fija SIEMPRE al final (incluso en el caso borde) para que la TUI no se reduzca tras el reflow del split. +- El ancho del sidebar se re-fija SIEMPRE al final (incluso en el caso borde) para que la TUI no se reduzca tras el reflow del split. - Opera SIEMPRE sobre el socket aislado (`tmux -L `). Build tag `//go:build !windows`. + +## Capability growth log + +- v1.0.1 (2026-06-17) — fix: resuelve el pane sidebar por menor `pane_index` y opera por `pane_id` en vez de `console.0`/indice 0. Antes rompia con `can't find pane: 0` bajo `pane-base-index 1` (el socket aislado hereda ~/.tmux.conf), dejando la sesion fleet con las windows desperdigadas y sin sidebar. Tests actualizados a base-index-agnostico; default de ancho del sidebar 47 -> 52 (coincide con launch_fleetclaude). diff --git a/functions/infra/tmux_swap_window_into_console_test.go b/functions/infra/tmux_swap_window_into_console_test.go index 85cd3195..318f9f0d 100644 --- a/functions/infra/tmux_swap_window_into_console_test.go +++ b/functions/infra/tmux_swap_window_into_console_test.go @@ -62,30 +62,38 @@ func TestTmuxSwapWindowIntoConsole(t *testing.T) { t.Fatalf("TmuxSwapWindowIntoConsole: %v", err) } - // Tras el swap: console tiene 2 panes y el pane 0 mide 47 columnas (default - // del sidebar, ya que la console arrancó con un solo pane full-width). + // Tras el swap: console tiene 2 panes y el sidebar (pane de MENOR indice) + // mide 52 columnas (default del sidebar, ya que la console arrancó con un + // solo pane full-width). Se localiza por menor pane_index, NO por indice + // literal "0": bajo `pane-base-index 1` (que el socket hereda de + // ~/.tmux.conf) el primer pane es el indice 1. panes := consolePanes(t, socket, session) if len(panes) != 2 { t.Fatalf("console deberia tener 2 panes tras swap, tiene %d: %v", len(panes), panes) } - var width0 int - found0 := false + minIdx, sidebarWidth, found := -1, 0, false for _, line := range panes { f := strings.Fields(line) - if len(f) >= 3 && f[0] == "0" { - found0 = true - w, err := strconv.Atoi(f[2]) - if err != nil { - t.Fatalf("ancho del pane 0 no numerico: %q", f[2]) - } - width0 = w + if len(f) < 3 { + continue + } + idx, err := strconv.Atoi(f[0]) + if err != nil { + t.Fatalf("pane_index no numerico: %q", f[0]) + } + w, err := strconv.Atoi(f[2]) + if err != nil { + t.Fatalf("ancho de pane no numerico: %q", f[2]) + } + if !found || idx < minIdx { + minIdx, sidebarWidth, found = idx, w, true } } - if !found0 { - t.Fatal("no se encontro el pane 0 en console") + if !found { + t.Fatal("no se encontro ningun pane en console") } - if width0 != 47 { - t.Errorf("ancho del pane 0 = %d, esperado 47", width0) + if sidebarWidth != 52 { + t.Errorf("ancho del sidebar (pane menor indice = %d) = %d, esperado 52", minIdx, sidebarWidth) } }