129 ImGuiStyle& style = ImGui::GetStyle();
132 static bool filter_by_provider =
false;
134 if (model_cache.last_provider != config.ai_provider ||
135 model_cache.last_openai_base != config.openai_base_url ||
136 model_cache.last_ollama_host != config.ollama_host) {
138 model_cache.last_provider = config.ai_provider;
139 model_cache.last_openai_base = config.openai_base_url;
140 model_cache.last_ollama_host = config.ollama_host;
144 !model_cache.auto_refresh_requested) {
145 model_cache.auto_refresh_requested =
true;
148 const float label_width = 120.0f;
149 const ImVec2 compact_padding(style.FramePadding.x,
150 std::max(2.0f, style.FramePadding.y * 0.6f));
151 const ImVec2 compact_spacing(style.ItemSpacing.x,
152 std::max(3.0f, style.ItemSpacing.y * 0.6f));
153 const float env_button_width =
154 ImGui::CalcTextSize(
ICON_MD_SYNC " Env").x + compact_padding.x * 2.0f;
156 auto set_openai_base = [&](
const std::string& base_url) {
157 std::snprintf(config.openai_base_url_buffer,
158 sizeof(config.openai_base_url_buffer),
"%s",
160 config.openai_base_url = config.openai_base_url_buffer;
164 auto provider_button = [&](
const char* label,
const char* value,
165 const ImVec4& color) {
166 bool active = config.ai_provider == value;
167 ImGui::TableNextColumn();
168 std::optional<gui::StyleColorGuard> btn_guard;
170 btn_guard.emplace(std::initializer_list<gui::StyleColorGuard::Entry>{
171 {ImGuiCol_Button, color},
172 {ImGuiCol_ButtonHovered, ImVec4(color.x * 1.15f, color.y * 1.15f,
173 color.z * 1.15f, color.w)}});
175 if (ImGui::Button(label, ImVec2(-FLT_MIN, 28))) {
176 config.ai_provider = value;
177 std::snprintf(config.provider_buffer,
sizeof(config.provider_buffer),
184 {{ImGuiStyleVar_FramePadding, compact_padding},
185 {ImGuiStyleVar_ItemSpacing, compact_spacing}});
186 if (ImGui::BeginTable(
187 "AgentProviderConfigTable", 2,
188 ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_BordersInnerV)) {
189 ImGui::TableSetupColumn(
"Label", ImGuiTableColumnFlags_WidthFixed,
191 ImGui::TableSetupColumn(
"Value", ImGuiTableColumnFlags_WidthStretch);
193 ImGui::TableNextRow();
194 ImGui::TableSetColumnIndex(0);
195 ImGui::TextDisabled(
"Provider");
196 ImGui::TableSetColumnIndex(1);
197 float provider_width = ImGui::GetContentRegionAvail().x;
198 int provider_columns = provider_width > 560.0f ? 3
199 : provider_width > 360.0f ? 2
201 if (ImGui::BeginTable(
"AgentProviderButtons", provider_columns,
202 ImGuiTableFlags_SizingStretchSame)) {
204 theme.provider_mock);
206 theme.provider_ollama);
208 theme.provider_gemini);
212 theme.provider_openai);
216 ImGui::TableNextRow();
217 ImGui::TableSetColumnIndex(0);
218 ImGui::TextDisabled(
"Ollama Host");
219 ImGui::TableSetColumnIndex(1);
220 ImGui::SetNextItemWidth(-1);
221 if (ImGui::InputTextWithHint(
"##ollama_host",
"http://localhost:11434",
222 config.ollama_host_buffer,
223 IM_ARRAYSIZE(config.ollama_host_buffer))) {
224 config.ollama_host = config.ollama_host_buffer;
227 auto key_row = [&](
const char* label,
const char* hint,
char* buffer,
228 size_t buffer_len, std::string* target,
229 const char* env_var,
const char* input_id,
230 const char* button_id) {
231 ImGui::TableNextRow();
232 ImGui::TableSetColumnIndex(0);
233 ImGui::TextDisabled(
"%s", label);
234 ImGui::TableSetColumnIndex(1);
235 float input_width = ImGui::GetContentRegionAvail().x -
236 env_button_width - style.ItemSpacing.x;
237 bool stack = input_width < 140.0f;
238 ImGui::SetNextItemWidth(stack ? -1 : input_width);
239 if (ImGui::InputTextWithHint(input_id, hint, buffer, buffer_len,
240 ImGuiInputTextFlags_Password)) {
248 if (ImGui::SmallButton(button_id)) {
249 const char* env_key = std::getenv(env_var);
251 std::snprintf(buffer, buffer_len,
"%s", env_key);
257 absl::StrFormat(
"Loaded %s from environment", env_var),
260 }
else if (toast_manager) {
261 toast_manager->
Show(absl::StrFormat(
"%s not set", env_var),
267 key_row(
"Gemini Key",
"API key...", config.gemini_key_buffer,
268 IM_ARRAYSIZE(config.gemini_key_buffer), &config.gemini_api_key,
269 "GEMINI_API_KEY",
"##gemini_key",
ICON_MD_SYNC " Env##gemini");
270 key_row(
"Anthropic Key",
"API key...", config.anthropic_key_buffer,
271 IM_ARRAYSIZE(config.anthropic_key_buffer),
272 &config.anthropic_api_key,
"ANTHROPIC_API_KEY",
"##anthropic_key",
274 key_row(
"OpenAI Key",
"API key...", config.openai_key_buffer,
275 IM_ARRAYSIZE(config.openai_key_buffer), &config.openai_api_key,
276 "OPENAI_API_KEY",
"##openai_key",
ICON_MD_SYNC " Env##openai");
278 ImGui::TableNextRow();
279 ImGui::TableSetColumnIndex(0);
280 ImGui::TextDisabled(
"OpenAI Base");
281 ImGui::TableSetColumnIndex(1);
282 float openai_button_width =
283 ImGui::CalcTextSize(
"OpenAI").x + compact_padding.x * 2.0f;
284 float lm_button_width =
285 ImGui::CalcTextSize(
"LM Studio").x + compact_padding.x * 2.0f;
286 float afs_button_width =
287 ImGui::CalcTextSize(
"AFS Bridge").x + compact_padding.x * 2.0f;
288 float reset_button_width =
289 ImGui::CalcTextSize(
"Reset").x + compact_padding.x * 2.0f;
290 float total_buttons = openai_button_width + lm_button_width +
291 afs_button_width + reset_button_width +
292 style.ItemSpacing.x * 3.0f;
293 float base_available = ImGui::GetContentRegionAvail().x;
294 bool base_stack = base_available < total_buttons + 160.0f;
295 ImGui::SetNextItemWidth(base_stack ? -1
296 : base_available - total_buttons -
297 style.ItemSpacing.x);
298 if (ImGui::InputTextWithHint(
299 "##openai_base",
"http://localhost:1234",
300 config.openai_base_url_buffer,
301 IM_ARRAYSIZE(config.openai_base_url_buffer))) {
302 config.openai_base_url = config.openai_base_url_buffer;
309 if (ImGui::SmallButton(
"OpenAI")) {
310 set_openai_base(
"https://api.openai.com");
313 if (ImGui::SmallButton(
"LM Studio")) {
314 set_openai_base(
"http://localhost:1234");
317 if (ImGui::SmallButton(
"AFS Bridge")) {
318 set_openai_base(
"https://halext.org");
321 if (ImGui::SmallButton(
"Reset")) {
322 set_openai_base(
"https://api.openai.com");
329 if (IsHalextEndpoint(config.openai_base_url)) {
330 ImGui::TextColored(theme.command_text_color,
332 }
else if (IsLocalEndpoint(config.openai_base_url)) {
333 ImGui::TextColored(theme.status_success,
336 ImGui::TextColored(theme.text_secondary_color,
344 {{ImGuiStyleVar_FramePadding, compact_padding},
345 {ImGuiStyleVar_ItemSpacing, compact_spacing}});
346 if (ImGui::BeginTable(
347 "AgentModelControls", 2,
348 ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_BordersInnerV)) {
349 ImGui::TableSetupColumn(
"Label", ImGuiTableColumnFlags_WidthFixed,
351 ImGui::TableSetupColumn(
"Value", ImGuiTableColumnFlags_WidthStretch);
353 ImGui::TableNextRow();
354 ImGui::TableSetColumnIndex(0);
355 ImGui::TextDisabled(
"Model");
356 ImGui::TableSetColumnIndex(1);
357 ImGui::SetNextItemWidth(-1);
358 if (ImGui::InputTextWithHint(
"##ai_model",
"Model name...",
360 IM_ARRAYSIZE(config.model_buffer))) {
361 config.ai_model = config.model_buffer;
364 ImGui::TableNextRow();
365 ImGui::TableSetColumnIndex(0);
366 ImGui::TextDisabled(
"Filter");
367 ImGui::TableSetColumnIndex(1);
368 ImGui::Checkbox(
"Provider", &filter_by_provider);
373 float refresh_width =
376 ImGui::CalcTextSize(
ICON_MD_CLEAR).x + compact_padding.x * 2.0f;
377 float search_width = ImGui::GetContentRegionAvail().x - refresh_width -
379 if (model_cache.search_buffer[0] !=
'\0') {
380 search_width -= clear_width + style.ItemSpacing.x;
382 ImGui::SetNextItemWidth(search_width);
383 ImGui::InputTextWithHint(
"##model_search",
"Search models...",
384 model_cache.search_buffer,
385 IM_ARRAYSIZE(model_cache.search_buffer));
387 if (model_cache.search_buffer[0] !=
'\0') {
389 model_cache.search_buffer[0] =
'\0';
391 if (ImGui::IsItemHovered()) {
392 ImGui::SetTooltip(
"Clear search");
396 if (ImGui::SmallButton(model_cache.loading ?
ICON_MD_SYNC
406 if (!model_cache.available_models.empty() ||
407 !model_cache.local_model_names.empty()) {
408 const int provider_count =
409 static_cast<int>(model_cache.available_models.size());
410 const int local_count =
411 static_cast<int>(model_cache.local_model_names.size());
412 if (provider_count > 0 && local_count > 0) {
413 ImGui::TextDisabled(
"Models: %d provider, %d local", provider_count,
415 }
else if (provider_count > 0) {
416 ImGui::TextDisabled(
"Models: %d provider", provider_count);
417 }
else if (local_count > 0) {
418 ImGui::TextDisabled(
"Models: %d local files", local_count);
422 float list_height = std::max(220.0f, ImGui::GetContentRegionAvail().y * 0.6f);
424 ImGui::BeginChild(
"UnifiedModelList", ImVec2(0, list_height),
true);
425 std::string filter = absl::AsciiStrToLower(model_cache.search_buffer);
429 std::string provider;
430 std::string param_size;
431 std::string quantization;
433 uint64_t size_bytes = 0;
434 bool is_local =
false;
435 bool is_file =
false;
438 std::vector<ModelRow> rows;
439 if (!model_cache.available_models.empty()) {
440 rows.reserve(model_cache.available_models.size());
441 for (
const auto& info : model_cache.available_models) {
443 row.name = info.name;
444 row.provider = info.provider;
445 row.param_size = info.parameter_size;
446 row.quantization = info.quantization;
447 row.family = info.family;
448 row.size_bytes = info.size_bytes;
449 row.is_local = info.is_local;
450 rows.push_back(std::move(row));
453 rows.reserve(model_cache.model_names.size());
454 for (
const auto& model_name : model_cache.model_names) {
456 row.name = model_name;
457 row.provider = config.ai_provider;
458 rows.push_back(std::move(row));
462 if (!filter_by_provider && !model_cache.local_model_names.empty()) {
463 for (
const auto& model_name : model_cache.local_model_names) {
465 row.name = model_name;
466 row.provider =
"local";
469 rows.push_back(std::move(row));
473 auto get_provider_color = [&theme](
const std::string& provider) -> ImVec4 {
475 return theme.provider_ollama;
477 return theme.provider_gemini;
479 return theme.provider_openai;
481 return theme.provider_openai;
482 return theme.provider_mock;
485 auto matches_filter = [&](
const ModelRow& row) {
486 if (filter.empty()) {
489 std::string lower_name = absl::AsciiStrToLower(row.name);
490 std::string lower_provider = absl::AsciiStrToLower(row.provider);
491 if (ContainsText(lower_name, filter) ||
492 ContainsText(lower_provider, filter)) {
495 if (!row.param_size.empty() &&
496 ContainsText(absl::AsciiStrToLower(row.param_size), filter)) {
499 if (!row.family.empty() &&
500 ContainsText(absl::AsciiStrToLower(row.family), filter)) {
503 if (!row.quantization.empty() &&
504 ContainsText(absl::AsciiStrToLower(row.quantization), filter)) {
511 ImGui::TextDisabled(
"No cached models. Refresh to discover.");
513 float list_width = ImGui::GetContentRegionAvail().x;
514 bool compact = list_width < 520.0f;
515 int column_count = compact ? 3 : 5;
516 ImGuiTableFlags table_flags = ImGuiTableFlags_RowBg |
517 ImGuiTableFlags_BordersInnerH |
518 ImGuiTableFlags_SizingStretchProp;
519 if (ImGui::BeginTable(
"ModelTable", column_count, table_flags)) {
521 ImGui::TableSetupColumn(
"Provider", ImGuiTableColumnFlags_WidthFixed,
523 ImGui::TableSetupColumn(
"Model", ImGuiTableColumnFlags_WidthStretch);
524 ImGui::TableSetupColumn(
"Actions", ImGuiTableColumnFlags_WidthFixed,
527 ImGui::TableSetupColumn(
"Provider", ImGuiTableColumnFlags_WidthFixed,
529 ImGui::TableSetupColumn(
"Model", ImGuiTableColumnFlags_WidthStretch);
530 ImGui::TableSetupColumn(
"Size", ImGuiTableColumnFlags_WidthFixed,
532 ImGui::TableSetupColumn(
"Meta", ImGuiTableColumnFlags_WidthStretch);
533 ImGui::TableSetupColumn(
"Actions", ImGuiTableColumnFlags_WidthFixed,
536 ImGui::TableHeadersRow();
539 for (
const auto& row : rows) {
540 if (filter_by_provider) {
541 if (row.provider ==
"local") {
544 if (!row.provider.empty() && row.provider != config.ai_provider) {
549 if (!matches_filter(row)) {
553 ImGui::PushID(row_id++);
554 ImGui::TableNextRow();
556 ImGui::TableSetColumnIndex(0);
557 if (row.provider.empty()) {
558 ImGui::TextDisabled(
"-");
559 }
else if (row.provider ==
"local") {
562 ImVec4 provider_color = get_provider_color(row.provider);
566 {{ImGuiStyleVar_FrameRounding, 6.0f},
567 {ImGuiStyleVar_FramePadding, ImVec2(4, 1)}});
568 ImGui::SmallButton(row.provider.c_str());
572 ImGui::TableSetColumnIndex(1);
573 bool is_selected = config.ai_model == row.name;
574 if (ImGui::Selectable(row.name.c_str(), is_selected)) {
575 config.ai_model = row.name;
576 std::snprintf(config.model_buffer,
sizeof(config.model_buffer),
"%s",
578 if (!row.provider.empty() && row.provider !=
"local") {
579 config.ai_provider = row.provider;
580 std::snprintf(config.provider_buffer,
581 sizeof(config.provider_buffer),
"%s",
582 row.provider.c_str());
586 if (row.is_file && ImGui::IsItemHovered()) {
588 "Local file detected. Serve this model via LM Studio/Ollama to "
592 std::string size_label = row.param_size;
593 if (size_label.empty() && row.size_bytes > 0) {
594 size_label = FormatByteSize(row.size_bytes);
598 ImGui::TableSetColumnIndex(2);
599 ImGui::TextColored(theme.text_secondary_color,
"%s",
602 ImGui::TableSetColumnIndex(3);
603 if (!row.quantization.empty()) {
604 ImGui::TextColored(theme.text_info,
"%s", row.quantization.c_str());
605 if (!row.family.empty()) {
609 if (!row.family.empty()) {
610 ImGui::TextColored(theme.text_secondary_gray,
"%s",
613 if (row.is_local && !row.is_file) {
617 }
else if (ImGui::IsItemHovered() &&
618 (!size_label.empty() || !row.quantization.empty() ||
619 !row.family.empty())) {
621 if (!size_label.empty()) {
624 if (!row.quantization.empty()) {
628 meta += row.quantization;
630 if (!row.family.empty()) {
636 ImGui::SetTooltip(
"%s", meta.c_str());
639 int action_column = compact ? 2 : 4;
640 ImGui::TableSetColumnIndex(action_column);
642 bool is_favorite = std::find(config.favorite_models.begin(),
643 config.favorite_models.end(),
644 row.name) != config.favorite_models.end();
648 is_favorite ? theme.status_warning : theme.text_secondary_color);
652 config.favorite_models.erase(
653 std::remove(config.favorite_models.begin(),
654 config.favorite_models.end(), row.name),
655 config.favorite_models.end());
656 config.model_chain.erase(
657 std::remove(config.model_chain.begin(),
658 config.model_chain.end(), row.name),
659 config.model_chain.end());
661 config.favorite_models.push_back(row.name);
666 if (ImGui::IsItemHovered()) {
667 ImGui::SetTooltip(is_favorite ?
"Remove from favorites"
671 if (!row.provider.empty() && row.provider !=
"local") {
675 preset.
name = row.name;
676 preset.
model = row.name;
679 preset.
host = config.ollama_host;
681 preset.
host = config.openai_base_url;
683 preset.
tags = {row.provider};
685 config.model_presets.push_back(std::move(preset));
690 if (ImGui::IsItemHovered()) {
691 ImGui::SetTooltip(
"Capture preset from this model");
702 if (model_cache.last_refresh != absl::InfinitePast()) {
704 absl::ToDoubleSeconds(absl::Now() - model_cache.last_refresh);
705 ImGui::TextDisabled(
"Last refresh %.0fs ago", seconds);
707 ImGui::TextDisabled(
"Models not refreshed yet");
714 if (!config.favorite_models.empty()) {
716 ImGui::TextColored(theme.status_warning,
ICON_MD_STAR " Favorites");
717 for (
size_t i = 0; i < config.favorite_models.size(); ++i) {
718 auto& favorite = config.favorite_models[i];
719 ImGui::PushID(
static_cast<int>(i));
720 bool active = config.ai_model == favorite;
722 std::string provider_name;
723 for (
const auto& info : model_cache.available_models) {
724 if (info.name == favorite) {
725 provider_name = info.provider;
730 if (!provider_name.empty()) {
731 ImVec4 badge_color = theme.provider_mock;
733 badge_color = theme.provider_ollama;
735 badge_color = theme.provider_gemini;
737 badge_color = theme.provider_openai;
739 badge_color = theme.provider_openai;
743 {{ImGuiStyleVar_FrameRounding, 6.0f},
744 {ImGuiStyleVar_FramePadding, ImVec2(3, 1)}});
745 ImGui::SmallButton(provider_name.c_str());
750 if (ImGui::Selectable(favorite.c_str(), active)) {
751 config.ai_model = favorite;
752 std::snprintf(config.model_buffer,
sizeof(config.model_buffer),
"%s",
754 if (!provider_name.empty()) {
755 config.ai_provider = provider_name;
756 std::snprintf(config.provider_buffer,
sizeof(config.provider_buffer),
757 "%s", provider_name.c_str());
764 config.model_chain.erase(
765 std::remove(config.model_chain.begin(), config.model_chain.end(),
767 config.model_chain.end());
768 config.favorite_models.erase(config.favorite_models.begin() + i);