Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import type { ComposerToken } from '../app/interfaces.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
/**
* Composer tokens are the ONE way deferred content shows up in the input line:
* a collapsed paste and an attached image both render as `[[ … ]]` sitting in
* the text the user is editing. They are ordinary characters — arrow keys,
* backspace, and selection work on them for free — and they carry their real
* payload out-of-band until submit.
*
* Two consequences the rest of the composer relies on:
* - Deleting the token is how you drop the thing. Nothing else to click.
* - Position in the text is meaningful: the model sees the payload where the
* token sat, not stapled to the front of the turn.
*/
export const imageToken = (index: number) => `[[ Image ${index} ]]`
/** Highest image token index handed out so far, so a new one never collides. */
export const nextImageIndex = (tokens: ComposerToken[]) =>
tokens.reduce((max, t) => (t.kind === 'image' ? Math.max(max, t.index) : max), 0) + 1
/** Tokens whose label is no longer anywhere in the composer text. */
export const droppedTokens = (tokens: ComposerToken[], value: string) => {
const live = new Set(value.match(PASTE_SNIPPET_RE) ?? [])
return tokens.filter(t => !live.has(t.label))
}
/**
* Resolve every token in `value` to what the agent should actually receive.
*
* Repeated identical labels expand in submission order (left to right), which
* is why this walks matches instead of doing a global replace per token.
*
* An image token expands to nothing: the gateway already holds the file in
* `session.attached_images` and splices the real vision content in at submit.
* The token's job was to show the user where it landed, so it also eats one
* adjacent space to avoid leaving a gap in the middle of a sentence.
*/
export const expandTokens = (tokens: ComposerToken[]) => {
const byLabel = new Map<string, ComposerToken[]>()
for (const token of tokens) {
const hit = byLabel.get(token.label)
hit ? hit.push(token) : byLabel.set(token.label, [token])
}
return (value: string) =>
value
.replace(new RegExp(`[ \\t]?(?:${PASTE_SNIPPET_RE.source})`, 'g'), match => {
const token = byLabel.get(match.trimStart())?.shift()
if (!token) {
return match
}
return token.kind === 'paste' ? match.slice(0, match.length - token.label.length) + token.text : ''
})
.trim()
}
+150
View File
@@ -0,0 +1,150 @@
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
import { sectionMode } from './details.js'
/**
* Visual group a transcript block belongs to. Blocks in the same group render
* flush; a single blank line opens at each group boundary. So a run of tool
* trails (or model paragraphs) reads as one section and the eye only catches a
* gap where the *kind* of content actually changes — the boundary between
* reasoning/tool trails, model prose, and notes/errors.
*
* user — the human turn (owns its separator + margins in MessageLine)
* model — assistant prose, the model's voice
* trail — reasoning + tool-call trails (the agent's working area)
* note — system notes and errors (a quieter band)
* diff — inline patch segments (an island, owns its own margins)
* slash — slash-command echoes (owns its margin)
* intro — banner / panels (rendered out-of-band, never gapped here)
*/
export type BlockGroup = 'diff' | 'event' | 'intro' | 'model' | 'note' | 'slash' | 'trail' | 'user'
export const messageGroup = (msg: Pick<Msg, 'kind' | 'role'>): BlockGroup => {
switch (msg.kind) {
case 'intro':
case 'panel':
return 'intro'
case 'slash':
return 'slash'
case 'event':
return 'event'
case 'diff':
return 'diff'
case 'trail':
return 'trail'
}
if (msg.role === 'user') {
return 'user'
}
// Assistant prose is the model's voice; system notes/errors are their own
// band. (No runtime block uses role 'tool' — tool *results* fold into
// trails — so a stray 'tool' falls through to the note band harmlessly.)
return msg.role === 'assistant' ? 'model' : 'note'
}
// Groups whose leading gap is already owned by their own chrome in
// MessageLine (the turn separator + top margin for user, the top margin for
// slash, the top+bottom margins for diff) or that are painted out-of-band
// (intro). The grouping primitive only spaces the model working area —
// model prose, reasoning/tool trails, and notes/errors.
const SELF_SPACED: ReadonlySet<BlockGroup> = new Set(['diff', 'event', 'intro', 'slash', 'user'])
// Groups that already paint a trailing blank line beneath themselves
// (marginBottom in MessageLine), so the block that follows must not add its
// own leading gap or the single boundary would become a double gap.
const PAINTS_TRAILING_GAP: ReadonlySet<BlockGroup> = new Set(['diff', 'event', 'user'])
/**
* Whether `cur` renders one blank line above it, given the block rendered
* directly above it (`prev`). True only where the visual group changes, and
* only for the model-working-area bands (model / trail / note) — user, slash,
* diff, and intro keep their existing spacing.
*
* Streaming-safe by construction: the result depends on the *predecessor's*
* group, never on `cur`'s own (live, changing) content. The actively-streaming
* assistant block therefore computes the same gap while it streams as the
* settled segment does once it flushes, so the live area never jumps.
*/
export const hasLeadGap = (prev: Pick<Msg, 'kind' | 'role'> | undefined, cur: Pick<Msg, 'kind' | 'role'>): boolean => {
const group = messageGroup(cur)
if (SELF_SPACED.has(group)) {
return false
}
if (!prev) {
return false
}
const prevGroup = messageGroup(prev)
return prevGroup !== group && !PAINTS_TRAILING_GAP.has(prevGroup)
}
export interface DetailsCtx {
commandOverride?: boolean
detailsMode: DetailsMode
sections?: SectionVisibility
}
const trailAllHidden = (ctx: DetailsCtx): boolean =>
sectionMode('thinking', ctx.detailsMode, ctx.sections, ctx.commandOverride) === 'hidden' &&
sectionMode('tools', ctx.detailsMode, ctx.sections, ctx.commandOverride) === 'hidden' &&
sectionMode('activity', ctx.detailsMode, ctx.sections, ctx.commandOverride) === 'hidden'
/**
* Whether a settled transcript block paints anything. A trail renders nothing
* when it has no reasoning/tools/todos to show (e.g. the finalDetails segment
* that carries only a token tally) or when every section it does have is hidden
* (`/details hidden`); every other block draws at least one row. A block that
* renders nothing is *transparent* to grouping: the block below it draws its
* boundary against the nearest visible block instead (see prevRenderedMsg), so
* a hidden or content-less trail never leaves a floating blank line, doubles
* the gap after a user prompt, or pads the space above the final reply. In the
* default/collapsed modes content-bearing trails always render, so this is a
* no-op there.
*/
export const blockRenders = (msg: Pick<Msg, 'kind' | 'thinking' | 'todos' | 'tools'>, ctx: DetailsCtx): boolean => {
if (msg.kind !== 'trail') {
return true
}
if (msg.todos?.length) {
return true
}
if (!(msg.tools?.length || msg.thinking?.trim())) {
return false
}
return !trailAllHidden(ctx)
}
/**
* The nearest block above `index` that actually renders, resolved through a
* lazy accessor so it works over either the virtualized history rows or the
* live block list. This is the grouping predecessor — using it (instead of the
* literal previous row) keeps hidden trails from interrupting the rhythm.
*/
export const prevRenderedMsg = (
msgAt: (i: number) => Msg | undefined,
index: number,
ctx: DetailsCtx
): Msg | undefined => {
for (let i = index - 1; i >= 0; i--) {
const candidate = msgAt(i)
if (candidate && blockRenders(candidate, ctx)) {
return candidate
}
}
return undefined
}
+95
View File
@@ -0,0 +1,95 @@
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
/**
* Reference spans in composer text: a `/skill` invoked or named in prose, an
* `@file:` / `@url:` / `@session:` ref, and a `[[ Image 1 ]]` / paste token.
* The same vocabulary the desktop chips, so the two surfaces agree on what a
* reference is.
*
* Concatenating every `text` reproduces the input exactly — styling only, the
* text is never rewritten. Regexes are built per call: a shared `/g` instance
* carries `lastIndex` between callers and silently skips the first match in
* the next string it is handed.
*/
export type ComposerHighlight = { ref: boolean; text: string }
// Leading OR mid-prose. `(?![\w-]*\/)` keeps `/usr/local` from lighting up as
// `/usr`, and requiring a letter keeps `a 3 /4 b` plain. A BARE `/` counts only
// at the very end — that is the user opening the command menu, not prose.
const slashRe = () => /(?<=^|\s)(?:\/[a-zA-Z][\w-]*(?![\w-]*\/)|\/$)/g
// Every `@ref` shape the composer accepts: a typed kind (`@file:src/a.ts`), a
// quoted value with spaces, a bare `@diff` / `@staged`, and the half-typed
// `@fi` the user is still working on. Quoted alternatives come before bare
// `\S+` or a quoted value would end at its first space.
const atRe = () => /(?<=^|\s)@(?:[\w-]+:(?:`[^`\n]*`?|"[^"\n]*"?|'[^'\n]*'?|\S*)|\S*)/g
const tokenRe = () => new RegExp(PASTE_SNIPPET_RE.source, 'g')
type Span = { end: number; start: number }
const matchSpans = (text: string, re: RegExp): Span[] =>
[...text.matchAll(re)].filter(m => m[0]).map(m => ({ end: (m.index ?? 0) + m[0].length, start: m.index ?? 0 }))
export const splitComposerHighlights = (text: string): ComposerHighlight[] => {
// Tokens, then @refs, then slashes: on an overlap the earlier kind wins, so
// a slash inside a quoted ref value stays part of that ref.
const spans = [...matchSpans(text, tokenRe()), ...matchSpans(text, atRe()), ...matchSpans(text, slashRe())]
.sort((a, b) => a.start - b.start)
.reduce<Span[]>((kept, span) => {
if (!kept.some(prev => span.start < prev.end && span.end > prev.start)) {
kept.push(span)
}
return kept
}, [])
const out: ComposerHighlight[] = []
let last = 0
for (const span of spans) {
if (span.start > last) {
out.push({ ref: false, text: text.slice(last, span.start) })
}
out.push({ ref: true, text: text.slice(span.start, span.end) })
last = span.end
}
if (last < text.length || !out.length) {
out.push({ ref: false, text: text.slice(last) })
}
return out
}
/** Per-character "is this cell accented", indexed to match the input string. */
export const highlightMask = (text: string): boolean[] => {
const mask = new Array<boolean>(text.length).fill(false)
let offset = 0
for (const segment of splitComposerHighlights(text)) {
if (segment.ref) {
mask.fill(true, offset, offset + segment.text.length)
}
offset += segment.text.length
}
return mask
}
/**
* Whether every character that stays on screen keeps the color it had.
*
* The fast-echo bypass writes ONLY the new cells, so it may run only when a
* keystroke leaves the existing ones alone. Typing `]` to close a
* `[[ token ]]`, or a second `/` demoting `/usr` to a path, recolors text
* already painted — those have to go through a full Ink repaint instead.
*/
export const highlightsStable = (prev: string, next: string): boolean => {
const before = highlightMask(prev)
const after = highlightMask(next)
return before.slice(0, Math.min(before.length, after.length)).every((on, i) => on === after[i])
}
+76
View File
@@ -0,0 +1,76 @@
import type { DetailsMode, SectionName, SectionVisibility } from '../types.js'
const MODES = ['hidden', 'collapsed', 'expanded'] as const
export const SECTION_NAMES = ['thinking', 'tools', 'subagents', 'activity'] as const
// Out-of-the-box per-section defaults — applied when the user hasn't pinned
// an explicit override and layered ABOVE the global details_mode:
//
// - thinking / tools: expanded — stream open so the turn reads like a
// live transcript (reasoning + tool calls side by side) instead of a
// wall of chevrons the user has to click every turn.
// - activity: hidden — ambient meta (gateway hints, terminal-parity
// nudges, background notifications) is noise for typical use. Tool
// failures still render inline on the failing tool row, and ambient
// errors/warnings surface via the floating-alert backstop when every
// panel resolves to hidden.
// - subagents: not set — falls through to the global details_mode so
// Spawn trees stay under a chevron until a delegation actually happens.
//
// Opt out of any of these with `display.sections.<name>` in config.yaml
// or at runtime via `/details <name> collapsed|hidden`.
const SECTION_DEFAULTS: SectionVisibility = {
thinking: 'expanded',
tools: 'expanded',
activity: 'hidden'
}
const THINKING_FALLBACK: Record<string, DetailsMode> = {
collapsed: 'collapsed',
full: 'expanded',
truncated: 'collapsed'
}
const norm = (v: unknown) =>
String(v ?? '')
.trim()
.toLowerCase()
export const parseDetailsMode = (v: unknown): DetailsMode | null => MODES.find(m => m === norm(v)) ?? null
export const isSectionName = (v: unknown): v is SectionName =>
typeof v === 'string' && (SECTION_NAMES as readonly string[]).includes(v)
export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode =>
parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[norm(d?.thinking_mode)] ?? 'collapsed'
// Build SectionVisibility from a free-form blob. Unknown section names and
// invalid modes are dropped silently — partial overrides are intentional, so
// missing keys fall through to SECTION_DEFAULTS / global at lookup time.
export const resolveSections = (raw: unknown): SectionVisibility =>
raw && typeof raw === 'object' && !Array.isArray(raw)
? (Object.fromEntries(
Object.entries(raw as Record<string, unknown>)
.map(([k, v]) => [k, parseDetailsMode(v)] as const)
.filter(([k, m]) => !!m && isSectionName(k))
) as SectionVisibility)
: {}
// Effective mode for one section: explicit override → global command mode →
// built-in live-stream defaults → global config mode.
//
// The `commandOverride` flag is set for in-session `/details <mode>` changes.
// That command should immediately apply to every section, including sections
// with built-in defaults like thinking/tools=expanded and activity=hidden. On
// startup/config sync we keep those defaults layered above the persisted global
// config so the TUI still opens live reasoning/tools by default unless the user
// pins explicit per-section overrides.
export const sectionMode = (
name: SectionName,
global: DetailsMode,
sections?: SectionVisibility,
commandOverride = false
): DetailsMode => sections?.[name] ?? (commandOverride ? global : (SECTION_DEFAULTS[name] ?? global))
export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]!
+118
View File
@@ -0,0 +1,118 @@
import { LONG_MSG } from '../config/limits.js'
import { buildToolTrailLine } from '../lib/text.js'
import type { Msg, SessionInfo } from '../types.js'
export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
export const userDisplay = (text: string) => {
if (text.length <= LONG_MSG) {
return text
}
const first = text.split('\n')[0]?.trim() ?? ''
const words = first.split(/\s+/).filter(Boolean)
const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80)
return `${prefix || '(message)'} [long message]`
}
export const toTranscriptMessages = (rows: unknown): Msg[] => {
if (!Array.isArray(rows)) {
return []
}
const out: Msg[] = []
let pending: string[] = []
for (const row of rows) {
if (!row || typeof row !== 'object') {
continue
}
const { context, display_kind, name, role, text, timestamp } = row as TranscriptRow
const createdAt =
typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined
if (role === 'tool') {
pending.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
continue
}
if (typeof text !== 'string' || !text.trim()) {
continue
}
// Display-only timeline events: render as dim ◈ markers instead of
// opaque user messages. Hidden compaction handoffs are skipped entirely.
if (display_kind === 'hidden') {
continue
}
if (display_kind === 'model_switch') {
out.push({ kind: 'event', role: 'system', text: 'model changed' })
pending = []
continue
}
if (display_kind === 'auto_continue') {
out.push({ kind: 'event', role: 'system', text: 'resumed interrupted turn' })
pending = []
continue
}
if (display_kind === 'personality_switch') {
out.push({ kind: 'event', role: 'system', text: 'personality changed' })
pending = []
continue
}
if (display_kind === 'async_delegation_complete') {
const meta = (row as TranscriptRow).display_metadata
const count = meta && typeof meta.task_count === 'number' ? meta.task_count : undefined
const label =
count === undefined
? 'background agent work finished'
: `${count} background agent${count === 1 ? '' : 's'} finished`
out.push({ kind: 'event', role: 'system', text: label })
pending = []
continue
}
if (role === 'assistant') {
out.push({ role, text, ...(createdAt !== undefined && { createdAt }), ...(pending.length && { tools: pending }) })
pending = []
} else if (role === 'user' || role === 'system') {
out.push({ role, text, ...(createdAt !== undefined && { createdAt }) })
pending = []
}
}
return out
}
export const fmtDuration = (ms: number) => {
const t = Math.max(0, Math.floor(ms / 1000))
const h = Math.floor(t / 3600)
const m = Math.floor((t % 3600) / 60)
const s = t % 60
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
}
interface TranscriptRow {
context?: string
display_kind?: string
display_metadata?: { task_count?: number; [key: string]: unknown }
name?: string
role?: string
text?: string
timestamp?: number
}
+68
View File
@@ -0,0 +1,68 @@
export const shortCwd = (cwd: string, max = 28) => {
const h = process.env.HOME
const p = h && cwd.startsWith(h) ? `~${cwd.slice(h.length)}` : cwd
return p.length <= max ? p : `…${p.slice(-(max - 1))}`
}
export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => {
if (!branch) {
return shortCwd(cwd, max)
}
const tag = ` (${branch.length > 16 ? `…${branch.slice(-15)}` : branch})`
return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}`
}
export const shortProject = (projectName: string, max = 18) => {
const name = projectName.trim()
return name.length <= max ? name : `${name.slice(0, Math.max(1, max - 1))}…`
}
// Status-bar workspace label: the terminal has no hover tooltip, so the project
// name is shown INLINE alongside the cwd/branch (`<project> · ~/cwd (branch)`).
// Falls back to the plain cwd/branch label when the session sits in no named
// project, and when space is tight the project name wins (it's the identity the
// user recognizes) with the cwd/branch dropped.
export const fmtProjectCwdBranch = (cwd: string, branch: null | string, projectName?: null | string, max = 40) => {
const project = shortProject(projectName || '')
if (!project) {
return fmtCwdBranch(cwd, branch, max)
}
const separator = ' · '
const remaining = max - project.length - separator.length
if (remaining < 8) {
return shortProject(project, max)
}
return `${project}${separator}${fmtCwdBranch(cwd, branch, remaining)}`
}
/**
* Compose the terminal titlebar string:
* `<marker> <session name> · <model> · <cwd>`
*
* The session name and cwd are each omitted when empty, and a long session
* name is truncated. The marker is always glued to the first present segment
* with a plain space (not a ` · ` separator). When no model is known yet the
* caller should fall back to a plain brand string instead of calling this.
*/
export const composeTabTitle = (
marker: string,
sessionName: string,
model: string,
cwd: string,
maxName = 28
): string => {
const name = sessionName.trim()
const shortName = name.length > maxName ? `${name.slice(0, maxName - 1)}…` : name
const segments = [shortName, model, cwd].filter(Boolean)
return segments.length ? `${marker} ${segments.join(' · ')}` : marker
}
+11
View File
@@ -0,0 +1,11 @@
export const providerDisplayNames = (providers: readonly { name: string; slug: string }[]): string[] => {
const counts = new Map<string, number>()
for (const p of providers) {
counts.set(p.name, (counts.get(p.name) ?? 0) + 1)
}
return providers.map(p =>
(counts.get(p.name) ?? 0) > 1 && p.slug && p.slug !== p.name ? `${p.name} (${p.slug})` : p.name
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { Theme } from '../theme.js'
import type { Role } from '../types.js'
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = {
assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.border }),
system: t => ({ body: '', glyph: '·', prefix: t.color.muted }),
tool: t => ({ body: t.color.muted, glyph: '⚡', prefix: t.color.muted }),
user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
}
+105
View File
@@ -0,0 +1,105 @@
/** Appended by TUI pickers; converted to the backend's `--session` flag before `config.set`. */
export const TUI_SESSION_MODEL_FLAG = '--tui-session'
export const sessionScopedModelArg = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean)
const kept = parts.filter(part => part !== TUI_SESSION_MODEL_FLAG && part !== '--global' && part !== '--session')
return kept.length ? `${kept.join(' ')} --session` : ''
}
export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text)
// A `/` means two different things depending on where it sits:
//
// - At position 0 it's a COMMAND invocation the TUI executes
// (`looksLikeSlashCommand`, and the backend's dispatch, are both
// `^`-anchored). Completion stays live past the command name so arg
// completion works (`/cron ad` → `add`).
// - After whitespace it's an inline SKILL reference the user is dropping into
// prose ("clean this up with /clean"). The text submits as an ordinary
// message, so there are no args to complete — the token ends at the caret.
//
// The inline shape is what makes skills reachable anywhere in a prompt. The
// trailing `$` matters for both: completion runs against the text the user has
// typed so far, so the match has to end where they're typing.
//
// Requiring a single bare segment (no second `/`) keeps real paths out:
// `look at /usr/local/bin` and `check src/foo/bar` never match. A bare `/us` is
// genuinely ambiguous with an absolute path, and resolves as a skill reference
// — typing the next `/` flips it straight back to path completion.
const INLINE_SLASH_RE = /\s\/([a-zA-Z][\w-]*)?$/
/**
* Locate an inline `/skill` reference at the end of `text`, or null when the
* text isn't one. `start` is the index of the `/` itself, so a completion
* replaces the typed token and leaves the prose in front of it untouched.
*/
export const inlineSlashTrigger = (text: string): { query: string; start: number } | null => {
const match = INLINE_SLASH_RE.exec(text)
if (!match) {
return null
}
const query = match[1] ?? ''
return { query, start: text.length - query.length - 1 }
}
// Only the separator between the command name and its argument is whitespace
// the parser owns. Everything after it is the user's text and survives
// verbatim: splitting the whole line on `\s+` and rejoining with a space
// flattened every pasted diff, log, or PR thread into one run-on line before
// the command ever saw it.
const SLASH_PARTS_RE = /^(\S*)\s*([\s\S]*)$/
export const parseSlashCommand = (cmd: string) => {
const [, name = '', arg = ''] = SLASH_PARTS_RE.exec(cmd.slice(1)) ?? []
return { arg, cmd, name: name.toLowerCase() }
}
/**
* Apply a completion row to the current input, mirroring the editor's
* replace semantics: replace from `compReplace` with the row text, dropping
* the row's leading slash when the input already has one immediately before
* the replace point (the gateway's slash completer returns bare command names
* whose replace span begins after the leading `/`).
*
* Keyed off the character before `compReplace` rather than the start of the
* input so a `/token` anywhere in the message behaves the same as one at
* position 0 — an inline `run /cle` replaces from after its own slash.
*/
export const applyCompletion = (value: string, rowText: string, compReplace: number): string => {
const text = value[compReplace - 1] === '/' && rowText.startsWith('/') ? rowText.slice(1) : rowText
return value.slice(0, compReplace) + text
}
/**
* Decide what Enter does when a completion is highlighted: returns the value
* to set (accept the completion) or `null` to fall through to submit.
*
* Enter accepts a completion only when it changes the command/argument token.
* A completion that merely appends trailing whitespace to an already-complete
* command (e.g. `/exit` → `/exit `, the trailing space the gateway adds so the
* classic CLI's prompt_toolkit dropdown stays open) must NOT swallow the Enter
* — otherwise every slash command needs an extra keypress: type → Enter
* completes the name → Enter adds the space → Enter finally submits. Treating a
* whitespace-only delta as "already complete" collapses that back to the
* expected one/two presses.
*/
export const completionToApplyOnSubmit = (
value: string,
rowText: string | undefined,
compReplace: number
): string | null => {
if (!rowText) {
return null
}
const next = applyCompletion(value, rowText, compReplace)
return next !== value && next.trimEnd() !== value.trimEnd() ? next : null
}
+3
View File
@@ -0,0 +1,3 @@
import type { Usage } from '../types.js'
export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 }
+51
View File
@@ -0,0 +1,51 @@
import type { Msg } from '../types.js'
import { userDisplay } from './messages.js'
const upperBound = (offsets: ArrayLike<number>, target: number) => {
let lo = 0
let hi = offsets.length
while (lo < hi) {
const mid = (lo + hi) >> 1
offsets[mid]! <= target ? (lo = mid + 1) : (hi = mid)
}
return lo
}
export const stickyPromptFromViewport = (
messages: readonly Msg[],
offsets: ArrayLike<number>,
top: number,
bottom: number,
sticky: boolean
) => {
if (sticky || !messages.length) {
return ''
}
const first = Math.max(0, upperBound(offsets, top) - 1)
const last = Math.max(first, upperBound(offsets, bottom) - 1)
const visibleStart = Math.min(messages.length, first)
const visibleEnd = Math.min(messages.length - 1, last)
for (let i = visibleStart; i <= visibleEnd; i++) {
if (messages[i]?.role === 'user') {
return ''
}
}
for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) {
if (messages[i]?.role !== 'user') {
continue
}
return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top
? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
: ''
}
return ''
}