diff --git a/build_docs/0.1.1-window-navigation/IMPLEMENTATION_PLAN.md b/build_docs/0.1.1-window-navigation/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..2c0bb3a --- /dev/null +++ b/build_docs/0.1.1-window-navigation/IMPLEMENTATION_PLAN.md @@ -0,0 +1,294 @@ +# Implementation Plan: Window Navigation + +## Feature Summary + +Extend the home screen sidebar to show tmux windows as expandable sub-items under sessions. Press Right arrow or Enter on a session to expand its windows; selecting a window shows that window's output in the preview pane. No config toggle needed — windows always show in the hierarchy. + +## Files Modified + +- `src/core/types.ts` — add `TmuxWindow` interface +- `src/core/tmux.ts` — add `listWindows()`, `selectWindowSync()` functions +- `src/tui/util/groups.ts` — extend `GroupedItem` for window type +- `src/tui/routes/home.tsx` — window expansion, `WindowItem`, window-specific capture + +## Dependencies + +None. This PR is independent. + +--- + +## Stage 2.1: tmux Window Listing + +Add the ability to list windows for a tmux session. + +### Steps + +#### 2.1.1 Add `TmuxWindow` type +**File:** `src/core/types.ts` + +Add interface: +```typescript +export interface TmuxWindow { + index: number + name: string + active: boolean // true if this is the currently active window +} +``` + +#### 2.1.2 Add `listWindows()` function +**File:** `src/core/tmux.ts` + +Add function using `execFileAsync` (NOT `execAsync`) to avoid shell escaping issues with `\t`: +```typescript +import { execFile } from "child_process" +import { promisify } from "util" +const execFileAsync = promisify(execFile) + +export async function listWindows(sessionName: string): Promise { + // Use execFile to avoid shell escaping issues with format strings containing \t + const args = tmuxSpawnArgs( + "list-windows", "-t", sessionName, + "-F", "#{window_index}\t#{window_name}\t#{window_active}" + ) + const { stdout } = await execFileAsync("tmux", args) + return stdout.trim().split("\n").filter(Boolean).map(line => { + const parts = line.split("\t") + return { + index: parseInt(parts[0]!, 10), + name: parts[1] || "", + active: parts[2] === "1" + } + }) +} +``` + +#### 2.1.3 Add `selectWindowSync()` function +**File:** `src/core/tmux.ts` + +Add function for selecting a window before attaching: +```typescript +export function selectWindowSync(sessionName: string, windowIndex: number): void { + try { + execSync(tmuxCmd(`select-window -t "${sessionName}:${windowIndex}"`), { timeout: 3000 }) + } catch { + // Window might not exist + } +} +``` + +**Important:** Ensure `execFileAsync` and `TmuxWindow` are properly imported. + +**Success criteria:** `listWindows("agentorch_test-abc")` returns an array of `TmuxWindow` objects for the given session. + +#### 2.1.4 Extend `GroupedItem` for windows +**File:** `src/tui/util/groups.ts` + +Add `"window"` to the `GroupedItem.type` union and add optional fields: +```typescript +export interface GroupedItem { + type: "group" | "session" | "window" + group?: Group + session?: Session + window?: TmuxWindow + groupPath: string + isLast: boolean + groupIndex?: number + sessionExpanded?: boolean // true if this window's parent session is expanded +} +``` + +Note: `TmuxWindow` must be imported from `@/core/types`. + +**Success criteria:** TypeScript compiles. Existing `flattenGroupTree()` callers still work (windows are only added separately, not by `flattenGroupTree`). + +--- + +## Stage 2.2: Window Navigation in Home Screen + +Add window sub-items under sessions in the home screen sidebar. + +### Steps + +#### 2.2.1 Add window expansion state to Home component +**File:** `src/tui/routes/home.tsx` + +Add signals: +```typescript +const [expandedSessions, setExpandedSessions] = createSignal>(new Set()) +const [sessionWindows, setSessionWindows] = createSignal>(new Map()) +``` + +Add helper to toggle session expansion and fetch windows: +```typescript +async function toggleSessionWindows(session: Session) { + const key = session.id + const expanded = new Set(expandedSessions()) + if (expanded.has(key)) { + expanded.delete(key) + } else { + expanded.add(key) + // Fetch windows for this session + if (session.tmuxSession) { + try { + const windows = await listWindows(session.tmuxSession) + setSessionWindows(prev => new Map(prev).set(key, windows)) + } catch { + // tmux session might not exist + } + } + } + setExpandedSessions(expanded) +} +``` + +#### 2.2.2 Create flat items list with windows +**File:** `src/tui/routes/home.tsx` + +Create a new memo that extends `groupedItems()` with window sub-items: +```typescript +const flatItems = createMemo(() => { + const items: GroupedItem[] = [] + const expanded = expandedSessions() + const windows = sessionWindows() + + for (const item of groupedItems()) { + items.push(item) + if (item.type === "session" && item.session && expanded.has(item.session.id)) { + const wins = windows.get(item.session.id) || [] + for (let i = 0; i < wins.length; i++) { + items.push({ + type: "window", + window: wins[i], + session: item.session, + groupPath: item.groupPath, + isLast: i === wins.length - 1, + sessionExpanded: true, + }) + } + } + } + return items +}) +``` + +Update `selectedIndex` bounds, `move()`, `selectedItem`, `selectedSession` to use `flatItems` instead of `groupedItems`. + +#### 2.2.3 Add `WindowItem` component +**File:** `src/tui/routes/home.tsx` + +Add component (similar to `SessionItem` but for windows): +```typescript +function WindowItem(props: { window: TmuxWindow; session: Session; index: number }) { + const isSelected = createMemo(() => props.index === selectedIndex()) + return ( + setSelectedIndex(props.index)} + onMouseOver={() => setSelectedIndex(props.index)} + > + + {props.window.active ? "▶" : " "} + + + + {props.window.name} + + + ) +} +``` + +#### 2.2.4 Handle session expand/collapse on Right +**File:** `src/tui/routes/home.tsx` + +Modify the Right arrow handler to check if a session has windows. Use Right arrow to expand windows (like groups), Enter to attach. This is consistent with the group/session pattern. + +Modify Right arrow handler: +```typescript +if (evt.name === "right" || evt.name === "l") { + const item = selectedItem() + if (item?.type === "group" && item.group && !item.group.expanded) { + sync.group.toggle(item.group.path) + } else if (item?.type === "session" && item.session) { + // If session has windows and isn't expanded, expand windows + const isExpanded = expandedSessions().has(item.session.id) + if (item.session.tmuxSession && !isExpanded) { + toggleSessionWindows(item.session) + } else { + handleAttach(item.session) + } + } +} +``` + +Also modify Enter handler to expand windows on first press: +```typescript +if (evt.name === "return") { + const item = selectedItem() + if (item?.type === "session" && item.session) { + const isExpanded = expandedSessions().has(item.session.id) + if (item.session.tmuxSession && !isExpanded) { + toggleSessionWindows(item.session) + } else { + handleAttach(item.session) + } + } + // ... existing group/window handlers +} +``` + +Add Left arrow collapse for windows: +```typescript +if (evt.name === "left" || evt.name === "h") { + const item = selectedItem() + if (item?.type === "window" && item.session) { + toggleSessionWindows(item.session) + } + // ... existing group handlers +} +``` + +#### 2.2.5 Capture window-specific output in preview +**File:** `src/tui/routes/home.tsx` + +Modify the preview capture effect to target a specific window when one is selected: +```typescript +const captureTarget = createMemo(() => { + const item = selectedItem() + if (item?.type === "window" && item.session?.tmuxSession && item.window) { + return `${item.session.tmuxSession}:${item.window.index}` + } + const session = selectedSession() + return session?.tmuxSession || "" +}) +``` + +Update the `capturePane` call in the preview effect to use `captureTarget()` instead of `session.tmuxSession`. + +#### 2.2.6 Update the rendering loop +**File:** `src/tui/routes/home.tsx` + +Switch from `groupedItems()` to `flatItems()` in the scrollbox rendering, adding a case for `window` type: +```tsx + + {(item, index) => renderFlatItem(item, index())} + +``` + +Where `renderFlatItem` dispatches to `GroupHeader`, `WindowItem`, or `SessionItem` based on `item.type`. + +**Success criteria:** +- Pressing Right on a session with windows expands to show window list +- Selecting a window shows that window's output in the preview pane +- Enter on a session still attaches (after expanding windows) +- Groups still expand/collapse as before + +--- + +## Risk Areas + +1. **Window fetch latency:** `listWindows()` spawns a subprocess per session expansion. Should be fast (< 100ms) but could be slow on heavily loaded systems. Mitigation: fetch once and cache per session. diff --git a/build_docs/0.1.1-window-navigation/PRD.md b/build_docs/0.1.1-window-navigation/PRD.md new file mode 100644 index 0000000..3c78ca7 --- /dev/null +++ b/build_docs/0.1.1-window-navigation/PRD.md @@ -0,0 +1,61 @@ +# Product Requirements Document: Window Navigation + +## Overview + +Extending the sidebar hierarchy from Groups > Sessions to Groups > Sessions > Windows, allowing users to see and navigate to individual tmux windows from the TUI. + +## Problem Statement + +The sidebar only shows sessions, not the windows within them — users cannot see or navigate to individual tmux windows from the TUI. + +## Goals + +- Show tmux windows as a navigable sub-level under sessions in the sidebar +- Allow selecting a window to preview its output +- No config toggle needed — windows always show in the hierarchy (expanded via arrow keys) + +## Non-Goals + +- Per-pane capture or navigation — only window-level granularity +- Window navigation in the always-visible sidebar (that is PR 3, which depends on this PR) + +## Requirements + +### Functional Requirements + +| ID | Requirement | Priority | Acceptance Criteria | +|----|-------------|----------|---------------------| +| FR-9 | List tmux windows | should | Sidebar shows tmux windows as expandable items under each session | +| FR-10 | Window-level output capture | should | Selecting a window in the sidebar captures that specific window's output in the preview pane | +| FR-12 | Settings dialog exposure | must | New settings (if any) appear in the `c` settings dialog | + +Note: This PR does not add a settings toggle. FR-12 is listed for completeness since other PRs in this feature set add settings. This PR has no user-facing settings. + +### Non-Functional Requirements + +| ID | Requirement | Priority | Target | +|----|-------------|----------|--------| +| NFR-2 | Performance of window listing | should | Window list fetch adds < 100ms latency per session expansion | + +## User Stories + +| ID | As a... | I want to... | So that... | Priority | +|----|---------|-------------|------------|----------| +| US-6 | power user | see windows under each session in the sidebar | I can navigate to specific windows without full attach | should | +| US-7 | power user | select a window to preview its output | I can monitor what's happening in each window | should | + +## Success Criteria + +- Pressing Right on a session with windows expands to show window list +- Selecting a window shows that window's output in the preview pane +- Enter on a session still attaches (after expanding windows) +- Groups still expand/collapse as before + +## Dependencies + +- Existing `capturePane()` in `tmux.ts` for output capture +- Existing `flattenGroupTree()` in `groups.ts` for sidebar structure + +## Implementation Notes + +The `listWindows()` function must use `execFileAsync` with `tmuxSpawnArgs()` (not `execAsync` with `tmuxCmd()`) to avoid shell escaping bugs with `\t` in the tmux format string `#{window_index}\t#{window_name}\t#{window_active}`. The original implementation using `execAsync` had a bug where the shell interpreted `\t` as a tab character in the command string, corrupting the output. diff --git a/src/core/config.ts b/src/core/config.ts index 0345af6..94189c8 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -30,6 +30,8 @@ export interface AppConfig { autoHibernateMinutes?: number // 0 = disabled, default 0 autoHibernatePrompted?: boolean // true = user has seen the prompt lastRemoteSession?: LastRemoteSession // Last used remote session values + importUserTmuxConfig?: boolean + expandSidebar?: boolean } const CONFIG_DIR = path.join(os.homedir(), ".agent-view") @@ -44,7 +46,9 @@ const DEFAULT_CONFIG: AppConfig = { }, defaultGroup: "default", shortcuts: [], - recents: [] + recents: [], + importUserTmuxConfig: false, + expandSidebar: false, } // Cached config for sync access diff --git a/src/core/tmux.conf b/src/core/tmux.conf index b898ae0..78bb426 100644 --- a/src/core/tmux.conf +++ b/src/core/tmux.conf @@ -43,3 +43,10 @@ set-option -g set-titles-string "#{window_name}" # --- User customizations --- # Create ~/.agent-view/tmux-user.conf to add your own settings if-shell "[ -f ~/.agent-view/tmux-user.conf ]" "source-file ~/.agent-view/tmux-user.conf" + +# --- User config import --- +# When ~/.agent-view/import-user-tmux exists, source the user's ~/.tmux.conf. +# This runs LAST so the user's config takes full precedence over all settings above. +# Note: AgentView reserves Ctrl+Q/K/L/T/O/]/\ — if your config rebinds these, +# session switching and the command palette may not work correctly. +if-shell "[ -f ~/.agent-view/import-user-tmux ]" "source-file ~/.tmux.conf" diff --git a/src/core/tmux.ts b/src/core/tmux.ts index 953e280..ef1f7a5 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -6,7 +6,7 @@ * to avoid conflicts with the user's tmux configuration. */ -import { spawn, exec, execFile } from "child_process" +import { spawn, exec, execFile, execSync } from "child_process" import { promisify } from "util" import path from "path" import os from "os" @@ -35,10 +35,12 @@ const SESSION_LIST_SIGNAL = "/tmp/agent-view-session-list" // so we never load or interfere with the user's ~/.tmux.conf. // The config is defined in src/core/tmux.conf and inlined at build time. import TMUX_CONF from "./tmux.conf" with { type: "text" } +import type { TmuxWindow } from "./types" const TMUX_SOCKET = "agent-view" const CONFIG_DIR = path.join(os.homedir(), ".agent-view") const CONFIG_PATH = path.join(CONFIG_DIR, "tmux.conf") +const USER_TMUX_IMPORT_MARKER = path.join(CONFIG_DIR, "import-user-tmux") let configWritten = false @@ -354,6 +356,30 @@ export function attachSession(name: string): void { }) } +export async function listWindows(sessionName: string): Promise { + const args = tmuxSpawnArgs( + "list-windows", "-t", sessionName, + "-F", "#{window_index}\t#{window_name}\t#{window_active}" + ) + const { stdout } = await execFileAsync("tmux", args) + return stdout.trim().split("\n").filter(Boolean).map(line => { + const parts = line.split("\t") + return { + index: parseInt(parts[0]!, 10), + name: parts[1] || "", + active: parts[2] === "1" + } + }) +} + +export function selectWindowSync(sessionName: string, windowIndex: number): void { + try { + execSync(tmuxCmd(`select-window -t "${sessionName}:${windowIndex}"`), { timeout: 3000 }) + } catch { + // Window might not exist + } +} + /** * List all sessions with our prefix */ @@ -620,6 +646,22 @@ export async function attachWithPty(sessionName: string): Promise { }) } +export async function setUserTmuxImport(enabled: boolean): Promise { + if (enabled) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }) + fs.writeFileSync(USER_TMUX_IMPORT_MARKER, "", { mode: 0o600 }) + } else { + try { fs.unlinkSync(USER_TMUX_IMPORT_MARKER) } catch {} + } + configWritten = false + ensureConfig() + try { + await execAsync(tmuxCmd("source-file " + CONFIG_PATH), { timeout: 3000 }) + } catch { + // Server might not be running yet + } +} + /** * Check if command palette was requested during attached session */ diff --git a/src/core/types.ts b/src/core/types.ts index d664da4..1c6e147 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -40,6 +40,12 @@ export interface Session { acknowledged: boolean } +export interface TmuxWindow { + index: number + name: string + active: boolean +} + export interface RemoteSession extends Session { remoteName: string // Key from remotes config remoteHost: string // SSH host diff --git a/src/tui/component/dialog-settings.tsx b/src/tui/component/dialog-settings.tsx index 9d7a6fc..bb54556 100644 --- a/src/tui/component/dialog-settings.tsx +++ b/src/tui/component/dialog-settings.tsx @@ -9,6 +9,7 @@ import { useToast } from "@tui/ui/toast" import { useTheme } from "@tui/context/theme" import { useSync } from "@tui/context/sync" import { getConfig, loadConfig, saveConfig } from "@/core/config" +import { setUserTmuxImport } from "@/core/tmux" import type { Tool } from "@/core/types" const TOOL_OPTIONS: { title: string; value: Tool }[] = [ @@ -64,6 +65,16 @@ export function DialogSettings() { value: "autoHibernate" as const, footer: formatHibernate(config.autoHibernateMinutes || 0), }, + { + title: "Import user tmux config", + value: "importUserTmuxConfig" as const, + footer: config.importUserTmuxConfig ? "On" : "Off", + }, + { + title: "Expand sidebar", + value: "expandSidebar" as const, + footer: config.expandSidebar ? "On" : "Off", + }, ] dialog.replace(() => ( @@ -77,6 +88,8 @@ export function DialogSettings() { case "theme": return showTheme() case "defaultGroup": return showDefaultGroup() case "autoHibernate": return showAutoHibernate() + case "importUserTmuxConfig": return showImportUserTmuxConfig() + case "expandSidebar": return showExpandSidebar() } }} /> @@ -162,6 +175,41 @@ export function DialogSettings() { )) } + async function showImportUserTmuxConfig() { + const config = getConfig() + dialog.replace(() => ( + { + await setUserTmuxImport(opt.value) + await updateConfig((c) => ({ ...c, importUserTmuxConfig: opt.value })) + }} + /> + )) + } + + function showExpandSidebar() { + const config = getConfig() + dialog.replace(() => ( + updateConfig((c) => ({ ...c, expandSidebar: opt.value }))} + /> + )) + } + // Show the settings list on mount showSettingsList() diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index 55f418d..c03d3ba 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -3,7 +3,7 @@ * Shows session list on left, preview pane on right */ -import { createMemo, createSignal, For, Show, createEffect, onCleanup, type Accessor } from "solid-js" +import { createMemo, createSignal, For, Show, createEffect, onCleanup, untrack, type Accessor } from "solid-js" import { TextAttributes, ScrollBoxRenderable } from "@opentui/core" import { useTerminalDimensions, useKeyboard, useRenderer } from "@opentui/solid" import { useTheme } from "@tui/context/theme" @@ -22,14 +22,14 @@ import { DialogRecents } from "@tui/component/dialog-recents" import { DialogSettings } from "@tui/component/dialog-settings" import { DialogNewRemote } from "@tui/component/dialog-new-remote" import { DialogHelp } from "@tui/component/dialog-help" -import { getShortcuts } from "@/core/config" +import { getShortcuts, getConfig } from "@/core/config" import { executeShortcut, getShortcutGroupPath } from "@/core/shortcut" import { useKeybind } from "@tui/context/keybind" import { useKV } from "@tui/context/kv" import { DialogUpdate } from "@tui/component/dialog-update" -import { attachSessionSync, capturePane, wasCommandPaletteRequested, wasSessionListRequested, sendKeys } from "@/core/tmux" +import { attachSessionSync, capturePane, wasCommandPaletteRequested, wasSessionListRequested, sendKeys, listWindows, selectWindowSync } from "@/core/tmux" import { useCommandDialog } from "@tui/component/dialog-command" -import type { Session, Group, RemoteSession } from "@/core/types" +import type { Session, Group, RemoteSession, TmuxWindow } from "@/core/types" import { isRemoteSession } from "@/core/types" import { formatRelativeTime, truncatePath } from "@tui/util/locale" import { STATUS_ICONS } from "@tui/util/status" @@ -110,6 +110,10 @@ export function Home() { const [selectedIndex, setSelectedIndex] = createSignal(0) const [previewContent, setPreviewContent] = createSignal("") const [previewLoading, setPreviewLoading] = createSignal(false) + const [expandedSessions, setExpandedSessions] = createSignal>(new Set()) + const [sessionWindows, setSessionWindows] = createSignal>(new Map()) + // Sessions the user manually collapsed — auto-expand skips these + const [collapsedSessions, setCollapsedSessions] = createSignal>(new Set()) let scrollRef: ScrollBoxRenderable | undefined let previewScrollRef: ScrollBoxRenderable | undefined let previewDebounceTimer: ReturnType | undefined @@ -165,27 +169,128 @@ export function Home() { } }) + // Auto-expand all sessions' windows when expandSidebar is enabled. + // Sessions the user manually collapsed (Left arrow) are excluded from auto-expand. + createEffect(() => { + const config = getConfig() + if (!config.expandSidebar) return + + const sessions = allSessions() + + untrack(() => { + const currentExpanded = new Set(expandedSessions()) + const collapsed = collapsedSessions() + let changed = false + + for (const session of sessions) { + if (session.tmuxSession && !currentExpanded.has(session.id) && !collapsed.has(session.id)) { + currentExpanded.add(session.id) + changed = true + listWindows(session.tmuxSession).then(windows => { + setSessionWindows(prev => new Map(prev).set(session.id, windows)) + }).catch(() => {}) + } + } + + if (changed) { + setExpandedSessions(currentExpanded) + } + }) + }) + const localSessions = createMemo(() => sync.session.list()) const remoteSessions = createMemo(() => sync.remote.list()) const allSessions = createMemo(() => [...localSessions(), ...remoteSessions()]) + // Periodically refresh windows for all expanded sessions (every 3s) + // so newly created/deleted tmux windows appear without restarting AV. + const WINDOW_REFRESH_MS = 3000 + const windowRefreshInterval = setInterval(() => { + const expanded = expandedSessions() + const sessions = allSessions() + if (expanded.size === 0) return + + for (const session of sessions) { + if (session.tmuxSession && expanded.has(session.id)) { + listWindows(session.tmuxSession) + .then(windows => { + setSessionWindows(prev => { + const next = new Map(prev) + next.set(session.id, windows) + return next + }) + }) + .catch(() => {}) + } + } + }, WINDOW_REFRESH_MS) + onCleanup(() => clearInterval(windowRefreshInterval)) + + + const groupedItems = createMemo(() => { const groups = ensureDefaultGroup(sync.group.list()) return flattenGroupTree(allSessions(), groups) }) + async function toggleSessionWindows(session: Session) { + const key = session.id + const expanded = new Set(expandedSessions()) + + if (expanded.has(key)) { + expanded.delete(key) + } else { + expanded.add(key) + if (session.tmuxSession) { + try { + const windows = await listWindows(session.tmuxSession) + setSessionWindows(prev => new Map(prev).set(key, windows)) + } catch { + // tmux session might not exist + } + } + } + setExpandedSessions(expanded) + } + + const flatItems = createMemo(() => { + const items: GroupedItem[] = [] + const expanded = expandedSessions() + const windows = sessionWindows() + + for (const item of groupedItems()) { + items.push(item) + if (item.type === "session" && item.session && expanded.has(item.session.id)) { + const wins = windows.get(item.session.id) || [] + for (let i = 0; i < wins.length; i++) { + items.push({ + type: "window", + window: wins[i], + session: item.session, + groupPath: item.groupPath, + isLast: i === wins.length - 1, + sessionExpanded: true, + }) + } + } + } + return items + }) + createEffect(() => { - const len = groupedItems().length + const len = flatItems().length if (selectedIndex() >= len && len > 0) { setSelectedIndex(len - 1) } }) - const selectedItem = createMemo(() => groupedItems()[selectedIndex()]) + const selectedItem = createMemo(() => flatItems()[selectedIndex()]) const selectedSession = createMemo(() => { const item = selectedItem() - return item?.type === "session" ? item.session : undefined + if (item?.type === "session") return item.session + if (item?.type === "window") return item.session + return undefined }) const selectedGroup = createMemo(() => { @@ -194,20 +299,29 @@ export function Home() { }) const move = createListNavigation( - () => groupedItems().length, + () => flatItems().length, selectedIndex, setSelectedIndex ) + const captureTarget = createMemo(() => { + const item = selectedItem() + if (item?.type === "window" && item.session?.tmuxSession && item.window) { + return `${item.session.tmuxSession}:${item.window.index}` + } + const session = selectedSession() + return session?.tmuxSession || "" + }) + // Fetch preview with debounce; keep showing previous content while loading createEffect(() => { - const session = selectedSession() + const target = captureTarget() if (previewDebounceTimer) { clearTimeout(previewDebounceTimer) } - if (!session || !session.tmuxSession) { + if (!target) { setPreviewContent("") setPreviewLoading(false) return @@ -230,7 +344,7 @@ export function Home() { if (previewFetchAbort) return try { - const content = await capturePane(session.tmuxSession, { + const content = await capturePane(target, { startLine: -200, // Last 200 lines join: true }) @@ -273,7 +387,7 @@ export function Home() { }) function jumpToGroup(groupIndex: number) { - const items = groupedItems() + const items = flatItems() const idx = items.findIndex(item => item.type === "group" && item.groupIndex === groupIndex) if (idx >= 0) { setSelectedIndex(idx) @@ -592,7 +706,7 @@ export function Home() { setSelectedIndex(0) } if (evt.name === "end") { - setSelectedIndex(Math.max(0, groupedItems().length - 1)) + setSelectedIndex(Math.max(0, flatItems().length - 1)) } // Number keys 1-9 to jump to groups @@ -606,6 +720,22 @@ export function Home() { if (item?.type === "group" && item.group && !item.group.expanded) { sync.group.toggle(item.group.path) } else if (item?.type === "session" && item.session) { + const isExpanded = expandedSessions().has(item.session.id) + if (item.session.tmuxSession && !isExpanded) { + toggleSessionWindows(item.session) + // Remove from collapsed set so auto-expand can include it again + setCollapsedSessions(prev => { + const next = new Set(prev) + next.delete(item.session.id) + return next + }) + } else { + handleAttach(item.session) + } + } else if (item?.type === "window" && item.session) { + if (item.session.tmuxSession && item.window) { + selectWindowSync(item.session.tmuxSession, item.window.index) + } handleAttach(item.session) } } @@ -615,13 +745,20 @@ export function Home() { const item = selectedItem() if (item?.type === "group" && item.group && item.group.expanded) { sync.group.toggle(item.group.path) + } else if (item?.type === "window" && item.session) { + toggleSessionWindows(item.session) } else if (item?.type === "session") { - // When on a session, collapse its parent group - const groupItem = groupedItems().find( - i => i.type === "group" && i.groupPath === item.groupPath - ) - if (groupItem?.group?.expanded) { - sync.group.toggle(groupItem.group.path) + // If session has expanded windows, collapse those first and mark as manually collapsed + if (expandedSessions().has(item.session.id)) { + toggleSessionWindows(item.session) + setCollapsedSessions(prev => new Set(prev).add(item.session.id)) + } else { + const groupItem = groupedItems().find( + i => i.type === "group" && i.groupPath === item.groupPath + ) + if (groupItem?.group?.expanded) { + sync.group.toggle(groupItem.group.path) + } } } } @@ -630,9 +767,25 @@ export function Home() { if (evt.name === "return") { const item = selectedItem() if (item?.type === "session" && item.session) { - handleAttach(item.session) + const isExpanded = expandedSessions().has(item.session.id) + if (item.session.tmuxSession && !isExpanded) { + toggleSessionWindows(item.session) + // Remove from collapsed set so auto-expand can include it again + setCollapsedSessions(prev => { + const next = new Set(prev) + next.delete(item.session.id) + return next + }) + } else { + handleAttach(item.session) + } } else if (item?.type === "group" && item.group) { sync.group.toggle(item.group.path) + } else if (item?.type === "window" && item.session) { + if (item.session.tmuxSession && item.window) { + selectWindowSync(item.session.tmuxSession, item.window.index) + } + handleAttach(item.session) } } @@ -838,6 +991,19 @@ export function Home() { return lines }) + function renderFlatItem(item: GroupedItem, index: number) { + if (item.type === "group" && item.group) { + return + } + if (item.type === "window" && item.window && item.session) { + return + } + if (item.session) { + return + } + return null + } + function GroupHeader(props: { group: Group; index: number }) { const isSelected = createMemo(() => props.index === selectedIndex()) const statusSummary = createMemo(() => getGroupStatusSummary(allSessions(), props.group.path)) @@ -888,7 +1054,7 @@ export function Home() { ) } - function SessionItem(props: { session: Session; index: number; indented?: boolean }) { + function SessionItem(props: { session: Session; index: number; indented?: boolean; expanded?: boolean }) { const isSelected = createMemo(() => props.index === selectedIndex()) const isRemote = createMemo(() => isRemoteSession(props.session)) const statusColor = createMemo(() => { @@ -908,7 +1074,8 @@ export function Home() { const reservedWidth = createMemo(() => { let reserved = 2 // left + right padding reserved += indent // indentation - reserved += 2 // status icon + space + reserved += 2 // status icon + if (props.session.tmuxSession) reserved += 1 // expand arrow reserved += 6 // memory indicator (e.g., "512M ") if (!useDualColumn()) { reserved += 8 // tool name + space in single column mode @@ -949,6 +1116,13 @@ export function Home() { + + {/* Expand/collapse indicator for sessions with tmux windows */} + {props.session.tmuxSession && ( + + {props.expanded ? "\u25BC" : "\u25B6"} + + )} {/* Title */} props.index === selectedIndex()) + + return ( + setSelectedIndex(props.index)} + onMouseOver={() => setSelectedIndex(props.index)} + > + + {" "} + + + {props.window.active ? "\u25CF" : " "} + + + + {props.window.name} + + + + ) + } + function PreviewHeader() { const session = () => selectedSession() @@ -1140,21 +1342,8 @@ export function Home() { scrollbarOptions={{ visible: true }} ref={(r: ScrollBoxRenderable) => { scrollRef = r }} > - - {(item, index) => ( - - } - > - - - )} + + {(item, index) => renderFlatItem(item, index())} diff --git a/src/tui/util/groups.ts b/src/tui/util/groups.ts index 68a8df7..badf3c1 100644 --- a/src/tui/util/groups.ts +++ b/src/tui/util/groups.ts @@ -2,15 +2,17 @@ * Group utility functions for organizing sessions */ -import type { Session, Group } from "@/core/types" +import type { Session, Group, TmuxWindow } from "@/core/types" export interface GroupedItem { - type: "group" | "session" + type: "group" | "session" | "window" group?: Group session?: Session + window?: TmuxWindow groupPath: string isLast: boolean - groupIndex?: number // 1-9 for hotkey jumps + groupIndex?: number + sessionExpanded?: boolean } export const DEFAULT_GROUP_PATH = "my-sessions"