Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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]!
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Usage } from '../types.js'
|
||||
|
||||
export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 }
|
||||
@@ -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 ''
|
||||
}
|
||||
Reference in new issue
Block a user