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
11 changes: 11 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
GetSandboxVariablesResponse,
GetSessionIdeResponse,
GetSessionPortResponse,
GetSupportedModelsResponse,
ListAgentConfigsResponse,
ListAgentDefaultsResponse,
ListAgentSessionsQuery,
Expand Down Expand Up @@ -51,6 +52,7 @@ import type {
SyncAgentSessionResponse,
SavedAgentConfig,
StartAgentSessionRequest,
SupportedModel,
UsageDashboard,
WhoAmI,
GetSessionLogResponse,
Expand Down Expand Up @@ -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<SupportedModel[]> {
const res = await this.request<GetSupportedModelsResponse>('GET', '/v1/models')
return res.models
}

// ---------------------------- agent templates ---------------------------

async listAgentTemplates(): Promise<AgentTemplate[]> {
Expand Down
80 changes: 80 additions & 0 deletions src/lib/editing.ts
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 20 additions & 8 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<ComposerModel> = [
{ 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: {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
8 changes: 8 additions & 0 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand Down
Loading