mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
4751416b29
**Breaking change for changelog:**
> `codersdk.Chat.last_error` now returns a structured `ChatError` object
(`{message, kind, provider, retryable, status_code, detail}`) instead of
a plain string. The chats API is experimental
(`/api/experimental/chats`), so this ships without a deprecation cycle;
consumers reading `chat.last_error` as a string must update to read
`chat.last_error.message`. SDK/generated TypeScript terminal error
payloads now use the single `ChatError` type; the live stream error
payload type is renamed from `ChatStreamError` to `ChatError`.
Persisted chat errors now carry the same provider-specific detail (kind,
provider, retryable, HTTP status, optional detail) as the live stream,
so refreshing a failed chat rehydrates with the full structured error
instead of a one-line headline.
Existing rows are migrated in place: legacy text errors are wrapped into
`{message, kind: "generic"}` so already-errored chats still render, and
rows with `last_error IS NULL` stay NULL. Internally, persisted fallback
decoding now reuses the existing `chaterror.KindGeneric` constant, with
no JSON value change.
Closes CODAGT-239
484 lines
11 KiB
Go
484 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
type (
|
|
openSelectedChatMsg struct {
|
|
chatID uuid.UUID
|
|
}
|
|
openDraftChatMsg struct{}
|
|
refreshChatsMsg struct{}
|
|
)
|
|
|
|
type chatDisplayRow struct {
|
|
chat codersdk.Chat
|
|
depth int
|
|
isSubagent bool
|
|
childCount int
|
|
isExpanded bool
|
|
}
|
|
|
|
type chatListModel struct {
|
|
styles tuiStyles
|
|
chats []codersdk.Chat
|
|
expanded map[uuid.UUID]bool
|
|
cursor int
|
|
offset int
|
|
loading bool
|
|
err error
|
|
search textinput.Model
|
|
searching bool
|
|
spinner spinner.Model
|
|
width int
|
|
height int
|
|
}
|
|
|
|
func newChatListModel(styles tuiStyles) chatListModel {
|
|
search := textinput.New()
|
|
search.Placeholder = "Search chats..."
|
|
search.Prompt = "/ "
|
|
|
|
s := spinner.New()
|
|
s.Spinner = spinner.Dot
|
|
s.Style = styles.dimmedText
|
|
|
|
return chatListModel{
|
|
styles: styles,
|
|
expanded: make(map[uuid.UUID]bool),
|
|
loading: true,
|
|
search: search,
|
|
spinner: s,
|
|
}
|
|
}
|
|
|
|
func (m chatListModel) searchQuery() string {
|
|
return strings.TrimSpace(strings.ToLower(m.search.Value()))
|
|
}
|
|
|
|
func (m chatListModel) filteredChats() []codersdk.Chat {
|
|
query := m.searchQuery()
|
|
if query == "" {
|
|
return m.chats
|
|
}
|
|
|
|
filtered := make([]codersdk.Chat, 0, len(m.chats))
|
|
for _, chat := range m.chats {
|
|
if strings.Contains(strings.ToLower(chat.Title), query) || strings.Contains(strings.ToLower(chat.ID.String()), query) {
|
|
filtered = append(filtered, chat)
|
|
continue
|
|
}
|
|
if chat.LastError != nil && strings.Contains(strings.ToLower(chat.LastError.Message), query) {
|
|
filtered = append(filtered, chat)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func (m chatListModel) displayRows() []chatDisplayRow {
|
|
filtered := m.filteredChats()
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
|
|
queryActive := m.searchQuery() != ""
|
|
chatsByID := make(map[uuid.UUID]codersdk.Chat, len(m.chats))
|
|
included := make(map[uuid.UUID]struct{}, len(filtered))
|
|
for _, chat := range m.chats {
|
|
chatsByID[chat.ID] = chat
|
|
}
|
|
for _, chat := range filtered {
|
|
included[chat.ID] = struct{}{}
|
|
if !queryActive {
|
|
continue
|
|
}
|
|
for parentID := chat.ParentChatID; parentID != nil; {
|
|
parent, ok := chatsByID[*parentID]
|
|
if !ok {
|
|
break
|
|
}
|
|
included[parent.ID] = struct{}{}
|
|
parentID = parent.ParentChatID
|
|
}
|
|
}
|
|
|
|
childrenOf := make(map[uuid.UUID][]codersdk.Chat)
|
|
roots := make([]codersdk.Chat, 0, len(included))
|
|
for _, chat := range m.chats {
|
|
if _, ok := included[chat.ID]; !ok {
|
|
continue
|
|
}
|
|
if chat.ParentChatID == nil {
|
|
roots = append(roots, chat)
|
|
continue
|
|
}
|
|
if _, ok := included[*chat.ParentChatID]; ok {
|
|
childrenOf[*chat.ParentChatID] = append(childrenOf[*chat.ParentChatID], chat)
|
|
}
|
|
}
|
|
|
|
rows := make([]chatDisplayRow, 0, len(included))
|
|
var appendRows func(codersdk.Chat, int)
|
|
appendRows = func(chat codersdk.Chat, depth int) {
|
|
children := childrenOf[chat.ID]
|
|
isExpanded := m.expanded[chat.ID]
|
|
if queryActive && len(children) > 0 {
|
|
isExpanded = true
|
|
}
|
|
|
|
rows = append(rows, chatDisplayRow{
|
|
chat: chat,
|
|
depth: depth,
|
|
isSubagent: depth > 0,
|
|
childCount: len(children),
|
|
isExpanded: isExpanded,
|
|
})
|
|
if !isExpanded {
|
|
return
|
|
}
|
|
for _, child := range children {
|
|
appendRows(child, depth+1)
|
|
}
|
|
}
|
|
|
|
for _, root := range roots {
|
|
appendRows(root, 0)
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
func (m chatListModel) selectedRow() (chatDisplayRow, bool) {
|
|
rows := m.displayRows()
|
|
if len(rows) == 0 || m.cursor < 0 || m.cursor >= len(rows) {
|
|
return chatDisplayRow{}, false
|
|
}
|
|
return rows[m.cursor], true
|
|
}
|
|
|
|
func (m *chatListModel) moveCursorToChat(chatID uuid.UUID) {
|
|
rows := m.displayRows()
|
|
for i, row := range rows {
|
|
if row.chat.ID == chatID {
|
|
m.cursor = i
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
type chatExpansionIntent int
|
|
|
|
const (
|
|
chatExpansionToggle chatExpansionIntent = iota
|
|
chatExpansionExpand
|
|
chatExpansionCollapse
|
|
)
|
|
|
|
func (m *chatListModel) updateSelectedRowExpansion(intent chatExpansionIntent) bool {
|
|
row, ok := m.selectedRow()
|
|
if !ok {
|
|
return false
|
|
}
|
|
if row.childCount == 0 {
|
|
if intent == chatExpansionExpand || row.chat.ParentChatID == nil {
|
|
return false
|
|
}
|
|
parentID := *row.chat.ParentChatID
|
|
m.expanded[parentID] = false
|
|
m.moveCursorToChat(parentID)
|
|
return true
|
|
}
|
|
|
|
switch intent {
|
|
case chatExpansionExpand:
|
|
if row.isExpanded {
|
|
return false
|
|
}
|
|
m.expanded[row.chat.ID] = true
|
|
case chatExpansionCollapse:
|
|
if row.isExpanded {
|
|
m.expanded[row.chat.ID] = false
|
|
return true
|
|
}
|
|
if row.chat.ParentChatID == nil || !m.expanded[*row.chat.ParentChatID] {
|
|
return false
|
|
}
|
|
parentID := *row.chat.ParentChatID
|
|
m.expanded[parentID] = false
|
|
m.moveCursorToChat(parentID)
|
|
return true
|
|
case chatExpansionToggle:
|
|
if row.isExpanded && !m.expanded[row.chat.ID] {
|
|
return false
|
|
}
|
|
m.expanded[row.chat.ID] = !row.isExpanded
|
|
default:
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (m chatListModel) selectedChat() *codersdk.Chat {
|
|
row, ok := m.selectedRow()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return &row.chat
|
|
}
|
|
|
|
func (m *chatListModel) normalizeCursor() {
|
|
total := len(m.displayRows())
|
|
if total == 0 {
|
|
m.cursor = 0
|
|
m.offset = 0
|
|
return
|
|
}
|
|
m.cursor = min(max(m.cursor, 0), total-1)
|
|
m.offset, _ = m.visibleWindow(total)
|
|
}
|
|
|
|
func (m chatListModel) visibleChatCount() int {
|
|
overhead := 3
|
|
if m.searching {
|
|
overhead += 2
|
|
}
|
|
|
|
visibleCount := m.height - overhead
|
|
if visibleCount < 3 {
|
|
visibleCount = 3
|
|
}
|
|
return visibleCount
|
|
}
|
|
|
|
func (m chatListModel) visibleWindow(total int) (start int, end int) {
|
|
if total == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
visibleCount := m.visibleChatCount()
|
|
maxOffset := max(total-visibleCount, 0)
|
|
cursor := min(max(m.cursor, 0), total-1)
|
|
start = min(max(min(max(m.offset, 0), maxOffset), cursor-visibleCount+1), cursor)
|
|
end = min(start+visibleCount, total)
|
|
return start, end
|
|
}
|
|
|
|
func (m chatListModel) Init() tea.Cmd {
|
|
return m.spinner.Tick
|
|
}
|
|
|
|
func (m chatListModel) Update(msg tea.Msg) (chatListModel, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.normalizeCursor()
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
if m.loading {
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
|
|
case chatsListedMsg:
|
|
m.chats = msg.chats
|
|
m.err = msg.err
|
|
m.loading = false
|
|
m.normalizeCursor()
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
key := msg.String()
|
|
if m.searching {
|
|
switch key {
|
|
case "esc":
|
|
if m.search.Value() != "" {
|
|
m.search.SetValue("")
|
|
}
|
|
m.search.Blur()
|
|
m.searching = false
|
|
m.normalizeCursor()
|
|
return m, nil
|
|
case "enter":
|
|
m.search.Blur()
|
|
m.searching = false
|
|
m.normalizeCursor()
|
|
return m, nil
|
|
default:
|
|
m.search, cmd = m.search.Update(msg)
|
|
m.normalizeCursor()
|
|
m.offset = 0
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
navigationHandled, normalizeNavigation := true, true
|
|
switch key {
|
|
case "/", "ctrl+f":
|
|
m.searching = true
|
|
m.search.Focus()
|
|
case "up", "k":
|
|
m.cursor--
|
|
case "down", "j":
|
|
m.cursor++
|
|
case "right", "l":
|
|
normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionExpand)
|
|
case "left", "h":
|
|
normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionCollapse)
|
|
case "x":
|
|
normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionToggle)
|
|
default:
|
|
navigationHandled = false
|
|
}
|
|
if navigationHandled {
|
|
if normalizeNavigation {
|
|
m.normalizeCursor()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch key {
|
|
case "enter":
|
|
selected := m.selectedChat()
|
|
if selected == nil {
|
|
return m, nil
|
|
}
|
|
return m, func() tea.Msg {
|
|
return openSelectedChatMsg{chatID: selected.ID}
|
|
}
|
|
case "n":
|
|
return m, func() tea.Msg {
|
|
return openDraftChatMsg{}
|
|
}
|
|
case "r":
|
|
m.loading = true
|
|
m.err = nil
|
|
return m, func() tea.Msg {
|
|
return refreshChatsMsg{}
|
|
}
|
|
case "q":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatListModel) View() string {
|
|
if m.loading {
|
|
return m.spinner.View() + " Loading chats…"
|
|
}
|
|
|
|
if m.err != nil {
|
|
return m.styles.errorText.Render(m.err.Error()) + "\n" + m.styles.helpText.Render("Press r to retry")
|
|
}
|
|
|
|
rows := m.displayRows()
|
|
lines := make([]string, 0, len(rows)+3)
|
|
if m.searching {
|
|
lines = append(lines, m.styles.searchInput.Render(m.search.View()))
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
if strings.TrimSpace(m.search.Value()) != "" {
|
|
lines = append(lines, m.styles.dimmedText.Render("No matches."))
|
|
} else {
|
|
lines = append(lines, m.styles.dimmedText.Render("No chats yet. Press n to start a new chat."))
|
|
}
|
|
help := fitHelpText(
|
|
m.width,
|
|
"/: search • n: new chat • r: refresh • q: quit",
|
|
"/ search • n new • r refresh • q quit",
|
|
"/ • n • r • q",
|
|
)
|
|
lines = append(lines, m.styles.helpText.Render(help))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
statusWidth := 12
|
|
start, end := m.visibleWindow(len(rows))
|
|
for i := start; i < end; i++ {
|
|
row := rows[i]
|
|
rowPrefix := " "
|
|
rowStyle := m.styles.normalItem
|
|
if i == m.cursor {
|
|
rowPrefix = "> "
|
|
rowStyle = m.styles.selectedItem
|
|
}
|
|
if row.depth > 0 {
|
|
rowPrefix += strings.Repeat(" ", row.depth)
|
|
}
|
|
if row.childCount > 0 {
|
|
if row.isExpanded {
|
|
rowPrefix += "▼ "
|
|
} else {
|
|
rowPrefix += "▶ "
|
|
}
|
|
}
|
|
|
|
extraText := ""
|
|
extra := ""
|
|
if row.childCount > 0 {
|
|
extraText = fmt.Sprintf(" (%d subagents)", row.childCount)
|
|
extra = m.styles.dimmedText.Render(extraText)
|
|
}
|
|
|
|
titleWidth := max(m.width-statusWidth-18-len(rowPrefix)-len(extraText), 20)
|
|
title := m.styles.truncate(sanitizeTerminalRenderableText(row.chat.Title), titleWidth)
|
|
status := m.styles.statusColor(row.chat.Status).Render(string(row.chat.Status))
|
|
rowText := fmt.Sprintf("%s%s %s %s%s", rowPrefix, rowStyle.Render(title), status, m.styles.dimmedText.Render(timeAgo(row.chat.UpdatedAt)), extra)
|
|
lines = append(lines, rowText)
|
|
|
|
if row.chat.Status == codersdk.ChatStatusError && row.chat.LastError != nil && row.chat.LastError.Message != "" {
|
|
lastError := row.chat.LastError.Message
|
|
errWidth := max(m.width-4, 20)
|
|
errPrefix := " "
|
|
if row.depth > 0 {
|
|
errPrefix += strings.Repeat(" ", row.depth)
|
|
}
|
|
lines = append(lines, errPrefix+m.styles.dimmedText.Render(m.styles.truncate(sanitizeTerminalRenderableText(lastError), errWidth)))
|
|
}
|
|
}
|
|
|
|
lines = append(lines, "")
|
|
help := fitHelpText(
|
|
m.width,
|
|
"↑/k: up • ↓/j: down • →/l: expand • ←/h: collapse • x: toggle • enter: open • /: search • n: new chat • r: refresh • q: quit",
|
|
"↑/k up • ↓/j down • →/l expand • ←/h collapse • x toggle • ↵ open • / search • n new • q quit",
|
|
"↑↓ nav • →← fold • x toggle • ↵ open • / search • n new • q quit",
|
|
"↑↓ • →← • x • ↵ • / • n • q",
|
|
)
|
|
lines = append(lines, m.styles.helpText.Render(help))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func timeAgo(t time.Time) string {
|
|
elapsed := time.Since(t)
|
|
if elapsed < time.Minute {
|
|
return "just now"
|
|
}
|
|
if elapsed < time.Hour {
|
|
return fmt.Sprintf("%dm ago", int(elapsed/time.Minute))
|
|
}
|
|
if elapsed < 24*time.Hour {
|
|
return fmt.Sprintf("%dh ago", int(elapsed/time.Hour))
|
|
}
|
|
return fmt.Sprintf("%dd ago", int(elapsed/(24*time.Hour)))
|
|
}
|