From 38f586107d273a36d8118e5e65805ee7d413b1c9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 13 May 2026 21:30:11 +0200 Subject: [PATCH] refactor: remove agents TUI (#25190) --- cli/agents.go | 205 - cli/agents_chat.go | 1444 ------- cli/agents_cmds.go | 180 - cli/agents_diff.go | 332 -- cli/agents_diff_test.go | 743 ---- cli/agents_e2e_helpers_test.go | 151 - cli/agents_e2e_test.go | 93 - cli/agents_helpers.go | 33 - cli/agents_list.go | 483 --- cli/agents_model.go | 514 --- cli/agents_render.go | 1251 ------- cli/agents_render_test.go | 1138 ------ cli/agents_stream_test.go | 131 - cli/agents_styles.go | 98 - cli/agents_test.go | 3331 ----------------- cli/root.go | 1 - cli/testdata/coder_--help.golden | 1 - cli/testdata/coder_agents_--help.golden | 16 - docs/README.md | 4 +- .../agents/tasks-to-chats-migration.md | 29 +- docs/ai-coder/index.md | 3 +- docs/reference/cli/agents.md | 28 - docs/reference/cli/index.md | 1 - 23 files changed, 13 insertions(+), 10197 deletions(-) delete mode 100644 cli/agents.go delete mode 100644 cli/agents_chat.go delete mode 100644 cli/agents_cmds.go delete mode 100644 cli/agents_diff.go delete mode 100644 cli/agents_diff_test.go delete mode 100644 cli/agents_e2e_helpers_test.go delete mode 100644 cli/agents_e2e_test.go delete mode 100644 cli/agents_helpers.go delete mode 100644 cli/agents_list.go delete mode 100644 cli/agents_model.go delete mode 100644 cli/agents_render.go delete mode 100644 cli/agents_render_test.go delete mode 100644 cli/agents_stream_test.go delete mode 100644 cli/agents_styles.go delete mode 100644 cli/agents_test.go delete mode 100644 cli/testdata/coder_agents_--help.golden delete mode 100644 docs/reference/cli/agents.md diff --git a/cli/agents.go b/cli/agents.go deleted file mode 100644 index 5ae92283ee..0000000000 --- a/cli/agents.go +++ /dev/null @@ -1,205 +0,0 @@ -package cli - -import ( - "context" - "os" - "os/signal" - "strings" - "syscall" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/google/uuid" - "github.com/muesli/termenv" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/codersdk" - "github.com/coder/serpent" -) - -func installTUISignalHandler(p *tea.Program) func() { - ch := make(chan struct{}) - go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) - defer func() { - signal.Stop(sig) - close(ch) - }() - for { - select { - case <-ch: - return - case <-sig: - p.Send(terminateTUIMsg{}) - } - } - }() - return func() { - ch <- struct{}{} - } -} - -func fitHelpText(width int, candidates ...string) string { - if len(candidates) == 0 { - return "" - } - if width <= 0 { - return candidates[0] - } - for _, candidate := range candidates { - if lipgloss.Width(candidate) <= width { - return candidate - } - } - return truncateText(candidates[len(candidates)-1], width, " •|│:", 1) -} - -func truncateText(text string, width int, trimRightCutset string, ellipsisWidth int) string { - if width <= 0 { - return "" - } - if lipgloss.Width(text) <= width { - return text - } - if width <= ellipsisWidth { - return "…" - } - for runes := []rune(text); len(runes) > 0; runes = runes[:len(runes)-1] { - truncated := strings.TrimRight(string(runes), trimRightCutset) + "…" - if lipgloss.Width(truncated) <= width { - return truncated - } - } - return "…" -} - -func (r *RootCmd) agentsCommand() *serpent.Command { - var ( - workspaceFlag string - modelFlag string - ) - - return &serpent.Command{ - Use: "agents [chat-id]", - Short: "Interactive terminal UI for AI agents.", - Options: serpent.OptionSet{ - { - Name: "workspace", - Flag: "workspace", - Description: "Associate the chat with a workspace by name, owner/name, or UUID.", - Value: serpent.StringOf(&workspaceFlag), - }, - { - Name: "model", - Flag: "model", - Description: "Choose a model by ID, provider/model, or display name.", - Value: serpent.StringOf(&modelFlag), - }, - }, - Handler: func(inv *serpent.Invocation) error { - client, err := r.InitClient(inv) - if err != nil { - return err - } - - orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) - if err != nil { - return xerrors.Errorf("list organizations: %w", err) - } - if len(orgs) == 0 { - return xerrors.New("no organizations found") - } - defaultOrgID := orgs[0].ID - - expClient := codersdk.NewExperimentalClient(client) - - if len(inv.Args) > 1 { - return xerrors.New("expected zero or one chat ID") - } - - var initialChatID *uuid.UUID - if len(inv.Args) == 1 { - chatID, err := uuid.Parse(inv.Args[0]) - if err != nil { - return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) - } - initialChatID = &chatID - } - - var workspaceID *uuid.UUID - if workspaceFlag != "" { - workspace, err := client.ResolveWorkspace(inv.Context(), workspaceFlag) - if err != nil { - return xerrors.Errorf("resolve workspace %q: %w", workspaceFlag, err) - } - workspaceID = &workspace.ID - } - - modelID, err := resolveModel(inv.Context(), expClient, modelFlag) - if err != nil { - return err - } - - // Set an explicit color profile before Bubble Tea acquires the - // terminal so lipgloss/termenv don't send OSC color queries that - // can leak back into stdin as literal input in some terminals. - renderer := lipgloss.NewRenderer( - inv.Stdout, - termenv.WithProfile(termenv.TrueColor), - ) - renderer.SetHasDarkBackground(true) - - model := newChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID, defaultOrgID) - model.setRenderer(renderer) - program := tea.NewProgram( - model, - tea.WithAltScreen(), - tea.WithoutSignalHandler(), - tea.WithContext(inv.Context()), - tea.WithInput(inv.Stdin), - tea.WithOutput(inv.Stdout), - ) - - closeSignalHandler := installTUISignalHandler(program) - defer closeSignalHandler() - - runModel, err := program.Run() - if err != nil { - return err - } - - if _, ok := runModel.(chatsTUIModel); !ok { - return xerrors.Errorf("unknown model found %T (%+v)", runModel, runModel) - } - - return nil - }, - } -} - -//nolint:nilnil // A nil string indicates that no model override was provided. -func resolveModel(ctx context.Context, client *codersdk.ExperimentalClient, modelFlag string) (*string, error) { - if modelFlag == "" { - return nil, nil - } - - if _, err := uuid.Parse(modelFlag); err == nil { - return &modelFlag, nil - } - - catalog, err := client.ListChatModels(ctx) - if err != nil { - return nil, xerrors.Errorf("listing models: %w", err) - } - - for _, provider := range catalog.Providers { - for _, model := range provider.Models { - if model.ID == modelFlag || model.Provider+"/"+model.Model == modelFlag || model.DisplayName == modelFlag { - return &model.ID, nil - } - } - } - - return nil, xerrors.Errorf("unknown model %q", modelFlag) -} diff --git a/cli/agents_chat.go b/cli/agents_chat.go deleted file mode 100644 index 8211659003..0000000000 --- a/cli/agents_chat.go +++ /dev/null @@ -1,1444 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "fmt" - "io" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" - "github.com/google/uuid" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/codersdk" -) - -type chatBlockKind int - -const ( - blockText chatBlockKind = iota - blockReasoning - blockToolCall - blockToolResult - blockCompaction -) - -type chatBlock struct { - kind chatBlockKind - role codersdk.ChatMessageRole - text string - toolName string - toolID string - args string - result string - isError bool - collapsedCount int - - cachedRender string - cachedWidth int - cachedExpanded bool - cachedCollapsedCount int -} - -type spinnerState bool - -type streamAccumulator struct { - parts []codersdk.ChatMessagePart - role codersdk.ChatMessageRole - pending bool - toolDeltas map[string]string -} - -func (a *streamAccumulator) applyDelta(mp codersdk.ChatStreamMessagePart) { - a.pending = true - a.role = mp.Role - part := mp.Part - - switch part.Type { - case codersdk.ChatMessagePartTypeText, codersdk.ChatMessagePartTypeReasoning: - if len(a.parts) > 0 && a.parts[len(a.parts)-1].Type == part.Type { - a.parts[len(a.parts)-1].Text += part.Text - } else { - a.parts = append(a.parts, part) - } - case codersdk.ChatMessagePartTypeToolCall: - if part.ArgsDelta != "" { - if a.toolDeltas == nil { - a.toolDeltas = make(map[string]string) - } - a.toolDeltas[part.ToolCallID] += part.ArgsDelta - found := false - for i, p := range a.parts { - if p.Type == codersdk.ChatMessagePartTypeToolCall && p.ToolCallID == part.ToolCallID { - a.parts[i].Args = json.RawMessage([]byte(a.toolDeltas[part.ToolCallID])) - found = true - break - } - } - if !found { - newPart := part - newPart.Args = json.RawMessage([]byte(a.toolDeltas[part.ToolCallID])) - newPart.ArgsDelta = "" - a.parts = append(a.parts, newPart) - } - } else { - found := false - for i, p := range a.parts { - if p.Type == codersdk.ChatMessagePartTypeToolCall && p.ToolCallID == part.ToolCallID { - a.parts[i] = part - found = true - break - } - } - if !found { - a.parts = append(a.parts, part) - } - } - default: - a.parts = append(a.parts, part) - } -} - -func (a streamAccumulator) isPending() bool { - return a.pending -} - -func (a *streamAccumulator) reset() { - *a = streamAccumulator{} -} - -// parsedAskOption represents one selectable option for a question. -type parsedAskOption struct { - Label string - Value string -} - -// parsedAskQuestion represents a single question within an ask_user_question -// tool call. -type parsedAskQuestion struct { - Header string - Question string - Options []parsedAskOption -} - -// askQuestionAnswer holds the user's answer for one question. -type askQuestionAnswer struct { - Header string `json:"header"` - Question string `json:"question"` - Answer string `json:"answer"` - OptionLabel string `json:"option_label,omitempty"` - Freeform bool `json:"freeform"` -} - -// askUserQuestionState holds the full state for an active ask_user_question -// overlay. -type askUserQuestionState struct { - ToolCallID string - Questions []parsedAskQuestion - Answers []askQuestionAnswer - CurrentIndex int - OptionCursor int - OtherMode bool - OtherInput textinput.Model - Submitting bool - Error error -} - -type askUserQuestionArgs struct { - Questions []parsedAskQuestion `json:"questions"` -} - -func newAskUserQuestionState(toolCallID string, questions []parsedAskQuestion) *askUserQuestionState { - otherInput := textinput.New() - otherInput.Placeholder = "Type your answer..." - - return &askUserQuestionState{ - ToolCallID: toolCallID, - Questions: questions, - Answers: make([]askQuestionAnswer, 0, len(questions)), - OtherInput: otherInput, - } -} - -func parseAskUserQuestionArgs(toolCallID string, rawArgs json.RawMessage) (*askUserQuestionState, error) { - var args askUserQuestionArgs - if err := json.Unmarshal(rawArgs, &args); err != nil { - return nil, xerrors.Errorf("parse ask_user_question args: %w", err) - } - if len(args.Questions) == 0 { - return nil, xerrors.New("ask_user_question args must include at least one question") - } - - return newAskUserQuestionState(toolCallID, args.Questions), nil -} - -func parseAskUserQuestionToolCall(toolCall codersdk.ChatStreamToolCall) (*askUserQuestionState, error) { - return parseAskUserQuestionArgs(toolCall.ToolCallID, json.RawMessage([]byte(toolCall.Args))) -} - -func buildAskUserQuestionToolResult(state *askUserQuestionState) (json.RawMessage, error) { - if state == nil { - return nil, xerrors.New("ask-user-question state is required") - } - - answers := state.Answers - if answers == nil { - answers = []askQuestionAnswer{} - } - - output, err := json.Marshal(struct { - Answers []askQuestionAnswer `json:"answers"` - }{ - Answers: answers, - }) - if err != nil { - return nil, xerrors.Errorf("marshal ask_user_question tool result: %w", err) - } - return json.RawMessage(output), nil -} - -func findPendingAskUserQuestion(messages []codersdk.ChatMessage) (*askUserQuestionState, error) { - answeredToolCalls := make(map[string]struct{}) - for i := len(messages) - 1; i >= 0; i-- { - for j := len(messages[i].Content) - 1; j >= 0; j-- { - part := messages[i].Content[j] - if part.Type != codersdk.ChatMessagePartTypeToolResult || part.ToolCallID == "" { - continue - } - if !toolResultHasAnswers(part.Result) { - continue - } - answeredToolCalls[part.ToolCallID] = struct{}{} - } - } - - for i := len(messages) - 1; i >= 0; i-- { - for j := len(messages[i].Content) - 1; j >= 0; j-- { - part := messages[i].Content[j] - if part.Type != codersdk.ChatMessagePartTypeToolCall || part.ToolName != "ask_user_question" { - continue - } - if _, ok := answeredToolCalls[part.ToolCallID]; ok { - continue - } - return parseAskUserQuestionArgs(part.ToolCallID, part.Args) - } - } - - //nolint:nilnil // Nil state and nil error mean no pending tool call was found. - return nil, nil -} - -// toolResultHasAnswers returns true when the tool result payload contains an -// "answers" field, which indicates the user submitted answers for an -// ask_user_question tool call. -func toolResultHasAnswers(result json.RawMessage) bool { - if len(result) == 0 { - return false - } - - var shape struct { - Answers json.RawMessage `json:"answers"` - } - if err := json.Unmarshal(result, &shape); err != nil { - return false - } - return len(shape.Answers) > 0 -} - -type chatViewModel struct { - styles tuiStyles - chat *codersdk.Chat - messages []codersdk.ChatMessage - blocks []chatBlock - loading bool - err error - metadataResolved bool - historyResolved bool - metadataErr error - historyErr error - draft bool - composer textinput.Model - viewport viewport.Model - spinner spinner.Model - accumulator streamAccumulator - width int - height int - cachedRenderer *glamour.TermRenderer - cachedRendererWidth int - lastTranscript string - - ctx context.Context - client *codersdk.ExperimentalClient - workspaceID *uuid.UUID - modelOverride *string - organizationID uuid.UUID - activeChatID uuid.UUID - chatGeneration uint64 - intentionalClose bool - creatingChat bool - pendingComposerText string - planMode codersdk.ChatPlanMode - - streaming bool - streamCloser io.Closer - streamEventCh <-chan codersdk.ChatStreamEvent - reconnecting bool - - chatStatus codersdk.ChatStatus - lastUsage *codersdk.ChatMessageUsage - queuedMessages []codersdk.ChatQueuedMessage - pendingAskUserQuestion *askUserQuestionState - - composerFocused bool - selectedBlock int - expandedBlocks map[int]bool - autoFollow bool - interrupting bool - - diffStatus *codersdk.ChatDiffStatus - diffContents *codersdk.ChatDiffContents - // diffSummary caches the rendered "N files changed" summary - // for diffContents so renderDiffDrawer can reuse it across - // View() redraws. parseChatGitChangesFromUnifiedDiff walks the - // full (potentially 4 MiB) diff text, so recomputing it on every - // keypress or resize stalls the TUI for large diffs. - diffSummary string - // diffStyledBody caches the lipgloss-styled unified-diff body for - // diffContents. renderStyledDiffBody sanitizes, splits, and styles - // every line of the (potentially 4 MiB) diff, and styles are stable - // across redraws (setRenderer runs once at startup), so we - // invalidate on the same trigger as diffSummary. - diffStyledBody string - diffErr error - - modelPickerFlat []codersdk.ChatModel - modelPickerCursor int -} - -func modelOverrideUUID(modelOverride *string) *uuid.UUID { - if modelOverride == nil { - return nil - } - - modelConfigID, err := uuid.Parse(*modelOverride) - if err != nil { - return nil - } - return &modelConfigID -} - -func canonicalChatModelID(provider, model string) string { - return strings.ToLower(strings.TrimSpace(provider)) + ":" + strings.TrimSpace(model) -} - -func normalizeChatModelOverride(modelOverride string) string { - modelOverride = strings.TrimSpace(modelOverride) - provider, model, ok := strings.Cut(modelOverride, "/") - if ok { - return canonicalChatModelID(provider, model) - } - provider, model, ok = strings.Cut(modelOverride, ":") - if ok { - return canonicalChatModelID(provider, model) - } - return modelOverride -} - -func resolveModelConfigID(ctx context.Context, client *codersdk.ExperimentalClient, modelOverride *string) (*uuid.UUID, error) { - if modelOverride == nil { - return nil, xerrors.New("model override is required") - } - if modelConfigID := modelOverrideUUID(modelOverride); modelConfigID != nil { - return modelConfigID, nil - } - - configs, err := client.ListChatModelConfigs(ctx) - if err != nil { - return nil, xerrors.Errorf("list chat model configs: %w", err) - } - - canonicalOverride := normalizeChatModelOverride(*modelOverride) - for _, config := range configs { - if canonicalChatModelID(config.Provider, config.Model) != canonicalOverride { - continue - } - modelConfigID := config.ID - return &modelConfigID, nil - } - - return nil, xerrors.Errorf("resolve model config ID for %q: no matching enabled model config", *modelOverride) -} - -func newChatViewModel( - ctx context.Context, - client *codersdk.ExperimentalClient, - workspaceID *uuid.UUID, - modelOverride *string, - organizationID uuid.UUID, - styles tuiStyles, -) chatViewModel { - composer := textinput.New() - composer.Placeholder = "Type a message..." - composer.Prompt = "> " - composer.Focus() - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = styles.dimmedText - - model := chatViewModel{ - ctx: ctx, - client: client, - workspaceID: workspaceID, - modelOverride: modelOverride, - organizationID: organizationID, - styles: styles, - loading: false, - metadataResolved: true, - historyResolved: true, - composerFocused: true, - expandedBlocks: make(map[int]bool), - autoFollow: true, - composer: composer, - viewport: viewport.New(0, 0), - spinner: s, - } - model.setComposerWidth() - return model -} - -func (m *chatViewModel) setComposerWidth() { - m.composer.Width = max(10, m.width-4) -} - -func (m *chatViewModel) recalcViewportHeight() { - if m.height <= 0 || m.width <= 0 { - return - } - - viewWidth := m.width - if viewWidth <= 0 { - viewWidth = 80 - } - - composerView := m.styles.composerStyle.Width(max(10, viewWidth-2)).Render(m.composer.View()) - composerHeight := lipgloss.Height(composerView) - - const nonViewportHeight = 4 - m.viewport.Width = m.width - m.viewport.Height = max(0, m.height-nonViewportHeight-composerHeight) -} -func (m *chatViewModel) refreshViewport() { m.recalcViewportHeight(); m.syncViewportContent() } - -func (m chatViewModel) readyToStartStream() bool { - return m.metadataResolved && m.historyResolved && m.err == nil && m.chat != nil && m.client != nil && !m.streaming -} - -func (m *chatViewModel) finishLoading(wasSpinnerActive bool) (chatViewModel, tea.Cmd) { - m.err = m.historyErr - if m.metadataErr != nil { - m.err = m.metadataErr - } - m.loading = !m.metadataResolved || !m.historyResolved - return m.startStreamIfReady(wasSpinnerActive) -} - -// restorePendingComposerIfEmpty restores pending text to the -// composer only when the user has not typed new input since the -// original send was dispatched. -func (m *chatViewModel) restorePendingComposerIfEmpty() { - if m.pendingComposerText != "" && m.composer.Value() == "" { - m.composer.SetValue(m.pendingComposerText) - m.recalcViewportHeight() - } -} - -func (m *chatViewModel) stopStream() { - m.intentionalClose = true - if m.streamCloser != nil { - _ = m.streamCloser.Close() - m.streaming, m.streamCloser, m.streamEventCh = false, nil, nil - } -} - -// matchesGeneration returns true when the generation embedded in an -// async message matches the current chat session generation. This -// prevents stale results from previous sessions (including drafts) -// from mutating the active view. -func (m chatViewModel) matchesGeneration(gen uint64) bool { - return m.chatGeneration == gen -} - -func (m *chatViewModel) setChat(chat codersdk.Chat) { - m.chat = &chat - m.activeChatID = chat.ID - m.chatStatus = chat.Status - m.diffStatus = chat.DiffStatus - m.diffContents = nil - m.diffSummary = "" - m.diffStyledBody = "" - m.diffErr = nil -} - -// recoverPendingAskUserQuestion restores the pending ask_user_question -// overlay after reopening a chat that is waiting on client input. -func (m *chatViewModel) recoverPendingAskUserQuestion() (tea.Cmd, error) { - if m.chatStatus != codersdk.ChatStatusRequiresAction { - return nil, nil //nolint:nilnil // Nil command means there is no pending recovery work. - } - - state, err := findPendingAskUserQuestion(m.messages) - if err != nil { - return nil, xerrors.Errorf("recover pending ask_user_question: %w", err) - } - return m.showPendingAskUserQuestion(state), nil -} - -func (m *chatViewModel) recoverPendingAskUserQuestionFromAccumulator() (tea.Cmd, error) { - if m.chatStatus != codersdk.ChatStatusRequiresAction { - return nil, nil //nolint:nilnil // Nil command means there is no pending recovery work. - } - - for i := len(m.accumulator.parts) - 1; i >= 0; i-- { - part := m.accumulator.parts[i] - if part.Type != codersdk.ChatMessagePartTypeToolCall || - part.ToolName != "ask_user_question" { - continue - } - - state, err := parseAskUserQuestionArgs(part.ToolCallID, part.Args) - if err != nil { - return nil, xerrors.Errorf( - "recover pending ask_user_question from accumulator: %w", - err, - ) - } - return m.showPendingAskUserQuestion(state), nil - } - - return nil, nil //nolint:nilnil // Nil command means there is no pending recovery work. -} - -func (m *chatViewModel) showPendingAskUserQuestion(state *askUserQuestionState) tea.Cmd { - if state == nil { - return nil - } - if m.pendingAskUserQuestion != nil && - m.pendingAskUserQuestion.ToolCallID == state.ToolCallID { - return nil - } - - m.pendingAskUserQuestion = state - return func() tea.Msg { - return showAskUserQuestionMsg{state: state} - } -} - -func (m chatViewModel) isInterruptible() bool { - return m.chatStatus == codersdk.ChatStatusPending || - m.chatStatus == codersdk.ChatStatusRunning -} - -func (m chatViewModel) shouldReconnect() bool { - return m.chat != nil && (m.isInterruptible() || m.chatStatus == codersdk.ChatStatusWaiting) -} -func (m chatViewModel) Init() tea.Cmd { return m.spinner.Tick } -func (m chatViewModel) spinnerActive() bool { - return m.reconnecting || m.accumulator.pending || m.isInterruptible() -} - -func (m chatViewModel) spinnerLabel() string { - if m.reconnecting { - return "Reconnecting..." - } - return "Thinking..." -} - -// spinnerVisibleInViewport reports whether the transient spinner line is -// currently visible. When it is offscreen we can skip spinner-only transcript -// refreshes and avoid scroll artifacts while preserving the next visible frame. -func (m chatViewModel) spinnerVisibleInViewport() bool { - return m.viewport.AtBottom() -} - -func (m chatViewModel) startSpinnerIfNeeded(wasSpinnerActive spinnerState, cmd tea.Cmd) tea.Cmd { - if bool(wasSpinnerActive) || !m.spinnerActive() { - return cmd - } - if cmd == nil { - return m.spinner.Tick - } - return tea.Batch(cmd, m.spinner.Tick) -} - -func availableChatModels(catalog codersdk.ChatModelsResponse) []codersdk.ChatModel { - var models []codersdk.ChatModel - for _, provider := range catalog.Providers { - if provider.Available { - models = append(models, provider.Models...) - } - } - return models -} - -func (m chatViewModel) togglePlanMode() codersdk.ChatPlanMode { - if m.planMode == codersdk.ChatPlanModePlan { - return "" - } - return codersdk.ChatPlanModePlan -} - -func (m chatViewModel) updatePlanModeCmd() tea.Cmd { - mode := m.planMode - return apiCmd(func() (struct{}, error) { - return struct{}{}, m.client.UpdateChat(m.ctx, m.chat.ID, codersdk.UpdateChatRequest{ - PlanMode: &mode, - }) - }, func(_ struct{}, err error) tea.Msg { - return chatPlanModeUpdatedMsg{generation: m.chatGeneration, chatID: m.chat.ID, err: err} - }) -} - -// sendMessage trims the composer, builds the content, and dispatches -// a create-chat or send-message command. -func (m chatViewModel) sendMessage() (chatViewModel, tea.Cmd) { - text := strings.TrimSpace(m.composer.Value()) - if text == "" { - return m, nil - } - if m.loading { - return m, nil - } - if !m.draft && m.chat == nil { - return m, nil - } - if m.draft && m.creatingChat { - return m, nil - } - m.autoFollow = true - m.pendingComposerText = text - m.composer.SetValue("") - (&m).recalcViewportHeight() - content := []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: text, - }} - - modelConfigID := modelOverrideUUID(m.modelOverride) - - if m.draft { - req := codersdk.CreateChatRequest{ - OrganizationID: m.organizationID, - Content: content, - WorkspaceID: m.workspaceID, - ModelConfigID: modelConfigID, - PlanMode: m.planMode, - } - m.creatingChat = true - return m, apiCmd(func() (codersdk.Chat, error) { - if req.ModelConfigID == nil && m.modelOverride != nil { - modelConfigID, err := resolveModelConfigID(m.ctx, m.client, m.modelOverride) - if err != nil { - return codersdk.Chat{}, err - } - req.ModelConfigID = modelConfigID - } - return m.client.CreateChat(m.ctx, req) - }, func(chat codersdk.Chat, err error) tea.Msg { - return chatCreatedMsg{generation: m.chatGeneration, chatID: chat.ID, chat: chat, err: err} - }) - } - - mode := m.planMode - req := codersdk.CreateChatMessageRequest{ - Content: content, - ModelConfigID: modelConfigID, - PlanMode: &mode, - } - return m, apiCmd(func() (codersdk.CreateChatMessageResponse, error) { - if req.ModelConfigID == nil && m.modelOverride != nil { - modelConfigID, err := resolveModelConfigID(m.ctx, m.client, m.modelOverride) - if err != nil { - return codersdk.CreateChatMessageResponse{}, err - } - req.ModelConfigID = modelConfigID - } - return m.client.CreateChatMessage(m.ctx, m.chat.ID, req) - }, func(resp codersdk.CreateChatMessageResponse, err error) tea.Msg { - return messageSentMsg{generation: m.chatGeneration, chatID: m.chat.ID, resp: resp, err: err} - }) -} - -// startStream opens a streaming connection from the latest known message ID. -func (m chatViewModel) startStream() (chatViewModel, tea.Cmd) { - if m.chat == nil || m.streaming { - return m, nil - } - m.intentionalClose = false - - var opts *codersdk.StreamChatOptions - if len(m.messages) > 0 { - lastID := m.messages[len(m.messages)-1].ID - opts = &codersdk.StreamChatOptions{AfterID: &lastID} - } - - eventCh, closer, err := m.client.StreamChat(m.ctx, m.chat.ID, opts) - if err != nil { - m.err = err - return m, nil - } - m.streaming, m.streamCloser, m.streamEventCh, m.reconnecting = true, closer, eventCh, false - m.syncViewportContent() - return m, listenToStream(m.activeChatID, m.chatGeneration, m.streamEventCh) -} - -func (m chatViewModel) startStreamWithSpinner(wasSpinnerActive bool) (chatViewModel, tea.Cmd) { - updated, cmd := m.startStream() - return updated, updated.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), cmd) -} - -func (m chatViewModel) startStreamIfReady(wasSpinnerActive bool) (chatViewModel, tea.Cmd) { - if !m.readyToStartStream() { - return m, m.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), nil) - } - return m.startStreamWithSpinner(wasSpinnerActive) -} - -// rebuildBlocks merges persisted messages + accumulator into renderable blocks. -func (m *chatViewModel) rebuildBlocks() { - oldBlocks := m.blocks - m.blocks = messagesToBlocks(m.messages) - - if m.accumulator.pending { - finalizedToolIDs := make(map[string]struct{}, len(m.blocks)) - for _, block := range m.blocks { - if block.toolID == "" { - continue - } - finalizedToolIDs[block.toolID] = struct{}{} - } - for _, part := range m.accumulator.parts { - if (part.Type == codersdk.ChatMessagePartTypeToolCall || part.Type == codersdk.ChatMessagePartTypeToolResult) && part.ToolCallID != "" { - if _, ok := finalizedToolIDs[part.ToolCallID]; ok { - continue - } - } - switch part.Type { - case codersdk.ChatMessagePartTypeReasoning: - m.blocks = append(m.blocks, chatBlock{kind: blockReasoning, role: m.accumulator.role, text: part.Text}) - case codersdk.ChatMessagePartTypeToolCall: - kind := blockToolCall - if part.ToolName == contextCompactionToolName { - kind = blockCompaction - } - m.blocks = append(m.blocks, chatBlock{ - kind: kind, - role: m.accumulator.role, - toolName: part.ToolName, - toolID: part.ToolCallID, - args: compactTranscriptJSON(part.Args), - }) - case codersdk.ChatMessagePartTypeToolResult: - kind := blockToolResult - if part.ToolName == contextCompactionToolName { - kind = blockCompaction - } - m.blocks = append(m.blocks, chatBlock{ - kind: kind, - role: m.accumulator.role, - toolName: part.ToolName, - toolID: part.ToolCallID, - result: compactTranscriptJSON(part.Result), - isError: part.IsError, - }) - case codersdk.ChatMessagePartTypeSource: - title := part.Title - if title == "" { - title = part.URL - } - m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: fmt.Sprintf("[Source: %s](%s)", title, part.URL)}) - case codersdk.ChatMessagePartTypeFile: - m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: fmt.Sprintf("[File: %s]", part.MediaType)}) - case codersdk.ChatMessagePartTypeFileReference: - m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: fmt.Sprintf("[%s L%d-%d]", part.FileName, part.StartLine, part.EndLine)}) - default: - m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: part.Text}) - } - } - } - - m.blocks = mergeConsecutiveToolBlocks(m.blocks) - - for _, qm := range m.queuedMessages { - for _, part := range qm.Content { - if part.Type == codersdk.ChatMessagePartTypeText && part.Text != "" { - m.blocks = append(m.blocks, chatBlock{ - kind: blockText, - role: codersdk.ChatMessageRoleUser, - text: part.Text, - }) - } - } - } - - for i := range m.blocks { - if i >= len(oldBlocks) || !blockPayloadEqual(m.blocks[i], oldBlocks[i]) { - continue - } - m.blocks[i].cachedRender = oldBlocks[i].cachedRender - m.blocks[i].cachedWidth = oldBlocks[i].cachedWidth - m.blocks[i].cachedExpanded = oldBlocks[i].cachedExpanded - m.blocks[i].cachedCollapsedCount = oldBlocks[i].cachedCollapsedCount - } - - if m.selectedBlock >= len(m.blocks) { - m.selectedBlock = max(len(m.blocks)-1, 0) - } - - m.syncViewportContent() -} - -func (m *chatViewModel) clearPendingStreamAccumulator() { - m.accumulator.reset() - m.rebuildBlocks() -} - -func (m chatViewModel) handleStreamError(err error, wasSpinnerActive bool) (chatViewModel, tea.Cmd) { - if !xerrors.Is(err, io.EOF) { - m.err = err - } - m.streaming, m.streamCloser, m.streamEventCh = false, nil, nil - if m.intentionalClose { - m.intentionalClose = false - return m, nil - } - if !m.shouldReconnect() { - return m, nil - } - m.clearPendingStreamAccumulator() - m.reconnecting = true - m.syncViewportContent() - updated, cmd := m.startStreamWithSpinner(wasSpinnerActive) - if updated.streaming { - updated.err = nil - return updated, cmd - } - return updated, tea.Batch(cmd, scheduleStreamRetry(updated.chatGeneration, 2*time.Second)) -} - -func (m *chatViewModel) getOrCreateMarkdownRenderer(width int) *glamour.TermRenderer { - if m.cachedRendererWidth == width && m.cachedRenderer != nil { - return m.cachedRenderer - } - - m.cachedRendererWidth = width - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), - glamour.WithWordWrap(width), - ) - if err != nil { - m.cachedRenderer = nil - return nil - } - - m.cachedRenderer = renderer - return renderer -} - -func (m *chatViewModel) syncViewportContent() { - wrapWidth := m.width - if wrapWidth <= 0 { - wrapWidth = 80 - } - - transcript := renderChatBlocks( - m.styles, - m.blocks, - m.selectedBlock, - m.expandedBlocks, - m.composerFocused, - m.width, - m.getOrCreateMarkdownRenderer(wrapWidth), - ) - - if m.spinnerActive() { - indicator := m.spinner.View() + " " + m.spinnerLabel() - transcript += "\n" + m.styles.dimmedText.Render(indicator) - } - - if transcript != m.lastTranscript { - m.lastTranscript = transcript - m.viewport.SetContent(transcript) - } - if m.autoFollow { - m.viewport.GotoBottom() - } -} - -func blockPayloadEqual(a, b chatBlock) bool { - return a.kind == b.kind && - a.role == b.role && - a.text == b.text && - a.toolName == b.toolName && - a.toolID == b.toolID && - a.args == b.args && - a.result == b.result && - a.isError == b.isError -} - -func (m *chatViewModel) addMessageIfNew(msg codersdk.ChatMessage) bool { - for _, existing := range m.messages { - if existing.ID == msg.ID { - return false - } - } - m.messages = append(m.messages, msg) - return true -} - -func (m chatViewModel) Update(msg tea.Msg) (chatViewModel, tea.Cmd) { - wasSpinnerActive := m.spinnerActive() - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - m.setComposerWidth() - m.refreshViewport() - return m, nil - - case spinner.TickMsg: - if !m.spinnerActive() { - return m, nil - } - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - if m.spinnerVisibleInViewport() { - m.syncViewportContent() - } - return m, cmd - - case tea.KeyMsg: - if msg.Type == tea.KeyShiftTab || msg.String() == "shift+tab" || msg.String() == "backtab" { - m.planMode = m.togglePlanMode() - if !m.draft && m.chat != nil { - return m, m.updatePlanModeCmd() - } - return m, nil - } - if msg.String() == "tab" { - m.composerFocused = !m.composerFocused - if m.composerFocused { - m.composer.Focus() - } else { - m.composer.Blur() - } - m.syncViewportContent() - return m, nil - } - - // Shortcut keys take priority over composer input so the parent model - // can toggle overlays and the chat view can interrupt active chats. - switch msg.Type { - case tea.KeyCtrlP: - return m, func() tea.Msg { return toggleModelPickerMsg{} } - case tea.KeyCtrlD: - return m, func() tea.Msg { return toggleDiffDrawerMsg{} } - case tea.KeyCtrlX: - if !m.isInterruptible() || m.chat == nil || m.interrupting { - return m, nil - } - m.interrupting = true - chatID := m.chat.ID - generation := m.chatGeneration - ctx := m.ctx - client := m.client - return m, apiCmd(func() (codersdk.Chat, error) { - return client.InterruptChat(ctx, chatID) - }, func(chat codersdk.Chat, err error) tea.Msg { - return chatInterruptedMsg{generation: generation, chatID: chatID, chat: chat, err: err} - }) - } - - if m.composerFocused { - if msg.Type == tea.KeyEnter { - if m.pendingAskUserQuestion != nil { - return m, nil - } - return m.sendMessage() - } - var cmd tea.Cmd - m.composer, cmd = m.composer.Update(msg) - m.refreshViewport() - return m, cmd - } - - switch msg.String() { - case "up", "k": - m.viewport.LineUp(3) - m.autoFollow = false - case "down", "j": - m.viewport.LineDown(3) - m.autoFollow = m.viewport.AtBottom() - case "pgup": - m.viewport.HalfViewUp() - m.autoFollow = false - case "pgdown": - m.viewport.HalfViewDown() - m.autoFollow = m.viewport.AtBottom() - case "home": - m.viewport.GotoTop() - m.autoFollow = false - case "end": - m.viewport.GotoBottom() - m.autoFollow = true - default: - return m, nil - } - return m, nil - - case chatOpenedMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - m.metadataResolved = true - var ( - cmds []tea.Cmd - recoveryErr error - ) - if msg.err != nil { - m.metadataErr = msg.err - } else { - m.metadataErr = nil - m.setChat(msg.chat) - m.planMode = m.chat.PlanMode - if m.historyResolved { - recoveryCmd, err := m.recoverPendingAskUserQuestion() - if err != nil { - recoveryErr = err - } else if recoveryCmd != nil { - cmds = append(cmds, recoveryCmd) - } - } - } - updated, cmd := m.finishLoading(wasSpinnerActive) - cmds = append(cmds, cmd) - if recoveryErr != nil && updated.err == nil { - updated.err = recoveryErr - } - return updated, tea.Batch(cmds...) - - case chatPlanModeUpdatedMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - if msg.err != nil { - m.planMode = m.togglePlanMode() - m.err = msg.err - } - return m, nil - - case chatHistoryMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - m.historyResolved = true - var ( - cmds []tea.Cmd - recoveryErr error - ) - if msg.err != nil { - m.historyErr = msg.err - } else { - m.historyErr, m.messages, m.lastUsage = nil, msg.messages, nil - for i := len(m.messages) - 1; i >= 0; i-- { - if m.messages[i].Usage != nil { - m.lastUsage = m.messages[i].Usage - break - } - } - m.autoFollow = true - m.rebuildBlocks() - - // Recover pending ask_user_question from history. - if m.chatStatus == codersdk.ChatStatusRequiresAction { - recoveryCmd, err := m.recoverPendingAskUserQuestion() - if err != nil { - recoveryErr = err - } else if recoveryCmd != nil { - cmds = append(cmds, recoveryCmd) - } - } - } - updated, cmd := m.finishLoading(wasSpinnerActive) - cmds = append(cmds, cmd) - if recoveryErr != nil && updated.err == nil { - updated.err = recoveryErr - } - return updated, tea.Batch(cmds...) - - case chatCreatedMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - m.creatingChat = false - if msg.err != nil { - m.err = msg.err - m.restorePendingComposerIfEmpty() - return m, nil - } - m.setChat(msg.chat) - m.draft = false - m.err, m.pendingComposerText = nil, "" - return m.startStreamWithSpinner(wasSpinnerActive) - - case messageSentMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - if msg.err != nil { - m.err = msg.err - m.restorePendingComposerIfEmpty() - return m, nil - } - m.err, m.pendingComposerText = nil, "" - if msg.resp.Message != nil { - m.addMessageIfNew(*msg.resp.Message) - } - if msg.resp.Queued && msg.resp.QueuedMessage != nil { - m.queuedMessages = []codersdk.ChatQueuedMessage{*msg.resp.QueuedMessage} - } - m.rebuildBlocks() - return m.startStreamIfReady(wasSpinnerActive) - - case toolResultsSubmittedMsg: - if !m.matchesGeneration(msg.generation) || m.activeChatID != msg.chatID { - return m, nil - } - if msg.err != nil { - if m.pendingAskUserQuestion != nil { - m.pendingAskUserQuestion.Submitting = false - m.pendingAskUserQuestion.Error = msg.err - } - return m, nil - } - m.pendingAskUserQuestion = nil - return m, nil - - case chatInterruptedMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - m.interrupting = false - if msg.err != nil { - m.err = msg.err - return m, nil - } - chat := msg.chat - m.chat, m.chatStatus = &chat, chat.Status - m.syncViewportContent() - return m, m.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), nil) - - case chatStreamEventMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - if msg.err != nil { - return m.handleStreamError(msg.err, wasSpinnerActive) - } - updated, cmd := m.handleStreamEvent(msg.event) - return updated, updated.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), cmd) - - case streamRetryMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - if m.streaming || !m.shouldReconnect() { - return m, nil - } - updated, cmd := m.startStreamWithSpinner(wasSpinnerActive) - if updated.streaming { - updated.err = nil - return updated, cmd - } - return updated, tea.Batch(cmd, scheduleStreamRetry(updated.chatGeneration, 5*time.Second)) - - case modelsListedMsg: - if msg.err != nil { - return m, nil - } - m.modelPickerFlat = availableChatModels(msg.catalog) - if m.modelPickerCursor >= len(m.modelPickerFlat) { - m.modelPickerCursor = max(len(m.modelPickerFlat)-1, 0) - } - return m, nil - - case diffContentsMsg: - if !m.matchesGeneration(msg.generation) { - return m, nil - } - if msg.err != nil { - m.diffErr = msg.err - return m, nil - } - diff := msg.diff - m.diffContents = &diff - // Pre-render the summary and styled body once so View() - // redraws reuse them instead of re-parsing and re-styling - // the full diff on every keypress. Styles are stable after - // setRenderer, so these caches only need to be refreshed - // when diffContents changes. - m.diffSummary = renderChatDiffSummary(diff) - m.diffStyledBody = renderStyledDiffBody(m.styles, diff.Diff) - return m, nil - - default: - return m, nil - } -} - -func (m chatViewModel) handleStreamEvent(event codersdk.ChatStreamEvent) (chatViewModel, tea.Cmd) { - nextCmd := func(cmd tea.Cmd) tea.Cmd { - if m.streaming && m.streamEventCh != nil { - listenCmd := listenToStream(m.activeChatID, m.chatGeneration, m.streamEventCh) - if cmd != nil { - return tea.Batch(cmd, listenCmd) - } - return listenCmd - } - return cmd - } - - switch event.Type { - case codersdk.ChatStreamEventTypeMessagePart: - if event.MessagePart != nil { - m.accumulator.applyDelta(*event.MessagePart) - m.rebuildBlocks() - } - - case codersdk.ChatStreamEventTypeMessage: - if event.Message != nil { - m.addMessageIfNew(*event.Message) - if event.Message.Usage != nil { - m.lastUsage = event.Message.Usage - } - m.accumulator = streamAccumulator{} - m.reconnecting = false - m.rebuildBlocks() - } - - case codersdk.ChatStreamEventTypeStatus: - if event.Status != nil && event.ChatID == m.activeChatID { - m.chatStatus = event.Status.Status - if m.chat != nil { - m.chat.Status = event.Status.Status - } - - var recoveryCmd tea.Cmd - if event.Status.Status == codersdk.ChatStatusRequiresAction && - m.pendingAskUserQuestion == nil { - var err error - recoveryCmd, err = m.recoverPendingAskUserQuestion() - if err != nil { - m.err = err - } else if recoveryCmd == nil { - recoveryCmd, err = m.recoverPendingAskUserQuestionFromAccumulator() - if err != nil { - m.err = err - } - } - } - - m.syncViewportContent() - if recoveryCmd != nil { - return m, nextCmd(recoveryCmd) - } - } - - case codersdk.ChatStreamEventTypeQueueUpdate: - m.queuedMessages = event.QueuedMessages - m.rebuildBlocks() - - case codersdk.ChatStreamEventTypeRetry: - m.reconnecting = true - m.syncViewportContent() - - case codersdk.ChatStreamEventTypeActionRequired: - if event.ActionRequired == nil { - return m, nextCmd(nil) - } - for _, tc := range event.ActionRequired.ToolCalls { - if tc.ToolName != "ask_user_question" { - continue - } - - state, err := parseAskUserQuestionToolCall(tc) - if err != nil { - return m, func() tea.Msg { - return chatStreamEventMsg{ - generation: m.chatGeneration, - chatID: m.activeChatID, - event: codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeError, - Error: &codersdk.ChatError{ - Message: fmt.Sprintf( - "failed to parse ask_user_question: %v", - err, - ), - }, - }, - } - } - } - - return m, nextCmd(m.showPendingAskUserQuestion(state)) - } - - case codersdk.ChatStreamEventTypeError: - if event.Error != nil { - m.err = xerrors.Errorf("stream error: %s", event.Error.Message) - } - } - - return m, nextCmd(nil) -} - -func (m chatViewModel) View() string { - viewWidth := m.width - if viewWidth <= 0 { - viewWidth = 80 - } - - header := "New Chat (draft)" - if !m.draft && m.chat != nil { - chatID := m.chat.ID.String() - shortID := chatID - if len(chatID) > 8 { - shortID = chatID[:8] - } - header = fmt.Sprintf("%s (%s)", sanitizeTerminalRenderableText(m.chat.Title), shortID) - } - - statusBar := renderStatusBar( - m.styles, - m.chat, - m.chatStatus, - m.lastUsage, - len(m.queuedMessages), - m.interrupting, - m.reconnecting, - viewWidth, - ) - - errorBanner := "" - if m.err != nil { - errorBanner = m.styles.errorText.Render(m.styles.truncate(strings.ReplaceAll(m.err.Error(), "\n", " "), viewWidth)) - } - - composerView := m.styles.composerStyle.Width(max(10, viewWidth-2)).Render(m.composer.View()) - - modeLabel := "exec" - modeBadgeStyle := m.styles.modeBadgeExec - if m.planMode == codersdk.ChatPlanModePlan { - modeLabel = "plan" - modeBadgeStyle = m.styles.modeBadgePlan - } - longHelpParts := []string{"mode: " + modeLabel, "shift+tab: switch mode", "tab: switch focus", "esc: back"} - shortHelpParts := []string{"mode: " + modeLabel, "⇧tab mode", "tab focus", "esc back"} - compactHelpParts := []string{"mode:" + modeLabel, "⇧tab", "tab", "esc"} - if m.composerFocused { - longHelpParts = append(longHelpParts, "enter: send") - shortHelpParts = append(shortHelpParts, "↵ send") - compactHelpParts = append(compactHelpParts, "↵") - } else { - longHelpParts = append(longHelpParts, "↑↓: scroll", "pgup/pgdn: page", "home/end: jump") - shortHelpParts = append(shortHelpParts, "↑↓ scroll", "pg page", "home/end") - compactHelpParts = append(compactHelpParts, "↑↓", "pg", "home/end") - } - if m.isInterruptible() { - longHelpParts = append(longHelpParts, "ctrl+x: interrupt") - shortHelpParts = append(shortHelpParts, "ctrl+x") - compactHelpParts = append(compactHelpParts, "^X") - } - longHelpParts = append(longHelpParts, "ctrl+p: models", "ctrl+d: diff") - shortHelpParts = append(shortHelpParts, "ctrl+p", "ctrl+d") - compactHelpParts = append(compactHelpParts, "^P", "^D") - - renderHelpRow := func(candidates ...string) string { - helpText := fitHelpText(viewWidth, candidates...) - prefix := "" - switch { - case strings.HasPrefix(helpText, "mode: "): - prefix = "mode: " - case strings.HasPrefix(helpText, "mode:"): - prefix = "mode:" - default: - return m.styles.helpText.Render(helpText) - } - - labelStart := len(prefix) - labelEnd := len(helpText) - if idx := strings.IndexAny(helpText[labelStart:], " |│"); idx >= 0 { - labelEnd = labelStart + idx - } - if labelStart == labelEnd { - return m.styles.helpText.Render(helpText) - } - - rendered := m.styles.helpText.Render(helpText[:labelStart]) + modeBadgeStyle.Render(helpText[labelStart:labelEnd]) - if labelEnd < len(helpText) { - rendered += m.styles.helpText.Render(helpText[labelEnd:]) - } - return rendered - } - - helpRow := renderHelpRow( - strings.Join(longHelpParts, " | "), - strings.Join(shortHelpParts, " │ "), - strings.Join(compactHelpParts, " "), - ) - separator := m.styles.separator.Render(strings.Repeat("─", max(viewWidth, 1))) - composerHeight := lipgloss.Height(composerView) - statusBarHeight := 0 - if statusBar != "" { - statusBarHeight = lipgloss.Height(statusBar) - } - errorBannerHeight := 0 - if errorBanner != "" { - errorBannerHeight = lipgloss.Height(errorBanner) - } - nonViewportHeight := 1 + 1 + statusBarHeight + errorBannerHeight + composerHeight + 1 - availableViewportHeight := max(0, m.height-nonViewportHeight) - - viewportView := m.viewport.View() - if m.loading && len(m.blocks) == 0 { - viewportWidth := max(max(m.viewport.Width, viewWidth), 1) - viewportView = lipgloss.Place( - viewportWidth, - max(availableViewportHeight, 1), - lipgloss.Center, - lipgloss.Center, - m.styles.dimmedText.Render("Loading chat..."), - ) - } - viewportView = clampLines(viewportView, availableViewportHeight) - - sections := []string{header} - sections = append(sections, separator, viewportView) - if statusBar != "" { - sections = append(sections, statusBar) - } - if errorBanner != "" { - sections = append(sections, errorBanner) - } - sections = append(sections, composerView, helpRow) - - return strings.Join(sections, "\n") -} diff --git a/cli/agents_cmds.go b/cli/agents_cmds.go deleted file mode 100644 index eae87c8960..0000000000 --- a/cli/agents_cmds.go +++ /dev/null @@ -1,180 +0,0 @@ -package cli - -import ( - "context" - "io" - "slices" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/google/uuid" - - "github.com/coder/coder/v2/codersdk" -) - -type ( - chatsListedMsg struct { - chats []codersdk.Chat - err error - } - chatOpenedMsg struct { - generation uint64 - chatID uuid.UUID - chat codersdk.Chat - err error - } - chatHistoryMsg struct { - generation uint64 - chatID uuid.UUID - messages []codersdk.ChatMessage - err error - } - chatCreatedMsg struct { - generation uint64 - chatID uuid.UUID - chat codersdk.Chat - err error - } - chatPlanModeUpdatedMsg struct { - generation uint64 - chatID uuid.UUID - err error - } - messageSentMsg struct { - generation uint64 - chatID uuid.UUID - resp codersdk.CreateChatMessageResponse - err error - } - chatInterruptedMsg struct { - generation uint64 - chatID uuid.UUID - chat codersdk.Chat - err error - } - modelsListedMsg struct { - catalog codersdk.ChatModelsResponse - err error - } - diffContentsMsg struct { - generation uint64 - chatID uuid.UUID - diff codersdk.ChatDiffContents - err error - } - chatStreamEventMsg struct { - generation uint64 - chatID uuid.UUID - event codersdk.ChatStreamEvent - err error - } - // showAskUserQuestionMsg tells the parent model to open the - // ask-user-question overlay. - showAskUserQuestionMsg struct { - state *askUserQuestionState - } - // hideAskUserQuestionMsg tells the parent model to close the - // ask-user-question overlay. - hideAskUserQuestionMsg struct{} - // toolResultsSubmittedMsg is sent after the async SubmitToolResults - // call completes. - toolResultsSubmittedMsg struct { - generation uint64 - chatID uuid.UUID - err error - } - streamRetryMsg struct { - generation uint64 - } - toggleModelPickerMsg struct{} - toggleDiffDrawerMsg struct{} -) - -func scheduleStreamRetry(generation uint64, delay time.Duration) tea.Cmd { - return tea.Tick(delay, func(time.Time) tea.Msg { - return streamRetryMsg{generation: generation} - }) -} - -func apiCmd[T any](fn func() (T, error), wrap func(T, error) tea.Msg) tea.Cmd { - return func() tea.Msg { - value, err := fn() - return wrap(value, err) - } -} - -func loadChatHistoryCmd(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID, generation uint64) tea.Cmd { - return apiCmd(func() ([]codersdk.ChatMessage, error) { - var ( - allMessages []codersdk.ChatMessage - opts *codersdk.ChatMessagesPaginationOptions - ) - - for { - resp, err := client.GetChatMessages(ctx, chatID, opts) - if err != nil { - return nil, err - } - - allMessages = append(allMessages, resp.Messages...) - if !resp.HasMore || len(resp.Messages) == 0 { - break - } - - opts = &codersdk.ChatMessagesPaginationOptions{ - BeforeID: resp.Messages[len(resp.Messages)-1].ID, - } - } - - slices.SortStableFunc(allMessages, func(a, b codersdk.ChatMessage) int { - switch { - case a.CreatedAt.Before(b.CreatedAt): - return -1 - case a.CreatedAt.After(b.CreatedAt): - return 1 - case a.ID < b.ID: - return -1 - case a.ID > b.ID: - return 1 - default: - return 0 - } - }) - - return allMessages, nil - }, func(messages []codersdk.ChatMessage, err error) tea.Msg { - return chatHistoryMsg{generation: generation, chatID: chatID, messages: messages, err: err} - }) -} - -func submitAskUserQuestionCmd(client *codersdk.Client, chatID uuid.UUID, generation uint64, state *askUserQuestionState) tea.Cmd { - output, err := buildAskUserQuestionToolResult(state) - if err != nil { - return func() tea.Msg { - return toolResultsSubmittedMsg{generation: generation, chatID: chatID, err: err} - } - } - - req := codersdk.SubmitToolResultsRequest{ - Results: []codersdk.ToolResult{{ - ToolCallID: state.ToolCallID, - Output: output, - IsError: false, - }}, - } - return apiCmd(func() (struct{}, error) { - return struct{}{}, codersdk.NewExperimentalClient(client).SubmitToolResults(context.Background(), chatID, req) - }, func(_ struct{}, err error) tea.Msg { - return toolResultsSubmittedMsg{generation: generation, chatID: chatID, err: err} - }) -} - -func listenToStream(chatID uuid.UUID, generation uint64, eventCh <-chan codersdk.ChatStreamEvent) tea.Cmd { - return func() tea.Msg { - event, ok := <-eventCh - if !ok { - return chatStreamEventMsg{generation: generation, chatID: chatID, err: io.EOF} - } - return chatStreamEventMsg{generation: generation, chatID: chatID, event: event} - } -} diff --git a/cli/agents_diff.go b/cli/agents_diff.go deleted file mode 100644 index 2ff5e8c96d..0000000000 --- a/cli/agents_diff.go +++ /dev/null @@ -1,332 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "slices" - "strings" - "time" - - "github.com/google/uuid" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/codersdk" - "github.com/coder/websocket" -) - -const localChatDiffWatchTimeout = 5 * time.Second - -// localChatDiffReadLimit bounds the size of the Changes message the -// client is willing to receive from the chat git watcher. agentgit -// caps each repository's UnifiedDiff at ~3 MiB (maxTotalDiffSize), -// and a Changes payload can aggregate many repos plus metadata, so -// 4 MiB is too tight for realistic multi-repo worktrees. 32 MiB -// covers ~10 maxed-out repos; pathological payloads beyond that still -// fall back to the remote empty diff via errLocalDiffWatchClosed / -// shouldIgnoreLocalDiffFallbackError. -const localChatDiffReadLimit = 32 << 20 // 32 MiB - -// errLocalDiffWatchClosed is returned when the chat git watcher -// websocket closes during the Changes read loop with one of the -// known-safe close statuses: -// -// - StatusMessageTooBig: the Changes payload exceeded our local -// 32 MiB client read limit (localChatDiffReadLimit). -// - StatusGoingAway: the coderd watchChatGit proxy tore the -// client stream down. This is the status the proxy always uses -// in coderd/exp_chats.go, so it also covers the upstream 4 MiB -// read limit on agent->coderd messages (see -// workspacesdk/agentconn.go): when that limit is exceeded the -// agent closes with StatusMessageTooBig, but the proxy does not -// propagate that status and the client only ever observes -// StatusGoingAway. -// -// Both cases degrade to the remote empty diff returned by /diff: -// the local watcher is a supplementary enrichment source that -// cannot improve on the remote when its stream is cut short. Other -// close statuses (StatusInternalError, StatusProtocolError, ...) -// and non-close read errors still surface as hard errors so real -// protocol regressions are not hidden behind the fallback. -var errLocalDiffWatchClosed = xerrors.New("chat git watcher connection closed before delivering a Changes message") - -func fetchChatDiffContents( - ctx context.Context, - client *codersdk.ExperimentalClient, - chatID uuid.UUID, -) (codersdk.ChatDiffContents, error) { - remoteDiff, err := client.GetChatDiffContents(ctx, chatID) - if err != nil { - return codersdk.ChatDiffContents{}, err - } - if strings.TrimSpace(remoteDiff.Diff) != "" { - return remoteDiff, nil - } - - localDiff, localSingleRepo, err := fetchLocalChatDiffContents(ctx, client, chatID) - if err != nil { - if shouldIgnoreLocalDiffFallbackError(err) { - return remoteDiff, nil - } - return codersdk.ChatDiffContents{}, err - } - if strings.TrimSpace(localDiff.Diff) == "" { - return remoteDiff, nil - } - - // Backfill metadata from the remote diff only when the local - // watcher produced a single contributing repository. Gate this on - // the explicit single-repo signal from buildLocalChatDiffContents - // rather than on Branch/RemoteOrigin being non-nil, because a - // single contributing repo can legitimately have an empty branch - // (detached HEAD) or no origin remote and we still want remote - // fields like Provider/PullRequestURL to flow through. Multi-repo - // aggregates cannot be described by a single remote's metadata, so - // we leave them alone. - if localSingleRepo { - if localDiff.Provider == nil { - localDiff.Provider = remoteDiff.Provider - } - if localDiff.RemoteOrigin == nil { - localDiff.RemoteOrigin = remoteDiff.RemoteOrigin - } - if localDiff.Branch == nil { - localDiff.Branch = remoteDiff.Branch - } - if localDiff.PullRequestURL == nil { - localDiff.PullRequestURL = remoteDiff.PullRequestURL - } - } - return localDiff, nil -} - -// fetchLocalChatDiffContents returns the aggregated local-watcher diff -// and a singleRepo flag that indicates whether that aggregate came from -// exactly one contributing repository. The caller uses singleRepo to -// decide whether it is safe to backfill remote-only metadata onto the -// local diff. All error paths return singleRepo=false. -// -// This intentionally bypasses wsjson.NewStream and reads the websocket -// directly so we can inspect the close status: an oversized Changes -// payload must degrade to the remote empty diff via -// errLocalDiffWatchClosed + shouldIgnoreLocalDiffFallbackError, -// but wsjson.Decoder swallows the read error (logs at debug) and -// closes the channel, which would collapse that specific case into -// the same generic "connection closed" bucket as server crashes or -// decode failures. Reading directly lets us narrowly fall back only -// for read-limit violations while still surfacing real protocol -// regressions. -func fetchLocalChatDiffContents( - parentCtx context.Context, - client *codersdk.ExperimentalClient, - chatID uuid.UUID, -) (codersdk.ChatDiffContents, bool, error) { - ctx, cancel := context.WithTimeout(parentCtx, localChatDiffWatchTimeout) - defer cancel() - - conn, err := dialChatGit(ctx, client, chatID) - if err != nil { - return codersdk.ChatDiffContents{}, false, err - } - defer func() { - _ = conn.Close(websocket.StatusNormalClosure, "") - }() - conn.SetReadLimit(localChatDiffReadLimit) - - refreshPayload, err := json.Marshal(codersdk.WorkspaceAgentGitClientMessage{ - Type: codersdk.WorkspaceAgentGitClientMessageTypeRefresh, - }) - if err != nil { - return codersdk.ChatDiffContents{}, false, xerrors.Errorf("marshal git refresh: %w", err) - } - if err := conn.Write(ctx, websocket.MessageText, refreshPayload); err != nil { - return codersdk.ChatDiffContents{}, false, xerrors.Errorf("request git refresh: %w", err) - } - - for { - msgType, payload, err := conn.Read(ctx) - if err != nil { - // Context expiration gets its own wrapping so it threads - // cleanly through shouldIgnoreLocalDiffFallbackError's - // context.DeadlineExceeded case. - if ctxErr := ctx.Err(); ctxErr != nil { - return codersdk.ChatDiffContents{}, false, xerrors.Errorf("watch chat git: %w", ctxErr) - } - // A Changes payload that exceeds localChatDiffReadLimit - // causes coder/websocket to close the connection with - // StatusMessageTooBig. The coderd watchChatGit proxy - // also always closes the client with StatusGoingAway - // (see coderd/exp_chats.go), which is how we observe - // the upstream 4 MiB agent->coderd read-limit breach: - // the agent closes its own hop with StatusMessageTooBig, - // but the proxy does not propagate that status, so the - // client only ever sees StatusGoingAway. Map both onto - // the narrow sentinel so shouldIgnoreLocalDiffFallbackError - // can degrade to the remote empty diff instead of - // surfacing a hard error. Every other close status - // (StatusInternalError, StatusProtocolError, ...) and - // every non-close read error still propagates so real - // protocol regressions reach the user. - switch websocket.CloseStatus(err) { - case websocket.StatusMessageTooBig, websocket.StatusGoingAway: - return codersdk.ChatDiffContents{}, false, errLocalDiffWatchClosed - } - return codersdk.ChatDiffContents{}, false, xerrors.Errorf("read git watch: %w", err) - } - // Ignore unexpected frame types instead of erroring; the - // watcher only emits text frames today and a future binary - // heartbeat should not break the overlay. - if msgType != websocket.MessageText { - continue - } - var msg codersdk.WorkspaceAgentGitServerMessage - if err := json.Unmarshal(payload, &msg); err != nil { - return codersdk.ChatDiffContents{}, false, xerrors.Errorf("decode git watch message: %w", err) - } - switch msg.Type { - case codersdk.WorkspaceAgentGitServerMessageTypeError: - message := strings.TrimSpace(msg.Message) - if message == "" { - message = "git watch returned an unknown error" - } - return codersdk.ChatDiffContents{}, false, xerrors.New(message) - case codersdk.WorkspaceAgentGitServerMessageTypeChanges: - diff, singleRepo := buildLocalChatDiffContents(chatID, msg.Repositories) - return diff, singleRepo, nil - } - } -} - -// dialChatGit opens the chat git-watcher WebSocket. We dial the socket -// manually instead of using codersdk.Client.Dial because that helper -// closes the HTTP response body before surfacing the error, which -// prevents codersdk.ReadBodyAsError from extracting the status code and -// message that shouldIgnoreLocalDiffFallbackError needs to decide -// whether to degrade to the empty remote diff. Keep this handrolled -// path as long as the shared helper has that limitation. -func dialChatGit( - ctx context.Context, - client *codersdk.ExperimentalClient, - chatID uuid.UUID, -) (*websocket.Conn, error) { - requestURL, err := client.URL.Parse( - fmt.Sprintf("/api/experimental/chats/%s/stream/git", chatID), - ) - if err != nil { - return nil, err - } - - dialOptions := &websocket.DialOptions{ - HTTPClient: client.HTTPClient, - CompressionMode: websocket.CompressionDisabled, - } - client.SessionTokenProvider.SetDialOption(dialOptions) - - conn, resp, err := websocket.Dial(ctx, requestURL.String(), dialOptions) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - if resp != nil { - return nil, codersdk.ReadBodyAsError(resp) - } - return nil, err - } - return conn, nil -} - -// buildLocalChatDiffContents aggregates the local watcher's -// per-repository changes into a single ChatDiffContents. The returned -// singleRepo flag is true iff the aggregated diff came from exactly -// one contributing repository (one repo with a non-empty UnifiedDiff -// that has not been removed). Callers use this flag to decide whether -// it is safe to backfill remote-only metadata onto the local diff: -// multi-repo aggregates cannot be described by a single remote's -// branch/origin/PR URL, but a single-repo aggregate can even when the -// contributing repo has an empty branch (detached HEAD) or no origin -// remote configured. -func buildLocalChatDiffContents( - chatID uuid.UUID, - repositories []codersdk.WorkspaceAgentRepoChanges, -) (codersdk.ChatDiffContents, bool) { - result := codersdk.ChatDiffContents{ChatID: chatID} - if len(repositories) == 0 { - return result, false - } - - repositories = slices.Clone(repositories) - slices.SortFunc(repositories, func(a, b codersdk.WorkspaceAgentRepoChanges) int { - return strings.Compare(a.RepoRoot, b.RepoRoot) - }) - - diffSegments := make([]string, 0, len(repositories)) - diffRepositories := make([]codersdk.WorkspaceAgentRepoChanges, 0, len(repositories)) - for _, repo := range repositories { - if repo.Removed || strings.TrimSpace(repo.UnifiedDiff) == "" { - continue - } - diffRepositories = append(diffRepositories, repo) - diffSegments = append(diffSegments, strings.TrimRight(repo.UnifiedDiff, "\n")) - } - if len(diffSegments) == 0 { - return result, false - } - - result.Diff = strings.Join(diffSegments, "\n") - singleRepo := len(diffRepositories) == 1 - if singleRepo { - if branch := strings.TrimSpace(diffRepositories[0].Branch); branch != "" { - result.Branch = &branch - } - if origin := strings.TrimSpace(diffRepositories[0].RemoteOrigin); origin != "" { - result.RemoteOrigin = &origin - } - } - return result, singleRepo -} - -func shouldIgnoreLocalDiffFallbackError(err error) bool { - if errors.Is(err, context.DeadlineExceeded) { - return true - } - // A watcher stream closed with StatusMessageTooBig or - // StatusGoingAway is a best-effort degradation point: the - // remote /diff endpoint already returns the empty placeholder - // in this case, so fall back to it instead of surfacing a hard - // error. See errLocalDiffWatchClosed for the rationale on why - // those two close statuses are safe while others still surface. - if errors.Is(err, errLocalDiffWatchClosed) { - return true - } - - sdkErr, ok := codersdk.AsError(err) - if !ok { - return false - } - - switch sdkErr.StatusCode() { - case http.StatusNotFound: - return true - case http.StatusForbidden: - // authorizeChatWorkspaceExec returns 403 when the chat owner's - // workspace permissions have been revoked. The remote diff - // endpoint (getChatDiffContents) does not re-check workspace - // permissions, so degrade to its empty response the same way - // we do for the 400 variants below. - return true - case http.StatusBadRequest: - // These correspond to the 400 responses from watchChatGit in - // coderd/exp_chats.go when the chat cannot be observed through - // a workspace agent (no workspace bound, workspace deleted, no - // agents, or an agent that is not yet connected). Each should - // fall back to the empty remote diff the same way a missing - // chat (404) does instead of surfacing a hard error. - // codersdk.IsChatGitWatchFallbackMessage keeps this list - // mechanically linked to the server-side messages. - return codersdk.IsChatGitWatchFallbackMessage(sdkErr.Message) - default: - return false - } -} diff --git a/cli/agents_diff_test.go b/cli/agents_diff_test.go deleted file mode 100644 index 3ff1f3a4fb..0000000000 --- a/cli/agents_diff_test.go +++ /dev/null @@ -1,743 +0,0 @@ -package cli //nolint:testpackage // Tests unexported local diff fallback helpers. - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/testutil" - "github.com/coder/websocket" -) - -func TestFetchChatDiffContents(t *testing.T) { - t.Parallel() - - t.Run("FallsBackToLocalGitWatcher", func(t *testing.T) { - t.Parallel() - - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - defer conn.Close(websocket.StatusNormalClosure, "") - - _, payload, err := conn.Read(ctx) - require.NoError(t, err) - var refresh codersdk.WorkspaceAgentGitClientMessage - require.NoError(t, json.Unmarshal(payload, &refresh)) - require.Equal(t, codersdk.WorkspaceAgentGitClientMessageTypeRefresh, refresh.Type) - - writer, err := conn.Writer(ctx, websocket.MessageText) - require.NoError(t, err) - require.NoError(t, json.NewEncoder(writer).Encode(codersdk.WorkspaceAgentGitServerMessage{ - Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges, - Repositories: []codersdk.WorkspaceAgentRepoChanges{{ - RepoRoot: "/workspace/repo", - Branch: "feature/local-diff", - RemoteOrigin: "https://github.com/coder/coder.git", - UnifiedDiff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", - }}, - })) - require.NoError(t, writer.Close()) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.NotNil(t, diff.Branch) - require.Equal(t, "feature/local-diff", *diff.Branch) - require.NotNil(t, diff.RemoteOrigin) - require.Equal(t, "https://github.com/coder/coder.git", *diff.RemoteOrigin) - require.Contains(t, diff.Diff, "diff --git a/a.txt b/a.txt") - require.Contains(t, diff.Diff, "+new") - }) - - t.Run("IgnoresTimedOutWatcherFallbackErrors", func(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(t.Context(), testutil.IntervalMedium) - defer cancel() - - handlerDone := make(chan struct{}) - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - defer close(handlerDone) - - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - defer conn.Close(websocket.StatusNormalClosure, "") - - _, payload, err := conn.Read(r.Context()) - require.NoError(t, err) - var refresh codersdk.WorkspaceAgentGitClientMessage - require.NoError(t, json.Unmarshal(payload, &refresh)) - require.Equal(t, codersdk.WorkspaceAgentGitClientMessageTypeRefresh, refresh.Type) - - // Keep the WebSocket open until the client disconnects - // (either from fetchChatDiffContents hitting its watch - // timeout or test cleanup closing the connection) - // instead of sleeping for a fixed duration. The second - // Read blocks on the socket and unblocks with an error - // when the peer closes the connection, so this handler - // drains cleanly without time.Sleep (see WORKFLOWS.md). - _, _, _ = conn.Read(r.Context()) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - require.Eventually(t, func() bool { - select { - case <-handlerDone: - return true - default: - return false - } - }, testutil.WaitShort, testutil.IntervalFast) - }) - - t.Run("IgnoresMissingWorkspaceFallbackErrors", func(t *testing.T) { - t.Parallel() - - // Each message here matches a 400 response that watchChatGit can - // return when the chat cannot be observed through the workspace - // agent. fetchChatDiffContents should swallow the error and fall - // back to the empty remote diff instead of surfacing a hard - // error in the TUI. Drive the subtests from the shared codersdk - // constants so a server-side rewording automatically flows - // through the test matrix. - for _, message := range []string{ - codersdk.ChatGitWatchNoWorkspaceMessage, - codersdk.ChatGitWatchWorkspaceNotFoundMessage, - codersdk.ChatGitWatchWorkspaceNoAgentsMessage, - codersdk.ChatGitWatchAgentStateMessage(codersdk.WorkspaceAgentConnecting), - } { - t.Run(message, func(t *testing.T) { - t.Parallel() - - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusBadRequest) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.Response{Message: message})) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - }) - } - }) - - t.Run("IgnoresForbiddenWatcherFallbackErrors", func(t *testing.T) { - t.Parallel() - - // authorizeChatWorkspaceExec in coderd/exp_chats.go returns 403 - // when the chat owner's workspace exec permission is revoked. - // The remote /diff endpoint does not re-check workspace - // permissions, so fetchChatDiffContents must swallow the 403 - // and fall back to the empty remote diff just like it does for - // the 400 variants above. Without this subtest, removing the - // `case http.StatusForbidden` branch in - // shouldIgnoreLocalDiffFallbackError would silently regress. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusForbidden) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.Response{Message: "forbidden"})) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - }) - - t.Run("IgnoresNotFoundWatcherFallbackErrors", func(t *testing.T) { - t.Parallel() - - // watchChatGit in coderd/exp_chats.go returns 404 for missing - // chats (httpapi.ResourceNotFound). The remote /diff endpoint - // already handles the missing-chat case on its own, so - // fetchChatDiffContents must swallow the 404 from /stream/git - // and fall back to whatever the remote diff returned, the - // same way it does for the 400 and 403 variants above. - // Without this subtest, removing the `case http.StatusNotFound` - // branch in shouldIgnoreLocalDiffFallbackError would silently - // regress (mirrors the 403 coverage added for DEREM-16). - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusNotFound) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.Response{Message: "not found"})) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - }) - - t.Run("BackfillsRemoteMetadataWhenLocalDiffIsSingleRepo", func(t *testing.T) { - t.Parallel() - - // The scenario this PR was written for: a chat has remote - // metadata (provider, pull-request URL, etc.) but the server - // returns an empty Diff because the remote watcher has not - // observed changes yet. The CLI fetches the local watcher - // diff and must carry the remote metadata forward so the - // Diff overlay still shows the PR URL / origin. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - remoteBranch := "feature/remote-branch" - remoteOrigin := "https://github.com/coder/coder.git" - remotePR := "https://github.com/coder/coder/pull/42" - remoteProvider := "github" - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ - ChatID: chatID, - Provider: &remoteProvider, - RemoteOrigin: &remoteOrigin, - Branch: &remoteBranch, - PullRequestURL: &remotePR, - })) - case path + "/stream/git": - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - defer conn.Close(websocket.StatusNormalClosure, "") - - _, payload, err := conn.Read(ctx) - require.NoError(t, err) - var refresh codersdk.WorkspaceAgentGitClientMessage - require.NoError(t, json.Unmarshal(payload, &refresh)) - require.Equal(t, codersdk.WorkspaceAgentGitClientMessageTypeRefresh, refresh.Type) - - writer, err := conn.Writer(ctx, websocket.MessageText) - require.NoError(t, err) - // Return exactly one repo so buildLocalChatDiffContents - // sets Branch/RemoteOrigin, which is the signal that - // fetchChatDiffContents uses to backfill missing - // metadata from the remote response (Provider, PR URL) - // without overwriting fields the local watcher - // already populated. - require.NoError(t, json.NewEncoder(writer).Encode(codersdk.WorkspaceAgentGitServerMessage{ - Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges, - Repositories: []codersdk.WorkspaceAgentRepoChanges{{ - RepoRoot: "/workspace/repo", - Branch: "feature/local-branch", - RemoteOrigin: "https://github.com/coder/local.git", - UnifiedDiff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", - }}, - })) - require.NoError(t, writer.Close()) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - - // The aggregated diff comes from the local watcher. - require.Contains(t, diff.Diff, "diff --git a/a.txt b/a.txt") - require.Contains(t, diff.Diff, "+new") - - // Branch and RemoteOrigin were populated by the single-repo - // local watcher result, so they must NOT be overwritten by - // the remote response. - require.NotNil(t, diff.Branch) - require.Equal(t, "feature/local-branch", *diff.Branch) - require.NotNil(t, diff.RemoteOrigin) - require.Equal(t, "https://github.com/coder/local.git", *diff.RemoteOrigin) - - // Provider and PullRequestURL were nil on the local diff, - // so they must be backfilled from the remote metadata. - require.NotNil(t, diff.Provider) - require.Equal(t, remoteProvider, *diff.Provider) - require.NotNil(t, diff.PullRequestURL) - require.Equal(t, remotePR, *diff.PullRequestURL) - }) - - t.Run("BackfillsRemoteMetadataWhenSingleRepoHasBlankBranchAndOrigin", func(t *testing.T) { - t.Parallel() - - // A single contributing repo can legitimately be in detached - // HEAD with no origin remote configured: buildLocalChatDiffContents - // then leaves both Branch and RemoteOrigin nil even though - // exactly one repository produced the aggregated diff. Before - // the singleRepo flag was introduced, the gate on - // `localDiff.Branch != nil || localDiff.RemoteOrigin != nil` - // skipped the backfill in this case and the drawer silently - // lost remote Provider/PullRequestURL. fetchChatDiffContents - // must now use the explicit singleRepo signal so remote - // metadata still flows through, and must also populate the - // nil Branch/RemoteOrigin from the remote response to keep the - // drawer display consistent with all other single-repo diffs. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - remoteBranch := "feature/remote-branch" - remoteOrigin := "https://github.com/coder/coder.git" - remotePR := "https://github.com/coder/coder/pull/42" - remoteProvider := "github" - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ - ChatID: chatID, - Provider: &remoteProvider, - RemoteOrigin: &remoteOrigin, - Branch: &remoteBranch, - PullRequestURL: &remotePR, - })) - case path + "/stream/git": - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - defer conn.Close(websocket.StatusNormalClosure, "") - - _, payload, err := conn.Read(ctx) - require.NoError(t, err) - var refresh codersdk.WorkspaceAgentGitClientMessage - require.NoError(t, json.Unmarshal(payload, &refresh)) - require.Equal(t, codersdk.WorkspaceAgentGitClientMessageTypeRefresh, refresh.Type) - - writer, err := conn.Writer(ctx, websocket.MessageText) - require.NoError(t, err) - // Exactly one repository contributes, but both - // Branch and RemoteOrigin are empty (detached HEAD, - // no origin remote). buildLocalChatDiffContents - // still flags this as singleRepo=true, so the - // backfill must run and populate every nil field - // from the remote response. - require.NoError(t, json.NewEncoder(writer).Encode(codersdk.WorkspaceAgentGitServerMessage{ - Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges, - Repositories: []codersdk.WorkspaceAgentRepoChanges{{ - RepoRoot: "/workspace/repo", - Branch: "", - RemoteOrigin: "", - UnifiedDiff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", - }}, - })) - require.NoError(t, writer.Close()) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - - // The aggregated diff still comes from the local watcher. - require.Contains(t, diff.Diff, "diff --git a/a.txt b/a.txt") - require.Contains(t, diff.Diff, "+new") - - // Every remote-only field is backfilled because - // buildLocalChatDiffContents flagged the aggregate as - // singleRepo=true even with blank branch/origin. - require.NotNil(t, diff.Branch) - require.Equal(t, remoteBranch, *diff.Branch) - require.NotNil(t, diff.RemoteOrigin) - require.Equal(t, remoteOrigin, *diff.RemoteOrigin) - require.NotNil(t, diff.Provider) - require.Equal(t, remoteProvider, *diff.Provider) - require.NotNil(t, diff.PullRequestURL) - require.Equal(t, remotePR, *diff.PullRequestURL) - }) - - t.Run("IgnoresWatcherMessageTooBigCloses", func(t *testing.T) { - t.Parallel() - - // agentgit caps each repository's UnifiedDiff at ~3 MiB and a - // Changes payload aggregates every repo plus metadata, so a - // realistic multi-repo workspace can legitimately produce a - // payload that exceeds the client's websocket read limit. - // When that happens coder/websocket closes the connection - // with StatusMessageTooBig. fetchChatDiffContents must map - // that specific close status onto errLocalDiffWatchClosed - // and fall back to the remote empty diff rather than - // surfacing a hard error to the TUI. Without this subtest, - // removing the StatusMessageTooBig branch in - // fetchLocalChatDiffContents or the errLocalDiffWatchClosed - // branch in shouldIgnoreLocalDiffFallbackError would - // silently regress the large-multi-repo case this feature is - // meant to improve. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - // Drain the refresh before closing so the client - // surfaces the close status from its next Read, not - // an unrelated write error. - _, _, err = conn.Read(ctx) - require.NoError(t, err) - require.NoError(t, conn.Close(websocket.StatusMessageTooBig, "too big")) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - }) - - t.Run("IgnoresWatcherGoingAwayCloses", func(t *testing.T) { - t.Parallel() - - // The coderd watchChatGit proxy always closes the client - // stream with StatusGoingAway regardless of why the - // upstream agent->coderd hop failed. In particular, when - // that hop's 4 MiB read limit (workspacesdk/agentconn.go) - // is exceeded, the agent closes its end with - // StatusMessageTooBig but the proxy does not propagate - // that status, so the client only observes - // StatusGoingAway. That is the exact scenario this PR's - // 32 MiB client read limit is meant to handle, so the - // TUI must degrade to the remote empty diff for - // StatusGoingAway just like it does for - // StatusMessageTooBig. Without this subtest, narrowing - // the close-status match back to StatusMessageTooBig - // only would silently regress multi-repo worktrees whose - // aggregate Changes payload sits between the 4 MiB - // upstream limit and the 32 MiB client limit. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - _, _, err = conn.Read(ctx) - require.NoError(t, err) - require.NoError(t, conn.Close(websocket.StatusGoingAway, "proxy tear-down")) - default: - http.NotFound(rw, r) - } - })) - - diff, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - }) - - t.Run("SurfacesUnexpectedWatcherCloseErrors", func(t *testing.T) { - t.Parallel() - - // The StatusMessageTooBig fallback is intentionally narrow: - // a generic websocket close (for example the server - // crashing and closing with StatusInternalError) should - // surface as an error rather than silently degrading, - // because that would hide real protocol regressions behind - // the best-effort fallback. This subtest pins that - // distinction so a future attempt to blanket-ignore every - // close reason immediately breaks the test. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - conn, err := websocket.Accept(rw, r, nil) - require.NoError(t, err) - _, _, err = conn.Read(ctx) - require.NoError(t, err) - require.NoError(t, conn.Close(websocket.StatusInternalError, "boom")) - default: - http.NotFound(rw, r) - } - })) - - _, err := fetchChatDiffContents(ctx, client, chatID) - require.Error(t, err) - }) - - t.Run("ReturnsRemoteDiffWithoutDialingWatcher", func(t *testing.T) { - t.Parallel() - - // When the remote /diff endpoint returns a non-empty diff the - // CLI short-circuits the WebSocket fallback. If the git stream - // handler ever fires, the test fails the request explicitly so - // an inverted condition regresses loudly. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - branch := "feature/remote" - prURL := "https://example.com/pr/1" - remoteDiff := codersdk.ChatDiffContents{ - ChatID: chatID, - Branch: &branch, - PullRequestURL: &prURL, - Diff: "diff --git a/remote.txt b/remote.txt\n--- a/remote.txt\n+++ b/remote.txt\n@@ -1 +1 @@\n-old\n+new\n", - } - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(remoteDiff)) - case path + "/stream/git": - t.Errorf("local git watcher should not be dialed when the remote diff is non-empty") - rw.WriteHeader(http.StatusInternalServerError) - default: - http.NotFound(rw, r) - } - })) - - got, err := fetchChatDiffContents(ctx, client, chatID) - require.NoError(t, err) - require.Equal(t, chatID, got.ChatID) - require.Equal(t, remoteDiff.Diff, got.Diff) - require.NotNil(t, got.Branch) - require.Equal(t, branch, *got.Branch) - require.NotNil(t, got.PullRequestURL) - require.Equal(t, prURL, *got.PullRequestURL) - }) - - t.Run("PropagatesRemoteDiffAPIErrors", func(t *testing.T) { - t.Parallel() - - // A 500 from /diff is a hard failure that the CLI must surface - // rather than silently fall back. The local watcher must not - // be dialed when the remote endpoint returned an error. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusInternalServerError) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.Response{Message: "boom"})) - case path + "/stream/git": - t.Errorf("local git watcher should not be dialed when /diff errors") - rw.WriteHeader(http.StatusInternalServerError) - default: - http.NotFound(rw, r) - } - })) - - _, err := fetchChatDiffContents(ctx, client, chatID) - require.Error(t, err) - sdkErr, ok := codersdk.AsError(err) - require.True(t, ok) - require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode()) - }) - - t.Run("SurfacesNonIgnorableWatcherErrors", func(t *testing.T) { - t.Parallel() - - // A 500 from the git stream is not in the ignorable set, so - // fetchChatDiffContents must return it verbatim instead of - // silently collapsing to the empty remote diff. - ctx := t.Context() - chatID := uuid.New() - path := fmt.Sprintf("/api/experimental/chats/%s", chatID) - client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case path + "/diff": - rw.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID})) - case path + "/stream/git": - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusInternalServerError) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.Response{Message: "internal git watcher failure"})) - default: - http.NotFound(rw, r) - } - })) - - _, err := fetchChatDiffContents(ctx, client, chatID) - require.Error(t, err) - sdkErr, ok := codersdk.AsError(err) - require.True(t, ok) - require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode()) - }) -} - -func TestBuildLocalChatDiffContents(t *testing.T) { - t.Parallel() - - t.Run("SortsMultipleReposByRepoRoot", func(t *testing.T) { - t.Parallel() - - chatID := uuid.New() - diff, singleRepo := buildLocalChatDiffContents(chatID, []codersdk.WorkspaceAgentRepoChanges{ - { - RepoRoot: "/workspace/z-repo", - UnifiedDiff: "diff --git a/z.txt b/z.txt\n+z\n", - }, - { - RepoRoot: "/workspace/a-repo", - Branch: "feature/local", - RemoteOrigin: "https://github.com/coder/coder.git", - UnifiedDiff: "diff --git a/a.txt b/a.txt\n+a\n", - }, - }) - - // Multi-repo aggregation drops the per-repo metadata because - // Branch/RemoteOrigin only make sense for a single repo. The - // singleRepo flag must be false so callers know not to - // backfill remote metadata onto a multi-repo aggregate. - require.Equal(t, chatID, diff.ChatID) - require.Contains(t, diff.Diff, "diff --git a/a.txt b/a.txt") - require.Contains(t, diff.Diff, "diff --git a/z.txt b/z.txt") - require.Less(t, strings.Index(diff.Diff, "a.txt"), strings.Index(diff.Diff, "z.txt")) - require.Nil(t, diff.Branch) - require.Nil(t, diff.RemoteOrigin) - require.False(t, singleRepo) - }) - - t.Run("ReturnsEmptyForNoRepositories", func(t *testing.T) { - t.Parallel() - - chatID := uuid.New() - // No repos: exercise the early-return in buildLocalChatDiffContents - // so the empty case is mechanically covered. singleRepo must - // be false because no repository contributed any diff. - for _, repos := range [][]codersdk.WorkspaceAgentRepoChanges{nil, {}} { - diff, singleRepo := buildLocalChatDiffContents(chatID, repos) - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - require.Nil(t, diff.Branch) - require.Nil(t, diff.RemoteOrigin) - require.False(t, singleRepo) - } - }) - - t.Run("SkipsRemovedAndEmptyRepositories", func(t *testing.T) { - t.Parallel() - - chatID := uuid.New() - // Removed repos (Removed=true) and repos with whitespace-only - // UnifiedDiff must not contribute to the aggregated diff. With - // a single contributing repo, the per-repo Branch and - // RemoteOrigin should still propagate to the result and - // singleRepo must be true because only one repository - // contributed. - diff, singleRepo := buildLocalChatDiffContents(chatID, []codersdk.WorkspaceAgentRepoChanges{ - { - RepoRoot: "/workspace/removed", - Removed: true, - UnifiedDiff: "diff --git a/removed.txt b/removed.txt\n+removed\n", - }, - { - RepoRoot: "/workspace/empty", - UnifiedDiff: " \n", - }, - { - RepoRoot: "/workspace/only", - Branch: "feature/only", - RemoteOrigin: "https://github.com/coder/coder.git", - UnifiedDiff: "diff --git a/only.txt b/only.txt\n+only\n", - }, - }) - - require.Equal(t, chatID, diff.ChatID) - require.Contains(t, diff.Diff, "diff --git a/only.txt b/only.txt") - require.NotContains(t, diff.Diff, "removed.txt") - require.NotContains(t, diff.Diff, "empty") - require.NotNil(t, diff.Branch) - require.Equal(t, "feature/only", *diff.Branch) - require.NotNil(t, diff.RemoteOrigin) - require.Equal(t, "https://github.com/coder/coder.git", *diff.RemoteOrigin) - require.True(t, singleRepo) - }) - - t.Run("ReturnsEmptyWhenAllRepositoriesAreSkipped", func(t *testing.T) { - t.Parallel() - - chatID := uuid.New() - // If every repo is removed or empty, buildLocalChatDiffContents - // returns the empty remote-diff shape so the caller falls back - // to the placeholder overlay instead of rendering a diff-less - // summary. singleRepo must be false because no repository - // contributed any diff content. - diff, singleRepo := buildLocalChatDiffContents(chatID, []codersdk.WorkspaceAgentRepoChanges{ - {RepoRoot: "/workspace/removed", Removed: true, UnifiedDiff: "diff --git a/removed.txt b/removed.txt\n+removed\n"}, - {RepoRoot: "/workspace/empty"}, - }) - - require.Equal(t, chatID, diff.ChatID) - require.Empty(t, diff.Diff) - require.Nil(t, diff.Branch) - require.Nil(t, diff.RemoteOrigin) - require.False(t, singleRepo) - }) -} diff --git a/cli/agents_e2e_helpers_test.go b/cli/agents_e2e_helpers_test.go deleted file mode 100644 index 8dc41aa8b1..0000000000 --- a/cli/agents_e2e_helpers_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package cli_test - -import ( - "context" - "os" - "runtime" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/cli/clitest" - "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/pty/ptytest" - "github.com/coder/coder/v2/testutil" -) - -func agentsPtr[T any](v T) *T { - return &v -} - -func setupAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient, uuid.UUID) { - t.Helper() - - values := coderdtest.DeploymentValues(t) - - client := coderdtest.New(t, &coderdtest.Options{ - DeploymentValues: values, - }) - firstUser := coderdtest.CreateFirstUser(t, client) - - expClient := codersdk.NewExperimentalClient(client) - ctx := testutil.Context(t, testutil.WaitLong) - - _, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ - Provider: "openai", - APIKey: "test-api-key", - }) - require.NoError(t, err) - - _, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ - Provider: "openai", - Model: "gpt-4o-mini", - ContextLimit: agentsPtr(int64(4096)), - IsDefault: agentsPtr(true), - }) - require.NoError(t, err) - - return client, expClient, firstUser.OrganizationID -} - -//nolint:revive // Test helper signature keeps t first for consistency with other helpers. -func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.ExperimentalClient, orgID uuid.UUID, seed string) codersdk.Chat { - t.Helper() - - chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: orgID, - Content: []codersdk.ChatInputPart{ - { - Type: codersdk.ChatInputPartTypeText, - Text: seed, - }, - }, - }) - require.NoError(t, err) - return chat -} - -type agentsSession struct { - t *testing.T - pty *ptytest.PTY - errCh <-chan error -} - -func (s *agentsSession) expect(ctx context.Context, text string) { - s.t.Helper() - s.pty.ExpectMatchContext(ctx, text) -} - -func (s *agentsSession) wait(ctx context.Context) error { - s.t.Helper() - return testutil.RequireReceive(ctx, s.t, s.errCh) -} - -//nolint:unused // Kept as a small PTY helper for future multi-character input. -func (s *agentsSession) write(text string) { - s.t.Helper() - s.pty.WriteLine(text) -} - -func (s *agentsSession) writeRune(r rune) { - s.t.Helper() - _, err := s.pty.Input().Write([]byte(string(r))) - require.NoError(s.t, err) -} - -func (s *agentsSession) enter() { - s.t.Helper() - _, err := s.pty.Input().Write([]byte("\r")) - require.NoError(s.t, err) -} - -func (s *agentsSession) esc() { - s.t.Helper() - _, err := s.pty.Input().Write([]byte("\x1b")) - require.NoError(s.t, err) -} - -func (s *agentsSession) ctrlC() { - s.t.Helper() - _, err := s.pty.Input().Write([]byte{3}) - require.NoError(s.t, err) -} - -func (s *agentsSession) quit() { - s.t.Helper() - s.writeRune('q') -} - -//nolint:revive // Test helper signature keeps t first for consistency with other helpers. -func startAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *agentsSession { - t.Helper() - - // Reading to / writing from the PTY is flaky on non-linux systems. - if runtime.GOOS != "linux" { - t.Skip("skipping on non-linux") - } - - fullArgs := append([]string{"agents"}, args...) - inv, root := clitest.New(t, fullArgs...) - clitest.SetupConfig(t, client, root) - - pty := ptytest.New(t) - tty, err := os.OpenFile(pty.Name(), os.O_RDWR, 0) - require.NoError(t, err) - t.Cleanup(func() { - _ = tty.Close() - }) - - inv.Stdin = tty - inv.Stdout = tty - inv.Stderr = tty - - errCh := make(chan error, 1) - tGo(t, func() { - errCh <- inv.WithContext(ctx).Run() - }) - - return &agentsSession{t: t, pty: pty, errCh: errCh} -} diff --git a/cli/agents_e2e_test.go b/cli/agents_e2e_test.go deleted file mode 100644 index 1bffbe985b..0000000000 --- a/cli/agents_e2e_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package cli_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/testutil" -) - -func TestAgentsE2E(t *testing.T) { - t.Parallel() - - t.Run("EmptyStateBoot", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, _, _ := setupAgentsBackend(t) - session := startAgentsSession(t, ctx, client) - - session.expect(ctx, "No chats yet. Press n to start a new chat.") - session.quit() - require.NoError(t, session.wait(ctx)) - }) - - t.Run("ListAndNavigate", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, expClient, orgID := setupAgentsBackend(t) - - _ = seedChat(t, ctx, expClient, orgID, "alpha nav seed") - _ = seedChat(t, ctx, expClient, orgID, "bravo nav seed") - _ = seedChat(t, ctx, expClient, orgID, "charlie nav seed") - - session := startAgentsSession(t, ctx, client) - - session.expect(ctx, "charlie nav seed") - session.expect(ctx, "enter: open") - session.enter() - session.expect(ctx, "esc") - session.esc() - session.expect(ctx, "enter: open") - session.quit() - require.NoError(t, session.wait(ctx)) - }) - - t.Run("SearchFilter", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, expClient, orgID := setupAgentsBackend(t) - - _ = seedChat(t, ctx, expClient, orgID, "alpha filter seed") - _ = seedChat(t, ctx, expClient, orgID, "zulu filter seed") - - session := startAgentsSession(t, ctx, client) - - session.expect(ctx, "alpha filter seed") - session.expect(ctx, "enter: open") - session.writeRune('/') - session.expect(ctx, "/ ") - for _, r := range "zzzznotamatch" { - session.writeRune(r) - } - session.expect(ctx, "No matches.") - session.ctrlC() - require.NoError(t, session.wait(ctx)) - }) - - t.Run("ExistingChatHistory", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, expClient, orgID := setupAgentsBackend(t) - - chat := seedChat(t, ctx, expClient, orgID, "direct open seed") - session := startAgentsSession(t, ctx, client, chat.ID.String()) - - // The initial render contains both the chat title/content - // and the status bar in a single frame. Their relative - // order in the PTY byte stream depends on async title - // generation, so matching them with separate sequential - // expects is racy. Instead, just confirm the seed text is - // visible (proving we are in the chat view), then verify - // esc navigates back to the list. - session.expect(ctx, "direct open seed") - session.esc() - session.expect(ctx, "enter: open") - session.quit() - require.NoError(t, session.wait(ctx)) - }) -} diff --git a/cli/agents_helpers.go b/cli/agents_helpers.go deleted file mode 100644 index 0e0b892469..0000000000 --- a/cli/agents_helpers.go +++ /dev/null @@ -1,33 +0,0 @@ -package cli - -import ( - "regexp" - "strings" - "unicode" -) - -var terminalEscapeSequenceRegexp = regexp.MustCompile( - `\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|` + - "›" + `[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|` + - `\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|` + - "" + `[^\x07\x1b]*(?:\x07|\x1b\\)|` + - `\x1b[^\[\]].`, -) - -func sanitizeTerminalRenderableText(text string) string { - if text == "" { - return "" - } - - text = terminalEscapeSequenceRegexp.ReplaceAllString(text, "") - return strings.Map(func(r rune) rune { - switch r { - case '\n', '\t': - return r - } - if unicode.IsControl(r) { - return -1 - } - return r - }, text) -} diff --git a/cli/agents_list.go b/cli/agents_list.go deleted file mode 100644 index 1d9912365c..0000000000 --- a/cli/agents_list.go +++ /dev/null @@ -1,483 +0,0 @@ -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))) -} diff --git a/cli/agents_model.go b/cli/agents_model.go deleted file mode 100644 index cf04a33632..0000000000 --- a/cli/agents_model.go +++ /dev/null @@ -1,514 +0,0 @@ -package cli - -import ( - "context" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/google/uuid" - - "github.com/coder/coder/v2/codersdk" -) - -type tuiView int - -const ( - viewList tuiView = iota - viewChat -) - -type tuiOverlay int - -const ( - overlayNone tuiOverlay = iota - overlayModelPicker - overlayDiffDrawer - overlayAskUserQuestion -) - -type ( - terminateTUIMsg struct{} - chatsTUIModel struct { - ctx context.Context - client *codersdk.ExperimentalClient - styles tuiStyles - currentView tuiView - overlay tuiOverlay - list chatListModel - chat chatViewModel - initialChatID *uuid.UUID - workspaceID *uuid.UUID - modelOverride *string - organizationID uuid.UUID - chatGeneration uint64 - catalog *codersdk.ChatModelsResponse - quitting bool - width int - height int - } -) - -func newChatsTUIModel( - ctx context.Context, - client *codersdk.ExperimentalClient, - initialChatID *uuid.UUID, - workspaceID *uuid.UUID, - modelOverride *string, - organizationID uuid.UUID, -) chatsTUIModel { - styles := newTUIStyles() - currentView := viewList - if initialChatID != nil { - currentView = viewChat - } - chat := newChatViewModel(ctx, client, workspaceID, modelOverride, organizationID, styles) - chatGeneration := uint64(0) - if initialChatID != nil { - chat.activeChatID = *initialChatID - chat.chatGeneration = 1 - chat.loading = true - chat.metadataResolved = false - chat.historyResolved = false - chatGeneration = 1 - } - return chatsTUIModel{ - ctx: ctx, - client: client, - styles: styles, - currentView: currentView, - overlay: overlayNone, - list: newChatListModel(styles), - chat: chat, - initialChatID: initialChatID, - workspaceID: workspaceID, - modelOverride: modelOverride, - organizationID: organizationID, - chatGeneration: chatGeneration, - } -} - -// resetChatSession creates a fresh chatViewModel, preserves the -// window dimensions from the previous session, and advances -// the monotonic generation counter so in-flight async messages -// from the old session are ignored. -func (m *chatsTUIModel) resetChatSession() { - old := m.chat - m.chat = newChatViewModel(m.ctx, m.client, m.workspaceID, m.modelOverride, m.organizationID, m.styles) - m.chat.width = old.width - m.chat.height = old.height - m.chat.loading = true - m.chat.metadataResolved = false - m.chat.historyResolved = false - m.chatGeneration++ - m.chat.chatGeneration = m.chatGeneration -} - -func (m *chatsTUIModel) setRenderer(renderer *lipgloss.Renderer) { - styles := newTUIStyles(renderer) - m.styles = styles - m.list.styles = styles - m.list.spinner.Style = styles.dimmedText - m.chat.styles = styles - m.chat.spinner.Style = styles.dimmedText -} - -func (m chatsTUIModel) Init() tea.Cmd { - if m.initialChatID != nil { - m.chat.activeChatID = *m.initialChatID - return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*m.initialChatID, m.chat.chatGeneration)...)...) - } - return tea.Batch(m.loadChatsCmd(), m.list.Init()) -} - -func (m chatsTUIModel) loadChatsCmd() tea.Cmd { - return apiCmd(func() ([]codersdk.Chat, error) { return m.client.ListChats(m.ctx, nil) }, func(chats []codersdk.Chat, err error) tea.Msg { return chatsListedMsg{chats: chats, err: err} }) -} - -func (m chatsTUIModel) loadChatCmd(chatID uuid.UUID, generation uint64) []tea.Cmd { - return []tea.Cmd{apiCmd(func() (codersdk.Chat, error) { return m.client.GetChat(m.ctx, chatID) }, func(chat codersdk.Chat, err error) tea.Msg { - return chatOpenedMsg{generation: generation, chatID: chatID, chat: chat, err: err} - }), loadChatHistoryCmd(m.ctx, m.client, chatID, generation)} -} - -func (m chatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg { - h := m.height - if m.currentView == viewList { - h = max(0, h-1) - } - return tea.WindowSizeMsg{Width: m.width, Height: h} -} - -func (m *chatsTUIModel) toggleOverlay(overlay tuiOverlay) bool { - if m.overlay == overlay { - m.overlay = overlayNone - return false - } - m.overlay = overlay - return true -} - -func (m *chatsTUIModel) handleEsc(msg tea.KeyMsg) tea.Cmd { - if m.currentView == viewList && m.list.searching { - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return cmd - } - if m.currentView == viewChat { - m.chatGeneration++ - m.chat.chatGeneration = m.chatGeneration - m.chat.stopStream() - m.currentView = viewList - m.list.loading = true - return m.loadChatsCmd() - } - m.quitting = true - return tea.Quit -} - -func isOverlayCloseKey(msg tea.KeyMsg) bool { - if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEscape { - return true - } - - key := msg.String() - return key == "esc" || key == "ctrl+[" -} - -func (m *chatsTUIModel) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "up", "k": - if m.chat.modelPickerCursor > 0 { - m.chat.modelPickerCursor-- - } - case "down", "j": - if m.chat.modelPickerCursor < len(m.chat.modelPickerFlat)-1 { - m.chat.modelPickerCursor++ - } - case "enter": - if len(m.chat.modelPickerFlat) > 0 && m.chat.modelPickerCursor < len(m.chat.modelPickerFlat) { - selected := m.chat.modelPickerFlat[m.chat.modelPickerCursor] - m.chat.modelOverride = &selected.ID - m.modelOverride = &selected.ID - m.overlay = overlayNone - } - case "ctrl+p", "q": - m.overlay = overlayNone - } - return nil -} - -func (m *chatsTUIModel) handleAskUserQuestionKey(msg tea.KeyMsg) tea.Cmd { - state := m.chat.pendingAskUserQuestion - if state == nil || state.Submitting || len(state.Questions) == 0 { - return nil - } - if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) { - return nil - } - - if state.OtherMode { - switch msg.Type { - case tea.KeyEsc: - state.OtherMode = false - state.OtherInput.Blur() - return nil - case tea.KeyEnter: - answer := strings.TrimSpace(state.OtherInput.Value()) - if answer == "" { - return nil - } - return m.recordAskAnswer(answer, "", true) - default: - var cmd tea.Cmd - state.OtherInput, cmd = state.OtherInput.Update(msg) - return cmd - } - } - - question := state.Questions[state.CurrentIndex] - optionCount := len(question.Options) + 1 - switch msg.String() { - case "up", "k": - state.OptionCursor-- - if state.OptionCursor < 0 { - state.OptionCursor = optionCount - 1 - } - case "down", "j": - state.OptionCursor++ - if state.OptionCursor >= optionCount { - state.OptionCursor = 0 - } - case "left", "h": - if state.CurrentIndex == 0 { - return nil - } - state.CurrentIndex-- - state.OptionCursor = 0 - state.OtherMode = false - state.OtherInput.Blur() - state.Error = nil - if len(state.Answers) > state.CurrentIndex { - state.Answers = state.Answers[:state.CurrentIndex] - } - case "enter": - state.Error = nil - if state.OptionCursor < len(question.Options) { - option := question.Options[state.OptionCursor] - answer := strings.TrimSpace(option.Value) - if answer == "" { - answer = option.Label - } - return m.recordAskAnswer(answer, option.Label, false) - } - state.OtherMode = true - state.OtherInput.SetValue("") - state.OtherInput.Focus() - } - - return nil -} - -func (m *chatsTUIModel) recordAskAnswer(answer, optionLabel string, freeform bool) tea.Cmd { - state := m.chat.pendingAskUserQuestion - if state == nil || len(state.Questions) == 0 { - return nil - } - if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) { - return nil - } - - question := state.Questions[state.CurrentIndex] - if len(state.Answers) > state.CurrentIndex { - state.Answers = state.Answers[:state.CurrentIndex] - } - - state.Answers = append(state.Answers, askQuestionAnswer{ - Header: question.Header, - Question: question.Question, - Answer: answer, - OptionLabel: optionLabel, - Freeform: freeform, - }) - state.OtherMode = false - state.OtherInput.Blur() - state.OtherInput.SetValue("") - state.OptionCursor = 0 - state.Error = nil - - if state.CurrentIndex+1 < len(state.Questions) { - state.CurrentIndex++ - return nil - } - - state.Submitting = true - return submitAskUserQuestionCmd(m.client.Client, m.chat.activeChatID, m.chat.chatGeneration, state) -} - -func (m *chatsTUIModel) openChatCmd(chatID *uuid.UUID) tea.Cmd { - m.currentView = viewChat - m.chat.stopStream() - m.resetChatSession() - if chatID == nil { - m.chat.draft = true - m.chat.loading = false - m.chat.metadataResolved = true - m.chat.historyResolved = true - m.chat, _ = m.chat.Update(m.childWindowSizeMsg()) - return nil - } - m.chat.activeChatID = *chatID - m.chat, _ = m.chat.Update(m.childWindowSizeMsg()) - return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*chatID, m.chat.chatGeneration)...)...) -} - -func (m *chatsTUIModel) toggleModelPickerCmd() tea.Cmd { - if !m.toggleOverlay(overlayModelPicker) { - return nil - } - if m.catalog == nil { - return apiCmd(func() (codersdk.ChatModelsResponse, error) { return m.client.ListChatModels(m.ctx) }, func(catalog codersdk.ChatModelsResponse, err error) tea.Msg { - return modelsListedMsg{catalog: catalog, err: err} - }) - } - if len(m.chat.modelPickerFlat) == 0 { - m.chat.modelPickerFlat = availableChatModels(*m.catalog) - } - return nil -} - -func (m *chatsTUIModel) toggleDiffDrawerCmd() tea.Cmd { - if m.chat.chat == nil { - return nil - } - if !m.toggleOverlay(overlayDiffDrawer) { - return nil - } - if m.chat.diffContents == nil || m.chat.diffErr != nil { - m.chat.diffErr = nil - chatID := m.chat.chat.ID - generation := m.chat.chatGeneration - return apiCmd(func() (codersdk.ChatDiffContents, error) { return fetchChatDiffContents(m.ctx, m.client, chatID) }, func(diff codersdk.ChatDiffContents, err error) tea.Msg { - return diffContentsMsg{generation: generation, chatID: chatID, diff: diff, err: err} - }) - } - return nil -} - -func (m chatsTUIModel) updateChild(msg tea.Msg, view tuiView) (chatsTUIModel, tea.Cmd) { - var cmd tea.Cmd - if view == viewChat { - m.chat, cmd = m.chat.Update(msg) - } else { - m.list, cmd = m.list.Update(msg) - } - return m, cmd -} - -func (m chatsTUIModel) renderOverlay(title, body string) string { - return renderOverlayFrame(m.styles, m.width, m.styles.title.Render(title), body, m.styles.helpText.Render("Esc to close")) -} - -func (m chatsTUIModel) diffOverlayView() string { - switch { - case m.chat.diffErr != nil: - return m.renderOverlay("Diff", m.styles.errorText.Render(wrapPreservingNewlines(m.chat.diffErr.Error(), contentWidth(m.width, 6)))) - case m.chat.diffContents != nil: - return renderDiffDrawer(m.styles, *m.chat.diffContents, m.chat.diffSummary, m.chat.diffStyledBody, m.width, m.height) - default: - return m.renderOverlay("Diff", m.styles.dimmedText.Render("Loading diff…")) - } -} - -func padViewHeight(text string, height int) string { - if height <= 0 { - return text - } - if text == "" { - return strings.Repeat("\n", max(height-1, 0)) - } - lineCount := countRenderedLines(text) - if lineCount >= height { - return text - } - return text + strings.Repeat("\n", height-lineCount) -} - -func (m chatsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - childMsg := m.childWindowSizeMsg() - m.list, _ = m.list.Update(childMsg) - m.chat, _ = m.chat.Update(childMsg) - return m, nil - case terminateTUIMsg: - m.quitting = true - return m, tea.Quit - case tea.KeyMsg: - if msg.Type == tea.KeyCtrlC { - m.quitting = true - return m, tea.Quit - } - // Handle overlays first so their keys do not leak to the underlying - // view. - if m.overlay == overlayAskUserQuestion { - return m, m.handleAskUserQuestionKey(msg) - } - if m.overlay == overlayModelPicker { - if isOverlayCloseKey(msg) { - m.overlay = overlayNone - return m, tea.ClearScreen - } - cmd := m.handleModelPickerKey(msg) - if m.overlay == overlayNone { - return m, tea.Batch(cmd, tea.ClearScreen) - } - return m, cmd - } - if m.overlay == overlayDiffDrawer { - if isOverlayCloseKey(msg) { - m.overlay = overlayNone - return m, tea.ClearScreen - } - return m, nil - } - if msg.String() == "esc" { - return m, m.handleEsc(msg) - } - case openSelectedChatMsg: - return m, m.openChatCmd(&msg.chatID) - case openDraftChatMsg: - return m, m.openChatCmd(nil) - case refreshChatsMsg: - return m, m.loadChatsCmd() - case toggleModelPickerMsg: - return m, m.toggleModelPickerCmd() - case toggleDiffDrawerMsg: - return m, m.toggleDiffDrawerCmd() - case showAskUserQuestionMsg: - m.chat.pendingAskUserQuestion = msg.state - m.overlay = overlayAskUserQuestion - return m.updateChild(msg, viewChat) - case hideAskUserQuestionMsg: - if m.overlay == overlayAskUserQuestion { - m.overlay = overlayNone - } - return m.updateChild(msg, viewChat) - case toolResultsSubmittedMsg: - if msg.err == nil && m.chat.matchesGeneration(msg.generation) && msg.chatID == m.chat.activeChatID { - m.chat.pendingAskUserQuestion = nil - if m.overlay == overlayAskUserQuestion { - m.overlay = overlayNone - } - } - return m.updateChild(msg, viewChat) - case chatsListedMsg: - return m.updateChild(msg, viewList) - case chatOpenedMsg, chatHistoryMsg, chatStreamEventMsg, messageSentMsg, chatCreatedMsg, chatInterruptedMsg, diffContentsMsg: - return m.updateChild(msg, viewChat) - case modelsListedMsg: - if msg.err != nil { - m.overlay = overlayNone - } else { - catalog := msg.catalog - m.catalog = &catalog - } - return m.updateChild(msg, viewChat) - } - return m.updateChild(msg, m.currentView) -} - -func (m chatsTUIModel) View() string { - if m.quitting { - return "" - } - - var base string - if m.currentView == viewChat { - base = m.chat.View() - } else { - base = m.styles.title.Render("Coder Chats") + "\n" + m.list.View() - } - - switch m.overlay { - case overlayAskUserQuestion: - if m.chat.pendingAskUserQuestion != nil { - base += "\n" + renderAskUserQuestion(m.styles, m.chat.pendingAskUserQuestion, m.width, m.height) - } - case overlayModelPicker: - if m.catalog == nil { - base += "\n" + m.renderOverlay("Select Model", m.styles.dimmedText.Render("Loading models...")) - break - } - selectedID := "" - if m.chat.modelOverride != nil { - selectedID = *m.chat.modelOverride - } - base += "\n" + renderModelPicker(m.styles, *m.catalog, selectedID, m.chat.modelPickerCursor, m.width, m.height) - case overlayDiffDrawer: - base += "\n" + m.diffOverlayView() - } - return padViewHeight(base, m.height) -} diff --git a/cli/agents_render.go b/cli/agents_render.go deleted file mode 100644 index b60046b6b8..0000000000 --- a/cli/agents_render.go +++ /dev/null @@ -1,1251 +0,0 @@ -package cli - -import ( - "bytes" - "cmp" - "encoding/json" - "fmt" - "slices" - "strconv" - "strings" - "sync" - - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" - - "github.com/coder/coder/v2/codersdk" -) - -const ( - contextCompactionToolName = "context_compaction" - toolBlockIndent = " " - toolDetailIndent = " " - toolSummaryFallbackWidth = 48 - pendingToolIcon = "○" - reasoningPrefix = "thinking: " -) - -func compactTranscriptJSON(raw json.RawMessage) string { - raw = bytes.TrimSpace(raw) - if len(raw) == 0 { - return "" - } - - var builder bytes.Buffer - if err := json.Compact(&builder, raw); err == nil { - return builder.String() - } - - return string(raw) -} - -func toolBaseName(name string) string { - name = strings.TrimSpace(name) - name = strings.TrimPrefix(name, "coder_") - name = strings.TrimPrefix(name, "github__") - return strings.Join(strings.Fields(name), " ") -} - -func humanizeToolName(name string) string { - name = strings.ReplaceAll(toolBaseName(name), "_", " ") - name = strings.Join(strings.Fields(name), " ") - if name == "" { - return "tool" - } - return name -} - -func normalizeToolName(name string) string { - if toolBaseName(name) == "" { - return "" - } - return strings.ReplaceAll(strings.ToLower(humanizeToolName(name)), " ", "_") -} - -func summarizeToolContent(toolName, raw string, fields ...string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - var parsed any - if err := json.Unmarshal([]byte(raw), &parsed); err == nil { - if summary := toolObjectSummary(toolName, parsed); summary != "" { - return summary - } - if value := firstStringField(parsed, fields...); value != "" { - return strconv.Quote(value) - } - if value := firstShortStringValue(parsed); value != "" { - return strconv.Quote(value) - } - } - compact := compactTranscriptJSON(json.RawMessage(raw)) - if compact == "" { - return "" - } - compactRunes := []rune(compact) - if len(compactRunes) <= toolSummaryFallbackWidth { - return compact - } - return string(compactRunes[:toolSummaryFallbackWidth-1]) + "…" -} - -var toolArgsSummary = summarizeToolContent - -func toolResultSummary(toolName, argsJSON, resultJSON string) string { - return cmp.Or( - summarizeToolContent(toolName, argsJSON), - summarizeToolContent(toolName, resultJSON), - "null", - ) -} - -func toolObjectSummary(toolName string, parsed any) string { - normalized := normalizeToolName(toolName) - switch { - case normalized == "execute" || normalized == "execute_command" || normalized == "run_command": - if command := firstStringField(parsed, "command", "cmd", "script", "input"); command != "" { - return strconv.Quote(command) - } - case strings.Contains(normalized, "read_file") || strings.Contains(normalized, "write_file") || strings.Contains(normalized, "delete_file") || strings.Contains(normalized, "stat_file"): - if path := firstStringField(parsed, "path", "file_path", "filename"); path != "" { - return "(" + path + ")" - } - case normalized == "get_pull_request": - owner := firstStringField(parsed, "owner") - repo := firstStringField(parsed, "repo", "repository") - switch { - case owner != "" && repo != "": - return "(" + owner + "/" + repo + ")" - case repo != "": - return "(" + repo + ")" - } - case strings.Contains(normalized, "workspace"): - if workspace := firstStringField(parsed, "workspace_name", "name", "workspace"); workspace != "" { - return "(" + workspace + ")" - } - } - return "" -} - -func firstStringField(value any, keys ...string) string { - object, ok := value.(map[string]any) - if !ok { - return "" - } - for _, key := range keys { - fieldValue, ok := object[key] - if !ok { - continue - } - if text := firstShortStringValue(fieldValue); text != "" { - return text - } - } - return "" -} - -func firstShortStringValue(value any) string { - switch typed := value.(type) { - case string: - trimmed := strings.Join(strings.Fields(strings.TrimSpace(typed)), " ") - if trimmed == "" { - return "" - } - return trimmed - case []any: - for _, item := range typed { - if text := firstShortStringValue(item); text != "" { - return text - } - } - case map[string]any: - keys := make([]string, 0, len(typed)) - for key := range typed { - keys = append(keys, key) - } - slices.Sort(keys) - for _, key := range keys { - if text := firstShortStringValue(typed[key]); text != "" { - return text - } - } - } - return "" -} - -func toolDisplayLabel(toolName string, kind chatBlockKind, collapsedCount int) string { - label := humanizeToolName(toolName) - if collapsedCount <= 1 { - return label - } - - switch kind { - case blockToolCall: - return label + "..." - case blockToolResult: - return fmt.Sprintf("%s (x%d)", label, collapsedCount) - default: - return label - } -} - -func renderToolLine(styles tuiStyles, labelStyle lipgloss.Style, icon, label, summary string, width int) string { - label = sanitizeTerminalRenderableText(label) - summary = sanitizeTerminalRenderableText(summary) - header := toolBlockIndent + labelStyle.Render(icon) + " " + label - if summary == "" || width <= 0 { - return header - } - available := width - lipgloss.Width(header) - 1 - preview := styles.truncate(summary, max(available, 0)) - if preview == "" { - return header - } - return header + " " + styles.dimmedText.Render(preview) -} - -func renderToolDetail(styles tuiStyles, label, value string, width int) string { - value = sanitizeTerminalRenderableText(value) - if strings.TrimSpace(value) == "" { - return "" - } - prefix := toolDetailIndent + label + ": " - wrapped := wrapPreservingNewlines(value, contentWidth(width, lipgloss.Width(prefix))) - lines := strings.Split(wrapped, "\n") - for i := range lines { - if i == 0 { - lines[i] = prefix + lines[i] - continue - } - lines[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + lines[i] - } - return styles.dimmedText.Render(strings.Join(lines, "\n")) -} - -func renderExpandedToolBlock(styles tuiStyles, labelStyle lipgloss.Style, icon, toolName, args, result string, width int) string { - lines := []string{toolBlockIndent + labelStyle.Render(icon) + " " + humanizeToolName(toolName)} - if argsLine := renderToolDetail(styles, "args", args, width); argsLine != "" { - lines = append(lines, argsLine) - } - if resultLine := renderToolDetail(styles, "result", result, width); resultLine != "" { - lines = append(lines, resultLine) - } - return strings.Join(lines, "\n") -} - -func toolResultIconAndStyle(styles tuiStyles, block chatBlock) (string, lipgloss.Style) { - if block.isError { - return "✗", styles.errorText - } - return "✓", styles.toolSuccess -} - -func renderToolCallBlock(styles tuiStyles, block chatBlock, width int) string { - if block.toolName == contextCompactionToolName { - return renderCompaction(styles, width) - } - - return renderToolLine( - styles, - styles.toolPending, - pendingToolIcon, - toolDisplayLabel(block.toolName, block.kind, block.collapsedCount), - summarizeToolContent(block.toolName, block.args), - width, - ) -} - -func renderToolResultBlock(styles tuiStyles, block chatBlock, width int) string { - if block.toolName == contextCompactionToolName { - return renderCompaction(styles, width) - } - icon, labelStyle := toolResultIconAndStyle(styles, block) - - summary := summarizeToolContent(block.toolName, block.args) - if summary == "" && block.isError { - summary = summarizeToolContent("", block.result, "error", "message", "detail", "stderr") - } - if summary == "" { - summary = toolResultSummary(block.toolName, "", block.result) - } - return renderToolLine( - styles, - labelStyle, - icon, - toolDisplayLabel(block.toolName, block.kind, block.collapsedCount), - summary, - width, - ) -} - -func renderCompaction(styles tuiStyles, width int) string { - banner := styles.compaction.Render("🗜️ Context compacted") - if width <= 0 { - return banner - } - return lipgloss.PlaceHorizontal(width, lipgloss.Center, banner) -} - -func contentWidth(width, inset int) int { - if width <= 0 { - return 80 - } - return max(width-inset, 1) -} - -func renderOverlayFrame(styles tuiStyles, width int, sections ...string) string { - sections = slices.DeleteFunc(sections, func(section string) bool { return section == "" }) - return styles.overlayBorder.Width(contentWidth(width, 6)).Render(strings.Join(sections, "\n\n")) -} - -func diffMetadataLines(diff codersdk.ChatDiffContents) []string { - var lines []string - if diff.Branch != nil && *diff.Branch != "" { - lines = append(lines, fmt.Sprintf("Branch: %s", *diff.Branch)) - } - if diff.PullRequestURL != nil && *diff.PullRequestURL != "" { - lines = append(lines, fmt.Sprintf("PR: %s", *diff.PullRequestURL)) - } - return lines -} - -func parseChatGitChangesFromUnifiedDiff(diff codersdk.ChatDiffContents) []codersdk.ChatGitChange { - rawDiff := sanitizeTerminalRenderableText(diff.Diff) - if strings.TrimSpace(rawDiff) == "" { - return nil - } - - var ( - changes []codersdk.ChatGitChange - current *codersdk.ChatGitChange - currentAdditions int - currentDeletions int - inHunk bool - ) - flush := func() { - if current == nil { - return - } - if current.FilePath == "" { - current = nil - currentAdditions = 0 - currentDeletions = 0 - return - } - if currentAdditions > 0 || currentDeletions > 0 { - stats := make([]string, 0, 2) - if currentAdditions > 0 { - stats = append(stats, fmt.Sprintf("+%d", currentAdditions)) - } - if currentDeletions > 0 { - stats = append(stats, fmt.Sprintf("-%d", currentDeletions)) - } - summary := strings.Join(stats, " ") - current.DiffSummary = &summary - } - changes = append(changes, *current) - current = nil - currentAdditions = 0 - currentDeletions = 0 - } - - for line := range strings.SplitSeq(rawDiff, "\n") { - switch { - case strings.HasPrefix(line, "diff --git "): - flush() - inHunk = false - // parseUnifiedDiffHeaderPaths may return ("", "", false) when - // the unquoted header form is ambiguous, such as a rename with - // spaces in the paths. We still want to start a new entry so - // the follow-up rename from / rename to / --- / +++ lines can - // populate the correct paths. flush() drops entries that never - // received a FilePath. - oldPath, newPath, _ := parseUnifiedDiffHeaderPaths(line) - current = &codersdk.ChatGitChange{ - ChatID: diff.ChatID, - FilePath: newPath, - ChangeType: "modified", - } - if oldPath != "" && newPath != "" && oldPath != newPath { - oldPathCopy := oldPath - current.OldPath = &oldPathCopy - current.ChangeType = "renamed" - } - case current == nil: - continue - case strings.HasPrefix(line, "@@"): - // Entering a hunk. Everything from here until the next - // "diff --git " header is diff content, including any - // added/removed lines that happen to start with "--- " - // or "+++ ". Those must no longer be treated as file - // headers. - inHunk = true - case !inHunk && strings.HasPrefix(line, "new file mode "): - current.ChangeType = "added" - case !inHunk && strings.HasPrefix(line, "deleted file mode "): - current.ChangeType = "deleted" - case !inHunk && strings.HasPrefix(line, "rename from "): - // rename from/rename to paths are repository-relative and - // never carry the a/ or b/ prefix, so we must not strip - // those segments: a real file at a/foo.txt would otherwise - // be truncated to foo.txt. - oldPath := decodeQuotedDiffLinePath(strings.TrimPrefix(line, "rename from ")) - if oldPath != "" { - oldPathCopy := oldPath - current.OldPath = &oldPathCopy - } - current.ChangeType = "renamed" - case !inHunk && strings.HasPrefix(line, "rename to "): - newPath := decodeQuotedDiffLinePath(strings.TrimPrefix(line, "rename to ")) - if newPath != "" { - current.FilePath = newPath - } - current.ChangeType = "renamed" - case !inHunk && strings.HasPrefix(line, "--- /dev/null"): - current.ChangeType = "added" - case !inHunk && strings.HasPrefix(line, "+++ /dev/null"): - current.ChangeType = "deleted" - case !inHunk && strings.HasPrefix(line, "--- "): - if current.ChangeType == "added" { - continue - } - if oldPath := trimUnifiedDiffPath(strings.TrimPrefix(line, "--- ")); oldPath != "" && oldPath != "/dev/null" { - oldPathCopy := oldPath - current.OldPath = &oldPathCopy - } - case !inHunk && strings.HasPrefix(line, "+++ "): - if current.ChangeType == "deleted" { - continue - } - if newPath := trimUnifiedDiffPath(strings.TrimPrefix(line, "+++ ")); newPath != "" && newPath != "/dev/null" { - current.FilePath = newPath - } - case inHunk && strings.HasPrefix(line, "+"): - currentAdditions++ - case inHunk && strings.HasPrefix(line, "-"): - currentDeletions++ - } - } - flush() - return changes -} - -// parseUnifiedDiffHeaderPaths extracts the old and new paths from a -// `diff --git ...` header line. Git emits paths in one of two forms: -// -// 1. Quoted: `diff --git "a/" "b/"`. Used when paths contain -// control characters, backslashes, double quotes, or (with the default -// core.quotepath setting) bytes above 0x7f. The contents are C-quoted. -// 2. Unquoted: `diff --git a/ b/`. Used for simple paths, which -// may still contain spaces. Because there is no delimiter between the -// two paths, this form is ambiguous when paths contain spaces: we rely -// on the git convention that non-rename diffs repeat the same path in -// both halves. -// -// For the unquoted form we first search for a split point at ` b/` where -// the left and right halves are equal after stripping the `a/` and `b/` -// prefixes (the non-rename case). If that fails but the line contains only -// a single space, we split there for simple renames with no embedded -// whitespace. Otherwise we return ok=false and let the caller rely on the -// subsequent `rename from`, `rename to`, `--- `, and `+++ ` lines. -func parseUnifiedDiffHeaderPaths(line string) (oldPath string, newPath string, ok bool) { - raw := strings.TrimSpace(strings.TrimPrefix(line, "diff --git ")) - if raw == "" { - return "", "", false - } - - if strings.HasPrefix(raw, `"`) { - old, rest, ok := consumeQuotedDiffPath(raw) - if !ok { - return "", "", false - } - rest = strings.TrimLeft(rest, " ") - newp, _, ok := consumeQuotedDiffPath(rest) - if !ok { - return "", "", false - } - // The unquoted values already have their surrounding quotes removed, - // so we must not feed them to trimUnifiedDiffPath (which would strip - // any legitimate leading or trailing quote characters in the file - // name). Only strip the a/ or b/ prefix here. - return stripUnifiedDiffPrefix(old), stripUnifiedDiffPrefix(newp), true - } - - if !strings.HasPrefix(raw, "a/") { - return "", "", false - } - for offset := 0; offset < len(raw); { - idx := strings.Index(raw[offset:], " b/") - if idx < 0 { - break - } - pos := offset + idx - left := trimUnifiedDiffPath(raw[:pos]) - right := trimUnifiedDiffPath(raw[pos+1:]) - if left == right { - return left, right, true - } - offset = pos + 1 - } - // No equal split was found. If the line only contains a single space, - // the split is unambiguous and this is a simple rename whose paths - // happen to differ. Splitting the quoted-path form was handled above, - // so we know the raw form has no quoting to worry about here. - if strings.Count(raw, " ") == 1 { - idx := strings.Index(raw, " b/") - if idx > 0 { - return trimUnifiedDiffPath(raw[:idx]), trimUnifiedDiffPath(raw[idx+1:]), true - } - } - return "", "", false -} - -// consumeQuotedDiffPath reads one C-quoted path from the start of s and -// returns the unquoted value along with the remainder of the string. The -// leading character of s must be `"`. git's C-quoting matches Go's quoted -// string syntax closely enough for strconv.Unquote to handle the common -// cases (octal byte escapes like `\303`, and the usual `\t`, `\n`, `\"`, -// `\\`). -func consumeQuotedDiffPath(s string) (path string, rest string, ok bool) { - if !strings.HasPrefix(s, `"`) { - return "", "", false - } - for i := 1; i < len(s); i++ { - switch s[i] { - case '\\': - // Skip the next byte so an escaped quote does not terminate - // the literal early. Bounds-check to avoid running off the - // end of a malformed input. - if i+1 >= len(s) { - return "", "", false - } - i++ - case '"': - unq, err := strconv.Unquote(s[:i+1]) - if err != nil { - return "", "", false - } - return unq, s[i+1:], true - } - } - return "", "", false -} - -// trimUnifiedDiffPath decodes a path taken from a `--- ` or `+++ ` line -// of a unified diff. Those lines always prefix the path with `a/` or `b/`, -// so the prefix is stripped after any C-quote decoding. -func trimUnifiedDiffPath(path string) string { - return stripUnifiedDiffPrefix(decodeQuotedDiffLinePath(path)) -} - -// decodeQuotedDiffLinePath decodes a git-emitted path without stripping -// any `a/` or `b/` prefix. Git only adds those prefixes to `diff --git`, -// `--- `, and `+++ ` lines, so `rename from`, `rename to`, and similar -// lines must use this helper to avoid truncating a real leading `a/` or -// `b/` directory component. -func decodeQuotedDiffLinePath(path string) string { - path = strings.TrimSpace(path) - // Git quotes the whole path with double quotes and C-style escapes when - // it contains control characters, backslashes, double quotes, or (with - // the default core.quotepath setting) bytes above 0x7f. strconv.Unquote - // understands the same escape vocabulary for the common cases. - if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) { - if unq, err := strconv.Unquote(path); err == nil { - return unq - } - return strings.Trim(path, `"`) - } - return path -} - -func stripUnifiedDiffPrefix(path string) string { - switch { - case strings.HasPrefix(path, "a/"), strings.HasPrefix(path, "b/"): - return path[2:] - default: - return path - } -} - -// agentgitOversizePlaceholderPrefix matches the literal prefix that -// agent/agentgit substitutes for a repository's UnifiedDiff when the -// raw diff exceeds maxTotalDiffSize (3 MiB). See -// agent/agentgit/agentgit.go. Multi-repo aggregates assembled by -// buildLocalChatDiffContents can mix real `diff --git` chunks with -// this placeholder, in which case parseChatGitChangesFromUnifiedDiff -// returns a non-zero count for the real chunks while silently -// dropping the placeholder repo. Detecting the prefix separately -// lets renderChatDiffSummary flag the omission so the user is not -// misled into thinking the summary is exhaustive. Kept as a local -// prefix match because the coupling is narrow and the string is -// stable. -const agentgitOversizePlaceholderPrefix = "Total diff too large to show. Size:" - -// hasOversizedRepoPlaceholder reports whether the combined unified -// diff contains at least one agentgit oversize-repo placeholder. -// Matching is scoped to lines that start with the placeholder prefix -// so a false positive from a diff body that legitimately contains the -// phrase (e.g. as a `+` added line inside a real patch) cannot -// trigger the omission notice. agentgit always writes the -// placeholder as the entire UnifiedDiff for a repo, and -// buildLocalChatDiffContents joins segments with "\n", so a real -// placeholder repo always appears on its own line after the join. -func hasOversizedRepoPlaceholder(diff string) bool { - for _, line := range strings.Split(diff, "\n") { - if strings.HasPrefix(line, agentgitOversizePlaceholderPrefix) { - return true - } - } - return false -} - -func renderChatDiffSummary(diff codersdk.ChatDiffContents) string { - changes := parseChatGitChangesFromUnifiedDiff(diff) - if len(changes) == 0 { - // The diff text might be non-empty but not in `diff --git` - // format (for example `agent/agentgit` emits a "Total diff - // too large to show..." placeholder when the raw diff exceeds - // the read limit). Report that changes exist but could not - // be summarized so we do not mislead the user into thinking - // the workspace is clean. - if strings.TrimSpace(diff.Diff) != "" { - return "Changes present but could not be summarized." - } - return "No changes detected." - } - - label := "files" - if len(changes) == 1 { - label = "file" - } - lines := []string{fmt.Sprintf("%d %s changed:", len(changes), label)} - for _, change := range changes { - path := sanitizeTerminalRenderableText(change.FilePath) - if change.ChangeType == "renamed" && change.OldPath != nil && *change.OldPath != "" { - path = fmt.Sprintf("%s → %s", sanitizeTerminalRenderableText(*change.OldPath), path) - } - line := fmt.Sprintf(" %-8s %s", change.ChangeType, path) - if change.DiffSummary != nil && strings.TrimSpace(*change.DiffSummary) != "" { - line = fmt.Sprintf("%s (%s)", line, sanitizeTerminalRenderableText(*change.DiffSummary)) - } - lines = append(lines, line) - } - // A multi-repo aggregate can mix real diff chunks (counted - // above) with agentgit's oversize placeholder for repos whose - // raw diff exceeds maxTotalDiffSize. The placeholder does not - // contribute to the files-changed count because it is not in - // `diff --git` format, so without this notice the summary would - // silently underreport the changeset. - if hasOversizedRepoPlaceholder(diff.Diff) { - lines = append(lines, " (some repositories omitted: diff too large to summarize)") - } - return strings.Join(lines, "\n") -} - -func renderStyledDiffBody(styles tuiStyles, diff string) string { - diff = sanitizeTerminalRenderableText(diff) - if strings.TrimSpace(diff) == "" { - return styles.dimmedText.Render("No diff contents.") - } - lines := strings.Split(diff, "\n") - inHunk := false - for i, line := range lines { - // Track whether we're inside a hunk body so styling can - // distinguish legitimate header `--- `/`+++ ` lines from - // additions/deletions whose content happens to start with - // those prefixes (for example a `+++ ` content line whose - // text begins with `++ `). Matches the parser's inHunk - // bookkeeping in parseChatGitChangesFromUnifiedDiff. - switch { - case strings.HasPrefix(line, "diff --git "): - inHunk = false - case strings.HasPrefix(line, "@@"): - inHunk = true - } - lines[i] = styleUnifiedDiffLine(styles, line, inHunk) - } - return strings.Join(lines, "\n") -} - -func styleUnifiedDiffLine(styles tuiStyles, line string, inHunk bool) string { - switch { - case strings.HasPrefix(line, "diff --git "): - return styles.selectedItem.Render(line) - case strings.HasPrefix(line, "index "), - strings.HasPrefix(line, "new file mode "), - strings.HasPrefix(line, "deleted file mode "), - strings.HasPrefix(line, "rename from "), - strings.HasPrefix(line, "rename to "), - strings.HasPrefix(line, "Binary files "): - return styles.subtitle.Render(line) - case !inHunk && (strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ")): - return styles.subtitle.Render(line) - case strings.HasPrefix(line, "@@"): - return styles.warningText.Render(line) - case strings.HasPrefix(line, "+"): - return styles.toolSuccess.Render(line) - case strings.HasPrefix(line, "-"): - return styles.errorText.Render(line) - default: - return line - } -} - -// renderDiffDrawer builds the diff overlay contents. The caller is -// responsible for producing summary with renderChatDiffSummary and -// styledBody with renderStyledDiffBody so that every View() redraw -// does not walk the full (potentially 4 MiB) diff through -// parseChatGitChangesFromUnifiedDiff or re-style every line through -// lipgloss. chatViewModel caches both in diffSummary and -// diffStyledBody for this reason. If styledBody is empty the caller -// had no cache (for example tests that construct diffs directly), so -// fall back to computing it here instead of silently rendering an -// empty body. -func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, summary, styledBody string, width, height int) string { - innerWidth := contentWidth(width, 6) - headerBits := []string{styles.title.Render("Diff")} - if meta := diffMetadataLines(diff); len(meta) > 0 { - headerBits = append(headerBits, styles.subtitle.Render(strings.Join(meta, " • "))) - } - diffBody := styledBody - if diffBody == "" { - diffBody = renderStyledDiffBody(styles, diff.Diff) - } - help := styles.helpText.Render("Esc to close") - overhead := countRenderedLines(strings.Join(headerBits, "\n")) + countRenderedLines(summary) + countRenderedLines(help) + 4 - availableBodyLines := max(height-overhead, 0) - if height <= 0 { - availableBodyLines = 12 - } - wrappedDiff := wrapPreservingNewlines(diffBody, innerWidth) - if availableBodyLines == 0 { - wrappedDiff = "" - } else { - wrappedDiff = clampLines(wrappedDiff, availableBodyLines) - } - return renderOverlayFrame(styles, width, strings.Join(headerBits, "\n"), summary, wrappedDiff, help) -} - -func renderModelPicker(styles tuiStyles, catalog codersdk.ChatModelsResponse, selected string, cursor int, width, height int) string { - innerWidth := contentWidth(width, 6) - lines := []string{styles.title.Render("Select Model")} - cursorLine := 0 - hasModels := false - flatIndex := 0 - for _, provider := range catalog.Providers { - if len(provider.Models) == 0 { - continue - } - lines = append(lines, styles.subtitle.Render(provider.Provider)) - if !provider.Available { - reason := string(provider.UnavailableReason) - if reason == "" { - reason = "unavailable" - } - lines = append(lines, " "+styles.dimmedText.Render(reason)) - lines = append(lines, "") - continue - } - for _, model := range provider.Models { - hasModels = true - name := model.DisplayName - if strings.TrimSpace(name) == "" { - name = model.Model - } - marker := " " - if flatIndex == cursor { - marker = "> " - } - rowStyle := styles.normalItem - if model.ID == selected { - rowStyle = styles.selectedItem - } - lines = append(lines, marker+rowStyle.Render(styles.truncate(name, max(innerWidth-2, 0)))) - if flatIndex == cursor { - cursorLine = len(lines) - 1 - } - flatIndex++ - } - lines = append(lines, "") - } - if !hasModels { - lines = append(lines, styles.dimmedText.Render("No models available.")) - lines = append(lines, "") - } - help := styles.helpText.Render("Esc to close, Enter to select") - contentLines := lines - maxContentLines := max(height-countRenderedLines(help)-4, 1) - if height <= 0 { - maxContentLines = len(contentLines) - } - windowStart := 0 - if cursorLine >= maxContentLines { - windowStart = cursorLine - maxContentLines + 1 - } - maxWindowStart := max(len(contentLines)-maxContentLines, 0) - windowStart = min(windowStart, maxWindowStart) - windowEnd := min(windowStart+maxContentLines, len(contentLines)) - content := append([]string(nil), contentLines[windowStart:windowEnd]...) - content = append(content, help) - return renderOverlayFrame(styles, width, strings.Join(content, "\n")) -} - -func renderAskUserQuestion(styles tuiStyles, state *askUserQuestionState, width, height int) string { - if state == nil || len(state.Questions) == 0 { - return "" - } - if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) { - return "" - } - - innerWidth := contentWidth(width, 6) - question := state.Questions[state.CurrentIndex] - sections := []string{styles.title.Render(fmt.Sprintf("Plan Question %d/%d", state.CurrentIndex+1, len(state.Questions)))} - if question.Header != "" { - sections = append(sections, styles.subtitle.Render(sanitizeTerminalRenderableText(question.Header))) - } - sections = append(sections, wrapPreservingNewlines(sanitizeTerminalRenderableText(question.Question), innerWidth)) - - if state.Submitting { - sections = append(sections, styles.dimmedText.Render("Submitting answers...")) - return renderOverlayFrame(styles, width, sections...) - } - - optionLines := make([]string, 0, len(question.Options)+3) - for i, option := range question.Options { - label := strings.TrimSpace(sanitizeTerminalRenderableText(option.Label)) - if label == "" { - label = "(empty option)" - } - label = styles.truncate(label, max(innerWidth-2, 0)) - row := " " + label - if i == state.OptionCursor { - row = styles.selectedItem.Render("> " + label) - } - optionLines = append(optionLines, row) - } - - otherLabel := styles.truncate("Other (type custom answer)", max(innerWidth-2, 0)) - otherRow := " " + otherLabel - if state.OptionCursor == len(question.Options) { - otherRow = styles.selectedItem.Render("> " + otherLabel) - } - optionLines = append(optionLines, otherRow) - if state.OtherMode { - optionLines = append(optionLines, "", state.OtherInput.View()) - } - sections = append(sections, strings.Join(optionLines, "\n")) - - if state.Error != nil { - sections = append(sections, styles.errorText.Render(wrapPreservingNewlines( - "Error: "+sanitizeTerminalRenderableText(state.Error.Error()), - innerWidth, - ))) - } - - longHelpParts := []string{"↑/↓ navigate", "enter select"} - shortHelpParts := []string{"↑↓", "↵"} - compactHelpParts := []string{"↑↓", "↵"} - if state.CurrentIndex > 0 { - longHelpParts = append(longHelpParts, "←/h back") - shortHelpParts = append(shortHelpParts, "←/h") - compactHelpParts = append(compactHelpParts, "←") - } - if state.OtherMode { - longHelpParts = append(longHelpParts, "esc cancel input") - shortHelpParts = append(shortHelpParts, "esc input") - compactHelpParts = append(compactHelpParts, "esc") - } - sections = append(sections, styles.helpText.Render(fitHelpText( - innerWidth, - strings.Join(longHelpParts, " | "), - strings.Join(shortHelpParts, " │ "), - strings.Join(compactHelpParts, " "), - ))) - - _ = height - return renderOverlayFrame(styles, width, sections...) -} - -//nolint:revive // Signature is dictated by the chat TUI view code. -func renderChatBlocks(styles tuiStyles, blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool, composerFocused bool, width int, renderers ...*glamour.TermRenderer) string { - if len(blocks) == 0 { - return "" - } - - var renderer *glamour.TermRenderer - if len(renderers) > 0 { - renderer = renderers[0] - } - activeSelection := -1 - if !composerFocused { - activeSelection = selectedBlock - } - visibleIndices := collapseConsecutiveSameNameBlocks(blocks, activeSelection, expandedBlocks) - rendered := make([]string, 0, len(visibleIndices)) - for _, index := range visibleIndices { - blockView := blocks[index].cachedRender - if blockView == "" || - blocks[index].cachedWidth != width || - blocks[index].cachedExpanded != expandedBlocks[index] || - blocks[index].cachedCollapsedCount != blocks[index].collapsedCount { - blockView = renderBlock(styles, blocks[index], expandedBlocks[index], width, renderer) - blocks[index].cachedRender = blockView - blocks[index].cachedWidth = width - blocks[index].cachedExpanded = expandedBlocks[index] - blocks[index].cachedCollapsedCount = blocks[index].collapsedCount - } - if index == activeSelection { - blockView = styles.selectedBlock.Render(blockView) - } - rendered = append(rendered, blockView) - } - return strings.Join(rendered, "\n") -} - -//nolint:revive // Signature is dictated by the chat TUI view code. -func renderStatusBar(styles tuiStyles, chat *codersdk.Chat, status codersdk.ChatStatus, usage *codersdk.ChatMessageUsage, queueCount int, interrupting, reconnecting bool, width int) string { - _ = chat - parts := []string{styles.statusColor(status).Render(string(status))} - if usage != nil && usage.TotalTokens != nil && usage.ContextLimit != nil { - total := *usage.TotalTokens - limit := *usage.ContextLimit - if limit > 0 { - tokenText := fmt.Sprintf("tokens: %d/%d", total, limit) - pct := float64(total) / float64(limit) * 100 - switch { - case pct > 95: - tokenText = styles.criticalText.Render(tokenText) - case pct > 80: - tokenText = styles.warningText.Render(tokenText) - } - parts = append(parts, tokenText) - } - } - if queueCount > 0 { - parts = append(parts, fmt.Sprintf("queued: %d", queueCount)) - } - if interrupting { - parts = append(parts, styles.warningText.Render("interrupting…")) - } - if reconnecting { - parts = append(parts, styles.warningText.Render("reconnecting…")) - } - line := strings.Join(parts, styles.separator.Render(" │ ")) - bar := styles.statusBar - if width > 0 { - bar = bar.MaxWidth(width) - } - return bar.Render(line) -} - -func collapseConsecutiveSameNameBlocks(blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool) []int { - if len(blocks) == 0 { - return nil - } - - for i := range blocks { - blocks[i].collapsedCount = 0 - } - - visibleIndices := make([]int, 0, len(blocks)) - for i := 0; i < len(blocks); { - runEnd := i + 1 - for runEnd < len(blocks) && canCollapseToolBlocks(blocks[i], blocks[runEnd]) { - runEnd++ - } - - if runEnd-i < 2 || hasExpandedToolBlock(expandedBlocks, i, runEnd) { - for j := i; j < runEnd; j++ { - visibleIndices = append(visibleIndices, j) - } - i = runEnd - continue - } - - representative := i - if selectedBlock >= i && selectedBlock < runEnd { - representative = selectedBlock - } - blocks[representative].collapsedCount = runEnd - i - visibleIndices = append(visibleIndices, representative) - i = runEnd - } - - return visibleIndices -} - -func canCollapseToolBlocks(a, b chatBlock) bool { - if a.kind != b.kind { - return false - } - if a.kind != blockToolCall && a.kind != blockToolResult { - return false - } - if a.toolName != b.toolName { - return false - } - if a.kind == blockToolResult && a.isError != b.isError { - return false - } - if a.args != b.args || a.result != b.result { - return false - } - return true -} - -func hasExpandedToolBlock(expandedBlocks map[int]bool, start, end int) bool { - for i := start; i < end; i++ { - if expandedBlocks[i] { - return true - } - } - return false -} - -func messagesToBlocks(messages []codersdk.ChatMessage) []chatBlock { - blocks := make([]chatBlock, 0) - for _, message := range messages { - if message.Role == codersdk.ChatMessageRoleSystem { - continue - } - for _, part := range message.Content { - switch part.Type { - case codersdk.ChatMessagePartTypeText: - blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: part.Text}) - case codersdk.ChatMessagePartTypeReasoning: - blocks = append(blocks, chatBlock{kind: blockReasoning, role: message.Role, text: part.Text}) - case codersdk.ChatMessagePartTypeToolCall, codersdk.ChatMessagePartTypeToolResult: - block := chatBlock{role: message.Role, toolName: part.ToolName, toolID: part.ToolCallID} - switch { - case part.ToolName == contextCompactionToolName: - block.kind = blockCompaction - case part.Type == codersdk.ChatMessagePartTypeToolCall: - block.kind = blockToolCall - block.args = compactTranscriptJSON(part.Args) - default: - block.kind = blockToolResult - block.result = compactTranscriptJSON(part.Result) - block.isError = part.IsError - } - blocks = append(blocks, block) - case codersdk.ChatMessagePartTypeSource: - title := part.Title - if strings.TrimSpace(title) == "" { - title = part.URL - } - blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[Source: %s](%s)", title, part.URL)}) - case codersdk.ChatMessagePartTypeFile: - blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[File: %s]", part.MediaType)}) - case codersdk.ChatMessagePartTypeFileReference: - blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[%s L%d-%d]", part.FileName, part.StartLine, part.EndLine)}) - } - } - } - return mergeConsecutiveToolBlocks(blocks) -} - -func mergeToolResult(call, result chatBlock) chatBlock { - if call.toolName != "" { - result.toolName = call.toolName - } - result.kind = blockToolResult - result.toolID = call.toolID - result.args = call.args - return result -} - -func mergeConsecutiveToolBlocks(blocks []chatBlock) []chatBlock { - if len(blocks) < 2 { - return blocks - } - - merged := make([]chatBlock, 0, len(blocks)) - for i := 0; i < len(blocks); i++ { - block := blocks[i] - if i+1 < len(blocks) { - next := blocks[i+1] - if block.kind == blockToolCall && next.kind == blockToolResult { - switch { - case block.toolID != "" && block.toolID == next.toolID: - merged = append(merged, mergeToolResult(block, next)) - i++ - continue - case block.toolID == "" && next.toolID == "" && block.toolName == next.toolName: - merged = append(merged, mergeToolResult(block, next)) - i++ - continue - } - } - } - merged = append(merged, block) - } - return merged -} - -//nolint:revive // Signature keeps block expansion state explicit at the callsite. -func renderBlock(styles tuiStyles, block chatBlock, expanded bool, width int, renderers ...*glamour.TermRenderer) string { - var renderer *glamour.TermRenderer - if len(renderers) > 0 { - renderer = renderers[0] - } - switch block.kind { - case blockText: - switch block.role { - case codersdk.ChatMessageRoleUser: - return renderPrefixedBlock(styles.userMessage.Render("You: "), block.text, width) - case codersdk.ChatMessageRoleAssistant: - return renderAssistantMarkdown(styles, block.text, width, renderer) - case codersdk.ChatMessageRoleTool: - return styles.dimmedText.Render(wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width)) - default: - return wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width) - } - case blockReasoning: - content := wrapPreservingNewlines(reasoningPrefix+sanitizeTerminalRenderableText(block.text), width) - if !expanded { - content = clampLines(content, 3) - } - return styles.reasoning.Render(content) - case blockToolCall: - if !expanded { - return renderToolCallBlock(styles, block, width) - } - return renderExpandedToolBlock(styles, styles.toolPending, pendingToolIcon, block.toolName, block.args, "", width) - case blockToolResult: - if !expanded { - return renderToolResultBlock(styles, block, width) - } - icon := "✓" - labelStyle := styles.toolSuccess - if block.isError { - icon = "✗" - labelStyle = styles.errorText - } - result := block.result - if strings.TrimSpace(result) == "" { - result = "null" - } - return renderExpandedToolBlock(styles, labelStyle, icon, block.toolName, block.args, result, width) - case blockCompaction: - return renderCompaction(styles, width) - default: - return "" - } -} - -var ( - fallbackMarkdownRenderers sync.Map - markdownRendererMu sync.Mutex -) - -func getFallbackMarkdownRenderer(width int) *glamour.TermRenderer { - wrapWidth := contentWidth(width, 0) - if cachedRenderer, ok := fallbackMarkdownRenderers.Load(wrapWidth); ok { - renderer, ok := cachedRenderer.(*glamour.TermRenderer) - if ok { - return renderer - } - } - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), - glamour.WithWordWrap(wrapWidth), - ) - if err != nil { - return nil - } - cachedRenderer, _ := fallbackMarkdownRenderers.LoadOrStore(wrapWidth, renderer) - storedRenderer, ok := cachedRenderer.(*glamour.TermRenderer) - if !ok { - return nil - } - return storedRenderer -} - -func renderAssistantMarkdown(styles tuiStyles, text string, width int, renderers ...*glamour.TermRenderer) string { - text = sanitizeTerminalRenderableText(text) - var renderer *glamour.TermRenderer - if len(renderers) > 0 { - renderer = renderers[0] - } - if renderer == nil { - renderer = getFallbackMarkdownRenderer(width) - } - if renderer != nil { - markdownRendererMu.Lock() - rendered, err := renderer.Render(text) - markdownRendererMu.Unlock() - if err == nil { - trimmedRendered := strings.TrimRight(rendered, "\n") - if strings.TrimSpace(trimmedRendered) != "" || strings.TrimSpace(text) == "" { - return styles.assistantMsg.Render(trimmedRendered) - } - } - } - return styles.assistantMsg.Render(wrapPreservingNewlines(text, width)) -} - -func renderPrefixedBlock(prefix, body string, width int) string { - body = sanitizeTerminalRenderableText(body) - if strings.TrimSpace(body) == "" { - return prefix - } - prefixWidth := lipgloss.Width(prefix) - available := width - prefixWidth - if available <= 0 { - available = width - } - wrapped := wrapPreservingNewlines(body, available) - lines := strings.Split(wrapped, "\n") - if len(lines) == 0 { - return prefix - } - for i := 1; i < len(lines); i++ { - lines[i] = strings.Repeat(" ", max(prefixWidth, 0)) + lines[i] - } - return prefix + strings.Join(lines, "\n") -} - -func wrapPreservingNewlines(text string, width int) string { - if width <= 0 { - return text - } - style := lipgloss.NewStyle().Width(width) - segments := strings.Split(text, "\n") - for i, segment := range segments { - segments[i] = strings.TrimRight(style.Render(segment), " ") - } - return strings.Join(segments, "\n") -} - -func clampLines(text string, maxLines int) string { - return strings.Join(clampLineSlice(strings.Split(text, "\n"), maxLines), "\n") -} - -func clampLineSlice(lines []string, maxLines int) []string { - if maxLines <= 0 { - return nil - } - if len(lines) <= maxLines { - return lines - } - clamped := append([]string(nil), lines[:maxLines]...) - clamped[maxLines-1] = stylesafeEllipsis(clamped[maxLines-1]) - return clamped -} - -func stylesafeEllipsis(line string) string { - trimmed := strings.TrimRight(line, " ") - if trimmed == "" { - return "…" - } - return trimmed + "…" -} - -func countRenderedLines(text string) int { - if text == "" { - return 0 - } - return strings.Count(text, "\n") + 1 -} diff --git a/cli/agents_render_test.go b/cli/agents_render_test.go deleted file mode 100644 index 180dfc4599..0000000000 --- a/cli/agents_render_test.go +++ /dev/null @@ -1,1138 +0,0 @@ -package cli //nolint:testpackage // Tests unexported chat TUI render helpers. - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "testing" - "unicode/utf8" - - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/codersdk" -) - -var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -func TestAgentsRender(t *testing.T) { - t.Parallel() - - styles := newTUIStyles() - - t.Run("MessagesToBlocks", func(t *testing.T) { - t.Parallel() - - user, assistant, tool := codersdk.ChatMessageRoleUser, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageRoleTool - msg := func(role codersdk.ChatMessageRole, parts ...codersdk.ChatMessagePart) codersdk.ChatMessage { - return codersdk.ChatMessage{Role: role, Content: parts} - } - text := func(body string) codersdk.ChatMessagePart { - return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: body} - } - reasoning := func(body string) codersdk.ChatMessagePart { - return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeReasoning, Text: body} - } - call := func(name, id, args string) codersdk.ChatMessagePart { - return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, ToolName: name, ToolCallID: id, Args: rawJSON(args)} - } - result := func(name, id, body string, isError bool) codersdk.ChatMessagePart { - return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolResult, ToolName: name, ToolCallID: id, Result: rawJSON(body), IsError: isError} - } - - tests := []struct { - name string - in []codersdk.ChatMessage - want []chatBlock - }{ - {name: "EmptyMessages", want: []chatBlock{}}, - {name: "UserText", in: []codersdk.ChatMessage{msg(user, text("hello"))}, want: []chatBlock{{kind: blockText, role: user, text: "hello"}}}, - {name: "AssistantText", in: []codersdk.ChatMessage{msg(assistant, text("hi there"))}, want: []chatBlock{{kind: blockText, role: assistant, text: "hi there"}}}, - {name: "ToolCallPart", in: []codersdk.ChatMessage{msg(assistant, call("weather", "call-1", `{"city":"SF"}`))}, want: []chatBlock{{kind: blockToolCall, role: assistant, toolName: "weather", toolID: "call-1", args: `{"city":"SF"}`}}}, - {name: "ToolResultPart", in: []codersdk.ChatMessage{msg(tool, result("weather", "call-1", `{"temp":"68F"}`, true))}, want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "weather", toolID: "call-1", result: `{"temp":"68F"}`, isError: true}}}, - { - name: "MultipleMessagesInOrder", - in: []codersdk.ChatMessage{ - msg(user, text("question")), - msg(assistant, reasoning("thinking"), call("search", "call-3", `{"q":"docs"}`), text("answer")), - }, - want: []chatBlock{ - {kind: blockText, role: user, text: "question"}, - {kind: blockReasoning, role: assistant, text: "thinking"}, - {kind: blockToolCall, role: assistant, toolName: "search", toolID: "call-3", args: `{"q":"docs"}`}, - {kind: blockText, role: assistant, text: "answer"}, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tt.want, messagesToBlocks(tt.in)) - }) - } - - t.Run("KeepsToolCallsAndLaterResultsSeparateByToolID", func(t *testing.T) { - t.Parallel() - - blocks := messagesToBlocks([]codersdk.ChatMessage{ - msg(assistant, - call("github__get_pull_request", "call-1", `{"owner":"openclaw","repo":"openclaw","pull_number":58036}`), - call("github__get_pull_request", "call-2", `{"owner":"openclaw","repo":"openclaw","pull_number":58037}`), - ), - msg(tool, - result("github__get_pull_request", "call-1", `{"base":{"ref":"main"}}`, false), - result("github__get_pull_request", "call-2", `{"base":{"ref":"main"}}`, false), - ), - }) - - require.Len(t, blocks, 4) - require.Equal(t, - []chatBlockKind{blockToolCall, blockToolCall, blockToolResult, blockToolResult}, - []chatBlockKind{blocks[0].kind, blocks[1].kind, blocks[2].kind, blocks[3].kind}, - ) - require.Equal(t, []string{"call-1", "call-2", "call-1", "call-2"}, []string{blocks[0].toolID, blocks[1].toolID, blocks[2].toolID, blocks[3].toolID}) - }) - }) - - t.Run("MergeConsecutiveToolBlocks", func(t *testing.T) { - t.Parallel() - - assistant, tool := codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageRoleTool - call := func(name, id, args string) chatBlock { - return chatBlock{kind: blockToolCall, role: assistant, toolName: name, toolID: id, args: args} - } - result := func(name, id, body string) chatBlock { - return chatBlock{kind: blockToolResult, role: tool, toolName: name, toolID: id, result: body} - } - - for _, tt := range []struct { - name string - in []chatBlock - want []chatBlock - }{ - { - name: "MergesAdjacentEmptyToolIDCallAndResult", - in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("read_file", "", `{"content":"hello"}`)}, - want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "", args: `{"path":"main.go"}`, result: `{"content":"hello"}`}}, - }, - { - name: "ExistingToolIDMergeStillWorks", - in: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), result("read_file", "call-1", `{"content":"hello"}`)}, - want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "call-1", args: `{"path":"main.go"}`, result: `{"content":"hello"}`}}, - }, - { - name: "MultiplePairs", - in: []chatBlock{ - call("read_file", "call-1", `{"path":"one.txt"}`), - result("read_file", "call-1", `{"ok":true}`), - call("list_dir", "call-2", `{"path":"/tmp"}`), - result("list_dir", "call-2", `{"entries":[]}`), - }, - want: []chatBlock{ - {kind: blockToolResult, role: tool, toolName: "read_file", toolID: "call-1", args: `{"path":"one.txt"}`, result: `{"ok":true}`}, - {kind: blockToolResult, role: tool, toolName: "list_dir", toolID: "call-2", args: `{"path":"/tmp"}`, result: `{"entries":[]}`}, - }, - }, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := mergeConsecutiveToolBlocks(tt.in) - require.Equal(t, tt.want, got) - }) - } - - t.Run("NegativeMergeCases", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - in []chatBlock - want []chatBlock - }{ - { - name: "DifferentToolNames", - in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("list_dir", "", `{"entries":[]}`)}, - want: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("list_dir", "", `{"entries":[]}`)}, - }, - { - name: "NonAdjacentEmptyToolID", - in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "", `{"content":"hello"}`)}, - want: []chatBlock{call("read_file", "", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "", `{"content":"hello"}`)}, - }, - { - name: "NonAdjacentMatchingToolID", - in: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "call-1", `{"content":"hello"}`)}, - want: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "call-1", `{"content":"hello"}`)}, - }, - { - name: "OrphanedCall", - in: []chatBlock{call("read_file", "call-orphan", `{"path":"solo.txt"}`)}, - want: []chatBlock{call("read_file", "call-orphan", `{"path":"solo.txt"}`)}, - }, - { - name: "OrphanedResult", - in: []chatBlock{result("read_file", "call-orphan", `{"content":"hello"}`)}, - want: []chatBlock{result("read_file", "call-orphan", `{"content":"hello"}`)}, - }, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := mergeConsecutiveToolBlocks(tt.in) - require.Equal(t, tt.want, got) - }) - } - }) - }) - - t.Run("ToolArgsSummary", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - toolName string - args string - assert func(t *testing.T, summary string) - }{ - {name: "CreateWorkspaceUsesNameField", toolName: "coder_create_workspace", args: `{"name":"my-workspace"}`, assert: func(t *testing.T, summary string) { require.Equal(t, "(my-workspace)", summary) }}, - {name: "CreateWorkspaceUsesWorkspaceNameField", toolName: "coder_create_workspace", args: `{"workspace_name":"my-ws","template":"docker"}`, assert: func(t *testing.T, summary string) { require.Equal(t, "(my-ws)", summary) }}, - {name: "WithUnicodeTruncatesOnRuneBoundary", toolName: "weather", args: strings.Repeat("こんにちは世界", 10), assert: func(t *testing.T, summary string) { - require.NotEmpty(t, summary) - require.True(t, utf8.ValidString(summary)) - require.True(t, strings.HasSuffix(summary, "…")) - require.LessOrEqual(t, len([]rune(summary)), toolSummaryFallbackWidth) - require.Contains(t, summary, "こんにちは") - }}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - tt.assert(t, toolArgsSummary(tt.toolName, tt.args)) - }) - } - require.Equal(t, "(created-ws)", toolResultSummary("coder_create_workspace", "", `{"workspace_name":"created-ws"}`)) - }) - t.Run("RenderToolCall", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - part codersdk.ChatMessagePart - width int - assert func(t *testing.T, output string) - }{ - {name: "ShowsHumanizedToolNameAndContext", part: codersdk.ChatMessagePart{ToolName: "github__get_pull_request", Args: rawJSON(`{"owner":"openclaw","repo":"openclaw","pull_number":58036}`)}, width: 60, assert: func(t *testing.T, output string) { - require.Contains(t, output, " ○ get pull request") - require.Contains(t, output, "(openclaw/openclaw)") - }}, - {name: "ShowsTruncatedCommandPreview", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Args: rawJSON(`{"command":"ls -la /tmp/with/a/very/long/path"}`)}, width: 30, assert: func(t *testing.T, output string) { - require.Contains(t, output, "○ execute command") - require.Contains(t, output, `"ls -la`) - require.Contains(t, output, "…") - }}, - {name: "ContextCompactionRendersBanner", part: codersdk.ChatMessagePart{ToolName: contextCompactionToolName}, width: 40, assert: func(t *testing.T, output string) { - require.Contains(t, output, "🗜️ Context compacted") - require.NotContains(t, output, pendingToolIcon) - }}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var output string - require.NotPanics(t, func() { - output = plainText(renderToolCallBlock(styles, chatBlock{ - kind: blockToolCall, - toolName: tt.part.ToolName, - args: compactTranscriptJSON(tt.part.Args), - }, tt.width)) - }) - tt.assert(t, output) - }) - } - }) - t.Run("RenderToolResult", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - part codersdk.ChatMessagePart - width int - assert func(t *testing.T, rawOutput, plainOutput string) - }{ - {name: "SuccessShowsCheckPrefixAndArgsContext", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Args: rawJSON(`{"command":"ls -la"}`), Result: rawJSON(`{"ok":true}`)}, width: 40, assert: func(t *testing.T, _, output string) { - require.Contains(t, output, "✓ execute command") - require.Contains(t, output, `"ls -la"`) - }}, - {name: "ErrorShowsErrorStyleAndMessage", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Result: rawJSON(`{"error":"command not found"}`), IsError: true}, width: 40, assert: func(t *testing.T, rawOutput, plainOutput string) { - require.Contains(t, rawOutput, styles.errorText.Render("✗ execute command")) - require.Contains(t, plainOutput, `"command not found"`) - }}, - {name: "MergedCreateWorkspaceResultKeepsArgsSummary", part: codersdk.ChatMessagePart{ToolName: "coder_create_workspace", ToolCallID: "call-create-workspace", Args: rawJSON(`{"name":"merged-workspace"}`), Result: rawJSON(`{"workspace_name":"merged-workspace","status":"created"}`)}, width: 60, assert: func(t *testing.T, _, output string) { - require.Contains(t, output, "✓ create workspace") - require.Contains(t, output, "(merged-workspace)") - }}, - {name: "ContextCompactionRendersBanner", part: codersdk.ChatMessagePart{ToolName: contextCompactionToolName}, width: 40, assert: func(t *testing.T, _, output string) { - require.Contains(t, output, "🗜️ Context compacted") - require.NotContains(t, output, "✓") - }}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var rawOutput string - require.NotPanics(t, func() { - rawOutput = renderToolResultBlock(styles, chatBlock{ - kind: blockToolResult, - toolName: tt.part.ToolName, - args: compactTranscriptJSON(tt.part.Args), - result: compactTranscriptJSON(tt.part.Result), - isError: tt.part.IsError, - }, tt.width) - }) - tt.assert(t, rawOutput, plainText(rawOutput)) - }) - } - }) - t.Run("RenderCompaction", func(t *testing.T) { - t.Parallel() - - output := plainText(renderCompaction(styles, 20)) - require.Contains(t, output, "🗜️ Context compacted") - }) - t.Run("RenderStatusBar", func(t *testing.T) { - t.Parallel() - - u := func(total, limit int64) *codersdk.ChatMessageUsage { - return &codersdk.ChatMessageUsage{TotalTokens: int64Ptr(total), ContextLimit: int64Ptr(limit)} - } - - for _, tt := range []struct { - name string - status codersdk.ChatStatus - usage *codersdk.ChatMessageUsage - queue int - interrupting, reconnecting bool - width, maxWidth int - wantRaw string - wantPlain, avoidPlain []string - }{ - {name: "RunningOmitsUsageWhenNil", status: codersdk.ChatStatusRunning, width: 80, avoidPlain: []string{"tokens:"}}, - {name: "RunningShowsTokenUsage", status: codersdk.ChatStatusRunning, usage: u(50, 100), width: 80, wantPlain: []string{"tokens: 50/100"}}, - {name: "RunningWarnsAndShowsTransientStates", status: codersdk.ChatStatusRunning, usage: u(81, 100), interrupting: true, reconnecting: true, width: 80, wantRaw: styles.warningText.Render("tokens: 81/100"), wantPlain: []string{"interrupting…", "reconnecting…"}}, - {name: "RunningShowsCriticalUsage", status: codersdk.ChatStatusRunning, usage: u(96, 100), width: 80, wantRaw: styles.criticalText.Render("tokens: 96/100")}, - {name: "PendingShowsQueue", status: codersdk.ChatStatusPending, queue: 2, width: 80, wantPlain: []string{"queued: 2"}}, - {name: "NarrowWidthFits", status: codersdk.ChatStatusRunning, usage: u(96, 100), queue: 2, interrupting: true, reconnecting: true, width: 20, maxWidth: 20}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var output string - require.NotPanics(t, func() { - output = renderStatusBar(styles, nil, tt.status, tt.usage, tt.queue, tt.interrupting, tt.reconnecting, tt.width) - }) - plain := plainText(output) - require.Contains(t, output, styles.statusColor(tt.status).Render(string(tt.status))) - if tt.wantRaw != "" { - require.Contains(t, output, tt.wantRaw) - } - for _, want := range tt.wantPlain { - require.Contains(t, plain, want) - } - for _, avoid := range tt.avoidPlain { - require.NotContains(t, plain, avoid) - } - if tt.maxWidth > 0 { - require.NotEmpty(t, plain) - require.LessOrEqual(t, lipgloss.Width(plain), tt.maxWidth) - require.LessOrEqual(t, lipgloss.Width(output), tt.width) - } - }) - } - }) - t.Run("RenderBlock", func(t *testing.T) { - t.Parallel() - - renderOutput := func(block chatBlock, expanded, plain bool, width int) string { - output := renderBlock(styles, block, expanded, width) - if plain { - return plainText(output) - } - return output - } - assertOutput := func(t *testing.T, output string, want, avoid []string, lines int, lastLine string) { - t.Helper() - for _, s := range want { - require.Contains(t, output, s) - } - for _, s := range avoid { - require.NotContains(t, output, s) - } - if lines > 0 { - split := strings.Split(output, "\n") - require.Len(t, split, lines) - if lastLine != "" { - require.Equal(t, lastLine, strings.TrimRight(split[len(split)-1], " ")) - } - } - } - - for _, tt := range []struct { - name string - block chatBlock - want []string - avoid []string - }{ - {name: "UserIncludesYouPrefix", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleUser, text: "hello"}, want: []string{"You: hello"}}, - {name: "AssistantRendersMarkdown", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "- first\n- second"}, want: []string{"• first", "• second"}, avoid: []string{"- first"}}, - {name: "ToolRendersDimmed", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleTool, text: "tool output"}, want: []string{styles.dimmedText.Render("tool output")}}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assertOutput(t, renderOutput(tt.block, false, tt.block.role != codersdk.ChatMessageRoleTool, 40), tt.want, tt.avoid, 0, "") - }) - } - - for _, tt := range []struct { - name string - block chatBlock - width int - collapsedWant []string - collapsedAvoid []string - collapsedLines int - collapsedLastLine string - expandedWant []string - expandedAvoid []string - expandedLines int - expandedLastLine string - }{ - { - name: "Reasoning", - block: chatBlock{kind: blockReasoning, role: codersdk.ChatMessageRoleAssistant, text: "line1\nline2\nline3\nline4"}, - width: 40, - collapsedWant: []string{"thinking: line1"}, - collapsedLines: 3, - collapsedLastLine: "line3…", - expandedWant: []string{"line4"}, - expandedAvoid: []string{"line4…"}, - expandedLines: 4, - }, - { - name: "ToolCall", - block: chatBlock{kind: blockToolCall, toolName: "read_file", args: `{"path":"very/long/path.txt","recursive":true}`}, - width: 60, - collapsedWant: []string{"○ read file", "(very/long/path.txt)"}, - collapsedAvoid: []string{"\n", "args:"}, - expandedWant: []string{"○ read file", "args:", `{"path":"very/long/path.txt","recursive":true}`, "\n"}, - }, - { - name: "ToolResult", - block: chatBlock{kind: blockToolResult, toolName: "read_file", args: `{"path":"a.txt"}`, result: `{"path":"a.txt","contents":"hello"}`}, - width: 60, - collapsedWant: []string{"✓ read file", "(a.txt)"}, - collapsedAvoid: []string{"\n", "result:"}, - expandedWant: []string{"✓ read file", "args:", "result:", `{"path":"a.txt","contents":"hello"}`, "\n"}, - }, - { - name: "CollapsedToolCallShowsRunCount", - block: chatBlock{kind: blockToolCall, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw"}`, collapsedCount: 3}, - width: 80, - collapsedWant: []string{"○ get pull request..."}, - }, - { - name: "CollapsedToolResultShowsRunCount", - block: chatBlock{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw"}`, result: `{"ok":true}`, collapsedCount: 10}, - width: 80, - collapsedWant: []string{"✓ get pull request (x10)"}, - }, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - collapsed := renderOutput(tt.block, false, true, tt.width) - assertOutput(t, collapsed, tt.collapsedWant, tt.collapsedAvoid, tt.collapsedLines, tt.collapsedLastLine) - if len(tt.expandedWant)+len(tt.expandedAvoid)+tt.expandedLines > 0 || tt.expandedLastLine != "" { - expanded := renderOutput(tt.block, true, true, tt.width) - assertOutput(t, expanded, tt.expandedWant, tt.expandedAvoid, tt.expandedLines, tt.expandedLastLine) - } - }) - } - - t.Run("CompactionRendersBanner", func(t *testing.T) { - t.Parallel() - - output := plainText(renderBlock(styles, chatBlock{kind: blockCompaction}, false, 40)) - require.Contains(t, output, "🗜️ Context compacted") - }) - }) - - t.Run("RenderChatBlocks", func(t *testing.T) { - t.Parallel() - - t.Run("MixedMessagesRenderInOrder", func(t *testing.T) { - t.Parallel() - - blocks := []chatBlock{ - {kind: blockText, role: codersdk.ChatMessageRoleUser, text: "hello"}, - {kind: blockReasoning, role: codersdk.ChatMessageRoleAssistant, text: "thinking"}, - {kind: blockToolResult, toolName: "read_file", args: `{"path":"a.txt"}`, result: `{"path":"a.txt","contents":"hello"}`}, - {kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "done"}, - } - - output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 60)) - require.Contains(t, output, "You: hello") - require.Contains(t, output, "thinking: thinking") - require.Contains(t, output, "✓ read file") - require.Contains(t, output, "done") - require.Less(t, strings.Index(output, "You: hello"), strings.Index(output, "thinking: thinking")) - require.Less(t, strings.Index(output, "thinking: thinking"), strings.Index(output, "✓ read file")) - require.Less(t, strings.Index(output, "✓ read file"), strings.LastIndex(output, "done")) - }) - - t.Run("SelectedBlockUsesLeftBorderIndicator", func(t *testing.T) { - t.Parallel() - - blocks := []chatBlock{{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "assistant reply"}} - - output := plainText(renderChatBlocks(styles, blocks, 0, map[int]bool{}, false, 60)) - require.Contains(t, output, "│ assistant reply") - }) - - t.Run("CollapsesConsecutiveSameNameToolResults", func(t *testing.T) { - t.Parallel() - - blocks := []chatBlock{ - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "create_file", args: `{"path":"main.go"}`, result: `{"ok":true}`}, - } - - output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 80)) - require.Equal(t, 2, strings.Count(output, "✓")) - require.Contains(t, output, "get pull request (x3)") - require.Contains(t, output, "create file") - }) - - t.Run("DoesNotCollapseDifferentToolResults", func(t *testing.T) { - t.Parallel() - - blocks := []chatBlock{ - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":2}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":3}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "create_file", args: `{"path":"main.go"}`, result: `{"ok":true}`}, - } - - output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 80)) - require.Equal(t, 4, strings.Count(output, "✓")) - require.NotContains(t, output, "get pull request (x3)") - require.Contains(t, output, "create file") - }) - - t.Run("ExpandedToolBlockPreventsCollapse", func(t *testing.T) { - t.Parallel() - - blocks := []chatBlock{ - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, - {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, - } - - output := plainText(renderChatBlocks(styles, blocks, 1, map[int]bool{1: true}, false, 80)) - require.Equal(t, 2, strings.Count(output, "✓")) - require.NotContains(t, output, "(x2)") - require.Contains(t, output, "result:") - }) - }) - t.Run("RenderDiffDrawer", func(t *testing.T) { - t.Parallel() - - branch := "feature/chat-ui" - prURL := "https://example.com/pulls/123" - for _, tt := range []struct { - name string - diff codersdk.ChatDiffContents - assert func(t *testing.T, output string) - }{ - {name: "ShowsMetadataWhenPresent", diff: codersdk.ChatDiffContents{Branch: &branch, PullRequestURL: &prURL}, assert: func(t *testing.T, output string) { - require.Contains(t, output, "Branch: feature/chat-ui") - require.Contains(t, output, "PR: https://example.com/pulls/123") - }}, - {name: "ShowsDiffContent", diff: codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n+added line"}, assert: func(t *testing.T, output string) { - require.Contains(t, output, "1 file changed:") - require.Contains(t, output, "modified a.txt (+1)") - require.Contains(t, output, "diff --git a/a.txt b/a.txt") - require.Contains(t, output, "+added line") - }}, - {name: "ShowsPlaceholderForEmptyDiff", assert: func(t *testing.T, output string) { - require.Contains(t, output, "No diff contents.") - require.Contains(t, output, "No changes detected.") - }}, - {name: "ShowsFallbackForUnparsableNonEmptyDiff", diff: codersdk.ChatDiffContents{Diff: "Total diff too large to show. Size: 12MB. Showing branch and remote only."}, assert: func(t *testing.T, output string) { - // When agent/agentgit substitutes a placeholder for - // an oversized diff, the text is non-empty but not in - // `diff --git` format. renderChatDiffSummary should - // report "Changes present but could not be summarized." - // instead of claiming no changes were detected. - require.Contains(t, output, "Changes present but could not be summarized.") - require.NotContains(t, output, "No changes detected.") - }}, - {name: "FlagsPartiallyUnparsableMultiRepoDiff", diff: codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n+added line\nTotal diff too large to show. Size: 12 MiB. Showing branch and remote only."}, assert: func(t *testing.T, output string) { - // Multi-repo aggregates can legitimately interleave - // real `diff --git` chunks from small repos with - // agent/agentgit's oversize placeholder for repos - // whose UnifiedDiff exceeded maxTotalDiffSize. - // renderChatDiffSummary must both count the real - // chunks and flag the omitted oversized repo, so - // the user is not misled into thinking the files - // listed are the whole changeset. - require.Contains(t, output, "1 file changed:") - require.Contains(t, output, "modified a.txt") - require.Contains(t, output, "some repositories omitted") - }}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var output string - require.NotPanics(t, func() { - output = plainText(renderDiffDrawer(styles, tt.diff, renderChatDiffSummary(tt.diff), "", 90, 20)) - }) - tt.assert(t, output) - }) - } - }) - t.Run("ParseChatGitChangesFromUnifiedDiff", func(t *testing.T) { - t.Parallel() - - diff := strings.Join([]string{ - "diff --git a/a.txt b/a.txt", - "--- a/a.txt", - "+++ b/a.txt", - "@@ -1 +1 @@", - "-old", - "+new", - "diff --git a/new.txt b/new.txt", - "new file mode 100644", - "--- /dev/null", - "+++ b/new.txt", - "@@ -0,0 +1 @@", - "+hello", - "diff --git a/old.txt b/old.txt", - "deleted file mode 100644", - "--- a/old.txt", - "+++ /dev/null", - "@@ -1 +0,0 @@", - "-bye", - "diff --git a/old-name.txt b/new-name.txt", - "similarity index 100%", - "rename from old-name.txt", - "rename to new-name.txt", - }, "\n") - - changes := parseChatGitChangesFromUnifiedDiff(codersdk.ChatDiffContents{Diff: diff}) - require.Len(t, changes, 4) - require.Equal(t, "a.txt", changes[0].FilePath) - require.Equal(t, "modified", changes[0].ChangeType) - require.NotNil(t, changes[0].DiffSummary) - require.Equal(t, "+1 -1", *changes[0].DiffSummary) - require.Equal(t, "new.txt", changes[1].FilePath) - require.Equal(t, "added", changes[1].ChangeType) - require.NotNil(t, changes[1].DiffSummary) - require.Equal(t, "+1", *changes[1].DiffSummary) - require.Equal(t, "old.txt", changes[2].FilePath) - require.Equal(t, "deleted", changes[2].ChangeType) - require.NotNil(t, changes[2].DiffSummary) - require.Equal(t, "-1", *changes[2].DiffSummary) - require.Equal(t, "new-name.txt", changes[3].FilePath) - require.Equal(t, "renamed", changes[3].ChangeType) - require.NotNil(t, changes[3].OldPath) - require.Equal(t, "old-name.txt", *changes[3].OldPath) - require.Nil(t, changes[3].DiffSummary) - }) - - t.Run("ParseChatGitChangesFromUnifiedDiffPathsWithSpaces", func(t *testing.T) { - t.Parallel() - - // Git does not quote paths that only contain spaces, so the - // `diff --git` header is ambiguous without help from the body. - // Verify that modifications, binary or mode-only diffs, and - // renames all resolve to the correct paths and change types. - diff := strings.Join([]string{ - "diff --git a/foo bar.txt b/foo bar.txt", - "--- a/foo bar.txt", - "+++ b/foo bar.txt", - "@@ -1 +1 @@", - "-old", - "+new", - "diff --git a/foo bar.bin b/foo bar.bin", - "index 0f49c4a..9100462 100644", - "Binary files a/foo bar.bin and b/foo bar.bin differ", - "diff --git a/new empty.txt b/new empty.txt", - "new file mode 100644", - "index 0000000..e69de29", - "diff --git a/old name.txt b/new name.txt", - "similarity index 100%", - "rename from old name.txt", - "rename to new name.txt", - }, "\n") - - changes := parseChatGitChangesFromUnifiedDiff(codersdk.ChatDiffContents{Diff: diff}) - require.Len(t, changes, 4) - - // The buggy parser used to split the unquoted header on any - // whitespace, producing truncated paths and marking simple edits - // as renames. Verify that each change now reports the full path - // and the correct change type. - require.Equal(t, "foo bar.txt", changes[0].FilePath) - require.Equal(t, "modified", changes[0].ChangeType) - - require.Equal(t, "foo bar.bin", changes[1].FilePath) - require.Equal(t, "modified", changes[1].ChangeType) - - require.Equal(t, "new empty.txt", changes[2].FilePath) - require.Equal(t, "added", changes[2].ChangeType) - - require.Equal(t, "new name.txt", changes[3].FilePath) - require.Equal(t, "renamed", changes[3].ChangeType) - require.NotNil(t, changes[3].OldPath) - require.Equal(t, "old name.txt", *changes[3].OldPath) - }) - - t.Run("ParseChatGitChangesFromUnifiedDiffQuotedPaths", func(t *testing.T) { - t.Parallel() - - // Git C-quotes paths when they contain bytes above 0x7f (with - // the default core.quotepath setting) or control characters. - diff := strings.Join([]string{ - `diff --git "a/f\303\266\303\266bar.txt" "b/f\303\266\303\266bar.txt"`, - `--- "a/f\303\266\303\266bar.txt"`, - `+++ "b/f\303\266\303\266bar.txt"`, - "@@ -1 +1 @@", - "-old", - "+new", - }, "\n") - - changes := parseChatGitChangesFromUnifiedDiff(codersdk.ChatDiffContents{Diff: diff}) - require.Len(t, changes, 1) - require.Equal(t, "fööbar.txt", changes[0].FilePath) - require.Equal(t, "modified", changes[0].ChangeType) - }) - - t.Run("ParseChatGitChangesFromUnifiedDiffQuotedRename", func(t *testing.T) { - t.Parallel() - - // Git C-quotes `rename from`/`rename to` paths when they contain - // non-ASCII bytes (like `ä`). The parser should decode them so - // the diff summary shows a readable file name rather than the - // raw quoted octal escape. - diff := strings.Join([]string{ - `diff --git "a/b\303\244r old.txt" "b/b\303\244r new.txt"`, - "similarity index 100%", - `rename from "b\303\244r old.txt"`, - `rename to "b\303\244r new.txt"`, - }, "\n") - - changes := parseChatGitChangesFromUnifiedDiff(codersdk.ChatDiffContents{Diff: diff}) - require.Len(t, changes, 1) - require.Equal(t, "renamed", changes[0].ChangeType) - require.Equal(t, "bär new.txt", changes[0].FilePath) - require.NotNil(t, changes[0].OldPath) - require.Equal(t, "bär old.txt", *changes[0].OldPath) - }) - - t.Run("ParseChatGitChangesFromUnifiedDiffRenameWithLiteralAPrefix", func(t *testing.T) { - t.Parallel() - - // rename from/rename to paths are repository-relative and never - // carry the a/ or b/ prefix, so real directories named a/ must - // survive parsing intact. - diff := strings.Join([]string{ - "diff --git a/a/foo.txt b/a/bar.txt", - "similarity index 100%", - "rename from a/foo.txt", - "rename to a/bar.txt", - }, "\n") - - changes := parseChatGitChangesFromUnifiedDiff(codersdk.ChatDiffContents{Diff: diff}) - require.Len(t, changes, 1) - require.Equal(t, "renamed", changes[0].ChangeType) - require.Equal(t, "a/bar.txt", changes[0].FilePath) - require.NotNil(t, changes[0].OldPath) - require.Equal(t, "a/foo.txt", *changes[0].OldPath) - }) - - t.Run("ParseChatGitChangesFromUnifiedDiffIgnoresHunkContentLookalikes", func(t *testing.T) { - t.Parallel() - - // Added/removed diff lines can legitimately start with `+++ ` or - // `--- ` (the content happens to begin with `++ ` or `-- `). The - // parser must treat those as content after the first `@@` hunk - // header instead of overwriting the already-resolved FilePath - // and change counts. - diff := strings.Join([]string{ - "diff --git a/a.txt b/a.txt", - "--- a/a.txt", - "+++ b/a.txt", - "@@ -1,2 +1,2 @@", - "--- not a header", - "+++ also not a header", - "-left", - "+right", - }, "\n") - - changes := parseChatGitChangesFromUnifiedDiff(codersdk.ChatDiffContents{Diff: diff}) - require.Len(t, changes, 1) - require.Equal(t, "a.txt", changes[0].FilePath) - require.Equal(t, "modified", changes[0].ChangeType) - require.NotNil(t, changes[0].DiffSummary) - // Inside the hunk, both "--- not a header" and "-left" are - // deletion lines, and both "+++ also not a header" and "+right" - // are addition lines. The header "--- a/a.txt" and "+++ b/a.txt" - // lines before @@ are not counted. - require.Equal(t, "+2 -2", *changes[0].DiffSummary) - }) - - t.Run("ParseUnifiedDiffHeaderPaths", func(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - name string - line string - oldPath string - newPath string - ok bool - }{ - { - name: "Simple", - line: "diff --git a/foo.txt b/foo.txt", - oldPath: "foo.txt", newPath: "foo.txt", ok: true, - }, - { - name: "Rename", - line: "diff --git a/old.txt b/new.txt", - oldPath: "old.txt", newPath: "new.txt", ok: true, - }, - { - name: "SpacesNonRename", - line: "diff --git a/foo bar.txt b/foo bar.txt", - oldPath: "foo bar.txt", newPath: "foo bar.txt", ok: true, - }, - { - name: "SpacesRenameIsAmbiguous", - line: "diff --git a/old name.txt b/new name.txt", - ok: false, - }, - { - name: "QuotedTabEscape", - line: `diff --git "a/a\tb.txt" "b/a\tb.txt"`, - oldPath: "a\tb.txt", newPath: "a\tb.txt", ok: true, - }, - { - name: "NestedBPrefix", - line: "diff --git a/b/foo.txt b/b/foo.txt", - oldPath: "b/foo.txt", newPath: "b/foo.txt", ok: true, - }, - { - name: "Empty", - line: "diff --git ", - ok: false, - }, - { - name: "MissingAPrefix", - line: "diff --git foo.txt bar.txt", - ok: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - gotOld, gotNew, gotOK := parseUnifiedDiffHeaderPaths(tc.line) - require.Equal(t, tc.ok, gotOK) - if gotOK { - require.Equal(t, tc.oldPath, gotOld) - require.Equal(t, tc.newPath, gotNew) - } - }) - } - }) - - t.Run("RenderDiffDrawerSanitizesUntrustedContent", func(t *testing.T) { - t.Parallel() - - diff := codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n+safe\x1b]52;c;clipboard\x07line"} - rawOutput := renderDiffDrawer(styles, diff, renderChatDiffSummary(diff), "", 90, 20) - output := plainText(rawOutput) - - require.Contains(t, output, "diff --git a/a.txt b/a.txt") - require.Contains(t, output, "+safeline") - require.Contains(t, output, "modified a.txt") - require.NotContains(t, rawOutput, "clipboard") - require.NotContains(t, rawOutput, "\x1b]52") - }) - t.Run("RenderModelPicker", func(t *testing.T) { - t.Parallel() - - catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ - Provider: "OpenAI", - Available: true, - Models: []codersdk.ChatModel{{ID: "gpt-4o", Provider: "OpenAI", Model: "gpt-4o", DisplayName: "GPT-4o"}, {ID: "gpt-4.1", Provider: "OpenAI", Model: "gpt-4.1", DisplayName: "GPT-4.1"}}, - }, { - Provider: "Anthropic", - Available: false, - UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey, - }, { - Provider: "Local", - Available: true, - Models: nil, - }}} - for _, tt := range []struct { - name string - selectedModel string - selectedIndex int - assert func(t *testing.T, output string) - }{ - {name: "GroupsModelsByProvider", selectedModel: "gpt-4o", assert: func(t *testing.T, output string) { - require.Contains(t, output, "OpenAI") - require.Contains(t, output, "GPT-4o") - require.Contains(t, output, "GPT-4.1") - }}, - {name: "ShowsCursorIndicatorOnSelectedPosition", selectedModel: "gpt-4.1", selectedIndex: 1, assert: func(t *testing.T, output string) { - require.Contains(t, output, "> GPT-4.1") - require.Contains(t, output, " GPT-4o") - }}, - {name: "HidesProvidersWithoutModels", selectedModel: "gpt-4o", assert: func(t *testing.T, output string) { - require.Contains(t, output, "OpenAI") - require.NotContains(t, output, "Anthropic") - require.NotContains(t, output, "Local") - }}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var output string - require.NotPanics(t, func() { - output = plainText(renderModelPicker(styles, catalog, tt.selectedModel, tt.selectedIndex, 90, 20)) - }) - tt.assert(t, output) - }) - } - - t.Run("ShowsGlobalEmptyStateWhenNoModelsSelectable", func(t *testing.T) { - t.Parallel() - - emptyCatalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ - Provider: "Anthropic", - Available: false, - UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey, - }, { - Provider: "Local", - Available: true, - Models: nil, - }}} - - output := plainText(renderModelPicker(styles, emptyCatalog, "", 0, 90, 20)) - require.NotContains(t, output, "Anthropic") - require.NotContains(t, output, "Local") - require.Equal(t, 1, strings.Count(output, "No models available.")) - }) - }) - t.Run("KeepsCursorVisibleWithinWindow", func(t *testing.T) { - t.Parallel() - - models := make([]codersdk.ChatModel, 0, 6) - for i := 1; i <= 6; i++ { - models = append(models, codersdk.ChatModel{ - ID: fmt.Sprintf("provider:model-%d", i), - Provider: "provider", - Model: fmt.Sprintf("model-%d", i), - DisplayName: fmt.Sprintf("Model %d", i), - }) - } - catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ - Provider: "provider", - Available: true, - Models: models, - }}} - - output := plainText(renderModelPicker(styles, catalog, "provider:model-5", 4, 60, 8)) - require.Contains(t, output, "> Model 5") - require.NotContains(t, output, "Model 1") - }) - - t.Run("RenderAssistantMarkdown", func(t *testing.T) { - t.Parallel() - - output := plainText(renderAssistantMarkdown(styles, "- first\n- second", 60, nil)) - require.Contains(t, output, "• first") - require.Contains(t, output, "• second") - require.NotContains(t, output, "- first") - }) - - t.Run("SanitizeTerminalRenderableText", func(t *testing.T) { - t.Parallel() - - output := sanitizeTerminalRenderableText("safe\ttext\n\x1b[31mred\u009b32mgreen\x1b]52;c;clipboard\x07\x1b(Bdone\r\x00") - require.Equal(t, "safe\ttext\nredgreendone", output) - require.NotContains(t, output, "\x1b") - require.NotContains(t, output, "\x07") - require.NotContains(t, output, "\r") - require.NotContains(t, output, "\x00") - }) - - t.Run("RenderToolDetailStripsTerminalEscapes", func(t *testing.T) { - t.Parallel() - - rawOutput := renderToolDetail(styles, "result", "ok\x1b]52;c;clipboard\x07\n\tstill here", 60) - output := plainText(rawOutput) - require.Contains(t, output, "result: ok") - require.Contains(t, output, "still here") - require.NotContains(t, output, "clipboard") - require.NotContains(t, output, "\x1b") - require.NotContains(t, output, "\x07") - }) - t.Run("UtilityRenderers", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct{ name, input, want string }{ - {name: "WrapPreservingNewlines/PreservesExplicitNewlines", input: "line one\nline two", want: "line one\nline two"}, - {name: "WrapPreservingNewlines/EmptyString", input: "", want: ""}, - {name: "WrapPreservingNewlines/OnlyNewlines", input: "\n\n\n", want: "\n\n\n"}, - } { - require.Equalf(t, tt.want, wrapPreservingNewlines(tt.input, 40), tt.name) - } - for _, tt := range []struct { - name string - input string - max int - assert func(t *testing.T, output string) - }{ - {name: "ClampLines/AddsEllipsis", input: "line1\nline2\nline3\nline4", max: 3, assert: func(t *testing.T, output string) { - lines := strings.Split(output, "\n") - require.Len(t, lines, 3) - require.Equal(t, "line3…", lines[2]) - }}, - {name: "ClampLines/ZeroMax", input: "line1\nline2", max: 0, assert: func(t *testing.T, output string) { require.Empty(t, output) }}, - } { - tt.assert(t, clampLines(tt.input, tt.max)) - } - for _, tt := range []struct { - name string - prefix string - input string - width int - assert func(t *testing.T, output string) - }{ - {name: "RenderPrefixedBlock/IndentsContinuationLines", prefix: "You: ", input: "alpha beta gamma delta", width: 12, assert: func(t *testing.T, output string) { - lines := strings.Split(output, "\n") - require.GreaterOrEqual(t, len(lines), 2) - require.True(t, strings.HasPrefix(lines[1], strings.Repeat(" ", lipgloss.Width("You: ")))) - require.Contains(t, output, "You: ") - }}, - {name: "RenderPrefixedBlock/EmptyContent", prefix: "You: ", width: 12, assert: func(t *testing.T, output string) { require.Equal(t, "You: ", output) }}, - } { - tt.assert(t, renderPrefixedBlock(tt.prefix, tt.input, tt.width)) - } - }) - - t.Run("RenderAskUserQuestion", func(t *testing.T) { - t.Parallel() - - firstQuestion := parsedAskQuestion{ - Header: "Review plan", - Question: "Which plan should we use?", - Options: []parsedAskOption{ - {Label: "Fast path", Value: "fast"}, - {Label: "Safe path", Value: "safe"}, - }, - } - secondQuestion := parsedAskQuestion{ - Header: "Risk", - Question: "How much risk is acceptable?", - Options: []parsedAskOption{{Label: "Low", Value: "low"}}, - } - renderPlain := func(state *askUserQuestionState, width, height int) string { - return plainText(renderAskUserQuestion(styles, state, width, height)) - } - - t.Run("BasicRenderShowsQuestionOptionsAndHelp", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - output := renderPlain(state, 100, 20) - - require.Contains(t, output, "Plan Question 1/1") - require.Contains(t, output, firstQuestion.Header) - require.Contains(t, output, firstQuestion.Question) - require.Contains(t, output, "Fast path") - require.Contains(t, output, "Safe path") - require.Contains(t, output, "Other (type custom answer)") - require.Contains(t, output, "↑/↓ navigate") - require.Contains(t, output, "enter select") - }) - - t.Run("SelectedOptionShowsCursor", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - state.OptionCursor = 1 - output := renderPlain(state, 100, 20) - - require.Contains(t, output, "> Safe path") - require.NotContains(t, output, "> Fast path") - }) - - t.Run("MultipleQuestionsShowProgress", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion, firstQuestion}) - state.CurrentIndex = 1 - output := renderPlain(state, 100, 20) - - require.Contains(t, output, "Plan Question 2/3") - require.Contains(t, output, secondQuestion.Header) - require.Contains(t, output, secondQuestion.Question) - }) - - t.Run("FreeformInputIsVisible", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - state.OptionCursor = len(firstQuestion.Options) - state.OtherMode = true - state.OtherInput.Focus() - state.OtherInput.SetValue("Need a custom plan") - output := renderPlain(state, 100, 20) - - require.Contains(t, output, "Need a custom plan") - require.Contains(t, output, "esc cancel input") - }) - - t.Run("NarrowTerminalDoesNotPanic", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - var output string - require.NotPanics(t, func() { - output = renderPlain(state, 18, 6) - }) - require.NotEmpty(t, strings.TrimSpace(output)) - }) - }) -} - -func plainText(text string) string { - return ansiRegexp.ReplaceAllString(text, "") -} - -func rawJSON(value string) json.RawMessage { - return json.RawMessage([]byte(value)) -} - -func int64Ptr(value int64) *int64 { - return &value -} diff --git a/cli/agents_stream_test.go b/cli/agents_stream_test.go deleted file mode 100644 index 169e5118a0..0000000000 --- a/cli/agents_stream_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package cli //nolint:testpackage // Tests unexported chat stream helpers. - -import ( - "bytes" - "fmt" - "io" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/codersdk" -) - -type chatWatchWriters struct{ stdout, stderr io.Writer } - -func (w chatWatchWriters) Write(p []byte) (int, error) { return w.stdout.Write(p) } - -func (w chatWatchWriters) Stderr() io.Writer { - if w.stderr != nil { - return w.stderr - } - return w.stdout -} - -func consumeChatStream(eventCh <-chan codersdk.ChatStreamEvent, out io.Writer) error { - errOut := out - if writer, ok := out.(interface{ Stderr() io.Writer }); ok { - errOut = writer.Stderr() - } - - printedInline := false - flush := func() error { - if !printedInline { - return nil - } - printedInline = false - _, err := fmt.Fprintln(out) - return err - } - - printLine := func(dst io.Writer, format string, args ...any) error { - if err := flush(); err != nil { - return err - } - _, err := fmt.Fprintf(dst, format, args...) - return err - } - - for event := range eventCh { - var err error - switch event.Type { - case codersdk.ChatStreamEventTypeMessagePart: - if part := event.MessagePart; part != nil && - part.Part.Type == codersdk.ChatMessagePartTypeText && part.Part.Text != "" { - printedInline = true - _, err = fmt.Fprint(out, part.Part.Text) - } - case codersdk.ChatStreamEventTypeMessage: - if message := event.Message; message != nil && !printedInline { - for _, part := range message.Content { - if part.Type != codersdk.ChatMessagePartTypeText || part.Text == "" { - continue - } - printedInline = true - if _, err = fmt.Fprint(out, part.Text); err != nil { - break - } - } - } - if err == nil { - err = flush() - } - case codersdk.ChatStreamEventTypeStatus: - if event.Status == nil { - err = flush() - break - } - err = printLine(out, "[Status: %s]\n", event.Status.Status) - case codersdk.ChatStreamEventTypeError: - if event.Error == nil { - err = flush() - break - } - err = printLine(errOut, "[Error: %s]\n", event.Error.Message) - case codersdk.ChatStreamEventTypeRetry: - if event.Retry == nil { - err = flush() - break - } - err = printLine(out, "[Retry attempt %d after error: %s]\n", event.Retry.Attempt, event.Retry.Error) - case codersdk.ChatStreamEventTypeQueueUpdate: - default: - err = printLine(out, "[Event: %s]\n", event.Type) - } - if err != nil { - return xerrors.Errorf("render chat stream event: %w", err) - } - } - - if err := flush(); err != nil { - return xerrors.Errorf("flush chat stream output: %w", err) - } - return nil -} - -func TestConsumeChatStreamText(t *testing.T) { - t.Parallel() - - events := make(chan codersdk.ChatStreamEvent, 7) - for _, event := range []codersdk.ChatStreamEvent{ - {Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "Hello"}}}, - {Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, Text: "ignored"}}}, - {Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: " world"}}}, - {Type: codersdk.ChatStreamEventTypeMessage, Message: &codersdk.ChatMessage{ID: 1, ChatID: uuid.New(), Role: codersdk.ChatMessageRoleAssistant, Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "Hello world"}}}}, - {Type: codersdk.ChatStreamEventTypeStatus, Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}}, - {Type: codersdk.ChatStreamEventTypeRetry, Retry: &codersdk.ChatStreamRetry{Attempt: 2, Error: "rate limited"}}, - {Type: codersdk.ChatStreamEventTypeError, Error: &codersdk.ChatError{Message: "boom"}}, - } { - events <- event - } - close(events) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := consumeChatStream(events, chatWatchWriters{stdout: &stdout, stderr: &stderr}) - require.NoError(t, err) - require.Equal(t, "Hello world\n[Status: running]\n[Retry attempt 2 after error: rate limited]\n", stdout.String()) - require.Equal(t, "[Error: boom]\n", stderr.String()) -} diff --git a/cli/agents_styles.go b/cli/agents_styles.go deleted file mode 100644 index 28c83f9658..0000000000 --- a/cli/agents_styles.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "github.com/charmbracelet/lipgloss" - - "github.com/coder/coder/v2/codersdk" -) - -type tuiStyles struct { - title lipgloss.Style - subtitle lipgloss.Style - statusBar lipgloss.Style - statusBadge lipgloss.Style - selectedItem lipgloss.Style - selectedBlock lipgloss.Style - normalItem lipgloss.Style - dimmedText lipgloss.Style - errorText lipgloss.Style - searchInput lipgloss.Style - separator lipgloss.Style - helpText lipgloss.Style - modeBadgeExec lipgloss.Style - modeBadgePlan lipgloss.Style - userMessage lipgloss.Style - assistantMsg lipgloss.Style - reasoning lipgloss.Style - toolCallStyle lipgloss.Style - toolPending lipgloss.Style - toolSuccess lipgloss.Style - compaction lipgloss.Style - warningText lipgloss.Style - criticalText lipgloss.Style - overlayBorder lipgloss.Style - composerStyle lipgloss.Style -} - -func newTUIStyles(renderers ...*lipgloss.Renderer) tuiStyles { - renderer := lipgloss.DefaultRenderer() - if len(renderers) > 0 && renderers[0] != nil { - renderer = renderers[0] - } - - return tuiStyles{ - title: renderer.NewStyle().Bold(true), - subtitle: renderer.NewStyle().Faint(true), - statusBar: renderer.NewStyle(), - statusBadge: renderer.NewStyle().Padding(0, 1), - selectedItem: renderer.NewStyle().Bold(true), - selectedBlock: renderer.NewStyle(). - BorderLeft(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.AdaptiveColor{Light: "63", Dark: "63"}). - PaddingLeft(1), - normalItem: renderer.NewStyle(), - dimmedText: renderer.NewStyle().Faint(true), - errorText: renderer.NewStyle().Foreground(lipgloss.Color("1")), - searchInput: renderer.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true), - separator: renderer.NewStyle().Faint(true), - helpText: renderer.NewStyle().Faint(true), - modeBadgeExec: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "42"}), - modeBadgePlan: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "214"}), - userMessage: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("6")), - assistantMsg: renderer.NewStyle(), - reasoning: renderer.NewStyle().Faint(true).Italic(true), - toolCallStyle: renderer.NewStyle().Foreground(lipgloss.Color("3")), - toolPending: renderer.NewStyle().Faint(true).Foreground(lipgloss.Color("3")), - toolSuccess: renderer.NewStyle().Foreground(lipgloss.Color("2")), - compaction: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("5")), - warningText: renderer.NewStyle().Foreground(lipgloss.Color("3")), - criticalText: renderer.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), - overlayBorder: renderer.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(1), - composerStyle: renderer.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderTop(true), - } -} - -func (s tuiStyles) statusColor(status codersdk.ChatStatus) lipgloss.Style { - color := lipgloss.Color("7") - switch status { - case codersdk.ChatStatusWaiting, codersdk.ChatStatusPending: - color = lipgloss.Color("3") - case codersdk.ChatStatusRunning: - color = lipgloss.Color("4") - case codersdk.ChatStatusPaused: - color = lipgloss.Color("5") - case codersdk.ChatStatusCompleted: - color = lipgloss.Color("2") - case codersdk.ChatStatusError: - color = lipgloss.Color("1") - } - return s.statusBadge.Foreground(color) -} - -func (s tuiStyles) truncate(text string, maxWidth int) string { - _ = s - return truncateText(text, maxWidth, "", 3) -} diff --git a/cli/agents_test.go b/cli/agents_test.go deleted file mode 100644 index 8d08145f2c..0000000000 --- a/cli/agents_test.go +++ /dev/null @@ -1,3331 +0,0 @@ -package cli //nolint:testpackage // Tests unexported chat TUI reducers. - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/codersdk" - "github.com/coder/websocket" -) - -func TestAgents(t *testing.T) { - t.Parallel() - t.Run("ResolveModel", func(t *testing.T) { - t.Parallel() - catalog := codersdk.ChatModelsResponse{ - Providers: []codersdk.ChatModelProvider{{ - Provider: "openai", - Available: true, - Models: []codersdk.ChatModel{{ - ID: "openai:gpt-4o", - Provider: "openai", - Model: "gpt-4o", - DisplayName: "GPT-4o", - }}, - }}, - } - - client := newTestExperimentalClient(t, func(rw http.ResponseWriter, _ *http.Request) { - rw.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(rw).Encode(catalog) - }) - tests := []struct { - name string - input string - want string - }{ - {name: "ExactID", input: "openai:gpt-4o", want: "openai:gpt-4o"}, - {name: "ProviderModel", input: "openai/gpt-4o", want: "openai:gpt-4o"}, - {name: "DisplayName", input: "GPT-4o", want: "openai:gpt-4o"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resolved, err := resolveModel(context.Background(), client, tt.input) - require.NoError(t, err) - require.NotNil(t, resolved) - require.Equal(t, tt.want, *resolved) - }) - } - }) - - t.Run("TopLevelModelRouting", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - overlay tuiOverlay - }{ - {"ModelPicker", overlayModelPicker}, - {"DiffDrawer", overlayDiffDrawer}, - } - for _, tt := range tests { - t.Run("EscFromOverlayClosesIt/"+tt.name, func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = tt.overlay - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, overlayNone, updated.overlay) - }) - } - - t.Run("AdditionalOverlayCloseKeys", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - overlay tuiOverlay - key tea.KeyMsg - }{ - {name: "ModelPicker/KeyEscape", overlay: overlayModelPicker, key: tea.KeyMsg{Type: tea.KeyEscape}}, - {name: "ModelPicker/CtrlOpenBracket", overlay: overlayModelPicker, key: tea.KeyMsg{Type: tea.KeyCtrlOpenBracket}}, - {name: "DiffDrawer/KeyEscape", overlay: overlayDiffDrawer, key: tea.KeyMsg{Type: tea.KeyEscape}}, - {name: "DiffDrawer/CtrlOpenBracket", overlay: overlayDiffDrawer, key: tea.KeyMsg{Type: tea.KeyCtrlOpenBracket}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = tt.overlay - - updatedModel, cmd := model.Update(tt.key) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, overlayNone, updated.overlay) - }) - } - }) - - t.Run("EscFromChatViewReturnsToListAndRefreshes", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = overlayNone - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.True(t, updated.list.loading) - require.NotNil(t, cmd) - }) - - t.Run("EscFromChatViewAdvancesGeneration", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = overlayNone - model.chatGeneration = 4 - model.chat.chatGeneration = 4 - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, uint64(5), updated.chatGeneration) - require.Equal(t, uint64(5), updated.chat.chatGeneration) - require.True(t, updated.chat.matchesGeneration(updated.chatGeneration)) - require.NotNil(t, cmd) - }) - - t.Run("EscFromChatViewRejectsLateChatLoadMessages", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = overlayNone - model.chatGeneration = 4 - model.chat.chatGeneration = 4 - model.chat.chat = &codersdk.Chat{ID: uuid.New(), Title: "current chat"} - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.NotNil(t, cmd) - - staleChat := codersdk.Chat{ID: uuid.New(), Title: "stale chat"} - updatedModel, cmd = updated.Update(chatOpenedMsg{generation: 4, chatID: staleChat.ID, chat: staleChat}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, "current chat", updated.chat.chat.Title) - - staleMessages := []codersdk.ChatMessage{testMessage( - 1, - codersdk.ChatMessageRoleUser, - codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale"}, - )} - updatedModel, cmd = updated.Update(chatHistoryMsg{generation: 4, chatID: staleChat.ID, messages: staleMessages}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Empty(t, updated.chat.messages) - }) - - t.Run("EscFromSearchClearsFilterAndRestoresListNavigation", func(t *testing.T) { - t.Parallel() - chats := []codersdk.Chat{ - {ID: uuid.New(), Title: "alpha", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()}, - {ID: uuid.New(), Title: "beta", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()}, - {ID: uuid.New(), Title: "gamma", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()}, - } - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10}) - model = mustTUIModel(t, updatedModel, cmd) - model.currentView = viewList - model.list.loading = false - model.list.chats = chats - - updatedModel, cmd = model.Update(keyRunes("/")) - updated := mustTUIModel(t, updatedModel, cmd) - require.True(t, updated.list.searching) - - updatedModel, cmd = updated.Update(keyRunes("b")) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, "b", updated.list.search.Value()) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated = mustTUIModel(t, updatedModel, cmd) - require.False(t, updated.quitting) - require.False(t, updated.list.searching) - require.Empty(t, updated.list.search.Value()) - - updatedModel, cmd = updated.Update(keyRunes("j")) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, 1, updated.list.cursor) - - updatedModel, cmd = updated.Update(keyRunes("k")) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, 0, updated.list.cursor) - }) - - for name, view := range map[string]tuiView{ - "List": viewList, - "Chat": viewChat, - } { - t.Run("CtrlCQuitsFromAnyState/"+name, func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = view - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.True(t, updated.quitting) - _, ok := mustMsg(t, cmd).(tea.QuitMsg) - require.True(t, ok) - }) - } - - t.Run("OpenChatSwitchesView", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - msg tea.Msg - draft bool - wantLoading bool - wantBatchLen int - }{ - {name: "SelectedChat", msg: openSelectedChatMsg{chatID: uuid.New()}, wantLoading: true, wantBatchLen: 3}, - {name: "DraftChat", msg: openDraftChatMsg{}, draft: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.width, model.height = 100, 40 - updatedModel, cmd := model.Update(tt.msg) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, 40, updated.chat.height) - require.Equal(t, 34, updated.chat.viewport.Height) - if tt.draft { - require.True(t, updated.chat.draft) - require.False(t, updated.chat.loading) - require.True(t, updated.chat.metadataResolved) - require.True(t, updated.chat.historyResolved) - require.Nil(t, cmd) - return - } - require.Equal(t, tt.wantLoading, updated.chat.loading) - require.Len(t, mustBatchMsg(t, cmd), tt.wantBatchLen) - }) - } - }) - t.Run("EscFromChatViewRestoresListHeaderAndPadsTerminal", func(t *testing.T) { - t.Parallel() - assertReturnToList := func(t testing.TB, model chatsTUIModel) { - t.Helper() - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - firstLine, _, _ := strings.Cut(plainText(updated.View()), "\n") - require.Equal(t, "Coder Chats", firstLine) - require.Equal(t, updated.height, countRenderedLines(plainText(updated.View()))) - } - - t.Run("SelectedChat", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) - model = mustTUIModel(t, updatedModel, cmd) - model.list.loading = false - model.list.chats = []codersdk.Chat{testChat(codersdk.ChatStatusCompleted)} - chatID := uuid.New() - - updatedModel, cmd = model.Update(openSelectedChatMsg{chatID: chatID}) - model, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - openedChat := testChat(codersdk.ChatStatusCompleted) - openedChat.ID = chatID - openedChat.Title = "Existing chat" - updatedModel, cmd = model.Update(chatOpenedMsg{generation: model.chat.chatGeneration, chatID: chatID, chat: openedChat}) - model = mustTUIModel(t, updatedModel, cmd) - require.Contains(t, plainText(model.View()), "Existing chat") - - assertReturnToList(t, model) - }) - - t.Run("DraftChat", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) - model = mustTUIModel(t, updatedModel, cmd) - model.list.loading = false - model.list.chats = []codersdk.Chat{testChat(codersdk.ChatStatusCompleted)} - - updatedModel, cmd = model.Update(openDraftChatMsg{}) - model, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Contains(t, plainText(model.View()), "New Chat (draft)") - - assertReturnToList(t, model) - }) - }) - t.Run("ChatViewOmitsListHeaderAndLoadingSpinner", func(t *testing.T) { - t.Parallel() - - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) - model = mustTUIModel(t, updatedModel, cmd) - model.currentView = viewChat - model.list.loading = true - model.chat.loading = false - - chat := testChat(codersdk.ChatStatusCompleted) - chat.Title = "Existing chat" - model.chat.chat = &chat - model.chat.chatStatus = chat.Status - model.chat.messages = []codersdk.ChatMessage{ - testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeText, - Text: "assistant reply", - }), - } - model.chat.rebuildBlocks() - - view := plainText(model.View()) - firstLine, _, _ := strings.Cut(view, "\n") - require.Contains(t, firstLine, "Existing chat") - require.NotContains(t, view, "Coder Chats") - require.NotContains(t, view, "Loading chats") - }) - - t.Run("ReopensModelPickerAfterClosing", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - catalog := codersdk.ChatModelsResponse{ - Providers: []codersdk.ChatModelProvider{{ - Provider: "provider", - Available: true, - Models: []codersdk.ChatModel{{ - ID: "provider:model-a", - Provider: "provider", - Model: "model-a", - DisplayName: "Model A", - }}, - }}, - } - model.catalog = &catalog - model.chat.modelPickerFlat = catalog.Providers[0].Models - updatedModel, cmd := model.Update(toggleModelPickerMsg{}) - updated := mustTUIModel(t, updatedModel, cmd) - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - }) - - t.Run("ModelPickerBehavior", func(t *testing.T) { - t.Parallel() - twoModelCatalog := codersdk.ChatModelsResponse{ - Providers: []codersdk.ChatModelProvider{{ - Provider: "openai", - Available: true, - Models: []codersdk.ChatModel{ - {ID: "openai:gpt-4o", Provider: "openai", Model: "gpt-4o", DisplayName: "GPT-4o"}, - {ID: "openai:gpt-4.1", Provider: "openai", Model: "gpt-4.1", DisplayName: "GPT-4.1"}, - }, - }}, - } - - t.Run("CancelClosesOverlay", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - model.height = 24 - updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - }) - - t.Run("EscClosesPickerWithoutLeavingChat", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - model.height = 24 - model.chat.draft = true - model.chat.composerFocused = true - model.chat.composer.SetValue("keep draft") - updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) - updated := mustTUIModel(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - // ClearScreen cmd is expected - require.Equal(t, overlayNone, updated.overlay) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, "keep draft", updated.chat.composer.Value()) - }) - - t.Run("AdditionalCloseKeysClosePickerWithoutLeavingChat", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - key tea.KeyMsg - }{ - {name: "CtrlP", key: tea.KeyMsg{Type: tea.KeyCtrlP}}, - {name: "Q", key: keyRunes("q")}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - model.height = 24 - model.chat.draft = true - model.chat.composerFocused = true - model.chat.composer.SetValue("keep draft") - updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) - updated := mustTUIModel(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - - updatedModel, cmd = updated.Update(tt.key) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - // ClearScreen cmd is expected - require.Equal(t, overlayNone, updated.overlay) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, "keep draft", updated.chat.composer.Value()) - }) - } - }) - - t.Run("EnterSelectsModelWithoutSendingDraft", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - model.height = 24 - model.chat.draft = true - model.chat.composerFocused = true - model.chat.composer.SetValue("keep draft") - updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) - updated := mustTUIModel(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, 1, updated.chat.modelPickerCursor) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - // ClearScreen cmd is expected - require.Equal(t, overlayNone, updated.overlay) - require.NotNil(t, updated.chat.modelOverride) - require.NotNil(t, updated.modelOverride) - require.Equal(t, "openai:gpt-4.1", *updated.chat.modelOverride) - require.Equal(t, "openai:gpt-4.1", *updated.modelOverride) - require.Equal(t, "keep draft", updated.chat.composer.Value()) - require.False(t, updated.chat.creatingChat) - }) - - t.Run("LoadErrorClosesOverlay", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - model.height = 24 - - updatedModel, cmd := model.Update(toggleModelPickerMsg{}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - require.NotNil(t, cmd) - require.Contains(t, plainText(updated.View()), "Loading models...") - - updatedModel, cmd = updated.Update(modelsListedMsg{err: xerrors.New("model discovery failed")}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - require.NotContains(t, plainText(updated.View()), "Loading models...") - }) - - t.Run("ScrollAndSelectModel", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - model.height = 24 - updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) - updated := mustTUIModel(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - - for range 4 { - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) - updated = mustTUIModel(t, updatedModel, cmd) - } - require.Equal(t, 1, updated.chat.modelPickerCursor) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - require.NotNil(t, updated.chat.modelOverride) - require.NotNil(t, updated.modelOverride) - require.Equal(t, "openai:gpt-4.1", *updated.chat.modelOverride) - require.Equal(t, "openai:gpt-4.1", *updated.modelOverride) - }) - }) - - t.Run("DiffDrawerLoadingState", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.chat = &chat - - updatedModel, cmd := model.Update(toggleDiffDrawerMsg{}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayDiffDrawer, updated.overlay) - require.NotNil(t, cmd) - require.Contains(t, plainText(updated.View()), "Loading diff") - }) - - t.Run("DiffDrawerErrorState", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.chat = &chat - - updatedModel, cmd := model.Update(toggleDiffDrawerMsg{}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(diffContentsMsg{err: xerrors.New("connection refused")}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Contains(t, plainText(updated.View()), "connection refused") - }) - - t.Run("DiffDrawerMemoizesSummary", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.width = 80 - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.chat = &chat - generation := model.chat.chatGeneration - - // A successful diffContentsMsg pre-renders the summary - // and the lipgloss-styled body so View() redraws do not - // re-parse or re-style the full diff on every keypress - // (see chatViewModel.diffSummary and diffStyledBody). - diff := codersdk.ChatDiffContents{ - ChatID: chat.ID, - Diff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new", - } - updatedModel, cmd := model.Update(diffContentsMsg{generation: generation, chatID: chat.ID, diff: diff}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - require.NotNil(t, updated.chat.diffContents) - require.Equal(t, "1 file changed:\n modified a.txt (+1 -1)", updated.chat.diffSummary) - require.NotEmpty(t, updated.chat.diffStyledBody) - // The cached styled body still contains the diff text - // verbatim: lipgloss wraps lines in escape codes without - // replacing them, so every original line of the input - // diff must survive the round-trip. - require.Contains(t, plainText(updated.chat.diffStyledBody), "diff --git a/a.txt b/a.txt") - require.Contains(t, plainText(updated.chat.diffStyledBody), "+new") - - // setChat clears both caches so a new chat does not - // inherit stale render output from the previous session. - (&updated.chat).setChat(testChat(codersdk.ChatStatusCompleted)) - require.Empty(t, updated.chat.diffSummary) - require.Empty(t, updated.chat.diffStyledBody) - require.Nil(t, updated.chat.diffContents) - }) - - t.Run("OverlayDismissedOnViewSwitch", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = overlayModelPicker - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, overlayNone, updated.overlay) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.Equal(t, overlayNone, updated.overlay) - require.True(t, updated.list.loading) - require.NotNil(t, cmd) - }) - - t.Run("OverlaysMutuallyExclusive", func(t *testing.T) { - t.Parallel() - catalog := codersdk.ChatModelsResponse{ - Providers: []codersdk.ChatModelProvider{{ - Provider: "provider", - Available: true, - Models: []codersdk.ChatModel{{ - ID: uuid.New().String(), - Provider: "provider", - Model: "model-a", - DisplayName: "Model A", - }}, - }}, - } - - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.overlay = overlayModelPicker - model.catalog = &catalog - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.chat = &chat - - updatedModel, cmd := model.Update(toggleDiffDrawerMsg{}) - updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayDiffDrawer, updated.overlay) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - }) - - t.Run("OverlayBlocksViewKeys", func(t *testing.T) { - t.Parallel() - catalog := codersdk.ChatModelsResponse{ - Providers: []codersdk.ChatModelProvider{{ - Provider: "provider", - Available: true, - Models: []codersdk.ChatModel{{ - ID: uuid.New().String(), - Provider: "provider", - Model: "model-a", - DisplayName: "Model A", - }}, - }}, - } - - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - model.catalog = &catalog - model.chat.modelPickerFlat = catalog.Providers[0].Models - - updatedModel, cmd := model.Update(toggleModelPickerMsg{}) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - - updatedModel, cmd = updated.Update(keyRunes("n")) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.Equal(t, overlayModelPicker, updated.overlay) - require.False(t, updated.chat.draft) - }) - - t.Run("RapidViewSwitching", func(t *testing.T) { - t.Parallel() - firstChatID := uuid.New() - secondChatID := uuid.New() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.width = 100 - model.height = 40 - - updatedModel, cmd := model.Update(openSelectedChatMsg{chatID: firstChatID}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.True(t, updated.chat.loading) - require.Nil(t, updated.chat.chat) - require.Empty(t, updated.chat.messages) - require.Len(t, mustBatchMsg(t, cmd), 3) - - firstChat := testChat(codersdk.ChatStatusCompleted) - firstChat.ID = firstChatID - updated.chat.chat = &firstChat - updated.chat.loading = false - updated.chat.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "from chat A"})} - updated.chat.composer.SetValue("stale draft") - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.True(t, updated.list.loading) - - updatedModel, cmd = updated.Update(openSelectedChatMsg{chatID: secondChatID}) - updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.True(t, updated.chat.loading) - require.Nil(t, updated.chat.chat) - require.Empty(t, updated.chat.messages) - require.Empty(t, updated.chat.composer.Value()) - require.False(t, updated.chat.draft) - require.Len(t, mustBatchMsg(t, cmd), 3) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.True(t, updated.list.loading) - }) - }) - - t.Run("ChatView/MessageReceiving", func(t *testing.T) { - t.Parallel() - setup := func(metadataResolved, historyResolved bool) chatViewModel { - model := newTestChatViewModel(nil) - model.loading, model.metadataResolved, model.historyResolved = true, metadataResolved, historyResolved - return model - } - t.Run("ChatOpenedSuccessAndError", func(t *testing.T) { - t.Parallel() - diffStatus := &codersdk.ChatDiffStatus{ChatID: uuid.New()} - chat := testChat(codersdk.ChatStatusRunning) - chat.DiffStatus = diffStatus - chat.PlanMode = codersdk.ChatPlanModePlan - updated, cmd := setup(false, true).Update(chatOpenedMsg{chat: chat}) - require.NotNil(t, cmd) - require.Equal(t, chat.ID, updated.chat.ID) - require.Equal(t, codersdk.ChatStatusRunning, updated.chatStatus) - require.Equal(t, diffStatus, updated.diffStatus) - require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) - require.False(t, updated.loading) - require.Nil(t, updated.err) - updated, cmd = setup(false, true).Update(chatOpenedMsg{err: xerrors.New("open failed")}) - require.Nil(t, cmd) - require.Equal(t, "open failed", updated.err.Error()) - require.False(t, updated.loading) - }) - t.Run("ChatHistorySuccessAndError", func(t *testing.T) { - t.Parallel() - usageA := &codersdk.ChatMessageUsage{TotalTokens: int64Ref(10)} - usageB := &codersdk.ChatMessageUsage{TotalTokens: int64Ref(20)} - second := testMessage(2, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "second"}) - second.Usage = usageA - third := testMessage(3, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeReasoning, Text: "third"}) - third.Usage = usageB - messages := []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "first"}), second, third} - updated, cmd := setup(true, false).Update(chatHistoryMsg{messages: messages}) - require.Nil(t, cmd) - require.Equal(t, messages, updated.messages) - require.Len(t, updated.blocks, 3) - require.Equal(t, usageB, updated.lastUsage) - require.False(t, updated.loading) - updated, cmd = setup(true, false).Update(chatHistoryMsg{err: xerrors.New("history failed")}) - require.Nil(t, cmd) - require.Equal(t, "history failed", updated.err.Error()) - require.False(t, updated.loading) - }) - t.Run("OpenHistoryBothSucceedOutOfOrder", func(t *testing.T) { - t.Parallel() - model := setup(false, false) - model, _ = model.Update(chatHistoryMsg{messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hi"})}}) - require.True(t, model.loading) - require.Nil(t, model.err) - model.streaming = true - chat := testChat(codersdk.ChatStatusCompleted) - model, _ = model.Update(chatOpenedMsg{chat: chat}) - require.False(t, model.loading) - require.Nil(t, model.err) - require.Len(t, model.messages, 1) - }) - t.Run("OpenHistoryBothFail", func(t *testing.T) { - t.Parallel() - model := setup(false, false) - model, _ = model.Update(chatOpenedMsg{err: xerrors.New("open err")}) - require.True(t, model.loading) - model, _ = model.Update(chatHistoryMsg{err: xerrors.New("history err")}) - require.False(t, model.loading) - require.Equal(t, "open err", model.err.Error()) - }) - t.Run("StaleAsyncMessagesAreDropped", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - chat := testChat(codersdk.ChatStatusCompleted) - model.setChat(chat) - model.chatGeneration = 1 - model.loading = false - - before := model - updated, cmd := model.Update(chatOpenedMsg{ - generation: 0, - chatID: uuid.New(), - chat: testChat(codersdk.ChatStatusRunning), - }) - require.Nil(t, cmd) - require.Equal(t, before.chat, updated.chat) - require.Equal(t, before.loading, updated.loading) - require.Equal(t, before.messages, updated.messages) - require.Equal(t, before.err, updated.err) - }) - - t.Run("StaleSessionMessagesAreDroppedByGeneration", func(t *testing.T) { - t.Parallel() - type staleGenerationCase struct { - name string - msg tea.Msg - draft bool - } - type staleGenerationSnapshot struct { - loading bool - err error - chat *codersdk.Chat - pendingComposerText string - composerValue string - messages []codersdk.ChatMessage - draft bool - creatingChat bool - interrupting bool - queuedMessages []codersdk.ChatQueuedMessage - } - - startingState := func(draft bool) chatViewModel { - model := newTestChatViewModel(nil) - model.chatGeneration = 2 - model.loading = false - model.pendingComposerText = "pending" - if draft { - model.draft = true - model.composer.SetValue("draft text") - return model - } - model.creatingChat = true - model.interrupting = true - model.setChat(testChat(codersdk.ChatStatusCompleted)) - model.composer.SetValue("current") - return model - } - snapshot := func(model chatViewModel) staleGenerationSnapshot { - return staleGenerationSnapshot{ - loading: model.loading, - err: model.err, - chat: model.chat, - pendingComposerText: model.pendingComposerText, - composerValue: model.composer.Value(), - messages: model.messages, - draft: model.draft, - creatingChat: model.creatingChat, - interrupting: model.interrupting, - queuedMessages: model.queuedMessages, - } - } - - tests := []staleGenerationCase{ - {name: "WriteSide/chatCreatedMsg", msg: chatCreatedMsg{generation: 1, chat: testChat(codersdk.ChatStatusRunning)}}, - {name: "WriteSide/messageSentMsg", msg: messageSentMsg{generation: 1, resp: codersdk.CreateChatMessageResponse{}}}, - {name: "WriteSide/chatInterruptedMsg", msg: chatInterruptedMsg{generation: 1, chat: testChat(codersdk.ChatStatusCompleted)}}, - {name: "Draft/chatOpenedMsg", msg: chatOpenedMsg{generation: 1, chatID: uuid.New(), chat: testChat(codersdk.ChatStatusCompleted)}, draft: true}, - {name: "Draft/chatHistoryMsg", msg: chatHistoryMsg{generation: 1, chatID: uuid.New(), messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hi"})}}, draft: true}, - {name: "Draft/chatStreamEventMsg", msg: chatStreamEventMsg{generation: 1, chatID: uuid.New(), event: testTextPartEvent("stale")}, draft: true}, - {name: "Draft/diffContentsMsg", msg: diffContentsMsg{generation: 1, chatID: uuid.New()}, draft: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := startingState(tt.draft) - before := snapshot(model) - updated, cmd := model.Update(tt.msg) - require.Nil(t, cmd) - require.Equal(t, before, snapshot(updated)) - }) - } - }) - - t.Run("ErrorThenRetrySucceeds", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - errMsg tea.Msg - retryMsg tea.Msg - needsClient bool - composerText string - wantBlocks int - wantRetryCmd bool - }{ - {name: "ChatOpened", errMsg: chatOpenedMsg{err: xerrors.New("open failed")}, retryMsg: chatOpenedMsg{chat: testChat(codersdk.ChatStatusRunning)}}, - {name: "History", errMsg: chatHistoryMsg{err: xerrors.New("history failed")}, retryMsg: chatHistoryMsg{messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "recovered"})}}, wantBlocks: 1}, - {name: "Send", needsClient: true, composerText: "keep me", errMsg: messageSentMsg{err: xerrors.New("send failed")}, wantRetryCmd: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - if tt.needsClient { - model = newTestChatViewModel(failingExperimentalClient()) - model.loading = false - chat := testChat(codersdk.ChatStatusCompleted) - model.chat = &chat - model.chatStatus = chat.Status - model.composer.SetValue(tt.composerText) - } - updated, cmd := model.Update(tt.errMsg) - require.Nil(t, cmd) - require.Error(t, updated.err) - if tt.retryMsg != nil { - updated, cmd = updated.Update(tt.retryMsg) - require.Nil(t, updated.err) - switch retryMsg := tt.retryMsg.(type) { - case chatOpenedMsg: - require.NotNil(t, cmd) - require.NotNil(t, updated.chat) - require.Equal(t, retryMsg.chat.ID, updated.chat.ID) - case chatHistoryMsg: - require.Nil(t, cmd) - require.Equal(t, retryMsg.messages, updated.messages) - require.Len(t, updated.blocks, tt.wantBlocks) - } - } - if !tt.wantRetryCmd { - return - } - require.Equal(t, tt.composerText, updated.composer.Value()) - require.Contains(t, updated.View(), "send failed") - updated.composer.SetValue("retry me") - retried, retryCmd := updated.sendMessage() - require.NotNil(t, retryCmd) - require.True(t, retried.autoFollow) - require.Empty(t, retried.composer.Value()) - _, ok := mustMsg(t, retryCmd).(messageSentMsg) - require.True(t, ok) - }) - } - }) - t.Run("ChatHistoryEdgeCases", func(t *testing.T) { - t.Parallel() - cases := []struct { - name string - messages []codersdk.ChatMessage - wantNil bool - }{ - {name: "NilMessages", wantNil: true}, - {name: "EmptyMessages", messages: []codersdk.ChatMessage{}, wantNil: false}, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.messages = []codersdk.ChatMessage{ - testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing"}), - } - model.rebuildBlocks() - require.Len(t, model.blocks, 1) - - var updated chatViewModel - require.NotPanics(t, func() { - updated, _ = model.Update(chatHistoryMsg{messages: tt.messages}) - }) - require.Equal(t, tt.wantNil, updated.messages == nil) - if !tt.wantNil { - require.Empty(t, updated.messages) - } - require.Empty(t, updated.blocks) - }) - } - }) - }) - - t.Run("ChatView/StreamEvents", func(t *testing.T) { - t.Parallel() - applyStream := func(model chatViewModel, event codersdk.ChatStreamEvent) (chatViewModel, tea.Cmd) { - return model.Update(chatStreamEventMsg{event: event}) - } - messageEvent := func(message codersdk.ChatMessage) codersdk.ChatStreamEvent { - return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessage, Message: &message} - } - usage := &codersdk.ChatMessageUsage{OutputTokens: int64Ref(7)} - finalMessage := testMessage(9, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "final"}) - finalMessage.Usage = usage - for _, tt := range []struct { - name string - seedEvents []codersdk.ChatStreamEvent - reconnecting bool - event codersdk.ChatStreamEvent - wantMessages int - wantAccumulatorText string - wantAccumulatorArgs string - wantBlockKind chatBlockKind - wantBlockText string - wantBlockArgs string - wantUsage *codersdk.ChatMessageUsage - }{ - { - name: "MessagePartTextAppendsAndRebuildsBlocks", - seedEvents: []codersdk.ChatStreamEvent{testTextPartEvent("hel")}, - event: testTextPartEvent("lo"), - wantAccumulatorText: "hello", - wantBlockText: "hello", - }, - { - name: "MessagePartToolCallDeltaAccumulatesArgs", - seedEvents: []codersdk.ChatStreamEvent{testToolCallDeltaEvent("tc-1", "search", `{"q":"hel`)}, - event: testToolCallDeltaEvent("tc-1", "search", `lo"}`), - wantAccumulatorArgs: `{"q":"hello"}`, - wantBlockKind: blockToolCall, - wantBlockArgs: `{"q":"hello"}`, - }, - { - name: "MessageFinalizesAndResetsAccumulator", - seedEvents: []codersdk.ChatStreamEvent{testTextPartEvent("partial")}, - reconnecting: true, - event: messageEvent(finalMessage), - wantMessages: 1, - wantBlockText: "final", - wantUsage: usage, - }, - } { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.reconnecting = tt.reconnecting - for _, event := range tt.seedEvents { - model, _ = applyStream(model, event) - } - var cmd tea.Cmd - model, cmd = applyStream(model, tt.event) - require.Nil(t, cmd) - assertStreamCase(t, model, tt.wantMessages, tt.wantAccumulatorText, tt.wantAccumulatorArgs, tt.wantBlockKind, tt.wantBlockText, tt.wantBlockArgs, tt.wantUsage) - }) - } - t.Run("StatusEventRouting", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - chat := testChat(codersdk.ChatStatusWaiting) - model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status - updated, cmd := model.Update(chatStreamEventMsg{event: codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeStatus, - ChatID: chat.ID, - Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}, - }}) - require.NotNil(t, cmd) - require.Equal(t, codersdk.ChatStatusRunning, updated.chatStatus) - require.Equal(t, codersdk.ChatStatusRunning, updated.chat.Status) - chat.Status = codersdk.ChatStatusWaiting - model.chatStatus = codersdk.ChatStatusWaiting - updated, cmd = model.Update(chatStreamEventMsg{event: codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeStatus, - ChatID: uuid.New(), - Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}, - }}) - require.Nil(t, cmd) - require.Equal(t, codersdk.ChatStatusWaiting, updated.chatStatus) - require.Equal(t, codersdk.ChatStatusWaiting, updated.chat.Status) - }) - t.Run("ErrorSetsErr", func(t *testing.T) { - t.Parallel() - updated, cmd := applyStream(newTestChatViewModel(nil), codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeError, - Error: &codersdk.ChatError{Message: "stream blew up"}, - }) - require.Nil(t, cmd) - require.Equal(t, "stream error: stream blew up", updated.err.Error()) - }) - queuedMessages := []codersdk.ChatQueuedMessage{ - testQueuedMessage(1, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "queued text"}), - } - existingMessages := []codersdk.ChatMessage{ - testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing"}), - } - for _, tt := range []struct { - name string - messages []codersdk.ChatMessage - event codersdk.ChatStreamEvent - wantMessages []codersdk.ChatMessage - wantQueuedMessages []codersdk.ChatQueuedMessage - wantBlockText string - }{ - { - name: "QueueUpdateReplacesQueuedMessages", - event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeQueueUpdate, QueuedMessages: queuedMessages}, - wantQueuedMessages: queuedMessages, - wantBlockText: "queued text", - }, - { - name: "StreamEventWithNilPartIsIgnored", - messages: existingMessages, - event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart}, - wantMessages: existingMessages, - wantBlockText: "existing", - }, - } { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.messages = tt.messages - model.rebuildBlocks() - updated, cmd := applyStream(model, tt.event) - require.Nil(t, cmd) - model = updated - require.Equal(t, tt.wantMessages, model.messages) - require.Equal(t, tt.wantQueuedMessages, model.queuedMessages) - require.Len(t, model.blocks, 1) - require.Equal(t, tt.wantBlockText, model.blocks[0].text) - require.False(t, model.accumulator.isPending()) - }) - } - t.Run("StreamEventErrorShowsInView", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 120, Height: 12}) - model.loading = false - chat := testChat(codersdk.ChatStatusCompleted) - model.chat = &chat - model.chatStatus = chat.Status - model.messages = []codersdk.ChatMessage{ - testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing response"}), - } - model.rebuildBlocks() - updated := mustChatViewUpdate(t, model, chatStreamEventMsg{err: xerrors.New("websocket closed")}) - view := plainText(updated.View()) - require.Contains(t, view, chat.Title) - require.Contains(t, view, "existing response") - require.Contains(t, view, "websocket closed") - require.Contains(t, view, "Type a message") - require.Contains(t, view, "esc: back") - }) - t.Run("LoadingViewKeepsChatChrome", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.loading = true - model.metadataResolved = false - model.historyResolved = false - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 120, Height: 12}) - view := plainText(model.View()) - require.Contains(t, view, "New Chat (draft)") - require.Contains(t, view, "Loading chat...") - require.Contains(t, view, "Type a message") - require.Contains(t, view, "esc: back") - }) - t.Run("MultipleStreamErrorsOnlyShowLatest", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 12}) - model.loading = false - updated := mustChatViewUpdate(t, model, chatStreamEventMsg{err: xerrors.New("first error")}) - updated = mustChatViewUpdate(t, updated, chatStreamEventMsg{err: xerrors.New("second error")}) - view := updated.View() - require.Contains(t, view, "second error") - require.NotContains(t, view, "first error") - }) - t.Run("StreamAccumulatorFinalToolCallUpsertsExistingPart", func(t *testing.T) { - t.Parallel() - newToolCallDelta := func(toolCallID, toolName, argsDelta string) codersdk.ChatStreamMessagePart { - return codersdk.ChatStreamMessagePart{ - Role: codersdk.ChatMessageRoleAssistant, - Part: codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: toolCallID, - ToolName: toolName, - ArgsDelta: argsDelta, - }, - } - } - newFinalToolCall := func(toolCallID, toolName, args string) codersdk.ChatStreamMessagePart { - return codersdk.ChatStreamMessagePart{ - Role: codersdk.ChatMessageRoleAssistant, - Part: codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: toolCallID, - ToolName: toolName, - Args: json.RawMessage(args), - }, - } - } - cases := []struct { - name string - seed []codersdk.ChatStreamMessagePart - final codersdk.ChatStreamMessagePart - want []codersdk.ChatMessagePart - }{ - { - name: "ReplaceExistingToolCall", - seed: []codersdk.ChatStreamMessagePart{ - newToolCallDelta("tc-1", "search", `{"q":"hel`), - }, - final: newFinalToolCall("tc-1", "search", `{"q":"hello"}`), - want: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: "tc-1", - ToolName: "search", - Args: json.RawMessage(`{"q":"hello"}`), - }}, - }, - { - name: "AppendNewToolCallID", - seed: []codersdk.ChatStreamMessagePart{ - newToolCallDelta("tc-1", "search", `{"q":"hel`), - }, - final: newFinalToolCall("tc-2", "lookup", `{"id":"42"}`), - want: []codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: "tc-1", - ToolName: "search", - Args: json.RawMessage(`{"q":"hel`), - }, - { - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: "tc-2", - ToolName: "lookup", - Args: json.RawMessage(`{"id":"42"}`), - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var accumulator streamAccumulator - for _, delta := range tt.seed { - accumulator.applyDelta(delta) - } - accumulator.applyDelta(tt.final) - require.True(t, accumulator.pending) - require.Equal(t, codersdk.ChatMessageRoleAssistant, accumulator.role) - require.Equal(t, tt.want, accumulator.parts) - }) - } - }) - t.Run("MessageDeduplication", func(t *testing.T) { - t.Parallel() - toolRoundTripParts := []codersdk.ChatMessagePart{ - {Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: "tool-1", ToolName: "search", Args: json.RawMessage(`{"q":"hello"}`)}, - {Type: codersdk.ChatMessagePartTypeToolResult, ToolCallID: "tool-1", ToolName: "search", Result: json.RawMessage(`{"ok":true}`)}, - } - model := newTestChatViewModel(nil) - model.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleAssistant, toolRoundTripParts...)} - model.accumulator = streamAccumulator{pending: true, role: codersdk.ChatMessageRoleAssistant, parts: toolRoundTripParts} - model.rebuildBlocks() - require.Len(t, model.messages, 1) - require.Len(t, model.blocks, 1) - require.True(t, model.accumulator.isPending()) - require.Equal(t, blockToolResult, model.blocks[0].kind) - require.Equal(t, "tool-1", model.blocks[0].toolID) - }) - t.Run("StaleStreamEventsAreDroppedByGeneration", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - chat := testChat(codersdk.ChatStatusRunning) - model.setChat(chat) - model.chatGeneration = 1 - model.streaming = true - staleMsg := chatStreamEventMsg{ - chatID: uuid.New(), - event: testTextPartEvent("should be ignored"), - } - updated, cmd := model.Update(staleMsg) - require.Nil(t, cmd) - require.Empty(t, updated.accumulator.parts) - require.Equal(t, model.chatStatus, updated.chatStatus) - require.Equal(t, model.blocks, updated.blocks) - }) - t.Run("IntentionalCloseSkipsReconnect", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - chat := testChat(codersdk.ChatStatusRunning) - model.setChat(chat) - model.streaming = true - model.stopStream() - require.True(t, model.intentionalClose) - eofMsg := chatStreamEventMsg{ - chatID: chat.ID, - err: io.EOF, - } - updated, cmd := model.Update(eofMsg) - require.Nil(t, cmd) - require.False(t, updated.streaming) - require.False(t, updated.reconnecting) - require.False(t, updated.intentionalClose) - require.NoError(t, updated.err) - }) - t.Run("EOFStopsStreamingAndAttemptsReconnectWhenInterruptible", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(failingExperimentalClient()) - chat := testChat(codersdk.ChatStatusPending) - model.setChat(chat) - model.streaming = true - updated, cmd := model.Update(chatStreamEventMsg{chatID: chat.ID, err: io.EOF}) - require.NotNil(t, cmd) - require.False(t, updated.streaming) - require.True(t, updated.reconnecting) - }) - t.Run("MessageEventsDeduplicateByID", func(t *testing.T) { - t.Parallel() - message := testMessage(11, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}) - model, _ := applyStream(newTestChatViewModel(nil), messageEvent(message)) - model, cmd := applyStream(model, messageEvent(message)) - require.Nil(t, cmd) - require.Len(t, model.messages, 1) - }) - }) - t.Run("ChatView/Sending", func(t *testing.T) { - t.Parallel() - t.Run("DeliveredMessageIsAddedAndBlocksRebuilt", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - message := testMessage(21, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "delivered"}) - - updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}}) - require.Nil(t, cmd) - require.Len(t, updated.messages, 1) - require.Len(t, updated.blocks, 1) - require.Equal(t, "delivered", updated.blocks[0].text) - }) - - t.Run("DisconnectedSendRestartsStream", func(t *testing.T) { - t.Parallel() - chat := testChat(codersdk.ChatStatusCompleted) - message := testMessage(22, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "sent"}) - streamQueryCh := make(chan string, 1) - streamErrCh := make(chan error, 1) - client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { - wantPath := fmt.Sprintf("/api/experimental/chats/%s/stream", chat.ID) - if req.URL.Path != wantPath { - select { - case streamErrCh <- xerrors.Errorf("stream path %q, want %q", req.URL.Path, wantPath): - default: - } - rw.WriteHeader(http.StatusNotFound) - return - } - - conn, err := websocket.Accept(rw, req, nil) - if err != nil { - select { - case streamErrCh <- err: - default: - } - return - } - defer conn.Close(websocket.StatusNormalClosure, "") - - select { - case streamQueryCh <- req.URL.RawQuery: - default: - } - }) - - model := newTestChatViewModel(client) - model.setChat(chat) - - updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}}) - defer updated.stopStream() - require.NotNil(t, cmd) - require.True(t, updated.streaming) - require.NotNil(t, updated.streamCloser) - require.NotNil(t, updated.streamEventCh) - require.Len(t, updated.messages, 1) - - select { - case err := <-streamErrCh: - require.NoError(t, err) - case query := <-streamQueryCh: - require.Equal(t, fmt.Sprintf("after_id=%d", message.ID), query) - case <-time.After(time.Second): - t.Fatal("timed out waiting for restarted chat stream connection") - } - }) - - t.Run("ActiveStreamDoesNotReconnectOnSend", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - chat := testChat(codersdk.ChatStatusCompleted) - message := testMessage(24, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "delivered"}) - model.setChat(chat) - model.streaming = true - - updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}}) - require.Nil(t, cmd) - require.True(t, updated.streaming) - require.Len(t, updated.messages, 1) - }) - - t.Run("QueuedResponseUpdatesQueuedMessages", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - queued := testQueuedMessage(22, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "queued"}) - - updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{ - Queued: true, - QueuedMessage: &queued, - }}) - require.Nil(t, cmd) - require.Len(t, updated.queuedMessages, 1) - require.Len(t, updated.blocks, 1) - require.Equal(t, "queued", updated.blocks[0].text) - }) - - t.Run("SendCreateErrorHandling", func(t *testing.T) { - t.Parallel() - tests := []struct { - name, composerText, wantComposer string - draft, setChat, useSend, typeNewInput, wantRetry bool - errMsg tea.Msg - }{ - {name: "send preserves existing composer text", composerText: "keep me", wantComposer: "keep me", errMsg: messageSentMsg{err: xerrors.New("send failed")}}, - {name: "send restores pending text", composerText: "my message", wantComposer: "my message", setChat: true, useSend: true, errMsg: messageSentMsg{err: xerrors.New("network error")}}, - {name: "create restores pending text", composerText: "first message", wantComposer: "first message", draft: true, useSend: true, errMsg: chatCreatedMsg{err: xerrors.New("create failed")}}, - {name: "create error allows retry", composerText: "keep draft", wantComposer: "keep draft", draft: true, wantRetry: true, errMsg: chatCreatedMsg{err: xerrors.New("create failed")}}, - {name: "messageSent error does not overwrite newer input", composerText: "original", wantComposer: "new input", setChat: true, useSend: true, typeNewInput: true, errMsg: messageSentMsg{err: xerrors.New("fail")}}, - {name: "chatCreated error does not overwrite newer input", composerText: "original", wantComposer: "new input", draft: true, useSend: true, typeNewInput: true, errMsg: chatCreatedMsg{err: xerrors.New("fail")}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var client *codersdk.ExperimentalClient - if tt.wantRetry { - client = failingExperimentalClient() - } - model := newTestChatViewModel(client) - model.loading = false - if tt.draft { - model.draft = true - } - if tt.setChat { - chat := testChat(codersdk.ChatStatusCompleted) - model.setChat(chat) - } - model.composer.SetValue(tt.composerText) - if tt.useSend { - model, _ = model.sendMessage() - require.Empty(t, model.composer.Value()) - require.Equal(t, tt.composerText, model.pendingComposerText) - if tt.draft { - require.True(t, model.creatingChat) - } - } - if tt.typeNewInput { - model.composer.SetValue("new input") - } - updated, cmd := model.Update(tt.errMsg) - require.Nil(t, cmd) - model = updated - require.Error(t, model.err) - switch msg := tt.errMsg.(type) { - case messageSentMsg: - require.Equal(t, msg.err.Error(), model.err.Error()) - case chatCreatedMsg: - require.Equal(t, msg.err.Error(), model.err.Error()) - } - require.Equal(t, tt.wantComposer, model.composer.Value()) - if tt.wantRetry { - require.True(t, model.draft) - require.Contains(t, model.View(), "create failed") - model.composer.SetValue("retry draft") - retried, retryCmd := model.sendMessage() - require.NotNil(t, retryCmd) - require.True(t, retried.draft) - require.Empty(t, retried.composer.Value()) - _, ok := mustMsg(t, retryCmd).(chatCreatedMsg) - require.True(t, ok) - } - if tt.draft && tt.useSend { - require.False(t, model.creatingChat) - } - }) - } - }) - - t.Run("DuplicateDraftCreateIsIgnored", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.draft = true - model.loading = false - model.creatingChat = true - model.composer.SetValue("hello") - - updated, cmd := model.sendMessage() - require.Nil(t, cmd) - require.Equal(t, "hello", updated.composer.Value()) - }) - }) - - t.Run("ChatView/ModelOverrideMapsCanonicalModelID", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - draft bool - }{ - {name: "DraftCreateReturnsChatCreatedMsg", draft: true}, - {name: "SendMessageReturnsMessageSentMsg", draft: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - modelConfigID := uuid.New() - organizationID := uuid.New() - modelOverride := "provider:model" - createdChat := testChat(codersdk.ChatStatusWaiting) - chat := testChat(codersdk.ChatStatusCompleted) - var createReq *codersdk.CreateChatRequest - var messageReq *codersdk.CreateChatMessageRequest - client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { - rw.Header().Set("Content-Type", "application/json") - switch { - case req.Method == http.MethodGet && req.URL.Path == "/api/experimental/chats/model-configs": - require.NoError(t, json.NewEncoder(rw).Encode([]codersdk.ChatModelConfig{{ID: modelConfigID, Provider: "provider", Model: "model"}})) - case req.Method == http.MethodPost && req.URL.Path == "/api/experimental/chats": - createReq = new(codersdk.CreateChatRequest) - require.NoError(t, json.NewDecoder(req.Body).Decode(createReq)) - rw.WriteHeader(http.StatusCreated) - require.NoError(t, json.NewEncoder(rw).Encode(createdChat)) - case req.Method == http.MethodPost && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s/messages", chat.ID): - messageReq = new(codersdk.CreateChatMessageRequest) - require.NoError(t, json.NewDecoder(req.Body).Decode(messageReq)) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.CreateChatMessageResponse{})) - default: - t.Fatalf("unexpected %s %s", req.Method, req.URL.Path) - } - }) - model := newTestChatViewModel(client) - if tt.draft { - model.draft = true - } else { - model.setChat(chat) - } - model.loading = false - model.modelOverride = &modelOverride - model.organizationID = organizationID - model.planMode = codersdk.ChatPlanModePlan - model.composer.SetValue("hello") - updated, cmd := model.sendMessage() - require.NotNil(t, cmd) - require.Empty(t, updated.composer.Value()) - if tt.draft { - msg, ok := mustMsg(t, cmd).(chatCreatedMsg) - require.True(t, ok) - require.NoError(t, msg.err) - require.NotNil(t, createReq) - require.Equal(t, organizationID, createReq.OrganizationID) - require.NotNil(t, createReq.ModelConfigID) - require.Equal(t, modelConfigID, *createReq.ModelConfigID) - require.Equal(t, codersdk.ChatPlanModePlan, createReq.PlanMode) - require.Equal(t, createdChat.ID, msg.chat.ID) - return - } - msg, ok := mustMsg(t, cmd).(messageSentMsg) - require.True(t, ok) - require.NoError(t, msg.err) - require.NotNil(t, messageReq) - require.NotNil(t, messageReq.ModelConfigID) - require.Equal(t, modelConfigID, *messageReq.ModelConfigID) - require.NotNil(t, messageReq.PlanMode) - require.Equal(t, codersdk.ChatPlanModePlan, *messageReq.PlanMode) - }) - } - }) - t.Run("ChatView/SendMessageExplicitlyClearsPlanMode", func(t *testing.T) { - t.Parallel() - chat := testChat(codersdk.ChatStatusCompleted) - var messageReq *codersdk.CreateChatMessageRequest - client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { - rw.Header().Set("Content-Type", "application/json") - switch { - case req.Method == http.MethodPost && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s/messages", chat.ID): - messageReq = new(codersdk.CreateChatMessageRequest) - require.NoError(t, json.NewDecoder(req.Body).Decode(messageReq)) - require.NoError(t, json.NewEncoder(rw).Encode(codersdk.CreateChatMessageResponse{})) - default: - t.Fatalf("unexpected %s %s", req.Method, req.URL.Path) - } - }) - model := newTestChatViewModel(client) - model.setChat(chat) - model.loading = false - model.composer.SetValue("hello") - - updated, cmd := model.sendMessage() - require.NotNil(t, cmd) - require.Empty(t, updated.composer.Value()) - - msg, ok := mustMsg(t, cmd).(messageSentMsg) - require.True(t, ok) - require.NoError(t, msg.err) - require.NotNil(t, messageReq) - require.NotNil(t, messageReq.PlanMode) - require.Empty(t, *messageReq.PlanMode) - }) - - t.Run("ChatView/ChatCreatedPromotesDraft", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.draft = true - model.streaming = true - chat := testChat(codersdk.ChatStatusWaiting) - - updated, cmd := model.Update(chatCreatedMsg{chat: chat}) - require.Nil(t, cmd) - require.NotNil(t, updated.chat) - require.Equal(t, chat.ID, updated.chat.ID) - require.False(t, updated.draft) - require.Equal(t, codersdk.ChatStatusWaiting, updated.chatStatus) - require.Nil(t, updated.err) - }) - - t.Run("ChatView/Interrupts", func(t *testing.T) { - t.Parallel() - newInterruptModel := func(status codersdk.ChatStatus) chatViewModel { - model := newTestChatViewModel(failingExperimentalClient()) - model.setChat(testChat(status)) - return model - } - - t.Run("InterruptedChatClearsInterruptingAndUpdatesStatus", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.interrupting = true - chat := testChat(codersdk.ChatStatusCompleted) - - updated, cmd := model.Update(chatInterruptedMsg{chat: chat}) - require.Nil(t, cmd) - require.False(t, updated.interrupting) - require.Equal(t, chat.ID, updated.chat.ID) - require.Equal(t, codersdk.ChatStatusCompleted, updated.chatStatus) - }) - - t.Run("InterruptedChatErrorClearsInterruptingAndSetsErr", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.interrupting = true - - updated, cmd := model.Update(chatInterruptedMsg{err: xerrors.New("interrupt failed")}) - require.Nil(t, cmd) - require.False(t, updated.interrupting) - require.Equal(t, "interrupt failed", updated.err.Error()) - }) - - tests := []struct { - name string - chatStatus codersdk.ChatStatus - alreadyInterrupting bool - expectedInterrupting bool - }{ - {name: "DoubleInterrupt", chatStatus: codersdk.ChatStatusRunning, alreadyInterrupting: true, expectedInterrupting: true}, - {name: "IdleChat", chatStatus: codersdk.ChatStatusCompleted}, - } - for _, tt := range tests { - t.Run("CtrlXNoOpCases/"+tt.name, func(t *testing.T) { - t.Parallel() - model := newInterruptModel(tt.chatStatus) - model.interrupting = tt.alreadyInterrupting - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) - require.Nil(t, cmd) - require.Equal(t, tt.expectedInterrupting, updated.interrupting) - }) - } - - t.Run("CtrlXInterruptsRunningChat", func(t *testing.T) { - t.Parallel() - model := newInterruptModel(codersdk.ChatStatusRunning) - require.True(t, model.composerFocused) - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) - require.NotNil(t, cmd) - require.True(t, updated.interrupting) - require.True(t, updated.composerFocused) - }) - - t.Run("TabKeepsFocusSwitchBehaviorWhileRunningChat", func(t *testing.T) { - t.Parallel() - model := newInterruptModel(codersdk.ChatStatusRunning) - require.True(t, model.composerFocused) - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyTab}) - require.Nil(t, cmd) - require.False(t, updated.interrupting) - require.False(t, updated.composerFocused) - }) - - t.Run("ViewShowsCtrlXInterruptHelp", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12}) - model.setChat(testChat(codersdk.ChatStatusRunning)) - model.loading = false - - view := plainText(model.View()) - require.Contains(t, view, "ctrl+x: interrupt") - require.NotContains(t, view, "ctrl+i: interrupt") - }) - - t.Run("ViewShowsPlanModeBadge", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12}) - model.loading = false - execView := model.View() - require.Contains(t, plainText(execView), "mode: exec") - require.Contains(t, execView, model.styles.modeBadgeExec.Render("exec")) - - model.planMode = codersdk.ChatPlanModePlan - planView := model.View() - view := plainText(planView) - require.Contains(t, view, "mode: plan") - require.Contains(t, planView, model.styles.modeBadgePlan.Render("plan")) - require.Contains(t, view, "shift+tab: switch mode") - }) - - t.Run("PlanModeUpdateErrorRollsBackLocalModeAndShowsBanner", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - current codersdk.ChatPlanMode - want codersdk.ChatPlanMode - }{ - {name: "BackToCode", current: codersdk.ChatPlanModePlan, want: ""}, - {name: "BackToPlan", current: "", want: codersdk.ChatPlanModePlan}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12}) - model.setChat(testChat(codersdk.ChatStatusCompleted)) - model.planMode = tt.current - - updated, cmd := model.Update(chatPlanModeUpdatedMsg{err: xerrors.New("update failed")}) - require.Nil(t, cmd) - require.Equal(t, tt.want, updated.planMode) - require.EqualError(t, updated.err, "update failed") - require.Contains(t, plainText(updated.View()), "update failed") - }) - } - }) - }) - - t.Run("ChatView/Keyboard", func(t *testing.T) { - t.Parallel() - t.Run("KeyboardShortcutRouting", func(t *testing.T) { - t.Parallel() - isToggleModelPicker := func(msg tea.Msg) bool { _, ok := msg.(toggleModelPickerMsg); return ok } - isToggleDiffDrawer := func(msg tea.Msg) bool { _, ok := msg.(toggleDiffDrawerMsg); return ok } - tests := []struct { - name string - key tea.KeyType - composerFocused bool - composerValue string - assert func(tea.Msg) bool - }{ - {name: "CtrlP/Focused", key: tea.KeyCtrlP, composerFocused: true, composerValue: "draft", assert: isToggleModelPicker}, - {name: "CtrlP/Unfocused", key: tea.KeyCtrlP, assert: isToggleModelPicker}, - {name: "CtrlD/Focused", key: tea.KeyCtrlD, composerFocused: true, composerValue: "draft", assert: isToggleDiffDrawer}, - {name: "CtrlD/Unfocused", key: tea.KeyCtrlD, assert: isToggleDiffDrawer}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.composerFocused = tt.composerFocused - model.composer.SetValue(tt.composerValue) - - updated, cmd := model.Update(tea.KeyMsg{Type: tt.key}) - require.NotNil(t, cmd) - require.Equal(t, tt.composerFocused, updated.composerFocused) - require.Equal(t, tt.composerValue, updated.composer.Value()) - require.True(t, tt.assert(mustMsg(t, cmd))) - }) - } - }) - - t.Run("ShiftTabTogglesPlanMode", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.composer.SetValue("draft") - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - require.Nil(t, cmd) - require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) - require.Equal(t, "draft", updated.composer.Value()) - - updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - require.Nil(t, cmd) - require.Empty(t, updated.planMode) - require.Equal(t, "draft", updated.composer.Value()) - }) - - t.Run("TabOnlySwitchesComposerFocus", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.planMode = codersdk.ChatPlanModePlan - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyTab}) - require.Nil(t, cmd) - require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) - require.False(t, updated.composerFocused) - }) - - t.Run("ShiftTabDraftChatDefersPlanModePersistence", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.draft = true - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - require.Nil(t, cmd) - require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) - }) - - t.Run("ShiftTabExistingChatUpdatesPlanModeImmediately", func(t *testing.T) { - t.Parallel() - chat := testChat(codersdk.ChatStatusCompleted) - var requests []codersdk.UpdateChatRequest - client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { - switch { - case req.Method == http.MethodPatch && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s", chat.ID): - var updateReq codersdk.UpdateChatRequest - require.NoError(t, json.NewDecoder(req.Body).Decode(&updateReq)) - requests = append(requests, updateReq) - rw.WriteHeader(http.StatusNoContent) - default: - t.Fatalf("unexpected %s %s", req.Method, req.URL.Path) - } - }) - model := newTestChatViewModel(client) - model.setChat(chat) - - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - require.NotNil(t, cmd) - require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) - - msg, ok := mustMsg(t, cmd).(chatPlanModeUpdatedMsg) - require.True(t, ok) - require.NoError(t, msg.err) - require.Len(t, requests, 1) - require.NotNil(t, requests[0].PlanMode) - require.Equal(t, codersdk.ChatPlanModePlan, *requests[0].PlanMode) - - updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - require.NotNil(t, cmd) - require.Empty(t, updated.planMode) - - msg, ok = mustMsg(t, cmd).(chatPlanModeUpdatedMsg) - require.True(t, ok) - require.NoError(t, msg.err) - require.Len(t, requests, 2) - require.NotNil(t, requests[1].PlanMode) - require.Empty(t, *requests[1].PlanMode) - }) - - t.Run("CtrlPFromListViewDoesNotOpenModelPicker", func(t *testing.T) { - t.Parallel() - model := newTestTUIModel() - model.currentView = viewList - model.list.loading = false - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.Equal(t, overlayNone, updated.overlay) - }) - }) - - t.Run("ChatView/ViewportScrolling", func(t *testing.T) { - t.Parallel() - applyWindowSize := func(t *testing.T, model chatsTUIModel, width int, height int) chatsTUIModel { - t.Helper() - updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: width, Height: height}) - return mustTUIModel(t, updatedModel, cmd) - } - scrollableModel := func(t *testing.T, keys ...tea.KeyType) chatViewModel { - t.Helper() - model := newTestChatViewModel(nil) - model.loading = false - chat := testChat(codersdk.ChatStatusCompleted) - model.chat = &chat - model.chatStatus = chat.Status - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 20}) - model.messages = overflowingMessages(24) - model.rebuildBlocks() - model = mustChatViewUpdate(t, model, tea.KeyMsg{Type: tea.KeyTab}) - require.False(t, model.composerFocused) - require.True(t, model.autoFollow) - require.True(t, model.viewport.AtBottom()) - require.Greater(t, model.viewport.YOffset, 0) - for _, key := range keys { - model = mustChatViewUpdate(t, model, tea.KeyMsg{Type: key}) - } - return model - } - streamMessage := func(id int64) chatStreamEventMsg { - message := testMessage( - id, - codersdk.ChatMessageRoleAssistant, - codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: strings.Repeat("new content ", 24)}, - ) - return chatStreamEventMsg{event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessage, Message: &message}} - } - updateView := func(model chatViewModel, msg tea.Msg) chatViewModel { - updated, _ := model.Update(msg) - return updated - } - t.Run("ViewportHeights", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - height int - viewChat bool - messageCount int - wantChatHeight int - wantViewportHeight int - }{ - {"Standard", 40, false, 0, 39, 33}, - {"MinimumZero", 5, false, 0, -1, 0}, - {"ViewFitsTerminal", 40, true, 24, -1, -1}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := applyWindowSize(t, newTestTUIModel(), 80, tt.height) - if tt.viewChat { - model.currentView = viewChat - model.chat.loading = false - model.chat, _ = model.chat.Update(model.childWindowSizeMsg()) - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.chat, model.chat.chatStatus = &chat, chat.Status - model.chat.messages = overflowingMessages(tt.messageCount) - model.chat.rebuildBlocks() - require.LessOrEqual(t, strings.Count(model.View(), "\n")+1, tt.height) - return - } - if tt.wantChatHeight >= 0 { - require.Equal(t, tt.wantChatHeight, model.chat.height) - } - if tt.wantViewportHeight >= 0 { - require.Equal(t, tt.wantViewportHeight, model.chat.viewport.Height) - } - }) - } - }) - t.Run("WrappedComposerFitsTerminal", func(t *testing.T) { - t.Parallel() - model := applyWindowSize(t, newTestTUIModel(), 40, 18) - model.currentView = viewChat - model.chat.loading = false - model.chat, _ = model.chat.Update(model.childWindowSizeMsg()) - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.chat = &chat - model.chat.chatStatus = chat.Status - model.chat.messages = overflowingMessages(18) - model.chat.rebuildBlocks() - initialViewportHeight := model.chat.viewport.Height - model.chat.composer.SetValue(strings.Repeat("wrapped input ", 14)) - model.chat.recalcViewportHeight() - model.chat.syncViewportContent() - view := plainText(model.View()) - lines := strings.Split(view, "\n") - require.LessOrEqual(t, model.chat.viewport.Height, initialViewportHeight) - require.LessOrEqual(t, len(lines), 18) - require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1])) - }) - t.Run("ViewShowsSingleStatusBarAndComposerDivider", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.loading = false - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 60, Height: 14}) - chat := testChat(codersdk.ChatStatusWaiting) - model.chat = &chat - model.chatStatus = chat.Status - model.messages = []codersdk.ChatMessage{ - testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing response"}), - } - model.rebuildBlocks() - view := plainText(model.View()) - require.NotContains(t, view, "Status: waiting") - require.Equal(t, 1, strings.Count(view, "waiting")) - lines := strings.Split(view, "\n") - composerLine := -1 - for i, line := range lines { - if strings.Contains(line, "> ") { - composerLine = i - break - } - } - require.Greater(t, composerLine, 1) - require.Contains(t, lines[composerLine-1], "────") - }) - t.Run("ScrollNavigation", func(t *testing.T) { - t.Parallel() - type yOffsetCheck int - const ( - ySkip yOffsetCheck = iota - yLess - yGreater - yEqual - yHalfUp - yHalfDown - ) - const skip = -1 - tests := []struct { - name string - preKeys []tea.KeyType - key tea.KeyType - yCheck yOffsetCheck - wantAutoFollow int - wantBeforeBottom int - wantAfterBottom int - wantBeforeYOffset int - wantAfterYOffset int - }{ - {"ScrollUpDecreasesYOffset", nil, tea.KeyUp, yLess, 0, skip, skip, skip, skip}, - {"ScrollDownIncreasesYOffset", []tea.KeyType{tea.KeyUp}, tea.KeyDown, yGreater, skip, skip, skip, skip, skip}, - {"ScrollUpAtTopIsNoOp", []tea.KeyType{tea.KeyHome}, tea.KeyUp, yEqual, skip, skip, skip, 0, skip}, - {"ScrollDownAtBottomReEnablesAutoFollow", []tea.KeyType{tea.KeyUp}, tea.KeyDown, yGreater, 1, 0, 1, skip, skip}, - {"PageUpScrollsHalfViewport", nil, tea.KeyPgUp, yHalfUp, 0, skip, skip, skip, skip}, - {"PageDownScrollsHalfViewport", []tea.KeyType{tea.KeyPgUp}, tea.KeyPgDown, yHalfDown, skip, skip, skip, skip, skip}, - {"HomeJumpsToTop", nil, tea.KeyHome, ySkip, 0, skip, skip, skip, 0}, - {"EndJumpsToBottomAndEnablesAutoFollow", []tea.KeyType{tea.KeyHome}, tea.KeyEnd, ySkip, 1, 0, 1, skip, skip}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - before := scrollableModel(t, tt.preKeys...) - after := mustChatViewUpdate(t, before, tea.KeyMsg{Type: tt.key}) - assertScrollNavigationCase(t, before, after, tt.wantBeforeYOffset, tt.wantAfterYOffset, tt.wantAutoFollow, tt.wantBeforeBottom, tt.wantAfterBottom, int(tt.yCheck)) - }) - } - }) - t.Run("AutoFollowOnContentUpdates", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - preKeys []tea.KeyType - messageID int64 - wantAutoFollow bool - wantAtBottom bool - wantPreserveYOffset bool - }{ - {"SetContentPreservesScrollPosition", []tea.KeyType{tea.KeyUp}, 1001, false, false, true}, - {"NewMessageAutoFollowsWhenAtBottom", nil, 1002, true, true, false}, - {"NewMessageDoesNotAutoFollowWhenScrolledUp", []tea.KeyType{tea.KeyUp}, 1003, false, false, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - before := scrollableModel(t, tt.preKeys...) - after := updateView(before, streamMessage(tt.messageID)) - require.Equal(t, tt.wantAutoFollow, after.autoFollow) - require.Equal(t, tt.wantAtBottom, after.viewport.AtBottom()) - if tt.wantPreserveYOffset { - require.Equal(t, before.viewport.YOffset, after.viewport.YOffset) - return - } - require.GreaterOrEqual(t, after.viewport.YOffset, before.viewport.YOffset) - }) - } - }) - t.Run("StreamingAutoFollows", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 10}) - model = updateView(model, chatHistoryMsg{messages: overflowingMessages(10)}) - before := model.viewport.YOffset - model = updateView(model, chatStreamEventMsg{event: testTextPartEvent(strings.Repeat("hello world ", 20))}) - model = updateView(model, chatStreamEventMsg{event: testTextPartEvent(strings.Repeat("more text ", 20))}) - require.True(t, model.autoFollow) - require.True(t, model.viewport.AtBottom()) - require.GreaterOrEqual(t, model.viewport.YOffset, before) - }) - }) - t.Run("ChatView/StatePersistence", func(t *testing.T) { - t.Parallel() - t.Run("ComposerTextSurvivesOverlayToggle", func(t *testing.T) { - t.Parallel() - model := newTestTUIModel() - model.currentView = viewChat - model.chat.loading = false - catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ - Provider: "provider", - Available: true, - Models: []codersdk.ChatModel{{ID: uuid.New().String(), Provider: "provider", Model: "model-a", DisplayName: "Model A"}}, - }}} - model.catalog = &catalog - model.chat.modelPickerFlat = catalog.Providers[0].Models - model.chat.composer.SetValue("keep this draft") - updatedModel, cmd := model.Update(toggleModelPickerMsg{}) - model = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, "keep this draft", model.chat.composer.Value()) - updatedModel, cmd = model.Update(toggleModelPickerMsg{}) - model = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, "keep this draft", model.chat.composer.Value()) - }) - - t.Run("ComposerTextSurvivesFocusSwitch", func(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.composer.SetValue("keep this draft") - - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab}) - require.False(t, updated.composerFocused) - require.Equal(t, "keep this draft", updated.composer.Value()) - - updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyTab}) - require.True(t, updated.composerFocused) - require.Equal(t, "keep this draft", updated.composer.Value()) - }) - - t.Run("ViewportScrollSurvivesOverlayToggle", func(t *testing.T) { - t.Parallel() - model := newTestTUIModel() - model.currentView = viewChat - model.chat.loading = false - updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10}) - model = mustTUIModel(t, updatedModel, cmd) - chat := testChat(codersdk.ChatStatusCompleted) - model.chat.setChat(chat) - model.chat.messages = overflowingMessages(10) - diff := codersdk.ChatDiffContents{ChatID: chat.ID, Diff: "diff --git a/file b/file"} - model.chat.diffContents = &diff - model.chat.rebuildBlocks() - model.chat.composerFocused = false - (&model.chat).syncViewportContent() - model.chat.viewport.GotoBottom() - updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyUp}) - model = mustTUIModel(t, updatedModel, cmd) - require.False(t, model.chat.viewport.AtBottom()) - yBefore := model.chat.viewport.YOffset - updatedModel, cmd = model.Update(toggleDiffDrawerMsg{}) - model = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayDiffDrawer, model.overlay) - require.Equal(t, yBefore, model.chat.viewport.YOffset) - - updatedModel, cmd = model.Update(toggleDiffDrawerMsg{}) - model = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayNone, model.overlay) - require.Equal(t, yBefore, model.chat.viewport.YOffset) - }) - - t.Run("SelectedModelSurvivesPickerReopen", func(t *testing.T) { - t.Parallel() - firstModelID := "provider:model-a" - secondModelID := "provider:model-b" - catalog := codersdk.ChatModelsResponse{ - Providers: []codersdk.ChatModelProvider{{ - Provider: "provider", - Available: true, - Models: []codersdk.ChatModel{ - { - ID: firstModelID, - Provider: "provider", - Model: "model-a", - DisplayName: "Model A", - }, - { - ID: secondModelID, - Provider: "provider", - Model: "model-b", - DisplayName: "Model B", - }, - }, - }}, - } - - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - - updatedModel, cmd := model.Update(modelsListedMsg{catalog: catalog}) - updated := mustTUIModel(t, updatedModel, cmd) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, 1, updated.chat.modelPickerCursor) - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - require.NotNil(t, updated.chat.modelOverride) - require.NotNil(t, updated.modelOverride) - require.Equal(t, secondModelID, *updated.chat.modelOverride) - require.Equal(t, secondModelID, *updated.modelOverride) - - updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) - updated = mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayModelPicker, updated.overlay) - require.Equal(t, 1, updated.chat.modelPickerCursor) - require.NotNil(t, updated.chat.modelOverride) - require.Equal(t, secondModelID, *updated.chat.modelOverride) - }) - }) - - t.Run("ChatView/ChatLifecycle", func(t *testing.T) { - t.Parallel() - t.Run("StreamingChatSwitchBackToList", func(t *testing.T) { - t.Parallel() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.currentView = viewChat - chat := testChat(codersdk.ChatStatusRunning) - model.chat.chat = &chat - model.chat.chatStatus = codersdk.ChatStatusRunning - model.chat.streaming = true - model.chat.streamCloser = io.NopCloser(strings.NewReader("stream")) - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.True(t, updated.list.loading) - require.False(t, updated.chat.streaming) - require.Nil(t, updated.chat.streamCloser) - require.NotNil(t, cmd) - }) - - t.Run("ReOpenSameChatAfterEsc", func(t *testing.T) { - t.Parallel() - chatID := uuid.New() - model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) - model.width = 100 - model.height = 40 - - updatedModel, cmd := model.Update(openSelectedChatMsg{chatID: chatID}) - updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.Len(t, mustBatchMsg(t, cmd), 3) - - openedChat := testChat(codersdk.ChatStatusCompleted) - openedChat.ID = chatID - updated.chat.chat = &openedChat - updated.chat.loading = false - updated.chat.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale message"})} - updated.chat.composer.SetValue("stale draft") - - updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewList, updated.currentView) - require.True(t, updated.list.loading) - - updatedModel, cmd = updated.Update(openSelectedChatMsg{chatID: chatID}) - updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd) - require.Equal(t, viewChat, updated.currentView) - require.True(t, updated.chat.loading) - require.Nil(t, updated.chat.chat) - require.Empty(t, updated.chat.messages) - require.Empty(t, updated.chat.composer.Value()) - require.Len(t, mustBatchMsg(t, cmd), 3) - }) - }) - - t.Run("ChatView/TranscriptSync", func(t *testing.T) { - t.Parallel() - newTranscriptModel := func() chatViewModel { - model := newTestChatViewModel(nil) - model.width = 80 - model.blocks = []chatBlock{ - {kind: blockText, role: codersdk.ChatMessageRoleUser, text: "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi"}, - {kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "assistant reply"}, - } - model.selectedBlock = 0 - model.composerFocused = false - return model - } - - t.Run("TranscriptRefreshRules", func(t *testing.T) { - t.Parallel() - tests := []struct { - name string - mutate func(m *chatViewModel) - expectNew bool - }{ - {"RepeatedViewNoRefresh", func(m *chatViewModel) {}, false}, - {"BlockChange", func(m *chatViewModel) { - m.blocks = append(m.blocks, chatBlock{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "new block"}) - }, true}, - {"SelectionChange", func(m *chatViewModel) { m.selectedBlock = 1 }, true}, - {"WidthChange", func(m *chatViewModel) { m.width = 60 }, true}, - {"ComposerFocusChange", func(m *chatViewModel) { m.composerFocused = true }, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - model := newTranscriptModel() - (&model).syncViewportContent() - firstTranscript := model.lastTranscript - require.NotEmpty(t, firstTranscript) - - tt.mutate(&model) - (&model).syncViewportContent() - if tt.expectNew { - require.NotEqual(t, firstTranscript, model.lastTranscript) - } else { - require.Equal(t, firstTranscript, model.lastTranscript) - } - }) - } - }) - }) - - t.Run("SpinnerTickOnlyRefreshesWhenVisible", func(t *testing.T) { - t.Parallel() - - model := newTestChatViewModel(nil) - model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 10}) - chat := testChat(codersdk.ChatStatusRunning) - model.chat = &chat - model.chatStatus = chat.Status - model.messages = overflowingMessages(18) - model.rebuildBlocks() - - visibleTranscript := model.lastTranscript - updated, cmd := model.Update(model.spinner.Tick()) - require.NotNil(t, cmd) - require.NotEqual(t, visibleTranscript, updated.lastTranscript) - - updated.viewport.LineUp(3) - updated.autoFollow = false - require.False(t, updated.viewport.AtBottom()) - - hiddenTranscript := updated.lastTranscript - hiddenYOffset := updated.viewport.YOffset - updated, cmd = updated.Update(updated.spinner.Tick()) - require.NotNil(t, cmd) - require.Equal(t, hiddenTranscript, updated.lastTranscript) - require.Equal(t, hiddenYOffset, updated.viewport.YOffset) - }) - - t.Run("AskUserQuestion", func(t *testing.T) { - t.Parallel() - mustAskArgs := func(t testing.TB, questions ...parsedAskQuestion) string { - t.Helper() - payloadQuestions := make([]map[string]any, 0, len(questions)) - for _, question := range questions { - options := make([]map[string]string, 0, len(question.Options)) - for _, option := range question.Options { - options = append(options, map[string]string{ - "label": option.Label, - "value": option.Value, - }) - } - payloadQuestions = append(payloadQuestions, map[string]any{ - "header": question.Header, - "question": question.Question, - "options": options, - }) - } - output, err := json.Marshal(map[string]any{"questions": payloadQuestions}) - require.NoError(t, err) - return string(output) - } - askToolCall := func(t testing.TB, toolCallID string, questions ...parsedAskQuestion) codersdk.ChatStreamToolCall { - t.Helper() - return codersdk.ChatStreamToolCall{ - ToolCallID: toolCallID, - ToolName: "ask_user_question", - Args: mustAskArgs(t, questions...), - } - } - message := func(parts ...codersdk.ChatMessagePart) codersdk.ChatMessage { - return codersdk.ChatMessage{Content: parts} - } - toolCallPart := func(toolCallID, toolName, args string) codersdk.ChatMessagePart { - return codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: toolCallID, - ToolName: toolName, - Args: rawJSON(args), - } - } - toolResultPart := func(toolCallID, toolName, result string) codersdk.ChatMessagePart { - return codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolResult, - ToolCallID: toolCallID, - ToolName: toolName, - Result: rawJSON(result), - } - } - firstQuestion := parsedAskQuestion{ - Header: "Review plan", - Question: "What should happen next?", - Options: []parsedAskOption{ - {Label: "Approve", Value: "approve"}, - {Label: "Reject", Value: "reject"}, - }, - } - secondQuestion := parsedAskQuestion{ - Header: "Reason", - Question: "Why?", - Options: []parsedAskOption{ - {Label: "Speed", Value: "speed"}, - {Label: "Quality", Value: "quality"}, - }, - } - - t.Run("ParseToolCall", func(t *testing.T) { - t.Parallel() - - t.Run("ValidJSONWithOptions", func(t *testing.T) { - t.Parallel() - - state, err := parseAskUserQuestionToolCall(askToolCall(t, "tool-1", firstQuestion, secondQuestion)) - require.NoError(t, err) - require.Equal(t, "tool-1", state.ToolCallID) - require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, state.Questions) - require.Empty(t, state.Answers) - require.Zero(t, state.CurrentIndex) - require.Zero(t, state.OptionCursor) - }) - - t.Run("EmptyOrMissingQuestionsReturnsError", func(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - args string - }{ - {name: "MissingQuestions", args: `{}`}, - {name: "EmptyQuestions", args: `{"questions":[]}`}, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - state, err := parseAskUserQuestionToolCall(codersdk.ChatStreamToolCall{ - ToolCallID: "tool-1", - ToolName: "ask_user_question", - Args: tt.args, - }) - require.Nil(t, state) - require.ErrorContains(t, err, "at least one question") - }) - } - }) - - t.Run("MalformedJSONReturnsError", func(t *testing.T) { - t.Parallel() - - state, err := parseAskUserQuestionToolCall(codersdk.ChatStreamToolCall{ - ToolCallID: "tool-1", - ToolName: "ask_user_question", - Args: `{"questions":[`, - }) - require.Nil(t, state) - require.ErrorContains(t, err, "parse ask_user_question args") - }) - }) - - t.Run("BuildToolResult", func(t *testing.T) { - t.Parallel() - - t.Run("AnswersMarshalToJSON", func(t *testing.T) { - t.Parallel() - - output, err := buildAskUserQuestionToolResult(&askUserQuestionState{ - Answers: []askQuestionAnswer{{ - Header: firstQuestion.Header, - Question: firstQuestion.Question, - Answer: "approve", - OptionLabel: "Approve", - Freeform: false, - }}, - }) - require.NoError(t, err) - require.JSONEq(t, `{"answers":[{"header":"Review plan","question":"What should happen next?","answer":"approve","option_label":"Approve","freeform":false}]}`, string(output)) - }) - - t.Run("NoAnswersUsesEmptyArray", func(t *testing.T) { - t.Parallel() - - output, err := buildAskUserQuestionToolResult(&askUserQuestionState{}) - require.NoError(t, err) - require.JSONEq(t, `{"answers":[]}`, string(output)) - }) - }) - - t.Run("FindPending", func(t *testing.T) { - t.Parallel() - - t.Run("NoMessagesReturnsNil", func(t *testing.T) { - t.Parallel() - - state, err := findPendingAskUserQuestion(nil) - require.NoError(t, err) - require.Nil(t, state) - }) - - t.Run("ServerToolResultStillReturnsPendingState", func(t *testing.T) { - t.Parallel() - - messages := []codersdk.ChatMessage{ - message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), - message(toolResultPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), - } - state, err := findPendingAskUserQuestion(messages) - require.NoError(t, err) - require.NotNil(t, state) - require.Equal(t, "tool-1", state.ToolCallID) - require.Equal(t, []parsedAskQuestion{firstQuestion}, state.Questions) - }) - - t.Run("UserAnsweredToolCallReturnsNil", func(t *testing.T) { - t.Parallel() - - messages := []codersdk.ChatMessage{ - message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), - message(toolResultPart("tool-1", "ask_user_question", `{"answers":[{"answer":"approve"}]}`)), - } - state, err := findPendingAskUserQuestion(messages) - require.NoError(t, err) - require.Nil(t, state) - }) - - t.Run("UnmatchedToolCallReturnsParsedState", func(t *testing.T) { - t.Parallel() - - messages := []codersdk.ChatMessage{ - message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion, secondQuestion))), - message(codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"}), - } - state, err := findPendingAskUserQuestion(messages) - require.NoError(t, err) - require.NotNil(t, state) - require.Equal(t, "tool-1", state.ToolCallID) - require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, state.Questions) - }) - - t.Run("NonAskUserQuestionToolCallReturnsNil", func(t *testing.T) { - t.Parallel() - - messages := []codersdk.ChatMessage{ - message(toolCallPart("tool-1", "search_docs", `{"query":"overlay"}`)), - } - state, err := findPendingAskUserQuestion(messages) - require.NoError(t, err) - require.Nil(t, state) - }) - }) - - t.Run("HandleStreamEventActionRequired", func(t *testing.T) { - t.Parallel() - - t.Run("AskUserQuestionShowsOverlay", func(t *testing.T) { - t.Parallel() - - model := newTestChatViewModel(nil) - updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeActionRequired, - ActionRequired: &codersdk.ChatStreamActionRequired{ - ToolCalls: []codersdk.ChatStreamToolCall{askToolCall(t, "tool-1", firstQuestion)}, - }, - }) - require.NotNil(t, updated.pendingAskUserQuestion) - require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID) - require.Equal(t, []parsedAskQuestion{firstQuestion}, updated.pendingAskUserQuestion.Questions) - showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg) - require.True(t, ok) - require.Same(t, updated.pendingAskUserQuestion, showMsg.state) - }) - - t.Run("NonAskUserQuestionToolCallIsIgnored", func(t *testing.T) { - t.Parallel() - - model := newTestChatViewModel(nil) - updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeActionRequired, - ActionRequired: &codersdk.ChatStreamActionRequired{ - ToolCalls: []codersdk.ChatStreamToolCall{{ - ToolCallID: "tool-1", - ToolName: "search_docs", - Args: `{"query":"overlay"}`, - }}, - }, - }) - require.Nil(t, updated.pendingAskUserQuestion) - require.Nil(t, cmd) - }) - - t.Run("MalformedArgsReturnErrorEvent", func(t *testing.T) { - t.Parallel() - - model := newTestChatViewModel(nil) - model.activeChatID = uuid.New() - model.chatGeneration = 7 - updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeActionRequired, - ActionRequired: &codersdk.ChatStreamActionRequired{ - ToolCalls: []codersdk.ChatStreamToolCall{{ - ToolCallID: "tool-1", - ToolName: "ask_user_question", - Args: `{"questions":[`, - }}, - }, - }) - require.Nil(t, updated.pendingAskUserQuestion) - streamMsg, ok := mustMsg(t, cmd).(chatStreamEventMsg) - require.True(t, ok) - require.Equal(t, uint64(7), streamMsg.generation) - require.Equal(t, model.activeChatID, streamMsg.chatID) - require.Equal(t, codersdk.ChatStreamEventTypeError, streamMsg.event.Type) - require.NotNil(t, streamMsg.event.Error) - require.Contains(t, streamMsg.event.Error.Message, "failed to parse ask_user_question") - - updated = mustChatViewUpdate(t, updated, streamMsg) - require.EqualError(t, updated.err, "stream error: "+streamMsg.event.Error.Message) - }) - }) - - t.Run("HandleStreamEventStatusRequiresAction", func(t *testing.T) { - t.Parallel() - - t.Run("RecoversFromMessages", func(t *testing.T) { - t.Parallel() - - chat := testChat(codersdk.ChatStatusRunning) - model := newTestChatViewModel(nil) - model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status - model.messages = []codersdk.ChatMessage{ - message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), - message(toolResultPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), - } - - updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeStatus, - ChatID: chat.ID, - Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRequiresAction}, - }) - require.Equal(t, codersdk.ChatStatusRequiresAction, updated.chatStatus) - require.NotNil(t, updated.pendingAskUserQuestion) - require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID) - require.Equal(t, []parsedAskQuestion{firstQuestion}, updated.pendingAskUserQuestion.Questions) - showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg) - require.True(t, ok) - require.Same(t, updated.pendingAskUserQuestion, showMsg.state) - }) - - t.Run("RecoversFromAccumulatorBeforeFinalMessage", func(t *testing.T) { - t.Parallel() - - chat := testChat(codersdk.ChatStatusRunning) - model := newTestChatViewModel(nil) - model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status - model.accumulator.parts = []codersdk.ChatMessagePart{ - toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion, secondQuestion)), - } - model.accumulator.pending = true - - updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ - Type: codersdk.ChatStreamEventTypeStatus, - ChatID: chat.ID, - Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRequiresAction}, - }) - require.Equal(t, codersdk.ChatStatusRequiresAction, updated.chatStatus) - require.NotNil(t, updated.pendingAskUserQuestion) - require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID) - require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, updated.pendingAskUserQuestion.Questions) - showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg) - require.True(t, ok) - require.Same(t, updated.pendingAskUserQuestion, showMsg.state) - }) - }) - - t.Run("OverlayLifecycle", func(t *testing.T) { - t.Parallel() - - newOverlayState := func() *askUserQuestionState { - return newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - } - - t.Run("ShowOpensOverlay", func(t *testing.T) { - t.Parallel() - - state := newOverlayState() - model := newTestTUIModel() - model.currentView = viewChat - - updatedModel, cmd := model.Update(showAskUserQuestionMsg{state: state}) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayAskUserQuestion, updated.overlay) - require.Same(t, state, updated.chat.pendingAskUserQuestion) - }) - - t.Run("HideClosesOverlay", func(t *testing.T) { - t.Parallel() - - state := newOverlayState() - model := newTestTUIModel() - model.currentView = viewChat - model.overlay = overlayAskUserQuestion - model.chat.pendingAskUserQuestion = state - - updatedModel, cmd := model.Update(hideAskUserQuestionMsg{}) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - require.Same(t, state, updated.chat.pendingAskUserQuestion) - }) - - t.Run("EscapeDoesNotCloseOverlay", func(t *testing.T) { - t.Parallel() - - state := newOverlayState() - model := newTestTUIModel() - model.currentView = viewChat - model.overlay = overlayAskUserQuestion - model.chat.pendingAskUserQuestion = state - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayAskUserQuestion, updated.overlay) - require.Same(t, state, updated.chat.pendingAskUserQuestion) - }) - - t.Run("SuccessfulSubmitClearsOverlay", func(t *testing.T) { - t.Parallel() - - state := newOverlayState() - model := newTestTUIModel() - model.currentView = viewChat - model.overlay = overlayAskUserQuestion - model.chat.pendingAskUserQuestion = state - model.chat.activeChatID = uuid.New() - model.chat.chatGeneration = 11 - - updatedModel, cmd := model.Update(toolResultsSubmittedMsg{ - generation: 11, - chatID: model.chat.activeChatID, - }) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayNone, updated.overlay) - require.Nil(t, updated.chat.pendingAskUserQuestion) - }) - - t.Run("SubmitErrorKeepsOverlayOpen", func(t *testing.T) { - t.Parallel() - - state := newOverlayState() - state.Submitting = true - model := newTestTUIModel() - model.currentView = viewChat - model.overlay = overlayAskUserQuestion - model.chat.pendingAskUserQuestion = state - model.chat.activeChatID = uuid.New() - model.chat.chatGeneration = 11 - - updatedModel, cmd := model.Update(toolResultsSubmittedMsg{ - generation: 11, - chatID: model.chat.activeChatID, - err: xerrors.New("submit failed"), - }) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayAskUserQuestion, updated.overlay) - require.NotNil(t, updated.chat.pendingAskUserQuestion) - require.False(t, updated.chat.pendingAskUserQuestion.Submitting) - require.EqualError(t, updated.chat.pendingAskUserQuestion.Error, "submit failed") - }) - - t.Run("StaleSubmitIsIgnored", func(t *testing.T) { - t.Parallel() - - state := newOverlayState() - state.Submitting = true - model := newTestTUIModel() - model.currentView = viewChat - model.overlay = overlayAskUserQuestion - model.chat.pendingAskUserQuestion = state - model.chat.activeChatID = uuid.New() - model.chat.chatGeneration = 11 - - updatedModel, cmd := model.Update(toolResultsSubmittedMsg{ - generation: 10, - chatID: model.chat.activeChatID, - }) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayAskUserQuestion, updated.overlay) - require.Same(t, state, updated.chat.pendingAskUserQuestion) - require.True(t, updated.chat.pendingAskUserQuestion.Submitting) - require.NoError(t, updated.chat.pendingAskUserQuestion.Error) - }) - }) - - t.Run("KeyHandling", func(t *testing.T) { - t.Parallel() - - t.Run("UpAndDownNavigateOptions", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - model := newTestTUIModel() - model.chat.pendingAskUserQuestion = state - - require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyDown})) - require.Equal(t, 1, state.OptionCursor) - require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyUp})) - require.Zero(t, state.OptionCursor) - require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyUp})) - require.Equal(t, len(firstQuestion.Options), state.OptionCursor) - }) - - t.Run("EnterOnOptionRecordsAnswerAndAdvances", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion}) - state.OptionCursor = 1 - model := newTestTUIModel() - model.chat.pendingAskUserQuestion = state - - cmd := model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyEnter}) - require.Nil(t, cmd) - require.Len(t, state.Answers, 1) - require.Equal(t, askQuestionAnswer{ - Header: firstQuestion.Header, - Question: firstQuestion.Question, - Answer: "reject", - OptionLabel: "Reject", - Freeform: false, - }, state.Answers[0]) - require.Equal(t, 1, state.CurrentIndex) - require.Zero(t, state.OptionCursor) - }) - - t.Run("EnterOnOtherEntersFreeformMode", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - state.OptionCursor = len(firstQuestion.Options) - model := newTestTUIModel() - model.chat.pendingAskUserQuestion = state - - cmd := model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyEnter}) - require.Nil(t, cmd) - require.True(t, state.OtherMode) - require.Empty(t, state.OtherInput.Value()) - }) - - t.Run("EscapeInFreeformModeExitsOnlyInput", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - state.OtherMode = true - state.OtherInput.Focus() - state.OtherInput.SetValue("typed answer") - model := newTestTUIModel() - model.currentView = viewChat - model.overlay = overlayAskUserQuestion - model.chat.pendingAskUserQuestion = state - - updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated := mustTUIModel(t, updatedModel, cmd) - require.Equal(t, overlayAskUserQuestion, updated.overlay) - require.NotNil(t, updated.chat.pendingAskUserQuestion) - require.False(t, updated.chat.pendingAskUserQuestion.OtherMode) - require.Equal(t, "typed answer", updated.chat.pendingAskUserQuestion.OtherInput.Value()) - }) - - t.Run("LeftOrHMovesBackToPreviousQuestion", func(t *testing.T) { - t.Parallel() - - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion}) - state.CurrentIndex = 1 - state.OptionCursor = 1 - state.Error = xerrors.New("temporary error") - state.Answers = []askQuestionAnswer{{ - Header: firstQuestion.Header, - Question: firstQuestion.Question, - Answer: "approve", - OptionLabel: "Approve", - Freeform: false, - }} - model := newTestTUIModel() - model.chat.pendingAskUserQuestion = state - - cmd := model.handleAskUserQuestionKey(keyRunes("h")) - require.Nil(t, cmd) - require.Zero(t, state.CurrentIndex) - require.Zero(t, state.OptionCursor) - require.False(t, state.OtherMode) - require.Nil(t, state.Error) - require.Empty(t, state.Answers) - }) - }) - - t.Run("RecordAskAnswer", func(t *testing.T) { - t.Parallel() - - model := newChatsTUIModel(context.Background(), failingExperimentalClient(), nil, nil, nil, uuid.Nil) - model.chat.activeChatID = uuid.New() - model.chat.chatGeneration = 4 - state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - state.OtherMode = true - state.OtherInput.Focus() - state.OtherInput.SetValue("custom answer") - model.chat.pendingAskUserQuestion = state - - cmd := model.recordAskAnswer("custom answer", "", true) - require.NotNil(t, cmd) - require.True(t, state.Submitting) - require.Len(t, state.Answers, 1) - require.Equal(t, askQuestionAnswer{ - Header: firstQuestion.Header, - Question: firstQuestion.Question, - Answer: "custom answer", - Freeform: true, - }, state.Answers[0]) - require.False(t, state.OtherMode) - require.Empty(t, state.OtherInput.Value()) - }) - - t.Run("ComposerBlocksEnterWhileQuestionPending", func(t *testing.T) { - t.Parallel() - - baseline := newTestChatViewModel(failingExperimentalClient()) - baseline.draft = true - baseline.loading = false - baseline.composer.SetValue("send this") - - updated, cmd := baseline.Update(tea.KeyMsg{Type: tea.KeyEnter}) - require.NotNil(t, cmd) - require.True(t, updated.creatingChat) - require.Empty(t, updated.composer.Value()) - - blocked := newTestChatViewModel(failingExperimentalClient()) - blocked.draft = true - blocked.loading = false - blocked.composer.SetValue("send this") - blocked.pendingAskUserQuestion = newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) - - updated, cmd = blocked.Update(tea.KeyMsg{Type: tea.KeyEnter}) - require.Nil(t, cmd) - require.False(t, updated.creatingChat) - require.Equal(t, "send this", updated.composer.Value()) - require.Empty(t, updated.pendingComposerText) - }) - }) - - t.Run("ChatList", func(t *testing.T) { - t.Parallel() - newChat := func(status codersdk.ChatStatus, title string, parent *uuid.UUID) codersdk.Chat { - chat := testChat(status) - chat.Title, chat.ParentChatID = title, parent - return chat - } - newList := func(chats ...codersdk.Chat) chatListModel { - model := newReadyChatListModel() - model.chats = chats - return model - } - mustUpdate := func(t testing.TB, model chatListModel, msg tea.Msg) chatListModel { - t.Helper() - updated, cmd := model.Update(msg) - require.Nil(t, cmd) - return updated - } - requireRows := func(t testing.TB, rows []chatDisplayRow, wantChats []codersdk.Chat, wantDepths ...int) { - t.Helper() - require.Len(t, rows, len(wantChats)) - for i, want := range wantChats { - require.Equal(t, want.ID, rows[i].chat.ID) - require.Equal(t, wantDepths[i], rows[i].depth) - } - } - t.Run("ChatsListedUpdatesState", func(t *testing.T) { - t.Parallel() - for _, tt := range []struct { - name string - msg chatsListedMsg - wantChats int - wantErr string - }{ - {name: "StoresChats", msg: chatsListedMsg{chats: []codersdk.Chat{testChat(codersdk.ChatStatusWaiting), testChat(codersdk.ChatStatusCompleted)}}, wantChats: 2}, - {name: "StoresErr", msg: chatsListedMsg{err: xerrors.New("list failed")}, wantErr: "list failed"}, - } { - updated, cmd := newChatListModel(newTUIStyles()).Update(tt.msg) - require.Nilf(t, cmd, tt.name) - require.Falsef(t, updated.loading, tt.name) - require.Lenf(t, updated.chats, tt.wantChats, tt.name) - if tt.wantErr == "" { - require.NoErrorf(t, updated.err, tt.name) - continue - } - require.EqualErrorf(t, updated.err, tt.wantErr, tt.name) - } - }) - t.Run("ParentExpansionAndCollapse", func(t *testing.T) { - t.Parallel() - parent := newChat(codersdk.ChatStatusRunning, "Parent chat", nil) - childA := newChat(codersdk.ChatStatusWaiting, "Subagent A", &parent.ID) - childB := newChat(codersdk.ChatStatusPending, "Subagent B", &parent.ID) - root := newChat(codersdk.ChatStatusCompleted, "Standalone chat", nil) - model := newList(parent, childA, childB, root) - requireRows(t, model.displayRows(), []codersdk.Chat{parent, root}, 0, 0) - output := plainText(model.View()) - require.Contains(t, output, "▶ Parent chat") - require.Contains(t, output, "(2 subagents)") - require.NotContains(t, output, childA.Title) - require.NotContains(t, output, childB.Title) - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight}) - require.True(t, model.expanded[parent.ID]) - requireRows(t, model.displayRows(), []codersdk.Chat{parent, childA, childB, root}, 0, 1, 1, 0) - model = mustUpdate(t, model, keyRunes("x")) - require.False(t, model.expanded[parent.ID]) - requireRows(t, model.displayRows(), []codersdk.Chat{parent, root}, 0, 0) - model = mustUpdate(t, model, keyRunes("x")) - require.True(t, model.expanded[parent.ID]) - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft}) - require.False(t, model.expanded[parent.ID]) - require.Zero(t, model.cursor) - }) - t.Run("NestedExpansionNavigationAndOpenSelectedChat", func(t *testing.T) { - t.Parallel() - parent := newChat(codersdk.ChatStatusRunning, "Parent chat", nil) - child := newChat(codersdk.ChatStatusWaiting, "Child subagent", &parent.ID) - grandchild := newChat(codersdk.ChatStatusPending, "Grandchild subagent", &child.ID) - root := newChat(codersdk.ChatStatusCompleted, "Standalone chat", nil) - model := newList(parent, child, grandchild, root) - model.width, model.height = 100, 10 - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight}) - require.True(t, model.expanded[parent.ID]) - requireRows(t, model.displayRows(), []codersdk.Chat{parent, child, root}, 0, 1, 0) - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown}) - selected := model.selectedChat() - require.NotNil(t, selected) - require.Equal(t, child.ID, selected.ID) - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight}) - require.True(t, model.expanded[child.ID]) - requireRows(t, model.displayRows(), []codersdk.Chat{parent, child, grandchild, root}, 0, 1, 2, 0) - require.Contains(t, plainText(model.View()), "Grandchild subagent") - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown}) - selected = model.selectedChat() - require.NotNil(t, selected) - require.Equal(t, grandchild.ID, selected.ID) - model, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) - openMsg, ok := mustMsg(t, cmd).(openSelectedChatMsg) - require.True(t, ok) - require.Equal(t, grandchild.ID, openMsg.chatID) - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft}) - require.False(t, model.expanded[child.ID]) - require.Equal(t, child.ID, model.selectedChat().ID) - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft}) - require.False(t, model.expanded[parent.ID]) - require.Equal(t, parent.ID, model.selectedChat().ID) - }) - t.Run("SearchIncludesVisibleAncestorChain", func(t *testing.T) { - t.Parallel() - for _, depth := range []int{1, 2} { - model := newReadyChatListModel() - chain := make([]codersdk.Chat, 0, depth+1) - wantDepths := make([]int, 0, depth+1) - var parentID *uuid.UUID - for d := 0; d <= depth; d++ { - title := "Root chat" - if d > 0 { - title = fmt.Sprintf("Subagent depth %d", d) - } - if d == depth { - title += " needle" - } - chain = append(chain, newChat(codersdk.ChatStatusWaiting, title, parentID)) - parentID = &chain[len(chain)-1].ID - wantDepths = append(wantDepths, d) - } - model.chats = append([]codersdk.Chat{}, chain...) - model.chats = append(model.chats, newChat(codersdk.ChatStatusCompleted, "Other root", nil)) - model.search.SetValue("needle") - rows := model.displayRows() - requireRows(t, rows, chain, wantDepths...) - for i, row := range rows { - require.Equalf(t, i < depth, row.isExpanded, "depth=%d row=%d", depth, i) - } - } - }) - t.Run("NavigationKeysMoveCursorWithinBounds", func(t *testing.T) { - t.Parallel() - chats := []codersdk.Chat{testChat(codersdk.ChatStatusWaiting), testChat(codersdk.ChatStatusPending), testChat(codersdk.ChatStatusCompleted)} - for _, tt := range []struct { - name string - key tea.KeyMsg - want int - }{ - {name: "Up", key: tea.KeyMsg{Type: tea.KeyUp}, want: 0}, - {name: "Down", key: tea.KeyMsg{Type: tea.KeyDown}, want: 2}, - {name: "J", key: keyRunes("j"), want: 2}, - {name: "K", key: keyRunes("k"), want: 0}, - } { - model := newList(chats...) - model.cursor = 1 - model = mustUpdate(t, model, tt.key) - require.Equalf(t, tt.want, model.cursor, tt.name) - model = mustUpdate(t, model, tt.key) - require.Equalf(t, tt.want, model.cursor, tt.name) - } - }) - t.Run("ViewKeepsSelectedChatVisible", func(t *testing.T) { - t.Parallel() - model := newReadyChatListModel() - model.width, model.height = 80, 8 - for i := range 8 { - model.chats = append(model.chats, newChat(codersdk.ChatStatusWaiting, fmt.Sprintf("chat %02d", i), nil)) - } - for range 6 { - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown}) - } - require.Equal(t, 2, model.offset) - listView := plainText(model.View()) - require.Contains(t, listView, "> chat 06") - require.NotContains(t, listView, "chat 00") - parent := newTestTUIModel() - parent.width, parent.height, parent.list = 80, 8, model - parentView := plainText(parent.View()) - require.Contains(t, parentView, "Coder Chats") - require.Contains(t, parentView, "> chat 06") - for range 5 { - model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyUp}) - } - require.Equal(t, 1, model.offset) - require.Contains(t, plainText(model.View()), "> chat 01") - }) - t.Run("EmptyListDisplaysNoChatsMessage", func(t *testing.T) { - t.Parallel() - updated, cmd := newChatListModel(newTUIStyles()).Update(chatsListedMsg{chats: []codersdk.Chat{}}) - require.Nil(t, cmd) - require.Contains(t, plainText(updated.View()), "No chats yet") - }) - }) -} - -func TestAgents_View_LongInputFitsTerminal(t *testing.T) { - t.Parallel() - model := newTestChatViewModel(nil) - model.width, model.height = 80, 24 - model.setComposerWidth() - model.recalcViewportHeight() - model.syncViewportContent() - chat := testChat(codersdk.ChatStatusCompleted) - model.chat = &chat - model.chatStatus = chat.Status - model.messages = overflowingMessages(24) - model.rebuildBlocks() - - defaultViewportHeight := model.viewport.Height - model.composer.SetValue(strings.Repeat("a", 250)) - model.recalcViewportHeight() - model.syncViewportContent() - - view := plainText(model.View()) - lines := strings.Split(view, "\n") - - require.LessOrEqual(t, len(lines), model.height) - require.LessOrEqual(t, model.viewport.Height, defaultViewportHeight) - require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1])) -} - -func mustTUIModel(t testing.TB, model tea.Model, cmd tea.Cmd) chatsTUIModel { - t.Helper() - updated, ok := model.(chatsTUIModel) - require.True(t, ok) - require.Nil(t, cmd) - return updated -} - -func mustTUIModelWithCmd(t testing.TB, model tea.Model, cmd tea.Cmd) (chatsTUIModel, tea.Cmd) { - t.Helper() - updated, ok := model.(chatsTUIModel) - require.True(t, ok) - return updated, cmd -} - -func mustChatViewUpdate(t testing.TB, model chatViewModel, msg tea.Msg) chatViewModel { - t.Helper() - updated, cmd := model.Update(msg) - require.Nil(t, cmd) - return updated -} - -func mustMsg(t testing.TB, cmd tea.Cmd) tea.Msg { t.Helper(); require.NotNil(t, cmd); return cmd() } - -func mustBatchMsg(t testing.TB, cmd tea.Cmd) tea.BatchMsg { - t.Helper() - batch, ok := mustMsg(t, cmd).(tea.BatchMsg) - require.True(t, ok) - return batch -} - -func assertStreamCase(t testing.TB, model chatViewModel, wantMessages int, wantAccumulatorText, wantAccumulatorArgs string, wantBlockKind chatBlockKind, wantBlockText, wantBlockArgs string, wantUsage *codersdk.ChatMessageUsage) { - t.Helper() - wantPending := wantAccumulatorText != "" || wantAccumulatorArgs != "" - require.Len(t, model.messages, wantMessages) - require.Equal(t, wantPending, model.accumulator.isPending()) - if wantPending { - require.Len(t, model.accumulator.parts, 1) - if wantAccumulatorText != "" { - require.Equal(t, wantAccumulatorText, model.accumulator.parts[0].Text) - } - if wantAccumulatorArgs != "" { - require.Equal(t, wantAccumulatorArgs, string(model.accumulator.parts[0].Args)) - } - } else { - require.Empty(t, model.accumulator.parts) - } - require.Len(t, model.blocks, 1) - require.Equal(t, wantBlockKind, model.blocks[0].kind) - if wantBlockText != "" { - require.Equal(t, wantBlockText, model.blocks[0].text) - } - if wantBlockArgs != "" { - require.Equal(t, wantBlockArgs, model.blocks[0].args) - } - require.Equal(t, wantUsage, model.lastUsage) - require.False(t, model.reconnecting) -} - -func assertScrollNavigationCase(t testing.TB, before chatViewModel, after chatViewModel, wantBeforeYOffset int, wantAfterYOffset int, wantAutoFollow int, wantBeforeBottom int, wantAfterBottom int, yCheck int) { - t.Helper() - if wantAfterYOffset == 0 && wantBeforeYOffset == -1 { - require.NotZero(t, before.viewport.YOffset) - } - if wantBeforeYOffset != -1 { - require.Equal(t, wantBeforeYOffset, before.viewport.YOffset) - } - if wantAfterYOffset != -1 { - require.Equal(t, wantAfterYOffset, after.viewport.YOffset) - } - if wantAutoFollow != -1 { - require.Equal(t, wantAutoFollow == 1, after.autoFollow) - } - if wantBeforeBottom != -1 { - require.Equal(t, wantBeforeBottom == 1, before.viewport.AtBottom()) - } - if wantAfterBottom != -1 { - require.Equal(t, wantAfterBottom == 1, after.viewport.AtBottom()) - } - switch yCheck { - case 1: - require.Less(t, after.viewport.YOffset, before.viewport.YOffset) - case 2: - require.Greater(t, after.viewport.YOffset, before.viewport.YOffset) - case 3: - require.Equal(t, before.viewport.YOffset, after.viewport.YOffset) - case 4: - halfView := before.viewport.Height / 2 - require.InDelta(t, float64(before.viewport.YOffset-halfView), float64(after.viewport.YOffset), 1) - case 5: - halfView := before.viewport.Height / 2 - require.InDelta(t, float64(before.viewport.YOffset+halfView), float64(after.viewport.YOffset), 1) - } -} - -// newTestChatViewModel creates a chatViewModel for reducer tests. -// The returned model has chatGeneration=0, so test messages with -// default generation=0 pass the generation guard. -func newTestChatViewModel(client *codersdk.ExperimentalClient) chatViewModel { - return newChatViewModel(context.Background(), client, nil, nil, uuid.Nil, newTUIStyles()) -} - -func newTestTUIModel() chatsTUIModel { - return newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) -} - -func newReadyChatListModel() chatListModel { - model := newChatListModel(newTUIStyles()) - model.loading = false - return model -} - -func newTestExperimentalClient(t testing.TB, handler http.HandlerFunc) *codersdk.ExperimentalClient { - t.Helper() - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - serverURL, err := url.Parse(server.URL) - require.NoError(t, err) - return codersdk.NewExperimentalClient(codersdk.New(serverURL)) -} - -func overflowingMessages(count int) []codersdk.ChatMessage { - messages := make([]codersdk.ChatMessage, 0, count) - for i := 0; i < count; i++ { - role := codersdk.ChatMessageRoleUser - if i%2 == 1 { - role = codersdk.ChatMessageRoleAssistant - } - messages = append(messages, testMessage(int64(i+1), role, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: fmt.Sprintf("message %d %s", i+1, strings.Repeat("content ", 18))})) - } - return messages -} - -func testChat(status codersdk.ChatStatus) codersdk.Chat { - return codersdk.Chat{ID: uuid.New(), Title: "test chat", Status: status, CreatedAt: time.Now(), UpdatedAt: time.Now()} -} - -func testMessage(id int64, role codersdk.ChatMessageRole, parts ...codersdk.ChatMessagePart) codersdk.ChatMessage { - return codersdk.ChatMessage{ID: id, ChatID: uuid.New(), CreatedAt: time.Now(), Role: role, Content: parts} -} - -func testQueuedMessage(id int64, parts ...codersdk.ChatMessagePart) codersdk.ChatQueuedMessage { - return codersdk.ChatQueuedMessage{ID: id, ChatID: uuid.New(), CreatedAt: time.Now(), Content: parts} -} - -func testTextPartEvent(text string) codersdk.ChatStreamEvent { - return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{ - Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: text}, - }} -} - -func testToolCallDeltaEvent(toolCallID, toolName, delta string) codersdk.ChatStreamEvent { - return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{ - Role: codersdk.ChatMessageRoleAssistant, - Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: toolCallID, ToolName: toolName, ArgsDelta: delta}, - }} -} - -func failingExperimentalClient() *codersdk.ExperimentalClient { - return codersdk.NewExperimentalClient(codersdk.New(&url.URL{})) -} - -func keyRunes(value string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)} } - -func int64Ref(v int64) *int64 { - return &v -} diff --git a/cli/root.go b/cli/root.go index 6064796534..cac01f2003 100644 --- a/cli/root.go +++ b/cli/root.go @@ -100,7 +100,6 @@ const ( func (r *RootCmd) CoreSubcommands() []*serpent.Command { // Please re-sort this list alphabetically if you change it! return []*serpent.Command{ - r.agentsCommand(), r.completion(), r.dotfiles(), externalAuth(), diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 47c9b3a3f7..cb667c3a5c 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,7 +14,6 @@ USAGE: $ coder templates init SUBCOMMANDS: - agents Interactive terminal UI for AI agents. autoupdate Toggle auto-update policy for a workspace completion Install or update shell completion scripts for the detected or chosen shell. diff --git a/cli/testdata/coder_agents_--help.golden b/cli/testdata/coder_agents_--help.golden deleted file mode 100644 index eeeaa2b73a..0000000000 --- a/cli/testdata/coder_agents_--help.golden +++ /dev/null @@ -1,16 +0,0 @@ -coder v0.0.0-devel - -USAGE: - coder agents [flags] [chat-id] - - Interactive terminal UI for AI agents. - -OPTIONS: - --model string - Choose a model by ID, provider/model, or display name. - - --workspace string - Associate the chat with a workspace by name, owner/name, or UUID. - -——— -Run `coder --help` for a list of global options. diff --git a/docs/README.md b/docs/README.md index ed57b83fd0..8a1a09828b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,8 +31,8 @@ workspace infrastructure. [Coder Agents](./ai-coder/agents/index.md) is a native AI coding agent built into Coder. The agent loop runs in the Coder control plane on your infrastructure, not in the workspace and not in a vendor's cloud. Developers -interact with agents through the web UI, the CLI (`coder agents`), or the REST -API for programmatic and CI-driven workflows. +interact with agents through the web UI or the REST API for programmatic and +CI-driven workflows. - **Self-hosted agent loop**: The control plane handles planning, model calls, and tool dispatch. Workspaces have zero AI awareness. diff --git a/docs/ai-coder/agents/tasks-to-chats-migration.md b/docs/ai-coder/agents/tasks-to-chats-migration.md index a00b1ef12b..1d78edde8f 100644 --- a/docs/ai-coder/agents/tasks-to-chats-migration.md +++ b/docs/ai-coder/agents/tasks-to-chats-migration.md @@ -687,23 +687,14 @@ Chats API returns a `Chat` object with conversation-centric fields: ## CLI changes -The Tasks CLI (`coder task`) and the Coder Agents CLI are separate. Coder -ships an experimental TUI for Coder Agents at `coder exp agents` (planned -to graduate to `coder agents` in the May Beta release per -[#24432](https://github.com/coder/coder/pull/24432)). The TUI talks to the -same `/api/experimental/chats` endpoints documented in this guide; for -automation, prefer direct API calls. +The Tasks CLI (`coder task`) remains separate from the Coder Agents Chats API. +Coder no longer ships an interactive Coder Agents TUI. Use the web UI for +interactive chat and direct API calls for automation. -| Tasks CLI | Chats equivalent | -|---------------------|-----------------------------------------| -| `coder task create` | `coder exp agents` TUI or `POST /chats` | -| `coder task list` | `coder exp agents` TUI or `GET /chats` | -| `coder task logs` | `GET /chats/{chat}/stream` (WebSocket) | -| `coder task pause` | `POST /chats/{chat}/interrupt` | -| `coder task resume` | Send a follow-up message to the chat | - -> [!NOTE] -> The Coder Agents CLI today is an interactive TUI rather than a set of -> per-action subcommands like `coder task`. Use `curl`, the SDK, or your -> HTTP client of choice for non-interactive automation. Dedicated -> non-interactive subcommands may be added in a future release. +| Tasks CLI | Chats equivalent | +|---------------------|----------------------------------------| +| `coder task create` | Web UI or `POST /chats` | +| `coder task list` | Web UI or `GET /chats` | +| `coder task logs` | `GET /chats/{chat}/stream` (WebSocket) | +| `coder task pause` | `POST /chats/{chat}/interrupt` | +| `coder task resume` | Send a follow-up message to the chat | diff --git a/docs/ai-coder/index.md b/docs/ai-coder/index.md index cc00bb3495..1b2f0b96a2 100644 --- a/docs/ai-coder/index.md +++ b/docs/ai-coder/index.md @@ -24,8 +24,7 @@ deployment. Coder Agents is a native AI coding agent built into Coder. The agent loop runs in the Coder control plane on your infrastructure rather than inside the workspace, so workspaces can be completely network isolated. Developers -interact with agents through the web UI, the CLI (`coder agents`), or the -REST API. +interact with agents through the web UI or the REST API. ![Coder Agents chat interface with git diff sidebar](../images/agents-hero-image.png) diff --git a/docs/reference/cli/agents.md b/docs/reference/cli/agents.md deleted file mode 100644 index 64240ca561..0000000000 --- a/docs/reference/cli/agents.md +++ /dev/null @@ -1,28 +0,0 @@ - -# agents - -Interactive terminal UI for AI agents. - -## Usage - -```console -coder agents [flags] [chat-id] -``` - -## Options - -### --workspace - -| | | -|------|---------------------| -| Type | string | - -Associate the chat with a workspace by name, owner/name, or UUID. - -### --model - -| | | -|------|---------------------| -| Type | string | - -Choose a model by ID, provider/model, or display name. diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 5ebf171298..211cba86c8 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -24,7 +24,6 @@ Coder — A tool for provisioning self-hosted development environments with Terr | Name | Purpose | |--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| -| [agents](./agents.md) | Interactive terminal UI for AI agents. | | [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | | [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | | [external-auth](./external-auth.md) | Manage external authentication |