Skip to content
Merged
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: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ full command and press `⏎`.
| `/memory` | Facts by target, pending-episode promote, consolidate |
| `/skills` | Skill provenance badges & promote |
| `/tools` | Tool registry with enabled state & MCP servers |
| `/mcp` | MCP servers — `/mcp [name]` jumps to one in the tools drawer |
| `/config` | Sanitized config, lifetime usage, connections (kick) |
| `/model [name]` | Switch model (opens a picker with no argument) |
| `/thinking [disabled\|low\|medium\|high]` | Set reasoning depth (`on` → `medium`, `off` → `disabled`, `inherit` restores the odek serve default; bare `/thinking` opens a picker) |
Expand All @@ -522,7 +523,7 @@ full command and press `⏎`.
### The management drawer

`/sessions`, `/runs`, `/agents`, `/jobs`, `/events`, `/plan`, `/memory`,
`/skills`, `/tools`, and `/config` all open tabs of **one drawer** that
`/skills`, `/tools`, `/mcp`, and `/config` all open tabs of **one drawer** that
sits as a **bottom sheet** — about eight transcript rows stay visible
above it (full-bleed only when the terminal is too short) — with a
shared grammar:
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ func slashCommands() []command {
{"tools", "tool registry & MCP servers", func(m *Model, _ string) tea.Cmd {
return m.openTools()
}},
{"mcp", "MCP servers — /mcp [name] jumps to one", func(m *Model, args string) tea.Cmd {
m.mcpJump = true
m.mcpFocus = strings.TrimSpace(args)
return m.openTools()
}},
{"config", "server config, usage & connections", func(m *Model, _ string) tea.Cmd {
return m.openConfig()
}},
Expand Down
18 changes: 18 additions & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ func TestE2EAllCommands(t *testing.T) {
t.Error("/tools fetched no rows")
}
},
"/mcp": func(t *testing.T, m *Model) {
if m.panel != panelTools {
t.Fatalf("panel = %d, want tools", m.panel)
}
if r := m.toolSelected(); r == nil || r.kind != "mcp" {
t.Fatalf("/mcp selection = %+v, want the mcp row", r)
}
},
"/mcp fs": func(t *testing.T, m *Model) {
if r := m.toolSelected(); r == nil || r.kind != "mcp" || r.text != "fs" {
t.Fatalf("/mcp fs selection = %+v, want the fs row", r)
}
},
"/mcp nope": func(t *testing.T, m *Model) {
if !strings.Contains(m.panelMsg, "no MCP server named") {
t.Errorf("/mcp nope panelMsg = %q, want a not-found note", m.panelMsg)
}
},
"/config": func(t *testing.T, m *Model) {
if m.panel != panelConfig {
t.Fatalf("panel = %d, want config", m.panel)
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/drawer.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ func (m *Model) switchDrawerTab(mode panelMode) tea.Cmd {
m.confirm = confirmNone // a gate never survives a tab change
m.panelDetail = false // nor does an open detail view
m.detailScroll = 0
m.mcpJump = false // nor does the /mcp one-shot focus
m.mcpFocus = ""
for _, t := range drawerTabs() {
if t.mode == mode {
return t.open(m)
Expand Down
190 changes: 190 additions & 0 deletions internal/tui/mcp_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package tui

import (
"errors"
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"

"github.com/BackendStack21/bodek/internal/client"
)

// runSlash executes a "/name args" line through the command registry and
// feeds the resulting message back into the model (the e2e command path).
func runSlash(t *testing.T, m *Model, line string) {
t.Helper()
name := strings.TrimPrefix(line, "/")
args := ""
if i := strings.IndexAny(name, " \t"); i >= 0 {
name, args = name[:i], strings.TrimSpace(name[i+1:])
}
var cmd tea.Cmd
if c := findCommand(name); c != nil {
cmd = c.run(m, args)
} else {
t.Fatalf("command %q not in registry", name)
}
if cmd != nil {
m.Update(exec(cmd))
}
}

func findCommand(name string) *command {
for i := range slashCommands() {
if slashCommands()[i].name == name {
return &slashCommands()[i]
}
}
return nil
}

// TestMCPCommandRegistry verifies the /mcp registry entry exists and is
// offered by the slash popup and the /help card.
func TestMCPCommandRegistry(t *testing.T) {
c := findCommand("mcp")
if c == nil {
t.Fatal("/mcp not registered in slashCommands()")
}
if c.desc == "" {
t.Fatal("/mcp has no description")
}
m := wired(t)
if !commandOffered(m, *c) {
t.Error("/mcp must be offered unconditionally (server-level, no session gate)")
}
m.ta.SetValue("/")
m.Update(key("m"))
m.Update(key("c"))
m.Update(key("p"))
found := false
for _, it := range m.ac.items {
if it.ID == "/mcp" {
found = true
}
}
if !found {
t.Errorf("slash popup missing /mcp: %+v", m.ac.items)
}
}

// TestMCPBareOpensToolsTabOnMCPSection verifies bare /mcp opens the drawer
// Tools tab with the first MCP server row focused.
func TestMCPBareOpensToolsTabOnMCPSection(t *testing.T) {
m := wired(t)
runSlash(t, m, "/mcp")
if m.panel != panelTools {
t.Fatalf("panel = %d, want tools", m.panel)
}
if len(m.toolRows) == 0 {
t.Fatal("toolRows empty after /mcp")
}
sel := m.toolSelected()
if sel == nil || sel.kind != "mcp" {
t.Fatalf("selection = %+v, want first mcp row focused", sel)
}
if sel.text != "fs" {
t.Fatalf("focused server = %q, want fs", sel.text)
}
}

// TestMCPNamedJump verifies /mcp <name> focuses that server's row
// case-insensitively.
func TestMCPNamedJump(t *testing.T) {
m := wired(t)
runSlash(t, m, "/mcp FS")
if m.panel != panelTools {
t.Fatalf("panel = %d, want tools", m.panel)
}
sel := m.toolSelected()
if sel == nil || sel.kind != "mcp" || sel.text != "fs" {
t.Fatalf("selection = %+v, want mcp row fs", sel)
}
}

// TestMCPUnknownName verifies an unknown server name reports in the drawer
// footer without moving the tab.
func TestMCPUnknownName(t *testing.T) {
m := wired(t)
runSlash(t, m, "/mcp nope")
if m.panel != panelTools {
t.Fatalf("panel = %d, want tools", m.panel)
}
if !strings.Contains(m.panelMsg, "no MCP server named") {
t.Errorf("panelMsg = %q, want a not-found note", m.panelMsg)
}
}

// TestMCPOneShotSemantics pins the one-shot focus contract: a /mcp jump
// consumed by one fetch never re-applies, and a tab switch before the fetch
// lands disarms it entirely.
func TestMCPOneShotSemantics(t *testing.T) {
// /mcp fs, fetch lands → focus applied once. A later plain /tools
// fetch must NOT re-apply the focus.
m := wired(t)
runSlash(t, m, "/mcp fs")
if r := m.toolSelected(); r == nil || r.text != "fs" {
t.Fatalf("focus not applied: %+v", r)
}
m.Update(exec(m.openTools())) // plain /tools re-fetch
if r := m.toolSelected(); r != nil && r.kind == "mcp" {
t.Fatalf("stale focus re-applied on a plain /tools fetch: %+v", r)
}

// /mcp fs then a tab switch before the fetch lands: the late tools
// result is dropped by the cross-tab guard AND the jump is disarmed,
// so a subsequent Tools load must not apply the stale focus.
m2 := wired(t)
runSlash(t, m2, "/mcp fs")
m2.Update(exec(m2.switchDrawerTab(panelMemory))) // tab switch disarms
m2.Update(exec(m2.openTools()))
if r := m2.toolSelected(); r != nil && r.kind == "mcp" {
t.Fatalf("disarmed jump re-applied after tab switch: %+v", r)
}
}

// TestMCPErrorConsumesFocus verifies a failed tools fetch consumes the
// one-shot focus instead of leaking it into the next successful load.
func TestMCPErrorConsumesFocus(t *testing.T) {
m := wired(t)
runSlash(t, m, "/mcp fs")
m.handleMgmtMsg(mgmtMsg{tab: panelTools, err: errors.New("boom")})
if m.mcpJump || m.mcpFocus != "" {
t.Fatalf("error left the jump armed: jump=%v focus=%q", m.mcpJump, m.mcpFocus)
}
m.handleMgmtMsg(mgmtMsg{tab: panelTools, mcpN: 1, tls: []client.Tool{{Name: "shell", Enabled: true}}})
if r := m.toolSelected(); r != nil && r.kind == "mcp" {
t.Fatalf("stale focus applied after an error recovery load: %+v", r)
}
}

// TestMCPUnknownNotClobbered verifies the not-found note survives the
// zero-MCP-server note on the same frame.
func TestMCPUnknownNotClobbered(t *testing.T) {
m := wired(t)
m.panel = panelTools
m.mcpJump, m.mcpFocus = true, "gone"
m.handleMgmtMsg(mgmtMsg{tab: panelTools, mcpN: 0, tls: []client.Tool{{Name: "shell", Enabled: true}}})
if !strings.Contains(m.panelMsg, "no MCP server named") {
t.Errorf("not-found note clobbered: %q", m.panelMsg)
}
}

// TestMCPZeroServers verifies the zero-server empty state: the Tools tab
// still opens (it has native tools) with a "no MCP servers configured" note.
func TestMCPZeroServers(t *testing.T) {
m := wired(t)
m.panel = panelTools
m.handleMgmtMsg(mgmtMsg{tab: panelTools, mcpN: 0, tls: []client.Tool{
{Name: "shell", Enabled: true}, {Name: "read_file", Enabled: true},
}})
if m.panel != panelTools {
t.Fatalf("panel = %d, want tools", m.panel)
}
if !strings.Contains(m.panelMsg, "no MCP servers configured") {
t.Errorf("panelMsg = %q, want the zero-servers note", m.panelMsg)
}
if len(m.toolRows) != 2 {
t.Fatalf("toolRows = %d, want the 2 built-ins", len(m.toolRows))
}
}
34 changes: 34 additions & 0 deletions internal/tui/mgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) {
return
}
if msg.err != nil {
// A failed fetch consumes the one-shot /mcp focus like a
// successful one — it must not leak into the next Tools load.
m.mcpJump = false
m.mcpFocus = ""
m.panelMsg = "error: " + msg.err.Error()
return
}
Expand Down Expand Up @@ -220,6 +224,36 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) {
} else {
m.panelMsg = fmt.Sprintf("%d built-ins · %d MCP servers", len(msg.tls), msg.mcpN)
}
// One-shot /mcp focus: land on the first MCP row (bare) or the
// named server (case-insensitive). Consumed exactly once.
notFound := false
if m.mcpJump {
m.mcpJump = false
focus := m.mcpFocus
m.mcpFocus = ""
if focus != "" {
for i, r := range m.toolRows {
if r.kind == "mcp" && strings.EqualFold(r.id, focus) {
m.panelSel = i
break
}
}
if r := m.toolSelected(); r == nil || r.kind != "mcp" || !strings.EqualFold(r.id, focus) {
m.panelMsg = fmt.Sprintf("no MCP server named %q — /mcp lists all", focus)
notFound = true
}
} else {
for i, r := range m.toolRows {
if r.kind == "mcp" {
m.panelSel = i
break
}
}
}
}
if msg.mcpN == 0 && len(m.toolRows) > 0 && !notFound {
m.panelMsg = fmt.Sprintf("%d built-ins · no MCP servers configured", len(msg.tls))
}
case panelConfig:
m.cfgRows = buildCfgRows(msg.cfg, msg.usr, msg.con)
m.panelMsg = ""
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ type Model struct {
memTarget string // add-fact editor target ("user" | "env")
skills []client.Skill
toolRows []toolRow
mcpJump bool // a /mcp invocation armed a one-shot focus
mcpFocus string // /mcp <name> focus target, consumed on fetch
cfgRows []cfgRow
shutdownReq bool // shutdown sent — the socket drop is expected, not a failure

Expand Down
Loading