mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
338b5275be
commit
01669f2f12
6 changed files with 147 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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() ? '❯ ' : ' '}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue