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
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,12 @@ is migrated on first use — your existing login becomes a host named for its AP
base.

The config file also carries UI preferences. `"sessionBar"` scopes the session
list at the bottom of the interactive UI:
list, which the interactive UI opens as a full screen with `ctrl+j`:

```json
{
"sessionBar": {
"hidden": false,
"rows": 5,
"days": 7,
"repo": "cwd",
"statuses": "all",
Expand All @@ -177,9 +176,8 @@ list at the bottom of the interactive UI:
}
```

`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
`hidden` drops the list entirely, so the chat never hands focus to it. `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
Expand Down
18 changes: 7 additions & 11 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,11 @@ export async function runConnect(
// Written by the app when it exits because the conversation closed (terminal;
// nothing left to reconnect to), so the detach sign-off below stays honest.
const exitState = { closed: false }
// Start the app at the top of a fresh window: newlines scroll whatever is on
// screen (the shell prompt, anything a caller printed) into scrollback, then
// the cursor homes to row 1. Without this the first paint begins mid-screen,
// overflows the window, and the app's opening lines (the sandbox startup
// notes) end up stranded above the fold.
if (process.stdout.isTTY) {
process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H')
}
// No screen-clearing dance here any more: the app prints its settled
// transcript INTO this terminal's scrollback (see ConnectApp's scrollback
// mode), so the conversation grows down the terminal from wherever the shell
// prompt left off, exactly like ordinary command output. Homing the cursor
// would throw away the scrollback the app now relies on.
const app = render(
React.createElement(ConnectApp, {
api: client,
Expand Down Expand Up @@ -204,9 +201,8 @@ export async function runConnect(

if (canSend && !exitState.closed) {
// The session keeps running after a detach; hand back the exact command
// that re-opens this conversation. No leading newline: the single row this
// line scrolls is absorbed by the app's top padding (see ConnectApp), so
// the sign-off never scrolls the app's first content line out of the window.
// that re-opens this conversation. It prints under the app's last frame,
// like any other command's parting line.
console.log(`resume with: agent session connect ${sessionId}`)
}
}
9 changes: 1 addition & 8 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ export interface Host {
// 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.
// Drop the session list entirely: the chat then never hands focus to it.
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
Expand Down Expand Up @@ -243,7 +241,6 @@ export const SESSION_BAR_DEFAULTS: Required<Omit<SessionBarConfig, 'sources'>> &
sources: string[] | undefined
} = {
hidden: false,
rows: 5,
days: 7,
repo: 'cwd',
statuses: 'all',
Expand All @@ -266,10 +263,6 @@ export function sessionBar(): ResolvedSessionBar {
: 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)
Expand Down
9 changes: 4 additions & 5 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ export function mergeSidebarSessions(
// still show it.
export function sessionBarQuery(
bar: {
rows: number
days: number
repo: 'cwd' | 'any'
statuses: 'all' | 'unfinished'
Expand All @@ -213,9 +212,9 @@ export function sessionBarQuery(
): 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),
// Enough rows to band and scroll past a screenful, without paying for a page
// nobody scrolls to.
limit: SESSION_BAR_FETCH,
}
if (bar.days > 0) query.days = bar.days
if (bar.repo === 'cwd' && context.detectedRepo) query.repo = context.detectedRepo
Expand All @@ -224,7 +223,7 @@ export function sessionBarQuery(
return query
}

// How many rows the bar fetches to fill its window from.
// How many rows the session list fetches to fill its screen from.
export const SESSION_BAR_FETCH = 50

// Attention transitions: a session that WAS in flight and now waits for a
Expand Down
116 changes: 36 additions & 80 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,91 +10,47 @@ import chalk from 'chalk'
// only ever renders the dark palette — there is no light variant to switch to.
//
// One rule carried over from the web app (landing globals.css `.dark`): the
// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores
// 1.79:1 on the panel — unreadable as terminal text. So emphasis is carried by
// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores 1.79:1
// on a dark surface — unreadable as terminal text. So emphasis is carried by
// brightness (bone against stone), not by hue. The ▶ cursor is the one
// exception, and takes `cursor` below.
//
// Because the CLI paints its own canvas, the palette only holds if it is used
// for EVERY cell of the frame. Two rules keep it whole on a terminal whose own
// theme is light:
// EXACTLY ONE SURFACE IS PAINTED: the composer's (`inputSurface` below). The CLI
// used to paint a canvas behind everything and lift panels onto it, which worked
// while the app owned every cell of a fixed frame. It no longer does — the
// transcript is printed into the terminal's own scrollback, where a row is never
// repainted, so a fill there outlives the frame that drew it and stale bands
// survive a resize or a shorter frame with nothing able to clean them up.
//
// 1. Every glyph takes a color from this file. Ink leaves a `<Text>` with no
// `color` prop on the terminal's DEFAULT foreground, which under a light
// theme is near-black — the same near-black we just painted the canvas
// with, so the text vanishes. `dimColor` on its own is that bug plus an
// \x1b[2m: secondary copy takes `muted`, never a bare `dimColor`. (dim is
// fine ON TOP of an explicit color, where it only shades a known hue.)
// 2. Surfaces reach ink already quantized for the terminal's color depth —
// see `surfaceFor`, which is why the three surface entries below are
// computed rather than literal.

// The surfaces as authored. Call sites never read these: they take the
// `theme.*` entries, which are these run through `surfaceFor`.
const BRAND_SURFACES = {
canvas: '#1c1b1a',
panel: '#262523',
panelActive: '#343330',
} as const

// A surface hex ink can paint at `level` without losing the step between one
// surface and the next.
// The composer is the exception because it is the one region that is ALWAYS
// repainted and NEVER flushed: it lives in the live frame for the whole session,
// so its fill is redrawn on every frame and disappears with the app. Transcript
// rows, the header, and list highlights are all either printed or sized around
// printed rows, so they carry no fill.
//
// chalk resolves a hex onto the 256-color palette two different ways: to the
// 24-rung GREYSCALE RAMP (indexes 232-255, ~10 units apart) when r, g and b
// are equal, and otherwise to the 6x6x6 COLOR CUBE, whose darkest step above
// black is rgb(95,95,95). The brand surfaces are WARM greys — their channels
// differ by a point or two — so on a terminal that does 256 colors but not
// truecolor (Terminal.app, tmux without RGB, mosh, plain conhost) all three
// land on cube index 59 simultaneously: the near-black canvas paints as a mid
// grey slab, and the panel and active steps disappear along with every "you
// are here" highlight that was carried by them.
//
// Averaging the channels is invisible at this brightness (a warm near-black
// and a neutral near-black are the same wall of dark) and puts each surface
// back on its own rung: 234, 235, 236. Truecolor terminals get the authored
// warmth untouched; a 16-color terminal renders both spellings as its palette
// black, so the substitution costs nothing there either.
export function surfaceFor(hex: string, level: number): string {
if (level >= 3) return hex
const value = hex.replace('#', '')
const channels = [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16))
if (channels.some(Number.isNaN)) return hex
const mean = Math.round((channels[0] + channels[1] + channels[2]) / 3)
return `#${mean.toString(16).padStart(2, '0').repeat(3)}`
}

// `chalk.level` is read once, at import: ink colorizes through this very chalk
// instance (it is a hoisted single copy), so what it can render is what we
// quantize for.
const COLOR_LEVEL: number = chalk.level
// One rule survives from the canvas days, and it still matters: every glyph takes
// a colour from this file. Ink leaves a `<Text>` with no `color` prop on the
// terminal's DEFAULT foreground, so a hardcoded assumption either way breaks one
// theme; `dimColor` on its own is that bug plus an \x1b[2m, so secondary copy
// takes `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit
// colour, where it only shades a known hue.)

export const theme = {
// The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely
// a lift, which is the point — a panel should separate, not stripe.
canvas: surfaceFor(BRAND_SURFACES.canvas, COLOR_LEVEL),
panel: surfaceFor(BRAND_SURFACES.panel, COLOR_LEVEL),
// One step lighter than `panel`: the brand border hairline, doing duty as
// the "you are here" surface (highlighted message, focused composer,
// selected nav row). Selection is a brightness step between surfaces —
// never the full inverse flash, which reads bone-white and far too loud.
panelActive: surfaceFor(BRAND_SURFACES.panelActive, COLOR_LEVEL),

// Type. `foreground` is body copy and doubles as the accent (see above);
// `muted` is every secondary string (meta, hints, timestamps) — and, since
// rule 1 above rules out a bare `dimColor`, it is also how a quiet line
// reads quiet. 7.4:1 on the canvas, so quiet still means legible.
// the rule above rules out a bare `dimColor`, it is also how a quiet line
// reads quiet. 7.4:1 on the brand charcoal, so quiet still means legible.
foreground: '#f0efe9',
muted: '#a8a59c',

// The ▶ cursor, and nothing else. Bone-on-stone was too quiet a step to find
// at a glance on a busy frame, so the cursor carries HUE as well as
// brightness: cyan is the one hue not already spoken for (green = done,
// amber = working, red = failed), so it never reads as a status. 9.7:1 on
// the canvas and 7.2:1 on the active surface, so it holds up highlighted.
// The ▶ cursor, and nothing else — which, with no highlight bar to fall back
// on, is now the ONLY thing that says "you are here". Bone-on-stone was too
// quiet a step to find at a glance, so the cursor carries HUE as well as
// brightness: cyan is the one hue not already spoken for (green = done, amber
// = working, red = failed), so it never reads as a status.
cursor: '#5fd3e0',

// Status. Tuned for the charcoal canvas, not the light one.
// Status.
success: '#4ebc7b',
error: '#e5544b',
// In-flight. brand/tokens.json has no dedicated "working" color; this is
Expand All @@ -108,12 +64,12 @@ export const theme = {
syntaxString: '#c8c6bc',
}

// The elevated surface an input area sits on. Named separately from
// `theme.panel` because call sites mean "this is an input", not "this is
// #262523" — the composers in ConnectApp/SessionsApp both use it.
export const SURFACE_ELEVATED = theme.panel

// The elevated surface, active: the focused composer, the highlighted
// transcript message, the selected nav row. One brightness step above
// SURFACE_ELEVATED — enough to read "you are here" without the inverse flash.
export const SURFACE_ACTIVE = theme.panelActive
// The composer's fill — the app's ONE painted surface (see the note above for why
// it is the only one that can be). The brand panel step, neutralized: chalk sends
// any hex whose channels differ to the 6x6x6 colour cube, whose darkest step
// above black is rgb(95,95,95), so the authored warm #262523 paints as a MID GREY
// slab on a terminal that does 256 colours but not truecolor (Terminal.app, tmux
// without RGB, mosh, conhost). Equal channels route to the greyscale ramp
// instead, where a near-black stays near-black. Truecolor terminals lose only the
// warmth, which is invisible at this brightness.
export const inputSurface = chalk.level >= 3 ? '#262523' : '#252525'
Loading