Skip to content

Commit 5e006b1

Browse files
jmelahmanclaude
andcommitted
feat(cli): right-align the provider column in the model picker
Picker items get a detail field that renders flush right with a two-space minimum gap; the label truncates to make room. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c08086c commit 5e006b1

3 files changed

Lines changed: 63 additions & 15 deletions

File tree

cli/internal/tui/app.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -611,15 +611,13 @@ func (m Model) handleModelsLoaded(msg ModelsLoadedMsg) (tea.Model, tea.Cmd) {
611611
var items []pickerItem
612612
for i, opt := range m.llmModels {
613613
label := opt.label
614-
if opt.providerLabel != "" {
615-
label += " - " + opt.providerLabel
616-
}
617614
if m.isCurrentModel(opt) {
618615
label += " *"
619616
}
620617
items = append(items, pickerItem{
621-
id: strconv.Itoa(i),
622-
label: label,
618+
id: strconv.Itoa(i),
619+
label: label,
620+
detail: opt.providerLabel,
623621
})
624622
}
625623
m.viewport.showPicker(pickerModel, items)

cli/internal/tui/commands_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ func TestSelectModelSetsOverrideAndStatus(t *testing.T) {
146146
func TestPickerBorderLinesShareOneWidth(t *testing.T) {
147147
v := newViewport(120, false)
148148
v.showPicker(pickerModel, []pickerItem{
149-
{id: "0", label: "Gemma 4 E2B - Ollama *"},
150-
{id: "1", label: "Qwen 3 8B - Ollama"},
149+
{id: "0", label: "Gemma 4 E2B *", detail: "Ollama"},
150+
{id: "1", label: "Qwen 3 8B", detail: "Ollama"},
151151
})
152152

153153
var widths []int
@@ -168,6 +168,33 @@ func TestPickerBorderLinesShareOneWidth(t *testing.T) {
168168
}
169169
}
170170

171+
func TestFormatPickerLabelAlignsDetail(t *testing.T) {
172+
rows := []pickerItem{
173+
{label: "Gemma 4 E2B *", detail: "Ollama"},
174+
{label: "Qwen 3 8B", detail: "Ollama"},
175+
{label: "GPT-4o", detail: "OpenAI"},
176+
}
177+
const avail = 40
178+
for _, row := range rows {
179+
got := formatPickerLabel(row, avail)
180+
if len([]rune(got)) != avail {
181+
t.Errorf("%q: width = %d, want %d", got, len([]rune(got)), avail)
182+
}
183+
if !strings.HasSuffix(got, row.detail) {
184+
t.Errorf("%q: detail %q must be flush right", got, row.detail)
185+
}
186+
}
187+
188+
long := pickerItem{label: strings.Repeat("x", 60), detail: "Ollama"}
189+
got := formatPickerLabel(long, avail)
190+
if len([]rune(got)) != avail {
191+
t.Errorf("long label: width = %d, want %d", len([]rune(got)), avail)
192+
}
193+
if !strings.Contains(got, "...") || !strings.HasSuffix(got, "Ollama") {
194+
t.Errorf("long label must truncate and keep detail flush right, got %q", got)
195+
}
196+
}
197+
171198
func TestSelectModelInvalidIndex(t *testing.T) {
172199
m := NewModel(config.DefaultConfig(), nil)
173200
m, _ = cmdSelectModel(m, "5")

cli/internal/tui/viewport.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ const (
3838
pickerModel
3939
)
4040

41-
// pickerItem is a selectable item in the picker.
41+
// pickerItem is a selectable item in the picker. detail, when set, is
42+
// right-aligned on the row (like a tabwriter column).
4243
type pickerItem struct {
43-
id string
44-
label string
44+
id string
45+
label string
46+
detail string
4547
}
4648

4749
// streamRenderInterval is the minimum time between markdown re-renders during streaming.
@@ -324,11 +326,7 @@ func (v *viewport) renderPicker(width, height int) string {
324326
var itemLines []string
325327
for i := startIdx; i < endIdx; i++ {
326328
item := v.pickerItems[i]
327-
label := item.label
328-
labelRunes := []rune(label)
329-
if len(labelRunes) > innerWidth-4 {
330-
label = string(labelRunes[:innerWidth-7]) + "..."
331-
}
329+
label := formatPickerLabel(item, innerWidth-4)
332330
if i == v.pickerIndex {
333331
line := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("> " + label)
334332
itemLines = append(itemLines, line)
@@ -376,6 +374,31 @@ func (v *viewport) renderPicker(width, height int) string {
376374
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, panel)
377375
}
378376

377+
// formatPickerLabel fits an item into avail columns. When the item has a
378+
// detail, the detail is right-aligned with at least a two-space gap and the
379+
// label is truncated to make room.
380+
func formatPickerLabel(item pickerItem, avail int) string {
381+
label := []rune(item.label)
382+
if item.detail == "" {
383+
if len(label) > avail {
384+
return string(label[:avail-3]) + "..."
385+
}
386+
return string(label)
387+
}
388+
389+
detail := []rune(item.detail)
390+
maxLabel := avail - len(detail) - 2
391+
if maxLabel < 8 {
392+
// Too narrow for columns — fall back to an inline suffix.
393+
return formatPickerLabel(pickerItem{label: item.label + " " + item.detail}, avail)
394+
}
395+
if len(label) > maxLabel {
396+
label = []rune(string(label[:maxLabel-3]) + "...")
397+
}
398+
gap := avail - len(label) - len(detail)
399+
return string(label) + strings.Repeat(" ", gap) + string(detail)
400+
}
401+
379402
// streamingContent returns the display content for the in-progress stream.
380403
func (v *viewport) streamingContent() string {
381404
if v.streamMarkdown && v.streamRendered != "" {

0 commit comments

Comments
 (0)