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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions cli/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<dt>` (a `<dt>` 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 `<p>` with a `<br>` closing each
`<each>` row** — separate block elements would gain blank separators.
- **A section that appears only with data is an `<each>` over a
Expand Down Expand Up @@ -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 — `<table>`/`<tr>`/`<td>` (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 `<dl>` idiom
settings and the picker established.
Models is genuinely columnar, but the canonical design is a borderless,
full-width table. The terminal renderer's `<table>`/`<tr>`/`<td>` path uses
lipgloss/table's bordered, content-sized grid, so `picker.ctml` instead
renders host-padded columns as adjacent spans in one `<p>` with `<br>` row
separators. Label+detail forms remain on the `<dl>` idiom used by Settings
and command-palette rows.

### The app shell

Expand Down Expand Up @@ -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 `<dl>` idiom, not the Data list's dense `<p>`+`<br>`
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 `<dl>` idiom, not the Data/Models dense
`<p>`+`<br>` 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`),
Expand All @@ -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"
Expand Down
159 changes: 126 additions & 33 deletions cli/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -3277,38 +3311,50 @@ 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)
return a.work.View(metrics.mainWidth, metrics.mainHeight, a.styles)
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)+" …")
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
Expand All @@ -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 "○"
Expand Down Expand Up @@ -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
}
Loading
Loading