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 `
` with a ` ` with ` `+` `+` DATA Filter: {{row.value}} {{row.text}} {{row.label}} ↑/↓ select · / filter · enter load MODELS {{count}}
` closing each
``/`
`/` ` (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 `
` 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 @@
-->
Import or capture data with `lem data import` / `lem serve --capture`.
Import or capture data with
lem data import or
lem serve --capture.Models
-
-
{{headName}}{{headEngine}}{{headPath}} 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{