@greppleai/grip 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +3 -1
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/grip-agent.d.ts.map +1 -1
- package/dist/core/grip-agent.js +4 -2
- package/dist/core/grip-agent.js.map +1 -1
- package/dist/core/sandbox/lima.d.ts.map +1 -1
- package/dist/core/sandbox/lima.js +6 -6
- package/dist/core/sandbox/lima.js.map +1 -1
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +18 -7
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts +26 -18
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +220 -192
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +68 -6
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/grip-user-agent.d.ts.map +1 -1
- package/dist/utils/grip-user-agent.js +1 -1
- package/dist/utils/grip-user-agent.js.map +1 -1
- package/docs/keybindings.md +3 -1
- package/docs/providers.md +3 -1
- package/docs/sandbox.md +1 -1
- package/docs/usage.md +37 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +12 -12
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model-selector.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/model-selector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAkB,MAAM,oBAAoB,CAAC;AAChE,OAAO,EACN,SAAS,EACT,KAAK,SAAS,EAGd,KAAK,EAGL,KAAK,GAAG,EACR,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAWzE,UAAU,eAAe;IACxB,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAID;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,SAAU,YAAW,SAAS;IACzE,OAAO,CAAC,WAAW,CAAQ;IAG3B,OAAO,CAAC,QAAQ,CAAS;IACzB,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IACD,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,gBAAgB,CAAmB;IAC3C,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,cAAc,CAAmB;IACzC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,YAAY,CAAC,CAAa;IAClC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,GAAG,CAAM;IACjB,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,SAAS,CAAC,CAAO;IACzB,OAAO,CAAC,aAAa,CAAC,CAAO;IAC7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAU;IAEzC,YACC,GAAG,EAAE,GAAG,EACR,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,EACpC,eAAe,EAAE,eAAe,EAChC,aAAa,EAAE,aAAa,EAC5B,YAAY,EAAE,aAAa,CAAC,eAAe,CAAC,EAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EACrC,QAAQ,EAAE,MAAM,IAAI,EACpB,kBAAkB,CAAC,EAAE,MAAM,EAC3B,cAAc,UAAO,EA+DrB;YAEa,UAAU;IAoDxB,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,gBAAgB;IAIxB,OAAO,CAAC,QAAQ;IAYhB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,UAAU;IA6ClB,OAAO,CAAC,gBAAgB;IAOxB,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,sBAAsB;IAK9B,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAwBjC;IAED,OAAO,CAAC,YAAY;IAMpB,cAAc,IAAI,KAAK,CAEtB;CACD","sourcesContent":["import { type Model, modelsAreEqual } from \"@greppleai/grip-ai\";\nimport {\n\tContainer,\n\ttype Focusable,\n\tfuzzyFilter,\n\tgetKeybindings,\n\tInput,\n\tSpacer,\n\tText,\n\ttype TUI,\n} from \"@greppleai/grip-tui\";\nimport type { ModelRegistry } from \"../../../core/model-registry.ts\";\nimport type { SettingsManager } from \"../../../core/settings-manager.ts\";\nimport { theme } from \"../theme/theme.ts\";\nimport { DynamicBorder } from \"./dynamic-border.ts\";\nimport { keyHint } from \"./keybinding-hints.ts\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\ninterface ScopedModelItem {\n\tmodel: Model<any>;\n\tthinkingLevel?: string;\n}\n\ntype ModelScope = \"all\" | \"scoped\";\n\n/**\n * Component that renders a model selector with search\n */\nexport class ModelSelectorComponent extends Container implements Focusable {\n\tprivate searchInput: Input;\n\n\t// Focusable implementation - propagate to searchInput for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.searchInput.focused = value;\n\t}\n\tprivate listContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate scopedModelItems: ModelItem[] = [];\n\tprivate activeModels: ModelItem[] = [];\n\tprivate filteredModels: ModelItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel?: Model<any>;\n\tprivate settingsManager: SettingsManager;\n\tprivate modelRegistry: ModelRegistry;\n\tprivate onSelectCallback: (model: Model<any>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage?: string;\n\tprivate tui: TUI;\n\tprivate scopedModels: ReadonlyArray<ScopedModelItem>;\n\tprivate scope: ModelScope = \"all\";\n\tprivate scopeText?: Text;\n\tprivate scopeHintText?: Text;\n\tprivate readonly persistDefault: boolean;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | undefined,\n\t\tsettingsManager: SettingsManager,\n\t\tmodelRegistry: ModelRegistry,\n\t\tscopedModels: ReadonlyArray<ScopedModelItem>,\n\t\tonSelect: (model: Model<any>) => void,\n\t\tonCancel: () => void,\n\t\tinitialSearchInput?: string,\n\t\tpersistDefault = true,\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.modelRegistry = modelRegistry;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.scope = scopedModels.length > 0 ? \"scoped\" : \"all\";\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onCancelCallback = onCancel;\n\t\tthis.persistDefault = persistDefault;\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add hint about model filtering\n\t\tif (scopedModels.length > 0) {\n\t\t\tthis.scopeText = new Text(this.getScopeText(), 0, 0);\n\t\t\tthis.addChild(this.scopeText);\n\t\t\tthis.scopeHintText = new Text(this.getScopeHintText(), 0, 0);\n\t\t\tthis.addChild(this.scopeHintText);\n\t\t} else {\n\t\t\tconst hintText = \"Only showing models from configured providers. Use /login to add providers.\";\n\t\t\tthis.addChild(new Text(theme.fg(\"warning\", hintText), 0, 0));\n\t\t}\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tif (initialSearchInput) {\n\t\t\tthis.searchInput.setValue(initialSearchInput);\n\t\t}\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\t// Enter on search input selects the first filtered item\n\t\t\tif (this.filteredModels[this.selectedIndex]) {\n\t\t\t\tthis.handleSelect(this.filteredModels[this.selectedIndex].model);\n\t\t\t}\n\t\t};\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Load models and do initial render\n\t\tthis.loadModels().then(() => {\n\t\t\tif (initialSearchInput) {\n\t\t\t\tthis.filterModels(initialSearchInput);\n\t\t\t} else {\n\t\t\t\tthis.updateList();\n\t\t\t}\n\t\t\t// Request re-render after models are loaded\n\t\t\tthis.tui.requestRender();\n\t\t});\n\t}\n\n\tprivate async loadModels(): Promise<void> {\n\t\tlet models: ModelItem[];\n\n\t\t// Refresh to pick up any changes to models.json\n\t\tthis.modelRegistry.refresh();\n\n\t\t// Check for models.json errors\n\t\tconst loadError = this.modelRegistry.getError();\n\t\tif (loadError) {\n\t\t\tthis.errorMessage = loadError;\n\t\t}\n\n\t\t// Load available models (built-in models still work even if models.json failed)\n\t\ttry {\n\t\t\tconst availableModels = await this.modelRegistry.getAvailable();\n\t\t\tmodels = availableModels.map((model: Model<any>) => ({\n\t\t\t\tprovider: model.provider,\n\t\t\t\tid: model.id,\n\t\t\t\tmodel,\n\t\t\t}));\n\t\t} catch (error) {\n\t\t\tthis.allModels = [];\n\t\t\tthis.scopedModelItems = [];\n\t\t\tthis.activeModels = [];\n\t\t\tthis.filteredModels = [];\n\t\t\tthis.errorMessage = error instanceof Error ? error.message : String(error);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.allModels = this.sortModels(models);\n\t\tconst registeredModels = new Map<string, Map<string, Model<any>>>();\n\t\tfor (const model of this.modelRegistry.getAll()) {\n\t\t\tconst providerModels = registeredModels.get(model.provider) ?? new Map<string, Model<any>>();\n\t\t\tif (!providerModels.has(model.id)) providerModels.set(model.id, model);\n\t\t\tregisteredModels.set(model.provider, providerModels);\n\t\t}\n\t\tthis.scopedModels = this.scopedModels.map((scoped) => {\n\t\t\tconst refreshed = registeredModels.get(scoped.model.provider)?.get(scoped.model.id);\n\t\t\treturn refreshed ? { ...scoped, model: refreshed } : scoped;\n\t\t});\n\t\tthis.scopedModelItems = this.scopedModels.map((scoped) => ({\n\t\t\tprovider: scoped.model.provider,\n\t\t\tid: scoped.model.id,\n\t\t\tmodel: scoped.model,\n\t\t}));\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t\tthis.filteredModels = this.activeModels;\n\t\tconst currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));\n\t\tthis.selectedIndex =\n\t\t\tcurrentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));\n\t}\n\n\tprivate sortModels(models: ModelItem[]): ModelItem[] {\n\t\tconst sorted = [...models];\n\t\t// Sort: current model first, then by provider\n\t\tsorted.sort((a, b) => {\n\t\t\tconst aIsCurrent = modelsAreEqual(this.currentModel, a.model);\n\t\t\tconst bIsCurrent = modelsAreEqual(this.currentModel, b.model);\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\t\treturn sorted;\n\t}\n\n\tprivate getScopeText(): string {\n\t\tconst allText = this.scope === \"all\" ? theme.fg(\"accent\", \"all\") : theme.fg(\"muted\", \"all\");\n\t\tconst scopedText = this.scope === \"scoped\" ? theme.fg(\"accent\", \"scoped\") : theme.fg(\"muted\", \"scoped\");\n\t\treturn `${theme.fg(\"muted\", \"Scope: \")}${allText}${theme.fg(\"muted\", \" | \")}${scopedText}`;\n\t}\n\n\tprivate getScopeHintText(): string {\n\t\treturn keyHint(\"tui.input.tab\", \"scope\") + theme.fg(\"muted\", \" (all/scoped)\");\n\t}\n\n\tprivate setScope(scope: ModelScope): void {\n\t\tif (this.scope === scope) return;\n\t\tthis.scope = scope;\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t\tconst currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));\n\t\tthis.selectedIndex = currentIndex >= 0 ? currentIndex : 0;\n\t\tthis.filterModels(this.searchInput.getValue());\n\t\tif (this.scopeText) {\n\t\t\tthis.scopeText.setText(this.getScopeText());\n\t\t}\n\t}\n\n\tprivate filterModels(query: string): void {\n\t\tthis.filteredModels = query\n\t\t\t? fuzzyFilter(\n\t\t\t\t\tthis.activeModels,\n\t\t\t\t\tquery,\n\t\t\t\t\t({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`,\n\t\t\t\t)\n\t\t\t: this.activeModels;\n\t\t// A query changes fuzzy-match ranking, so select the best match. When the query\n\t\t// is cleared, preserve the current position and only clamp it to the restored list.\n\t\tthis.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate appendModelListStatus(): void {\n\t\tif (this.errorMessage) {\n\t\t\tfor (const line of this.errorMessage.split(\"\\n\")) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (this.filteredModels.length === 0) {\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", \" No matching models\"), 0, 0));\n\t\t\treturn;\n\t\t}\n\t\tconst selected = this.filteredModels[this.selectedIndex];\n\t\tthis.listContainer.addChild(new Spacer(1));\n\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", ` Model Name: ${selected.model.name}`), 0, 0));\n\t}\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst maxVisible = 10;\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);\n\n\t\t// Show visible slice of filtered models\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredModels[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst isCurrent = modelsAreEqual(this.currentModel, item.model);\n\n\t\t\tlet line = \"\";\n\t\t\tif (isSelected) {\n\t\t\t\tconst prefix = theme.fg(\"accent\", \"→ \");\n\t\t\t\tconst modelText = `${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = `${prefix + theme.fg(\"accent\", modelText)} ${providerBadge}${checkmark}`;\n\t\t\t} else {\n\t\t\t\tconst modelText = ` ${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = `${modelText} ${providerBadge}${checkmark}`;\n\t\t\t}\n\n\t\t\tthis.listContainer.addChild(new Text(line, 0, 0));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredModels.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tthis.appendModelListStatus();\n\t}\n\n\tprivate toggleModelScope(): void {\n\t\tif (this.scopedModelItems.length === 0) return;\n\t\tconst nextScope: ModelScope = this.scope === \"all\" ? \"scoped\" : \"all\";\n\t\tthis.setScope(nextScope);\n\t\tthis.scopeHintText?.setText(this.getScopeHintText());\n\t}\n\n\tprivate moveSelection(direction: -1 | 1): void {\n\t\tif (this.filteredModels.length === 0) return;\n\t\tif (direction < 0) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;\n\t\t} else {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t}\n\t\tthis.updateList();\n\t}\n\n\tprivate selectHighlightedModel(): void {\n\t\tconst selectedModel = this.filteredModels[this.selectedIndex];\n\t\tif (selectedModel) this.handleSelect(selectedModel.model);\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\tif (kb.matches(keyData, \"tui.input.tab\")) {\n\t\t\tthis.toggleModelScope();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.moveSelection(-1);\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.moveSelection(1);\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tthis.selectHighlightedModel();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tthis.onCancelCallback();\n\t\t\treturn;\n\t\t}\n\t\tthis.searchInput.handleInput(keyData);\n\t\tthis.filterModels(this.searchInput.getValue());\n\t}\n\n\tprivate handleSelect(model: Model<any>): void {\n\t\t// Save as new default\n\t\tif (this.persistDefault) this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\t\tthis.onSelectCallback(model);\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"model-selector.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/model-selector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK,KAAK,EAAkB,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EACN,SAAS,EACT,KAAK,SAAS,EAGd,KAAK,EAGL,KAAK,GAAG,EAER,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAWzE,UAAU,eAAe;IACxB,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAWD,gFAAgF;AAChF,qBAAa,sBAAuB,SAAQ,SAAU,YAAW,SAAS;IACzE,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IACD,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,gBAAgB,CAAmB;IAC3C,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,cAAc,CAAmB;IACzC,OAAO,CAAC,SAAS,CAAsB;IACvC,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,gBAAgB,CAA6B;IACrD,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,IAAI,CAAa;IACzB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,GAAG,CAAM;IACjB,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,SAAS,CAAO;IACxB,OAAO,CAAC,cAAc,CAAO;IAC7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAU;IAEzC,YACC,GAAG,EAAE,GAAG,EACR,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,EACpC,eAAe,EAAE,eAAe,EAChC,aAAa,EAAE,aAAa,EAC5B,YAAY,EAAE,aAAa,CAAC,eAAe,CAAC,EAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EACrC,QAAQ,EAAE,MAAM,IAAI,EACpB,kBAAkB,CAAC,EAAE,MAAM,EAC3B,cAAc,UAAO,EA+BrB;YAEa,UAAU;IAwCxB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,WAAW;IAoBnB,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,YAAY;IAuBpB,OAAO,CAAC,UAAU;IA4BlB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,WAAW;IAIV,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAMvC;IAED,OAAO,CAAC,gBAAgB;IAexB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,gBAAgB;IAoBxB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CA8BjC;IAED,cAAc,IAAI,KAAK,CAEtB;CACD","sourcesContent":["import { type Api, type Model, modelsAreEqual } from \"@greppleai/grip-ai\";\nimport {\n\tContainer,\n\ttype Focusable,\n\tfuzzyFilter,\n\tgetKeybindings,\n\tInput,\n\tSpacer,\n\tText,\n\ttype TUI,\n\ttruncateToWidth,\n} from \"@greppleai/grip-tui\";\nimport type { ModelRegistry } from \"../../../core/model-registry.ts\";\nimport type { SettingsManager } from \"../../../core/settings-manager.ts\";\nimport { theme } from \"../theme/theme.ts\";\nimport { DynamicBorder } from \"./dynamic-border.ts\";\nimport { keyHint } from \"./keybinding-hints.ts\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<Api>;\n}\n\ninterface ScopedModelItem {\n\tmodel: Model<Api>;\n\tthinkingLevel?: string;\n}\n\ninterface ProviderItem {\n\tid: string;\n\tname: string;\n\tmodels: ModelItem[];\n}\n\ntype ModelScope = \"all\" | \"scoped\";\ntype PickerView = \"providers\" | \"models\" | \"search\";\n\n/** Native provider/model navigation, with a global model-search entry point. */\nexport class ModelSelectorComponent extends Container implements Focusable {\n\tprivate searchInput: Input;\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.searchInput.focused = value;\n\t}\n\tprivate listContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate scopedModelItems: ModelItem[] = [];\n\tprivate activeModels: ModelItem[] = [];\n\tprivate filteredModels: ModelItem[] = [];\n\tprivate providers: ProviderItem[] = [];\n\tprivate filteredProviders: ProviderItem[] = [];\n\tprivate selectedProvider: ProviderItem | null = null;\n\tprivate providerQuery = \"\";\n\tprivate providerIndex = 0;\n\tprivate view: PickerView;\n\tprivate selectedIndex = 0;\n\tprivate currentModel: Model<Api> | undefined;\n\tprivate settingsManager: SettingsManager;\n\tprivate modelRegistry: ModelRegistry;\n\tprivate onSelectCallback: (model: Model<Api>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage: string | undefined;\n\tprivate loading = true;\n\tprivate listWidth = 80;\n\tprivate tui: TUI;\n\tprivate scopedModels: ReadonlyArray<ScopedModelItem>;\n\tprivate scope: ModelScope;\n\tprivate scopeText: Text;\n\tprivate navigationText: Text;\n\tprivate readonly persistDefault: boolean;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<Api> | undefined,\n\t\tsettingsManager: SettingsManager,\n\t\tmodelRegistry: ModelRegistry,\n\t\tscopedModels: ReadonlyArray<ScopedModelItem>,\n\t\tonSelect: (model: Model<Api>) => void,\n\t\tonCancel: () => void,\n\t\tinitialSearchInput?: string,\n\t\tpersistDefault = true,\n\t) {\n\t\tsuper();\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.modelRegistry = modelRegistry;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.scope = scopedModels.length > 0 ? \"scoped\" : \"all\";\n\t\tthis.view = initialSearchInput ? \"search\" : \"providers\";\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onCancelCallback = onCancel;\n\t\tthis.persistDefault = persistDefault;\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.scopeText = new Text(\"\", 0, 0);\n\t\tthis.addChild(this.scopeText);\n\t\tthis.navigationText = new Text(\"\", 0, 0);\n\t\tthis.addChild(this.navigationText);\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.searchInput = new Input();\n\t\tthis.searchInput.setValue(initialSearchInput ?? \"\");\n\t\tthis.searchInput.onSubmit = () => this.confirmSelection();\n\t\tthis.addChild(this.searchInput);\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.updateList();\n\t\tvoid this.loadModels();\n\t}\n\n\tprivate async loadModels(): Promise<void> {\n\t\ttry {\n\t\t\t// Refresh without introducing provider/network validation.\n\t\t\tthis.modelRegistry.refresh();\n\t\t\tthis.errorMessage = this.modelRegistry.getError();\n\t\t\tconst available = await this.modelRegistry.getAvailable();\n\t\t\tthis.allModels = available.map((model) => ({ provider: model.provider, id: model.id, model }));\n\t\t\tthis.allModels.sort((a, b) => {\n\t\t\t\tconst aCurrent = modelsAreEqual(this.currentModel, a.model);\n\t\t\t\tconst bCurrent = modelsAreEqual(this.currentModel, b.model);\n\t\t\t\tif (aCurrent !== bCurrent) return aCurrent ? -1 : 1;\n\t\t\t\treturn a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);\n\t\t\t});\n\t\t\tconst registered = new Map<string, Map<string, Model<Api>>>();\n\t\t\tfor (const model of this.modelRegistry.getAll()) {\n\t\t\t\tlet models = registered.get(model.provider);\n\t\t\t\tif (!models) {\n\t\t\t\t\tmodels = new Map();\n\t\t\t\t\tregistered.set(model.provider, models);\n\t\t\t\t}\n\t\t\t\tif (!models.has(model.id)) models.set(model.id, model);\n\t\t\t}\n\t\t\t// Keep scoped order, reconciling stale instances after registry refresh.\n\t\t\tthis.scopedModelItems = this.scopedModels.map(({ model }) => ({\n\t\t\t\tprovider: model.provider,\n\t\t\t\tid: model.id,\n\t\t\t\tmodel: registered.get(model.provider)?.get(model.id) ?? model,\n\t\t\t}));\n\t\t} catch (error) {\n\t\t\tthis.allModels = [];\n\t\t\tthis.scopedModelItems = [];\n\t\t\tthis.errorMessage = error instanceof Error ? error.message : String(error);\n\t\t}\n\t\tthis.loading = false;\n\t\tthis.rebuildProviders();\n\t\tthis.selectCurrent();\n\t\tthis.filterItems(this.searchInput.getValue());\n\t\tthis.tui.requestRender();\n\t}\n\n\tprivate rebuildProviders(): void {\n\t\tthis.activeModels = this.scope === \"scoped\" ? this.scopedModelItems : this.allModels;\n\t\tconst groups = new Map<string, ProviderItem>();\n\t\tfor (const item of this.activeModels) {\n\t\t\tlet provider = groups.get(item.provider);\n\t\t\tif (!provider) {\n\t\t\t\tprovider = {\n\t\t\t\t\tid: item.provider,\n\t\t\t\t\tname: this.modelRegistry.getProviderDisplayName(item.provider),\n\t\t\t\t\tmodels: [],\n\t\t\t\t};\n\t\t\t\tgroups.set(item.provider, provider);\n\t\t\t}\n\t\t\tprovider.models.push(item);\n\t\t}\n\t\tthis.providers = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));\n\t}\n\n\tprivate selectCurrent(): void {\n\t\tconst index =\n\t\t\tthis.view === \"providers\"\n\t\t\t\t? this.providers.findIndex((item) => item.id === this.currentModel?.provider)\n\t\t\t\t: this.getModelItems().findIndex((item) => modelsAreEqual(this.currentModel, item.model));\n\t\tthis.selectedIndex = Math.max(0, index);\n\t}\n\n\tprivate getModelItems(): ModelItem[] {\n\t\treturn this.view === \"models\" ? (this.selectedProvider?.models ?? []) : this.activeModels;\n\t}\n\n\tprivate filterItems(query: string): void {\n\t\tif (this.view === \"providers\") {\n\t\t\tthis.filteredProviders = query\n\t\t\t\t? fuzzyFilter(this.providers, query, ({ id, name }) => `${name} ${id}`)\n\t\t\t\t: this.providers;\n\t\t} else {\n\t\t\tconst items = this.getModelItems();\n\t\t\tthis.filteredModels = query\n\t\t\t\t? fuzzyFilter(\n\t\t\t\t\t\titems,\n\t\t\t\t\t\tquery,\n\t\t\t\t\t\t({ id, provider, model }) => `${id} ${provider} ${provider}/${id} ${model.name}`,\n\t\t\t\t\t)\n\t\t\t\t: items;\n\t\t}\n\t\t// Query edits select the best match; clearing only clamps the position (7209).\n\t\tthis.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.itemCount() - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate itemCount(): number {\n\t\treturn this.view === \"providers\" ? this.filteredProviders.length : this.filteredModels.length;\n\t}\n\n\tprivate updateHeader(): void {\n\t\tconst all = this.scope === \"all\" ? theme.fg(\"accent\", \"all\") : theme.fg(\"muted\", \"all\");\n\t\tconst scoped = this.scope === \"scoped\" ? theme.fg(\"accent\", \"scoped\") : theme.fg(\"muted\", \"scoped\");\n\t\tconst scopeHint =\n\t\t\tthis.scopedModels.length > 0 ? ` · ${keyHint(\"tui.input.tab\", \"scope\")} (${all}/${scoped})` : \"\";\n\t\tconst title =\n\t\t\tthis.view === \"providers\"\n\t\t\t\t? \"Providers\"\n\t\t\t\t: this.view === \"search\"\n\t\t\t\t\t? \"Search all providers\"\n\t\t\t\t\t: `${this.selectedProvider?.name} [${this.selectedProvider?.id}]`;\n\t\tthis.scopeText.setText(theme.fg(\"muted\", title) + scopeHint);\n\t\tthis.navigationText.setText(\n\t\t\tkeyHint(\"tui.select.up\", \"up\") +\n\t\t\t\t\" · \" +\n\t\t\t\tkeyHint(\"tui.select.down\", \"down\") +\n\t\t\t\t\" · \" +\n\t\t\t\tkeyHint(\"tui.select.confirm\", this.view === \"providers\" ? \"models\" : \"select\") +\n\t\t\t\t\" · \" +\n\t\t\t\tkeyHint(\"tui.select.cancel\", this.view === \"models\" ? \"providers\" : \"cancel\"),\n\t\t);\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.updateHeader();\n\t\tthis.listContainer.clear();\n\t\tconst count = this.itemCount();\n\t\tconst maxVisible = 10;\n\t\tconst start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));\n\t\tconst end = Math.min(start + maxVisible, count);\n\t\tfor (let i = start; i < end; i++) {\n\t\t\tlet label: string;\n\t\t\tlet current: boolean;\n\t\t\tif (this.view === \"providers\") {\n\t\t\t\tconst item = this.filteredProviders[i];\n\t\t\t\tlabel = `${item.name} [${item.id}] (${item.models.length} models)`;\n\t\t\t\tcurrent = item.id === this.currentModel?.provider;\n\t\t\t} else {\n\t\t\t\tconst item = this.filteredModels[i];\n\t\t\t\tlabel = `${item.id} [${item.provider}]`;\n\t\t\t\tcurrent = modelsAreEqual(this.currentModel, item.model);\n\t\t\t}\n\t\t\tconst selected = i === this.selectedIndex;\n\t\t\tconst prefix = selected ? theme.fg(\"accent\", \"→ \") : \" \";\n\t\t\tconst marker = current ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\tthis.addListLine(prefix + (selected ? theme.fg(\"accent\", label) : label) + marker);\n\t\t}\n\t\tif (start > 0 || end < count) this.addListLine(theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${count})`));\n\t\tthis.appendListStatus(count);\n\t}\n\n\tprivate appendListStatus(count: number): void {\n\t\tif (this.loading) {\n\t\t\tthis.addListLine(theme.fg(\"muted\", \" Loading models…\"));\n\t\t} else if (count === 0) {\n\t\t\tconst empty =\n\t\t\t\tthis.view === \"providers\" ? \"No matching providers. Use /login to add providers.\" : \"No matching models\";\n\t\t\tthis.addListLine(theme.fg(\"muted\", ` ${empty}`));\n\t\t} else if (this.view !== \"providers\") {\n\t\t\tthis.listContainer.addChild(new Spacer(1));\n\t\t\tthis.addListLine(theme.fg(\"muted\", ` Model Name: ${this.filteredModels[this.selectedIndex].model.name}`));\n\t\t}\n\t\tif (this.errorMessage) {\n\t\t\tfor (const line of this.errorMessage.split(\"\\n\")) this.addListLine(theme.fg(\"error\", line));\n\t\t}\n\t}\n\n\tprivate addListLine(line: string): void {\n\t\tthis.listContainer.addChild(new Text(truncateToWidth(line, this.listWidth), 0, 0));\n\t}\n\n\toverride render(width: number): string[] {\n\t\tif (this.listWidth !== width) {\n\t\t\tthis.listWidth = width;\n\t\t\tthis.updateList();\n\t\t}\n\t\treturn super.render(width);\n\t}\n\n\tprivate toggleModelScope(): void {\n\t\tif (this.scopedModels.length === 0) return;\n\t\tthis.scope = this.scope === \"all\" ? \"scoped\" : \"all\";\n\t\tif (this.view !== \"search\") {\n\t\t\tthis.view = \"providers\";\n\t\t\tthis.selectedProvider = null;\n\t\t\tthis.providerQuery = \"\";\n\t\t\tthis.providerIndex = 0;\n\t\t\tthis.searchInput.setValue(\"\");\n\t\t}\n\t\tthis.rebuildProviders();\n\t\tthis.selectCurrent();\n\t\tthis.filterItems(this.searchInput.getValue());\n\t}\n\n\tprivate moveSelection(delta: number): void {\n\t\tconst count = this.itemCount();\n\t\tif (count === 0) return;\n\t\tthis.selectedIndex = (this.selectedIndex + delta + count) % count;\n\t\tthis.updateList();\n\t}\n\n\tprivate confirmSelection(): void {\n\t\tif (this.loading) return;\n\t\tif (this.view === \"providers\") {\n\t\t\tconst provider = this.filteredProviders[this.selectedIndex];\n\t\t\tif (!provider) return;\n\t\t\tthis.providerQuery = this.searchInput.getValue();\n\t\t\tthis.providerIndex = this.selectedIndex;\n\t\t\tthis.selectedProvider = provider;\n\t\t\tthis.view = \"models\";\n\t\t\tthis.searchInput.setValue(\"\");\n\t\t\tthis.selectCurrent();\n\t\t\tthis.filterItems(\"\");\n\t\t\treturn;\n\t\t}\n\t\tconst item = this.filteredModels[this.selectedIndex];\n\t\tif (!item) return;\n\t\tif (this.persistDefault) this.settingsManager.setDefaultModelAndProvider(item.provider, item.id);\n\t\tthis.onSelectCallback(item.model);\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\tif (kb.matches(keyData, \"tui.input.tab\")) {\n\t\t\tthis.toggleModelScope();\n\t\t} else if (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.moveSelection(-1);\n\t\t} else if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.moveSelection(1);\n\t\t} else if (kb.matches(keyData, \"tui.select.pageUp\")) {\n\t\t\tthis.moveSelection(-Math.min(10, Math.max(0, this.itemCount() - 1)));\n\t\t} else if (kb.matches(keyData, \"tui.select.pageDown\")) {\n\t\t\tthis.moveSelection(Math.min(10, Math.max(0, this.itemCount() - 1)));\n\t\t} else if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tthis.confirmSelection();\n\t\t} else if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tif (this.view === \"models\") {\n\t\t\t\tthis.view = \"providers\";\n\t\t\t\tthis.selectedProvider = null;\n\t\t\t\tthis.searchInput.setValue(this.providerQuery);\n\t\t\t\tthis.filterItems(this.providerQuery);\n\t\t\t\tthis.selectedIndex = Math.min(this.providerIndex, Math.max(0, this.itemCount() - 1));\n\t\t\t\tthis.updateList();\n\t\t\t} else {\n\t\t\t\tthis.onCancelCallback();\n\t\t\t}\n\t\t} else {\n\t\t\tconst query = this.searchInput.getValue();\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tif (query !== this.searchInput.getValue()) this.filterItems(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { modelsAreEqual } from "@greppleai/grip-ai";
|
|
2
|
-
import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, } from "@greppleai/grip-tui";
|
|
2
|
+
import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, truncateToWidth, } from "@greppleai/grip-tui";
|
|
3
3
|
import { theme } from "../theme/theme.js";
|
|
4
4
|
import { DynamicBorder } from "./dynamic-border.js";
|
|
5
5
|
import { keyHint } from "./keybinding-hints.js";
|
|
6
|
-
/**
|
|
7
|
-
* Component that renders a model selector with search
|
|
8
|
-
*/
|
|
6
|
+
/** Native provider/model navigation, with a global model-search entry point. */
|
|
9
7
|
export class ModelSelectorComponent extends Container {
|
|
10
8
|
searchInput;
|
|
11
|
-
// Focusable implementation - propagate to searchInput for IME cursor positioning
|
|
12
9
|
_focused = false;
|
|
13
10
|
get focused() {
|
|
14
11
|
return this._focused;
|
|
@@ -22,6 +19,12 @@ export class ModelSelectorComponent extends Container {
|
|
|
22
19
|
scopedModelItems = [];
|
|
23
20
|
activeModels = [];
|
|
24
21
|
filteredModels = [];
|
|
22
|
+
providers = [];
|
|
23
|
+
filteredProviders = [];
|
|
24
|
+
selectedProvider = null;
|
|
25
|
+
providerQuery = "";
|
|
26
|
+
providerIndex = 0;
|
|
27
|
+
view;
|
|
25
28
|
selectedIndex = 0;
|
|
26
29
|
currentModel;
|
|
27
30
|
settingsManager;
|
|
@@ -29,11 +32,13 @@ export class ModelSelectorComponent extends Container {
|
|
|
29
32
|
onSelectCallback;
|
|
30
33
|
onCancelCallback;
|
|
31
34
|
errorMessage;
|
|
35
|
+
loading = true;
|
|
36
|
+
listWidth = 80;
|
|
32
37
|
tui;
|
|
33
38
|
scopedModels;
|
|
34
|
-
scope
|
|
39
|
+
scope;
|
|
35
40
|
scopeText;
|
|
36
|
-
|
|
41
|
+
navigationText;
|
|
37
42
|
persistDefault;
|
|
38
43
|
constructor(tui, currentModel, settingsManager, modelRegistry, scopedModels, onSelect, onCancel, initialSearchInput, persistDefault = true) {
|
|
39
44
|
super();
|
|
@@ -43,251 +48,274 @@ export class ModelSelectorComponent extends Container {
|
|
|
43
48
|
this.modelRegistry = modelRegistry;
|
|
44
49
|
this.scopedModels = scopedModels;
|
|
45
50
|
this.scope = scopedModels.length > 0 ? "scoped" : "all";
|
|
51
|
+
this.view = initialSearchInput ? "search" : "providers";
|
|
46
52
|
this.onSelectCallback = onSelect;
|
|
47
53
|
this.onCancelCallback = onCancel;
|
|
48
54
|
this.persistDefault = persistDefault;
|
|
49
|
-
// Add top border
|
|
50
55
|
this.addChild(new DynamicBorder());
|
|
51
56
|
this.addChild(new Spacer(1));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.scopeHintText = new Text(this.getScopeHintText(), 0, 0);
|
|
57
|
-
this.addChild(this.scopeHintText);
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
const hintText = "Only showing models from configured providers. Use /login to add providers.";
|
|
61
|
-
this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
|
|
62
|
-
}
|
|
57
|
+
this.scopeText = new Text("", 0, 0);
|
|
58
|
+
this.addChild(this.scopeText);
|
|
59
|
+
this.navigationText = new Text("", 0, 0);
|
|
60
|
+
this.addChild(this.navigationText);
|
|
63
61
|
this.addChild(new Spacer(1));
|
|
64
|
-
// Create search input
|
|
65
62
|
this.searchInput = new Input();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
this.searchInput.onSubmit = () => {
|
|
70
|
-
// Enter on search input selects the first filtered item
|
|
71
|
-
if (this.filteredModels[this.selectedIndex]) {
|
|
72
|
-
this.handleSelect(this.filteredModels[this.selectedIndex].model);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
63
|
+
this.searchInput.setValue(initialSearchInput ?? "");
|
|
64
|
+
this.searchInput.onSubmit = () => this.confirmSelection();
|
|
75
65
|
this.addChild(this.searchInput);
|
|
76
66
|
this.addChild(new Spacer(1));
|
|
77
|
-
// Create list container
|
|
78
67
|
this.listContainer = new Container();
|
|
79
68
|
this.addChild(this.listContainer);
|
|
80
69
|
this.addChild(new Spacer(1));
|
|
81
|
-
// Add bottom border
|
|
82
70
|
this.addChild(new DynamicBorder());
|
|
83
|
-
|
|
84
|
-
this.loadModels()
|
|
85
|
-
if (initialSearchInput) {
|
|
86
|
-
this.filterModels(initialSearchInput);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
this.updateList();
|
|
90
|
-
}
|
|
91
|
-
// Request re-render after models are loaded
|
|
92
|
-
this.tui.requestRender();
|
|
93
|
-
});
|
|
71
|
+
this.updateList();
|
|
72
|
+
void this.loadModels();
|
|
94
73
|
}
|
|
95
74
|
async loadModels() {
|
|
96
|
-
let models;
|
|
97
|
-
// Refresh to pick up any changes to models.json
|
|
98
|
-
this.modelRegistry.refresh();
|
|
99
|
-
// Check for models.json errors
|
|
100
|
-
const loadError = this.modelRegistry.getError();
|
|
101
|
-
if (loadError) {
|
|
102
|
-
this.errorMessage = loadError;
|
|
103
|
-
}
|
|
104
|
-
// Load available models (built-in models still work even if models.json failed)
|
|
105
75
|
try {
|
|
106
|
-
|
|
107
|
-
|
|
76
|
+
// Refresh without introducing provider/network validation.
|
|
77
|
+
this.modelRegistry.refresh();
|
|
78
|
+
this.errorMessage = this.modelRegistry.getError();
|
|
79
|
+
const available = await this.modelRegistry.getAvailable();
|
|
80
|
+
this.allModels = available.map((model) => ({ provider: model.provider, id: model.id, model }));
|
|
81
|
+
this.allModels.sort((a, b) => {
|
|
82
|
+
const aCurrent = modelsAreEqual(this.currentModel, a.model);
|
|
83
|
+
const bCurrent = modelsAreEqual(this.currentModel, b.model);
|
|
84
|
+
if (aCurrent !== bCurrent)
|
|
85
|
+
return aCurrent ? -1 : 1;
|
|
86
|
+
return a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
|
|
87
|
+
});
|
|
88
|
+
const registered = new Map();
|
|
89
|
+
for (const model of this.modelRegistry.getAll()) {
|
|
90
|
+
let models = registered.get(model.provider);
|
|
91
|
+
if (!models) {
|
|
92
|
+
models = new Map();
|
|
93
|
+
registered.set(model.provider, models);
|
|
94
|
+
}
|
|
95
|
+
if (!models.has(model.id))
|
|
96
|
+
models.set(model.id, model);
|
|
97
|
+
}
|
|
98
|
+
// Keep scoped order, reconciling stale instances after registry refresh.
|
|
99
|
+
this.scopedModelItems = this.scopedModels.map(({ model }) => ({
|
|
108
100
|
provider: model.provider,
|
|
109
101
|
id: model.id,
|
|
110
|
-
model,
|
|
102
|
+
model: registered.get(model.provider)?.get(model.id) ?? model,
|
|
111
103
|
}));
|
|
112
104
|
}
|
|
113
105
|
catch (error) {
|
|
114
106
|
this.allModels = [];
|
|
115
107
|
this.scopedModelItems = [];
|
|
116
|
-
this.activeModels = [];
|
|
117
|
-
this.filteredModels = [];
|
|
118
108
|
this.errorMessage = error instanceof Error ? error.message : String(error);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
this.allModels = this.sortModels(models);
|
|
122
|
-
const registeredModels = new Map();
|
|
123
|
-
for (const model of this.modelRegistry.getAll()) {
|
|
124
|
-
const providerModels = registeredModels.get(model.provider) ?? new Map();
|
|
125
|
-
if (!providerModels.has(model.id))
|
|
126
|
-
providerModels.set(model.id, model);
|
|
127
|
-
registeredModels.set(model.provider, providerModels);
|
|
128
109
|
}
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.
|
|
134
|
-
provider: scoped.model.provider,
|
|
135
|
-
id: scoped.model.id,
|
|
136
|
-
model: scoped.model,
|
|
137
|
-
}));
|
|
138
|
-
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
|
|
139
|
-
this.filteredModels = this.activeModels;
|
|
140
|
-
const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
|
|
141
|
-
this.selectedIndex =
|
|
142
|
-
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
|
143
|
-
}
|
|
144
|
-
sortModels(models) {
|
|
145
|
-
const sorted = [...models];
|
|
146
|
-
// Sort: current model first, then by provider
|
|
147
|
-
sorted.sort((a, b) => {
|
|
148
|
-
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
|
|
149
|
-
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
|
|
150
|
-
if (aIsCurrent && !bIsCurrent)
|
|
151
|
-
return -1;
|
|
152
|
-
if (!aIsCurrent && bIsCurrent)
|
|
153
|
-
return 1;
|
|
154
|
-
return a.provider.localeCompare(b.provider);
|
|
155
|
-
});
|
|
156
|
-
return sorted;
|
|
110
|
+
this.loading = false;
|
|
111
|
+
this.rebuildProviders();
|
|
112
|
+
this.selectCurrent();
|
|
113
|
+
this.filterItems(this.searchInput.getValue());
|
|
114
|
+
this.tui.requestRender();
|
|
157
115
|
}
|
|
158
|
-
|
|
159
|
-
const allText = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all");
|
|
160
|
-
const scopedText = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped");
|
|
161
|
-
return `${theme.fg("muted", "Scope: ")}${allText}${theme.fg("muted", " | ")}${scopedText}`;
|
|
162
|
-
}
|
|
163
|
-
getScopeHintText() {
|
|
164
|
-
return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)");
|
|
165
|
-
}
|
|
166
|
-
setScope(scope) {
|
|
167
|
-
if (this.scope === scope)
|
|
168
|
-
return;
|
|
169
|
-
this.scope = scope;
|
|
116
|
+
rebuildProviders() {
|
|
170
117
|
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
118
|
+
const groups = new Map();
|
|
119
|
+
for (const item of this.activeModels) {
|
|
120
|
+
let provider = groups.get(item.provider);
|
|
121
|
+
if (!provider) {
|
|
122
|
+
provider = {
|
|
123
|
+
id: item.provider,
|
|
124
|
+
name: this.modelRegistry.getProviderDisplayName(item.provider),
|
|
125
|
+
models: [],
|
|
126
|
+
};
|
|
127
|
+
groups.set(item.provider, provider);
|
|
128
|
+
}
|
|
129
|
+
provider.models.push(item);
|
|
176
130
|
}
|
|
131
|
+
this.providers = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
177
132
|
}
|
|
178
|
-
|
|
179
|
-
this.
|
|
180
|
-
?
|
|
181
|
-
: this.
|
|
182
|
-
|
|
183
|
-
// is cleared, preserve the current position and only clamp it to the restored list.
|
|
184
|
-
this.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
|
185
|
-
this.updateList();
|
|
133
|
+
selectCurrent() {
|
|
134
|
+
const index = this.view === "providers"
|
|
135
|
+
? this.providers.findIndex((item) => item.id === this.currentModel?.provider)
|
|
136
|
+
: this.getModelItems().findIndex((item) => modelsAreEqual(this.currentModel, item.model));
|
|
137
|
+
this.selectedIndex = Math.max(0, index);
|
|
186
138
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
139
|
+
getModelItems() {
|
|
140
|
+
return this.view === "models" ? (this.selectedProvider?.models ?? []) : this.activeModels;
|
|
141
|
+
}
|
|
142
|
+
filterItems(query) {
|
|
143
|
+
if (this.view === "providers") {
|
|
144
|
+
this.filteredProviders = query
|
|
145
|
+
? fuzzyFilter(this.providers, query, ({ id, name }) => `${name} ${id}`)
|
|
146
|
+
: this.providers;
|
|
193
147
|
}
|
|
194
|
-
|
|
195
|
-
this.
|
|
196
|
-
|
|
148
|
+
else {
|
|
149
|
+
const items = this.getModelItems();
|
|
150
|
+
this.filteredModels = query
|
|
151
|
+
? fuzzyFilter(items, query, ({ id, provider, model }) => `${id} ${provider} ${provider}/${id} ${model.name}`)
|
|
152
|
+
: items;
|
|
197
153
|
}
|
|
198
|
-
|
|
199
|
-
this.
|
|
200
|
-
this.
|
|
154
|
+
// Query edits select the best match; clearing only clamps the position (7209).
|
|
155
|
+
this.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.itemCount() - 1));
|
|
156
|
+
this.updateList();
|
|
157
|
+
}
|
|
158
|
+
itemCount() {
|
|
159
|
+
return this.view === "providers" ? this.filteredProviders.length : this.filteredModels.length;
|
|
160
|
+
}
|
|
161
|
+
updateHeader() {
|
|
162
|
+
const all = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all");
|
|
163
|
+
const scoped = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped");
|
|
164
|
+
const scopeHint = this.scopedModels.length > 0 ? ` · ${keyHint("tui.input.tab", "scope")} (${all}/${scoped})` : "";
|
|
165
|
+
const title = this.view === "providers"
|
|
166
|
+
? "Providers"
|
|
167
|
+
: this.view === "search"
|
|
168
|
+
? "Search all providers"
|
|
169
|
+
: `${this.selectedProvider?.name} [${this.selectedProvider?.id}]`;
|
|
170
|
+
this.scopeText.setText(theme.fg("muted", title) + scopeHint);
|
|
171
|
+
this.navigationText.setText(keyHint("tui.select.up", "up") +
|
|
172
|
+
" · " +
|
|
173
|
+
keyHint("tui.select.down", "down") +
|
|
174
|
+
" · " +
|
|
175
|
+
keyHint("tui.select.confirm", this.view === "providers" ? "models" : "select") +
|
|
176
|
+
" · " +
|
|
177
|
+
keyHint("tui.select.cancel", this.view === "models" ? "providers" : "cancel"));
|
|
201
178
|
}
|
|
202
179
|
updateList() {
|
|
180
|
+
this.updateHeader();
|
|
203
181
|
this.listContainer.clear();
|
|
182
|
+
const count = this.itemCount();
|
|
204
183
|
const maxVisible = 10;
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
let line = "";
|
|
215
|
-
if (isSelected) {
|
|
216
|
-
const prefix = theme.fg("accent", "→ ");
|
|
217
|
-
const modelText = `${item.id}`;
|
|
218
|
-
const providerBadge = theme.fg("muted", `[${item.provider}]`);
|
|
219
|
-
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
|
220
|
-
line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`;
|
|
184
|
+
const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
|
|
185
|
+
const end = Math.min(start + maxVisible, count);
|
|
186
|
+
for (let i = start; i < end; i++) {
|
|
187
|
+
let label;
|
|
188
|
+
let current;
|
|
189
|
+
if (this.view === "providers") {
|
|
190
|
+
const item = this.filteredProviders[i];
|
|
191
|
+
label = `${item.name} [${item.id}] (${item.models.length} models)`;
|
|
192
|
+
current = item.id === this.currentModel?.provider;
|
|
221
193
|
}
|
|
222
194
|
else {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
line = `${modelText} ${providerBadge}${checkmark}`;
|
|
195
|
+
const item = this.filteredModels[i];
|
|
196
|
+
label = `${item.id} [${item.provider}]`;
|
|
197
|
+
current = modelsAreEqual(this.currentModel, item.model);
|
|
227
198
|
}
|
|
228
|
-
|
|
199
|
+
const selected = i === this.selectedIndex;
|
|
200
|
+
const prefix = selected ? theme.fg("accent", "→ ") : " ";
|
|
201
|
+
const marker = current ? theme.fg("success", " ✓") : "";
|
|
202
|
+
this.addListLine(prefix + (selected ? theme.fg("accent", label) : label) + marker);
|
|
203
|
+
}
|
|
204
|
+
if (start > 0 || end < count)
|
|
205
|
+
this.addListLine(theme.fg("muted", ` (${this.selectedIndex + 1}/${count})`));
|
|
206
|
+
this.appendListStatus(count);
|
|
207
|
+
}
|
|
208
|
+
appendListStatus(count) {
|
|
209
|
+
if (this.loading) {
|
|
210
|
+
this.addListLine(theme.fg("muted", " Loading models…"));
|
|
211
|
+
}
|
|
212
|
+
else if (count === 0) {
|
|
213
|
+
const empty = this.view === "providers" ? "No matching providers. Use /login to add providers." : "No matching models";
|
|
214
|
+
this.addListLine(theme.fg("muted", ` ${empty}`));
|
|
215
|
+
}
|
|
216
|
+
else if (this.view !== "providers") {
|
|
217
|
+
this.listContainer.addChild(new Spacer(1));
|
|
218
|
+
this.addListLine(theme.fg("muted", ` Model Name: ${this.filteredModels[this.selectedIndex].model.name}`));
|
|
219
|
+
}
|
|
220
|
+
if (this.errorMessage) {
|
|
221
|
+
for (const line of this.errorMessage.split("\n"))
|
|
222
|
+
this.addListLine(theme.fg("error", line));
|
|
229
223
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
}
|
|
225
|
+
addListLine(line) {
|
|
226
|
+
this.listContainer.addChild(new Text(truncateToWidth(line, this.listWidth), 0, 0));
|
|
227
|
+
}
|
|
228
|
+
render(width) {
|
|
229
|
+
if (this.listWidth !== width) {
|
|
230
|
+
this.listWidth = width;
|
|
231
|
+
this.updateList();
|
|
234
232
|
}
|
|
235
|
-
|
|
236
|
-
this.appendModelListStatus();
|
|
233
|
+
return super.render(width);
|
|
237
234
|
}
|
|
238
235
|
toggleModelScope() {
|
|
239
|
-
if (this.
|
|
236
|
+
if (this.scopedModels.length === 0)
|
|
240
237
|
return;
|
|
241
|
-
|
|
242
|
-
this.
|
|
243
|
-
|
|
238
|
+
this.scope = this.scope === "all" ? "scoped" : "all";
|
|
239
|
+
if (this.view !== "search") {
|
|
240
|
+
this.view = "providers";
|
|
241
|
+
this.selectedProvider = null;
|
|
242
|
+
this.providerQuery = "";
|
|
243
|
+
this.providerIndex = 0;
|
|
244
|
+
this.searchInput.setValue("");
|
|
245
|
+
}
|
|
246
|
+
this.rebuildProviders();
|
|
247
|
+
this.selectCurrent();
|
|
248
|
+
this.filterItems(this.searchInput.getValue());
|
|
244
249
|
}
|
|
245
|
-
moveSelection(
|
|
246
|
-
|
|
250
|
+
moveSelection(delta) {
|
|
251
|
+
const count = this.itemCount();
|
|
252
|
+
if (count === 0)
|
|
247
253
|
return;
|
|
248
|
-
|
|
249
|
-
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
|
|
250
|
-
}
|
|
251
|
-
else {
|
|
252
|
-
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
|
|
253
|
-
}
|
|
254
|
+
this.selectedIndex = (this.selectedIndex + delta + count) % count;
|
|
254
255
|
this.updateList();
|
|
255
256
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
confirmSelection() {
|
|
258
|
+
if (this.loading)
|
|
259
|
+
return;
|
|
260
|
+
if (this.view === "providers") {
|
|
261
|
+
const provider = this.filteredProviders[this.selectedIndex];
|
|
262
|
+
if (!provider)
|
|
263
|
+
return;
|
|
264
|
+
this.providerQuery = this.searchInput.getValue();
|
|
265
|
+
this.providerIndex = this.selectedIndex;
|
|
266
|
+
this.selectedProvider = provider;
|
|
267
|
+
this.view = "models";
|
|
268
|
+
this.searchInput.setValue("");
|
|
269
|
+
this.selectCurrent();
|
|
270
|
+
this.filterItems("");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const item = this.filteredModels[this.selectedIndex];
|
|
274
|
+
if (!item)
|
|
275
|
+
return;
|
|
276
|
+
if (this.persistDefault)
|
|
277
|
+
this.settingsManager.setDefaultModelAndProvider(item.provider, item.id);
|
|
278
|
+
this.onSelectCallback(item.model);
|
|
260
279
|
}
|
|
261
280
|
handleInput(keyData) {
|
|
262
281
|
const kb = getKeybindings();
|
|
263
282
|
if (kb.matches(keyData, "tui.input.tab")) {
|
|
264
283
|
this.toggleModelScope();
|
|
265
|
-
return;
|
|
266
284
|
}
|
|
267
|
-
if (kb.matches(keyData, "tui.select.up")) {
|
|
285
|
+
else if (kb.matches(keyData, "tui.select.up")) {
|
|
268
286
|
this.moveSelection(-1);
|
|
269
|
-
return;
|
|
270
287
|
}
|
|
271
|
-
if (kb.matches(keyData, "tui.select.down")) {
|
|
288
|
+
else if (kb.matches(keyData, "tui.select.down")) {
|
|
272
289
|
this.moveSelection(1);
|
|
273
|
-
return;
|
|
274
290
|
}
|
|
275
|
-
if (kb.matches(keyData, "tui.select.
|
|
276
|
-
this.
|
|
277
|
-
return;
|
|
291
|
+
else if (kb.matches(keyData, "tui.select.pageUp")) {
|
|
292
|
+
this.moveSelection(-Math.min(10, Math.max(0, this.itemCount() - 1)));
|
|
278
293
|
}
|
|
279
|
-
if (kb.matches(keyData, "tui.select.
|
|
280
|
-
this.
|
|
281
|
-
|
|
294
|
+
else if (kb.matches(keyData, "tui.select.pageDown")) {
|
|
295
|
+
this.moveSelection(Math.min(10, Math.max(0, this.itemCount() - 1)));
|
|
296
|
+
}
|
|
297
|
+
else if (kb.matches(keyData, "tui.select.confirm")) {
|
|
298
|
+
this.confirmSelection();
|
|
299
|
+
}
|
|
300
|
+
else if (kb.matches(keyData, "tui.select.cancel")) {
|
|
301
|
+
if (this.view === "models") {
|
|
302
|
+
this.view = "providers";
|
|
303
|
+
this.selectedProvider = null;
|
|
304
|
+
this.searchInput.setValue(this.providerQuery);
|
|
305
|
+
this.filterItems(this.providerQuery);
|
|
306
|
+
this.selectedIndex = Math.min(this.providerIndex, Math.max(0, this.itemCount() - 1));
|
|
307
|
+
this.updateList();
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
this.onCancelCallback();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const query = this.searchInput.getValue();
|
|
315
|
+
this.searchInput.handleInput(keyData);
|
|
316
|
+
if (query !== this.searchInput.getValue())
|
|
317
|
+
this.filterItems(this.searchInput.getValue());
|
|
282
318
|
}
|
|
283
|
-
this.searchInput.handleInput(keyData);
|
|
284
|
-
this.filterModels(this.searchInput.getValue());
|
|
285
|
-
}
|
|
286
|
-
handleSelect(model) {
|
|
287
|
-
// Save as new default
|
|
288
|
-
if (this.persistDefault)
|
|
289
|
-
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
|
290
|
-
this.onSelectCallback(model);
|
|
291
319
|
}
|
|
292
320
|
getSearchInput() {
|
|
293
321
|
return this.searchInput;
|