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
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 70 additions & 7 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,9 +61,7 @@ export interface CliConfig {
version: 2
activeHost?: string
hosts: Record<string, Host>
// 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
Expand Down Expand Up @@ -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<Omit<SessionBarConfig, 'sources'>> & {
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[] {
Expand Down
44 changes: 43 additions & 1 deletion src/lib/sessions.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------
Expand Down
24 changes: 19 additions & 5 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
},
)
Comment on lines +725 to +730

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first ctrl+c wipes a typed-but-unsent draft: submit() clears the composer (ConnectApp.tsx:601) before it runs /stop, so interrupting while a turn is in flight throws away the message the user was composing. Restore the draft after the stop.

Typing while the agent works is a supported flow (sends queue). With text in the composer and working && canSend, ctrl+c calls submit('/stop'), whose first statement is setComposer({text: '', cursor: 0}) — the draft is gone, and the behavior is inconsistent with ctrl+c while idle, which leaves it alone.

Suggested change
const ctrlCArmed = useCtrlCQuit(
isRawModeSupported && focused && (composerVisible || hosted || !hasHost),
() => {
if (working && canSend) submit('/stop')
},
)
const ctrlCArmed = useCtrlCQuit(
isRawModeSupported && focused && (composerVisible || hosted || !hasHost),
() => {
if (!working || !canSend) return
// submit() clears the composer, but a stop is not a send: put the draft back.
const draft = composer
submit('/stop')
setComposer(draft)
},
)

// 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
Expand All @@ -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
Expand All @@ -752,7 +766,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
topPad,
composerVisible,
composer.text,
notice,
shownNotice,
props.hideMetaLine,
])

Expand Down Expand Up @@ -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 && (
<Box height={noticeRows} flexShrink={0} overflow="hidden">
<Text color={theme.muted}>· {notice}</Text>
<Text color={theme.muted}>· {shownNotice}</Text>
</Box>
)}
{/* The composer: the input area on the elevated surface — one step
Expand Down
Loading