@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/core/auth-storage.d.ts.map +1 -1
  4. package/dist/core/auth-storage.js +3 -1
  5. package/dist/core/auth-storage.js.map +1 -1
  6. package/dist/core/grip-agent.d.ts.map +1 -1
  7. package/dist/core/grip-agent.js +4 -2
  8. package/dist/core/grip-agent.js.map +1 -1
  9. package/dist/core/sandbox/lima.d.ts.map +1 -1
  10. package/dist/core/sandbox/lima.js +6 -6
  11. package/dist/core/sandbox/lima.js.map +1 -1
  12. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  13. package/dist/modes/interactive/components/assistant-message.js +18 -7
  14. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  15. package/dist/modes/interactive/components/model-selector.d.ts +26 -18
  16. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  17. package/dist/modes/interactive/components/model-selector.js +220 -192
  18. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  19. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  20. package/dist/modes/interactive/interactive-mode.js +68 -6
  21. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  22. package/dist/utils/grip-user-agent.d.ts.map +1 -1
  23. package/dist/utils/grip-user-agent.js +1 -1
  24. package/dist/utils/grip-user-agent.js.map +1 -1
  25. package/docs/keybindings.md +3 -1
  26. package/docs/providers.md +3 -1
  27. package/docs/sandbox.md +1 -1
  28. package/docs/usage.md +37 -1
  29. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  30. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  31. package/examples/extensions/sandbox/package.json +1 -1
  32. package/examples/extensions/with-deps/package.json +1 -1
  33. package/npm-shrinkwrap.json +12 -12
  34. 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 = "all";
39
+ scope;
35
40
  scopeText;
36
- scopeHintText;
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
- // Add hint about model filtering
53
- if (scopedModels.length > 0) {
54
- this.scopeText = new Text(this.getScopeText(), 0, 0);
55
- this.addChild(this.scopeText);
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
- if (initialSearchInput) {
67
- this.searchInput.setValue(initialSearchInput);
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
- // Load models and do initial render
84
- this.loadModels().then(() => {
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
- const availableModels = await this.modelRegistry.getAvailable();
107
- models = availableModels.map((model) => ({
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.scopedModels = this.scopedModels.map((scoped) => {
130
- const refreshed = registeredModels.get(scoped.model.provider)?.get(scoped.model.id);
131
- return refreshed ? { ...scoped, model: refreshed } : scoped;
132
- });
133
- this.scopedModelItems = this.scopedModels.map((scoped) => ({
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
- getScopeText() {
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 currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
172
- this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
173
- this.filterModels(this.searchInput.getValue());
174
- if (this.scopeText) {
175
- this.scopeText.setText(this.getScopeText());
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
- filterModels(query) {
179
- this.filteredModels = query
180
- ? fuzzyFilter(this.activeModels, query, ({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`)
181
- : this.activeModels;
182
- // A query changes fuzzy-match ranking, so select the best match. When the query
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
- appendModelListStatus() {
188
- if (this.errorMessage) {
189
- for (const line of this.errorMessage.split("\n")) {
190
- this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
191
- }
192
- return;
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
- if (this.filteredModels.length === 0) {
195
- this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
196
- return;
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
- const selected = this.filteredModels[this.selectedIndex];
199
- this.listContainer.addChild(new Spacer(1));
200
- this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
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 startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible));
206
- const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
207
- // Show visible slice of filtered models
208
- for (let i = startIndex; i < endIndex; i++) {
209
- const item = this.filteredModels[i];
210
- if (!item)
211
- continue;
212
- const isSelected = i === this.selectedIndex;
213
- const isCurrent = modelsAreEqual(this.currentModel, item.model);
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 modelText = ` ${item.id}`;
224
- const providerBadge = theme.fg("muted", `[${item.provider}]`);
225
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
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
- this.listContainer.addChild(new Text(line, 0, 0));
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
- // Add scroll indicator if needed
231
- if (startIndex > 0 || endIndex < this.filteredModels.length) {
232
- const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);
233
- this.listContainer.addChild(new Text(scrollInfo, 0, 0));
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
- // Show error message or "no results" if empty
236
- this.appendModelListStatus();
233
+ return super.render(width);
237
234
  }
238
235
  toggleModelScope() {
239
- if (this.scopedModelItems.length === 0)
236
+ if (this.scopedModels.length === 0)
240
237
  return;
241
- const nextScope = this.scope === "all" ? "scoped" : "all";
242
- this.setScope(nextScope);
243
- this.scopeHintText?.setText(this.getScopeHintText());
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(direction) {
246
- if (this.filteredModels.length === 0)
250
+ moveSelection(delta) {
251
+ const count = this.itemCount();
252
+ if (count === 0)
247
253
  return;
248
- if (direction < 0) {
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
- selectHighlightedModel() {
257
- const selectedModel = this.filteredModels[this.selectedIndex];
258
- if (selectedModel)
259
- this.handleSelect(selectedModel.model);
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.confirm")) {
276
- this.selectHighlightedModel();
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.cancel")) {
280
- this.onCancelCallback();
281
- return;
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;