124 ImGuiStyle& style = ImGui::GetStyle();
127 static bool filter_by_provider =
false;
129 if (model_cache.last_provider != config.ai_provider ||
130 model_cache.last_openai_base != config.openai_base_url ||
131 model_cache.last_ollama_host != config.ollama_host) {
133 model_cache.last_provider = config.ai_provider;
134 model_cache.last_openai_base = config.openai_base_url;
135 model_cache.last_ollama_host = config.ollama_host;
139 !model_cache.auto_refresh_requested) {
140 model_cache.auto_refresh_requested =
true;
143 const float label_width = 120.0f;
144 const ImVec2 compact_padding(style.FramePadding.x,
145 std::max(2.0f, style.FramePadding.y * 0.6f));
146 const ImVec2 compact_spacing(style.ItemSpacing.x,
147 std::max(3.0f, style.ItemSpacing.y * 0.6f));
148 const float env_button_width =
149 ImGui::CalcTextSize(
ICON_MD_SYNC " Env").x + compact_padding.x * 2.0f;
151 auto set_openai_base = [&](
const std::string& base_url) {
152 std::snprintf(config.openai_base_url_buffer,
153 sizeof(config.openai_base_url_buffer),
"%s",
155 config.openai_base_url = config.openai_base_url_buffer;
159 auto provider_button = [&](
const char* label,
const char* value,
160 const ImVec4& color) {
161 bool active = config.ai_provider == value;
162 ImGui::TableNextColumn();
163 std::optional<gui::StyleColorGuard> btn_guard;
165 btn_guard.emplace(std::initializer_list<gui::StyleColorGuard::Entry>{
166 {ImGuiCol_Button, color},
167 {ImGuiCol_ButtonHovered,
168 ImVec4(color.x * 1.15f, color.y * 1.15f, color.z * 1.15f,
171 if (ImGui::Button(label, ImVec2(-FLT_MIN, 28))) {
172 config.ai_provider = value;
173 std::snprintf(config.provider_buffer,
sizeof(config.provider_buffer),
180 {{ImGuiStyleVar_FramePadding, compact_padding},
181 {ImGuiStyleVar_ItemSpacing, compact_spacing}});
182 if (ImGui::BeginTable(
"AgentProviderConfigTable", 2,
183 ImGuiTableFlags_SizingFixedFit |
184 ImGuiTableFlags_BordersInnerV)) {
185 ImGui::TableSetupColumn(
"Label", ImGuiTableColumnFlags_WidthFixed,
187 ImGui::TableSetupColumn(
"Value", ImGuiTableColumnFlags_WidthStretch);
189 ImGui::TableNextRow();
190 ImGui::TableSetColumnIndex(0);
191 ImGui::TextDisabled(
"Provider");
192 ImGui::TableSetColumnIndex(1);
193 float provider_width = ImGui::GetContentRegionAvail().x;
194 int provider_columns = provider_width > 560.0f ? 3
195 : provider_width > 360.0f ? 2
197 if (ImGui::BeginTable(
"AgentProviderButtons", provider_columns,
198 ImGuiTableFlags_SizingStretchSame)) {
200 provider_button(
ICON_MD_CLOUD " Ollama",
"ollama", theme.provider_ollama);
202 theme.provider_gemini);
204 theme.provider_openai);
206 theme.provider_openai);
210 ImGui::TableNextRow();
211 ImGui::TableSetColumnIndex(0);
212 ImGui::TextDisabled(
"Ollama Host");
213 ImGui::TableSetColumnIndex(1);
214 ImGui::SetNextItemWidth(-1);
215 if (ImGui::InputTextWithHint(
"##ollama_host",
"http://localhost:11434",
216 config.ollama_host_buffer,
217 IM_ARRAYSIZE(config.ollama_host_buffer))) {
218 config.ollama_host = config.ollama_host_buffer;
221 auto key_row = [&](
const char* label,
const char* hint,
char* buffer,
222 size_t buffer_len, std::string* target,
223 const char* env_var,
const char* input_id,
224 const char* button_id) {
225 ImGui::TableNextRow();
226 ImGui::TableSetColumnIndex(0);
227 ImGui::TextDisabled(
"%s", label);
228 ImGui::TableSetColumnIndex(1);
230 ImGui::GetContentRegionAvail().x - env_button_width -
232 bool stack = input_width < 140.0f;
233 ImGui::SetNextItemWidth(stack ? -1 : input_width);
234 if (ImGui::InputTextWithHint(input_id, hint, buffer, buffer_len,
235 ImGuiInputTextFlags_Password)) {
243 if (ImGui::SmallButton(button_id)) {
244 const char* env_key = std::getenv(env_var);
246 std::snprintf(buffer, buffer_len,
"%s", env_key);
252 absl::StrFormat(
"Loaded %s from environment", env_var),
255 }
else if (toast_manager) {
263 key_row(
"Gemini Key",
"API key...", config.gemini_key_buffer,
264 IM_ARRAYSIZE(config.gemini_key_buffer), &config.gemini_api_key,
265 "GEMINI_API_KEY",
"##gemini_key",
267 key_row(
"Anthropic Key",
"API key...", config.anthropic_key_buffer,
268 IM_ARRAYSIZE(config.anthropic_key_buffer),
269 &config.anthropic_api_key,
"ANTHROPIC_API_KEY",
"##anthropic_key",
271 key_row(
"OpenAI Key",
"API key...", config.openai_key_buffer,
272 IM_ARRAYSIZE(config.openai_key_buffer), &config.openai_api_key,
273 "OPENAI_API_KEY",
"##openai_key",
ICON_MD_SYNC " Env##openai");
275 ImGui::TableNextRow();
276 ImGui::TableSetColumnIndex(0);
277 ImGui::TextDisabled(
"OpenAI Base");
278 ImGui::TableSetColumnIndex(1);
279 float openai_button_width =
280 ImGui::CalcTextSize(
"OpenAI").x + compact_padding.x * 2.0f;
281 float lm_button_width =
282 ImGui::CalcTextSize(
"LM Studio").x + compact_padding.x * 2.0f;
283 float reset_button_width =
284 ImGui::CalcTextSize(
"Reset").x + compact_padding.x * 2.0f;
285 float total_buttons =
286 openai_button_width + lm_button_width + reset_button_width +
287 style.ItemSpacing.x * 2.0f;
288 float base_available = ImGui::GetContentRegionAvail().x;
289 bool base_stack = base_available < total_buttons + 160.0f;
290 ImGui::SetNextItemWidth(
291 base_stack ? -1 : base_available - total_buttons - style.ItemSpacing.x);
292 if (ImGui::InputTextWithHint(
"##openai_base",
"http://localhost:1234",
293 config.openai_base_url_buffer,
294 IM_ARRAYSIZE(config.openai_base_url_buffer))) {
295 config.openai_base_url = config.openai_base_url_buffer;
302 if (ImGui::SmallButton(
"OpenAI")) {
303 set_openai_base(
"https://api.openai.com");
306 if (ImGui::SmallButton(
"LM Studio")) {
307 set_openai_base(
"http://localhost:1234");
310 if (ImGui::SmallButton(
"Reset")) {
311 set_openai_base(
"https://api.openai.com");
318 if (IsLocalEndpoint(config.openai_base_url)) {
319 ImGui::TextColored(theme.status_success,
322 ImGui::TextColored(theme.text_secondary_color,
330 {{ImGuiStyleVar_FramePadding, compact_padding},
331 {ImGuiStyleVar_ItemSpacing, compact_spacing}});
332 if (ImGui::BeginTable(
"AgentModelControls", 2,
333 ImGuiTableFlags_SizingFixedFit |
334 ImGuiTableFlags_BordersInnerV)) {
335 ImGui::TableSetupColumn(
"Label", ImGuiTableColumnFlags_WidthFixed,
337 ImGui::TableSetupColumn(
"Value", ImGuiTableColumnFlags_WidthStretch);
339 ImGui::TableNextRow();
340 ImGui::TableSetColumnIndex(0);
341 ImGui::TextDisabled(
"Model");
342 ImGui::TableSetColumnIndex(1);
343 ImGui::SetNextItemWidth(-1);
344 if (ImGui::InputTextWithHint(
"##ai_model",
"Model name...",
346 IM_ARRAYSIZE(config.model_buffer))) {
347 config.ai_model = config.model_buffer;
350 ImGui::TableNextRow();
351 ImGui::TableSetColumnIndex(0);
352 ImGui::TextDisabled(
"Filter");
353 ImGui::TableSetColumnIndex(1);
354 ImGui::Checkbox(
"Provider", &filter_by_provider);
359 float refresh_width =
362 ImGui::CalcTextSize(
ICON_MD_CLEAR).x + compact_padding.x * 2.0f;
363 float search_width = ImGui::GetContentRegionAvail().x - refresh_width -
365 if (model_cache.search_buffer[0] !=
'\0') {
366 search_width -= clear_width + style.ItemSpacing.x;
368 ImGui::SetNextItemWidth(search_width);
369 ImGui::InputTextWithHint(
"##model_search",
"Search models...",
370 model_cache.search_buffer,
371 IM_ARRAYSIZE(model_cache.search_buffer));
373 if (model_cache.search_buffer[0] !=
'\0') {
375 model_cache.search_buffer[0] =
'\0';
377 if (ImGui::IsItemHovered()) {
378 ImGui::SetTooltip(
"Clear search");
382 if (ImGui::SmallButton(model_cache.loading ?
ICON_MD_SYNC
392 if (!model_cache.available_models.empty() ||
393 !model_cache.local_model_names.empty()) {
394 const int provider_count =
395 static_cast<int>(model_cache.available_models.size());
396 const int local_count =
397 static_cast<int>(model_cache.local_model_names.size());
398 if (provider_count > 0 && local_count > 0) {
399 ImGui::TextDisabled(
"Models: %d provider, %d local", provider_count,
401 }
else if (provider_count > 0) {
402 ImGui::TextDisabled(
"Models: %d provider", provider_count);
403 }
else if (local_count > 0) {
404 ImGui::TextDisabled(
"Models: %d local files", local_count);
409 std::max(220.0f, ImGui::GetContentRegionAvail().y * 0.6f);
411 ImGui::BeginChild(
"UnifiedModelList", ImVec2(0, list_height),
true);
412 std::string filter = absl::AsciiStrToLower(model_cache.search_buffer);
416 std::string provider;
417 std::string param_size;
418 std::string quantization;
420 uint64_t size_bytes = 0;
421 bool is_local =
false;
422 bool is_file =
false;
425 std::vector<ModelRow> rows;
426 if (!model_cache.available_models.empty()) {
427 rows.reserve(model_cache.available_models.size());
428 for (
const auto& info : model_cache.available_models) {
430 row.name = info.name;
431 row.provider = info.provider;
432 row.param_size = info.parameter_size;
433 row.quantization = info.quantization;
434 row.family = info.family;
435 row.size_bytes = info.size_bytes;
436 row.is_local = info.is_local;
437 rows.push_back(std::move(row));
440 rows.reserve(model_cache.model_names.size());
441 for (
const auto& model_name : model_cache.model_names) {
443 row.name = model_name;
444 row.provider = config.ai_provider;
445 rows.push_back(std::move(row));
449 if (!filter_by_provider && !model_cache.local_model_names.empty()) {
450 for (
const auto& model_name : model_cache.local_model_names) {
452 row.name = model_name;
453 row.provider =
"local";
456 rows.push_back(std::move(row));
460 auto get_provider_color = [&theme](
const std::string& provider) -> ImVec4 {
461 if (provider ==
"ollama")
462 return theme.provider_ollama;
463 if (provider ==
"gemini")
464 return theme.provider_gemini;
465 if (provider ==
"anthropic")
466 return theme.provider_openai;
467 if (provider ==
"openai")
468 return theme.provider_openai;
469 return theme.provider_mock;
472 auto matches_filter = [&](
const ModelRow& row) {
473 if (filter.empty()) {
476 std::string lower_name = absl::AsciiStrToLower(row.name);
477 std::string lower_provider = absl::AsciiStrToLower(row.provider);
478 if (ContainsText(lower_name, filter) ||
479 ContainsText(lower_provider, filter)) {
482 if (!row.param_size.empty() &&
483 ContainsText(absl::AsciiStrToLower(row.param_size), filter)) {
486 if (!row.family.empty() &&
487 ContainsText(absl::AsciiStrToLower(row.family), filter)) {
490 if (!row.quantization.empty() &&
491 ContainsText(absl::AsciiStrToLower(row.quantization), filter)) {
498 ImGui::TextDisabled(
"No cached models. Refresh to discover.");
500 float list_width = ImGui::GetContentRegionAvail().x;
501 bool compact = list_width < 520.0f;
502 int column_count = compact ? 3 : 5;
503 ImGuiTableFlags table_flags =
504 ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH |
505 ImGuiTableFlags_SizingStretchProp;
506 if (ImGui::BeginTable(
"ModelTable", column_count, table_flags)) {
508 ImGui::TableSetupColumn(
"Provider", ImGuiTableColumnFlags_WidthFixed,
510 ImGui::TableSetupColumn(
"Model", ImGuiTableColumnFlags_WidthStretch);
511 ImGui::TableSetupColumn(
"Actions", ImGuiTableColumnFlags_WidthFixed,
514 ImGui::TableSetupColumn(
"Provider", ImGuiTableColumnFlags_WidthFixed,
516 ImGui::TableSetupColumn(
"Model", ImGuiTableColumnFlags_WidthStretch);
517 ImGui::TableSetupColumn(
"Size", ImGuiTableColumnFlags_WidthFixed,
519 ImGui::TableSetupColumn(
"Meta", ImGuiTableColumnFlags_WidthStretch);
520 ImGui::TableSetupColumn(
"Actions", ImGuiTableColumnFlags_WidthFixed,
523 ImGui::TableHeadersRow();
526 for (
const auto& row : rows) {
527 if (filter_by_provider) {
528 if (row.provider ==
"local") {
531 if (!row.provider.empty() && row.provider != config.ai_provider) {
536 if (!matches_filter(row)) {
540 ImGui::PushID(row_id++);
541 ImGui::TableNextRow();
543 ImGui::TableSetColumnIndex(0);
544 if (row.provider.empty()) {
545 ImGui::TextDisabled(
"-");
546 }
else if (row.provider ==
"local") {
549 ImVec4 provider_color = get_provider_color(row.provider);
553 {{ImGuiStyleVar_FrameRounding, 6.0f},
554 {ImGuiStyleVar_FramePadding, ImVec2(4, 1)}});
555 ImGui::SmallButton(row.provider.c_str());
559 ImGui::TableSetColumnIndex(1);
560 bool is_selected = config.ai_model == row.name;
561 if (ImGui::Selectable(row.name.c_str(), is_selected)) {
562 config.ai_model = row.name;
563 std::snprintf(config.model_buffer,
sizeof(config.model_buffer),
"%s",
565 if (!row.provider.empty() && row.provider !=
"local") {
566 config.ai_provider = row.provider;
567 std::snprintf(config.provider_buffer,
568 sizeof(config.provider_buffer),
"%s",
569 row.provider.c_str());
573 if (row.is_file && ImGui::IsItemHovered()) {
575 "Local file detected. Serve this model via LM Studio/Ollama to "
579 std::string size_label = row.param_size;
580 if (size_label.empty() && row.size_bytes > 0) {
581 size_label = FormatByteSize(row.size_bytes);
585 ImGui::TableSetColumnIndex(2);
586 ImGui::TextColored(theme.text_secondary_color,
"%s",
589 ImGui::TableSetColumnIndex(3);
590 if (!row.quantization.empty()) {
591 ImGui::TextColored(theme.text_info,
"%s",
592 row.quantization.c_str());
593 if (!row.family.empty()) {
597 if (!row.family.empty()) {
598 ImGui::TextColored(theme.text_secondary_gray,
"%s",
601 if (row.is_local && !row.is_file) {
605 }
else if (ImGui::IsItemHovered() && (!size_label.empty() ||
606 !row.quantization.empty() ||
607 !row.family.empty())) {
609 if (!size_label.empty()) {
612 if (!row.quantization.empty()) {
616 meta += row.quantization;
618 if (!row.family.empty()) {
624 ImGui::SetTooltip(
"%s", meta.c_str());
627 int action_column = compact ? 2 : 4;
628 ImGui::TableSetColumnIndex(action_column);
632 std::find(config.favorite_models.begin(),
633 config.favorite_models.end(),
634 row.name) != config.favorite_models.end();
637 ImGuiCol_Text, is_favorite ? theme.status_warning
638 : theme.text_secondary_color);
642 config.favorite_models.erase(
643 std::remove(config.favorite_models.begin(),
644 config.favorite_models.end(), row.name),
645 config.favorite_models.end());
646 config.model_chain.erase(
647 std::remove(config.model_chain.begin(),
648 config.model_chain.end(), row.name),
649 config.model_chain.end());
651 config.favorite_models.push_back(row.name);
656 if (ImGui::IsItemHovered()) {
657 ImGui::SetTooltip(is_favorite ?
"Remove from favorites"
661 if (!row.provider.empty() && row.provider !=
"local") {
665 preset.
name = row.name;
666 preset.
model = row.name;
668 if (row.provider ==
"ollama") {
669 preset.
host = config.ollama_host;
670 }
else if (row.provider ==
"openai") {
671 preset.
host = config.openai_base_url;
673 preset.
tags = {row.provider};
675 config.model_presets.push_back(std::move(preset));
681 if (ImGui::IsItemHovered()) {
682 ImGui::SetTooltip(
"Capture preset from this model");
693 if (model_cache.last_refresh != absl::InfinitePast()) {
695 absl::ToDoubleSeconds(absl::Now() - model_cache.last_refresh);
696 ImGui::TextDisabled(
"Last refresh %.0fs ago", seconds);
698 ImGui::TextDisabled(
"Models not refreshed yet");
701 if (config.ai_provider ==
"ollama") {
705 if (!config.favorite_models.empty()) {
707 ImGui::TextColored(theme.status_warning,
ICON_MD_STAR " Favorites");
708 for (
size_t i = 0; i < config.favorite_models.size(); ++i) {
709 auto& favorite = config.favorite_models[i];
710 ImGui::PushID(
static_cast<int>(i));
711 bool active = config.ai_model == favorite;
713 std::string provider_name;
714 for (
const auto& info : model_cache.available_models) {
715 if (info.name == favorite) {
716 provider_name = info.provider;
721 if (!provider_name.empty()) {
722 ImVec4 badge_color = theme.provider_mock;
723 if (provider_name ==
"ollama")
724 badge_color = theme.provider_ollama;
725 else if (provider_name ==
"gemini")
726 badge_color = theme.provider_gemini;
727 else if (provider_name ==
"anthropic")
728 badge_color = theme.provider_openai;
729 else if (provider_name ==
"openai")
730 badge_color = theme.provider_openai;
734 {{ImGuiStyleVar_FrameRounding, 6.0f},
735 {ImGuiStyleVar_FramePadding, ImVec2(3, 1)}});
736 ImGui::SmallButton(provider_name.c_str());
741 if (ImGui::Selectable(favorite.c_str(), active)) {
742 config.ai_model = favorite;
743 std::snprintf(config.model_buffer,
sizeof(config.model_buffer),
"%s",
745 if (!provider_name.empty()) {
746 config.ai_provider = provider_name;
747 std::snprintf(config.provider_buffer,
sizeof(config.provider_buffer),
748 "%s", provider_name.c_str());
755 config.model_chain.erase(
756 std::remove(config.model_chain.begin(), config.model_chain.end(),
758 config.model_chain.end());
759 config.favorite_models.erase(config.favorite_models.begin() + i);