Skip to content
Merged
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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,18 @@ uam version
| `Ctrl+X` | Stop and remove the selected record, or restart it, with confirmation |
| `Ctrl+S` | Toggle group-by-directory |
| `Shift+↑/↓` | Reorder rows |
| `/` with an empty command | Filter by name, provider, task, workspace, ID, or lifecycle |
| `e` | Open the guided dispatch wizard |
| `?` | Open help |
| `Esc` | Close overlays, clear input, or quit |

The dashboard responds to every terminal resize. Wide terminals show sessions
beside selected details or Peek; standard terminals stack those surfaces; and
compact or keyboard-constrained mobile terminals give Peek and the New Session
wizard the full primary surface. See [Responsive TUI design and operations](docs/responsive-tui.md)
for layout thresholds, mobile guidance, lifecycle markers, and accessibility.
The dashboard responds to every terminal resize. Operations always use a
full-width, bordered session list; the selected row expands in place with its
task, Workspace, identity, and pull request. Wide terminals split only when Peek
is open. Compact or keyboard-constrained mobile terminals keep ordinary rows to
one line and expand the selected row to two. See
[Responsive TUI design and operations](docs/responsive-tui.md) for layout
thresholds, filtering, mobile guidance, lifecycle labels, and accessibility.

## Attached sessions

Expand Down
31 changes: 27 additions & 4 deletions docs/responsive-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ UAM does not infer "working," "waiting," or "completed" by scraping provider
text. The selected row's prompt is kept as its task summary; failure detail is
added without replacing it.

Every row includes its provider, evidence-based lifecycle label, and age since
the Managed Session was created. Age is deliberately not an activity indicator:
live discovery timestamps change on every refresh and cannot prove that an agent
is busy or idle.

Attaching to Running reconnects to the existing host. Acting on Stopped resumes
the provider when supported. If that resume can only select the provider's most
recent conversation and several retained sessions share the Workspace, the TUI
Expand All @@ -28,9 +33,9 @@ The layout is derived from the current terminal dimensions on every resize.

| Layout | Geometry | Operations view | Peek view |
|---|---|---|---|
| **Wide** | At least 96 columns and 28 rows | Session list and selected-session details are side by side. | Session list remains beside the output tail. |
| **Standard** | At least 58 columns and 24 rows, but below Wide | Selected-session summary appears above the list. | Output tail replaces the list so it has useful width. |
| **Compact** | Fewer than 58 columns or fewer than 24 rows | A bounded selected summary and single-line session rows share the screen. | Output tail becomes the primary surface. |
| **Wide** | At least 96 columns and 28 rows | Full-width list; the selected row expands with task, Workspace, exact ID, and PR. | Session list remains beside the output tail. |
| **Standard** | At least 58 columns and 24 rows, but below Wide | Full-width list with an expanded selected row. | Output tail replaces the list so it has useful width. |
| **Compact** | Fewer than 58 columns or fewer than 24 rows | Ordinary rows use one line; the selected row uses a second task line. | Output tail becomes the primary surface. |

The prompt is reserved at the bottom before the remaining rows are allocated.
The New Session wizard is a primary surface in all layouts, so every step remains
Expand All @@ -54,13 +59,30 @@ by terminal-cell width without splitting Unicode text.
| `Ctrl+X` | Confirm stop **and record removal**; press `r` in the confirmation to restart in place. Use CLI `uam stop <id>` to retain a Stopped row. |
| `Ctrl+S` | Toggle Workspace grouping. |
| `Shift+↑` / `Shift+↓` | Reorder within the same lifecycle, pin, and visible Workspace group. |
| `/` with an empty command | Enter live filtering. Type to narrow, use arrows to move, and press `Esc` to clear. |
| `?` | Open key help. |
| `Esc` | Close the current overlay or input; from the base dashboard, quit. |

Inside an attached session, `Ctrl+B d` detaches. A bare left arrow also detaches
when the provider input is empty and the quick-detach option is enabled. See the
README for the complete attach-key contract.

## Filtering sessions

Press `/` while the command composer is empty to filter the existing dashboard.
Matching is case-insensitive across display name, managed-session ID, provider,
command alias, task, Workspace, and lifecycle label. Space-separated terms must
all match the same session. The dashboard shows matched/total counts and removes
empty Workspace sections without changing the stored order.

Filtering is a temporary presentation state. It is not stored, and pin, rename,
stop, attach, resume, grouping, and reorder actions still use the session's
provider-and-ID identity. `Esc` clears the query and restores the prior selection
when it still exists. A slash typed after command text has begun remains literal
prompt content. Peek replies also keep `/` as literal input rather than entering
filter mode; an empty Operations dashboard still opens the filter and shows a
zero-result state.

## Workspace grouping and parallel sessions

`Ctrl+S` groups rows by normalized absolute working directory without resolving
Expand All @@ -87,7 +109,8 @@ touch-only substitutes for those terminal keys.

1. Keep the terminal narrower than 58 columns or let the keyboard reduce it
below 24 rows.
2. Use `Space` to dedicate the primary surface to Peek; use `Esc` to return.
2. Use the one-line rows and expanded two-line selection to scan sessions; use
`Space` to dedicate the primary surface to Peek and `Esc` to return.
3. Use `e` for the bounded wizard instead of composing a long inline dispatch.
4. Use `Ctrl+G` with a terminal/editor combination that supports external editor
handoff when a multi-line prompt is easier outside the small viewport.
Expand Down
187 changes: 68 additions & 119 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type Model struct {
sessions []adapter.Session
selected int
input string
filterActive bool
filterQuery string
filterRestore sessionIdentity
filterSaved bool
defaultAgent string
message string
messageSetAt time.Time
Expand Down Expand Up @@ -83,6 +87,10 @@ type Model struct {
// for that gate.
lastPeekAt map[string]time.Time
peekClock func() time.Time
// now is the presentation clock used for deterministic session-age labels.
// Discovery refreshes LastChange on every scan, so the dashboard deliberately
// derives age from CreatedAt instead.
now func() time.Time
}

// messageTTL is how long a status/error line stays on screen before a refresh
Expand Down Expand Up @@ -409,11 +417,17 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model {
for i, sess := range m.sessions {
if sess.AgentType == selectedAgent && sess.ID == selectedID {
m.selected = i
if m.filterActive {
m.reconcileFilterSelection()
}
return m
}
}
}
m.selected = max(0, min(m.selected, len(m.sessions)-1))
if m.filterActive {
m.reconcileFilterSelection()
}
return m
}

Expand Down Expand Up @@ -471,6 +485,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if handled, model, cmd := m.handleModalKey(msg, key); handled {
return model, cmd
}
// Peek owns the command composer for replies. Keep the filtered projection,
// but route text/Enter/Esc through the established reply flow while it is open.
if m.filterActive && !m.peekOpen {
if handled, cmd := m.handleFilterKey(msg, key); handled {
return m, cmd
}
}
if key == "/" && m.input == "" && !m.peekOpen {
m.enterFilter()
return m, nil
}
if handled, cmd := m.handleMovementKey(key); handled {
return m, cmd
}
Expand Down Expand Up @@ -601,6 +626,24 @@ func (m *Model) moveSelectionPeek(delta int) tea.Cmd {
}

func (m *Model) moveSelection(delta int) {
if m.filterActive {
visible := m.visibleSessionIndices()
if len(visible) == 0 {
return
}
position := 0
for i, index := range visible {
if index == m.selected {
position = i
break
}
}
next := position + delta
if next >= 0 && next < len(visible) {
m.selected = visible[next]
}
return
}
next := m.selected + delta
if next >= 0 && next < len(m.sessions) {
m.selected = next
Expand Down Expand Up @@ -647,10 +690,24 @@ func (m Model) shouldPollFocusedPeek(id string, now time.Time) bool {
}

func (m *Model) moveSession(delta int) tea.Cmd {
if m.filterActive {
return m.moveFilteredSession(delta)
}
next := m.selected + delta
if next < 0 || next >= len(m.sessions) {
return nil
}
return m.moveSessionTo(next)
}

// moveSessionTo applies the shared identity-safe reorder invariants between two
// canonical indices. Both normal and filtered navigation resolve their target
// index before entering this path, so hidden rows are never mistaken for the
// selected session.
func (m *Model) moveSessionTo(next int) tea.Cmd {
if m.selected < 0 || m.selected >= len(m.sessions) || next < 0 || next >= len(m.sessions) || next == m.selected {
return nil
}
// SortSessions buckets rows by process liveness, then Pinned, before honoring
// SortIndex. A swap that crosses either boundary is undone on the next
// refresh (the row snaps back to its partition), so reject it and give
Expand Down Expand Up @@ -1583,12 +1640,17 @@ func (m Model) View() string {
if m.quitting {
return ""
}
// Before Bubble Tea sends its first WindowSizeMsg preserve the historical
// unconstrained view. Once dimensions are known all composition is budgeted.
// Before Bubble Tea sends its first WindowSizeMsg, keep the first frame small
// and stable instead of flashing the legacy unbounded dashboard.
if !m.sizeKnown {
return m.unboundedView()
// A confirmation can arrive before a WindowSizeMsg in tests and on very
// slow remote terminals. Never hide a safety-critical modal behind loading.
if m.helpOpen || m.confirmLatest || m.confirmStop || m.wizard || m.renaming {
return m.unboundedView()
}
return bar() + " " + brandStyle.Render("UAM") + " " + hintStyle.Render("loading dashboard…")
}
return m.responsiveView()
return m.dashboardView()
}

func (m Model) unboundedView() string {
Expand Down Expand Up @@ -1682,68 +1744,7 @@ func (m Model) responsiveBody(width, budget int) []string {
if m.dashboardMode() == ModeNew {
return takeLines(boundedNonBlankLines(m.renderWizard(), width), budget)
}
class := m.layoutClass()
if class == LayoutWide {
leftWidth := max(28, width*52/100)
rightWidth := max(20, width-leftWidth-3)
left := m.sessionListLines(leftWidth, budget, class)
right := m.detailPaneLines(rightWidth, budget, m.dashboardMode())
return joinColumns(left, right, leftWidth, rightWidth, budget)
}
if m.dashboardMode() == ModePeek {
return m.peekSurfaceLines(width, budget, class)
}
detailLimit := 3
if class == LayoutCompact {
detailLimit = 2
}
details := takeLines(m.selectedSummaryLines(width, class), min(detailLimit, budget))
list := m.sessionListLines(width, budget-len(details), class)
return append(details, list...)
}

func (m Model) selectedSummaryLines(width int, class LayoutClass) []string {
sess, ok := m.selectedSession()
if !ok {
return nil
}
name := truncate(firstNonEmpty(sess.DisplayName, sess.ID), max(1, width-11))
lines := []string{ansi.Truncate(sectionStyle.Render("SELECTED")+" "+titleStyle.Render(name), width, "…")}
if class == LayoutCompact {
meta := firstNonEmpty(boundedTaskSummary(sess, max(1, width-2)), "agent: "+sess.AgentType)
return append(lines, ansi.Truncate(" "+taskStyle.Render(meta), width, "…"))
}
lines = append(lines,
ansi.Truncate(" "+hintStyle.Render("agent: "+displaytext.Sanitize(firstNonEmpty(sess.AgentType, "?"))), width, "…"),
ansi.Truncate(" "+hintStyle.Render("cwd: "+absCwd(sess.Cwd)), width, "…"),
)
return lines
}

func (m Model) detailPaneLines(width, budget int, mode DashboardMode) []string {
if mode == ModePeek {
return m.peekSurfaceLines(width, budget, LayoutWide)
}
return takeLines(m.selectedSummaryLines(width, LayoutWide), budget)
}

func (m Model) peekSurfaceLines(width, budget int, class LayoutClass) []string {
if budget <= 0 {
return nil
}
lines := []string{sectionStyle.Render("PEEK")}
if sess, ok := m.selectedSession(); ok && budget > 1 {
lines = append(lines, ansi.Truncate(" "+titleStyle.Render(firstNonEmpty(sess.DisplayName, sess.ID)), width, "…"))
}
remaining := budget - len(lines)
if remaining > 0 {
peek := boundedTailLines(m.peekText, remaining, width)
if len(peek) == 0 {
peek = []string{hintStyle.Render("waiting for output…")}
}
lines = append(lines, takeLinesFromEnd(peek, remaining)...)
}
return takeLines(lines, budget)
return m.dashboardBody(width, budget)
}

// boundedTailLines scans backward only far enough to find the requested tail.
Expand Down Expand Up @@ -1774,54 +1775,6 @@ func boundedTailLines(s string, n, width int) []string {
return lines
}

func (m Model) sessionListLines(width, budget int, class LayoutClass) []string {
if budget <= 0 {
return nil
}
if m.groupByDir {
return m.groupedSessionListLines(width, budget, class)
}
if len(m.sessions) == 0 {
return takeLines([]string{m.renderSectionAtWidth("SESSIONS", "0", width), " " + hintStyle.Render("no sessions")}, budget)
}
stopped := 0
for _, sess := range m.sessions {
if sess.ProcAlive == adapter.Exited {
stopped++
}
}
rowBudget := max(0, budget-2)
if class == LayoutCompact {
rowBudget = max(0, budget-1)
}
start, end := visibleWindow(len(m.sessions), m.selected, rowBudget)
nameWidth, taskWidth, showTask := tableWidthsFor(width, class)
lines := make([]string, 0, budget)
if class == LayoutCompact {
right := fmt.Sprintf("%d", len(m.sessions))
if stopped > 0 {
right = fmt.Sprintf("%d · %d stopped", len(m.sessions), stopped)
}
lines = append(lines, m.renderSectionAtWidth("SESSIONS", right, width))
} else {
label := "RUNNING"
if m.sessions[start].ProcAlive == adapter.Exited {
label = "STOPPED"
}
lines = append(lines, m.renderSectionAtWidth(label, "", width))
}
lastAlive := m.sessions[start].ProcAlive
for i := start; i < end && len(lines) < budget; i++ {
sess := m.sessions[i]
if class != LayoutCompact && sess.ProcAlive != lastAlive && len(lines) < budget-1 {
lines = append(lines, m.renderSectionAtWidth("STOPPED", "", width))
lastAlive = sess.ProcAlive
}
lines = append(lines, ansi.Truncate(renderRow(sess, i == m.selected, nameWidth, taskWidth, showTask), width, "…"))
}
return takeLines(lines, budget)
}

func (m Model) renderSectionAtWidth(label, right string, width int) string {
head := sectionStyle.Render(label)
rightWidth := ansi.StringWidth(right)
Expand Down Expand Up @@ -1892,11 +1845,6 @@ func takeLines(lines []string, n int) []string {
return lines[:min(len(lines), max(0, n))]
}

func takeLinesFromEnd(lines []string, n int) []string {
n = min(len(lines), max(0, n))
return lines[len(lines)-n:]
}

func fitScreen(lines []string, width, height int) string {
if len(lines) > height {
lines = lines[len(lines)-height:]
Expand Down Expand Up @@ -2222,6 +2170,7 @@ func (m Model) renderHelp() string {
rows := []string{
"↑/↓ move Shift+↑/↓ reorder Enter/→ attach/resume",
"Space peek running / resume stopped",
"/ filter sessions when the command line is empty",
"Tab cycle agent Ctrl+T pin Ctrl+R rename",
"Ctrl+X stop+remove / restart Ctrl+S group-by-dir",
"e new session Esc quit",
Expand Down
Loading
Loading