Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3f9f4c42b | |||
| a5a1d0df2c | |||
| 1ccbfc7842 | |||
| de94b1620c | |||
| 8aedc2f0b4 | |||
| 937f6ce61d | |||
| a7d0016fce | |||
| 3537f5ac64 | |||
| ab6b9bbc9c | |||
| 36c6ca76d5 | |||
| 97f887d92e | |||
| 5a373c5ad0 | |||
| 0cd9ba0243 | |||
| bca042065e | |||
| 991cf2abd2 | |||
| cdd396ef9a | |||
| b8ca81e5e3 | |||
| d4a65741db | |||
| 33faaf59ba | |||
| af655ce4df | |||
| 52d97a94c3 | |||
| d83573051b | |||
| 9b1bf8d73d | |||
| b3884a029f | |||
| 8cecf43d7c | |||
| 9e17971b57 | |||
| f7d0ac1976 | |||
| 62aef236a1 | |||
| 6d7704c18c | |||
| 2a9d9d9921 | |||
| f55238a39b | |||
| 7abac8208b | |||
| d5549824db | |||
| 737e6c89c0 | |||
| 4382d87254 | |||
| 1448bfb56b | |||
| 1eb7f79251 | |||
| dda7699bd9 | |||
| 5b339b7006 | |||
| dd37cff882 | |||
| dcaec82215 | |||
| f697ca4c91 | |||
| 99d71e85f7 | |||
| fe6a044c70 | |||
| e113665de8 | |||
| 5c6b5c75c4 | |||
| 49609962a1 | |||
| 2e09db271f | |||
| fc24c960e7 | |||
| 43334dd1ac | |||
| 04b8cd33c3 | |||
| dea3e060b9 | |||
| d55c24bcd9 | |||
| 8d601a04e4 | |||
| 5606b2ddd6 | |||
| a0663d31f8 | |||
| 6c348fe4b2 | |||
| 31b9321591 | |||
| 9a4e009c05 | |||
| 5f442f42c2 | |||
| 2cb066d516 | |||
| 28ef88d985 | |||
| 4fc751aa7e | |||
| 4f52849462 | |||
| 6d1fd2fb78 | |||
| 3b878463fb | |||
| aa1444154a | |||
| 194cd113e2 | |||
| 3855255e04 | |||
| 9b7be2404e | |||
| 2fabaf0ab6 | |||
| 966b89c00b | |||
| 510b7f822b | |||
| bf6912a35d | |||
| eac1a5f5a4 | |||
| 63bfaef26e | |||
| 828ad21d60 | |||
| eeca70a801 | |||
| 22227b9477 | |||
| aa2d82c897 | |||
| d13e3ec573 | |||
| 0a60348500 | |||
| 45f1544b87 | |||
| 4dd40aca9e | |||
| 5cbe107ee2 | |||
| ba2293a05d | |||
| a60ad40866 | |||
| 7a21e8aadb | |||
| 013ecbaed6 | |||
| f7d3a097be | |||
| d887994345 | |||
| 29c606031d | |||
| abe00a6610 | |||
| 1efe526682 | |||
| 883d3eb279 | |||
| c23435112e | |||
| a16fd2bd04 | |||
| d20eab3460 |
@@ -0,0 +1,198 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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.",
|
||||
Aliases: []string{"agent"},
|
||||
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
|
||||
}
|
||||
|
||||
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 := namedWorkspace(inv.Context(), client, 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 := newExpChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID)
|
||||
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.(expChatsTUIModel); !ok {
|
||||
return xerrors.New(fmt.Sprintf("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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
gitChangesMsg struct {
|
||||
generation uint64
|
||||
chatID uuid.UUID
|
||||
changes []codersdk.ChatGitChange
|
||||
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
|
||||
}
|
||||
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 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}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"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 expAgentsPtr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func setupExpAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient) {
|
||||
t.Helper()
|
||||
|
||||
values := coderdtest.DeploymentValues(t)
|
||||
values.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: values,
|
||||
})
|
||||
_ = 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: expAgentsPtr(int64(4096)),
|
||||
IsDefault: expAgentsPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return client, expClient
|
||||
}
|
||||
|
||||
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
|
||||
func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.ExperimentalClient, seed string) codersdk.Chat {
|
||||
t.Helper()
|
||||
|
||||
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: seed,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
type expAgentsSession struct {
|
||||
t *testing.T
|
||||
pty *ptytest.PTY
|
||||
errCh <-chan error
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) expect(ctx context.Context, text string) {
|
||||
s.t.Helper()
|
||||
s.pty.ExpectMatchContext(ctx, text)
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) 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 *expAgentsSession) write(text string) {
|
||||
s.t.Helper()
|
||||
s.pty.WriteLine(text)
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) writeRune(r rune) {
|
||||
s.t.Helper()
|
||||
_, err := s.pty.Input().Write([]byte(string(r)))
|
||||
require.NoError(s.t, err)
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) enter() {
|
||||
s.t.Helper()
|
||||
_, err := s.pty.Input().Write([]byte("\r"))
|
||||
require.NoError(s.t, err)
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) esc() {
|
||||
s.t.Helper()
|
||||
_, err := s.pty.Input().Write([]byte("\x1b"))
|
||||
require.NoError(s.t, err)
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) ctrlC() {
|
||||
s.t.Helper()
|
||||
_, err := s.pty.Input().Write([]byte{3})
|
||||
require.NoError(s.t, err)
|
||||
}
|
||||
|
||||
func (s *expAgentsSession) quit() {
|
||||
s.t.Helper()
|
||||
s.writeRune('q')
|
||||
}
|
||||
|
||||
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
|
||||
func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *expAgentsSession {
|
||||
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{"exp", "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 &expAgentsSession{t: t, pty: pty, errCh: errCh}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestExpAgentsE2E(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyStateBoot", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := setupExpAgentsBackend(t)
|
||||
session := startExpAgentsSession(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 := setupExpAgentsBackend(t)
|
||||
|
||||
_ = seedChat(t, ctx, expClient, "alpha nav seed")
|
||||
_ = seedChat(t, ctx, expClient, "bravo nav seed")
|
||||
_ = seedChat(t, ctx, expClient, "charlie nav seed")
|
||||
|
||||
session := startExpAgentsSession(t, ctx, client)
|
||||
|
||||
session.expect(ctx, "charlie nav seed")
|
||||
session.expect(ctx, "enter: open")
|
||||
session.enter()
|
||||
session.expect(ctx, "esc: back")
|
||||
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 := setupExpAgentsBackend(t)
|
||||
|
||||
_ = seedChat(t, ctx, expClient, "alpha filter seed")
|
||||
_ = seedChat(t, ctx, expClient, "zulu filter seed")
|
||||
|
||||
session := startExpAgentsSession(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 := setupExpAgentsBackend(t)
|
||||
|
||||
chat := seedChat(t, ctx, expClient, "direct open seed")
|
||||
session := startExpAgentsSession(t, ctx, client, chat.ID.String())
|
||||
|
||||
session.expect(ctx, "direct open seed")
|
||||
session.expect(ctx, "esc: back")
|
||||
session.esc()
|
||||
session.expect(ctx, "enter: open")
|
||||
session.quit()
|
||||
require.NoError(t, session.wait(ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
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), 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 {
|
||||
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(*row.chat.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)))
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
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
|
||||
)
|
||||
|
||||
type (
|
||||
terminateTUIMsg struct{}
|
||||
expChatsTUIModel 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
|
||||
chatGeneration uint64
|
||||
catalog *codersdk.ChatModelsResponse
|
||||
quitting bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
)
|
||||
|
||||
func newExpChatsTUIModel(
|
||||
ctx context.Context,
|
||||
client *codersdk.ExperimentalClient,
|
||||
initialChatID *uuid.UUID,
|
||||
workspaceID *uuid.UUID,
|
||||
modelOverride *string,
|
||||
) expChatsTUIModel {
|
||||
styles := newTUIStyles()
|
||||
currentView := viewList
|
||||
if initialChatID != nil {
|
||||
currentView = viewChat
|
||||
}
|
||||
chat := newChatViewModel(ctx, client, workspaceID, modelOverride, 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 expChatsTUIModel{
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
styles: styles,
|
||||
currentView: currentView,
|
||||
overlay: overlayNone,
|
||||
list: newChatListModel(styles),
|
||||
chat: chat,
|
||||
initialChatID: initialChatID,
|
||||
workspaceID: workspaceID,
|
||||
modelOverride: modelOverride,
|
||||
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 *expChatsTUIModel) resetChatSession() {
|
||||
old := m.chat
|
||||
m.chat = newChatViewModel(m.ctx, m.client, m.workspaceID, m.modelOverride, 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 *expChatsTUIModel) 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 expChatsTUIModel) 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 expChatsTUIModel) 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 expChatsTUIModel) 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 expChatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg {
|
||||
return tea.WindowSizeMsg{Width: m.width, Height: max(0, m.height-1)}
|
||||
}
|
||||
|
||||
func (m *expChatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
|
||||
if m.overlay == overlay {
|
||||
m.overlay = overlayNone
|
||||
return false
|
||||
}
|
||||
m.overlay = overlay
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *expChatsTUIModel) 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 *expChatsTUIModel) 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 *expChatsTUIModel) 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 *expChatsTUIModel) 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 *expChatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
|
||||
if m.chat.chat == nil {
|
||||
return nil
|
||||
}
|
||||
if !m.toggleOverlay(overlayDiffDrawer) {
|
||||
return nil
|
||||
}
|
||||
if m.chat.gitChanges == nil || m.chat.diffContents == nil || m.chat.diffErr != nil {
|
||||
m.chat.diffErr = nil
|
||||
chatID := m.chat.chat.ID
|
||||
generation := m.chat.chatGeneration
|
||||
return tea.Batch(apiCmd(func() ([]codersdk.ChatGitChange, error) { return m.client.GetChatGitChanges(m.ctx, chatID) }, func(changes []codersdk.ChatGitChange, err error) tea.Msg {
|
||||
return gitChangesMsg{generation: generation, chatID: chatID, changes: changes, err: err}
|
||||
}), apiCmd(func() (codersdk.ChatDiffContents, error) { return m.client.GetChatDiffContents(m.ctx, chatID) }, func(diff codersdk.ChatDiffContents, err error) tea.Msg {
|
||||
return diffContentsMsg{generation: generation, chatID: chatID, diff: diff, err: err}
|
||||
}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m expChatsTUIModel) updateChild(msg tea.Msg, view tuiView) (expChatsTUIModel, 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 expChatsTUIModel) 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 expChatsTUIModel) 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.gitChanges, 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 expChatsTUIModel) 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 == 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 chatsListedMsg:
|
||||
return m.updateChild(msg, viewList)
|
||||
case chatOpenedMsg, chatHistoryMsg, chatStreamEventMsg, messageSentMsg, chatCreatedMsg, chatInterruptedMsg, gitChangesMsg, 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 expChatsTUIModel) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
body := m.list.View()
|
||||
if m.currentView == viewChat {
|
||||
body = m.chat.View()
|
||||
}
|
||||
base := m.styles.title.Render("Coder Chats") + "\n" + body
|
||||
switch m.overlay {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
"⏳",
|
||||
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 renderChatDiffSummary(diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange) string {
|
||||
lines := diffMetadataLines(diff)
|
||||
if len(changes) == 0 {
|
||||
if len(lines) > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, "No changes detected.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, "Files changed:")
|
||||
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)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %-8s %s", change.ChangeType, path))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange, 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, " • ")))
|
||||
}
|
||||
summary := renderChatDiffSummary(diff, changes)
|
||||
diffBody := sanitizeTerminalRenderableText(diff.Diff)
|
||||
if strings.TrimSpace(diffBody) == "" {
|
||||
diffBody = styles.dimmedText.Render("No diff contents.")
|
||||
}
|
||||
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"))
|
||||
}
|
||||
|
||||
//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.selectedItem.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("💭 "+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, "⏳", 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
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
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 TestExpAgentsRender(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, "⏳")
|
||||
}},
|
||||
} {
|
||||
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{"💭 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")
|
||||
require.Contains(t, output, "✓ read file")
|
||||
require.Contains(t, output, "done")
|
||||
require.Less(t, strings.Index(output, "You: hello"), strings.Index(output, "💭 thinking"))
|
||||
require.Less(t, strings.Index(output, "💭 thinking"), strings.Index(output, "✓ read file"))
|
||||
require.Less(t, strings.Index(output, "✓ read file"), strings.LastIndex(output, "done"))
|
||||
})
|
||||
|
||||
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
|
||||
changes []codersdk.ChatGitChange
|
||||
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+added line"}, changes: []codersdk.ChatGitChange{{FilePath: "a.txt", ChangeType: "modified"}}, assert: func(t *testing.T, output string) {
|
||||
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.") }},
|
||||
} {
|
||||
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, tt.changes, 90, 20)) })
|
||||
tt.assert(t, output)
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Run("RenderDiffDrawerSanitizesUntrustedContent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawOutput := renderDiffDrawer(
|
||||
styles,
|
||||
codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n+safe\x1b]52;c;clipboard\x07line"},
|
||||
[]codersdk.ChatGitChange{{
|
||||
FilePath: "a.txt\x1b]52;c;clipboard\x07",
|
||||
ChangeType: "modified",
|
||||
}},
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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.ChatStreamError{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())
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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
|
||||
normalItem lipgloss.Style
|
||||
dimmedText lipgloss.Style
|
||||
errorText lipgloss.Style
|
||||
searchInput lipgloss.Style
|
||||
separator lipgloss.Style
|
||||
helpText 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),
|
||||
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),
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
|
||||
r.promptExample(),
|
||||
r.rptyCommand(),
|
||||
r.syncCommand(),
|
||||
r.agentsCommand(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user