mirror of
https://github.com/ollama/ollama.git
synced 2026-10-09 01:16:44 +00:00
cmd/tui/chat: consolidate model and resume pickers into generic chatPicker
This commit is contained in:
parent
087ffc697f
commit
2e9b631176
2 changed files with 190 additions and 242 deletions
|
|
@ -763,10 +763,10 @@ func (m chatModel) View() string {
|
|||
height = 24
|
||||
}
|
||||
|
||||
if m.resumePicker != nil && m.shouldRenderResumePickerFullFrame(width, height) {
|
||||
if m.resumePicker != nil && m.shouldRenderPickerFullFrame(width, height) {
|
||||
return renderFullFrame(m.renderResumePicker(width), width, height)
|
||||
}
|
||||
if m.modelPicker != nil && m.shouldRenderModelPickerFullFrame(width, height) {
|
||||
if m.modelPicker != nil && m.shouldRenderPickerFullFrame(width, height) {
|
||||
return renderFullFrame(m.renderModelPicker(width), width, height)
|
||||
}
|
||||
if m.thinkPicker != nil {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ type chatPicker[T any] struct {
|
|||
scroll int
|
||||
match func(T, string) bool
|
||||
less func(T, T, string) int
|
||||
|
||||
// Display configuration shared by the full-frame and inline renderers.
|
||||
title string
|
||||
inlineTitle string
|
||||
emptyLabel string
|
||||
fullFooter string
|
||||
itemTitle func(T) string
|
||||
itemMeta func(T) string
|
||||
}
|
||||
|
||||
type chatHistoryPopup struct {
|
||||
|
|
@ -276,6 +284,12 @@ func newChatModelPicker(models []ModelOption, current, filter string) *chatModel
|
|||
return modelOptionMatchScore(model, filter).ok
|
||||
})
|
||||
picker.less = compareModelOptionsForFilter
|
||||
picker.title = "Switch model"
|
||||
picker.inlineTitle = "Select model"
|
||||
picker.emptyLabel = "No matching models"
|
||||
picker.fullFooter = "↑/↓ move • enter switch • type search • esc cancel"
|
||||
picker.itemTitle = func(model ModelOption) string { return model.Name }
|
||||
picker.itemMeta = func(model ModelOption) string { return modelOptionMeta(model, current) }
|
||||
return picker
|
||||
}
|
||||
|
||||
|
|
@ -366,40 +380,17 @@ func compareInt(a, b int) int {
|
|||
}
|
||||
|
||||
func (m chatModel) updateModelPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
if m.modelPicker == nil {
|
||||
return m, nil
|
||||
}
|
||||
clear, enter := m.modelPicker.handleKey(msg)
|
||||
if clear {
|
||||
m.modelPicker = nil
|
||||
m.status = "ready"
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
}
|
||||
if enter {
|
||||
return m.selectModel()
|
||||
case tea.KeyUp:
|
||||
m.modelPicker.move(-1)
|
||||
case tea.KeyDown:
|
||||
m.modelPicker.move(1)
|
||||
case tea.KeyPgUp:
|
||||
m.modelPicker.move(-maxResumePickerItems)
|
||||
case tea.KeyPgDown:
|
||||
m.modelPicker.move(maxResumePickerItems)
|
||||
case tea.KeyBackspace:
|
||||
if len(m.modelPicker.filter) > 0 {
|
||||
runes := []rune(m.modelPicker.filter)
|
||||
m.modelPicker.filter = string(runes[:len(runes)-1])
|
||||
m.modelPicker.cursor = 0
|
||||
m.modelPicker.scroll = 0
|
||||
}
|
||||
case tea.KeyCtrlU:
|
||||
m.modelPicker.filter = ""
|
||||
m.modelPicker.cursor = 0
|
||||
m.modelPicker.scroll = 0
|
||||
case tea.KeySpace:
|
||||
m.modelPicker.filter += " "
|
||||
m.modelPicker.cursor = 0
|
||||
m.modelPicker.scroll = 0
|
||||
case tea.KeyRunes:
|
||||
m.modelPicker.filter += string(msg.Runes)
|
||||
m.modelPicker.cursor = 0
|
||||
m.modelPicker.scroll = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -503,7 +494,7 @@ func (m *chatModel) openResumePicker() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func newChatResumePicker(chats []appstore.ChatSummary, currentChatID string) *chatResumePicker {
|
||||
return newChatPicker(chats, "", func(chat appstore.ChatSummary) bool {
|
||||
picker := newChatPicker(chats, "", func(chat appstore.ChatSummary) bool {
|
||||
return chat.ID == currentChatID
|
||||
}, func(chat appstore.ChatSummary, filter string) bool {
|
||||
return strings.Contains(strings.ToLower(strings.Join([]string{
|
||||
|
|
@ -512,43 +503,27 @@ func newChatResumePicker(chats []appstore.ChatSummary, currentChatID string) *ch
|
|||
chat.ID,
|
||||
}, " ")), filter)
|
||||
})
|
||||
picker.title = "Resume session"
|
||||
picker.inlineTitle = "Resume chat"
|
||||
picker.emptyLabel = "No matching chats"
|
||||
picker.fullFooter = "↑/↓ move • enter resume • type search • esc cancel"
|
||||
picker.itemTitle = resumeChatTitle
|
||||
picker.itemMeta = resumeChatMeta
|
||||
return picker
|
||||
}
|
||||
|
||||
func (m chatModel) updateResumePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
if m.resumePicker == nil {
|
||||
return m, nil
|
||||
}
|
||||
clear, enter := m.resumePicker.handleKey(msg)
|
||||
if clear {
|
||||
m.resumePicker = nil
|
||||
m.status = "ready"
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
}
|
||||
if enter {
|
||||
return m.resumeSelectedChat()
|
||||
case tea.KeyUp:
|
||||
m.resumePicker.move(-1)
|
||||
case tea.KeyDown:
|
||||
m.resumePicker.move(1)
|
||||
case tea.KeyPgUp:
|
||||
m.resumePicker.move(-maxResumePickerItems)
|
||||
case tea.KeyPgDown:
|
||||
m.resumePicker.move(maxResumePickerItems)
|
||||
case tea.KeyBackspace:
|
||||
if len(m.resumePicker.filter) > 0 {
|
||||
runes := []rune(m.resumePicker.filter)
|
||||
m.resumePicker.filter = string(runes[:len(runes)-1])
|
||||
m.resumePicker.cursor = 0
|
||||
m.resumePicker.scroll = 0
|
||||
}
|
||||
case tea.KeyCtrlU:
|
||||
m.resumePicker.filter = ""
|
||||
m.resumePicker.cursor = 0
|
||||
m.resumePicker.scroll = 0
|
||||
case tea.KeySpace:
|
||||
m.resumePicker.filter += " "
|
||||
m.resumePicker.cursor = 0
|
||||
m.resumePicker.scroll = 0
|
||||
case tea.KeyRunes:
|
||||
m.resumePicker.filter += string(msg.Runes)
|
||||
m.resumePicker.cursor = 0
|
||||
m.resumePicker.scroll = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -606,197 +581,23 @@ func (m *chatModel) resumeSelectedChat() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m chatModel) renderResumePicker(width int) string {
|
||||
picker := m.resumePicker
|
||||
if picker == nil {
|
||||
return ""
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(chatResumeTitleStyle.Render("Resume session"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(renderResumeSearchBox(picker.filter, width))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
filtered := picker.filtered()
|
||||
if len(filtered) == 0 {
|
||||
b.WriteString(chatResumeMetaStyle.Render("No matching chats"))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
start := clamp(picker.scroll, 0, max(0, len(filtered)-1))
|
||||
end := min(len(filtered), start+maxResumePickerItems)
|
||||
for i := start; i < end; i++ {
|
||||
chat := filtered[i]
|
||||
selected := i == picker.cursor
|
||||
if selected {
|
||||
b.WriteString(chatResumeSelectedStyle.Render("› " + resumeChatTitle(chat)))
|
||||
} else {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(chatResumeTextStyle.Render(resumeChatTitle(chat)))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(chatResumeMetaStyle.Render(" " + resumeChatMeta(chat)))
|
||||
b.WriteByte('\n')
|
||||
if i < end-1 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if end < len(filtered) {
|
||||
b.WriteString(chatResumeMetaStyle.Render(fmt.Sprintf("\n +%d more", len(filtered)-end)))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(chatResumeMetaStyle.Render("↑/↓ move • enter resume • type search • esc cancel"))
|
||||
return b.String()
|
||||
return m.resumePicker.render(width)
|
||||
}
|
||||
|
||||
func (m chatModel) renderModelPicker(width int) string {
|
||||
picker := m.modelPicker
|
||||
if picker == nil {
|
||||
return ""
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(chatResumeTitleStyle.Render("Switch model"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(renderResumeSearchBox(picker.filter, width))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
filtered := picker.filtered()
|
||||
if len(filtered) == 0 {
|
||||
b.WriteString(chatResumeMetaStyle.Render("No matching models"))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
start := clamp(picker.scroll, 0, max(0, len(filtered)-1))
|
||||
end := min(len(filtered), start+maxResumePickerItems)
|
||||
for i := start; i < end; i++ {
|
||||
model := filtered[i]
|
||||
selected := i == picker.cursor
|
||||
if selected {
|
||||
b.WriteString(chatResumeSelectedStyle.Render("› " + model.Name))
|
||||
} else {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(chatResumeTextStyle.Render(model.Name))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
if meta := modelOptionMeta(model, m.opts.Model); meta != "" {
|
||||
b.WriteString(chatResumeMetaStyle.Render(" " + meta))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if end < len(filtered) {
|
||||
b.WriteString(chatResumeMetaStyle.Render(fmt.Sprintf("\n +%d more", len(filtered)-end)))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(chatResumeMetaStyle.Render("↑/↓ move • enter switch • type search • esc cancel"))
|
||||
return b.String()
|
||||
return m.modelPicker.render(width)
|
||||
}
|
||||
|
||||
func (m chatModel) shouldRenderModelPickerFullFrame(width, height int) bool {
|
||||
return width < 48 || height < 12
|
||||
}
|
||||
|
||||
func (m chatModel) shouldRenderResumePickerFullFrame(width, height int) bool {
|
||||
func (m chatModel) shouldRenderPickerFullFrame(width, height int) bool {
|
||||
return width < 48 || height < 12
|
||||
}
|
||||
|
||||
func (m chatModel) renderInlineResumePicker(width int) []string {
|
||||
picker := m.resumePicker
|
||||
if picker == nil {
|
||||
return nil
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
filter := strings.TrimSpace(picker.filter)
|
||||
title := "Resume chat"
|
||||
if filter != "" {
|
||||
title += ": " + filter
|
||||
} else {
|
||||
title += ": type to filter"
|
||||
}
|
||||
|
||||
lines := []string{truncateRenderedLine(chatResumeTitleStyle.Render(title), width)}
|
||||
filtered := picker.filtered()
|
||||
if len(filtered) == 0 {
|
||||
lines = append(lines, chatResumeMetaStyle.Render(" No matching chats"))
|
||||
} else {
|
||||
start, end := completionWindow(len(filtered), picker.cursor, maxInlineModelPickerItems)
|
||||
for i := start; i < end; i++ {
|
||||
chat := filtered[i]
|
||||
marker := " "
|
||||
if i == picker.cursor {
|
||||
marker = "› "
|
||||
}
|
||||
line := marker + chatResumeTextStyle.Render(resumeChatTitle(chat))
|
||||
if meta := resumeChatMeta(chat); meta != "" {
|
||||
line += " " + chatResumeMetaStyle.Render(meta)
|
||||
}
|
||||
lines = append(lines, truncateRenderedLine(line, width))
|
||||
}
|
||||
if end < len(filtered) {
|
||||
lines = append(lines, chatResumeMetaStyle.Render(fmt.Sprintf(" +%d more", len(filtered)-end)))
|
||||
}
|
||||
}
|
||||
lines = append(lines, chatResumeMetaStyle.Render("↑/↓ move • enter resume • type filter • esc cancel"))
|
||||
return lines
|
||||
return m.resumePicker.renderInline(width)
|
||||
}
|
||||
|
||||
func (m chatModel) renderInlineModelPicker(width int) []string {
|
||||
picker := m.modelPicker
|
||||
if picker == nil {
|
||||
return nil
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
filter := strings.TrimSpace(picker.filter)
|
||||
title := "Select model"
|
||||
if filter != "" {
|
||||
title += ": " + filter
|
||||
} else {
|
||||
title += ": type to filter"
|
||||
}
|
||||
|
||||
lines := []string{truncateRenderedLine(chatResumeTitleStyle.Render(title), width)}
|
||||
filtered := picker.filtered()
|
||||
if len(filtered) == 0 {
|
||||
lines = append(lines, chatResumeMetaStyle.Render(" No matching models"))
|
||||
} else {
|
||||
start, end := completionWindow(len(filtered), picker.cursor, maxInlineModelPickerItems)
|
||||
for i := start; i < end; i++ {
|
||||
model := filtered[i]
|
||||
marker := " "
|
||||
if i == picker.cursor {
|
||||
marker = "› "
|
||||
}
|
||||
line := marker + chatResumeTextStyle.Render(model.Name)
|
||||
if meta := modelOptionMeta(model, m.opts.Model); meta != "" {
|
||||
line += " " + chatResumeMetaStyle.Render(meta)
|
||||
}
|
||||
lines = append(lines, truncateRenderedLine(line, width))
|
||||
}
|
||||
if end < len(filtered) {
|
||||
lines = append(lines, chatResumeMetaStyle.Render(fmt.Sprintf(" +%d more", len(filtered)-end)))
|
||||
}
|
||||
}
|
||||
lines = append(lines, chatResumeMetaStyle.Render("↑/↓ move • enter switch • type filter • esc cancel"))
|
||||
return lines
|
||||
return m.modelPicker.renderInline(width)
|
||||
}
|
||||
|
||||
func (m chatModel) renderHistoryPopup(width, height int) string {
|
||||
|
|
@ -1002,6 +803,153 @@ func (p *chatPicker[T]) selected() (T, bool) {
|
|||
return filtered[p.cursor], true
|
||||
}
|
||||
|
||||
// handleKey processes a key event for any chatPicker. It returns clear when the
|
||||
// picker should be closed and enter when the selected item should be activated.
|
||||
func (p *chatPicker[T]) handleKey(msg tea.KeyMsg) (clear, enter bool) {
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
return true, false
|
||||
case tea.KeyEnter:
|
||||
return false, true
|
||||
case tea.KeyUp:
|
||||
p.move(-1)
|
||||
case tea.KeyDown:
|
||||
p.move(1)
|
||||
case tea.KeyPgUp:
|
||||
p.move(-maxResumePickerItems)
|
||||
case tea.KeyPgDown:
|
||||
p.move(maxResumePickerItems)
|
||||
case tea.KeyBackspace:
|
||||
if len(p.filter) > 0 {
|
||||
runes := []rune(p.filter)
|
||||
p.filter = string(runes[:len(runes)-1])
|
||||
p.cursor = 0
|
||||
p.scroll = 0
|
||||
}
|
||||
case tea.KeyCtrlU:
|
||||
p.filter = ""
|
||||
p.cursor = 0
|
||||
p.scroll = 0
|
||||
case tea.KeySpace:
|
||||
p.filter += " "
|
||||
p.cursor = 0
|
||||
p.scroll = 0
|
||||
case tea.KeyRunes:
|
||||
p.filter += string(msg.Runes)
|
||||
p.cursor = 0
|
||||
p.scroll = 0
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// render produces the full-frame picker view with a search box.
|
||||
func (p *chatPicker[T]) render(width int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(truncateRenderedLine(chatResumeTitleStyle.Render(p.title), width))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(renderResumeSearchBox(p.filter, width))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
filtered := p.filtered()
|
||||
if len(filtered) == 0 {
|
||||
b.WriteString(chatResumeMetaStyle.Render(p.emptyLabel))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
start := clamp(p.scroll, 0, max(0, len(filtered)-1))
|
||||
end := min(len(filtered), start+maxResumePickerItems)
|
||||
for i := start; i < end; i++ {
|
||||
item := filtered[i]
|
||||
selected := i == p.cursor
|
||||
for j, line := range wrapChatText(p.itemTitle(item), max(10, width-2)) {
|
||||
marker := " "
|
||||
if selected && j == 0 {
|
||||
marker = "› "
|
||||
}
|
||||
if selected {
|
||||
b.WriteString(chatResumeSelectedStyle.Render(marker + line))
|
||||
} else {
|
||||
b.WriteString(" " + chatResumeTextStyle.Render(line))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if meta := p.itemMeta(item); meta != "" {
|
||||
for _, line := range wrapChatText(meta, max(10, width-2)) {
|
||||
b.WriteString(chatResumeMetaStyle.Render(" " + line))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if end < len(filtered) {
|
||||
b.WriteString(chatResumeMetaStyle.Render(fmt.Sprintf("\n +%d more", len(filtered)-end)))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(truncateRenderedLine(chatResumeMetaStyle.Render(p.fullFooter), width))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderInline produces the compact inline picker view shown above the input.
|
||||
func (p *chatPicker[T]) renderInline(width int) []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
filter := strings.TrimSpace(p.filter)
|
||||
title := p.inlineTitle
|
||||
if filter != "" {
|
||||
title += ": " + filter
|
||||
} else {
|
||||
title += ": type to filter"
|
||||
}
|
||||
|
||||
lines := []string{truncateRenderedLine(chatResumeTitleStyle.Render(title), width)}
|
||||
filtered := p.filtered()
|
||||
if len(filtered) == 0 {
|
||||
lines = append(lines, truncateRenderedLine(chatResumeMetaStyle.Render(" "+p.emptyLabel), width))
|
||||
} else {
|
||||
start, end := completionWindow(len(filtered), p.cursor, maxInlineModelPickerItems)
|
||||
for i := start; i < end; i++ {
|
||||
item := filtered[i]
|
||||
selected := i == p.cursor
|
||||
for j, line := range wrapChatText(p.itemTitle(item), max(10, width-2)) {
|
||||
marker := " "
|
||||
if selected && j == 0 {
|
||||
marker = "› "
|
||||
}
|
||||
if selected {
|
||||
lines = append(lines, truncateRenderedLine(chatResumeSelectedStyle.Render(marker+line), width))
|
||||
} else {
|
||||
lines = append(lines, truncateRenderedLine(" "+chatResumeTextStyle.Render(line), width))
|
||||
}
|
||||
}
|
||||
if meta := p.itemMeta(item); meta != "" {
|
||||
for _, line := range wrapChatText(meta, max(10, width-2)) {
|
||||
lines = append(lines, truncateRenderedLine(chatResumeMetaStyle.Render(" "+line), width))
|
||||
}
|
||||
}
|
||||
}
|
||||
if end < len(filtered) {
|
||||
lines = append(lines, truncateRenderedLine(chatResumeMetaStyle.Render(fmt.Sprintf(" +%d more", len(filtered)-end)), width))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func modelOptionMeta(model ModelOption, current string) string {
|
||||
var parts []string
|
||||
if model.Name == current {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue