| import { |
| CombinedAutocompleteProvider, |
| Editor, |
| Key, |
| SelectList, |
| decodeKittyPrintable, |
| isKeyRepeat, |
| matchesKey, |
| truncateToWidth, |
| visibleWidth, |
| type AutocompleteItem, |
| type AutocompleteProvider, |
| type AutocompleteSuggestions, |
| type Component, |
| type SelectItem, |
| type TUI, |
| } from '@earendil-works/pi-tui'; |
| import type { UserQuestionOption } from '@maka/core'; |
| import type { PermissionMode } from '@maka/core/permission'; |
| import type { ThinkingLevel } from '@maka/core/model-thinking'; |
| import type { InvocableSkillEntry } from '@maka/runtime'; |
| import { PROVIDER_DEFAULTS, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; |
| import type { ModelChoice } from './connection-target.js'; |
| import type { OnboardingProviderEntry } from './onboarding.js'; |
| import { skillInvocationPrefixAt } from './skill-token.js'; |
| import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; |
| |
| export class MakaAutocompleteProvider implements AutocompleteProvider { |
| private readonly fileProvider: CombinedAutocompleteProvider; |
| private readonly slashCommands: readonly MakaSlashCommandMetadata[]; |
| private readonly listSkills?: () => Promise<readonly InvocableSkillEntry[]>; |
| |
| // The kind of suggestions last returned by getSuggestions: 'skill' when the |
| // active list was mid-message `/skill:` completions, null otherwise. The |
| // Editor runs getSuggestions before applyCompletion and snapshot-guards the |
| // request, so this reliably disambiguates a mid-message skill selection (no |
| // `/` in prefix) from a file selection sharing the same prefix. |
| private lastSlashKind: 'skill' | null = null; |
| |
| constructor( |
| basePath: string, |
| slashCommands: readonly MakaSlashCommandMetadata[], |
| listSkills?: () => Promise<readonly InvocableSkillEntry[]>, |
| ) { |
| this.fileProvider = new CombinedAutocompleteProvider([], basePath); |
| this.slashCommands = slashCommands; |
| this.listSkills = listSkills; |
| } |
| |
| async getSuggestions( |
| lines: string[], |
| cursorLine: number, |
| cursorCol: number, |
| options: { signal: AbortSignal; force?: boolean }, |
| ): Promise<AutocompleteSuggestions | null> { |
| this.lastSlashKind = null; |
| // `/skill:<query>` takes precedence everywhere - including at line start, |
| // where it would otherwise parse as a (non-matching) slash command — and |
| // it suppresses file completion: the token charset looks path-like. |
| const skillPrefix = skillInvocationPrefixAt(lines, cursorLine, cursorCol); |
| if (skillPrefix !== null && this.listSkills && !options.force) { |
| // Skill completion is first-line only, matching pi-tui's isSlashMenuAllowed. |
| if (cursorLine !== 0) return null; |
| const query = skillPrefix.query.toLowerCase(); |
| const skills = await this.listSkills(); |
| if (options.signal.aborted) return null; |
| const items = skills |
| .filter( |
| (skill) => |
| skill.id.toLowerCase().startsWith(query) || skill.name.toLowerCase().includes(query), |
| ) |
| .map((skill) => ({ |
| value: skill.id, |
| label: `/skill:${skill.id}`, |
| description: skill.description ? `${skill.name} · ${skill.description}` : skill.name, |
| })); |
| if (items.length > 0) { |
| // Line-start keeps `/skill:query` so pi-tui auto-submits on select (the |
| // existing "select to invoke" UX). Mid-message drops the `/skill:` head |
| // (just the query) so selection inserts and returns instead of |
| // submitting - pi-tui submits only when `autocompletePrefix` starts with `/`. |
| this.lastSlashKind = 'skill'; |
| const currentLine = lines[cursorLine] || ''; |
| const textBeforeCursor = currentLine.slice(0, cursorCol); |
| const atLineStart = |
| textBeforeCursor.slice(0, textBeforeCursor.length - skillPrefix.prefix.length).trim() === |
| ''; |
| return { items, prefix: atLineStart ? skillPrefix.prefix : skillPrefix.query }; |
| } |
| return null; |
| } |
| if (skillPrefix !== null && !options.force) { |
| // Inside a token but no skill surface: never fall through to path completion. |
| return null; |
| } |
| const slashPrefix = slashCommandPrefix(lines, cursorLine, cursorCol); |
| if (slashPrefix !== null && !options.force) { |
| const query = slashPrefix.slice(1).toLowerCase(); |
| const items = this.slashCommands |
| .filter((command) => command.name.startsWith(query)) |
| .map((command) => ({ |
| value: command.name, |
| label: `/${command.name}`, |
| description: command.description, |
| })); |
| return items.length > 0 ? { items, prefix: slashPrefix } : null; |
| } |
| // A bare mid-message `/`-token (not `/skill:`, handled above): offer |
| // `/skill:xxx` completions so typing `/` surfaces skills immediately. Plain |
| // commands are not offered here - they only execute at line start. |
| const midSlash = midMessageSlashToken(lines, cursorLine, cursorCol); |
| if (midSlash !== null && this.listSkills && !options.force) { |
| // Keep the raw query as the replacement prefix; toLowerCase can change |
| // UTF-16 length (e.g. "İ" -> "i̇", len 1 -> 2), and applyCompletion slices |
| // by prefix.length, so a lowercased prefix would over-delete the original. |
| const rawQuery = midSlash.slice(1); |
| const query = rawQuery.toLowerCase(); |
| const skills = await this.listSkills(); |
| if (options.signal.aborted) return null; |
| const items = skills |
| .filter( |
| (skill) => |
| skill.id.toLowerCase().startsWith(query) || skill.name.toLowerCase().includes(query), |
| ) |
| .map((skill) => ({ |
| value: `skill:${skill.id}`, |
| label: `/skill:${skill.id}`, |
| description: skill.description ? `${skill.name} · ${skill.description}` : skill.name, |
| })); |
| if (items.length > 0) { |
| // Prefix is the raw text after `/` (no leading `/`) so pi-tui's |
| // select-confirm guard does not auto-submit. applyCompletion reuses the |
| // mid-message skill path: beforePrefix ends with `/`, item.value is |
| // `skill:<id>`, so `${beforePrefix}${item.value} ` yields `/skill:<id> `. |
| this.lastSlashKind = 'skill'; |
| return { items, prefix: rawQuery }; |
| } |
| // No skill matched. Do NOT fall through to the file provider: a mid-message |
| // `/`-token's file completion would carry a `/`-prefixed prefix, and pi-tui's |
| // select-confirm guard auto-submits when `prefix.startsWith("/")` - so |
| // selecting it would send the unfinished message. Mid-message `/`-path |
| // completion was not available before this PR either (pi-tui excludes `/` |
| // from triggerCharacters), so returning null restores the prior behavior. |
| return null; |
| } |
| return this.fileProvider.getSuggestions(lines, cursorLine, cursorCol, options); |
| } |
| |
| applyCompletion( |
| lines: string[], |
| cursorLine: number, |
| cursorCol: number, |
| item: AutocompleteItem, |
| prefix: string, |
| ): { lines: string[]; cursorLine: number; cursorCol: number } { |
| const currentLine = lines[cursorLine] || ''; |
| const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); |
| if (prefix.startsWith('/skill:')) { |
| const nextLines = [...lines]; |
| nextLines[cursorLine] = `${beforePrefix}/skill:${item.value} ${currentLine.slice(cursorCol)}`; |
| return { |
| lines: nextLines, |
| cursorLine, |
| cursorCol: beforePrefix.length + item.value.length + 8, |
| }; |
| } |
| if (prefix.startsWith('/') && beforePrefix.trim() === '') { |
| const nextLines = [...lines]; |
| nextLines[cursorLine] = `${beforePrefix}/${item.value} ${currentLine.slice(cursorCol)}`; |
| return { |
| lines: nextLines, |
| cursorLine, |
| cursorCol: beforePrefix.length + item.value.length + 2, |
| }; |
| } |
| if (this.lastSlashKind === 'skill') { |
| // Mid-message skill: prefix is just the query (no `/skill:`); the |
| // `/skill:` head sits at the end of beforePrefix. Insert |
| // `/skill:<value> ` and leave the cursor after the space; pi-tui will not |
| // auto-submit because the prefix did not start with `/`. |
| const nextLines = [...lines]; |
| nextLines[cursorLine] = `${beforePrefix}${item.value} ${currentLine.slice(cursorCol)}`; |
| return { |
| lines: nextLines, |
| cursorLine, |
| cursorCol: beforePrefix.length + item.value.length + 1, |
| }; |
| } |
| return this.fileProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix); |
| } |
| |
| shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { |
| if (skillInvocationPrefixAt(lines, cursorLine, cursorCol) !== null) return false; |
| return this.fileProvider.shouldTriggerFileCompletion(lines, cursorLine, cursorCol); |
| } |
| } |
| |
| /** Autocomplete surface for `/move`: reuse path completion but expose folders only. */ |
| export class DirectoryAutocompleteProvider implements AutocompleteProvider { |
| private readonly provider: CombinedAutocompleteProvider; |
| |
| constructor(basePath: string) { |
| this.provider = new CombinedAutocompleteProvider([], basePath); |
| } |
| |
| async getSuggestions( |
| lines: string[], |
| cursorLine: number, |
| cursorCol: number, |
| options: { signal: AbortSignal; force?: boolean }, |
| ): Promise<AutocompleteSuggestions | null> { |
| const suggestions = await this.provider.getSuggestions(lines, cursorLine, cursorCol, options); |
| if (!suggestions) return null; |
| const items = suggestions.items.filter((item) => item.label.endsWith('/')); |
| return items.length > 0 ? { ...suggestions, items } : null; |
| } |
| |
| applyCompletion( |
| lines: string[], |
| cursorLine: number, |
| cursorCol: number, |
| item: AutocompleteItem, |
| prefix: string, |
| ): { lines: string[]; cursorLine: number; cursorCol: number } { |
| const currentLine = lines[cursorLine] || ''; |
| const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); |
| if (prefix.startsWith('/') && beforePrefix.trim() === '') { |
| // Directory completion is path syntax even when the editor sees a |
| // slash-prefixed token. Do not route an absolute path through the |
| // slash-command completion rule, which would add a second slash. |
| const completed = item.value.startsWith('/') ? item.value : `/${item.value}`; |
| const nextLines = [...lines]; |
| nextLines[cursorLine] = `${beforePrefix}${completed} ${currentLine.slice(cursorCol)}`; |
| return { |
| lines: nextLines, |
| cursorLine, |
| cursorCol: beforePrefix.length + completed.length + 1, |
| }; |
| } |
| return this.provider.applyCompletion(lines, cursorLine, cursorCol, item, prefix); |
| } |
| |
| shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { |
| return this.provider.shouldTriggerFileCompletion(lines, cursorLine, cursorCol); |
| } |
| } |
| |
| export interface MakaSlashCommandMetadata { |
| name: string; |
| description: string; |
| } |
| |
| export interface MakaSlashCommand extends MakaSlashCommandMetadata { |
| run(parts: string[], rawTail: string | undefined, context: { idleMs: number }): void; |
| /** Alternate names that dispatch to this command without appearing in |
| * completion or the /help menu (e.g. /quit as an alias of /exit). */ |
| aliases?: readonly string[]; |
| } |
| |
| function slashCommandPrefix(lines: string[], cursorLine: number, cursorCol: number): string | null { |
| const currentLine = lines[cursorLine] || ''; |
| const textBeforeCursor = currentLine.slice(0, cursorCol); |
| return textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ') |
| ? textBeforeCursor |
| : null; |
| } |
| |
| // A `/`-token that begins mid-message (after whitespace) on the first line, |
| // excluding the `/skill:` form (handled by skillInvocationPrefixAt above) and |
| // line-start (handled by slashCommandPrefix). Used to offer `/skill:xxx` |
| // completions from a bare `/` so typing `/` surfaces skills immediately. |
| function midMessageSlashToken( |
| lines: string[], |
| cursorLine: number, |
| cursorCol: number, |
| ): string | null { |
| if (cursorLine !== 0) return null; |
| const currentLine = lines[cursorLine] || ''; |
| const textBeforeCursor = currentLine.slice(0, cursorCol); |
| const match = /(?:\s)(\/\S*)$/.exec(textBeforeCursor); |
| if (!match) return null; |
| const token = match[1]; |
| return token.startsWith('/skill:') ? null : token; |
| } |
| |
| export class PickerOverlay implements Component { |
| constructor( |
| private readonly list: SelectList, |
| private readonly input: { |
| title: string; |
| rightLabel: string; |
| hint?: string; |
| onInput?: (data: string) => boolean; |
| }, |
| ) {} |
| |
| invalidate(): void { |
| this.list.invalidate(); |
| } |
| |
| handleInput(data: string): void { |
| if (this.input.onInput?.(data)) return; |
| this.list.handleInput(data); |
| } |
| |
| render(width: number): string[] { |
| const safeWidth = Math.max(1, width); |
| return [ |
| padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), |
| padLine(ansi.dim(this.input.hint ?? 'enter select / esc close'), safeWidth), |
| padLine('', safeWidth), |
| ...this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth)), |
| padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), |
| ]; |
| } |
| } |
| |
| export class DirectoryPickerOverlay implements Component { |
| private readonly editor: Editor; |
| |
| constructor( |
| tui: TUI, |
| private readonly input: { |
| currentCwd: string; |
| basePath: string; |
| onSubmit: (cwd: string) => void; |
| onCancel: () => void; |
| }, |
| ) { |
| this.editor = new Editor(tui, editorTheme(), { paddingX: 0, autocompleteMaxVisible: 8 }); |
| this.editor.setAutocompleteProvider(new DirectoryAutocompleteProvider(input.basePath)); |
| this.editor.onSubmit = (value) => { |
| const cwd = value.trim(); |
| if (cwd) this.input.onSubmit(cwd); |
| }; |
| } |
| |
| invalidate(): void { |
| this.editor.invalidate(); |
| } |
| |
| handleInput(data: string): void { |
| if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { |
| this.input.onCancel(); |
| return; |
| } |
| this.editor.handleInput(data); |
| } |
| |
| render(width: number): string[] { |
| const safeWidth = Math.max(1, width); |
| this.editor.focused = true; |
| const label = 'Directory '; |
| const labelWidth = visibleWidth(label); |
| const editorLines = this.editor.render(Math.max(1, safeWidth - labelWidth)).slice(1, -1); |
| return [ |
| padLine('Move Session', safeWidth), |
| padLine(ansi.dim('Type a directory · Tab complete · Enter confirm · Esc cancel'), safeWidth), |
| padLine(ansi.dim(`Current: ${this.input.currentCwd}`), safeWidth), |
| padLine('', safeWidth), |
| ...(editorLines.length > 0 |
| ? editorLines.map((line, index) => |
| padLine(`${index === 0 ? label : ' '.repeat(labelWidth)}${line}`, safeWidth), |
| ) |
| : [padLine(label, safeWidth)]), |
| padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), |
| ]; |
| } |
| } |
| |
| const USER_QUESTION_ROW_PREFIX_WIDTH = 2; |
| |
| /** |
| * A single question's overlay: the preset options and a free-text "Other" row on |
| * one screen. The free-text row is the list's last line — an inline {@link Editor} |
| * that activates when the highlight lands on it. ↑↓ move the highlight through the |
| * options and the input row as one ring; typing a printable character while on an |
| * option jumps to the input row and starts the answer there (gemini-cli's |
| * type-to-jump). Enter selects the highlighted option, or submits non-empty input |
| * text; Esc leaves the whole question unanswered. Replaces the old two-step design |
| * that swapped the option list out for a separate text overlay. |
| */ |
| export class UserQuestionOverlay implements Component { |
| private readonly editor: Editor; |
| // Highlight index over [0, options.length]. `options.length` is the input row. |
| private activeIndex = 0; |
| |
| constructor( |
| tui: TUI, |
| private readonly input: { |
| title: string; |
| rightLabel: string; |
| hint: string; |
| placeholder: string; |
| options: readonly UserQuestionOption[]; |
| onSelectOption(index: number): void; |
| onSubmitText(value: string): void; |
| onSkip(): void; |
| }, |
| ) { |
| // paddingX 0 so the inline row aligns under the ` `/`→ ` option prefix |
| // instead of the editor's own gutter. |
| this.editor = new Editor(tui, editorTheme(), { paddingX: 0 }); |
| // Submit through the Editor's own submitValue() path: it expands paste |
| // markers (a large paste is stored as a `[paste #N …]` placeholder until |
| // then) and trims, so the answer is the real pasted/typed text. An empty |
| // submission is a no-op so Enter on the blank row can't send a blank answer. |
| this.editor.onSubmit = (value) => { |
| if (value) this.input.onSubmitText(value); |
| }; |
| } |
| |
| private get inputRowIndex(): number { |
| return this.input.options.length; |
| } |
| |
| private get onInputRow(): boolean { |
| return this.activeIndex === this.inputRowIndex; |
| } |
| |
| invalidate(): void { |
| this.editor.invalidate(); |
| } |
| |
| handleInput(data: string): void { |
| // Esc always abandons the whole question (advance unanswered), even with |
| // text typed — one Esc level, matching the pre-inline behavior. |
| if (matchesKey(data, Key.escape)) { |
| this.input.onSkip(); |
| return; |
| } |
| if (matchesKey(data, Key.up)) { |
| this.activeIndex = this.activeIndex === 0 ? this.inputRowIndex : this.activeIndex - 1; |
| return; |
| } |
| if (matchesKey(data, Key.down)) { |
| this.activeIndex = this.activeIndex === this.inputRowIndex ? 0 : this.activeIndex + 1; |
| return; |
| } |
| if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { |
| // A held-key repeat must not double-advance onto the next question. |
| if (isKeyRepeat(data)) return; |
| if (!this.onInputRow) { |
| this.input.onSelectOption(this.activeIndex); |
| return; |
| } |
| // Fall through: on the input row even Enter goes to the Editor, whose own |
| // key classification decides newline (LF/Ctrl-J, Shift+Enter, `\`+Enter) |
| // vs submit — submitValue() then feeds the wired onSubmit above. |
| } |
| if (this.onInputRow) { |
| this.editor.handleInput(data); |
| return; |
| } |
| // Type-to-jump: a printable key (or an IME/legacy multi-byte sequence) while |
| // an option is highlighted moves to the input row and starts the answer with |
| // that key. Mirror the editor's own printable test — a Kitty CSI-u printable, |
| // or a legacy sequence whose first byte is a non-control character — so |
| // navigation/control keys (arrows, Enter, Esc, Ctrl/Alt combos) never trigger |
| // the jump. The raw sequence is handed to the editor so its IME and paste |
| // handling stay intact. |
| const printable = decodeKittyPrintable(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined); |
| if (printable !== undefined) { |
| this.activeIndex = this.inputRowIndex; |
| this.editor.handleInput(data); |
| } |
| } |
| |
| render(width: number): string[] { |
| const safeWidth = Math.max(1, width); |
| const lines: string[] = [ |
| padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), |
| padLine(ansi.dim(this.input.hint), safeWidth), |
| padLine('', safeWidth), |
| ]; |
| this.input.options.forEach((option, index) => { |
| lines.push(this.renderOptionRow(option, index === this.activeIndex, safeWidth)); |
| }); |
| lines.push(...this.renderInputRow(safeWidth)); |
| lines.push(padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth)); |
| return lines; |
| } |
| |
| private renderOptionRow(option: UserQuestionOption, active: boolean, width: number): string { |
| const prefix = active ? '→ ' : ' '; |
| const body = option.description |
| ? `${option.label} ${active ? option.description : ansi.dim(option.description)}` |
| : option.label; |
| return formatPickerItemLine(`${prefix}${body}`, width); |
| } |
| |
| private renderInputRow(width: number): string[] { |
| const prefix = this.onInputRow ? '→ ' : ' '; |
| const contentWidth = Math.max(1, width - USER_QUESTION_ROW_PREFIX_WIDTH); |
| // Focused only while the input row is highlighted: that both shows the block |
| // cursor and emits the hardware-cursor marker (#1064) so IME candidate windows |
| // anchor to the edited text instead of the terminal bottom. |
| this.editor.focused = this.onInputRow; |
| if (!this.onInputRow && this.editor.getText().length === 0) { |
| return [padLine(`${prefix}${ansi.dim(this.input.placeholder)}`, width)]; |
| } |
| // Drop the editor's own top/bottom border rows; keep just its content lines |
| // so the answer reads as one row of the list. |
| const editorLines = this.editor.render(contentWidth).slice(1, -1); |
| if (editorLines.length === 0) { |
| return [padLine(`${prefix}${ansi.dim(this.input.placeholder)}`, width)]; |
| } |
| return editorLines.map((line, index) => |
| padLine(`${index === 0 ? prefix : ' '}${line}`, width), |
| ); |
| } |
| } |
| |
| export function modelPickerItems( |
| currentModel: string, |
| models: readonly string[] | undefined, |
| ): SelectItem[] { |
| const ids: string[] = []; |
| const seen = new Set<string>(); |
| for (const candidate of [currentModel, ...(models ?? [])]) { |
| const id = candidate.trim(); |
| if (!id || seen.has(id)) continue; |
| seen.add(id); |
| ids.push(id); |
| } |
| return ids.map((id) => ({ |
| value: id, |
| label: id, |
| ...(id === currentModel ? { description: 'current' } : {}), |
| })); |
| } |
| |
| /** |
| * `/model` items across every ready connection. The value is the choice's index |
| * (models can repeat across connections, so no id is unique on its own); the |
| * caller maps it back to the {@link ModelChoice}. The description carries the |
| * owning connection so identical model ids on different providers are readable. |
| */ |
| function modelChoicePickerItems( |
| choices: readonly ModelChoice[], |
| current: { model: string; connectionSlug: string }, |
| ): SelectItem[] { |
| return choices.map((choice, index) => { |
| const isCurrent = |
| choice.model === current.model && choice.connectionSlug === current.connectionSlug; |
| const tags = [choice.connectionName || choice.connectionSlug]; |
| if (isCurrent) tags.push('current'); |
| else if (choice.isDefaultConnection) tags.push('default'); |
| return { value: String(index), label: choice.model, description: tags.join(' · ') }; |
| }); |
| } |
| |
| /** |
| * Case-insensitive substring match for the `/model` search field, against every |
| * criterion the issue names: model id, provider label/type, and connection |
| * name/slug. `ModelChoice` carries no display name, so the model id is the only |
| * model-side match target; a display-name enrichment would slot in here. |
| */ |
| function matchesModelChoice(choice: ModelChoice, query: string): boolean { |
| if (choice.model.toLowerCase().includes(query)) return true; |
| if (choice.connectionName.toLowerCase().includes(query)) return true; |
| if (choice.connectionSlug.toLowerCase().includes(query)) return true; |
| if (choice.providerType.toLowerCase().includes(query)) return true; |
| const providerLabel = PROVIDER_DEFAULTS[choice.providerType]?.label; |
| if (providerLabel && providerLabel.toLowerCase().includes(query)) return true; |
| return false; |
| } |
| |
| export interface ModelSearchOverlayInput { |
| choices: readonly ModelChoice[]; |
| current: { model: string; connectionSlug: string }; |
| onSelect: (choice: ModelChoice) => void; |
| onCancel: () => void; |
| } |
| |
| /** |
| * One bottom search field + a bounded single-select list, for the cross- |
| * connection `/model` picker (issue #1098 seam 2). Mirrors the OnboardingWizard |
| * search phase — the same one-field-powers-the-list shape — but stays a focused |
| * single-select: it is not a shared base for the setup multi-select. The list is |
| * rebuilt in place on every keystroke (SelectList has no setItems), and Esc / |
| * Ctrl-C close without rebinding. The `models`-only fallback (minimal hosts, |
| * tests) stays on the non-searchable PickerOverlay. |
| */ |
| export class ModelSearchOverlay implements Component { |
| private readonly searchEditor: Editor; |
| private filtered: readonly ModelChoice[]; |
| private list: SelectList; |
| private readonly initialIndex: number; |
| |
| constructor( |
| private readonly tui: TUI, |
| private readonly input: ModelSearchOverlayInput, |
| ) { |
| this.filtered = [...input.choices]; |
| this.initialIndex = input.choices.findIndex( |
| (choice) => |
| choice.model === input.current.model && |
| choice.connectionSlug === input.current.connectionSlug, |
| ); |
| this.list = this.buildList(); |
| if (this.initialIndex >= 0) this.list.setSelectedIndex(this.initialIndex); |
| this.searchEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); |
| this.searchEditor.onChange = (text) => this.applyQuery(text); |
| } |
| |
| private buildList(): SelectList { |
| const list = new SelectList( |
| modelChoicePickerItems(this.filtered, this.input.current), |
| 10, |
| selectListTheme(), |
| { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 48 }, |
| ); |
| list.onSelect = (item) => { |
| const choice = this.filtered[Number(item.value)]; |
| if (!choice) return; |
| this.input.onSelect(choice); |
| }; |
| list.onCancel = () => this.input.onCancel(); |
| return list; |
| } |
| |
| private applyQuery(text: string): void { |
| const query = text.trim().toLowerCase(); |
| const next = query |
| ? this.input.choices.filter((choice) => matchesModelChoice(choice, query)) |
| : this.input.choices; |
| if (next === this.filtered) return; |
| this.filtered = next; |
| this.list = this.buildList(); |
| } |
| |
| invalidate(): void { |
| this.searchEditor.invalidate(); |
| this.list.invalidate(); |
| } |
| |
| handleInput(data: string): void { |
| if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { |
| this.input.onCancel(); |
| return; |
| } |
| // Arrows and Enter drive the list; everything else is typed into the search |
| // field (mirrors the OnboardingWizard search phase). |
| if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { |
| this.list.handleInput(data); |
| return; |
| } |
| if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { |
| if (isKeyRepeat(data)) return; |
| this.list.handleInput(data); |
| return; |
| } |
| this.searchEditor.handleInput(data); |
| } |
| |
| render(width: number): string[] { |
| const safeWidth = Math.max(1, width); |
| this.searchEditor.focused = true; |
| return [ |
| padLine(`Select Model ${ansi.accent(String(this.filtered.length))}`, safeWidth), |
| padLine(ansi.dim('搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消'), safeWidth), |
| padLine('', safeWidth), |
| ...this.renderFieldRow(this.searchEditor, '搜索', safeWidth), |
| padLine('', safeWidth), |
| ...(this.filtered.length === 0 |
| ? [padLine(ansi.dim('没有匹配的模型'), safeWidth)] |
| : this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth))), |
| padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), |
| ]; |
| } |
| |
| private renderFieldRow(editor: Editor, label: string, width: number): string[] { |
| const prefix = `${label} `; |
| const prefixWidth = visibleWidth(prefix); |
| const contentWidth = Math.max(1, width - prefixWidth); |
| const editorLines = editor.render(contentWidth).slice(1, -1); |
| if (editorLines.length === 0) { |
| return [padLine(prefix, width)]; |
| } |
| return editorLines.map((line, index) => |
| padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), |
| ); |
| } |
| } |
| |
| /** |
| * #1611: `current` marks an option that is genuinely in force, so choosing it |
| * is a no-op. A read-only session is neither of these options, and marking |
| * Auto as current there turned "confirm what I already have" into a silent |
| * widening of the boundary. Legacy `execute` has no boundary of its own and |
| * really does resolve to Auto, so it still marks Auto. |
| */ |
| export function permissionModePickerItems(currentMode: PermissionMode): SelectItem[] { |
| const autoIsCurrent = currentMode === 'ask' || currentMode === 'execute'; |
| return [ |
| { |
| value: 'auto', |
| label: 'Auto', |
| description: autoIsCurrent ? 'current · protected' : 'protected', |
| }, |
| { |
| value: 'bypass', |
| label: 'Full access', |
| description: |
| currentMode === 'bypass' |
| ? 'current · your files and network, unprotected' |
| : 'your files and network, unprotected', |
| }, |
| ]; |
| } |
| |
| /** |
| * `/skill` picker items (issue #1148). The value is the skill id (that's what |
| * the inserted `/skill:<id>` token resolves by); the description carries the |
| * id too, since CJK display names alone don't tell the user what to type. |
| */ |
| export function skillPickerItems(skills: readonly InvocableSkillEntry[]): SelectItem[] { |
| return skills.map((skill) => ({ |
| value: skill.id, |
| label: skill.name, |
| description: skill.description ? `${skill.id} · ${skill.description}` : skill.id, |
| })); |
| } |
| |
| /** Provider search items for `/setup`, marking connections that already exist |
| * `已设置` so a re-onboard reads as edit/rotate rather than create. */ |
| export function onboardingProviderPickerItems( |
| providers: readonly OnboardingProviderEntry[], |
| ): SelectItem[] { |
| return providers.map((provider) => ({ |
| value: provider.providerType, |
| label: provider.label, |
| description: provider.hasConnection |
| ? `${provider.providerType} · 已设置` |
| : provider.providerType, |
| })); |
| } |
| |
| const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = { |
| off: '关', |
| minimal: '最小', |
| low: '低', |
| medium: '中', |
| high: '高', |
| xhigh: '超高', |
| max: '最高', |
| }; |
| |
| export function thinkingLevelPickerItems( |
| levels: readonly ThinkingLevel[], |
| current: ThinkingLevel | undefined, |
| ): SelectItem[] { |
| return [ |
| { |
| value: 'default', |
| label: '默认', |
| ...(current === undefined ? { description: 'current' } : {}), |
| }, |
| ...levels.map((level) => ({ |
| value: level, |
| label: THINKING_LEVEL_LABELS[level], |
| ...(level === current ? { description: 'current' } : {}), |
| })), |
| ]; |
| } |
| |
| function formatPickerItemLine(line: string, width: number): string { |
| const padded = padLine(line, width); |
| return stripAnsi(line).startsWith('→ ') ? ansi.reverse(padded) : padded; |
| } |
| |
| function padLine(text: string, width: number): string { |
| const safeWidth = Math.max(1, width); |
| const trimmed = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, '') : text; |
| return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; |
| } |
| |
| export type OnboardingWizardPhase = 'search' | 'key' | 'models' | 'success'; |
| |
| export type OnboardingWizardStatus = |
| | { kind: 'prompt' } |
| | { kind: 'verifying' } |
| | { kind: 'error'; text: string } |
| | { kind: 'saving' }; |
| |
| export interface OnboardingWizardInput { |
| providers: readonly OnboardingProviderEntry[]; |
| /** search→key: the user picked a provider. The runner records it for verify/save. */ |
| onPickProvider: (providerType: ProviderType) => void; |
| /** key submit. The value may be empty — an existing connection reuses the stored |
| * secret, while a new required-key provider is rejected by verify. */ |
| onSubmitKey: (apiKey: string) => void; |
| /** models submit: save the curated enabled set (≥1 model). */ |
| onSubmitModels: (enabledModelIds: readonly string[]) => void; |
| /** search Esc / Ctrl+C: close (first-run closes the TUI). */ |
| onCancel: () => void; |
| /** key Esc → search; models Esc → key. The runner invalidates in-flight work. */ |
| onBack: () => void; |
| /** success Enter/Esc: close. */ |
| onClose: () => void; |
| } |
| |
| const ONBOARDING_MODELS_MAX_VISIBLE = 10; |
| |
| /** |
| * One input field, four phases. The same overlay is the provider search, the |
| * API-key field, the searchable model multi-select, and the in-frame success — |
| * so onboarding never pushes its prompt/verifying/failure/saving/success notices |
| * into the transcript. Status lives in a single status line beside the field |
| * instead of the top entry flow (#1098 UX). `Esc` always moves back exactly one |
| * level (models → key → provider → close); late async results are ignored after |
| * back/close/retry because the runner bumps its attempt id on every transition. |
| */ |
| export class OnboardingWizard implements Component { |
| private phase: OnboardingWizardPhase = 'search'; |
| private picked: OnboardingProviderEntry | undefined; |
| private status: OnboardingWizardStatus = { kind: 'prompt' }; |
| private readonly searchEditor: Editor; |
| private readonly keyEditor: Editor; |
| private readonly modelsSearchEditor: Editor; |
| private filtered: readonly OnboardingProviderEntry[]; |
| private list: SelectList; |
| // Models phase state. Selection seeds from the picked provider's enabled set |
| // on first verify; a re-verify preserves the user's toggles (stale ids drop). |
| private models: ModelInfo[] = []; |
| private filteredModels: ModelInfo[] = []; |
| private selectedIds: Set<string> = new Set(); |
| private modelsInitialized = false; |
| private modelHighlight = 0; |
| private modelScroll = 0; |
| private successCount = 0; |
| |
| constructor( |
| private readonly tui: TUI, |
| private readonly input: OnboardingWizardInput, |
| ) { |
| this.filtered = input.providers; |
| this.list = this.buildList(); |
| this.searchEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); |
| // editor.onChange fires on every keystroke: refilter the provider list in |
| // place. SelectList has no setItems, so rebuild it; the next render picks |
| // the new instance up. |
| this.searchEditor.onChange = (text) => this.applyQuery(text); |
| this.keyEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); |
| // Allow a blank submit: an existing connection reuses the stored secret; the |
| // host's verify rejects a blank key for a new required-key provider. |
| this.keyEditor.onSubmit = (value) => { |
| if (this.picked) this.input.onSubmitKey(value); |
| }; |
| this.modelsSearchEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); |
| this.modelsSearchEditor.onChange = (text) => this.applyModelQuery(text); |
| } |
| |
| private buildList(): SelectList { |
| const list = new SelectList( |
| onboardingProviderPickerItems(this.filtered), |
| 10, |
| selectListTheme(), |
| { minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 32 }, |
| ); |
| list.onSelect = (item) => { |
| const provider = this.filtered.find((p) => p.providerType === item.value); |
| if (!provider) return; |
| this.enterKeyPhase(provider); |
| }; |
| return list; |
| } |
| |
| private enterKeyPhase(provider: OnboardingProviderEntry): void { |
| this.picked = provider; |
| this.phase = 'key'; |
| this.status = { kind: 'prompt' }; |
| this.keyEditor.setText(''); |
| this.keyEditor.disableSubmit = false; |
| this.searchEditor.setText(''); |
| // Reset the models phase for the new provider; selection seeds on first verify. |
| this.models = []; |
| this.filteredModels = []; |
| this.selectedIds = new Set(); |
| this.modelsInitialized = false; |
| this.modelHighlight = 0; |
| this.modelScroll = 0; |
| this.modelsSearchEditor.setText(''); |
| this.input.onPickProvider(provider.providerType); |
| } |
| |
| private applyQuery(text: string): void { |
| const query = text.trim().toLowerCase(); |
| const next = query |
| ? this.input.providers.filter( |
| (p) => |
| p.label.toLowerCase().includes(query) || p.providerType.toLowerCase().includes(query), |
| ) |
| : this.input.providers; |
| if (next === this.filtered) return; |
| this.filtered = next; |
| this.list = this.buildList(); |
| } |
| |
| private applyModelQuery(text: string): void { |
| const query = text.trim().toLowerCase(); |
| this.filteredModels = query |
| ? this.models.filter( |
| (m) => |
| m.id.toLowerCase().includes(query) || |
| (m.displayName ?? '').toLowerCase().includes(query), |
| ) |
| : this.models; |
| this.modelHighlight = 0; |
| this.modelScroll = 0; |
| } |
| |
| /** Runner hook: verify is in flight. Lock the key field and show progress. */ |
| setVerifying(): void { |
| if (this.phase !== 'key') return; |
| this.status = { kind: 'verifying' }; |
| this.keyEditor.disableSubmit = true; |
| } |
| |
| /** Runner hook: verify failed — re-arm the key field in place. */ |
| setKeyError(text: string): void { |
| if (this.phase !== 'key') return; |
| this.status = { kind: 'error', text }; |
| this.keyEditor.disableSubmit = false; |
| this.keyEditor.setText(''); |
| } |
| |
| /** Runner hook: verify succeeded — advance to the models step with fresh |
| * discovered models. Selection seeds from the picked provider's enabled set |
| * on first entry (existing connections preserve it; new ones start empty); |
| * a re-verify preserves the user's toggles, dropping ids no longer discovered. */ |
| setModels(models: ModelInfo[]): void { |
| if (this.phase !== 'key') return; |
| this.models = models; |
| if (!this.modelsInitialized) { |
| this.selectedIds = new Set( |
| (this.picked?.enabledModelIds ?? []).filter((id) => models.some((m) => m.id === id)), |
| ); |
| this.modelsInitialized = true; |
| } else { |
| for (const id of [...this.selectedIds]) { |
| if (!models.some((m) => m.id === id)) this.selectedIds.delete(id); |
| } |
| } |
| this.applyModelQuery(this.modelsSearchEditor.getText()); |
| this.phase = 'models'; |
| this.status = { kind: 'prompt' }; |
| } |
| |
| /** Runner hook: save is in flight. */ |
| setSaving(): void { |
| if (this.phase !== 'models') return; |
| this.status = { kind: 'saving' }; |
| } |
| |
| /** Runner hook: save failed — stay in the models step with an error. */ |
| setModelError(text: string): void { |
| if (this.phase !== 'models') return; |
| this.status = { kind: 'error', text }; |
| } |
| |
| /** Runner hook: save succeeded — show the enabled-model count in-frame. */ |
| setSuccess(enabledCount: number): void { |
| this.phase = 'success'; |
| this.successCount = enabledCount; |
| this.status = { kind: 'prompt' }; |
| } |
| |
| invalidate(): void { |
| this.searchEditor.invalidate(); |
| this.keyEditor.invalidate(); |
| this.modelsSearchEditor.invalidate(); |
| this.list.invalidate(); |
| } |
| |
| handleInput(data: string): void { |
| switch (this.phase) { |
| case 'search': |
| return this.handleSearchInput(data); |
| case 'key': |
| return this.handleKeyInput(data); |
| case 'models': |
| return this.handleModelsInput(data); |
| case 'success': |
| return this.handleSuccessInput(data); |
| } |
| } |
| |
| private handleSearchInput(data: string): void { |
| if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { |
| this.input.onCancel(); |
| return; |
| } |
| // Arrows and Enter drive the list (selecting a provider); everything else |
| // is typed into the search field. The search editor therefore never owns |
| // history navigation during the wizard. |
| if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { |
| this.list.handleInput(data); |
| return; |
| } |
| if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { |
| if (isKeyRepeat(data)) return; |
| this.list.handleInput(data); |
| return; |
| } |
| this.searchEditor.handleInput(data); |
| } |
| |
| private handleKeyInput(data: string): void { |
| // Ctrl+C cancels the whole wizard (the overlay cancel contract binds both |
| // keys); Esc only returns to the provider search. Both fire while a probe |
| // is in flight, matching pi-tui `tui.select.cancel = [escape, ctrl+c]`. |
| if (matchesKey(data, Key.ctrl('c'))) { |
| this.input.onCancel(); |
| return; |
| } |
| if (matchesKey(data, Key.escape)) { |
| this.phase = 'search'; |
| this.picked = undefined; |
| this.status = { kind: 'prompt' }; |
| this.keyEditor.setText(''); |
| this.keyEditor.disableSubmit = false; |
| this.input.onBack(); |
| return; |
| } |
| // The probe owns the key field while it is in flight: disableSubmit only |
| // blocks Enter, so swallow the rest too — otherwise typed text renders and |
| // is then silently wiped by the error path's setText(''). |
| if (this.status.kind === 'verifying') return; |
| this.keyEditor.handleInput(data); |
| } |
| |
| private handleModelsInput(data: string): void { |
| if (matchesKey(data, Key.ctrl('c'))) { |
| this.input.onCancel(); |
| return; |
| } |
| if (matchesKey(data, Key.escape)) { |
| // models → key (one level back); query/selection state survives. |
| this.phase = 'key'; |
| this.status = { kind: 'prompt' }; |
| this.keyEditor.setText(''); |
| this.keyEditor.disableSubmit = false; |
| this.input.onBack(); |
| return; |
| } |
| if (this.status.kind === 'saving') return; |
| if (matchesKey(data, Key.up)) { |
| this.moveModelHighlight(-1); |
| return; |
| } |
| if (matchesKey(data, Key.down)) { |
| this.moveModelHighlight(1); |
| return; |
| } |
| // Space toggles the highlighted model instead of entering the search query — |
| // model ids never contain spaces, so the search field does not need it. |
| if (matchesKey(data, Key.space)) { |
| this.toggleHighlightedModel(); |
| return; |
| } |
| if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { |
| if (isKeyRepeat(data)) return; |
| if (this.selectedIds.size === 0) { |
| this.status = { kind: 'error', text: '至少选择一个模型再保存' }; |
| return; |
| } |
| this.input.onSubmitModels([...this.selectedIds]); |
| return; |
| } |
| // Everything else (arrows, backspace, paste, printable text) goes to the |
| // search editor — it owns editing semantics; the wizard only intercepts the |
| // keys the list owns (Esc/Ctrl+C/up/down/Space/Enter). Mirrors handleSearchInput. |
| this.modelsSearchEditor.handleInput(data); |
| } |
| |
| private handleSuccessInput(data: string): void { |
| if ( |
| matchesKey(data, Key.enter) || |
| matchesKey(data, Key.return) || |
| matchesKey(data, Key.escape) || |
| matchesKey(data, Key.ctrl('c')) |
| ) { |
| if (matchesKey(data, Key.enter) && isKeyRepeat(data)) return; |
| this.input.onClose(); |
| } |
| } |
| |
| private moveModelHighlight(delta: number): void { |
| const count = this.filteredModels.length; |
| if (count === 0) return; |
| this.modelHighlight = (this.modelHighlight + delta + count) % count; |
| // Keep the highlight inside the visible window. |
| if (this.modelHighlight < this.modelScroll) this.modelScroll = this.modelHighlight; |
| else if (this.modelHighlight >= this.modelScroll + ONBOARDING_MODELS_MAX_VISIBLE) { |
| this.modelScroll = this.modelHighlight - ONBOARDING_MODELS_MAX_VISIBLE + 1; |
| } |
| } |
| |
| private toggleHighlightedModel(): void { |
| const model = this.filteredModels[this.modelHighlight]; |
| if (!model) return; |
| if (this.selectedIds.has(model.id)) this.selectedIds.delete(model.id); |
| else this.selectedIds.add(model.id); |
| // Clear a stale "至少选择一个模型" error once a selection exists. |
| if (this.status.kind === 'error' && this.selectedIds.size > 0) { |
| this.status = { kind: 'prompt' }; |
| } |
| } |
| |
| render(width: number): string[] { |
| const safeWidth = Math.max(1, width); |
| switch (this.phase) { |
| case 'search': |
| return this.renderSearch(safeWidth); |
| case 'key': |
| return this.renderKey(safeWidth); |
| case 'models': |
| return this.renderModels(safeWidth); |
| case 'success': |
| return this.renderSuccess(safeWidth); |
| } |
| } |
| |
| private renderSearch(width: number): string[] { |
| this.searchEditor.focused = true; |
| this.keyEditor.focused = false; |
| this.modelsSearchEditor.focused = false; |
| return [ |
| padLine( |
| `Set Up Provider ${ansi.dim('· 1/3')} ${ansi.accent(String(this.filtered.length))}`, |
| width, |
| ), |
| padLine(ansi.dim('搜索服务商,↑↓ 选择 · Enter 确认 · Esc 取消'), width), |
| padLine('', width), |
| ...this.renderFieldRow(this.searchEditor, '搜索', width), |
| padLine('', width), |
| ...(this.filtered.length === 0 |
| ? [padLine(ansi.dim('没有匹配的服务商'), width)] |
| : this.list.render(width).map((line) => formatPickerItemLine(line, width))), |
| padLine(ansi.accent('-'.repeat(width)), width), |
| ]; |
| } |
| |
| private renderKey(width: number): string[] { |
| this.searchEditor.focused = false; |
| this.keyEditor.focused = this.status.kind === 'prompt' || this.status.kind === 'error'; |
| this.modelsSearchEditor.focused = false; |
| const label = this.picked?.label ?? ''; |
| const hint = this.picked?.hasConnection |
| ? '留空复用已保存的 key,或输入新 key 轮换 · Esc 返回选择服务商' |
| : '输入 API key · 仅本机存储 · Esc 返回选择服务商'; |
| return [ |
| padLine(`Set Up Provider ${ansi.dim('· 2/3')} ${ansi.accent(label)}`, width), |
| padLine(ansi.dim(hint), width), |
| padLine('', width), |
| ...this.renderFieldRow(this.keyEditor, 'API key', width), |
| padLine('', width), |
| padLine(this.renderKeyStatusLine(), width), |
| padLine(ansi.accent('-'.repeat(width)), width), |
| ]; |
| } |
| |
| private renderKeyStatusLine(): string { |
| switch (this.status.kind) { |
| case 'prompt': |
| return ansi.dim('Enter 提交'); |
| case 'verifying': |
| return `${ansi.yellow('⠋')} 正在验证 key…`; |
| case 'error': |
| return ansi.red(`✗ ${this.status.text}`); |
| case 'saving': |
| return ansi.dim('Enter 提交'); |
| } |
| } |
| |
| private renderModels(width: number): string[] { |
| this.searchEditor.focused = false; |
| this.keyEditor.focused = false; |
| this.modelsSearchEditor.focused = this.status.kind !== 'saving'; |
| const label = this.picked?.label ?? ''; |
| const lines = [ |
| padLine(`Set Up Provider ${ansi.dim('· 3/3')} ${ansi.accent(label)}`, width), |
| padLine(ansi.dim('搜索模型,↑↓ 选择 · Space 切换 · Enter 保存 · Esc 返回'), width), |
| padLine('', width), |
| ...this.renderFieldRow(this.modelsSearchEditor, '搜索', width), |
| padLine('', width), |
| ]; |
| if (this.filteredModels.length === 0) { |
| lines.push(padLine(ansi.dim('没有匹配的模型'), width)); |
| } else { |
| const end = Math.min( |
| this.modelScroll + ONBOARDING_MODELS_MAX_VISIBLE, |
| this.filteredModels.length, |
| ); |
| for (let i = this.modelScroll; i < end; i++) { |
| const model = this.filteredModels[i]!; |
| const highlighted = i === this.modelHighlight; |
| const mark = this.selectedIds.has(model.id) ? '☑' : '☐'; |
| const body = model.displayName ? `${model.displayName} ${ansi.dim(model.id)}` : model.id; |
| lines.push(formatPickerItemLine(`${highlighted ? '→ ' : ' '}${mark} ${body}`, width)); |
| } |
| } |
| lines.push(padLine('', width)); |
| lines.push(padLine(this.renderModelsStatusLine(), width)); |
| lines.push(padLine(ansi.accent('-'.repeat(width)), width)); |
| return lines; |
| } |
| |
| private renderModelsStatusLine(): string { |
| switch (this.status.kind) { |
| case 'prompt': |
| return ansi.dim(`已选 ${this.selectedIds.size} · Enter 保存`); |
| case 'verifying': |
| return ansi.dim(`已选 ${this.selectedIds.size}`); |
| case 'saving': |
| return `${ansi.yellow('⠋')} 正在保存…`; |
| case 'error': |
| return ansi.red(`✗ ${this.status.text}`); |
| } |
| } |
| |
| private renderSuccess(width: number): string[] { |
| this.searchEditor.focused = false; |
| this.keyEditor.focused = false; |
| this.modelsSearchEditor.focused = false; |
| const label = this.picked?.label ?? ''; |
| return [ |
| padLine(`Set Up Provider ${ansi.dim('· 完成')} ${ansi.accent(label)}`, width), |
| padLine(ansi.green(`✓ 已启用 ${this.successCount} 个模型`), width), |
| padLine('', width), |
| padLine(ansi.dim('Enter 关闭'), width), |
| padLine(ansi.accent('-'.repeat(width)), width), |
| ]; |
| } |
| |
| private renderFieldRow(editor: Editor, label: string, width: number): string[] { |
| const prefix = `${label} `; |
| const prefixWidth = visibleWidth(prefix); |
| const contentWidth = Math.max(1, width - prefixWidth); |
| const editorLines = editor.render(contentWidth).slice(1, -1); |
| if (editorLines.length === 0) { |
| return [padLine(prefix, width)]; |
| } |
| return editorLines.map((line, index) => |
| padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), |
| ); |
| } |
| } |