diff --git a/cli/tui/README.md b/cli/tui/README.md index 83b97f05..b5f23195 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -32,7 +32,7 @@ providers, mutate private/source Git, or admit or change native queue work. | **Chat** | Durable multi-session transcript, Bubbles viewport and composer, streamed thought/answer channels, cached Glamour Markdown, per-session draft/scroll state, tool receipts, and hidden-session attention. | | **Work** | Durable Work CRUD, native provider dispatch, queue controls, ordered process output, blocked questions, retry/resume, and reviewed acceptance through the connected `go/agent/*` engine. Future actions remain visible with an exact unavailable reason. | | **Models** | Asynchronous discovery from the Hugging Face cache and `LEM_MODELS_DIR`, fuzzy filtering, explicit loading, and loaded-model detail. A swap is refused while any chat job is active and drains the HTTP listener before replacing an idle model. | -| **Service** | Start or stop the API over the exact serial model lane used by Chat; inspect address, model, request count, client URLs, and listener errors. Stopping the service never closes the model. | +| **Service** | Start or stop the API over the exact serial model lane used by Chat; inspect address, request count, client URLs, and listener errors. Stopping the service never closes the model. | | **Data** | Review surface for the `lem data` training-data loop (`~/.lem/datasets.duckdb`): item list with dataset/status/kind/source/score filters, side-by-side detail (rendered content, score breakdown, lineage, welfare flags), and the review actions — approve, reject, quarantine-clear (note required), edit-as-derived (original archived), tag — each with an uppercase bulk form across the current filter behind a count confirmation. Every action has a mirrored palette entry; unavailable states render honestly. | Settings, sampling modes, built-in tools, local knowledge, runtime detection, @@ -142,11 +142,11 @@ The Models panel (`picker.go` + `picker.ctml`) and the Tools tab - **A Bubbles-list screen keeps its state in `list.Model`** (items, cursor, fuzzy filter, pagination through `Update`) and derives its row bindings from it — the current page as one sequence, each row carrying its - selection class, marker, and box id (`id="model-{{row.id}}"`, keyed by - the model path discovery already de-duplicates on); the host truncates - each field to the row budget because the page math requires exactly one - line per `
` (a `
` wraps to the render width, and a wrapped row - would overflow the page the list delegate sized). + selection class, table-rule marker, and box id + (`id="model-{{row.id}}"`, keyed by the model path discovery already + de-duplicates on). The host pads Model / Engine / Snapshot path to fixed + terminal-cell widths and truncates each field so every model remains one + table line; Snapshot path keeps its tail, matching the canonical mockup. - **Adjacent single-line rows ride ONE `

` with a `
` closing each `` row** — separate block elements would gain blank separators. - **A section that appears only with data is an `` over a @@ -240,12 +240,12 @@ mirrored — its "render" builds the export document, not a screen). None composes a screen — like `preferences.go`, `markdown.go`, and `agentcap.go` before them, they have no rendering to migrate. -No TUI screen is genuinely columnar today. When one arrives, the -terminal renderer has a real table path — ``/``/`
` (and -`thead`/`th`) render through lipgloss/table as a bordered, -content-sized grid, and none of those tags is reserved by ctml — but a -label+detail row list is not a table: it stays on the `
` idiom -settings and the picker established. +Models is genuinely columnar, but the canonical design is a borderless, +full-width table. The terminal renderer's ``/``/`
` path uses +lipgloss/table's bordered, content-sized grid, so `picker.ctml` instead +renders host-padded columns as adjacent spans in one `

` with `
` row +separators. Label+detail forms remain on the `

` idiom used by Settings +and command-palette rows. ### The app shell @@ -362,10 +362,10 @@ list for the first time. `Filtering`, and Enter/Esc close the overlay before either ever reaches `list.Model`), so a header that vanished the instant the overlay opened would rarely be seen. -- **Rows are Picker's `
` idiom, not the Data list's dense `

`+`
` - rows**: each command is a two-line block (marker + title, indented - description), matching Picker's value-line/hint-line shape rather than - Data's one-line-per-item density. Selection styling rides the +- **Rows use the Settings-style `

` idiom, not the Data/Models dense + `

`+`
` rows**: each command is a two-line block (marker + title, + indented description), matching a value-line/hint-line form rather than + the primary lists' one-line-per-item density. Selection styling rides the row-scoped class bind (`class="{{row.state}}"`) with the marker glyph on the row; an unavailable command's "— unavailable: reason" suffix already lives in its description text (`commandListItem.Description`), @@ -380,7 +380,7 @@ list for the first time. `core.Command`/`CommandAction` at all. Adapting one to the other would mean building a registry bridge that does not exist, for a rendering-only slice; the palette instead composes the same generic - row-list idiom `picker.ctml` and `datalist.ctml` already established. + bound-row idioms the other `.ctml` screens established. - Bubbles' own chrome — the auto-generated help line, the dot pagination bar, the title-vs-filter swap — is replaced wholesale with host-owned markup, the same trade every earlier screen made: a literal "page x/y" diff --git a/cli/tui/app.go b/cli/tui/app.go index db641d10..82d2d638 100644 --- a/cli/tui/app.go +++ b/cli/tui/app.go @@ -42,6 +42,12 @@ type turn struct { calls []string // rendered tool-call receipts on an assistant turn } +const ( + chatEmptyNoModel = "○ no model loaded — open Models and choose one. The prompt below stays put either way." + chatNoModelNag = "○ no model — pick one in Models to send. You can still type." + chatNoModelInput = "select a model to start chatting…" +) + type app struct { boot bootState workspaceLoader func() core.Result @@ -380,9 +386,11 @@ func newApp(modelPath string, ctxLen, maxTokens int) app { sp.Spinner = spinner.MiniDot in := textarea.New() - in.Placeholder = "ask… (enter sends · esc cancels a reply · tab switches tabs · ctrl+c quits)" - in.SetHeight(3) + in.Prompt = "› " + in.Placeholder = chatNoModelInput + in.SetHeight(1) in.ShowLineNumbers = false + styleComposer(&in, styles, false) in.Focus() cfg := newSettings() @@ -1184,6 +1192,7 @@ func (a *app) rebuildTheme(selected theme) { return } a.styles = newUIStyles(selected) + styleComposer(&a.input, a.styles, a.model != nil) a.markdown = newMarkdownRenderer(selected.name) if a.switcher != nil { a.switcher.list.Styles.Title = a.styles.title @@ -1316,7 +1325,7 @@ func (a app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { offset := a.view.YOffset() a.width, a.height = msg.Width, msg.Height metrics := measureFrame(msg.Width, msg.Height, a.inspectorOpen) - a.picker.SetSize(max(1, metrics.mainWidth), max(1, metrics.mainHeight)) + resizeModelPicker(&a.picker, metrics.mainWidth, metrics.mainHeight) a.input.SetWidth(max(1, metrics.mainWidth-4)) a.view = viewport.New( viewport.WithWidth(max(1, metrics.mainWidth)), @@ -1354,6 +1363,8 @@ func (a app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.lane = laneResult.Value.(*modelLane) a.model, a.modelName = a.lane.Model(), msg.name + a.input.Placeholder = "message " + msg.name + "…" + styleComposer(&a.input, a.styles, true) a.loading = "" a.errText = "" a.activePanel = panelChat @@ -2217,7 +2228,7 @@ func (a *app) toggleInspector() { return } metrics := measureFrame(a.width, a.height, a.inspectorOpen) - a.picker.SetSize(max(1, metrics.mainWidth), max(1, metrics.mainHeight)) + resizeModelPicker(&a.picker, metrics.mainWidth, metrics.mainHeight) a.input.SetWidth(max(1, metrics.mainWidth-4)) a.view.SetWidth(max(1, metrics.mainWidth)) a.view.SetHeight(a.transcriptHeight()) @@ -2259,6 +2270,8 @@ func (a *app) beginModelLoad(path string) tea.Cmd { a.lane = nil a.model = nil a.modelName = "" + a.input.Placeholder = chatNoModelInput + styleComposer(&a.input, a.styles, false) a.loading = path a.activePanel = panelModels loader := a.modelLoader @@ -2268,6 +2281,27 @@ func (a *app) beginModelLoad(path string) tea.Cmd { return a.lifecycle.command(loader(path, a.cfg.contextLen())) } +// styleComposer applies the shell palette without replacing textarea state; +// the prompt shifts from muted to teal once a model is ready. +func styleComposer(input *textarea.Model, styles uiStyles, modelReady bool) { + if input == nil { + return + } + prompt := styles.status + if modelReady { + prompt = styles.accent + } + composerStyles := input.Styles() + composerStyles.Focused.Prompt = prompt + composerStyles.Blurred.Prompt = prompt + composerStyles.Focused.Placeholder = styles.status + composerStyles.Blurred.Placeholder = styles.status + composerStyles.Focused.Text = styles.answer + composerStyles.Blurred.Text = styles.answer + composerStyles.Cursor.Color = styles.theme.focus + input.SetStyles(composerStyles) +} + func (a *app) sendPrompt(prompt string) core.Result { if a == nil || a.model == nil { return core.Fail(core.E("tui.app.sendPrompt", "a loaded model is required", nil)) @@ -3277,17 +3311,15 @@ func (a app) panelView() string { case panelModels: return renderPicker(a.picker, measureFrame(a.width, a.height, a.inspectorOpen).mainWidth, a.styles) case panelService: - return a.svc.view(a.modelName, a.width, a.styles) + return a.svc.view(a.modelName, measureFrame(a.width, a.height, a.inspectorOpen).mainWidth, a.styles) case panelWork: if a.work == nil { return style.Column(style.Left, - a.styles.title.Render("Work"), + a.styles.title.Render("WORK")+" "+a.styles.status.Render("ACTIVE 0 · WAITING 0 · DONE 0"), + a.styles.thought.Render("Select a work item for its timeline and context."), "", a.styles.status.Render("○ No work yet"), - a.styles.thought.Render("Local work becomes durable when the workspace store connects."), - "", - a.styles.attention.Render("Agent actions unavailable"), - a.styles.thought.Render(defaultAgentUnavailableReason), + a.styles.thought.Render("A connected provider or workspace action will create work here. Open the inspector and dispatch one."), ) } metrics := measureFrame(a.width, a.height, a.inspectorOpen) @@ -3295,20 +3327,34 @@ func (a app) panelView() string { case panelData: if a.data == nil { return style.Column(style.Left, - a.styles.title.Render("Data"), - "", - a.styles.status.Render("○ No dataset store connected"), - a.styles.thought.Render("datasets.duckdb becomes available when the workspace store connects."), + a.styles.title.Render("DATA")+" "+a.styles.status.Render("0 items · sort date"), + a.styles.thought.Render("Select an item for its content, scores, and lineage."), "", - a.styles.attention.Render("Review actions unavailable"), - a.styles.thought.Render("open ~/.lem/datasets.duckdb via `lem data create`/`lem data import` first"), + a.styles.status.Render("○ No items match this filter"), + a.styles.thought.Render("Import or capture data with ")+ + a.styles.title.Render("lem data import")+ + a.styles.thought.Render(" or ")+ + a.styles.title.Render("lem serve --capture")+ + a.styles.thought.Render("."), ) } metrics := measureFrame(a.width, a.height, a.inspectorOpen) return a.data.View(metrics.mainWidth, metrics.mainHeight, a.styles) default: // chat if a.model == nil && a.loading == "" { - return "\n " + a.styles.status.Render("○ no model loaded — open Models and choose one") + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + emptyHeight := max(1, metrics.mainHeight-a.input.Height()-3) + empty := fitPane("\n "+a.styles.status.Render(chatEmptyNoModel), metrics.mainWidth, emptyHeight, a.styles.panel) + nagText := chatNoModelNag + if text, ok := core.CutPrefix(nagText, "○ "); ok { + nagText = text + } + nag := fitLine(" "+a.styles.attention.Render("○")+" "+a.styles.status.Render(nagText), metrics.mainWidth, a.styles.panel) + return style.Column(style.Left, + empty, + nag, + a.styles.inputBorder.Render(a.input.View()), + ) } if a.model == nil { return "\n " + a.spin.View() + a.styles.status.Render(" loading "+displayName(a.loading)+" …") @@ -3424,18 +3470,16 @@ func (a app) overlayView() string { func (a app) sessionStrip() string { if a.sessions == nil || len(a.sessions.Recent()) == 0 { - title := newSessionTitle for _, item := range a.turns { if item.role == "user" && core.Trim(item.text) != "" { - title = compactStripTitle(item.text, 24) - break + marker := "●" + if a.generating { + marker = "◉" + } + return core.Concat(marker, " ", compactStripTitle(item.text, 24)) } } - marker := "●" - if a.generating { - marker = "◉" - } - return core.Concat(marker, " ", title) + return "session 1" } recent := a.sessions.Recent() @@ -3447,13 +3491,19 @@ func (a app) sessionStrip() string { parts := make([]string, 0, shown+1) for _, session := range recent[:shown] { title := core.Trim(session.Record.Title) - if title == "" { - title = newSessionTitle + generic := title == "" || title == newSessionTitle + if generic { + title = core.Sprintf("session %d", sessionOrdinal(recent, session)) } - parts = append(parts, core.Concat(sessionStripMarker(session, a.sessions.activeID), " ", compactStripTitle(title, 24))) + marker := sessionStripMarker(session, a.sessions.activeID) + if generic && marker == "●" { + parts = append(parts, title) + continue + } + parts = append(parts, core.Concat(marker, " ", compactStripTitle(title, 24))) } hidden := len(recent) - shown - maxWidth := a.width - 14 + maxWidth := a.width - 2 - style.Measure("SESSIONS ● New session · ") if maxWidth < 20 { maxWidth = 20 } @@ -3474,6 +3524,23 @@ func (a app) sessionStrip() string { return core.Join(" · ", parts...) } +func sessionOrdinal(recent []*chatSession, target *chatSession) int { + if target == nil { + return 1 + } + ordinal := 1 + for _, candidate := range recent { + if candidate == nil || candidate == target { + continue + } + if candidate.Record.CreatedAt.Before(target.Record.CreatedAt) || + (candidate.Record.CreatedAt.Equal(target.Record.CreatedAt) && candidate.Record.ID < target.Record.ID) { + ordinal++ + } + } + return ordinal +} + func sessionStripMarker(session *chatSession, activeID string) string { if session == nil { return "○" @@ -3523,13 +3590,39 @@ func (a app) inspectorView() string { } func (a app) footerLine() string { - keys := "tab panels · ctrl+k commands · ctrl+o inspector · f2 settings · f1 help" - if chooseLayout(a.width) == layoutNarrow { - keys = "tab panels · ^K commands · ^O info · F2 settings · F1 help" + key := func(binding, label string) string { + return a.styles.title.Render(binding) + " " + a.styles.status.Render(label) + } + gap := " " + a.styles.separator.Render("·") + " " + fullKeys := core.Join(gap, + key("tab", "panels"), + key("ctrl+k", "commands"), + key("ctrl+o", "inspector"), + key("f1", "help"), + ) + keys := fullKeys + if a.width > 0 && chooseLayout(a.width) != layoutWide { + keys = core.Join(gap, + a.styles.title.Render("tab"), + a.styles.title.Render("ctrl+k"), + a.styles.title.Render("ctrl+o"), + a.styles.title.Render("f1"), + ) } status := a.statusLine() if status == "" { return keys } - return status + " │ " + keys + if a.width <= 0 { + return status + " │ " + fullKeys + } + if chooseLayout(a.width) != layoutWide { + return status + " │ " + keys + } + innerWidth := measureFrame(a.width, a.height, a.inspectorOpen).innerWidth + statusWidth := innerWidth - style.Measure(fullKeys) - style.Measure(" │ ") + if statusWidth <= 0 { + return fullKeys + } + return fitLine(status, statusWidth, a.styles.status) + " │ " + fullKeys } diff --git a/cli/tui/app_test.go b/cli/tui/app_test.go index 91adfaa7..1056122e 100644 --- a/cli/tui/app_test.go +++ b/cli/tui/app_test.go @@ -935,7 +935,86 @@ func TestAppComposerNewline_Good(t *testing.T) { } } +func TestAppPanelView_ChatEmptyStateMatchesCanonicalCopy(t *testing.T) { + a := newApp("", 0, 64) + model, _ := a.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + a = model.(app) + + view := ansi.Strip(a.panelView()) + compact := strings.Join(strings.Fields(view), " ") + for _, want := range []string{ + "○ no model loaded — open Models and choose one. The prompt below stays put either way.", + "○ no model — pick one in Models to send. You can still type.", + "›", + "select a model to start chatting…", + } { + if !strings.Contains(compact, want) { + t.Fatalf("empty Chat panel missing %q:\n%s", want, view) + } + } + + model, _ = a.Update(testTextPress('x')) + a = model.(app) + if got := a.input.Value(); got != "x" { + t.Fatalf("no-model composer value = %q, want typing to remain available", got) + } +} + +func TestAppPanelView_WorkAndDataEmptyStatesMatchCanonicalCopy(t *testing.T) { + a := newApp("", 0, 64) + + a.activePanel = panelWork + workView := ansi.Strip(a.panelView()) + for _, want := range []string{ + "WORK ACTIVE 0 · WAITING 0 · DONE 0", + "○ No work yet", + "A connected provider or workspace action will create work here. Open the inspector and dispatch one.", + } { + if !strings.Contains(workView, want) { + t.Fatalf("empty Work panel missing %q:\n%s", want, workView) + } + } + + a.activePanel = panelData + dataView := ansi.Strip(a.panelView()) + for _, want := range []string{ + "DATA 0 items · sort date", + "○ No items match this filter", + "Import or capture data with lem data import or lem serve --capture.", + } { + if !strings.Contains(dataView, want) { + t.Fatalf("empty Data panel missing %q:\n%s", want, dataView) + } + } +} + +func TestAppFooterLine_Good(t *testing.T) { + a := newApp("", 0, 64) + footer := ansi.Strip(a.footerLine()) + want := "tab panels · ctrl+k commands · ctrl+o inspector · f1 help" + if !strings.Contains(footer, want) || strings.Contains(footer, "settings") { + t.Fatalf("footer = %q, want canonical key strip %q", footer, want) + } + + a.width, a.height = 140, 30 + footer = ansi.Strip(a.footerLine()) + if !strings.HasSuffix(footer, want) { + t.Fatalf("sized footer must preserve its right-hand key strip: %q", footer) + } + if got, limit := lipgloss.Width(footer), measureFrame(a.width, a.height, a.inspectorOpen).innerWidth; got > limit { + t.Fatalf("footer width = %d, exceeds frame inner width %d: %q", got, limit, footer) + } + if frame := ansi.Strip(a.View().Content); !strings.Contains(frame, want) { + t.Fatalf("rendered wide frame clipped the canonical key strip:\n%s", frame) + } +} + func TestAppSessionStrip_Good(t *testing.T) { + unmanaged := newApp("", 0, 64) + if strip := unmanaged.sessionStrip(); strip != "session 1" { + t.Fatalf("initial session chip = %q, want %q", strip, "session 1") + } + manager := openTestSessionManager(t, sequenceIDs("session-one", "session-two", "session-three")) first := manager.Create().Value.(*chatSession) second := manager.Create().Value.(*chatSession) @@ -2251,7 +2330,7 @@ func TestApp_AttachDataWiresPanelAndPalette(t *testing.T) { } } -func TestApp_AttachDataNilStoreLeavesPanelHonestlyUnavailable(t *testing.T) { +func TestApp_AttachDataNilStoreLeavesCanonicalEmptyPanel(t *testing.T) { a := newApp("", 0, 64) if result := a.attachData(nil); !result.OK { t.Fatalf("attachData(nil): %v", result.Value) @@ -2260,8 +2339,11 @@ func TestApp_AttachDataNilStoreLeavesPanelHonestlyUnavailable(t *testing.T) { t.Fatalf("attachData(nil) left a.data non-nil: %#v", a.data) } a.activePanel = panelData - view := a.panelView() - if !strings.Contains(view, "No dataset store connected") { + view := ansi.Strip(a.panelView()) + compact := strings.Join(strings.Fields(view), " ") + if !strings.Contains(compact, "DATA 0 items · sort date") || + !strings.Contains(compact, "○ No items match this filter") || + !strings.Contains(compact, "Import or capture data with lem data import or lem serve --capture.") { t.Fatalf("panelView with no data store:\n%s", view) } } diff --git a/cli/tui/datalist.ctml b/cli/tui/datalist.ctml index 696323af..0a6ea9a8 100644 --- a/cli/tui/datalist.ctml +++ b/cli/tui/datalist.ctml @@ -37,5 +37,5 @@ -->

DATA {{meta}} filter {{row.expr}}

-

{{row.cursor}}{{row.status}} {{row.meta}} {{row.slug}}

○ No items match this filter

Import or capture data with `lem data import` / `lem serve --capture`.

/ filter · j/k select · f filters · s sort · a/r/c/e/t act · A/R/C/T bulk

+

{{row.cursor}}{{row.status}} {{row.meta}} {{row.slug}}

○ No items match this filter

Import or capture data with

lem data import or

lem serve --capture.

/ filter · j/k select · f filters · s sort · a/r/c/e/t act · A/R/C/T bulk

diff --git a/cli/tui/datapanel.go b/cli/tui/datapanel.go index 6bc42f49..e24eb9ba 100644 --- a/cli/tui/datapanel.go +++ b/cli/tui/datapanel.go @@ -599,16 +599,17 @@ func dataListTheme(styles uiStyles) *html.TermTheme { theme.Header = style.New() theme.Footer = style.New() theme.Classes = map[string]style.Style{ - "list-title": styles.title, - "list-meta": styles.status, - "list-filter": styles.thought, - "row-active": styles.accent, - "row-idle": styles.answer, - "row-status": styles.status, - "row-meta": styles.thought, - "list-empty": styles.status, - "list-hint": styles.thought, - "list-keys": styles.thought, + "list-title": styles.title, + "list-meta": styles.status, + "list-filter": styles.thought, + "row-active": styles.accent, + "row-idle": styles.answer, + "row-status": styles.status, + "row-meta": styles.thought, + "list-empty": styles.status, + "list-hint": styles.thought, + "list-command": styles.title, + "list-keys": styles.thought, } return theme } diff --git a/cli/tui/datapanel_test.go b/cli/tui/datapanel_test.go index e423ea7b..d52f90a8 100644 --- a/cli/tui/datapanel_test.go +++ b/cli/tui/datapanel_test.go @@ -665,8 +665,13 @@ func TestDataPanel_View_Empty(t *testing.T) { store := dataset.NewMemoryStore() opened := newDataPanel(store, nil, nil, nil) panel := opened.Value.(*dataPanel) - view := panel.View(100, 24, newUIStyles(midnightTheme())) - if !strings.Contains(view, "No items match this filter") || !strings.Contains(view, "Select an item") { + view := ansi.Strip(panel.View(100, 24, newUIStyles(midnightTheme()))) + compact := strings.Join(strings.Fields(view), " ") + wantHint := "Import or capture data with lem data import or lem serve --capture." + if !strings.Contains(compact, "DATA 0 items · sort date") || + !strings.Contains(compact, "○ No items match this filter") || + !strings.Contains(compact, wantHint) || + !strings.Contains(compact, "Select an item for its content, scores, and lineage.") { t.Fatalf("empty view:\n%s", view) } } diff --git a/cli/tui/inspector.go b/cli/tui/inspector.go index 8b467851..a9497ec3 100644 --- a/cli/tui/inspector.go +++ b/cli/tui/inspector.go @@ -206,14 +206,14 @@ func (inspector inspectorState) bindings(target app) ctml.Bindings { } sequences["modelsHead"] = modelsHead loadedClass, loaded := "c-status", "○ none" - if target.modelName != "" { - loadedClass, loaded = "c-success", "● "+target.modelName + if selected, ok := target.picker.SelectedItem().(modelItem); ok && target.modelName == selected.name { + loadedClass, loaded = "c-success", "● loaded" } sequences["modelsBody"] = append(sequences["modelsBody"], map[string]any{"loadedClass": loadedClass, "loaded": loaded}) case panelService: stateClass, state := "c-status", "○ stopped" if target.svc.running { - stateClass, state = "c-success", "● serving" + stateClass, state = "c-success", "● listening" } sequences["serviceBody"] = append(sequences["serviceBody"], map[string]any{ "addr": target.svc.addr(), @@ -246,7 +246,7 @@ func (inspector inspectorState) chatBindings(target app, sequences map[string][] } generation := "○ idle" if target.generating { - generation = "◉ generating" + generation = "● generating" } sequences["chatBody"] = append(sequences["chatBody"], map[string]any{"model": model, "generation": generation}) sequences["chatSettings"] = append(sequences["chatSettings"], diff --git a/cli/tui/inspector_test.go b/cli/tui/inspector_test.go index 806edc79..d98bf8a3 100644 --- a/cli/tui/inspector_test.go +++ b/cli/tui/inspector_test.go @@ -17,6 +17,7 @@ func TestInspector_Good(t *testing.T) { a := newApp("", 0, 64) a.modelName = "gemma-4" a.generating = true + a.svc.running = true a.svc.requests.Store(7) a.picker.SetItems([]list.Item{modelItem{path: "/models/qwen", name: "qwen", modelType: "qwen3"}}) @@ -24,10 +25,10 @@ func TestInspector_Good(t *testing.T) { panel panelID want []string }{ - {panelChat, []string{"SESSION", "MODEL", "GENERATION", "SETTINGS", "MODE", "TOOLS", "gemma-4"}}, + {panelChat, []string{"SESSION", "MODEL", "GENERATION", "● generating", "SETTINGS", "MODE", "TOOLS", "gemma-4"}}, {panelWork, []string{"WORK DETAIL", "RUNTIME", "AGENT CAPABILITY", "not installed"}}, - {panelModels, []string{"MODEL DETAIL", "qwen", "/models/qwen"}}, - {panelService, []string{"ADDRESS", "REQUESTS", a.svc.addr(), "7"}}, + {panelModels, []string{"MODEL DETAIL", "qwen", "/models/qwen", "LOADED", "○ none"}}, + {panelService, []string{"ADDRESS", "REQUESTS", "STATE", a.svc.addr(), "7", "● listening"}}, } for _, test := range tests { a.activePanel = test.panel @@ -38,6 +39,12 @@ func TestInspector_Good(t *testing.T) { } } } + + a.activePanel = panelModels + a.modelName = "qwen" + if view := a.inspector.View(a, 36, 20); !strings.Contains(view, "● loaded") { + t.Fatalf("selected loaded model must use the canonical loaded receipt:\n%s", view) + } } func TestInspector_Bad(t *testing.T) { @@ -47,7 +54,7 @@ func TestInspector_Bad(t *testing.T) { a.activePanel = panelWork a.inspectorOpen = true view := a.View().Content - if !strings.Contains(view, "Work") || !strings.Contains(view, "AGENT CAPABILITY") { + if !strings.Contains(view, "WORK") || !strings.Contains(view, "AGENT CAPABILITY") { t.Fatalf("overlay inspector did not retain main Work panel:\n%s", view) } } diff --git a/cli/tui/layout.go b/cli/tui/layout.go index cfdfddd2..7ac4ceb2 100644 --- a/cli/tui/layout.go +++ b/cli/tui/layout.go @@ -263,7 +263,7 @@ func renderWideLayout(src []byte, width int, theme *html.TermTheme, bindings ... // pair's divider too. See shellinspectorpair.ctml's header comment and // renderInspectorStack's own comment for the full account. // -// The tab strip and session strip (both already fully styled, single-line, +// The tab strip and the composed SESSIONS action/chip band (both single-line, // pre-fitted to metrics.innerWidth) join as the shell's one "header" value; // the status/key-hint line is the "footer" value. Verbatim is emitted // exactly as supplied with no added blank, so the "\n" join building header @@ -278,7 +278,16 @@ func renderFrame(spec frameSpec, styles uiStyles) string { } metrics := measureFrame(spec.Width, spec.Height, spec.InspectorOpen) tabstrip := renderPanelBar(spec.Active, metrics.innerWidth, metrics.kind, styles) - sessions := fitLine(styles.title.Render("SESSIONS")+" "+styles.session.Render(spec.SessionStrip), metrics.innerWidth, styles.session) + sessionLine := styles.title.Render("SESSIONS") + + " " + styles.accent.Render("●") + " " + styles.session.Render("New session") + if core.Trim(spec.SessionStrip) != "" { + chipStyle := styles.session + if core.Trim(spec.SessionStrip) == "session 1" { + chipStyle = styles.accent + } + sessionLine += " " + styles.separator.Render("·") + " " + chipStyle.Render(spec.SessionStrip) + } + sessions := fitLine(sessionLine, metrics.innerWidth, styles.session) header := core.Join("\n", tabstrip, sessions) footer := fitLine(spec.Footer, metrics.innerWidth, styles.footer) diff --git a/cli/tui/layout_test.go b/cli/tui/layout_test.go index 41c2ec7b..94acee62 100644 --- a/cli/tui/layout_test.go +++ b/cli/tui/layout_test.go @@ -88,12 +88,15 @@ func TestWorkspaceFrame_Good(t *testing.T) { Width: 140, Height: 26, Active: panelChat, - SessionStrip: "● New session ○ Refactor scheduler", + SessionStrip: "session 1", Main: "MAIN REGION\nchat transcript", Inspector: "INSPECTOR\nmodel ready", Footer: "FOOTER ctrl+k commands", }, styles) assertFrameBasics(t, view, 140) + if plain := ansi.Strip(view); !strings.Contains(plain, "SESSIONS ● New session · session 1") { + t.Fatalf("frame missing the canonical New session action and current session chip:\n%s", plain) + } if !strings.Contains(view, "INSPECTOR") { t.Fatal("wide frame did not render its permanent inspector") } diff --git a/cli/tui/palette.ctml b/cli/tui/palette.ctml index 219ce2d5..c1e20e7b 100644 --- a/cli/tui/palette.ctml +++ b/cli/tui/palette.ctml @@ -23,8 +23,8 @@ visible commands, ONE row per command {state, marker, id, title, detail} — selection styling rides the row-scoped class bind class="{{row.state}}" (go-html v0.13.0) and the marker glyph - rides the row (› active, ○ idle), the same idiom picker.ctml - established. detail already carries the "— unavailable: reason" + rides the row (› active, ○ idle), the same form-row idiom + settings.ctml uses. detail already carries the "— unavailable: reason" suffix (commandListItem.Description), so an unavailable command paints identically to an available one — matching the original list.DefaultDelegate, which never styled the two states apart. diff --git a/cli/tui/palette.go b/cli/tui/palette.go index c61e70fc..1b5c9a3a 100644 --- a/cli/tui/palette.go +++ b/cli/tui/palette.go @@ -165,8 +165,8 @@ var paletteCTML []byte // commandPaletteBindings derives the palette's rows from the list model's // own state: the current page of visible commands as ONE sequence — // selection styling rides the row-scoped class bind (class="{{row.state}}", -// go-html v0.13.0) and the marker glyph rides the row, the same idiom -// picker.ctml established — plus the zero-or-one-row conditional sections +// go-html v0.13.0) and the marker glyph rides the row, the same form-row +// idiom settings.ctml uses — plus the zero-or-one-row conditional sections // for the empty and page states. detail already carries the // "— unavailable: reason" suffix (commandListItem.Description), so an // unavailable command renders with the same row-idle/row-active paint an @@ -219,7 +219,7 @@ func commandPaletteBindings(palette *commandPalette, width int) ctml.Bindings { // palette. It carries the same band chrome every overlay uses // (overlayFrameTheme's blank header / one-row-padded footer, so the // palette's key-hint line keeps the same gap every overlay draws above its -// footer) plus the row-selection classes picker.ctml established, since +// footer) plus the shared row-selection classes, since // the palette is the first overlay to carry a selectable row list — a // plain renderOverlayFrame call has no seam for those extra classes, so // this theme is built directly and rendered through renderBandFrame, the diff --git a/cli/tui/picker.ctml b/cli/tui/picker.ctml index 7cedd303..12bf0b4b 100644 --- a/cli/tui/picker.ctml +++ b/cli/tui/picker.ctml @@ -1,48 +1,28 @@
-

Models

-

Filter: {{row.value}}

-
-
{{row.marker}} {{row.name}}
-
{{row.detail}}
-
-

{{row.text}}

-

{{row.label}}

-

↑/↓ select · / filter · enter load

+

MODELS {{count}} / filter by name… / {{row.value}}

{{headName}}{{headEngine}}{{headPath}}

{{row.marker}}{{row.name}}{{row.engine}}{{row.path}}

○ No local models found

{{row.label}}

diff --git a/cli/tui/picker.go b/cli/tui/picker.go index 3c45ff08..5a74ebc2 100644 --- a/cli/tui/picker.go +++ b/cli/tui/picker.go @@ -78,19 +78,35 @@ func displayName(path string) string { return core.PathBase(path) } +const modelPickerChromeRows = 3 + // newPicker builds the model list. The list.Model owns picker STATE only — -// items, cursor, filter, pagination — and its default delegate survives as -// the page-size driver (height 2 + spacing 1, the same three cells a -// picker.ctml row occupies); rendering goes through renderPicker, so the -// list carries no styling of its own. +// items, cursor, filter, pagination — while picker.ctml renders the mockup's +// dense one-line table rows. func newPicker() list.Model { - l := list.New(nil, list.NewDefaultDelegate(), 0, 0) + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(0) + l := list.New(nil, delegate, 0, 0) l.Title = "Models" + l.SetShowTitle(false) + l.SetShowFilter(false) l.SetShowStatusBar(false) + l.SetShowPagination(false) + l.SetShowHelp(false) l.SetFilteringEnabled(true) return l } +// resizeModelPicker reserves the three structural rows owned by picker.ctml: +// its toolbar, table heading, and optional page receipt. +func resizeModelPicker(picker *list.Model, width, height int) { + if picker == nil { + return + } + picker.SetSize(max(1, width), max(1, height-modelPickerChromeRows)) +} + // pickerCTML is the Models panel's markup — see picker.ctml for the seams it // exposes (row/filter/empty/page sequences, class tokens, the model-picker // block id). @@ -98,61 +114,108 @@ func newPicker() list.Model { //go:embed picker.ctml var pickerCTML []byte -// modelPickerBindings derives the panel's rows from the list model's own -// state: the current page of visible items as ONE sequence — selection -// styling rides the row-scoped class bind (class="{{row.state}}", go-html -// v0.13.0) and the marker glyph rides the row, so no before/active/after -// split is needed — plus the zero-or-one-row conditional sections for the -// typed filter, the empty state, and the page position. Each row also -// binds its box id (the model path — unique, discovery de-duplicates on -// it). Name and hint are truncated host-side to the row budget because the -// page math budgets exactly three cells per row — a
wraps to the -// render width, and a wrapped row would overflow the page the list -// delegate sized. +// modelPickerBindings derives the panel's toolbar and current page from the +// list model. Table columns are padded host-side because terminal markup has +// no borderless fixed-grid primitive; state, filtering, and pagination stay +// entirely in list.Model. func modelPickerBindings(picker list.Model, width int) ctml.Bindings { sequences := map[string][]map[string]any{ - "filter": {}, - "rows": {}, - "empty": {}, - "page": {}, + "filterIdle": {}, + "filterActive": {}, + "rows": {}, + "empty": {}, + "page": {}, } - if picker.FilterState() == list.Filtering { - sequences["filter"] = append(sequences["filter"], map[string]any{"value": picker.FilterValue()}) + filterValue := core.Trim(picker.FilterValue()) + if filterValue == "" { + sequences["filterIdle"] = append(sequences["filterIdle"], map[string]any{}) + } else { + sequences["filterActive"] = append(sequences["filterActive"], map[string]any{"value": filterValue}) } + visible := picker.VisibleItems() + count := core.Sprintf("%d local", len(picker.Items())) + if filterValue != "" { + count = core.Sprintf("%d of %d", len(visible), len(picker.Items())) + } + start, end := picker.Paginator.GetSliceBounds(len(visible)) active := picker.Index() - start - budget := max(1, width-2) + nameWidth, engineWidth, pathWidth := modelPickerColumnWidths(width) for index, item := range visible[start:end] { entry, ok := item.(list.DefaultItem) if !ok { continue } - state, marker := "row-idle", "○" + state, marker := "row-idle", " " if index == active { - state, marker = "row-active", "›" + state, marker = "row-active", "│ " } id := entry.Title() + name, engine, path := entry.Title(), "", entry.Description() if model, ok := item.(modelItem); ok { id = model.path + name, engine, path = model.name, model.modelType, model.path } sequences["rows"] = append(sequences["rows"], map[string]any{ "state": state, "marker": marker, "id": id, - "name": style.Truncate(entry.Title(), budget, "…"), - "detail": style.Truncate(entry.Description(), budget, "…"), + "name": padPickerCell(style.Truncate(name, max(1, nameWidth-2), "…"), max(1, nameWidth-2)), + "engine": padPickerCell(style.Truncate(engine, engineWidth, "…"), engineWidth), + "path": padPickerCell(truncatePickerTail(path, pathWidth), pathWidth), }) } - if len(visible) == 0 && picker.FilterState() != list.Filtering { - sequences["empty"] = append(sequences["empty"], map[string]any{"text": "No items."}) + if len(picker.Items()) == 0 { + sequences["empty"] = append(sequences["empty"], map[string]any{}) } if picker.Paginator.TotalPages > 1 { sequences["page"] = append(sequences["page"], map[string]any{ "label": core.Sprintf("page %d/%d", picker.Paginator.Page+1, picker.Paginator.TotalPages), }) } - return ctml.Bindings{Sequences: sequences} + return ctml.Bindings{ + Sequences: sequences, + Values: map[string]any{ + "count": count, + "headName": " " + padPickerCell("Model", max(1, nameWidth-2)), + "headEngine": padPickerCell("Engine", engineWidth), + "headPath": padPickerCell("Snapshot path", pathWidth), + }, + } +} + +func modelPickerColumnWidths(width int) (name, engine, path int) { + width = max(1, width) + name = max(8, width*31/100) + engine = max(7, width*15/100) + if name+engine >= width { + name = max(1, width/2) + engine = max(1, min(7, width-name-1)) + } + path = max(1, width-name-engine) + return name, engine, path +} + +func padPickerCell(value string, width int) string { + width = max(1, width) + value = style.Truncate(value, width, "…") + return value + core.Repeat(" ", max(0, width-style.Measure(value))) +} + +func truncatePickerTail(value string, width int) string { + width = max(1, width) + if style.Measure(value) <= width { + return value + } + if width == 1 { + return "…" + } + runes := []rune(value) + for len(runes) > 0 && style.Measure("…"+string(runes)) > width { + runes = runes[1:] + } + return "…" + string(runes) } // modelPickerTheme maps the markup's class tokens onto the existing palette, @@ -160,27 +223,28 @@ func modelPickerBindings(picker list.Model, width int) ctml.Bindings { func modelPickerTheme(styles uiStyles) *html.TermTheme { theme := html.DefaultTermTheme() theme.Text = styles.answer - theme.Heading = styles.title // the

panel title theme.Classes = map[string]style.Style{ - "row-idle": styles.answer, - "row-active": styles.accent, - "row-hint": styles.thought, + "picker-title": styles.title, + "picker-count": styles.status, + "table-heading": styles.status, + "row-idle": styles.title, + "row-active": styles.accent.Bold(true), + "row-engine": styles.status, + "row-path": styles.thought, "filter-prompt": styles.accent, "filter-value": styles.answer, + "filter-idle": styles.status, "picker-empty": styles.status, "picker-page": styles.status, - "picker-keys": styles.status, } return theme } // renderPicker parses picker.ctml with bindings derived from the list -// model's current state and renders it through the go-html terminal -// renderer: the panel title, the filter line while filtering, one
row -// per model on the current page (marker + name, indented type/path hint), -// the empty and page states, and the key footer. Cursor movement, fuzzy -// filtering, and pagination stay in list.Model — this is the render swap -// over a.picker.Update. +// model's current state and renders it through the go-html terminal renderer: +// toolbar, borderless Model / Engine / Snapshot path table, empty state, and +// page receipt. Cursor movement, fuzzy filtering, and pagination stay in +// list.Model — this is the render swap over a.picker.Update. func renderPicker(picker list.Model, width int, styles uiStyles) string { if width <= 0 { return "" diff --git a/cli/tui/picker_test.go b/cli/tui/picker_test.go index 15e2b6b0..334c6fee 100644 --- a/cli/tui/picker_test.go +++ b/cli/tui/picker_test.go @@ -32,44 +32,53 @@ func TestRenderPicker_Good(t *testing.T) { plain := ansi.Strip(view) lines := strings.Split(plain, "\n") - if strings.TrimSpace(lines[0]) != "Models" { - t.Fatalf("panel must open with its title line: %q", lines[0]) + if !strings.Contains(lines[0], "MODELS 3 local") || !strings.Contains(lines[0], "/ filter by name…") { + t.Fatalf("panel must open with its canonical toolbar: %q", lines[0]) } - if strings.TrimSpace(lines[1]) != "" { - t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + if !strings.Contains(plain, "/ filter by name…") { + t.Fatalf("panel must keep the canonical filter prompt visible: %q", plain) } - if !strings.Contains(plain, "› beta") { - t.Fatalf("selected row must carry the active marker: %q", plain) - } - for _, text := range []string{"○ alpha", "○ gamma"} { - if !strings.Contains(plain, text) { - t.Fatalf("unselected row missing the idle marker %q: %q", text, plain) + header := "" + for _, line := range lines { + if strings.Contains(line, "Model") && strings.Contains(line, "Engine") && strings.Contains(line, "Snapshot path") { + header = line + break } } - for _, text := range []string{"fake /models/alpha", "fake /models/beta", "fake /models/gamma"} { - if !strings.Contains(plain, text) { - t.Fatalf("row missing the type/path hint %q: %q", text, plain) - } + if header == "" || strings.Index(header, "Model") >= strings.Index(header, "Engine") || strings.Index(header, "Engine") >= strings.Index(header, "Snapshot path") { + t.Fatalf("canonical table headings missing or out of order: %q", plain) } - if !strings.Contains(plain, "↑/↓ select · / filter · enter load") { - t.Fatalf("panel missing the key footer: %q", plain) + if !strings.Contains(plain, "│ beta") { + t.Fatalf("selected row must carry the table selection rule: %q", plain) } if strings.Contains(plain, "No items.") || strings.Contains(plain, "page ") { t.Fatalf("a populated single page must carry no empty or page state: %q", plain) } - row := -1 - for index, line := range lines { - if strings.Contains(line, "› beta") { - row = index - break + for _, model := range []struct { + name, engine, path string + }{ + {"alpha", "fake", "/models/alpha"}, + {"beta", "fake", "/models/beta"}, + {"gamma", "fake", "/models/gamma"}, + } { + row := "" + for _, line := range lines { + if strings.Contains(line, model.name) { + row = line + break + } + } + if row == "" { + t.Fatalf("table row for %q missing: %q", model.name, plain) + } + nameAt, engineAt, pathAt := strings.Index(row, model.name), strings.Index(row, model.engine), strings.Index(row, model.path) + if nameAt < 0 || engineAt <= nameAt || pathAt <= engineAt { + t.Fatalf("table row %q does not keep Model / Engine / Snapshot path order", row) } } - if row < 0 || row+1 >= len(lines) { - t.Fatalf("selected row not found in the rendered lines: %q", plain) - } - if !strings.Contains(lines[row+1], "fake /models/beta") { - t.Fatalf("hint must sit directly beneath its name line: %q", lines[row+1]) + if strings.Contains(plain, "○ alpha") || strings.Contains(plain, "○ gamma") { + t.Fatalf("table rows must not retain the old list-item circle markers: %q", plain) } first := strings.Index(plain, "alpha") @@ -103,7 +112,7 @@ func TestRenderPicker_Ugly(t *testing.T) { empty := newPicker() empty.SetSize(40, 20) plain := ansi.Strip(renderPicker(empty, 40, styles)) - if !strings.Contains(plain, "No items.") { + if !strings.Contains(plain, "MODELS 0 local") || !strings.Contains(plain, "/ filter by name…") || !strings.Contains(plain, "No local models found") { t.Fatalf("an empty picker must render its empty state: %q", plain) } @@ -116,17 +125,17 @@ func TestRenderPicker_Ugly(t *testing.T) { filtered.SetFilterText("beta") filtered.SetFilterState(list.Filtering) plain = ansi.Strip(renderPicker(filtered, 80, styles)) - if !strings.Contains(plain, "Filter: beta") { + if !strings.Contains(plain, "/ beta") || !strings.Contains(plain, "1 of 2") { t.Fatalf("filtering must surface the typed filter: %q", plain) } - if !strings.Contains(plain, "› beta") || strings.Contains(plain, "alpha") { + if !strings.Contains(plain, "│ beta") || strings.Contains(plain, "alpha") { t.Fatalf("filtering must render matches only, cursor on the first: %q", plain) } filtered.SetFilterText("zzz") filtered.SetFilterState(list.Filtering) plain = ansi.Strip(renderPicker(filtered, 80, styles)) - if strings.Contains(plain, "No items.") || strings.Contains(plain, "○ ") { - t.Fatalf("a match-free filter renders no rows and no empty mislabel: %q", plain) + if !strings.Contains(plain, "0 of 2") || strings.Contains(plain, "No local models found") || strings.Contains(plain, "○ ") { + t.Fatalf("a match-free filter renders its count but no discovery-empty mislabel: %q", plain) } // A long snapshot path truncates to the row budget instead of wrapping, @@ -140,7 +149,10 @@ func TestRenderPicker_Ugly(t *testing.T) { view := renderPicker(long, 40, styles) plain = ansi.Strip(view) if !strings.Contains(plain, "…") { - t.Fatalf("an over-budget hint must truncate with an ellipsis: %q", plain) + t.Fatalf("an over-budget path must truncate with an ellipsis: %q", plain) + } + if !strings.Contains(plain, "0123456789abcdef") { + t.Fatalf("the truncated Snapshot path must retain its identifying tail: %q", plain) } for index, line := range strings.Split(plain, "\n") { if got := lipgloss.Width(line); got > 40 { @@ -166,7 +178,7 @@ func TestRenderPicker_Ugly(t *testing.T) { if !strings.Contains(plain, "page 1/") { t.Fatalf("a paginated list must state its page position: %q", plain) } - if !strings.Contains(plain, "› m00") { + if !strings.Contains(plain, "│ m00") { t.Fatalf("the first page must open on the first item: %q", plain) } if strings.Contains(plain, "m29") { diff --git a/cli/tui/service.go b/cli/tui/service.go index 0bd8fce9..4940e1e5 100644 --- a/cli/tui/service.go +++ b/cli/tui/service.go @@ -20,12 +20,12 @@ import ( // hitting the API and a turn typed in Chat queue behind each other instead of // racing the engine. The service owns only its listener and context. -// serviceAddrs are the listen presets cycled with ←/→ while stopped. +// serviceAddrs are the listen presets cycled with ‹/› while stopped. var serviceAddrs = []struct{ addr, hint string }{ - {":36911", "Lethean's own port — the default the client hints below use"}, - {":11434", "Ollama's port — drop-in for clients hard-wired to an Ollama install"}, - {":8080", "plain local HTTP port"}, - {"0.0.0.0:36911", "every interface — reachable from other machines on your network"}, + {":36911", "Lethean's own port — the default the client hints below use."}, + {":11434", "Ollama's port — drop-in for clients hard-wired to an Ollama install."}, + {":8080", "plain local HTTP port."}, + {"0.0.0.0:36911", "every interface — reachable from other machines on your network."}, } type serviceState struct { @@ -152,36 +152,23 @@ func serviceTick() tea.Cmd { return tea.Tick(time.Second, func(time.Time) tea.Msg { return serviceTickMsg{} }) } -func (s serviceState) view(modelName string, width int, styles uiStyles) string { +func (s serviceState) view(_ string, width int, styles uiStyles) string { var b core.Builder - b.WriteString(styles.title.Render("service") + " " + + b.WriteString(styles.title.Render("SERVICE") + " " + styles.thought.Render("OpenAI · Anthropic · Ollama HTTP API for the loaded model") + "\n\n") state := styles.status.Render("○ stopped") if s.running { - label := "● serving on " + s.addr() - if modelName != "" { - label = "● serving " + modelName + " on " + s.addr() - } - state = styles.success.Render(label) + state = styles.success.Render("● listening") } b.WriteString(" " + state + "\n\n") - addrLabel := styles.answer.Render("address") + addrLabel := styles.accent.Render("address") value := "‹ " + s.addr() + " ›" hint := serviceAddrs[s.addrIdx].hint - if s.running { - value = s.addr() - hint = "locked while serving — stop first to change it" - } b.WriteString(" " + addrLabel + " " + styles.title.Render(value) + "\n") b.WriteString(" " + styles.thought.Render(hint) + "\n\n") - if s.running || s.requests.Load() > 0 { - b.WriteString(" " + styles.answer.Render("requests") + " " + - styles.title.Render(core.Sprintf("%d", s.requests.Load())) + "\n\n") - } - base := s.baseURL() b.WriteString(styles.title.Render("point a client here") + " " + styles.thought.Render("the request's model name is cosmetic — the loaded model answers") + "\n") @@ -190,15 +177,15 @@ func (s serviceState) view(modelName string, width int, styles uiStyles) string b.WriteString(" " + styles.answer.Render("Ollama clients ") + " " + styles.accent.Render(base) + "\n\n") b.WriteString(styles.title.Render("smoke") + "\n") - b.WriteString(" " + styles.thought.Render("curl -s "+base+"/v1/chat/completions \\") + "\n") - b.WriteString(" " + styles.thought.Render(` -d '{"model":"lem","messages":[{"role":"user","content":"hello"}]}'`) + "\n\n") + b.WriteString(" " + styles.accent.Render("curl") + styles.answer.Render(" -s "+base+"/v1/chat/completions \\") + "\n") + b.WriteString(" " + styles.answer.Render(` -d '{"model":"lem","messages":[{"role":"user","content":"hello"}]}'`) + "\n\n") - b.WriteString(" " + styles.thought.Render("TUI chat and API requests share the model through one serial lane —") + "\n") - b.WriteString(" " + styles.thought.Render("turns queue behind each other, nothing races the engine.") + "\n\n") + laneHint := "TUI chat and API requests share the model through one serial lane — turns queue behind each other, nothing races the engine." + b.WriteString(" " + styles.thought.Width(max(1, width-2)).Render(laneHint) + "\n\n") - if s.note != "" { + if s.note != "" && s.note != "stopped" { b.WriteString(" " + styles.err.Render(s.note) + "\n\n") } - b.WriteString(styles.status.Render("enter start/stop · ←/→ address (while stopped)")) + b.WriteString(styles.status.Render("enter start/stop · ‹/› address (while stopped)")) return b.String() } diff --git a/cli/tui/service_test.go b/cli/tui/service_test.go new file mode 100644 index 00000000..0a2cb243 --- /dev/null +++ b/cli/tui/service_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestServiceState_ViewMatchesCanonicalCopy(t *testing.T) { + service := newService() + styles := newUIStyles(midnightTheme()) + + stopped := ansi.Strip(service.view("", 180, styles)) + for _, want := range []string{ + "SERVICE OpenAI · Anthropic · Ollama HTTP API for the loaded model", + "○ stopped", + "address ‹ :36911 ›", + "Lethean's own port — the default the client hints below use.", + "point a client here the request's model name is cosmetic — the loaded model answers", + "opencode / codex / OpenAI SDKs", + "http://localhost:36911/v1", + "Claude Code / Anthropic SDKs", + "Ollama clients", + "smoke", + "curl -s http://localhost:36911/v1/chat/completions", + "TUI chat and API requests share the model through one serial lane — turns queue behind each other, nothing races the engine.", + "enter start/stop · ‹/› address (while stopped)", + } { + if !strings.Contains(stopped, want) { + t.Fatalf("stopped Service panel missing %q:\n%s", want, stopped) + } + } + service.note = "stopped" + if stopped = ansi.Strip(service.view("", 180, styles)); strings.Contains(stopped, "\n stopped\n") { + t.Fatalf("routine stop note duplicated the canonical stopped toggle:\n%s", stopped) + } + + service.running = true + running := ansi.Strip(service.view("fixture", 180, styles)) + if !strings.Contains(running, "● listening") || strings.Contains(running, "● serving") || strings.Contains(running, "\nrequests") { + t.Fatalf("running Service state drifted from the canonical toggle:\n%s", running) + } +} diff --git a/cli/tui/style.go b/cli/tui/style.go index a27de12e..0bfe4a45 100644 --- a/cli/tui/style.go +++ b/cli/tui/style.go @@ -27,8 +27,8 @@ func midnightTheme() theme { text: style.AdaptiveColor{Light: "#172033", Dark: "#E2E8F0"}.Resolve(true), muted: style.AdaptiveColor{Light: "#475569", Dark: "#718096"}.Resolve(true), border: style.AdaptiveColor{Light: "#94A3B8", Dark: "#334155"}.Resolve(true), - focus: style.AdaptiveColor{Light: "#007A89", Dark: "#67E8F9"}.Resolve(true), - assistant: style.AdaptiveColor{Light: "#6D28D9", Dark: "#C4B5FD"}.Resolve(true), + focus: style.AdaptiveColor{Light: "#007A89", Dark: "#40C1C5"}.Resolve(true), + assistant: style.AdaptiveColor{Light: "#007A89", Dark: "#40C1C5"}.Resolve(true), attention: style.AdaptiveColor{Light: "#92400E", Dark: "#FBBF24"}.Resolve(true), success: style.AdaptiveColor{Light: "#047857", Dark: "#6EE7B7"}.Resolve(true), error: style.AdaptiveColor{Light: "#BE123C", Dark: "#FB7185"}.Resolve(true), @@ -98,7 +98,7 @@ func newUIStyles(t theme) uiStyles { attention: style.New().Bold(true).Foreground(t.attention), success: style.New().Foreground(t.success), brand: style.New().Bold(true).Foreground(t.focus), - navActive: style.New().Bold(true).Foreground(t.text).Underline(true).UnderlineSpaces(false), + navActive: style.New().Bold(true).Foreground(t.focus).Underline(true).UnderlineSpaces(false), navInactive: style.New().Foreground(t.muted), header: style.New().Foreground(t.text), session: style.New().Foreground(t.muted), diff --git a/cli/tui/tabs.go b/cli/tui/tabs.go index 43f9fb4f..66467c55 100644 --- a/cli/tui/tabs.go +++ b/cli/tui/tabs.go @@ -24,7 +24,6 @@ const ( ) var panelNames = [panelCount]string{"Chat", "Work", "Models", "Service", "Data"} -var compactPanelNames = [panelCount]string{"Chat", "Work", "Models", "API", "Data"} func (panel panelID) next() panelID { return (panel + 1) % panelCount } func (panel panelID) prev() panelID { return (panel + panelCount - 1) % panelCount } @@ -43,12 +42,9 @@ const ( tabsSlotID = "C" ) -// panelBarNames picks the label set for the layout. Compact labels preserve -// all destinations on narrow terminals without squeezing or clipping. -func panelBarNames(kind layoutKind) [panelCount]string { - if kind == layoutNarrow { - return compactPanelNames - } +// panelBarNames keeps the canonical panel labels at every width. Narrow +// terminals may clip the right edge, but never rename a destination. +func panelBarNames(_ layoutKind) [panelCount]string { return panelNames } diff --git a/cli/tui/tabs_test.go b/cli/tui/tabs_test.go index 4464cfab..de643415 100644 --- a/cli/tui/tabs_test.go +++ b/cli/tui/tabs_test.go @@ -45,8 +45,8 @@ func TestRenderPanelBar_Ugly(t *testing.T) { styles := newUIStyles(midnightTheme()) view := renderPanelBar(panelData, 72, layoutNarrow, styles) plain := ansi.Strip(view) - if !strings.Contains(plain, "○ API") || strings.Contains(plain, "Service") { - t.Fatalf("narrow bar must use compact labels: %q", plain) + if !strings.Contains(plain, "○ Service") || strings.Contains(plain, "○ API") { + t.Fatalf("narrow bar must preserve the canonical Service label: %q", plain) } if !strings.Contains(plain, "● Data") || strings.Contains(plain, "○ Data") { t.Fatalf("last tab active must carry the active marker: %q", plain) diff --git a/cli/tui/work.go b/cli/tui/work.go index 40ba5078..8458c2df 100644 --- a/cli/tui/work.go +++ b/cli/tui/work.go @@ -395,7 +395,7 @@ func (panel *workPanel) renderList(width, height int, styles uiStyles) string { builder.WriteString("\n") builder.WriteString(styles.status.Render("○ No work yet")) builder.WriteString("\n") - builder.WriteString(styles.thought.Render("A connected provider or workspace action will create work here.")) + builder.WriteString(styles.thought.Render("A connected provider or workspace action will create work here. Open the inspector and dispatch one.")) return builder.String() } for index, raw := range visible { diff --git a/cli/tui/work_test.go b/cli/tui/work_test.go index 79b4daa2..91f77e3d 100644 --- a/cli/tui/work_test.go +++ b/cli/tui/work_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" core "dappco.re/go" ) @@ -279,7 +280,9 @@ func TestWorkPanel_Ugly(t *testing.T) { } panel := opened.Value.(*workPanel) empty := panel.View(72, 18, newUIStyles(midnightTheme())) - if !strings.Contains(empty, "No work yet") || !strings.Contains(empty, "workspace action") || strings.Contains(empty, "command palette") { + compact := strings.Join(strings.Fields(ansi.Strip(empty)), " ") + wantEmpty := "A connected provider or workspace action will create work here. Open the inspector and dispatch one." + if !strings.Contains(compact, "○ No work yet") || !strings.Contains(compact, wantEmpty) || strings.Contains(compact, "command palette") { t.Fatalf("empty work view:\n%s", empty) } fixtures := []workItemRecord{