From b84d750e7edf018fb7e8efe3c74e6e31781f3a2c Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Thu, 2 Apr 2026 21:31:53 -0500 Subject: [PATCH 1/4] feat(sidebar): add window navigation to home screen sidebar --- .../IMPLEMENTATION_PLAN.md | 294 ++++++++++++++++++ build_docs/0.1.1-window-navigation/PRD.md | 61 ++++ src/core/config.ts | 4 +- src/core/tmux.ts | 27 +- src/core/types.ts | 6 + src/tui/component/dialog-settings.tsx | 22 ++ src/tui/routes/home.tsx | 193 ++++++++++-- src/tui/util/groups.ts | 8 +- 8 files changed, 580 insertions(+), 35 deletions(-) create mode 100644 build_docs/0.1.1-window-navigation/IMPLEMENTATION_PLAN.md create mode 100644 build_docs/0.1.1-window-navigation/PRD.md 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..cd95afe 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -30,6 +30,7 @@ export interface AppConfig { autoHibernateMinutes?: number // 0 = disabled, default 0 autoHibernatePrompted?: boolean // true = user has seen the prompt lastRemoteSession?: LastRemoteSession // Last used remote session values + expandSidebar?: boolean } const CONFIG_DIR = path.join(os.homedir(), ".agent-view") @@ -44,7 +45,8 @@ const DEFAULT_CONFIG: AppConfig = { }, defaultGroup: "default", shortcuts: [], - recents: [] + recents: [], + expandSidebar: false, } // Cached config for sync access diff --git a/src/core/tmux.ts b/src/core/tmux.ts index 953e280..554fb59 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,6 +35,7 @@ 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") @@ -354,6 +355,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 */ 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..dbf1c2b 100644 --- a/src/tui/component/dialog-settings.tsx +++ b/src/tui/component/dialog-settings.tsx @@ -64,6 +64,11 @@ export function DialogSettings() { value: "autoHibernate" as const, footer: formatHibernate(config.autoHibernateMinutes || 0), }, + { + title: "Expand sidebar", + value: "expandSidebar" as const, + footer: config.expandSidebar ? "On" : "Off", + }, ] dialog.replace(() => ( @@ -77,6 +82,7 @@ export function DialogSettings() { case "theme": return showTheme() case "defaultGroup": return showDefaultGroup() case "autoHibernate": return showAutoHibernate() + case "expandSidebar": return showExpandSidebar() } }} /> @@ -162,6 +168,22 @@ export function DialogSettings() { )) } + 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..d21cd1e 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,8 @@ 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()) let scrollRef: ScrollBoxRenderable | undefined let previewScrollRef: ScrollBoxRenderable | undefined let previewDebounceTimer: ReturnType | undefined @@ -165,6 +167,33 @@ export function Home() { } }) + // Auto-expand all sessions' windows when expandSidebar is enabled + createEffect(() => { + const config = getConfig() + if (!config.expandSidebar) return + + const sessions = allSessions() + + untrack(() => { + const currentExpanded = new Set(expandedSessions()) + let changed = false + + for (const session of sessions) { + if (session.tmuxSession && !currentExpanded.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()]) @@ -174,18 +203,64 @@ export function Home() { 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 +269,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 +314,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 +357,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 +676,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 +690,16 @@ 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) + } 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,8 +709,9 @@ 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 ) @@ -630,9 +725,19 @@ 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) + } 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 +943,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)) @@ -996,6 +1114,34 @@ export function Home() { ) } + 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 ? "\u25B6" : " "} + + + + {props.window.name} + + + + ) + } + function PreviewHeader() { const session = () => selectedSession() @@ -1140,21 +1286,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" From 3396aea6cea1c80af17a4d1b4099767cfa864c88 Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Thu, 2 Apr 2026 22:16:55 -0500 Subject: [PATCH 2/4] feat(sidebar): add window navigation to home screen sidebar --- src/tui/routes/home.tsx | 52 ++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index d21cd1e..8266efe 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -112,6 +112,8 @@ export function Home() { 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 @@ -167,7 +169,8 @@ export function Home() { } }) - // Auto-expand all sessions' windows when expandSidebar is enabled + // 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 @@ -176,10 +179,11 @@ export function Home() { untrack(() => { const currentExpanded = new Set(expandedSessions()) + const collapsed = collapsedSessions() let changed = false for (const session of sessions) { - if (session.tmuxSession && !currentExpanded.has(session.id)) { + if (session.tmuxSession && !currentExpanded.has(session.id) && !collapsed.has(session.id)) { currentExpanded.add(session.id) changed = true listWindows(session.tmuxSession).then(windows => { @@ -693,6 +697,12 @@ export function Home() { 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) } @@ -712,11 +722,17 @@ export function Home() { } else if (item?.type === "window" && item.session) { toggleSessionWindows(item.session) } else if (item?.type === "session") { - 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) + } } } } @@ -728,6 +744,12 @@ export function Home() { 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) } @@ -951,7 +973,7 @@ export function Home() { return } if (item.session) { - return + return } return null } @@ -1006,7 +1028,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(() => { @@ -1026,7 +1048,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 @@ -1067,6 +1090,13 @@ export function Home() { + + {/* Expand/collapse indicator for sessions with tmux windows */} + {props.session.tmuxSession && ( + + {props.expanded ? "\u25BC" : "\u25B6"} + + )} {/* Title */} - {props.window.active ? "\u25B6" : " "} + {props.window.active ? "\u25CF" : " "} From 98bd9150c7f51165018cc84787aa49ddae9b1cf5 Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Thu, 2 Apr 2026 23:03:30 -0500 Subject: [PATCH 3/4] feat(settings): add import user tmux config support --- src/core/config.ts | 2 ++ src/core/tmux.conf | 7 +++++++ src/core/tmux.ts | 17 +++++++++++++++++ src/tui/component/dialog-settings.tsx | 26 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/core/config.ts b/src/core/config.ts index cd95afe..94189c8 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -30,6 +30,7 @@ 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 } @@ -46,6 +47,7 @@ const DEFAULT_CONFIG: AppConfig = { defaultGroup: "default", shortcuts: [], recents: [], + importUserTmuxConfig: false, expandSidebar: false, } 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 554fb59..ef1f7a5 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -40,6 +40,7 @@ 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 @@ -645,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/tui/component/dialog-settings.tsx b/src/tui/component/dialog-settings.tsx index dbf1c2b..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,11 @@ 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, @@ -82,6 +88,7 @@ export function DialogSettings() { case "theme": return showTheme() case "defaultGroup": return showDefaultGroup() case "autoHibernate": return showAutoHibernate() + case "importUserTmuxConfig": return showImportUserTmuxConfig() case "expandSidebar": return showExpandSidebar() } }} @@ -168,6 +175,25 @@ 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(() => ( From 898d7c88b8c4e1d424c7746ffbeb5173a4dabb1f Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Fri, 3 Apr 2026 11:15:11 -0500 Subject: [PATCH 4/4] fix(sidebar): refresh window list for expanded sessions periodically Windows were only fetched once on expand, so newly created or deleted tmux windows wouldn't appear until restarting AV. Now polls every 3s for all currently-expanded sessions. --- src/tui/routes/home.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index 8266efe..c03d3ba 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -202,6 +202,32 @@ export function Home() { 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)