opentui(v6): /sessions groups this directory's sessions first + TUI persists its cwd

The resume picker never had cwd grouping — deliberately deferred in the v1
spec because TUI session rows had no cwd to group by: the TUI's
session.create sent only {cols}, so explicit_cwd stayed false and
_ensure_session_db_row skipped cwd stamping by design (the desktop's launch
dir is meaningless — 'No workspace' grouping is its desired default).

In a terminal the launch directory IS the workspace choice, so the entry now
passes cwd: process.cwd() at session.create — the existing explicit-workspace
machinery persists it to the session row on first message (covered by
test_ensure_session_db_row_persists_explicit_cwd; zero gateway changes).

Picker: while browsing (no search), sessions whose cwd matches the TUI's
current directory order first under a '▾ this directory (N)' caption, the
rest under '▾ other directories' — one flat reordered list, so selection/
windowing/load-more math is untouched, and captions are pure render
decoration keyed off hereCount. During search the fuzzy score keeps owning
the order. Trailing-slash-normalized comparison, no fs calls.

Old sessions can't be backfilled (their cwd was never recorded); coverage
accumulates from here. 6 new tests (pure ordering edges + grouped frames,
search-drops-grouping, no-cwd passthrough).
This commit is contained in:
alt-glitch 2026-06-12 23:50:59 +05:30
parent 338b5275be
commit 01669f2f12
6 changed files with 147 additions and 3 deletions

View file

@ -180,7 +180,13 @@ const postSessionSetup = (gateway: GatewayServiceShape, store: SessionStore, sid
const createFreshSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) =>
Effect.gen(function* () {
const created = yield* gateway.request<{ session_id?: string; info?: Record<string, unknown> }>('session.create', {
cols: input.cols
cols: input.cols,
// The launch directory IS the workspace choice in a terminal (you cd'd
// here) — passing it makes the gateway treat it as explicit, so the
// session row gets a persisted cwd on first message and /sessions can
// group this directory's sessions first. (The desktop deliberately
// omits cwd — its launch dir is meaningless; see _ensure_session_db_row.)
cwd: process.cwd()
})
const sid = created?.session_id ?? gateway.sessionId()
if (!sid) {

View file

@ -163,6 +163,36 @@ export function filterSessions(query: string, rows: readonly SessionRow[]): Sess
return fuzzyFilter(query, rows, sessionFields)
}
// ── this-directory grouping ───────────────────────────────────────────────
/** Path equality for cwd grouping: trim + drop trailing slashes. Pure string
* work (no fs) rows carry the gateway's already-absolute paths. */
export function normalizeCwd(path: string | undefined): string {
return (path ?? '').trim().replace(/\/+$/, '')
}
/** Display order with sessions started in the CURRENT directory first.
*
* Browse mode only: while a search query is active the fuzzy score owns the
* order (relevance beats locality), so `hereCount` is 0 and rows pass through.
* Stable within both groups (each keeps the gateway's recency order). The
* view renders section captions off `hereCount`; selection math is untouched
* because this just reorders the one flat list.
*/
export function orderRowsForCwd(
rows: SessionRow[],
currentCwd: string | undefined,
query: string
): { rows: SessionRow[]; hereCount: number } {
const here = normalizeCwd(currentCwd)
if (!here || query.trim()) return { hereCount: 0, rows }
const local: SessionRow[] = []
const elsewhere: SessionRow[] = []
for (const row of rows) (normalizeCwd(row.cwd) === here ? local : elsewhere).push(row)
if (!local.length) return { hereCount: 0, rows }
return { hereCount: local.length, rows: [...local, ...elsewhere] }
}
// ── key routing (pattern: completionMenu.ts routeMenuKey) ────────────────
export interface SessionPickerKeyContext {

View file

@ -22,7 +22,9 @@ import {
SESSION_TABS,
tabAccepts,
tailTruncate,
type SessionRow
type SessionRow,
normalizeCwd,
orderRowsForCwd
} from '../logic/sessionPicker.ts'
const row = (over: Partial<SessionRow>): SessionRow => ({
@ -254,3 +256,28 @@ describe('slash entry points', () => {
expect(resolveSessionArg(rows, '')).toBeUndefined()
})
})
describe('orderRowsForCwd — pure edges', () => {
const row = (id: string, cwd?: string) =>
({ cwd, id, lastActive: 0, messageCount: 0, preview: '', source: 'tui', startedAt: 0, title: id }) as never
test('normalizeCwd trims and strips trailing slashes; empty-safe', () => {
expect(normalizeCwd(' /a/b// ')).toBe('/a/b')
expect(normalizeCwd(undefined)).toBe('')
expect(normalizeCwd(' ')).toBe('')
})
test('identity passthrough when nothing matches or no cwd known', () => {
const rows = [row('a', '/x'), row('b')]
expect(orderRowsForCwd(rows, undefined, '')).toEqual({ hereCount: 0, rows })
expect(orderRowsForCwd(rows, '/nowhere', '')).toEqual({ hereCount: 0, rows })
expect(orderRowsForCwd(rows, '/x', 'query')).toEqual({ hereCount: 0, rows })
})
test('stable partition: here first, recency kept within groups', () => {
const rows = [row('a', '/y'), row('b', '/x'), row('c'), row('d', '/x/')]
const out = orderRowsForCwd(rows, '/x', '')
expect(out.hereCount).toBe(2)
expect(out.rows.map(r => (r as { id: string }).id)).toEqual(['b', 'd', 'a', 'c'])
})
})

View file

@ -74,6 +74,8 @@ interface MountOptions {
sessions?: FakeSession[]
truncated?: boolean
initialTab?: 'recent' | 'cron' | 'gateways' | 'all'
/** The TUI's cwd — drives the this-directory-first grouping. */
currentCwd?: string
/** Per-id peek delay (ms) — drives the stale-cancellation test. */
peekDelay?: (id: string) => number
/** Paged list: serve `sessions` windowed by offset/limit instead of whole. */
@ -118,6 +120,7 @@ async function mountPicker(options: MountOptions = {}): Promise<Harness> {
<SessionPicker
ops={ops}
initialTab={options.initialTab ?? 'recent'}
currentCwd={() => options.currentCwd}
peekDebounceMs={20}
onResume={id => resumed.push(id)}
onClose={() => (closed.value = true)}
@ -356,3 +359,65 @@ describe('SessionPicker — pagination', () => {
}
})
})
describe('this-directory grouping', () => {
const CWD = '/home/u/projects/alpha'
const GROUPED: FakeSession[] = [
{ cwd: '/elsewhere/beta', id: 'g1', last_active: NOW_S - 60, message_count: 5, source: 'tui', title: 'beta work' },
{ cwd: CWD, id: 'g2', last_active: NOW_S - 120, message_count: 8, source: 'tui', title: 'alpha here' },
{ id: 'g3', last_active: NOW_S - 180, message_count: 2, source: 'cli', title: 'no cwd at all' },
{
cwd: CWD + '/',
id: 'g4',
last_active: NOW_S - 240,
message_count: 4,
source: 'tui',
title: 'alpha trailing slash'
}
]
test('sessions started in the current cwd group first under a caption', async () => {
const h = await mountPicker({ currentCwd: CWD, sessions: GROUPED })
try {
const frame = h.probe.frame()
const at = (needle: string) => frame.indexOf(needle)
expect(at('▾ this directory (2)')).toBeGreaterThanOrEqual(0)
expect(at('▾ other directories')).toBeGreaterThan(at('▾ this directory (2)'))
// here-rows (incl. trailing-slash normalization) above the caption split,
// elsewhere rows below it; selection starts on the first here-row.
expect(at('alpha here')).toBeLessThan(at('▾ other directories'))
expect(at('alpha trailing slash')).toBeLessThan(at('▾ other directories'))
expect(at('beta work')).toBeGreaterThan(at('▾ other directories'))
expect(at('no cwd at all')).toBeGreaterThan(at('▾ other directories'))
expect(frame).toContain(' alpha here')
// Enter resumes the first here-session
h.probe.keys.pressEnter()
await h.probe.settle()
expect(h.resumed).toEqual(['g2'])
} finally {
h.probe.destroy()
}
})
test('search drops the grouping (fuzzy relevance owns the order)', async () => {
const h = await mountPicker({ currentCwd: CWD, sessions: GROUPED })
try {
await h.probe.keys.typeText('beta')
await h.probe.settle()
const frame = await h.probe.waitForFrame(f => f.includes('beta work'))
expect(frame).not.toContain('▾ this directory')
expect(frame).not.toContain('▾ other directories')
} finally {
h.probe.destroy()
}
})
test('no current cwd (or no matches) → plain recency list, no captions', async () => {
const h = await mountPicker({ sessions: GROUPED })
try {
expect(h.probe.frame()).not.toContain('▾ this directory')
} finally {
h.probe.destroy()
}
})
})

View file

@ -156,6 +156,7 @@ export function App(props: AppProps) {
<SessionPicker
ops={props.sessionOps ?? NOOP_OPS}
initialTab={sp().tab}
currentCwd={() => props.store.state.info.cwd}
onResume={resume}
onClose={closeSessionPicker}
/>

View file

@ -34,6 +34,7 @@ import { visibleRows, type PickerRow } from '../../logic/fuzzy.ts'
import {
filterSessions,
listParamsFor,
orderRowsForCwd,
mapSessionRows,
relativeTime,
routeSessionPickerKey,
@ -79,6 +80,9 @@ export function SessionPicker(props: {
onResume: (sessionId: string) => void
onClose: () => void
initialTab?: SessionTabId
/** The TUI's working directory sessions started here group first while
* browsing (no effect during search). */
currentCwd?: () => string | undefined
/** Test seam: override the peek debounce (default PEEK_DEBOUNCE_MS). */
peekDebounceMs?: number
}) {
@ -134,7 +138,12 @@ export function SessionPicker(props: {
})
)
const filtered = createMemo(() => filterSessions(query(), rows()))
// Display order: this-directory sessions first while browsing (design ask
// 2026-06-12); search keeps pure fuzzy relevance. One flat list, so the
// selection/windowing math below is order-agnostic.
const ordered = createMemo(() => orderRowsForCwd(filterSessions(query(), rows()), props.currentCwd?.(), query()))
const filtered = createMemo(() => ordered().rows)
const hereCount = createMemo(() => ordered().hereCount)
createEffect(on(query, () => setSel(0), { defer: true }))
// "load more" pseudo-row: selectable index === filtered().length. Offered
@ -371,6 +380,12 @@ export function SessionPicker(props: {
{row =>
row.kind === 'item' && row.item ? (
<box style={{ flexDirection: 'column' }} onMouseDown={() => setSel(row.index)}>
<Show when={hereCount() > 0 && row.index === 0}>
<text fg={theme().color.muted}>{` ▾ this directory (${hereCount()})`}</text>
</Show>
<Show when={hereCount() > 0 && row.index === hereCount()}>
<text fg={theme().color.muted}>{' ▾ other directories'}</text>
</Show>
<text bg={row.index === sel() ? theme().color.selectionBg : 'transparent'}>
<span style={{ fg: row.index === sel() ? theme().color.text : theme().color.muted }}>
{row.index === sel() ? ' ' : ' '}