From 4c64c8cd1c03209e1e9a5dafabb4c49cff69124a Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 24 Jul 2026 15:49:41 -0400 Subject: [PATCH] connect: full-width composer that wraps, readline shortcuts, and a live model list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-session pane had grown several papercuts that made it read as a prototype rather than the place you start work: - the heading asked "What would you like to do?"; it now asks what we are shipping today - the prompt input sat one column in from the edge, so its rules did not line up with the header's or the nav's - a long prompt truncated and ran off the right edge instead of wrapping - neither text input honored the readline bindings a terminal actually delivers (option+arrows, ctrl+a/e/w/u/k), so a typo mid-prompt meant holding down the left arrow - the model picker's list was hardcoded and had gone stale, missing Opus 5 - the session nav ran its cells together as one strip of text, and its new-session button truncated to "+ Ne…" once the bar got crowded The model list now comes from GET /v1/models, so the picker tracks the server's registry instead of drifting from it every time we enable a model. An older server without that route falls back to the built-in list. The nav gains vertical rules between cells and pins the new-session button at its full label width, dropping session cells (which the "› N more" tail already accounts for) when the terminal is too narrow for both. --- src/lib/api.ts | 11 +++ src/lib/editing.ts | 80 +++++++++++++++++ src/lib/sessions.ts | 28 ++++-- src/lib/types.ts | 13 +++ src/ui/ConnectApp.tsx | 8 ++ src/ui/SessionsApp.tsx | 194 ++++++++++++++++++++++++++--------------- test/api.test.ts | 23 +++++ test/editing.test.ts | 112 ++++++++++++++++++++++++ test/sessions.test.ts | 24 +++++ 9 files changed, 414 insertions(+), 79 deletions(-) create mode 100644 src/lib/editing.ts create mode 100644 test/editing.test.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index 0fc191d..610b9f1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -23,6 +23,7 @@ import type { GetSandboxVariablesResponse, GetSessionIdeResponse, GetSessionPortResponse, + GetSupportedModelsResponse, ListAgentConfigsResponse, ListAgentDefaultsResponse, ListAgentSessionsQuery, @@ -51,6 +52,7 @@ import type { SyncAgentSessionResponse, SavedAgentConfig, StartAgentSessionRequest, + SupportedModel, UsageDashboard, WhoAmI, GetSessionLogResponse, @@ -396,6 +398,15 @@ export class ApiClient { return res.variables } + // ------------------------------- models --------------------------------- + + // The models a customer may select for their agent (GET /v1/models) — the + // registry behind the dashboard's rate table, most expensive first. + async listSupportedModels(): Promise { + const res = await this.request('GET', '/v1/models') + return res.models + } + // ---------------------------- agent templates --------------------------- async listAgentTemplates(): Promise { diff --git a/src/lib/editing.ts b/src/lib/editing.ts new file mode 100644 index 0000000..1f3bb40 --- /dev/null +++ b/src/lib/editing.ts @@ -0,0 +1,80 @@ +// Cursor and kill shortcuts shared by both text inputs (the chat composer and +// the new-session prompt): the readline bindings a terminal actually delivers +// — option/ctrl + ←/→ (and meta+b / meta+f, which is what iTerm sends for +// option+←/→) hop a word, ctrl+a / ctrl+e reach the line edges, +// option+backspace and ctrl+w kill a word, ctrl+u / ctrl+k kill to the line +// edges. cmd+←/→ never reaches the process (macOS terminals keep the command +// modifier for themselves), so ctrl+a / ctrl+e stand in for it. + +export type TextState = { text: string; cursor: number } + +// The subset of ink's key object these shortcuts read. +export interface EditKey { + ctrl: boolean + meta: boolean + leftArrow: boolean + rightArrow: boolean + backspace: boolean + delete: boolean +} + +const WORD_CHAR = /\S/ + +// The start of the word at or before the cursor: skip any whitespace to the +// left, then the run of word characters (newlines count as whitespace, so a +// hop can cross a line, exactly like a text editor's). +export function wordLeft(text: string, cursor: number): number { + let i = Math.max(0, Math.min(cursor, text.length)) + while (i > 0 && !WORD_CHAR.test(text[i - 1])) i-- + while (i > 0 && WORD_CHAR.test(text[i - 1])) i-- + return i +} + +// The end of the word at or after the cursor (mirror of wordLeft). +export function wordRight(text: string, cursor: number): number { + let i = Math.max(0, Math.min(cursor, text.length)) + while (i < text.length && !WORD_CHAR.test(text[i])) i++ + while (i < text.length && WORD_CHAR.test(text[i])) i++ + return i +} + +export function lineStart(text: string, cursor: number): number { + return cursor > 0 ? text.lastIndexOf('\n', cursor - 1) + 1 : 0 +} + +export function lineEnd(text: string, cursor: number): number { + const next = text.indexOf('\n', cursor) + return next < 0 ? text.length : next +} + +// The input state after an editing shortcut, or null when the keypress isn't +// one — the signal for the caller to keep walking its own key handling. +export function applyEditShortcut( + state: TextState, + ch: string, + key: EditKey, +): TextState | null { + if (!key.ctrl && !key.meta) return null + const { text, cursor } = state + if (key.leftArrow || (key.meta && (ch === 'b' || ch === 'B'))) { + return { text, cursor: wordLeft(text, cursor) } + } + if (key.rightArrow || (key.meta && (ch === 'f' || ch === 'F'))) { + return { text, cursor: wordRight(text, cursor) } + } + if (key.ctrl && ch === 'a') return { text, cursor: lineStart(text, cursor) } + if (key.ctrl && ch === 'e') return { text, cursor: lineEnd(text, cursor) } + if ((key.meta && (key.backspace || key.delete)) || (key.ctrl && ch === 'w')) { + const at = wordLeft(text, cursor) + return { text: text.slice(0, at) + text.slice(cursor), cursor: at } + } + if (key.ctrl && ch === 'u') { + const at = lineStart(text, cursor) + return { text: text.slice(0, at) + text.slice(cursor), cursor: at } + } + if (key.ctrl && ch === 'k') { + const at = lineEnd(text, cursor) + return { text: text.slice(0, cursor) + text.slice(at), cursor } + } + return null +} diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 486e7b0..d44b69f 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,6 +1,6 @@ import { sessionStatusWord } from '@ellipsis-dev/sdk/stream' import type { AgentSessionWire } from '@ellipsis-dev/sdk' -import type { AgentSession } from './types' +import type { AgentSession, 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. @@ -155,19 +155,31 @@ export function attentionFlip(prevWord: string | undefined, nextWord: string): b // --------------------------- new-session picker --------------------------- -// The agent-selectable models for the new-session composer, most capable -// first (the dashboard's GET /models ordering). Static because /models is a -// dashboard-cookie route the CLI's bearer token can't call; keep in sync -// with model_registry.py's agent_selectable set. `null` id = let the server -// pick (DEFAULT_AGENT_MODEL). -export const COMPOSER_MODELS: ReadonlyArray<{ id: string | null; label: string }> = [ - { id: null, label: 'Default (Claude Opus 4.8)' }, +export type ComposerModel = { id: string | null; label: string } + +// The composer's model list when GET /v1/models is unavailable (an older +// server): the agent-selectable set as of this build, most expensive first. +// `null` id = let the server pick (DEFAULT_AGENT_MODEL). +export const COMPOSER_MODELS: ReadonlyArray = [ + { id: null, label: 'Default' }, { id: 'claude-fable-5', label: 'Claude Fable 5' }, + { id: 'claude-opus-5', label: 'Claude Opus 5' }, { id: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, { id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }, { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, ] +// The composer's model options from the server's list, keeping its order and +// naming the model "Default" resolves to. +export function composerModelOptions(models: readonly SupportedModel[]): ComposerModel[] { + if (models.length === 0) return [...COMPOSER_MODELS] + const fallback = models.find((m) => m.is_default_agent_model) + return [ + { id: null, label: fallback ? `Default (${fallback.display_name})` : 'Default' }, + ...models.map((m) => ({ id: m.id as string | null, label: m.display_name })), + ] +} + // A saved config's display name (the YAML's ellipsis.name), falling back to // the row id. export function configDisplayName(config: { diff --git a/src/lib/types.ts b/src/lib/types.ts index 047f5bd..17ff2c5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -336,6 +336,19 @@ export interface ListAgentTemplatesResponse { first_run_yaml: string } +// One model a customer may select for their agent, from the registry behind +// the dashboard's rate table. `is_default_agent_model` marks what the server +// resolves "Default" to. +export interface SupportedModel { + id: string + display_name: string + is_default_agent_model: boolean +} + +export interface GetSupportedModelsResponse { + models: SupportedModel[] +} + export interface ListAgentSessionsQuery { config_id?: string source?: AgentSessionSource[] diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index c3a4931..5d4dace 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -34,6 +34,7 @@ import { import { ApiClient, ApiError } from '../lib/api' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' +import { applyEditShortcut } from '../lib/editing' import { SELECTION_GLYPH } from '../lib/sessions' import { VERSION } from '../lib/constants' @@ -879,6 +880,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { submit(composer.text) return } + // Word/line jumps and kills (option+←/→, ctrl+a/e/w/u/k, …) before the + // plain arrow handling below, which moves by one character. + const edited = applyEditShortcut(composer, ch, key) + if (edited) { + setComposer(edited) + return + } if (key.upArrow) { // Up inside a multi-line input climbs a line; up on line 1 moves // focus into the transcript, landing on the newest line. diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index d18e0f3..0db74a6 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -17,13 +17,15 @@ import type { AgentSession, SavedAgentConfig, StartAgentSessionRequest, + SupportedModel, } from '../lib/types' +import { applyEditShortcut } from '../lib/editing' import { hyperlink, sessionUrl } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { VERSION } from '../lib/constants' import { attentionFlip, - COMPOSER_MODELS, + composerModelOptions, configDisplayName, connectability, lastEventAt, @@ -64,6 +66,10 @@ const AGE_TICK_MS = 5_000 // One nav cell: dot + truncated description + age, fixed width so the bar // windows predictably. const NAV_ITEM_W = 30 +// The pinned new-session cell, wide enough for its whole label plus the +// selected state's padding — it never truncates to "+ Ne…". +const NAV_NEW_LABEL = '+ New session' +const NAV_NEW_W = NAV_NEW_LABEL.length + 3 export interface SessionsAppProps { api: ApiClient @@ -252,10 +258,13 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const [startError, setStartError] = useState(null) // The composer's picker options, fetched once when the new-session pane - // first opens: saved agent configs and the account's repositories (the - // dashboard composer's choices; models are the static selectable set). + // first opens: the dashboard composer's three choices — saved agent + // configs, the account's repositories, and the selectable models. A models + // failure (an older server without GET /v1/models) leaves the list empty + // and the composer falls back to its built-in set. const [configs, setConfigs] = useState(null) const [repos, setRepos] = useState(null) + const [models, setModels] = useState(null) const pickersLoading = useRef(false) useEffect(() => { if (mainPane.type !== 'new' || pickersLoading.current) return @@ -268,6 +277,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { .listGithubRepositories() .then((r) => setRepos(r.repositories.map((repo) => repo.full_name))) .catch(() => setRepos([])) + void api + .listSupportedModels() + .then(setModels) + .catch(() => setModels([])) }, [mainPane.type, api]) const startSession = useCallback( @@ -400,22 +413,18 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { let main: React.ReactElement if (mainPane.type === 'new') { main = ( - + // Full width, no side padding: the prompt input's rules span the + // terminal exactly like the header's and the nav's do. + void startSession(text, choices)} onLeave={focusNav} rawMode={isRawModeSupported} @@ -483,27 +492,40 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ---- band 4: the session nav ---- // A horizontal bar of fixed-width cells (status dot + description over a - // dim age line), windowed around the highlight. The pinned "+ New" cell - // leads. - const cellCapacity = Math.max(1, Math.floor((termCols - 10) / NAV_ITEM_W)) + // dim age line) separated by vertical rules, windowed around the + // highlight. The pinned new-session cell leads, at its full label width — + // so a terminal too narrow for even one session cell shows zero of them + // (the "› N more" tail still says what's there) rather than wrapping the + // bar onto an extra row and pushing the layout past the frame. + const cellCapacity = Math.max( + 0, + Math.floor((termCols - NAV_NEW_W - 10) / (NAV_ITEM_W + 1)), + ) const selectedRowIdx = Math.max(0, rows.findIndex((s) => s.id === selected)) - const win = sidebarSlice(rows.length, cellCapacity, selectedRowIdx) + // sidebarSlice always keeps one entry in frame, so the no-room case is + // handled here rather than by widening its contract. + const win = + cellCapacity === 0 + ? { start: 0, end: 0 } + : sidebarSlice(rows.length, cellCapacity, selectedRowIdx) const navFocused = focus === 'nav' const nav = ( {'─'.repeat(Math.max(0, termCols))} - {/* The pinned new-session cell. */} - - + {/* The pinned new-session cell, always at its full label width + (flexShrink=0 + an explicit width, so a crowded bar drops + session cells instead of clipping this one). */} + + {' '} {selected === 'new' && navFocused ? ( - {` ${SELECTION_GLYPH} New `} + {` ${SELECTION_GLYPH} ${NAV_NEW_LABEL.slice(2)} `} ) : ( - + New + {NAV_NEW_LABEL} )} @@ -522,31 +544,39 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const desc = rowDescription(s) const descW = NAV_ITEM_W - 4 return ( - - - {' '} - - {cursorHere ? SELECTION_GLYPH : g.glyph} - {' '} - - {cursorHere - ? ` ${desc.slice(0, Math.max(4, descW - 2))} ` - : desc.slice(0, Math.max(4, descW))} + + {/* A vertical rule between cells, spanning both cell rows — + the bar reads as discrete sessions, not one run of text. */} + + + + + + + {' '} + + {cursorHere ? SELECTION_GLYPH : g.glyph} + {' '} + + {cursorHere + ? ` ${desc.slice(0, Math.max(4, descW - 2))} ` + : desc.slice(0, Math.max(4, descW))} + - - - {' '} - - {shortAge(lastEventAt(s))} · {s.source === 'laptop' ? 'laptop' : 'cloud'} - {attention.has(s.id) ? ' · needs you' : ''} + + {' '} + + {shortAge(lastEventAt(s))} · {s.source === 'laptop' ? 'laptop' : 'cloud'} + {attention.has(s.id) ? ' · needs you' : ''} + - - + + ) })} {win.end < rows.length && ( @@ -647,9 +677,9 @@ const PICKER_ROWS: readonly PickerRow[] = [ ] // The new-session pane, mirroring the dashboard's home composer -// (app.ellipsis.dev/[login]): a centered "What would you like to do?" -// heading a third of the way down, then ONE box — a single-line prompt -// input with the Agent / Model / Repository selects as compact chips along +// (app.ellipsis.dev/[login]): a centered "What are we shipping today?" +// heading a third of the way down, then ONE box — a prompt input that wraps +// as you type, with the Agent / Model / Repository selects as compact chips along // the box's bottom edge. ↓ from the prompt reaches the chip row, ←/→ move // between chips, enter/↓ opens a chip's option list ([x] marks the pick). // Inside an open list ↑/↓ walk, → (or enter/space) activates the @@ -665,6 +695,7 @@ function NewSessionPane({ error, configs, repos, + models, onSubmit, onLeave, rawMode, @@ -677,6 +708,7 @@ function NewSessionPane({ // null while loading; [] when the account has none / the fetch failed. configs: SavedAgentConfig[] | null repos: string[] | null + models: SupportedModel[] | null onSubmit: ( text: string, choices: { configId: string | null; model: string | null; repos: string[] }, @@ -708,7 +740,9 @@ function NewSessionPane({ ], [configs], ) - const modelOptions = COMPOSER_MODELS + // The server's selectable set (GET /v1/models); before it lands — and on an + // older server that has no such route — the built-in fallback list. + const modelOptions = useMemo(() => composerModelOptions(models ?? []), [models]) const repoOptions = useMemo( () => [ { id: null as string | null, label: 'Default (detected)' }, @@ -794,6 +828,15 @@ function NewSessionPane({ return } if (starting) return + // Word/line jumps and kills (option+←/→, ctrl+a/e/w/u/k, …) act on the + // prompt from anywhere in the form, dropping focus back onto it. + const edited = applyEditShortcut({ text, cursor }, ch, key) + if (edited) { + setRow('prompt') + setText(edited.text) + setCursor(edited.cursor) + return + } if (row !== 'prompt') { // The option rows under the prompt box, walked vertically: ↑/↓ move // between them (↑ off the first returns to the prompt, ↓ off the @@ -876,6 +919,10 @@ function NewSessionPane({ // How many dropdown options fit in the space above the input: the pane // minus the heading, notices, input box, rows, and hints (~13 rows). const dropdownCapacity = Math.max(3, height - 13) + // The wrap width inside the input box: the pane minus the box's own left + // and right padding (the "▶ " prompt is part of the wrapped text). + const inputWidth = Math.max(8, width - 2) + const caretVisible = focused && row === 'prompt' && !starting && openPicker === null const open = openPicker const openOptions = open ? optionsFor(open.key) : [] const openHover = open ? Math.min(open.hover, openOptions.length - 1) : 0 @@ -889,19 +936,21 @@ function NewSessionPane({ // + option rows pin to the bottom edge (just above the session nav), // where they NEVER move — an open dropdown expands upward instead of // pushing the input around. - + // No pane padding: the input's rules span the full terminal width like + // the header's and the nav's (the rows inside carry their own indents). + - What would you like to do? + What are we shipping today? - {error && ✗ {error}} - {starting && ✻ Starting session…} + {error && ✗ {error}} + {starting && ✻ Starting session…} {/* The open row's option list, a popup ABOVE the input (the input is docked; menus open upward like any bottom bar's): ↑/↓ walk, → activates, ← backs out. [x] marks the pick. */} {open && ( - + {PICKER_ROWS.find((r) => r.key === open.key)?.label}: {win.start > 0 && … {win.start} more} {openOptions.slice(win.start, win.end).map((opt, j) => { @@ -943,24 +992,27 @@ function NewSessionPane({ minHeight={5} alignItems="flex-start" paddingLeft={1} + paddingRight={1} > - - - {SELECTION_GLYPH}{' '} + {/* Wraps instead of truncating: a long prompt flows onto the next + row (the box grows downward into the spacer above) rather than + running off the right edge. The explicit width is what ink wraps + against; the key remounts the node so a stale measurement can't + wrap the caret onto the border row. */} + + + + {SELECTION_GLYPH}{' '} + + {text.slice(0, cursor)} + {caretVisible && ( + {cursor < text.length ? text[cursor] : ' '} + )} + {cursor < text.length ? text.slice(cursor + (caretVisible ? 1 : 0)) : ''} + {text === '' && !caretVisible && ' '} + {text === '' && Describe the task…} - {text.slice(0, cursor)} - {focused && row === 'prompt' && !starting && openPicker === null && ( - {cursor < text.length ? text[cursor] : ' '} - )} - {cursor < text.length - ? text.slice( - cursor + - (focused && row === 'prompt' && !starting && openPicker === null ? 1 : 0), - ) - : ''} - {text === '' && (focused && row === 'prompt' ? '' : ' ')} - {text === '' && Describe the task…} - + {/* The run controls, one row each below the input: Repository, Agent, Model. →/enter opens a row's option list ABOVE the input (the @@ -971,7 +1023,7 @@ function NewSessionPane({ const isOpen = openPicker?.key === r.key return ( - {' '} + {' '} {active || isOpen ? SELECTION_GLYPH : ' '} {' '} @@ -990,7 +1042,7 @@ function NewSessionPane({ {/* Always rendered (blank while a dropdown is open) so the docked input never shifts by the hint row's height. */} - {open ? ' ' : ' enter: start · ↓ options · esc: sessions'} + {open ? ' ' : ' enter: start · ↓ options · esc: sessions'} ) diff --git a/test/api.test.ts b/test/api.test.ts index 9e5b28b..5035351 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -283,6 +283,29 @@ describe('agent templates', () => { }) }) +describe('supported models', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('unwraps the models array from the list response', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + models: [ + { id: 'claude-opus-5', display_name: 'Claude Opus 5', is_default_agent_model: true }, + ], + }), + { status: 200 }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').listSupportedModels() + expect(out.map((m) => m.id)).toEqual(['claude-opus-5']) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/models') + }) +}) + describe('createAgentConfig', () => { afterEach(() => vi.unstubAllGlobals()) diff --git a/test/editing.test.ts b/test/editing.test.ts new file mode 100644 index 0000000..7998885 --- /dev/null +++ b/test/editing.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + applyEditShortcut, + lineEnd, + lineStart, + wordLeft, + wordRight, + type EditKey, +} from '../src/lib/editing' + +function key(overrides: Partial = {}): EditKey { + return { + ctrl: false, + meta: false, + leftArrow: false, + rightArrow: false, + backspace: false, + delete: false, + ...overrides, + } +} + +describe('wordLeft / wordRight', () => { + it('hops whole words, skipping the whitespace between them', () => { + expect(wordLeft('fix the webhook tests', 21)).toBe(16) + expect(wordLeft('fix the webhook tests', 16)).toBe(8) + expect(wordRight('fix the webhook tests', 0)).toBe(3) + expect(wordRight('fix the webhook tests', 3)).toBe(7) + }) + + it('stops at the text edges', () => { + expect(wordLeft('abc', 0)).toBe(0) + expect(wordRight('abc', 3)).toBe(3) + expect(wordLeft(' ', 3)).toBe(0) + }) + + it('crosses newlines like an editor does', () => { + expect(wordLeft('one\ntwo', 7)).toBe(4) + expect(wordLeft('one\ntwo', 4)).toBe(0) + expect(wordRight('one\ntwo', 3)).toBe(7) + }) +}) + +describe('lineStart / lineEnd', () => { + it('finds the bounds of the caret line', () => { + expect(lineStart('ab\ncd', 4)).toBe(3) + expect(lineStart('ab\ncd', 1)).toBe(0) + expect(lineEnd('ab\ncd', 0)).toBe(2) + expect(lineEnd('ab\ncd', 4)).toBe(5) + }) +}) + +describe('applyEditShortcut', () => { + const state = { text: 'fix the webhook tests', cursor: 21 } + + it('ignores unmodified keys', () => { + expect(applyEditShortcut(state, 'a', key())).toBeNull() + expect(applyEditShortcut(state, '', key({ leftArrow: true }))).toBeNull() + }) + + it('jumps a word with option/ctrl + arrows', () => { + expect(applyEditShortcut(state, '', key({ meta: true, leftArrow: true }))?.cursor).toBe(16) + expect(applyEditShortcut(state, '', key({ ctrl: true, leftArrow: true }))?.cursor).toBe(16) + expect( + applyEditShortcut({ text: state.text, cursor: 0 }, '', key({ meta: true, rightArrow: true })) + ?.cursor, + ).toBe(3) + }) + + it('jumps a word with meta+b / meta+f (what iTerm sends for option+arrows)', () => { + expect(applyEditShortcut(state, 'b', key({ meta: true }))?.cursor).toBe(16) + expect(applyEditShortcut({ ...state, cursor: 0 }, 'f', key({ meta: true }))?.cursor).toBe(3) + }) + + it('reaches the line edges with ctrl+a / ctrl+e', () => { + expect(applyEditShortcut(state, 'a', key({ ctrl: true }))?.cursor).toBe(0) + expect( + applyEditShortcut({ text: 'ab\ncd', cursor: 4 }, 'a', key({ ctrl: true }))?.cursor, + ).toBe(3) + expect(applyEditShortcut({ text: 'ab\ncd', cursor: 0 }, 'e', key({ ctrl: true }))?.cursor).toBe( + 2, + ) + }) + + it('kills the word behind the caret with option+backspace / ctrl+w', () => { + expect(applyEditShortcut(state, '', key({ meta: true, backspace: true }))).toEqual({ + text: 'fix the webhook ', + cursor: 16, + }) + expect(applyEditShortcut(state, 'w', key({ ctrl: true }))).toEqual({ + text: 'fix the webhook ', + cursor: 16, + }) + }) + + it('kills to the line start with ctrl+u and to the line end with ctrl+k', () => { + expect(applyEditShortcut({ text: 'ab cd', cursor: 3 }, 'u', key({ ctrl: true }))).toEqual({ + text: 'cd', + cursor: 0, + }) + expect(applyEditShortcut({ text: 'ab cd', cursor: 3 }, 'k', key({ ctrl: true }))).toEqual({ + text: 'ab ', + cursor: 3, + }) + }) + + it('leaves other modified keys to the caller (ctrl+r, ctrl+s, ctrl+c)', () => { + for (const ch of ['r', 's', 'c']) { + expect(applyEditShortcut(state, ch, key({ ctrl: true }))).toBeNull() + } + }) +}) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 23977fe..c6f293c 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { attentionFlip, + COMPOSER_MODELS, + composerModelOptions, connectability, isActiveStatusWord, isOpenConversation, @@ -238,3 +240,25 @@ describe('sidebarSlice', () => { expect(sidebarSlice(20, 6, 19)).toEqual({ start: 14, end: 20 }) }) }) + +describe('composerModelOptions', () => { + it('keeps the server order and names the default model', () => { + const options = composerModelOptions([ + { id: 'claude-fable-5', display_name: 'Claude Fable 5', is_default_agent_model: false }, + { id: 'claude-opus-5', display_name: 'Claude Opus 5', is_default_agent_model: true }, + ]) + expect(options.map((o) => o.id)).toEqual([null, 'claude-fable-5', 'claude-opus-5']) + expect(options[0].label).toBe('Default (Claude Opus 5)') + }) + + it('falls back to the built-in list when the server returns none', () => { + expect(composerModelOptions([])).toEqual([...COMPOSER_MODELS]) + }) + + it('leaves Default unnamed when no model claims the flag', () => { + const options = composerModelOptions([ + { id: 'claude-opus-5', display_name: 'Claude Opus 5', is_default_agent_model: false }, + ]) + expect(options[0].label).toBe('Default') + }) +})