Scope the session bar from config, and interrupt on ctrl+c before quitting - #109
Conversation
…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.
785abe9 to
9cef4d9
Compare
There was a problem hiding this comment.
Caution
Changes requested ❌ — 4 issues
Reviewed 785abe9 in 11 minutes, 45 seconds.
- Reviewed
1commit with581lines of code in11files - Ran
1review agent producing4comments where4were posted - This pipeline runs no gatekeeper, so findings are posted as written.
- View full details on ellipsis.dev
This review was created by . You can tag
@ellipsis in this pull request.
| const ctrlCArmed = useCtrlCQuit( | ||
| isRawModeSupported && focused && (composerVisible || hosted || !hasHost), | ||
| () => { | ||
| if (working && canSend) submit('/stop') | ||
| }, | ||
| ) |
There was a problem hiding this comment.
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.
| 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) | |
| }, | |
| ) |
| 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) |
There was a problem hiding this comment.
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.
| 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]) |
| // 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)) |
There was a problem hiding this comment.
"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.
| const navSessionRows = Math.max(1, Math.min(sessionBar.rows, contentRows - 10)) | |
| const navSessionRows = Math.max(2, Math.min(sessionBar.rows, contentRows - 10)) |
| const listed = await api.listAgentSessions( | ||
| sessionBarQuery(sessionBar, { authorId, detectedRepo: props.detectedRepo }), | ||
| ) |
There was a problem hiding this comment.
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.
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.jsonnow takes asessionBarobject:{ "sessionBar": { "hidden": false, "rows": 5, "days": 7, "repo": "cwd", "statuses": "all", "sources": ["cli", "manual"] } }days,repo,statuses, andsourcesbecome server-side filters, so the page is spent on rows the bar will actually show rather than on ones it drops.rowsreplaces 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
sessionBarat all:repo: "cwd"anddays: 7, which is what makes the bar short without anyone editing a file.rows: 5andstatuses: "all"keep today's shape — a session that finished an hour ago is still the row you most often reopen.hideSessionBaris gone, not honored as a fallback. It shipped recently and we are pre-revenue;sessionBar.hiddenreplaces it. A test pins that the old key does nothing.repoandunfinishedare the params ellipsis-dev/ellipsis#6194 added, released in@ellipsis-dev/sdk0.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
/stopthe composer does) and arms the quit, the second exits, any other key disarms.useCtrlCQuitinsrc/ui/ctrlC.tsis 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: falseat both render sites. While armed the chat's notice line and the multi-session header carrypress 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: 5renders five rows;rows: 2renders two and gives the space back to the chat;hiddendrops the band and issues no poll at all.{author_id, limit, days, repo}at the defaults,{author_id, limit, unfinished}underrepo: "any"+statuses: "unfinished"+days: 0.Heads up, not in this PR
agent reviewwith no PR argument is broken against SDK 0.6.0+, and has been since before this branch.buildCreateRequestsendsbranch/shaonCreateReviewRequest; the server model (reviews_service.py) has onlyowner,repo,pull_request_number,scope,post. CI never caught it becausebun.lockpins 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, sinceListAgentSessionsQueryis a hand-rolled mirror by the convention documented at the top ofsrc/lib/types.ts. Bumping the CLI to 0.7.0 needs the review command fixed first, which is its own change.Important
Adds
sessionBarconfig object to scope the session list, and improves ctrl+c behavior to interrupt before quitting.sessionBarsettings in config.json —hidden,rows,days,repo,statuses, andsources. Age, repo, status, and source filters now run server-side instead of client-side, so the page fetches only rows the bar will show.repo: "cwd"(current repository) anddays: 7(last week), making the bar short without config edits;rows: 5andstatuses: "all"preserve today's appearance for sessions.hideSessionBaris gone, replaced bysessionBar.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.exitOnCtrlCis disabled so this runs instead of immediate teardown.This description was created by
for 785abe9. It will automatically update as commits are pushed.