diff --git a/README.md b/README.md index 3a83ef9..7f1643b 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,37 @@ Hosts and tokens live in `~/.ellipsis/config.json` (mode 0600); set is migrated on first use — your existing login becomes a host named for its API base. -The config file also carries UI preferences. Set `"hideSessionBar": true` at -the top level to drop the session list from the bottom of the interactive UI -and give its rows to the chat window. +The config file also carries UI preferences. `"sessionBar"` scopes the session +list at the bottom of the interactive UI: + +```json +{ + "sessionBar": { + "hidden": false, + "rows": 5, + "days": 7, + "repo": "cwd", + "statuses": "all", + "sources": ["cli", "manual"] + } +} +``` + +`hidden` drops the bar entirely and gives its rows to the chat window. `rows` is +how many sessions it lists (a short terminal shows fewer). `days` hides sessions +that have not moved in that long; `0` means no age cutoff. `repo` is `"cwd"` to +list only sessions on the repository your shell is in, or `"any"` for all of +them. `statuses` is `"unfinished"` to leave out the sessions that finished, +errored, or were stopped, or `"all"` to keep them. `sources` lists only sessions +started those ways (`react`, `manual`, `api`, `cli`, `mention`, `cron`); leave it +out for all of them. + +Every field is optional and the defaults above are what you get with no +`sessionBar` at all. Two caveats on `repo`: a shell outside a repository lists +every repository rather than nothing, and sessions that name their repository +only inside their agent config — dashboard starts, cron runs, handoffs — do not +match a repo filter, so `"repo": "any"` is the way to see those alongside the +rest. ## Develop diff --git a/src/commands/connect.ts b/src/commands/connect.ts index dab3080..80485da 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -163,7 +163,9 @@ export async function runConnect( // buffer and emit only the final frame off-TTY, which would silence the // documented headless path (`--no-input` piped into a script or an agent) // for the entire life of the session. - { interactive: true }, + // exitOnCtrlC off: the app handles ctrl+c itself (first press interrupts a + // running turn, second quits), which ink's default teardown would preempt. + { interactive: true, exitOnCtrlC: false }, ) // Guard against the revoked-TTY spin. When the controlling terminal is torn // down abruptly (terminal app force-quit/crash, SSH drop, login-session diff --git a/src/lib/config.ts b/src/lib/config.ts index f527a91..cbebb76 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -32,6 +32,27 @@ export interface Host { enrolledRepos?: string[] } +// How the interactive UI's session bar is scoped. Every field is optional; a +// missing one takes the SESSION_BAR_DEFAULTS value below. +export interface SessionBarConfig { + // Drop the bar entirely, giving its rows to the chat window. + hidden?: boolean + // How many session rows the bar shows (a short terminal shows fewer). + rows?: number + // Only sessions that moved in the last N days; 0 means no age cutoff. + days?: number + // "cwd" lists only sessions on the repository the shell is in, falling back + // to every repository when the cwd is not one. "any" never scopes by repo. + repo?: 'cwd' | 'any' + // "unfinished" drops the sessions that completed, errored, or were stopped, + // leaving the conversations still going. "all" keeps them. + statuses?: 'all' | 'unfinished' + // Only sessions started these ways, e.g. ["cli", "manual"]. Omit for all of + // them. Laptop sessions never appear whatever this says: there is nothing in + // the cloud to open. + sources?: string[] +} + // The config file (v2): a named set of hosts plus which one is active. Commands // resolve against the active host unless an env var / explicit arg overrides. // UI preferences live at the top level, not per host: they describe the @@ -40,9 +61,7 @@ export interface CliConfig { version: 2 activeHost?: string hosts: Record - // Hide the session bar (the nav under the text input) in the multi-session - // UI, giving its rows to the chat window. - hideSessionBar?: boolean + sessionBar?: SessionBarConfig } // The pre-hosts (v1) file shape — a single flat instance. Kept only so @@ -216,10 +235,54 @@ export function clearAllTokens(): void { saveConfig(cfg) } -// Whether the multi-session UI should hide the session bar. Read from the -// config file only — set `"hideSessionBar": true` in ~/.ellipsis/config.json. -export function hideSessionBar(): boolean { - return loadConfig().hideSessionBar === true +// The session bar with nothing configured: scoped to the repository you are +// standing in and the last week, which is short enough to read at a glance +// without hiding a session you are likely to reopen. `repo: 'cwd'` falls back +// to every repository outside a repo, so the bar is never mysteriously empty. +export const SESSION_BAR_DEFAULTS: Required> & { + sources: string[] | undefined +} = { + hidden: false, + rows: 5, + days: 7, + repo: 'cwd', + statuses: 'all', + sources: undefined, +} + +export type ResolvedSessionBar = typeof SESSION_BAR_DEFAULTS + +const SESSION_SOURCES = ['react', 'manual', 'api', 'cli', 'mention', 'cron', 'laptop'] + +// The session bar's settings, defaults filled in — set them under +// `"sessionBar"` in ~/.ellipsis/config.json. A value of the wrong type or +// outside its range takes the default rather than throwing: a typo in a +// preference should not stop the UI from opening. +export function sessionBar(): ResolvedSessionBar { + const raw = loadConfig().sessionBar + if (!raw || typeof raw !== 'object') return { ...SESSION_BAR_DEFAULTS } + const sources = Array.isArray(raw.sources) + ? raw.sources.filter((s) => SESSION_SOURCES.includes(s)) + : undefined + return { + hidden: raw.hidden === true, + rows: + typeof raw.rows === 'number' && isFinite(raw.rows) && raw.rows >= 1 + ? Math.floor(raw.rows) + : SESSION_BAR_DEFAULTS.rows, + days: + typeof raw.days === 'number' && isFinite(raw.days) && raw.days >= 0 + ? Math.floor(raw.days) + : SESSION_BAR_DEFAULTS.days, + repo: raw.repo === 'any' || raw.repo === 'cwd' ? raw.repo : SESSION_BAR_DEFAULTS.repo, + statuses: + raw.statuses === 'unfinished' || raw.statuses === 'all' + ? raw.statuses + : SESSION_BAR_DEFAULTS.statuses, + // An explicit [] would list nothing at all, which no one means; treat it + // as "every source", the same as leaving the key out. + sources: sources && sources.length > 0 ? sources : undefined, + } } export function getEnrolledRepos(): string[] { diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 659eaf6..8fca574 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,7 +1,13 @@ import { sessionStatusWord } from '@ellipsis-dev/sdk/stream' import type { AgentSessionWire } from '@ellipsis-dev/sdk' import { theme } from './theme' -import type { AgentSession, StartAgentSessionRequest, SupportedModel } from './types' +import type { + AgentSession, + AgentSessionSource, + ListAgentSessionsQuery, + StartAgentSessionRequest, + SupportedModel, +} from './types' // Pure session-model helpers shared by the connect command and the // multi-session UI (SessionsApp). No I/O here — everything is testable. @@ -221,6 +227,42 @@ export function mergeSidebarSessions( return sortSidebarSessions([...byId.values()]) } +// ---------------------------- session bar scope --------------------------- + +// The list query behind the session bar, from the user's `sessionBar` settings. +// Age, repository, status, and source are all server-side filters, so the rows +// that come back are already the ones worth showing and the page is not spent +// on sessions the bar would drop. +// +// `repo: 'cwd'` outside a repository asks for every repository rather than a +// repo named "" — the bar is a way back into your work, so a shell in ~ should +// still show it. +export function sessionBarQuery( + bar: { + rows: number + days: number + repo: 'cwd' | 'any' + statuses: 'all' | 'unfinished' + sources: string[] | undefined + }, + context: { authorId: number | null; detectedRepo: string | null }, +): ListAgentSessionsQuery { + const query: ListAgentSessionsQuery = { + author_id: context.authorId ?? undefined, + // Enough rows to band and scroll past the visible window, without paying + // for a page nobody scrolls to. + limit: Math.max(SESSION_BAR_FETCH, bar.rows), + } + if (bar.days > 0) query.days = bar.days + if (bar.repo === 'cwd' && context.detectedRepo) query.repo = context.detectedRepo + if (bar.statuses === 'unfinished') query.unfinished = true + if (bar.sources) query.source = bar.sources as AgentSessionSource[] + return query +} + +// How many rows the bar fetches to fill its window from. +export const SESSION_BAR_FETCH = 50 + // Attention transitions: a session that WAS in flight and now waits for a // human (waiting/sleeping/idle) deserves the sidebar dot. Pure step function // over consecutive poll snapshots. diff --git a/src/lib/types.ts b/src/lib/types.ts index f00b767..527aaf3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -389,6 +389,14 @@ export interface ListAgentSessionsQuery { // sessions attributed to that developer. The CLI resolves it from a --author // login. author_id?: number + // "owner/name" or a bare repository name. Sessions that name their + // repository only inside their agent config — dashboard starts, cron runs, + // handoffs — do not match. + repo?: string + // Keep only the conversations still going (live or sleeping), dropping the + // ones that completed, errored, or were stopped. A session parked between + // turns counts as unfinished. + unfinished?: boolean } // ----------------------------- session records --------------------------- diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 90c3f19..fd99715 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -32,6 +32,7 @@ import { ApiClient, ApiError } from '../lib/api' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' +import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { fitLines } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' @@ -717,6 +718,19 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // smears stale rows up the terminal. So the window's budget is whatever is // left AFTER the footer, never a floor that could exceed the pane, and the // window itself renders exactly that many rows (see rowViewport). + // ctrl+c interrupts the turn, then quits: the first press sends the same + // /stop the composer's command does, the second exits. Active whenever this + // pane owns the keyboard, watch-only follows included (nothing to stop there, + // but ctrl+c still has to be the way out). + const ctrlCArmed = useCtrlCQuit( + isRawModeSupported && focused && (composerVisible || hosted || !hasHost), + () => { + if (working && canSend) submit('/stop') + }, + ) + // The notice bar doubles as the ctrl+c prompt: armed, it says what a second + // press does, so the quit is never a surprise. + const shownNotice = ctrlCArmed ? CTRL_C_QUIT_HINT : notice const { viewBudget, padRows, composerRows, noticeRows } = useMemo(() => { // Both wrapping parts of the footer are measured as the rows they will // actually OCCUPY, not as the newlines they contain: a notice ("stream @@ -730,8 +744,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { let free = rows - bottomSlack - fixed // A pane with no room for chat + composer + notice drops the notice // entirely: overflowing the frame would smear the whole app. - const noticeRows = notice - ? Math.max(0, Math.min(fitLines(`· ${notice}`, cols).length, free - 2)) + const noticeRows = shownNotice + ? Math.max(0, Math.min(fitLines(`· ${shownNotice}`, cols).length, free - 2)) : 0 free -= noticeRows // The composer panel: its interior grows with the input, plus the 1-cell @@ -752,7 +766,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { topPad, composerVisible, composer.text, - notice, + shownNotice, props.hideMetaLine, ]) @@ -1351,9 +1365,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* The notice is budgeted at its wrapped height (noticeRows) and pinned to it, so an unbounded one (a stream error, an API error detail) can't grow the frame past the pane. */} - {notice && noticeRows > 0 && ( + {shownNotice && noticeRows > 0 && ( - · {notice} + · {shownNotice} )} {/* The composer: the input area on the elevated surface — one step diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 6cd321a..17dfc57 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -20,6 +20,7 @@ import type { SupportedModel, } from '../lib/types' import { applyEditShortcut } from '../lib/editing' +import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { hyperlink, sessionUrl } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { @@ -35,11 +36,13 @@ import { rowMeta, rowStatusWord, navSlice, + sessionBarQuery, sessionSource, SELECTION_GLYPH, sidebarSlice, mergeSidebarSessions, } from '../lib/sessions' +import type { ResolvedSessionBar } from '../lib/config' import { randomFact } from '../lib/facts' import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' import { ConnectApp } from './ConnectApp' @@ -67,7 +70,6 @@ import { ConnectApp } from './ConnectApp' // repaints instantly and the stream resumes past the cached cursor. const SIDEBAR_POLL_MS = 5_000 -const SIDEBAR_LIMIT = 50 // The nav clock driving the "12s" age tags. const AGE_TICK_MS = 5_000 const NAV_NEW_LABEL = '+ New session' @@ -75,9 +77,6 @@ const NAV_NEW_LABEL = '+ New session' // the right budgets itself against what's left of the row. const HEADER_TITLE = 'ellipsis.dev' const TITLE_WIDTH = HEADER_TITLE.length -// The nav shows six rows: the pinned new-session row plus the five most -// recent sessions, which scroll under the highlight. -const NAV_SESSION_ROWS = 5 // Blank canvas cells between the frame and the terminal edge, on all four // sides. Everything inside lays out against the inset width/height. @@ -114,10 +113,11 @@ export interface SessionsAppProps { // caveat to show in its chat (watch-only reasons ride connectability). initialConfigName?: string initialNotice?: string - // Drop the session nav (band 4) entirely, giving its rows to the chat. - // Focus never leaves the chat: esc and ↓ at the bottom edge do nothing. - // Set via "hideSessionBar": true in the config file. - hideSessionBar?: boolean + // How the session nav (band 4) is scoped: how many rows it shows and which + // sessions reach it. `hidden` drops the band entirely, giving its rows to + // the chat — focus then never leaves the chat, so esc and ↓ at the bottom + // edge do nothing. Set under "sessionBar" in the config file. + sessionBar: ResolvedSessionBar // Builds the start request for a composer-spawned session (the entry point // owns repository detection and defaults). buildStartRequest: (prompt: string) => StartAgentSessionRequest @@ -136,8 +136,8 @@ type ChatEntry = { type MainPane = { type: 'new' } | { type: 'chat'; sessionId: string } export function SessionsApp(props: SessionsAppProps): React.ReactElement { - const { api, openSocket, appBase, customerLogin, authorId } = props - const hideNav = props.hideSessionBar === true + const { api, openSocket, appBase, customerLogin, authorId, sessionBar } = props + const hideNav = sessionBar.hidden const { exit } = useApp() const { isRawModeSupported } = useStdin() const { stdout } = useStdout() @@ -185,10 +185,9 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const poll = useCallback(async (): Promise => { try { - const listed = await api.listAgentSessions({ - author_id: authorId ?? undefined, - limit: SIDEBAR_LIMIT, - }) + const listed = await api.listAgentSessions( + sessionBarQuery(sessionBar, { authorId, detectedRepo: props.detectedRepo }), + ) setAttention((prev) => { const next = new Set(prev) for (const s of listed) { @@ -207,7 +206,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // fails every tick is a broken session list, not a blip. reportApiError('sessions', err) } - }, [api, authorId, reportApiError]) + }, [api, authorId, reportApiError, sessionBar, props.detectedRepo]) // The poll only feeds the nav's rows and attention dots; with the bar // hidden there is nothing on screen it could update. @@ -227,7 +226,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // Cloud sessions only. A laptop session is a local `claude` run synced up for // the record; opening one here has nothing to connect to, so it would be a - // dead row taking a slot from the five cloud sessions worth showing. + // dead row taking a slot from a session worth showing. Client-side because + // it holds whatever `sessionBar.sources` says. const rows = useMemo( () => mergeSidebarSessions(sessions, localSessions).filter((s) => sessionSource(s) !== 'laptop'), [localSessions, sessions], @@ -432,6 +432,13 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { { isActive: focus === 'nav' && isRawModeSupported }, ) + // ctrl+c quits from the nav and from the panes that aren't a live chat (the + // new-session form, a chat still loading) — the chat pane owns its own, where + // the first press also interrupts the running turn. + const chatOwnsCtrlC = mainPane.type === 'chat' && entries.has(mainPane.sessionId) + const navArmed = useCtrlCQuit(focus === 'nav' && isRawModeSupported) + const paneArmed = useCtrlCQuit(focus === 'chat' && !chatOwnsCtrlC && isRawModeSupported) + // With the session bar hidden there is nothing to hand focus to: esc and ↓ // at the chat's bottom edge land where they started. const focusNav = useCallback((): void => { @@ -444,11 +451,11 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------- rendering -------------------------------- // Band heights: header = blank + title line + rule (3); nav = rule + the - // new-session row + five session rows + hint (8), or nothing when hidden. - // The chat band gets the rest, and a terminal too short for both drops - // session rows rather than growing the frame past the screen. + // new-session row + `sessionBar.rows` session rows + hint, or nothing when + // hidden. The chat band gets the rest, and a terminal too short for both + // drops session rows rather than growing the frame past the screen. const headerRows = 3 - const navSessionRows = Math.max(1, Math.min(NAV_SESSION_ROWS, contentRows - 10)) + const navSessionRows = Math.max(1, Math.min(sessionBar.rows, contentRows - 10)) const navRows = hideNav ? 0 : 3 + navSessionRows const chatRows = Math.max(4, contentRows - headerRows - navRows) @@ -492,7 +499,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { {/* truncate, never wrap: the bar is budgeted at exactly one row, and a wrapped meta line pushes the rule off the bottom of the band. */} - {metaText ?? whoText} + {/* Armed, the bar carries the ctrl+c prompt: the nav and the + new-session form have no notice line of their own, and the + header is the one band always on screen. */} + {navArmed || paneArmed ? CTRL_C_QUIT_HINT : (metaText ?? whoText)} @@ -580,7 +590,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { } // ---- band 4: the session nav ---- - // A vertical list of six rows: the pinned new-session row, then five session + // A vertical list: the pinned new-session row, then `sessionBar.rows` session // rows (status dot + description + a dim age tag) in sortSidebarSessions // order — status band, newest-born first — windowed so the highlight parks // on the second-to-last row and the list scrolls under it. The band's height diff --git a/src/ui/ctrlC.ts b/src/ui/ctrlC.ts new file mode 100644 index 0000000..f99f467 --- /dev/null +++ b/src/ui/ctrlC.ts @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { useApp, useInput } from 'ink' + +// ctrl+c, everywhere in the UI: the first press interrupts (whatever the pane +// decides that means — the chat stops a running turn) and arms the quit, the +// second press exits, any other key disarms. Ink's own exitOnCtrlC is turned +// off at both render calls so this runs instead of an immediate teardown: a +// running agent should be interruptible without ending the session. +// +// Every pane that owns the keyboard mounts this, and only the pane with focus +// is active — so the armed flag is per-pane, and one press can't arm a handler +// that a later press won't reach. Returns whether the quit is armed, for the +// pane to prompt with. +export function useCtrlCQuit(active: boolean, onInterrupt?: () => void): boolean { + const { exit } = useApp() + const [armed, setArmed] = useState(false) + useInput( + (ch, key) => { + if (key.ctrl && ch === 'c') { + if (armed) exit() + else { + setArmed(true) + onInterrupt?.() + } + return + } + if (armed) setArmed(false) + }, + { isActive: active }, + ) + return active && armed +} + +export const CTRL_C_QUIT_HINT = 'press ctrl+c again to exit' diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 6fa1951..24f3145 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -1,7 +1,7 @@ import React from 'react' import { render } from 'ink' import { ApiClient } from '../lib/api' -import { hideSessionBar, requireToken, resolveApiBase, resolveAppBase } from '../lib/config' +import { requireToken, resolveApiBase, resolveAppBase, sessionBar } from '../lib/config' import { repoFromCwd } from '../lib/laptop' import { makeOpenSocket, resolveWsBase } from '../lib/stream' import type { StartAgentSessionRequest } from '../lib/types' @@ -70,9 +70,12 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { initialSessionId: options.initialSessionId, initialConfigName: options.initialConfigName, initialNotice: options.initialNotice, - hideSessionBar: hideSessionBar(), + sessionBar: sessionBar(), buildStartRequest: options.buildStartRequest, }), + // exitOnCtrlC off: the app handles ctrl+c itself (first press interrupts a + // running turn, second quits), which ink's default teardown would preempt. + { exitOnCtrlC: false }, ) // Same revoked-TTY guard as the solo connect: when the terminal is torn // down abruptly, stdin's fd stays open but polls fire forever; unmount on diff --git a/test/config.test.ts b/test/config.test.ts index 4be127d..7e0fcdd 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -9,13 +9,14 @@ import { clearActiveHostToken, deleteHost, getEnrolledRepos, - hideSessionBar, listHosts, loadConfig, requireToken, resolveApiBase, resolveAppBase, resolveToken, + SESSION_BAR_DEFAULTS, + sessionBar, setActiveHostToken, setEnrolledRepos, updateHost, @@ -220,25 +221,73 @@ describe('host management', () => { }) }) -describe('hideSessionBar', () => { - it('defaults to false with no config file', () => { - expect(hideSessionBar()).toBe(false) +describe('sessionBar', () => { + it('falls back to the defaults with no config file', () => { + expect(sessionBar()).toEqual(SESSION_BAR_DEFAULTS) + }) + + it('reads every field from the config file', () => { + writeConfig({ + version: 2, + hosts: {}, + sessionBar: { + hidden: true, + rows: 8, + days: 30, + repo: 'any', + statuses: 'unfinished', + sources: ['cli', 'manual'], + }, + }) + expect(sessionBar()).toEqual({ + hidden: true, + rows: 8, + days: 30, + repo: 'any', + statuses: 'unfinished', + sources: ['cli', 'manual'], + }) }) - it('reads true from the config file', () => { - writeConfig({ version: 2, hosts: {}, hideSessionBar: true }) - expect(hideSessionBar()).toBe(true) + it('fills in the fields the file leaves out', () => { + writeConfig({ version: 2, hosts: {}, sessionBar: { rows: 3 } }) + expect(sessionBar()).toEqual({ ...SESSION_BAR_DEFAULTS, rows: 3 }) + }) + + // A typo in a preference should not stop the UI from opening. + it('ignores values of the wrong type or out of range', () => { + writeConfig({ + version: 2, + hosts: {}, + sessionBar: { hidden: 'yes', rows: 0, days: -3, repo: 'origin', statuses: 'live' }, + }) + expect(sessionBar()).toEqual(SESSION_BAR_DEFAULTS) }) - it('treats a non-boolean value as false', () => { - writeConfig({ version: 2, hosts: {}, hideSessionBar: 'yes' }) - expect(hideSessionBar()).toBe(false) + it('drops unknown sources and treats an empty list as every source', () => { + writeConfig({ version: 2, hosts: {}, sessionBar: { sources: ['cli', 'carrier-pigeon'] } }) + expect(sessionBar().sources).toEqual(['cli']) + writeConfig({ version: 2, hosts: {}, sessionBar: { sources: [] } }) + expect(sessionBar().sources).toBeUndefined() + writeConfig({ version: 2, hosts: {}, sessionBar: { sources: ['carrier-pigeon'] } }) + expect(sessionBar().sources).toBeUndefined() + }) + + it('takes days 0 as "no age cutoff", not as a missing value', () => { + writeConfig({ version: 2, hosts: {}, sessionBar: { days: 0 } }) + expect(sessionBar().days).toBe(0) }) it('survives a host write', () => { - writeConfig({ version: 2, hosts: {}, hideSessionBar: true }) + writeConfig({ version: 2, hosts: {}, sessionBar: { hidden: true } }) addHost('beta', 'https://beta-api.ellipsis.dev') - expect(hideSessionBar()).toBe(true) + expect(sessionBar().hidden).toBe(true) + }) + + // hideSessionBar (the flat key sessionBar replaced) is gone, not honored. + it('ignores the old hideSessionBar key', () => { + writeConfig({ version: 2, hosts: {}, hideSessionBar: true }) + expect(sessionBar().hidden).toBe(false) }) }) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 1b62301..39d246a 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -13,6 +13,8 @@ import { rowGlyph, rowMeta, rowStatusWord, + SESSION_BAR_FETCH, + sessionBarQuery, sessionSource, shortAge, sidebarSlice, @@ -246,6 +248,64 @@ describe('sessionSource', () => { }) }) +describe('sessionBarQuery', () => { + const bar = { + rows: 5, + days: 7, + repo: 'cwd' as const, + statuses: 'all' as const, + sources: undefined, + } + const context = { authorId: 42, detectedRepo: 'acme/api' } + + it('scopes to the author, the cwd repo, and the age cutoff', () => { + expect(sessionBarQuery(bar, context)).toEqual({ + author_id: 42, + limit: SESSION_BAR_FETCH, + days: 7, + repo: 'acme/api', + }) + }) + + // Outside a repository, asking for repo "" would empty the bar; the whole + // account is the useful answer instead. + it('drops the repo filter when the cwd is not a repository', () => { + expect(sessionBarQuery(bar, { authorId: 42, detectedRepo: null }).repo).toBeUndefined() + }) + + it('drops the repo filter under repo "any" even inside one', () => { + expect(sessionBarQuery({ ...bar, repo: 'any' }, context).repo).toBeUndefined() + }) + + it('omits days entirely at 0, rather than asking for a zero-day window', () => { + expect(sessionBarQuery({ ...bar, days: 0 }, context).days).toBeUndefined() + }) + + it('asks for unfinished sessions only when configured to', () => { + expect(sessionBarQuery(bar, context).unfinished).toBeUndefined() + expect(sessionBarQuery({ ...bar, statuses: 'unfinished' }, context).unfinished).toBe(true) + }) + + it('passes sources through and omits them when unset', () => { + expect(sessionBarQuery(bar, context).source).toBeUndefined() + expect(sessionBarQuery({ ...bar, sources: ['cli', 'manual'] }, context).source).toEqual([ + 'cli', + 'manual', + ]) + }) + + // An API-key credential has no GitHub user behind it: list the account's. + it('omits the author filter without one', () => { + expect( + sessionBarQuery(bar, { authorId: null, detectedRepo: 'acme/api' }).author_id, + ).toBeUndefined() + }) + + it('fetches at least as many rows as the bar displays', () => { + expect(sessionBarQuery({ ...bar, rows: 200 }, context).limit).toBe(200) + }) +}) + describe('statusBand / sortSidebarSessions', () => { // A row per band, deliberately born newest-first-is-wrong-order so a // recency sort can't accidentally pass.