Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/mecatui/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ func (m Model) zeroStateMemoryNote() string {
// provider id comes from the status row, so a future non-ToolHive
// intent-driven provider reads naturally without a code change here.
func (m Model) zeroStateGatewayNote() string {
if isToolhiveProviderID(m.resolvedSessionModel.ProviderID) {
return ""
}
row, ok := availableNotDefaultStatus(m.modelCatalog.statuses)
if !ok {
return ""
Expand Down
8 changes: 6 additions & 2 deletions cmd/mecatui/ui/models_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (m Model) applyModelsCatalog(msg client.ModelsMsg) (tea.Model, tea.Cmd, boo
surface.catalog = m.modelCatalog
surface.provenance = m.modelProvenanceLine()
}
if !m.gatewayNoticeShown {
if !m.gatewayNoticeShown && !isToolhiveProviderID(m.resolvedSessionModel.ProviderID) {
if row, ok := availableNotDefaultStatus(msg.Statuses); ok {
pid := sanitizeTerminal(row.ProviderID)
m.gatewayNotice = pid + " gateway available (" + strconv.Itoa(int(row.ModelCount)) +
Expand Down Expand Up @@ -193,7 +193,7 @@ func statusAutoSelected(statuses []client.ProviderStatus, providerID string) boo
func configProvenanceProviderSet(statuses []client.ProviderStatus) map[string]bool {
var out map[string]bool
for _, s := range statuses {
if s.ProviderID == "toolhive" {
if isToolhiveProviderID(s.ProviderID) {
if out == nil {
out = make(map[string]bool, 1)
}
Expand All @@ -203,6 +203,10 @@ func configProvenanceProviderSet(statuses []client.ProviderStatus) map[string]bo
return out
}

func isToolhiveProviderID(providerID string) bool {
return providerID == "toolhive" || providerID == "toolhive-anthropic"
}

// availableNotDefaultStatus returns the first reachable intent-driven provider
// that is available but not the active default.
func availableNotDefaultStatus(statuses []client.ProviderStatus) (client.ProviderStatus, bool) {
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/ui/models_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,13 @@ var customProviderStatusCopy = map[string]string{
"unauthorized": "model service rejected access — check provider access configuration",
"empty": "no selectable models",
}
var toolhiveStatusCopy = map[string]string{"unreachable": "proxy not reachable", "unauthorized": "gateway rejected the credential", "empty": "credential lists no models"}
var toolhiveStatusCopy = map[string]string{"unreachable": "gateway not reachable", "unauthorized": "gateway rejected the credential", "empty": "credential lists no models"}
var openAICodexStatusCopy = map[string]string{"unreachable": "ChatGPT Codex service not reachable", "unauthorized": "manual token rejected", "empty": "account lists no selectable models"}

func providerStatusLine(s client.ProviderStatus) string {
copyByState := customProviderStatusCopy
switch s.ProviderID {
case "toolhive":
case "toolhive", "toolhive-anthropic":
copyByState = toolhiveStatusCopy
case "openai-codex":
copyByState = openAICodexStatusCopy
Expand Down
54 changes: 50 additions & 4 deletions cmd/mecatui/ui/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1425,7 +1425,7 @@ func TestModelsEmptyCopy_PromotesAnyNonOkStatus(t *testing.T) {
{ProviderID: "toolhive", State: "unreachable", Hint: "start it with `thv llm proxy start`"},
}
got := modelsEmptyCopy(client.Capabilities{ModelSelection: true}, unreachable)
want := "toolhive: proxy not reachable — start it with `thv llm proxy start`"
want := "toolhive: gateway not reachable — start it with `thv llm proxy start`"
if got != want {
t.Errorf("unreachable empty copy = %q, want %q", got, want)
}
Expand Down Expand Up @@ -1534,7 +1534,7 @@ func TestRenderProviderStatusLines_UnreachableAndUnauthorized(t *testing.T) {
lines := renderProviderStatusLines([]client.ProviderStatus{
{ProviderID: "toolhive", State: "unreachable", Hint: "start it with `thv llm proxy start`"},
}, false)
if len(lines) != 1 || lines[0] != "toolhive: proxy not reachable — start it with `thv llm proxy start`" {
if len(lines) != 1 || lines[0] != "toolhive: gateway not reachable — start it with `thv llm proxy start`" {
t.Fatalf("unreachable line = %v", lines)
}
lines = renderProviderStatusLines([]client.ProviderStatus{
Expand All @@ -1556,7 +1556,7 @@ func TestModelsPickerStatuses_ThreadedFromMsg(t *testing.T) {
t.Fatalf("models.statuses = %+v, want the threaded status", m.modelCatalog.statuses)
}
rendered := stripANSI([]byte(m.View().Content))
if !strings.Contains(string(rendered), "toolhive: proxy not reachable") {
if !strings.Contains(string(rendered), "toolhive: gateway not reachable") {
t.Fatalf("rendered picker missing the status line:\n%s", rendered)
}
}
Expand Down Expand Up @@ -1587,7 +1587,7 @@ func TestModelsPickerCustomProviderStatusRendersAlongsideFloor(t *testing.T) {
t.Errorf("rendered picker missing %q:\n%s", want, rendered)
}
}
for _, unwanted := range []string{"proxy not reachable", "gateway rejected the credential", "credential lists no models", "https://", "listing response body", "gateway-secret"} {
for _, unwanted := range []string{"gateway not reachable", "gateway rejected the credential", "credential lists no models", "https://", "listing response body", "gateway-secret"} {
if strings.Contains(rendered, unwanted) {
t.Errorf("rendered custom status leaked or used ToolHive copy %q:\n%s", unwanted, rendered)
}
Expand Down Expand Up @@ -1663,6 +1663,16 @@ func TestHeaderToolhiveSegment(t *testing.T) {
t.Fatal("a toolhive session must show the gateway segment at a wide width")
}

m.resolvedSessionModel = client.ResolvedModel{ProviderID: "toolhive-anthropic", ModelID: "claude-sonnet-4-6"}
m.modelCatalog.statuses = []client.ProviderStatus{
{ProviderID: "toolhive", State: "ok", AvailableNotDefault: true},
{ProviderID: "toolhive-anthropic", State: "ok"},
}
header := stripANSIstr(m.renderHeader())
if !strings.Contains(header, "via ToolHive gateway") || strings.Contains(header, "gateway available") {
t.Fatalf("native ToolHive header must show one active-family segment, got:\n%s", header)
}

// At a narrow width the segment sheds along with the other low-priority
// segments; the header must not panic/overflow.
m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 30})
Expand Down Expand Up @@ -2082,6 +2092,26 @@ func TestGatewayNoticeNotFiredWhenNoAvailableNotDefault(t *testing.T) {
}
}

func TestGatewayNoticeNotFiredForActiveToolhiveFamily(t *testing.T) {
fm := gatewayModels()
statuses := []client.ProviderStatus{{
ProviderID: "toolhive-anthropic",
State: "ok",
ModelCount: 1,
AvailableNotDefault: true,
}}
for _, providerID := range []string{"toolhive", "toolhive-anthropic"} {
m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{})
m.resolvedSessionModel = client.ResolvedModel{ProviderID: providerID, ModelID: "claude-sonnet-4-6"}
mm, _, _ := m.updateModelsMsg(client.ModelsMsg{Models: fm.models, Statuses: statuses})
m = mm.(Model)
if m.gatewayNotice != "" || m.gatewayNoticeShown {
t.Errorf("active provider %q armed same-family gateway notice %q (shown=%t)",
providerID, m.gatewayNotice, m.gatewayNoticeShown)
}
}
}

// TestGatewayNoticeClearedOnKeypress: any keypress at idle clears the notice text
// (the latch stays true so it never re-fires).
func TestGatewayNoticeClearedOnKeypress(t *testing.T) {
Expand Down Expand Up @@ -2171,6 +2201,7 @@ func TestModelRowOrgTagForConfigIntentProvider(t *testing.T) {
func TestConfigIntentProviderSetExcludesOpenAICodexStatus(t *testing.T) {
got := configProvenanceProviderSet([]client.ProviderStatus{
{ProviderID: "toolhive", State: "ok"},
{ProviderID: "toolhive-anthropic", State: "ok"},
{ProviderID: "openai-codex", State: "ok"},
})
if !got["toolhive"] {
Expand All @@ -2179,6 +2210,21 @@ func TestConfigIntentProviderSetExcludesOpenAICodexStatus(t *testing.T) {
if got["openai-codex"] {
t.Fatal("Codex entitlement status was misclassified as config intent")
}
if !got["toolhive-anthropic"] {
t.Fatal("native ToolHive status lost its config-intent classification")
}
}

func TestToolhiveNativeAnthropic_Scenario3_StatusAndPresentation(t *testing.T) {
got := providerStatusLine(client.ProviderStatus{
ProviderID: "toolhive-anthropic",
State: "unreachable",
Hint: "check gateway connectivity or use `--toolhive-llm-mode proxy`",
})
want := "toolhive-anthropic: gateway not reachable — check gateway connectivity or use `--toolhive-llm-mode proxy`"
if got != want {
t.Fatalf("providerStatusLine = %q, want %q", got, want)
}
}

// TestModelRowOrgTagNilConfigIntentProviderIDs: a nil configProvenanceProviderIDs map (no gateway)
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
┃ openai · text-embed ┃
┃ openrouter · Claude img reason 1M ┃
┃ ┃
┃ toolhive: proxy not reachable — start it with `thv llm proxy start`
┃ toolhive: gateway not reachable — start it with `thv llm proxy start` ┃
┃ ┃
┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃
┃ ● current ★ global default ┃
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ func (m Model) headerIdentityParts(sid, withNext string) []string {
// toolhive — disclosure-only (no acknowledgment required), riding the same
// segment slice so the EXISTING width-shedding/fitHeader math applies
// unchanged (it sheds like any other low-priority segment under pressure).
if m.resolvedSessionModel.ProviderID == "toolhive" {
if isToolhiveProviderID(m.resolvedSessionModel.ProviderID) {
parts = append(parts, m.deps.Theme.Style("muted").Render("via ToolHive gateway"))
} else if row, ok := availableNotDefaultStatus(m.modelCatalog.statuses); ok {
// Sibling (N1): when an intent-driven provider is detected-and-reachable
Expand Down
16 changes: 16 additions & 0 deletions cmd/mecatui/ui/zerostate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ func TestZeroStateGatewayNote(t *testing.T) {
t.Errorf("splash should NOT render the gateway line when the gateway is the default, got:\n%s", plain)
}

// Suppressed when either protocol-specific ToolHive provider is already
// active, even if its sibling is reported as available-but-not-default.
m.modelCatalog.statuses = []client.ProviderStatus{{
ProviderID: "toolhive-anthropic",
State: "ok",
ModelCount: 1,
AvailableNotDefault: true,
}}
for _, providerID := range []string{"toolhive", "toolhive-anthropic"} {
m.resolvedSessionModel = client.ResolvedModel{ProviderID: providerID, ModelID: "claude-sonnet-4-6"}
plain = stripANSIstr(m.renderZeroState())
if strings.Contains(plain, "gateway detected") {
t.Errorf("splash should NOT render a same-family gateway line for %q, got:\n%s", providerID, plain)
}
}

// Suppressed with no statuses (byte-identical pre-feature path).
m.modelCatalog.statuses = nil
plain = stripANSIstr(m.renderZeroState())
Expand Down
4 changes: 4 additions & 0 deletions docs/acceptance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ PR after verification. There is no cleanup or status-only PR.
injection; all callers admitted to one mecated share configured endpoint availability and
gateway identity, while ToolHive remains explicit optional proxy compatibility with no
cross-store secret migration. Status: proposed.
- [ToolHive native Anthropic gateway support](toolhive-native-anthropic.md) — expose the
gateway's native Anthropic catalog and Messages endpoint as `toolhive-anthropic`, while
preserving the existing Responses-backed `toolhive` provider and shared gateway identity.
Status: implementation in progress under explicit workflow waiver; not approved or landed.
- [Surface approval migration](surface-approval-migration.md) — final Phase-2 migration of the mecatui approval UI onto the dynamic surface contract, including ephemeral render-frame hit dispatch. Status: landed.
- [Spine convergence](spine-convergence.md) — bring the
to-acceptance-plan / plan-orchestrate / test-writer spine + ac-trace into
Expand Down
Loading