diff --git a/AGENTS.md b/AGENTS.md index 3d3ba61..b3e0233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,11 @@ feat(tui): compact tool steps with Ctrl+E details toggle - The slash-completion popup holds key capture while open; typed keys must keep flowing to the input. Route keys through the popup first, then fall through to normal input handling. +- Management drawer tabs (memory/skills/tools/config) have a detail + submode: `⏎` expands the selected row (skill description, full fact + text, MCP args, raw config JSON — everything through `sanitize()`), + `esc`/`q` folds back, `p` promotes in place. Tab switches reset it + (`switchDrawerTab`); keep that reset when adding new open paths. ## Workflow rules for agents diff --git a/README.md b/README.md index e0161e4..b4ed0d3 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,12 @@ command and press `⏎`. `/sessions`, `/runs`, `/events`, `/memory`, `/skills`, `/tools`, and `/config` all open tabs of **one drawer** with a shared grammar: -- `]` / `[` cycle tabs · `1`–`7` jump · `r` and `⏎` refresh · `esc` closes. +- `]` / `[` cycle tabs · `1`–`7` jump · `r` refresh · `esc` closes. +- **Every management row opens a detail view on `⏎`** — the full text + behind the gate: a skill's description and provenance, a fact or pending + episode's body, an MCP server's command/args/limits, raw JSON for nested + config values. `↑`/`↓` scroll it, `esc`/`q` folds back (selection kept), + and `p` promotes straight from the detail — no more promoting blind. - **Sessions** — `/` search (server-side), `p` pin, `r` rename, `e`/`E` export md/json, `d` delete (`y` confirms — deletes are always two-step), `⏎` resume. @@ -199,9 +204,11 @@ command and press `⏎`. clear filters (a runs-tab drill-in scopes it to one run). - **Memory** — `a`/`A` add user/env facts, `d` delete fact (`y` confirms), `p` promote a pending episode, `c`/`E` consolidate. -- **Skills** — provenance badges; `p` promote, `P` force-promote tainted. -- **Tools/Config** — registry + MCP servers; sanitized config, lifetime - usage, `d` kick a connection, `S` typed shutdown death-gate. +- **Skills** — provenance badges plus a dim description line; `p` promote + (also from the detail view), `P` force-promote tainted. +- **Tools/Config** — registry + MCP servers; config values flatten one + level (`sandbox.enabled`), nested values show raw JSON in the detail; + lifetime usage, `d` kick a connection, `S` typed shutdown death-gate. ### File attachments (`@`) diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index a950687..6c72e26 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -134,6 +134,8 @@ func drawerPanel(p panelMode) bool { // tab owns its state and fetches fresh on open. 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 for _, t := range drawerTabs() { if t.mode == mode { return t.open(m) diff --git a/internal/tui/drawer_test.go b/internal/tui/drawer_test.go index 1ed2d52..5425edc 100644 --- a/internal/tui/drawer_test.go +++ b/internal/tui/drawer_test.go @@ -105,7 +105,7 @@ func TestDrawerTabCycling(t *testing.T) { t.Errorf("digit %s: panel = %d, want %d", d, m.panel, w) } } - // The strip renders every tab name, and r/⏎ refresh a management tab + // The strip renders every tab name, and r refreshes a management tab // the same as a core tab (they are drawer tabs now). out := plain(m.View()) for _, name := range []string{"sessions", "runs", "events", "memory", "skills", "tools", "config"} { @@ -122,14 +122,19 @@ func TestDrawerTabCycling(t *testing.T) { } else { m.Update(exec(cmd)) } - _, cmd = m.Update(key("enter")) - if cmd == nil { - t.Error("enter did not refresh the memory tab") - } else { - m.Update(exec(cmd)) + // Enter no longer refreshes — it expands the selected row into the + // detail view (the readable half of the promote gate). Esc folds the + // detail; a second esc closes the drawer. + m.Update(key("enter")) + if !m.panelDetail { + t.Error("enter did not open the memory detail view") + } + m.Update(key("esc")) + if m.panelDetail { + t.Error("esc did not fold the detail view") } if m.panel != panelMemory { - t.Errorf("refresh left the tab: %d", m.panel) + t.Errorf("folding the detail left the tab: %d", m.panel) } m.Update(key("esc")) if m.panel != panelNone { diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index 6b73210..534b49e 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -200,7 +200,7 @@ func standIn(t *testing.T, token string) *Model { })) mux.HandleFunc("/api/skills", guard(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"skills": []client.Skill{ - {Name: "deploy-helper", Description: "deploys", UsageCount: 3, Source: "~/.odek/skills"}, + {Name: "deploy-helper", Description: "deploys the service with zero-downtime rolling restarts, health gates, and automatic rollback on failed probes across all regions", UsageCount: 3, Source: "~/.odek/skills"}, {Name: "tainted-thing", NeedsReview: true, Untrusted: true, Source: "./.odek/skills"}, }}) })) diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index 0ec142f..0497efd 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/json" "fmt" "sort" "strings" @@ -43,7 +44,8 @@ type toolRow struct { kind string // "tool" | "mcp" text string dim string - id string // mcp: server name + id string // mcp: server name + srv *client.MCPServer // mcp: full record for the detail view } // cfgRow is one config/usage/connection row. @@ -52,6 +54,7 @@ type cfgRow struct { k string v string id string // conn: kickable id + raw string // cfg: pretty JSON when v is the "·" marker (detail view) } // mgmtActionMsg reports a mutation outcome (delete/promote/consolidate/ @@ -100,6 +103,8 @@ func (m *Model) openMemory() tea.Cmd { m.panel = panelMemory m.panelSel = 0 m.panelEdit = panelEditNone + m.panelDetail = false + m.detailScroll = 0 m.panelMsg = "loading memory…" m.relayout() m.refresh() @@ -113,6 +118,8 @@ func (m *Model) openMemory() tea.Cmd { func (m *Model) openSkills() tea.Cmd { m.panel = panelSkills m.panelSel = 0 + m.panelDetail = false + m.detailScroll = 0 m.panelMsg = "loading skills…" m.relayout() m.refresh() @@ -126,6 +133,8 @@ func (m *Model) openSkills() tea.Cmd { func (m *Model) openTools() tea.Cmd { m.panel = panelTools m.panelSel = 0 + m.panelDetail = false + m.detailScroll = 0 m.panelMsg = "loading tools…" m.relayout() m.refresh() @@ -141,6 +150,8 @@ func (m *Model) openTools() tea.Cmd { func (m *Model) openConfig() tea.Cmd { m.panel = panelConfig m.panelSel = 0 + m.panelDetail = false + m.detailScroll = 0 m.panelMsg = "loading config…" m.relayout() m.refresh() @@ -224,7 +235,7 @@ func buildToolRows(tools []client.Tool, servers []client.MCPServer) []toolRow { if s.AutoApprove { detail += " · auto-approve" } - rows = append(rows, toolRow{kind: "mcp", text: s.Name, dim: detail, id: s.Name}) + rows = append(rows, toolRow{kind: "mcp", text: s.Name, dim: detail, id: s.Name, srv: &s}) } return rows } @@ -244,7 +255,7 @@ func buildCfgRows(cfg map[string]any, usage client.Usage, conns []client.Connect } sort.Strings(keys) for _, k := range keys { - rows = append(rows, cfgRow{kind: "cfg", k: k, v: scalarOrMarker(cfg[k])}) + rows = appendCfgRow(rows, k, cfg[k]) } } for _, c := range conns { @@ -282,6 +293,49 @@ func scalarOrMarker(v any) string { } } +// appendCfgRow flattens one level of a config value into list rows: +// scalars render as "k", a map's children as "k.child". Anything deeper +// (nested maps, slices) keeps a "·" marker with the raw JSON stashed in +// raw for the detail view. +func appendCfgRow(rows []cfgRow, k string, v any) []cfgRow { + if isList(v) { + return append(rows, cfgRow{kind: "cfg", k: k, v: "·", raw: rawJSON(v)}) + } + if sub, ok := v.(map[string]any); ok && len(sub) > 0 { + keys := make([]string, 0, len(sub)) + for ck := range sub { + keys = append(keys, ck) + } + sort.Strings(keys) + for _, ck := range keys { + kk, vv := k+"."+ck, sub[ck] + if _, nested := vv.(map[string]any); nested || isList(vv) { + rows = append(rows, cfgRow{kind: "cfg", k: kk, v: "·", raw: rawJSON(vv)}) + continue + } + rows = append(rows, cfgRow{kind: "cfg", k: kk, v: scalarOrMarker(vv)}) + } + return rows + } + r := cfgRow{kind: "cfg", k: k, v: scalarOrMarker(v)} + if _, ok := v.(map[string]any); ok { // empty map: marker with raw body + r.raw = rawJSON(v) + } + return append(rows, r) +} + +func isList(v any) bool { _, ok := v.([]any); return ok } + +// rawJSON pretty-prints a nested config value for the detail view; a +// marshal failure yields "" and the detail simply falls back to the row. +func rawJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "" + } + return string(b) +} + // ── memory actions ────────────────────────────────────────────────────────── func (m *Model) memSelected() *memRow { @@ -411,7 +465,7 @@ func (m *Model) memRowsRender(w int) []string { func (m *Model) skillRowsRender(w int) []string { th := m.th - rows := make([]string, 0, len(m.skills)) + rows := make([]string, 0, len(m.skills)*2) for i, s := range m.skills { badges := "" if s.NeedsReview { @@ -427,6 +481,11 @@ func (m *Model) skillRowsRender(w int) []string { prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(s.Name, budget)) } rows = append(rows, prefix+lab+th.acDetail.Render(detail)) + // A dim description line under each name: the list stays scannable + // while the skill's purpose is finally readable at a glance. + if d := strings.TrimSpace(s.Description); d != "" { + rows = append(rows, " "+th.acDim.Render(truncate(collapse(sanitize(d)), w-4))) + } } return rows } @@ -466,3 +525,153 @@ func (m *Model) cfgRowsRender(w int) []string { } return rows } + +// ── detail view (management tabs) ─────────────────────────────────────────── +// +// Enter on a list row expands it into a readable block: the full text the +// promote/delete gates assume the human can see. Esc folds back to the list +// with the selection intact. + +// mgmtPanel reports whether p is a management drawer tab. +func mgmtPanel(p panelMode) bool { + switch p { + case panelMemory, panelSkills, panelTools, panelConfig: + return true + } + return false +} + +// closeDetail folds the detail view back to the list, selection intact. +func (m *Model) closeDetail() { + m.panelDetail = false + m.detailScroll = 0 + m.refresh() +} + +// detailMaxScroll is the last scroll offset that keeps the final detail line +// on screen; scrolling stops there. +func (m *Model) detailMaxScroll() int { + visible := m.height - 5 // border(2) + title(1) + breathing room + if visible < 1 { + visible = 1 + } + return max(len(m.mgmtDetailLines(m.width-8))-visible, 0) +} + +// skillSelRow maps the selected skill to its visual row in the list, +// accounting for the description line some skills render below their name. +func (m *Model) skillSelRow() int { + row := 0 + for i := range m.skills { + if i == m.panelSel { + break + } + row++ + if strings.TrimSpace(m.skills[i].Description) != "" { + row++ + } + } + return row +} + +func (m *Model) toolSelected() *toolRow { + if m.panel == panelTools && m.panelSel < len(m.toolRows) { + return &m.toolRows[m.panelSel] + } + return nil +} + +// mgmtDetailLines renders the selected row's detail block, wrapped to w. +// Everything from the wire goes through sanitize(). +func (m *Model) mgmtDetailLines(w int) []string { + th := m.th + var out []string + switch m.panel { + case panelSkills: + s := m.skillSelected() + if s == nil { + return []string{th.acDim.Render("no skill selected")} + } + out = append(out, th.acSel.Render("› "+sanitize(s.Name))) + meta := []string{fmt.Sprintf("×%d used", s.UsageCount), sanitize(s.Source)} + if s.AutoLoad { + meta = append(meta, "auto-load") + } + if s.NeedsReview { + meta = append(meta, "needs review") + } + if s.Untrusted { + meta = append(meta, "untrusted") + } + out = append(out, th.acDetail.Render(strings.Join(meta, " · "))) + if d := strings.TrimSpace(s.Description); d != "" { + out = append(out, "") + out = append(out, wrapText(sanitize(d), w)...) + } + case panelMemory: + r := m.memSelected() + if r == nil { + return []string{th.acDim.Render("no row selected")} + } + if r.kind == "episode" { + out = append(out, th.acSel.Render("› pending episode")) + out = append(out, th.acDetail.Render("session "+sanitize(r.sessionID))) + } else { + out = append(out, th.acSel.Render("› "+sanitize(r.kind)+" fact")) + } + out = append(out, "") + out = append(out, wrapText(sanitize(r.text), w)...) + case panelTools: + r := m.toolSelected() + if r == nil { + return []string{th.acDim.Render("no row selected")} + } + if r.kind == "mcp" && r.srv != nil { + srv := r.srv + out = append(out, th.acSel.Render("› "+sanitize(srv.Name)+" · mcp server")) + cmd := sanitize(srv.Command) + for _, a := range srv.Args { + cmd += " " + sanitize(a) + } + out = append(out, th.acDetail.Render(cmd)) + var meta []string + if srv.Project { + meta = append(meta, "project-scoped") + } + if srv.AutoApprove { + meta = append(meta, "auto-approve") + } + if srv.TimeoutSeconds > 0 { + meta = append(meta, fmt.Sprintf("timeout %ds", srv.TimeoutSeconds)) + } + if srv.MaxResponseBytes > 0 { + meta = append(meta, fmt.Sprintf("max response %s", human(int(srv.MaxResponseBytes)))) + } + if srv.MaxResultChars > 0 { + meta = append(meta, fmt.Sprintf("max result %s chars", human(srv.MaxResultChars))) + } + if len(meta) > 0 { + out = append(out, th.acDetail.Render(strings.Join(meta, " · "))) + } + } else { + out = append(out, th.acSel.Render("› "+sanitize(r.text))) + state := "enabled" + if r.dim != "on" { + state = "disabled" + } + out = append(out, th.acDetail.Render("built-in tool · "+state)) + } + case panelConfig: + r := m.cfgSelected() + if r == nil { + return []string{th.acDim.Render("no row selected")} + } + out = append(out, th.acSel.Render("› "+sanitize(r.k))) + out = append(out, th.acDetail.Render(sanitize(r.v))) + if r.raw != "" { + out = append(out, "") + out = append(out, wrapText(sanitize(r.raw), w)...) + } + } + return out +} diff --git a/internal/tui/mgmt_detail_test.go b/internal/tui/mgmt_detail_test.go new file mode 100644 index 0000000..dfdd231 --- /dev/null +++ b/internal/tui/mgmt_detail_test.go @@ -0,0 +1,227 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestMgmtSkillDetailOnEnter verifies Enter expands the highlighted skill +// into a readable detail view (full description, provenance, usage) and esc +// folds it back with the selection intact — promote needs something to read. +func TestMgmtSkillDetailOnEnter(t *testing.T) { + m := wired(t) + m.Update(exec(m.openSkills())) + m.panelSel = 0 // deploy-helper (carries a description) + m.Update(key("enter")) + if !m.panelDetail { + t.Fatal("enter did not open the detail view") + } + out := plain(m.View()) + for _, want := range []string{"deploy-helper", "deploys", "regions", "~/.odek/skills"} { + if !strings.Contains(out, want) { + t.Errorf("skill detail missing %q:\n%s", want, out) + } + } + if !strings.Contains(out, "esc back") { + t.Errorf("detail footer missing the way back:\n%s", out) + } + + // Esc closes the detail but keeps the panel open and the row selected. + m.Update(key("esc")) + if m.panelDetail || m.panel != panelSkills || m.panelSel != 0 { + t.Fatalf("esc: detail=%v panel=%d sel=%d", m.panelDetail, m.panel, m.panelSel) + } + // Back on the list the long description is one truncated dim line: + // the tail is detail-only. + list := plain(m.View()) + if !strings.Contains(list, "deploys") { + t.Errorf("list view lost the description line:\n%s", list) + } + if strings.Contains(list, "regions") { + t.Errorf("list view shows the untruncated description:\n%s", list) + } +} + +// TestMgmtSkillDetailPromoteInPlace verifies p promotes straight from the +// detail view, and the post-action refetch folds the detail away. +func TestMgmtSkillDetailPromoteInPlace(t *testing.T) { + m := wired(t) + m.Update(exec(m.openSkills())) + m.panelSel = 1 // tainted-thing (needs review) + m.Update(key("enter")) + if !m.panelDetail { + t.Fatal("detail did not open") + } + _, cmd := m.Update(key("p")) + if cmd == nil { + t.Fatal("p did not fire promote from the detail view") + } + _, cmd2 := m.Update(exec(cmd)) // mgmtActionMsg → afterMgmtAction refetch + m.Update(exec(cmd2)) // mgmtMsg lands, tab rebuilt + if m.panelDetail { + t.Error("detail stayed open after the promote refetch") + } +} + +// TestMgmtMemoryDetail verifies facts and pending episodes expand, and q +// folds the detail like esc. +func TestMgmtMemoryDetail(t *testing.T) { + m := wired(t) + m.Update(exec(m.openMemory())) + + m.panelSel = 0 // a user fact + m.Update(key("enter")) + out := plain(m.View()) + if !strings.Contains(out, "user fact") || !strings.Contains(out, "prefers vim") { + t.Errorf("fact detail missing text:\n%s", out) + } + m.Update(key("q")) + if m.panelDetail { + t.Error("q did not fold the detail") + } + + m.panelSel = 2 // the pending episode + m.Update(key("enter")) + out = plain(m.View()) + if !strings.Contains(out, "pending episode") || !strings.Contains(out, "fixed the login bug") { + t.Errorf("episode detail missing summary:\n%s", out) + } +} + +// TestMgmtToolsDetail verifies built-in tools and MCP servers expand with +// their arguments. +func TestMgmtToolsDetail(t *testing.T) { + m := wired(t) + m.Update(exec(m.openTools())) + + m.panelSel = 0 // a built-in tool + m.Update(key("enter")) + if !strings.Contains(plain(m.View()), "shell") { + t.Errorf("tool detail missing name:\n%s", plain(m.View())) + } + + m.closeDetail() + m.panelSel = len(m.toolRows) - 1 // the MCP server row + m.Update(key("enter")) + out := plain(m.View()) + for _, want := range []string{"mcp-fs", "--ro"} { + if !strings.Contains(out, want) { + t.Errorf("mcp detail missing %q:\n%s", want, out) + } + } +} + +// TestMgmtConfigFlattenAndRawDetail verifies nested config values flatten one +// level in the list and deeper values render as indented JSON in the detail. +func TestMgmtConfigFlattenAndRawDetail(t *testing.T) { + rows := buildCfgRows(map[string]any{ + "model": "m", + "sandbox": map[string]any{"enabled": true}, + "deep": map[string]any{"a": map[string]any{"b": 1}}, + "servers": []any{"x"}, + "miss_me": "", + }, client.Usage{}, nil) + + want := map[string]string{ + "model": "m", + "sandbox.enabled": "true", + "deep.a": "·", // two levels down keeps the marker… + "servers": "·", // …as do slices + } + seen := map[string]string{} + for _, r := range rows { + seen[r.k] = r.v + } + for k, v := range want { + if seen[k] != v { + t.Errorf("cfg row %q = %q, want %q (rows: %v)", k, seen[k], v, seen) + } + } + + // The marker row keeps its raw value: the detail view shows the JSON. + m := newTestModel() + m.panel = panelConfig + m.cfgRows = rows + for i, r := range rows { + if r.k != "deep.a" { + continue + } + m.panelSel = i + } + m.panelDetail = true + out := plain(m.View()) + if !strings.Contains(out, `"b": 1`) { + t.Errorf("deep config detail missing JSON body:\n%s", out) + } +} + +// TestPanelDetailScrollAndTabs verifies detail scrolling clamps and tab +// switches (] [ and digits) leave the detail behind. +func TestPanelDetailScrollAndTabs(t *testing.T) { + m := wired(t) + m.Update(exec(m.openSkills())) + m.Update(key("enter")) + + m.detailScroll = 5 + m.Update(key("up")) + if m.detailScroll != 4 { + t.Errorf("up: scroll = %d, want 4", m.detailScroll) + } + m.detailScroll = 0 + m.Update(key("up")) + if m.detailScroll != 0 { + t.Errorf("up at top: scroll = %d, want clamp 0", m.detailScroll) + } + // Shrink the window so the skill detail overflows and scrolling has + // somewhere to go; down stops at the last line, not beyond. + m.Update(tea.WindowSizeMsg{Width: 100, Height: 8}) + for i := 0; i < 200; i++ { + m.Update(key("down")) + } + if m.detailScroll <= 0 || m.detailScroll != m.detailMaxScroll() { + t.Errorf("down: scroll = %d, want clamp at max %d", m.detailScroll, m.detailMaxScroll()) + } + m.Update(key("up")) + if m.detailScroll != m.detailMaxScroll()-1 { + t.Errorf("up: scroll = %d, want %d", m.detailScroll, m.detailMaxScroll()-1) + } + + // Digit jumps switch tabs and reset the detail. + m.Update(key("2")) // runs + if m.panelDetail || m.panel != panelRuns { + t.Errorf("digit jump: detail=%v panel=%d", m.panelDetail, m.panel) + } + m.Update(exec(m.openSkills())) + m.Update(key("enter")) + if !m.panelDetail { + t.Fatal("detail did not reopen") + } + m.Update(key("]")) // skills → tools + if m.panelDetail || m.panel != panelTools { + t.Errorf("]: detail=%v panel=%d", m.panelDetail, m.panel) + } +} + +// TestSkillSelRow accounts for description lines when windowing the skills +// list: item 1 with one description above sits on visual row 2. +func TestSkillSelRow(t *testing.T) { + m := newTestModel() + m.panel = panelSkills + m.skills = []client.Skill{ + {Name: "a", Description: "has one"}, + {Name: "b"}, + {Name: "c"}, + } + m.panelSel = 1 + if got := m.skillSelRow(); got != 2 { + t.Errorf("skillSelRow = %d, want 2", got) + } + m.panelSel = 2 + if got := m.skillSelRow(); got != 3 { + t.Errorf("skillSelRow(2) = %d, want 3 (desc above + row 0)", got) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index cfca0d5..fd6dff2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -158,7 +158,13 @@ type Model struct { models []client.ModelInfo panelSel int panelMsg string // status/error line inside a panel - popover bool // cockpit overlay (h): server/link/budget/session consolidation + // Detail submode for the management tabs: Enter expands the selected + // row into a readable block (skill description, full fact text, MCP + // args, raw config JSON) — the promote/delete gates assume the human + // can see what they are gating. + panelDetail bool // management tab: detail view open + detailScroll int // detail view top-line scroll offset + popover bool // cockpit overlay (h): server/link/budget/session consolidation // Sessions panel state: server-side search plus paged "load more". sessQuery string // applied search text (server-side substring match) diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 99ef08e..f167632 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -199,6 +199,8 @@ func (m *Model) closePanel() { m.panel = panelNone m.panelMsg = "" m.panelEdit = panelEditNone + m.panelDetail = false + m.detailScroll = 0 m.confirm = confirmNone m.relayout() m.refresh() @@ -211,6 +213,52 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.panelEdit != panelEditNone { return m.handlePanelEditKey(msg) } + // Detail submode: scrolling, folding, and the in-place promote; the + // drawer tab keys (] [ and digits) fall through and switch tabs, which + // resets the detail via the open* constructors. + if m.panelDetail && mgmtPanel(m.panel) { + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.closeDetail() + return m, nil + case "up", "ctrl+p", "k": + if m.detailScroll > 0 { + m.detailScroll-- + m.refresh() + } + return m, nil + case "down", "ctrl+n", "j": + if m.detailScroll < m.detailMaxScroll() { + m.detailScroll++ + m.refresh() + } + return m, nil + case "p": + if m.panel == panelSkills { + return m, m.skillPromote(false) + } + if m.panel == panelMemory { + return m, m.memPromoteSelected() + } + return m, nil + case "P": + if m.panel == panelSkills { + return m, m.skillPromote(true) + } + return m, nil + } + // Everything else is swallowed by the detail view — except the + // drawer navigation keys, which fall through to switch tabs. + s := msg.String() + drawerNav := s == "]" || s == "[" || s == "left" || s == "right" || + (len(s) == 1 && s[0] >= '1' && s[0] <= '9') + if !drawerNav { + return m, nil + } + } // Drawer-level keys: tab cycling and digit jumps work on every drawer tab. if drawerPanel(m.panel) { tabs := drawerTabs() @@ -542,8 +590,15 @@ func (m *Model) panelSelect() tea.Cmd { case panelEvents: return m.fetchEvents() case panelMemory, panelSkills, panelTools, panelConfig: - // Enter on management tabs refreshes the visible list. - return m.switchDrawerTab(m.panel) + // Enter expands the selected row into its detail view — the + // promote/delete gates assume the human can read what they gate. + if m.panelLen() == 0 { + return nil + } + m.panelDetail = true + m.detailScroll = 0 + m.refresh() + return nil } return nil } @@ -1036,7 +1091,23 @@ func (m *Model) renderPanel(w, h int) string { if m.panelMsg != "" { body += "\n" + th.acDim.Render(m.panelMsg) } - if len(rows) > 0 { + if m.panelDetail && mgmtPanel(m.panel) { + // Detail view replaces the list: window the wrapped block by the + // scroll offset, clamped so the final line stays reachable. + lines := m.mgmtDetailLines(w - 8) + visible := h - 5 // border(2) + title(1) + breathing room + if visible < 1 { + visible = 1 + } + if m.detailScroll > max(len(lines)-visible, 0) { + m.detailScroll = max(len(lines)-visible, 0) + } + win := lines + if len(lines) > visible { + win = lines[m.detailScroll : m.detailScroll+visible] + } + body += "\n" + strings.Join(win, "\n") + } else if len(rows) > 0 { // Window the rows around the selection to fit the available height. visible := h - 4 // border(2) + title(1) + breathing room if m.panelEdit != panelEditNone { @@ -1045,7 +1116,11 @@ func (m *Model) renderPanel(w, h int) string { if visible < 1 { visible = 1 } - body += "\n" + strings.Join(windowRows(rows, m.panelSel, visible), "\n") + sel := m.panelSel + if m.panel == panelSkills { + sel = m.skillSelRow() // description lines shift visual rows + } + body += "\n" + strings.Join(windowRows(rows, sel, visible), "\n") } // acBox is exactly the rounded brand box this panel used to hand-build. diff --git a/internal/tui/view.go b/internal/tui/view.go index 19f9368..4584372 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1052,7 +1052,15 @@ func (m *Model) footer() string { th.footer.Render("any other key cancels"), ) } + if m.panelDetail { + return m.panelFooter( + th.footer.Render("↑↓ scroll"), + th.footerKey.Render("p")+th.footer.Render(" promote episode"), + th.footer.Render("esc back"), + ) + } return m.panelFooter( + th.footer.Render("⏎ detail · "), th.footerKey.Render("a")+th.footer.Render(" add user · "), th.footerKey.Render("A")+th.footer.Render(" add env"), th.footerKey.Render("d")+th.footer.Render(" delete fact → y confirm"), @@ -1063,16 +1071,27 @@ func (m *Model) footer() string { ) } if m.panel == panelSkills { + if m.panelDetail { + return m.panelFooter( + th.footer.Render("↑↓ scroll"), + th.footerKey.Render("p")+th.footer.Render(" promote · "), + th.footerKey.Render("P")+th.footer.Render(" force-promote"), + th.footer.Render("esc back"), + ) + } return m.panelFooter( - th.footer.Render("↑↓ select · ]/[ tabs"), + th.footer.Render("↑↓ select · ⏎ detail · ]/[ tabs"), th.footerKey.Render("p")+th.footer.Render(" promote"), th.footerKey.Render("P")+th.footer.Render(" force-promote"), th.footer.Render("esc close"), ) } if m.panel == panelTools { + if m.panelDetail { + return m.panelFooter(th.footer.Render("↑↓ scroll · esc back")) + } return m.panelFooter( - th.footer.Render("↑↓ browse · ]/[ tabs · esc close"), + th.footer.Render("↑↓ browse · ⏎ detail · ]/[ tabs · esc close"), ) } if m.panel == panelConfig { @@ -1083,8 +1102,11 @@ func (m *Model) footer() string { th.footerKey.Render("esc")+th.footer.Render(" cancels"), ) } + if m.panelDetail { + return m.panelFooter(th.footer.Render("↑↓ scroll · esc back")) + } return m.panelFooter( - th.footer.Render("↑↓ browse · ]/[ tabs"), + th.footer.Render("↑↓ browse · ⏎ detail · ]/[ tabs"), th.footerKey.Render("d")+th.footer.Render(" kick connection"), th.footerKey.Render("S")+th.footerDanger.Render(" shutdown server"), th.footerKey.Render("r")+th.footer.Render(" refresh"),