Skip to content

Scope the session bar from config, and interrupt on ctrl+c before quitting - #109

Merged
hbrooks merged 1 commit into
mainfrom
hunter/session-bar-filters
Aug 11, 2026
Merged

Scope the session bar from config, and interrupt on ctrl+c before quitting#109
hbrooks merged 1 commit into
mainfrom
hunter/session-bar-filters

Conversation

@hbrooks

@hbrooks hbrooks commented Aug 11, 2026

Copy link
Copy Markdown
Member

The session bar

It listed everything: one GET /sessions?author_id&limit=50, laptop rows dropped client-side, five rows shown. Any age, any repo, any status. In an account with several repos and some history that is mostly noise.

config.json now takes a sessionBar object:

{
  "sessionBar": {
    "hidden": false,
    "rows": 5,
    "days": 7,
    "repo": "cwd",
    "statuses": "all",
    "sources": ["cli", "manual"]
  }
}

days, repo, statuses, and sources become server-side filters, so the page is spent on rows the bar will actually show rather than on ones it drops. rows replaces the hardcoded 5. Every field is optional; a value of the wrong type or out of range takes the default rather than throwing, since a typo in a preference should not stop the UI from opening.

Defaults change. With no sessionBar at all: repo: "cwd" and days: 7, which is what makes the bar short without anyone editing a file. rows: 5 and statuses: "all" keep today's shape — a session that finished an hour ago is still the row you most often reopen.

hideSessionBar is gone, not honored as a fallback. It shipped recently and we are pre-revenue; sessionBar.hidden replaces it. A test pins that the old key does nothing.

repo and unfinished are the params ellipsis-dev/ellipsis#6194 added, released in @ellipsis-dev/sdk 0.7.0. This does not bump the CLI's SDK dependency — see below.

Two caveats worth knowing, both documented in the README: a shell outside a repository lists every repository rather than nothing, and sessions that name their repo only inside their agent config (dashboard starts, cron, handoffs) do not match a repo filter at all, so "repo": "any" is the way to see those alongside the rest.

ctrl+c

Bundling a change that was in the working tree, by request. ctrl+c used to let ink tear the app down immediately, killing a running turn. Now the first press interrupts (the chat sends the same /stop the composer does) and arms the quit, the second exits, any other key disarms. useCtrlCQuit in src/ui/ctrlC.ts is mounted by each pane that owns the keyboard, so the armed flag is per-pane and one press cannot arm a handler a later press will not reach. exitOnCtrlC: false at both render sites. While armed the chat's notice line and the multi-session header carry press ctrl+c again to exit, so the quit is never a surprise.

Verification

Typecheck, 456 tests, build, and the compile-smoke all pass locally — the same four gates CI runs.

Behavior checked with throwaway offline ink render harnesses rather than a live session:

  • rows: 5 renders five rows; rows: 2 renders two and gives the space back to the chat; hidden drops the band and issues no poll at all.
  • The queries each config produces: {author_id, limit, days, repo} at the defaults, {author_id, limit, unfinished} under repo: "any" + statuses: "unfinished" + days: 0.
  • ctrl+c: header swaps to the hint on the first press without exiting, exits on the second.

Heads up, not in this PR

agent review with no PR argument is broken against SDK 0.6.0+, and has been since before this branch. buildCreateRequest sends branch/sha on CreateReviewRequest; the server model (reviews_service.py) has only owner, repo, pull_request_number, scope, post. CI never caught it because bun.lock pins 0.5.0, where the type was looser.

So this PR deliberately does not bump @ellipsis-dev/sdk — the new list params go over the wire regardless, since ListAgentSessionsQuery is a hand-rolled mirror by the convention documented at the top of src/lib/types.ts. Bumping the CLI to 0.7.0 needs the review command fixed first, which is its own change.

Important

Adds sessionBar config object to scope the session list, and improves ctrl+c behavior to interrupt before quitting.

  • Session bar configuration: The session list now respects sessionBar settings in config.json — hidden, rows, days, repo, statuses, and sources. Age, repo, status, and source filters now run server-side instead of client-side, so the page fetches only rows the bar will show.
  • Defaults tighten the bar: With no config, defaults to repo: "cwd" (current repository) and days: 7 (last week), making the bar short without config edits; rows: 5 and statuses: "all" preserve today's appearance for sessions.
  • Old key removed: hideSessionBar is gone, replaced by sessionBar.hidden. Invalid config values (wrong type, out of range) fall back to defaults rather than throwing — a typo should not stop the UI from opening.
  • ctrl+c improves: First press interrupts the running turn; second exits the app. While armed, the notice line shows "press ctrl+c again to exit" so the quit is never a surprise. Ink's exitOnCtrlC is disabled so this runs instead of immediate teardown.

This description was created by Ellipsis for 785abe9. It will automatically update as commits are pushed.

…re it quits

The bar listed every session you had ever run, of any age, on any repo. It now
takes a `sessionBar` object in config.json — hidden, rows, days, repo, statuses,
sources — with the age, repo, status, and source cuts pushed to the server, so
the page is not spent on rows the bar would drop. Defaults scope it to the repo
you are standing in and the last week. Replaces the flat hideSessionBar key.

ctrl+c no longer tears the UI down mid-turn: the first press stops the running
turn, the second exits, and the pane says so while armed.
@hbrooks
hbrooks force-pushed the hunter/session-bar-filters branch from 785abe9 to 9cef4d9 Compare August 11, 2026 17:11

@ellipsis-dev ellipsis-dev Bot left a comment

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.

Caution

Changes requested ❌ — 4 issues

Reviewed 785abe9 in 11 minutes, 45 seconds.
  • Reviewed 1 commit with 581 lines of code in 11 files
  • Ran 1 review agent producing 4 comments where 4 were posted
  • This pipeline runs no gatekeeper, so findings are posted as written.
  • View full details on ellipsis.dev

This review was created by Ellipsis. You can tag @ellipsis in this pull request.

Comment thread src/ui/ConnectApp.tsx
Comment on lines +725 to +730
const ctrlCArmed = useCtrlCQuit(
isRawModeSupported && focused && (composerVisible || hosted || !hasHost),
() => {
if (working && canSend) submit('/stop')
},
)

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)
},
)

Comment thread src/ui/ctrlC.ts
Comment on lines +1 to +16
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)

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.

armed survives the pane going inactive, so a later single ctrl+c exits with no warning press: reset it when active turns false.

agent session connect <id>: the chat pane is still loading, so paneArmed (SessionsApp.tsx:419) owns ctrl+c. One press arms it; loadEntry then resolves, chatOwnsCtrlC flips true and the hook goes inactive with armed still true — no key ever reached it to disarm. Walk back to the nav and open '+ New session' and paneArmed is active again, returning armed=true: the header shows the hint unprompted and the next single press quits.

Suggested change
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)
import { useEffect, 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)
// Losing the keyboard disarms: no key reaches this handler while it is
// inactive, so a stale flag would let the next single press exit unwarned.
useEffect(() => {
if (!active) setArmed(false)
}, [active])

Comment thread src/ui/SessionsApp.tsx
// 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))

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.

"rows": 1 overflows the frame: navSlice keeps a row of lookahead, so with 2+ sessions it returns 2 cells for a 1-row band (3 + navSessionRows = 4 budgeted, 5 rendered), and an over-tall frame smears rows the way this file's comments warn about. Floor the band at 2.

navSlice(count, 1, sel) with count > 1 takes cap = Math.max(2, capacity) = 2 (sessions.ts:399) and returns a 2-cell window, which line 612 renders in full; the band's children are then blank + new-session + 2 + hint = 5 rows inside height={navRows} = 4.

Suggested change
const navSessionRows = Math.max(1, Math.min(sessionBar.rows, contentRows - 10))
const navSessionRows = Math.max(2, Math.min(sessionBar.rows, contentRows - 10))

Comment thread src/ui/SessionsApp.tsx
Comment on lines +179 to +181
const listed = await api.listAgentSessions(
sessionBarQuery(sessionBar, { authorId, detectedRepo: props.detectedRepo }),
)

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.

A composer-started session the repo filter cannot match never refreshes: it stays in localSessions forever with its creation-time status, because line 192 only drops a local row once the poll returns it.

Uncheck every repository in the new-session pane (a supported 'sandbox with no checkout') and applyComposerChoices deletes req.repository (sessions.ts:364), so the session has no repo and the default repo=cwd query never lists it. Its nav row then holds 'scheduled', 0 turns, and an age that only climbs, and attentionFlip never fires for it. Same for a session started on a repo other than the cwd, whose repo rides only in config_override — which the README says a repo filter does not match.

@hbrooks
hbrooks merged commit d1cb710 into main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant