@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
310 lines • 13.1 kB
JavaScript
import { modelsAreEqual } from "@earendil-works/pi-ai";
import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, } from "@earendil-works/pi-tui";
import { getModelSelectorSearchText } from "../model-search.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
/**
* Component that renders a model selector with search
*/
export class ModelSelectorComponent extends Container {
searchInput;
// Focusable implementation - propagate to searchInput for IME cursor positioning
_focused = false;
get focused() {
return this._focused;
}
set focused(value) {
this._focused = value;
this.searchInput.focused = value;
}
listContainer;
allModels = [];
scopedModelItems = [];
activeModels = [];
filteredModels = [];
selectedIndex = 0;
currentModel;
settingsManager;
modelRuntime;
onSelectCallback;
onCancelCallback;
errorMessage;
refreshStatusMessage = "Refreshing model catalogs…";
refreshStatusSuccess = false;
tui;
scopedModels;
scope = "all";
scopeText;
scopeHintText;
refreshAbortController = new AbortController();
refreshTimeout;
closed = false;
constructor(tui, currentModel, settingsManager, modelRuntime, scopedModels, onSelect, onCancel, initialSearchInput) {
super();
this.tui = tui;
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.modelRuntime = modelRuntime;
this.scopedModels = scopedModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add hint about model filtering
if (scopedModels.length > 0) {
this.scopeText = new Text(this.getScopeText(), 0, 0);
this.addChild(this.scopeText);
this.scopeHintText = new Text(this.getScopeHintText(), 0, 0);
this.addChild(this.scopeHintText);
}
else {
const hintText = "Only showing models from configured providers. Use /login to add providers.";
this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
}
this.addChild(new Spacer(1));
// Create search input
this.searchInput = new Input();
if (initialSearchInput) {
this.searchInput.setValue(initialSearchInput);
}
this.searchInput.onSubmit = () => {
// Enter on search input selects the first filtered item
if (this.filteredModels[this.selectedIndex]) {
this.handleSelect(this.filteredModels[this.selectedIndex].model);
}
};
this.addChild(this.searchInput);
this.addChild(new Spacer(1));
// Create list container
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
// Render the current snapshot immediately, then refresh in the background.
this.loadModelsFromSnapshot();
if (initialSearchInput)
this.filterModels(initialSearchInput);
else
this.updateList();
this.tui.requestRender();
void this.refreshModels();
}
loadModelsFromSnapshot() {
const models = this.modelRuntime.getAvailableSnapshot().map((model) => ({
provider: model.provider,
id: model.id,
model,
}));
this.allModels = this.sortModels(models);
this.scopedModels = this.scopedModels.map((scoped) => {
const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);
return refreshed ? { ...scoped, model: refreshed } : scoped;
});
this.scopedModelItems = this.scopedModels.map((scoped) => ({
provider: scoped.model.provider,
id: scoped.model.id,
model: scoped.model,
}));
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
this.filteredModels = this.activeModels;
const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
this.selectedIndex =
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
}
async refreshModels() {
const timeoutMs = 15_000;
let timedOut = false;
this.refreshTimeout = setTimeout(() => {
timedOut = true;
this.refreshAbortController.abort();
}, timeoutMs);
try {
const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal });
if (this.closed)
return;
this.refreshStatusMessage = "";
if (result.aborted && timedOut) {
this.errorMessage = "Model refresh timed out; showing cached models.";
}
else if (result.errors.size === 1) {
this.errorMessage = `Could not refresh ${result.errors.keys().next().value}; showing cached models.`;
}
else if (result.errors.size > 1) {
this.errorMessage = `Could not refresh ${result.errors.size} model catalogs; showing cached models.`;
}
else {
this.errorMessage = this.modelRuntime.getError();
if (!this.errorMessage) {
this.refreshStatusMessage = "Model catalogs refreshed.";
this.refreshStatusSuccess = true;
}
}
this.loadModelsFromSnapshot();
this.filterModels(this.searchInput.getValue());
this.tui.requestRender();
}
finally {
if (this.refreshTimeout)
clearTimeout(this.refreshTimeout);
}
}
close() {
this.closed = true;
if (this.refreshTimeout)
clearTimeout(this.refreshTimeout);
this.refreshAbortController.abort();
}
sortModels(models) {
const sorted = [...models];
// Sort: current model first, then by provider
sorted.sort((a, b) => {
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
if (aIsCurrent && !bIsCurrent)
return -1;
if (!aIsCurrent && bIsCurrent)
return 1;
return a.provider.localeCompare(b.provider);
});
return sorted;
}
getScopeText() {
const allText = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all");
const scopedText = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped");
return `${theme.fg("muted", "Scope: ")}${allText}${theme.fg("muted", " | ")}${scopedText}`;
}
getScopeHintText() {
return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)");
}
setScope(scope) {
if (this.scope === scope)
return;
this.scope = scope;
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
const currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
this.filterModels(this.searchInput.getValue());
if (this.scopeText) {
this.scopeText.setText(this.getScopeText());
}
}
filterModels(query) {
this.filteredModels = query
? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => getModelSelectorSearchText({ id, provider, name: model.name }))
: this.activeModels;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
this.updateList();
}
updateList() {
this.listContainer.clear();
const maxVisible = 10;
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible));
const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
// Show visible slice of filtered models
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredModels[i];
if (!item)
continue;
const isSelected = i === this.selectedIndex;
const isCurrent = modelsAreEqual(this.currentModel, item.model);
let line = "";
if (isSelected) {
const prefix = theme.fg("accent", "→ ");
const modelText = `${item.id}`;
const providerBadge = theme.fg("muted", `[${item.provider}]`);
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`;
}
else {
const modelText = ` ${item.id}`;
const providerBadge = theme.fg("muted", `[${item.provider}]`);
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
line = `${modelText} ${providerBadge}${checkmark}`;
}
this.listContainer.addChild(new Text(line, 0, 0));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredModels.length) {
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
}
// Show error message or "no results" if empty
if (this.errorMessage) {
// Show error in red
const errorLines = this.errorMessage.split("\n");
for (const line of errorLines) {
this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
}
}
else if (this.filteredModels.length === 0) {
this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
}
else {
const selected = this.filteredModels[this.selectedIndex];
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
}
if (this.refreshStatusMessage) {
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg(this.refreshStatusSuccess ? "success" : "muted", ` ${this.refreshStatusMessage}`), 0, 0));
}
}
handleInput(keyData) {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.input.tab")) {
if (this.scopedModelItems.length > 0) {
const nextScope = this.scope === "all" ? "scoped" : "all";
this.setScope(nextScope);
if (this.scopeHintText) {
this.scopeHintText.setText(this.getScopeHintText());
}
}
return;
}
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
if (this.filteredModels.length === 0)
return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
this.updateList();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
if (this.filteredModels.length === 0)
return;
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedModel = this.filteredModels[this.selectedIndex];
if (selectedModel) {
this.handleSelect(selectedModel.model);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
this.close();
this.onCancelCallback();
}
// Pass everything else to search input
else {
this.searchInput.handleInput(keyData);
this.filterModels(this.searchInput.getValue());
}
}
handleSelect(model) {
this.close();
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
}
getSearchInput() {
return this.searchInput;
}
}
//# sourceMappingURL=model-selector.js.map