Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions build_docs/0.1.1-window-navigation/IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -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<TmuxWindow[]> {
// 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<Set<string>>(new Set())
const [sessionWindows, setSessionWindows] = createSignal<Map<string, TmuxWindow[]>>(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 (
<box
flexDirection="row"
paddingLeft={5} // indent under session
paddingRight={1}
height={1}
backgroundColor={isSelected() ? theme.primary : undefined}
onMouseUp={() => setSelectedIndex(props.index)}
onMouseOver={() => setSelectedIndex(props.index)}
>
<text fg={isSelected() ? theme.selectedListItemText : theme.accent}>
{props.window.active ? "▶" : " "}
</text>
<text> </text>
<text fg={isSelected() ? theme.selectedListItemText : theme.text}>
{props.window.name}
</text>
</box>
)
}
```

#### 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
<For each={flatItems()}>
{(item, index) => renderFlatItem(item, index())}
</For>
```

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.
61 changes: 61 additions & 0 deletions build_docs/0.1.1-window-navigation/PRD.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -44,7 +46,9 @@ const DEFAULT_CONFIG: AppConfig = {
},
defaultGroup: "default",
shortcuts: [],
recents: []
recents: [],
importUserTmuxConfig: false,
expandSidebar: false,
}

// Cached config for sync access
Expand Down
7 changes: 7 additions & 0 deletions src/core/tmux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading