mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 09:26:06 -10:00
3625 lines
118 KiB
Go
3625 lines
118 KiB
Go
package ai
|
|
|
|
/*
|
|
Sliver Implant Framework
|
|
Copyright (C) 2026 Bishop Fox
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image/color"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/bishopfox/sliver/client/console"
|
|
aithinking "github.com/bishopfox/sliver/client/spin/thinking"
|
|
clienttheme "github.com/bishopfox/sliver/client/theme"
|
|
"github.com/bishopfox/sliver/protobuf/clientpb"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
func assertColorEqual(t *testing.T, got color.Color, want color.Color) {
|
|
t.Helper()
|
|
gotR, gotG, gotB, gotA := got.RGBA()
|
|
wantR, wantG, wantB, wantA := want.RGBA()
|
|
if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA {
|
|
t.Fatalf("unexpected color: got rgba(%d,%d,%d,%d), want rgba(%d,%d,%d,%d)", gotR, gotG, gotB, gotA, wantR, wantG, wantB, wantA)
|
|
}
|
|
}
|
|
|
|
func TestPromptConversationTitleUsesFirstNonEmptyLine(t *testing.T) {
|
|
title := promptConversationTitle("\n\n First line title \nsecond line")
|
|
if title != "First line title" {
|
|
t.Fatalf("expected first non-empty line, got %q", title)
|
|
}
|
|
}
|
|
|
|
func TestBuildConversationMarkdownUsesMarkdownSectionsAndOperatorLabel(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
OperatorName: "alice",
|
|
Summary: "Operator context",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "Hello"},
|
|
{Role: "assistant", Content: "## Reply"},
|
|
},
|
|
}
|
|
|
|
markdown := buildConversationMarkdown(conversation)
|
|
expected := []string{
|
|
"Operator context",
|
|
"### alice",
|
|
"Hello",
|
|
"### AI",
|
|
"## Reply",
|
|
}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(markdown, fragment) {
|
|
t.Fatalf("expected markdown to contain %q, got %q", fragment, markdown)
|
|
}
|
|
}
|
|
if strings.Contains(markdown, "```text") {
|
|
t.Fatalf("expected markdown messages to avoid text fences, got %q", markdown)
|
|
}
|
|
}
|
|
|
|
func TestBuildConversationMarkdownFallsBackToUserLabel(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "Hello"},
|
|
},
|
|
}
|
|
|
|
markdown := buildConversationMarkdown(conversation)
|
|
if !strings.Contains(markdown, "### User\n\nHello") {
|
|
t.Fatalf("expected markdown to contain user fallback label, got %q", markdown)
|
|
}
|
|
}
|
|
|
|
func TestBuildConversationMarkdownIncludesSystemPromptAsFirstMessage(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
SystemPrompt: "Stay concise.",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "Hello"},
|
|
},
|
|
}
|
|
|
|
markdown := buildConversationMarkdown(conversation)
|
|
systemIndex := strings.Index(markdown, "### System\n\nStay concise.")
|
|
assistantIndex := strings.Index(markdown, "### AI\n\nHello")
|
|
if systemIndex == -1 {
|
|
t.Fatalf("expected markdown to include the system prompt as a message, got %q", markdown)
|
|
}
|
|
if assistantIndex == -1 {
|
|
t.Fatalf("expected markdown to include the assistant message, got %q", markdown)
|
|
}
|
|
if systemIndex > assistantIndex {
|
|
t.Fatalf("expected system prompt to appear before assistant reply, got %q", markdown)
|
|
}
|
|
}
|
|
|
|
func TestBuildConversationMarkdownFormatsToolCallsAsCodeBlocks(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
|
ToolName: "fs_ls",
|
|
ToolArguments: `{"path":"/tmp","session_id":"session-1"}`,
|
|
ToolResult: `{"path":"/tmp","exists":true}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
markdown := buildConversationMarkdown(conversation)
|
|
for _, fragment := range []string{
|
|
"### Tool: fs\\_ls",
|
|
"Arguments:\n```json",
|
|
"\"path\": \"/tmp\"",
|
|
"Result:\n```json",
|
|
"\"exists\": true",
|
|
} {
|
|
if !strings.Contains(markdown, fragment) {
|
|
t.Fatalf("expected markdown to contain %q, got %q", fragment, markdown)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{OperatorName: "<unknown>"}
|
|
message := &clientpb.AIConversationMessage{
|
|
Role: "user",
|
|
OperatorName: "<unknown>",
|
|
Content: "Hello",
|
|
}
|
|
|
|
label := messageBlockLabel(conversation, message)
|
|
if label != "User" {
|
|
t.Fatalf("expected placeholder operator names to fall back to User, got %q", label)
|
|
}
|
|
}
|
|
|
|
func TestThinkingLevelOptionFocusedStyleUsesDarkerPrimaryShade(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
|
|
assertColorEqual(t, model.styles.optionFocused.GetBackground(), clienttheme.PrimaryMod(200))
|
|
assertColorEqual(t, model.styles.optionFocused.GetForeground(), clienttheme.DefaultMod(900))
|
|
assertColorEqual(t, model.styles.itemFocused.GetBackground(), clienttheme.PrimaryMod(800))
|
|
}
|
|
|
|
func TestTranscriptSelectionStyleUsesExplicitHighlightColors(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
|
|
if !model.styles.selection.GetBold() {
|
|
t.Fatal("expected transcript selection style to be bold")
|
|
}
|
|
if !model.styles.selection.GetUnderline() {
|
|
t.Fatal("expected transcript selection style to be underlined")
|
|
}
|
|
if model.styles.selection.GetReverse() {
|
|
t.Fatal("expected transcript selection style to avoid reverse-video highlighting")
|
|
}
|
|
assertColorEqual(t, model.styles.selection.GetForeground(), clienttheme.DefaultMod(900))
|
|
assertColorEqual(t, model.styles.selection.GetBackground(), clienttheme.WarningMod(200))
|
|
}
|
|
|
|
func TestTranscriptConversationLabelUsesDarkerPrimaryShade(t *testing.T) {
|
|
styles := transcriptSpeakerStyle("Conversation", "system")
|
|
|
|
assertColorEqual(t, styles.label.GetBackground(), clienttheme.PrimaryMod(200))
|
|
assertColorEqual(t, styles.label.GetForeground(), clienttheme.DefaultMod(900))
|
|
}
|
|
|
|
func TestTranscriptSystemPromptStyleUsesSecondaryTheme(t *testing.T) {
|
|
styles := transcriptSpeakerStyle("System", "system")
|
|
|
|
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Secondary())
|
|
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Secondary())
|
|
assertColorEqual(t, styles.meta.GetForeground(), clienttheme.Secondary())
|
|
}
|
|
|
|
func TestBuildConversationMarkdownWithoutConversationAvoidsKeyHints(t *testing.T) {
|
|
markdown := buildConversationMarkdown(nil)
|
|
if strings.Contains(markdown, "`n`") {
|
|
t.Fatalf("expected empty-state markdown to avoid inline key hints, got %q", markdown)
|
|
}
|
|
if !strings.Contains(markdown, "Create a new conversation") {
|
|
t.Fatalf("expected empty-state markdown to describe the action without keybindings, got %q", markdown)
|
|
}
|
|
}
|
|
|
|
func TestRenderTranscriptMarkdownLinesRendersAssistantMarkdown(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "## Reply\n\n- first item"},
|
|
},
|
|
}
|
|
|
|
rendered := ansi.Strip(strings.Join(model.renderTranscriptMarkdownLines(48), "\n"))
|
|
if !strings.Contains(rendered, "Reply") {
|
|
t.Fatalf("expected rendered transcript to contain the assistant heading text, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "• first item") {
|
|
t.Fatalf("expected assistant markdown list to be rendered with glow styling, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestRenderConversationTranscriptLinesWrapsMessagesInBoxes(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
OperatorName: "alice",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", OperatorName: "alice", Content: "hello"},
|
|
{Role: "user", OperatorName: "bob", Content: "second voice"},
|
|
{Role: "assistant", Content: "## Reply"},
|
|
},
|
|
}
|
|
|
|
renderedRaw := strings.Join(renderConversationTranscriptLines(64, conversation), "\n")
|
|
rendered := ansi.Strip(renderedRaw)
|
|
expected := []string{"alice", "bob", "AI", "hello", "second voice", "Reply"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected boxed transcript to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
for _, fragment := range []string{"╭", "╰"} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected boxed transcript framing %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
if strings.Contains(rendered, "╮") || strings.Contains(rendered, "╯") {
|
|
t.Fatalf("expected message framing to stay open on the right, got %q", rendered)
|
|
}
|
|
for _, needle := range []string{"hello", "second voice"} {
|
|
var found bool
|
|
for _, line := range strings.Split(rendered, "\n") {
|
|
if strings.Contains(line, needle) {
|
|
found = true
|
|
if !strings.HasPrefix(line, "│") {
|
|
t.Fatalf("expected %q line to stay inside the box, got %q", needle, line)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected to find content line for %q, got %q", needle, rendered)
|
|
}
|
|
}
|
|
if !strings.Contains(renderedRaw, "\x1b[") {
|
|
t.Fatalf("expected boxed transcript to include ANSI styling, got %q", renderedRaw)
|
|
}
|
|
}
|
|
|
|
func TestRenderConversationTranscriptLinesOnlyBoxesMessages(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Summary: "Operator context",
|
|
SystemPrompt: "Stay concise.",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "hello"},
|
|
},
|
|
}
|
|
|
|
lines := renderConversationTranscriptLines(64, conversation)
|
|
rendered := ansi.Strip(strings.Join(lines, "\n"))
|
|
|
|
for _, fragment := range []string{"Summary", "Operator context", "System", "Stay concise.", "AI", "hello"} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected transcript to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
|
|
var summaryLine string
|
|
for _, line := range strings.Split(rendered, "\n") {
|
|
if strings.Contains(line, "Summary") {
|
|
summaryLine = line
|
|
break
|
|
}
|
|
}
|
|
if summaryLine == "" {
|
|
t.Fatalf("expected summary header line, got %q", rendered)
|
|
}
|
|
if strings.HasPrefix(summaryLine, "╭") || strings.HasPrefix(summaryLine, "│") || strings.HasPrefix(summaryLine, "╰") {
|
|
t.Fatalf("expected summary to remain unboxed, got %q", summaryLine)
|
|
}
|
|
}
|
|
|
|
func TestRenderConversationTranscriptLinesRendersReasoningAndToolBlocks(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
OperatorName: "alice",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
|
Content: "Summary:\nChecked the active target before choosing a tool.",
|
|
},
|
|
{
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
|
|
ToolName: "fs_ls",
|
|
ToolArguments: `{"path":"/tmp","session_id":"session-1"}`,
|
|
ToolResult: `{"path":"/tmp","exists":true}`,
|
|
ErrorText: "permission denied",
|
|
},
|
|
},
|
|
}
|
|
|
|
renderedRaw := strings.Join(renderConversationTranscriptLines(72, conversation), "\n")
|
|
rendered := ansi.Strip(renderedRaw)
|
|
for _, fragment := range []string{"Reasoning", "Checked the active target", "Tool: fs_ls", "ARGUMENTS", "\"path\": \"/tmp\"", "ERROR", "permission denied", "RESULT"} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected transcript to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFormatToolCallStructuredBodyPrettyPrintsAndTruncates(t *testing.T) {
|
|
fields := make([]string, 0, aiToolCallSectionMaxLines+4)
|
|
for i := 0; i < aiToolCallSectionMaxLines+4; i++ {
|
|
fields = append(fields, fmt.Sprintf(`"key%02d":"value%02d"`, i, i))
|
|
}
|
|
raw := "{" + strings.Join(fields, ",") + "}"
|
|
|
|
formatted := formatToolCallStructuredBody(raw)
|
|
if formatted.language != "json" {
|
|
t.Fatalf("expected json language, got %q", formatted.language)
|
|
}
|
|
if !strings.Contains(formatted.content, "\"key00\": \"value00\"") {
|
|
t.Fatalf("expected formatted tool body to pretty-print JSON, got %q", formatted.content)
|
|
}
|
|
if strings.Count(formatted.content, "\n")+1 > aiToolCallSectionMaxLines {
|
|
t.Fatalf("expected formatted tool body to stay within %d lines, got %d", aiToolCallSectionMaxLines, strings.Count(formatted.content, "\n")+1)
|
|
}
|
|
if formatted.hiddenBytes <= 0 {
|
|
t.Fatalf("expected formatted tool body to record hidden bytes, got %+v", formatted)
|
|
}
|
|
if strings.Contains(formatted.content, "\"key18\": \"value18\"") {
|
|
t.Fatalf("expected formatted tool body to hide truncated tail content, got %q", formatted.content)
|
|
}
|
|
}
|
|
|
|
func TestRenderConversationTranscriptLinesTruncatesLongToolPayloads(t *testing.T) {
|
|
fields := make([]string, 0, aiToolCallSectionMaxLines+4)
|
|
for i := 0; i < aiToolCallSectionMaxLines+4; i++ {
|
|
fields = append(fields, fmt.Sprintf(`"key%02d":"value%02d"`, i, i))
|
|
}
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
|
ToolName: "fs_cat",
|
|
ToolResult: "{" + strings.Join(fields, ",") + "}",
|
|
},
|
|
},
|
|
}
|
|
|
|
rendered := ansi.Strip(strings.Join(renderConversationTranscriptLines(72, conversation), "\n"))
|
|
for _, fragment := range []string{"Tool: fs_cat", "RESULT", "\"key00\":", "... (", "bytes) ..."} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected transcript to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
if strings.Contains(rendered, "\"key18\":") {
|
|
t.Fatalf("expected transcript to hide truncated tail content, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestRenderToolCallStructuredMarkdownAddsItalicTruncationMarkerOnOwnLine(t *testing.T) {
|
|
fields := make([]string, 0, aiToolCallSectionMaxLines+4)
|
|
for i := 0; i < aiToolCallSectionMaxLines+4; i++ {
|
|
fields = append(fields, fmt.Sprintf(`"key%02d":"value%02d"`, i, i))
|
|
}
|
|
|
|
markdown := renderToolCallStructuredMarkdown("{" + strings.Join(fields, ",") + "}")
|
|
if !strings.Contains(markdown, "```json\n") {
|
|
t.Fatalf("expected structured markdown to use a json code block, got %q", markdown)
|
|
}
|
|
if !strings.Contains(markdown, "\n```\n\n_... (") || !strings.Contains(markdown, "bytes) ..._") {
|
|
t.Fatalf("expected structured markdown to place the truncation marker on its own italic line, got %q", markdown)
|
|
}
|
|
}
|
|
|
|
func TestRenderConversationTranscriptLinesRendersReasoningMarkdown(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
|
Content: "Summary:\n\n- Checked the active target before choosing a tool.\n- Used `fs_ls` after that.",
|
|
},
|
|
},
|
|
}
|
|
|
|
rendered := ansi.Strip(strings.Join(renderConversationTranscriptLines(72, conversation), "\n"))
|
|
for _, fragment := range []string{"Reasoning", "Checked the active target before choosing a tool.", "fs_ls"} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected transcript to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
if strings.Contains(rendered, "`fs_ls`") {
|
|
t.Fatalf("expected reasoning transcript to render markdown inline code, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestRenderConversationTranscriptLinesExplainsMissingReasoningSummary(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
|
},
|
|
},
|
|
}
|
|
|
|
rendered := ansi.Strip(strings.Join(renderConversationTranscriptLines(72, conversation), "\n"))
|
|
if !strings.Contains(rendered, "Reasoning was used, but the provider did not return a visible summary.") {
|
|
t.Fatalf("expected reasoning fallback text, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestLastContextConversationMessageUsesIncludeInContextFlag(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Shown but excluded.",
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
|
IncludeInContext: boolPtr(false),
|
|
},
|
|
{
|
|
Role: "assistant",
|
|
Content: "UI-only but still contextual.",
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
IncludeInContext: boolPtr(true),
|
|
},
|
|
},
|
|
}
|
|
|
|
message := lastContextConversationMessage(conversation)
|
|
if message == nil {
|
|
t.Fatal("expected a context-visible message")
|
|
}
|
|
if message.GetContent() != "UI-only but still contextual." {
|
|
t.Fatalf("unexpected context-visible message: %+v", message)
|
|
}
|
|
}
|
|
|
|
func TestRenderTranscriptHeaderLinesUsesCompactInlineMetadata(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-5.4",
|
|
UpdatedAt: time.Now().Unix(),
|
|
ContextWindowUsage: &clientpb.AIContextWindowUsage{
|
|
InputTokens: 12000,
|
|
OutputTokens: 6000,
|
|
TotalTokens: 18000,
|
|
ContextWindowTokens: 128000,
|
|
},
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "hello"},
|
|
},
|
|
}
|
|
|
|
lines := model.renderTranscriptHeaderLines(96, 12, renderConversationTranscriptLines(96, model.currentConversation))
|
|
if len(lines) != 2 {
|
|
t.Fatalf("expected compact transcript header to use 2 lines, got %d", len(lines))
|
|
}
|
|
|
|
rendered := ansi.Strip(strings.Join(lines, "\n"))
|
|
expected := []string{"Conversation", "Thread", "ctx 18k/128k", "provider openai", "model gpt-5.4", "1 msgs"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected transcript header to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func boolPtr(value bool) *bool {
|
|
return &value
|
|
}
|
|
|
|
func TestRenderHeaderUsesSingleCompactRow(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
target: aiTargetSummary{
|
|
Label: "Session demo",
|
|
},
|
|
connection: aiConnectionSummary{
|
|
Operator: "alice",
|
|
},
|
|
}, nil)
|
|
model.width = 120
|
|
model.loading = false
|
|
|
|
rendered := ansi.Strip(model.renderHeader())
|
|
if strings.Contains(rendered, "\n") {
|
|
t.Fatalf("expected compact header to stay on one line, got %q", rendered)
|
|
}
|
|
if strings.Contains(rendered, "Server-backed AI conversation threads") {
|
|
t.Fatalf("expected compact header to omit the old subtitle, got %q", rendered)
|
|
}
|
|
for _, fragment := range []string{"SLIVER AI", "synced", "alice", "Session demo"} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected compact header to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
for _, fragment := range []string{"split", "stacked"} {
|
|
if strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected compact header to omit layout label %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
|
|
userStyles := transcriptSpeakerStyle("alice", "user")
|
|
assertColorEqual(t, userStyles.border.GetForeground(), clienttheme.Primary())
|
|
assertColorEqual(t, userStyles.label.GetBackground(), clienttheme.Primary())
|
|
|
|
userBorder := userStyles.border.Render("│")
|
|
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
|
|
if userBorder != wantUserBorder {
|
|
t.Fatalf("expected user message border to use theme primary color")
|
|
}
|
|
|
|
wantUserLabel := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(clienttheme.DefaultMod(900)).
|
|
Background(clienttheme.Primary()).
|
|
Padding(0, 1).
|
|
Render("alice")
|
|
if got := userStyles.label.Render("alice"); got != wantUserLabel {
|
|
t.Fatalf("expected user message label to use theme primary color")
|
|
}
|
|
|
|
wantUserMeta := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("openai | gpt-5.4")
|
|
if got := userStyles.meta.Render("openai | gpt-5.4"); got != wantUserMeta {
|
|
t.Fatalf("expected user message metadata to use theme primary color")
|
|
}
|
|
|
|
assistantBorder := transcriptSpeakerStyle("AI", "assistant").border.Render("│")
|
|
if assistantBorder == wantUserBorder {
|
|
t.Fatalf("expected assistant message border to keep its non-primary speaker color")
|
|
}
|
|
}
|
|
|
|
func TestWindowResizeQueuesTranscriptRenderAsync(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 100
|
|
model.height = 30
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "## Reply\n\n- first item"},
|
|
},
|
|
}
|
|
model.invalidateTranscriptCache()
|
|
|
|
updated, cmd := model.Update(tea.WindowSizeMsg{Width: 120, Height: 36})
|
|
if cmd == nil {
|
|
t.Fatal("expected resize to queue transcript rendering")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.transcriptPendingKey == "" {
|
|
t.Fatal("expected resize to mark a transcript render as pending")
|
|
}
|
|
|
|
placeholder := ansi.Strip(strings.Join(updatedModel.transcriptDisplayLines(updatedModel.currentTranscriptWidth()), "\n"))
|
|
if !strings.Contains(placeholder, "Rendering transcript") {
|
|
t.Fatalf("expected resize to show a placeholder while rendering, got %q", placeholder)
|
|
}
|
|
|
|
msg := cmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
if !strings.Contains(ansi.Strip(strings.Join(renderedMsg.lines, "\n")), "• first item") {
|
|
t.Fatalf("expected rendered resize transcript to include markdown formatting, got %#v", renderedMsg.lines)
|
|
}
|
|
|
|
updated, _ = updatedModel.Update(renderedMsg)
|
|
updatedModel = updated.(*aiModel)
|
|
if updatedModel.transcriptPendingKey != "" {
|
|
t.Fatal("expected transcript render to clear the pending state")
|
|
}
|
|
|
|
rendered := ansi.Strip(strings.Join(updatedModel.transcriptDisplayLines(updatedModel.currentTranscriptWidth()), "\n"))
|
|
if !strings.Contains(rendered, "• first item") {
|
|
t.Fatalf("expected cached transcript render after resize, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestWindowPollSchedulesWindowSizeMsgAndKeepsPolling(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 100
|
|
model.height = 30
|
|
|
|
updated, cmd := model.Update(aiWindowPollMsg{width: 120, height: 36})
|
|
if cmd == nil {
|
|
t.Fatal("expected poll tick to emit a window size message and keep polling")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.width != 100 || updatedModel.height != 30 {
|
|
t.Fatalf("expected poll tick to leave dimensions unchanged until a WindowSizeMsg arrives, got %dx%d", updatedModel.width, updatedModel.height)
|
|
}
|
|
|
|
msg := cmd()
|
|
batch, ok := msg.(tea.BatchMsg)
|
|
if !ok {
|
|
t.Fatalf("expected poll tick to return a tea.BatchMsg, got %T", msg)
|
|
}
|
|
if len(batch) != 2 {
|
|
t.Fatalf("expected poll tick batch to contain 2 commands, got %d", len(batch))
|
|
}
|
|
|
|
var sawWindowSizeMsg bool
|
|
var sawPollTick bool
|
|
for _, subcmd := range batch {
|
|
if subcmd == nil {
|
|
continue
|
|
}
|
|
submsg := subcmd()
|
|
switch msg := submsg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
sawWindowSizeMsg = true
|
|
if msg.Width != 120 || msg.Height != 36 {
|
|
t.Fatalf("expected polled window size 120x36, got %dx%d", msg.Width, msg.Height)
|
|
}
|
|
case aiWindowPollMsg:
|
|
sawPollTick = true
|
|
}
|
|
}
|
|
|
|
if !sawWindowSizeMsg {
|
|
t.Fatal("expected poll tick to include a tea.WindowSizeMsg")
|
|
}
|
|
if !sawPollTick {
|
|
t.Fatal("expected poll tick to schedule the next polling tick")
|
|
}
|
|
}
|
|
|
|
func TestApplyWindowSizeSkipsUnchangedDimensions(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 100
|
|
model.height = 30
|
|
|
|
if cmd := model.applyWindowSize(100, 30); cmd != nil {
|
|
t.Fatal("expected unchanged dimensions to avoid scheduling a rerender")
|
|
}
|
|
}
|
|
|
|
func TestViewWidthTracksResize(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
target: aiTargetSummary{
|
|
Label: "No active target",
|
|
Host: "Select a session or beacon with `use`",
|
|
OS: "unknown",
|
|
Arch: "unknown",
|
|
C2: "n/a",
|
|
},
|
|
connection: aiConnectionSummary{
|
|
Profile: "<disconnected>",
|
|
Server: "<unknown>",
|
|
Operator: "<unknown>",
|
|
State: "ready",
|
|
},
|
|
}, nil)
|
|
model.loading = false
|
|
model.width = 140
|
|
model.height = 36
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "New conversation", Provider: "openai", Model: "gpt-5.4"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "New conversation",
|
|
Provider: "openai",
|
|
Model: "gpt-5.4",
|
|
UpdatedAt: time.Now().Unix(),
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "## Reply\n\n- first item"},
|
|
},
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected initial transcript render command")
|
|
}
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected initial transcript render message, got %T", msg)
|
|
}
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
|
|
view := model.View()
|
|
if got := lipgloss.Width(view.Content); got > model.width {
|
|
t.Fatalf("expected initial view width <= %d, got %d", model.width, got)
|
|
}
|
|
if got := lipgloss.Height(view.Content); got > model.height {
|
|
t.Fatalf("expected initial view height <= %d, got %d", model.height, got)
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.WindowSizeMsg{Width: 108, Height: 32})
|
|
if cmd == nil {
|
|
t.Fatal("expected resize to queue transcript rendering")
|
|
}
|
|
model = updated.(*aiModel)
|
|
msg = cmd()
|
|
renderedMsg, ok = msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected resize transcript render message, got %T", msg)
|
|
}
|
|
updated, _ = model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
|
|
view = model.View()
|
|
if got := lipgloss.Width(view.Content); got > model.width {
|
|
t.Fatalf("expected resized view width <= %d, got %d", model.width, got)
|
|
}
|
|
if got := lipgloss.Height(view.Content); got > model.height {
|
|
t.Fatalf("expected resized view height <= %d, got %d", model.height, got)
|
|
}
|
|
|
|
for _, line := range strings.Split(view.Content, "\n") {
|
|
if got := ansi.StringWidth(line); got > model.width {
|
|
t.Fatalf("expected resized view line width <= %d, got %d for %q", model.width, got, ansi.Strip(line))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestViewRendersAtHeightSeventeen(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
target: aiTargetSummary{
|
|
Label: "No active target",
|
|
},
|
|
connection: aiConnectionSummary{
|
|
Operator: "alice",
|
|
},
|
|
}, nil)
|
|
model.width = 96
|
|
model.height = 17
|
|
model.loading = false
|
|
model.status = "Ready"
|
|
|
|
rendered := ansi.Strip(model.View().Content)
|
|
if strings.Contains(rendered, "Terminal too small for the AI conversation view.") {
|
|
t.Fatalf("expected 96x17 to render the TUI, got %q", rendered)
|
|
}
|
|
for _, fragment := range []string{"Conversations", "Composer", "focus: sidebar"} {
|
|
if !strings.Contains(rendered, fragment) {
|
|
t.Fatalf("expected rendered minimum-height view to contain %q, got %q", fragment, rendered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConversationAwaitingResponseWhenLastMessageIsUser(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "Previous reply"},
|
|
{Role: "user", Content: "Still waiting"},
|
|
},
|
|
}
|
|
|
|
if !conversationAwaitingResponse(conversation) {
|
|
t.Fatal("expected conversation to be waiting on an assistant response")
|
|
}
|
|
}
|
|
|
|
func TestConversationAwaitingResponseStopsWhenAssistantReplies(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "Question"},
|
|
{Role: "assistant", Content: "Answer"},
|
|
},
|
|
}
|
|
|
|
if conversationAwaitingResponse(conversation) {
|
|
t.Fatal("expected conversation to be settled once the assistant replies")
|
|
}
|
|
}
|
|
|
|
func TestConversationAwaitingResponseUsesTurnState(t *testing.T) {
|
|
conversation := &clientpb.AIConversation{
|
|
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Listing files",
|
|
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
|
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
|
},
|
|
},
|
|
}
|
|
|
|
if !conversationAwaitingResponse(conversation) {
|
|
t.Fatal("expected in-progress turn state to keep the conversation pending")
|
|
}
|
|
|
|
conversation.TurnState = clientpb.AIConversationTurnState_AI_TURN_STATE_FAILED
|
|
if conversationAwaitingResponse(conversation) {
|
|
t.Fatal("expected failed turn state to clear the pending marker")
|
|
}
|
|
}
|
|
|
|
func TestPendingLabelUsesThinkingWhenConfigured(t *testing.T) {
|
|
model := &aiModel{
|
|
config: &clientpb.AIConfigSummary{ThinkingLevel: "xhigh"},
|
|
}
|
|
|
|
if got := model.pendingLabel(); got != "Thinking" {
|
|
t.Fatalf("expected pending label %q, got %q", "Thinking", got)
|
|
}
|
|
}
|
|
|
|
func TestPendingLabelFallsBackToWorking(t *testing.T) {
|
|
model := &aiModel{}
|
|
|
|
if got := model.pendingLabel(); got != "Working" {
|
|
t.Fatalf("expected pending label %q, got %q", "Working", got)
|
|
}
|
|
}
|
|
|
|
func TestPendingLabelUsesConversationOverrideBeforeConfig(t *testing.T) {
|
|
model := &aiModel{
|
|
config: &clientpb.AIConfigSummary{ThinkingLevel: "high"},
|
|
currentConversation: &clientpb.AIConversation{ThinkingLevel: "disabled"},
|
|
}
|
|
|
|
if got := model.pendingLabel(); got != "Working" {
|
|
t.Fatalf("expected disabled conversation override to use %q, got %q", "Working", got)
|
|
}
|
|
}
|
|
|
|
func TestThinkingLevelOptionsIncludeXHigh(t *testing.T) {
|
|
options := aiThinkingLevelOptions("high")
|
|
for _, option := range options {
|
|
if option.value == "xhigh" {
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("expected xhigh thinking option to be available")
|
|
}
|
|
|
|
func TestRenderFooterUsesPaneSpecificControls(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 200
|
|
model.focus = aiFocusSidebar
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread"}
|
|
|
|
footer := ansi.Strip(model.renderFooter())
|
|
expected := []string{"focus: sidebar", "j/k: move", "enter: open", "x: delete", "t: thinking", "q/esc: quit"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(footer, fragment) {
|
|
t.Fatalf("expected sidebar footer to contain %q, got %q", fragment, footer)
|
|
}
|
|
}
|
|
if strings.Contains(footer, "enter: send") {
|
|
t.Fatalf("expected sidebar footer to avoid composer controls, got %q", footer)
|
|
}
|
|
|
|
model.focus = aiFocusTranscript
|
|
footer = ansi.Strip(model.renderFooter())
|
|
expected = []string{"focus: conversation", "j/k: scroll", "pgup/pgdn: page", "g/G: ends", "x: delete", "t: thinking", "q/esc: quit"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(footer, fragment) {
|
|
t.Fatalf("expected transcript footer to contain %q, got %q", fragment, footer)
|
|
}
|
|
}
|
|
if strings.Contains(footer, "enter: open") || strings.Contains(footer, "enter: send") {
|
|
t.Fatalf("expected transcript footer to avoid sidebar/composer controls, got %q", footer)
|
|
}
|
|
renderedTranscript := ansi.Strip(model.renderTranscript(96, 12))
|
|
if strings.Contains(renderedTranscript, "scroll j/k pgup/pgdn g/G") {
|
|
t.Fatalf("expected transcript pane to omit inline focus controls, got %q", renderedTranscript)
|
|
}
|
|
|
|
model.focus = aiFocusComposer
|
|
footer = ansi.Strip(model.renderFooter())
|
|
expected = []string{"focus: composer", "tab: sidebar", "enter: send", "/exit: quit", "ctrl+u: clear", "ctrl+c: quit"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(footer, fragment) {
|
|
t.Fatalf("expected composer footer to contain %q, got %q", fragment, footer)
|
|
}
|
|
}
|
|
for _, fragment := range []string{"ctrl+o: context", "ctrl+s: target", "ctrl+t: thinking"} {
|
|
if strings.Contains(footer, fragment) {
|
|
t.Fatalf("expected composer footer to omit duplicated composer shortcuts %q, got %q", fragment, footer)
|
|
}
|
|
}
|
|
if strings.Contains(footer, "esc: blur") {
|
|
t.Fatalf("expected composer footer to omit esc blur control, got %q", footer)
|
|
}
|
|
if strings.Contains(footer, "x: delete") {
|
|
t.Fatalf("expected composer footer to avoid sidebar controls, got %q", footer)
|
|
}
|
|
}
|
|
|
|
func TestTabCyclesVisiblePanesOnly(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
|
|
updated, _ := model.handleGlobalKey(tea.Key{Code: tea.KeyTab})
|
|
if got := updated.(*aiModel).focus; got != aiFocusTranscript {
|
|
t.Fatalf("expected tab to move from sidebar to transcript, got %s", got.String())
|
|
}
|
|
|
|
updated, _ = updated.(*aiModel).handleGlobalKey(tea.Key{Code: tea.KeyTab})
|
|
if got := updated.(*aiModel).focus; got != aiFocusComposer {
|
|
t.Fatalf("expected tab to move from transcript to composer, got %s", got.String())
|
|
}
|
|
|
|
updated, _ = updated.(*aiModel).handleGlobalKey(tea.Key{Code: tea.KeyTab})
|
|
if got := updated.(*aiModel).focus; got != aiFocusSidebar {
|
|
t.Fatalf("expected tab to wrap back to sidebar, got %s", got.String())
|
|
}
|
|
}
|
|
|
|
func TestAIViewEnablesCellMotionMouseReporting(t *testing.T) {
|
|
view := aiView("hello")
|
|
if view.MouseMode != tea.MouseModeAllMotion {
|
|
t.Fatalf("expected AI view mouse mode %v, got %v", tea.MouseModeAllMotion, view.MouseMode)
|
|
}
|
|
}
|
|
|
|
func TestMouseClickSwitchesFocusAcrossPanesWide(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.focus = aiFocusSidebar
|
|
|
|
rects := model.currentPaneRects()
|
|
|
|
updated, _ := model.Update(tea.MouseClickMsg{
|
|
X: rects.transcript.x + rects.transcript.width/2,
|
|
Y: rects.transcript.y + rects.transcript.height/2,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
if got := updated.(*aiModel).focus; got != aiFocusTranscript {
|
|
t.Fatalf("expected transcript click to focus transcript, got %s", got.String())
|
|
}
|
|
|
|
rects = updated.(*aiModel).currentPaneRects()
|
|
updated, _ = updated.(*aiModel).Update(tea.MouseClickMsg{
|
|
X: rects.composer.x + rects.composer.width/2,
|
|
Y: rects.composer.y + rects.composer.height/2,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
if got := updated.(*aiModel).focus; got != aiFocusComposer {
|
|
t.Fatalf("expected composer click to focus composer, got %s", got.String())
|
|
}
|
|
|
|
rects = updated.(*aiModel).currentPaneRects()
|
|
updated, _ = updated.(*aiModel).Update(tea.MouseClickMsg{
|
|
X: rects.sidebar.x + rects.sidebar.width/2,
|
|
Y: rects.sidebar.y + rects.sidebar.height/2,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
if got := updated.(*aiModel).focus; got != aiFocusSidebar {
|
|
t.Fatalf("expected sidebar click to focus sidebar, got %s", got.String())
|
|
}
|
|
}
|
|
|
|
func TestMouseClickSwitchesFocusAcrossPanesStacked(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 76
|
|
model.height = 24
|
|
model.focus = aiFocusSidebar
|
|
|
|
rects := model.currentPaneRects()
|
|
if rects.transcript.y <= rects.sidebar.y {
|
|
t.Fatalf("expected stacked transcript pane below sidebar, got sidebar=%+v transcript=%+v", rects.sidebar, rects.transcript)
|
|
}
|
|
|
|
updated, _ := model.Update(tea.MouseClickMsg{
|
|
X: rects.transcript.x + rects.transcript.width/2,
|
|
Y: rects.transcript.y + rects.transcript.height/2,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
if got := updated.(*aiModel).focus; got != aiFocusTranscript {
|
|
t.Fatalf("expected transcript click to focus transcript in stacked layout, got %s", got.String())
|
|
}
|
|
|
|
rects = updated.(*aiModel).currentPaneRects()
|
|
updated, _ = updated.(*aiModel).Update(tea.MouseClickMsg{
|
|
X: rects.composer.x + rects.composer.width/2,
|
|
Y: rects.composer.y + rects.composer.height/2,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
if got := updated.(*aiModel).focus; got != aiFocusComposer {
|
|
t.Fatalf("expected composer click to focus composer in stacked layout, got %s", got.String())
|
|
}
|
|
}
|
|
|
|
func TestMouseClickLoadsConversationFromSidebar(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.focus = aiFocusTranscript
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "One"},
|
|
{ID: "conv-2", Title: "Two"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "One"}
|
|
|
|
rect := model.currentPaneRects().sidebar
|
|
updated, cmd := model.Update(tea.MouseClickMsg{
|
|
X: rect.x + rect.width/2,
|
|
Y: rect.y + 3,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
if cmd == nil {
|
|
t.Fatal("expected sidebar click to request conversation loading")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if got := updatedModel.focus; got != aiFocusSidebar {
|
|
t.Fatalf("expected sidebar click to focus sidebar, got %s", got.String())
|
|
}
|
|
if updatedModel.selectedConversation != 1 {
|
|
t.Fatalf("expected sidebar click to select the second conversation, got %d", updatedModel.selectedConversation)
|
|
}
|
|
if !updatedModel.loading {
|
|
t.Fatal("expected sidebar click to enter loading state")
|
|
}
|
|
if updatedModel.status != "Loading conversation..." {
|
|
t.Fatalf("expected sidebar click to set loading status, got %q", updatedModel.status)
|
|
}
|
|
}
|
|
|
|
func TestRenderComposerOmitsInlineControls(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 120
|
|
model.status = "Ready"
|
|
|
|
rendered := ansi.Strip(model.renderComposer(4))
|
|
if strings.Contains(rendered, "ctrl+u clear") || strings.Contains(rendered, "q quit") {
|
|
t.Fatalf("expected composer pane to omit inline control hints, got %q", rendered)
|
|
}
|
|
if strings.Contains(rendered, "Ready") {
|
|
t.Fatalf("expected composer pane to omit the inline status row, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestComposerPasteMsgInsertsAtCursor(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.input = []rune("hello")
|
|
model.cursor = len(model.input)
|
|
|
|
updated, cmd := model.Update(tea.PasteMsg{Content: " world"})
|
|
if cmd != nil {
|
|
t.Fatalf("expected paste to update in place without commands, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if got := string(updatedModel.input); got != "hello world" {
|
|
t.Fatalf("expected pasted composer input, got %q", got)
|
|
}
|
|
if updatedModel.cursor != len([]rune("hello world")) {
|
|
t.Fatalf("expected cursor at end of pasted input, got %d", updatedModel.cursor)
|
|
}
|
|
}
|
|
|
|
func TestTranscriptFocusKeepsMouseReportingEnabled(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 28
|
|
model.focus = aiFocusTranscript
|
|
view := model.View()
|
|
if view.MouseMode != tea.MouseModeAllMotion {
|
|
t.Fatalf("expected transcript focus to keep mouse reporting enabled, got %v", view.MouseMode)
|
|
}
|
|
}
|
|
|
|
func TestAIToastMessageShowsAndExpires(t *testing.T) {
|
|
listener := make(chan *clientpb.Event)
|
|
model := newAIModel(nil, aiContext{}, listener)
|
|
model.width = 108
|
|
model.height = 28
|
|
|
|
updated, cmd := model.Update(aiToastMsg{
|
|
level: "info",
|
|
message: "Session alpha connected",
|
|
})
|
|
if cmd == nil {
|
|
t.Fatal("expected toast message to schedule follow-up work")
|
|
}
|
|
|
|
model = updated.(*aiModel)
|
|
if len(model.toasts) != 1 {
|
|
t.Fatalf("expected one toast state, got %d", len(model.toasts))
|
|
}
|
|
toast := model.toasts[0]
|
|
if toast.message != "Session alpha connected" {
|
|
t.Fatalf("unexpected toast message %+v", toast)
|
|
}
|
|
if toast.createdAt.IsZero() || toast.expiresAt.IsZero() {
|
|
t.Fatalf("expected toast timing metadata to be populated, got %+v", toast)
|
|
}
|
|
if got := toast.expiresAt.Sub(toast.createdAt); got != aiToastDuration {
|
|
t.Fatalf("expected toast duration %v, got %v", aiToastDuration, got)
|
|
}
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
if !strings.Contains(view, "Session alpha connected") {
|
|
t.Fatalf("expected toast message in rendered view, got %q", view)
|
|
}
|
|
if !strings.Contains(view, "left") {
|
|
t.Fatalf("expected toast to render time left text, got %q", view)
|
|
}
|
|
if !strings.Contains(view, "█") {
|
|
t.Fatalf("expected toast to render a progress bar, got %q", view)
|
|
}
|
|
|
|
toastID := toast.id
|
|
updated, _ = model.Update(aiToastExpiredMsg{id: toastID})
|
|
model = updated.(*aiModel)
|
|
if len(model.toasts) != 0 {
|
|
t.Fatalf("expected toast to clear after expiry, got %+v", model.toasts)
|
|
}
|
|
}
|
|
|
|
func TestAIToastMessagesStackNewestFirstAndKeepFourMostRecent(t *testing.T) {
|
|
listener := make(chan *clientpb.Event)
|
|
model := newAIModel(nil, aiContext{}, listener)
|
|
model.width = 108
|
|
model.height = 28
|
|
|
|
messages := []string{
|
|
"First event",
|
|
"Second event",
|
|
"Third event",
|
|
"Fourth event",
|
|
"Fifth event",
|
|
}
|
|
|
|
for _, message := range messages {
|
|
updated, cmd := model.Update(aiToastMsg{level: "info", message: message})
|
|
if cmd == nil {
|
|
t.Fatalf("expected toast %q to schedule follow-up work", message)
|
|
}
|
|
model = updated.(*aiModel)
|
|
}
|
|
|
|
if len(model.toasts) != aiToastStackLimit {
|
|
t.Fatalf("expected %d stacked toasts, got %d", aiToastStackLimit, len(model.toasts))
|
|
}
|
|
|
|
wantOrder := []string{"Fifth event", "Fourth event", "Third event", "Second event"}
|
|
for idx, want := range wantOrder {
|
|
if got := model.toasts[idx].message; got != want {
|
|
t.Fatalf("expected toast %d to be %q, got %q", idx, want, got)
|
|
}
|
|
}
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
for _, want := range wantOrder {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("expected stacked toast view to contain %q, got %q", want, view)
|
|
}
|
|
}
|
|
if strings.Contains(view, "First event") {
|
|
t.Fatalf("expected oldest toast to be dropped from the stack, got %q", view)
|
|
}
|
|
|
|
if strings.Index(view, "Fifth event") > strings.Index(view, "Fourth event") {
|
|
t.Fatalf("expected newest toast to render above older toasts, got %q", view)
|
|
}
|
|
|
|
expiredID := model.toasts[1].id
|
|
updated, _ := model.Update(aiToastExpiredMsg{id: expiredID})
|
|
model = updated.(*aiModel)
|
|
if len(model.toasts) != aiToastStackLimit-1 {
|
|
t.Fatalf("expected one toast to expire from the stack, got %d", len(model.toasts))
|
|
}
|
|
for _, toast := range model.toasts {
|
|
if toast.id == expiredID {
|
|
t.Fatalf("expected expired toast %d to be removed, got %+v", expiredID, model.toasts)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranscriptClickPromptsNativeSelection(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 28
|
|
model.focus = aiFocusSidebar
|
|
rect := model.currentPaneRects().transcript
|
|
updated, _ := model.Update(tea.MouseClickMsg{
|
|
X: rect.x + rect.width/2,
|
|
Y: rect.y + rect.height/2,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if got := model.focus; got != aiFocusTranscript {
|
|
t.Fatalf("expected transcript click to focus transcript, got %s", got.String())
|
|
}
|
|
if !strings.Contains(model.status, "wheel to scroll") {
|
|
t.Fatalf("expected transcript click to explain transcript mouse interactions, got %q", model.status)
|
|
}
|
|
}
|
|
|
|
func TestTranscriptDragSelectsAndCopiesMessageText(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "hello world"},
|
|
},
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
rect := model.currentPaneRects().transcript
|
|
startX := rect.x + 4
|
|
endX := rect.x + 8
|
|
contentY := rect.y + 4
|
|
|
|
updated, _ = model.Update(tea.MouseClickMsg{
|
|
X: startX,
|
|
Y: contentY,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if model.transcriptSelection == nil || !model.transcriptSelection.dragging {
|
|
t.Fatalf("expected transcript drag selection to start, got %+v", model.transcriptSelection)
|
|
}
|
|
|
|
updated, _ = model.Update(tea.MouseMotionMsg{
|
|
X: endX,
|
|
Y: contentY,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if selected := strings.TrimSpace(model.selectedTranscriptText()); selected != "hel" {
|
|
t.Fatalf("expected transcript selection %q, got %q", "hel", selected)
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.MouseReleaseMsg{
|
|
X: endX,
|
|
Y: contentY,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if cmd == nil {
|
|
t.Fatal("expected transcript mouse release to issue a clipboard command")
|
|
}
|
|
if selected := strings.TrimSpace(model.selectedTranscriptText()); selected != "hel" {
|
|
t.Fatalf("expected transcript selection to remain available after release, got %q", selected)
|
|
}
|
|
if !strings.Contains(model.status, "Copied") {
|
|
t.Fatalf("expected transcript selection release to update copy status, got %q", model.status)
|
|
}
|
|
}
|
|
|
|
func TestTranscriptSelectionStartsOnlyOnMessageText(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Summary: "Operator context",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "hello world"},
|
|
},
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
|
|
contentWidth, _ := model.currentTranscriptViewportSize()
|
|
content := model.renderTranscriptDisplayContent(contentWidth)
|
|
|
|
nonSelectableLine := -1
|
|
selectableLine := -1
|
|
for idx, line := range content {
|
|
if selectableLine < 0 && line.hasSelectableText() {
|
|
selectableLine = idx
|
|
}
|
|
if nonSelectableLine < 0 && !line.hasSelectableText() && strings.TrimSpace(ansi.Strip(line.styled)) != "" {
|
|
nonSelectableLine = idx
|
|
}
|
|
}
|
|
if nonSelectableLine < 0 {
|
|
t.Fatal("expected transcript to include non-selectable chrome or summary rows")
|
|
}
|
|
if selectableLine < 0 {
|
|
t.Fatal("expected transcript to include selectable message text rows")
|
|
}
|
|
|
|
rect := model.currentPaneRects().transcript
|
|
baseY := rect.y + 1 + model.transcriptHeaderLineCount()
|
|
clickX := rect.x + 4
|
|
|
|
updated, _ = model.Update(tea.MouseClickMsg{
|
|
X: clickX,
|
|
Y: baseY + nonSelectableLine,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if model.transcriptSelection != nil {
|
|
t.Fatalf("expected non-message transcript rows to ignore selection, got %+v", model.transcriptSelection)
|
|
}
|
|
|
|
updated, _ = model.Update(tea.MouseClickMsg{
|
|
X: clickX,
|
|
Y: baseY + selectableLine,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if model.transcriptSelection == nil || !model.transcriptSelection.dragging {
|
|
t.Fatalf("expected message text row to begin selection, got %+v", model.transcriptSelection)
|
|
}
|
|
}
|
|
|
|
func TestLayoutHeightsKeepWideComposerCompact(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 120
|
|
model.height = 30
|
|
|
|
headerHeight, composerHeight, footerHeight, bodyHeight := model.layoutHeights()
|
|
if headerHeight != 1 {
|
|
t.Fatalf("expected header height 1, got %d", headerHeight)
|
|
}
|
|
if composerHeight != 4 {
|
|
t.Fatalf("expected wide composer height 4, got %d", composerHeight)
|
|
}
|
|
if footerHeight != 1 {
|
|
t.Fatalf("expected footer height 1, got %d", footerHeight)
|
|
}
|
|
if bodyHeight != 24 {
|
|
t.Fatalf("expected body height 24 after reclaiming the composer status row, got %d", bodyHeight)
|
|
}
|
|
}
|
|
|
|
func TestRenderPaneClampsEmbeddedContentToViewport(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusTranscript
|
|
|
|
rendered := model.renderPane(24, 6, aiFocusTranscript, []string{
|
|
"Conversation",
|
|
"line one\nline two\nline three\nline four",
|
|
strings.Repeat("x", 48),
|
|
})
|
|
|
|
if got := lipgloss.Width(rendered); got != 24 {
|
|
t.Fatalf("expected pane width 24, got %d", got)
|
|
}
|
|
if got := lipgloss.Height(rendered); got != 6 {
|
|
t.Fatalf("expected pane height 6, got %d", got)
|
|
}
|
|
|
|
for _, line := range strings.Split(rendered, "\n") {
|
|
if got := ansi.StringWidth(line); got > 24 {
|
|
t.Fatalf("expected pane line width <= 24, got %d for %q", got, ansi.Strip(line))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestViewKeepsComposerVisibleWhenTranscriptLinesEmbedNewlines(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
model.width = 120
|
|
model.height = 24
|
|
model.status = "Ready"
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-5.4"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-5.4",
|
|
UpdatedAt: time.Now().Unix(),
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "hello"},
|
|
},
|
|
}
|
|
|
|
width := model.currentTranscriptWidth()
|
|
model.transcriptCacheKey = model.transcriptRenderKey(width)
|
|
model.transcriptCacheLines = []string{
|
|
"╭─ box ─╮",
|
|
"│ alpha │\n│ beta │\n│ gamma │\n│ delta │\n│ eps │",
|
|
"╰───────╯",
|
|
}
|
|
|
|
view := model.View()
|
|
rendered := ansi.Strip(view.Content)
|
|
|
|
if !strings.Contains(rendered, "Composer") {
|
|
t.Fatalf("expected composer pane to remain visible, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, ">>>") {
|
|
t.Fatalf("expected composer input line to remain visible, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "focus: sidebar") {
|
|
t.Fatalf("expected footer to remain visible, got %q", rendered)
|
|
}
|
|
if got := lipgloss.Height(view.Content); got > model.height {
|
|
t.Fatalf("expected view height <= %d, got %d", model.height, got)
|
|
}
|
|
}
|
|
|
|
func TestHandleGlobalKeyScrollsTranscript(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.focus = aiFocusTranscript
|
|
model.loading = false
|
|
|
|
messages := make([]*clientpb.AIConversationMessage, 0, 16)
|
|
for i := 0; i < 16; i++ {
|
|
messages = append(messages, &clientpb.AIConversationMessage{
|
|
Role: "assistant",
|
|
Content: fmt.Sprintf("Message %02d\n\n- detail", i),
|
|
})
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: messages,
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
initialScroll := model.transcriptScroll
|
|
if initialScroll == 0 {
|
|
t.Fatalf("expected transcript to start pinned near the bottom, got scroll %d", initialScroll)
|
|
}
|
|
|
|
updated, _ = model.handleGlobalKey(tea.Key{Text: "k"})
|
|
model = updated.(*aiModel)
|
|
if model.transcriptFollow {
|
|
t.Fatal("expected transcript scroll-up to disable follow mode")
|
|
}
|
|
if model.transcriptScroll != initialScroll-1 {
|
|
t.Fatalf("expected transcript scroll to move up by one line, got %d want %d", model.transcriptScroll, initialScroll-1)
|
|
}
|
|
|
|
updated, _ = model.handleGlobalKey(tea.Key{Text: "G"})
|
|
model = updated.(*aiModel)
|
|
if !model.transcriptFollow {
|
|
t.Fatal("expected transcript end-jump to restore follow mode")
|
|
}
|
|
if model.transcriptScroll != initialScroll {
|
|
t.Fatalf("expected transcript end-jump to restore bottom scroll %d, got %d", initialScroll, model.transcriptScroll)
|
|
}
|
|
}
|
|
|
|
func TestMouseWheelScrollsTranscriptAndFocusesConversation(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.focus = aiFocusSidebar
|
|
model.loading = false
|
|
|
|
messages := make([]*clientpb.AIConversationMessage, 0, 24)
|
|
for i := 0; i < 24; i++ {
|
|
messages = append(messages, &clientpb.AIConversationMessage{
|
|
Role: "assistant",
|
|
Content: fmt.Sprintf("Message %02d\n\n- detail", i),
|
|
})
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: messages,
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
initialScroll := model.transcriptScroll
|
|
if initialScroll < aiTranscriptMouseWheelStep {
|
|
t.Fatalf("expected transcript to have enough scrollback for wheel scrolling, got %d", initialScroll)
|
|
}
|
|
|
|
rect := model.currentPaneRects().transcript
|
|
updated, _ = model.Update(tea.MouseWheelMsg{
|
|
X: rect.x + rect.width/2,
|
|
Y: rect.y + rect.height/2,
|
|
Button: tea.MouseWheelUp,
|
|
})
|
|
model = updated.(*aiModel)
|
|
|
|
if got := model.focus; got != aiFocusTranscript {
|
|
t.Fatalf("expected transcript wheel to focus transcript, got %s", got.String())
|
|
}
|
|
if model.transcriptFollow {
|
|
t.Fatal("expected wheel-up scrolling to disable follow mode")
|
|
}
|
|
if want := initialScroll - aiTranscriptMouseWheelStep; model.transcriptScroll != want {
|
|
t.Fatalf("expected wheel-up scroll %d, got %d", want, model.transcriptScroll)
|
|
}
|
|
|
|
updated, _ = model.Update(tea.MouseWheelMsg{
|
|
X: rect.x + rect.width/2,
|
|
Y: rect.y + rect.height/2,
|
|
Button: tea.MouseWheelDown,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if !model.transcriptFollow {
|
|
t.Fatal("expected wheel-down at the bottom to restore follow mode")
|
|
}
|
|
if model.transcriptScroll != initialScroll {
|
|
t.Fatalf("expected wheel-down to restore bottom scroll %d, got %d", initialScroll, model.transcriptScroll)
|
|
}
|
|
}
|
|
|
|
func TestTranscriptScrollbarDragScrollsConversation(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.focus = aiFocusSidebar
|
|
model.loading = false
|
|
|
|
messages := make([]*clientpb.AIConversationMessage, 0, 24)
|
|
for i := 0; i < 24; i++ {
|
|
messages = append(messages, &clientpb.AIConversationMessage{
|
|
Role: "assistant",
|
|
Content: fmt.Sprintf("Message %02d\n\n- detail", i),
|
|
})
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: messages,
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
initialScroll := model.transcriptScroll
|
|
if initialScroll == 0 {
|
|
t.Fatalf("expected transcript to start pinned near the bottom, got scroll %d", initialScroll)
|
|
}
|
|
|
|
rect := model.currentPaneRects().transcript
|
|
_, viewportHeight := model.currentTranscriptViewportSize()
|
|
scrollbarX := rect.x + rect.width - 4
|
|
topY := rect.y + 1 + model.transcriptHeaderLineCount()
|
|
bottomY := topY + viewportHeight - 1
|
|
|
|
updated, _ = model.Update(tea.MouseClickMsg{
|
|
X: scrollbarX,
|
|
Y: topY,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if got := model.focus; got != aiFocusTranscript {
|
|
t.Fatalf("expected scrollbar click to focus transcript, got %s", got.String())
|
|
}
|
|
if !model.transcriptScrollbarDrag {
|
|
t.Fatal("expected scrollbar click to begin dragging")
|
|
}
|
|
if model.transcriptSelection != nil {
|
|
t.Fatalf("expected scrollbar click to avoid text selection, got %+v", model.transcriptSelection)
|
|
}
|
|
if model.transcriptFollow {
|
|
t.Fatal("expected scrollbar click near the top to disable follow mode")
|
|
}
|
|
if model.transcriptScroll >= initialScroll {
|
|
t.Fatalf("expected scrollbar click near the top to move upward from %d, got %d", initialScroll, model.transcriptScroll)
|
|
}
|
|
|
|
updated, _ = model.Update(tea.MouseMotionMsg{
|
|
X: scrollbarX,
|
|
Y: bottomY,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if !model.transcriptFollow {
|
|
t.Fatal("expected dragging scrollbar to the bottom to restore follow mode")
|
|
}
|
|
if model.transcriptScroll != initialScroll {
|
|
t.Fatalf("expected scrollbar drag to restore bottom scroll %d, got %d", initialScroll, model.transcriptScroll)
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.MouseReleaseMsg{
|
|
X: scrollbarX,
|
|
Y: bottomY,
|
|
Button: tea.MouseLeft,
|
|
})
|
|
model = updated.(*aiModel)
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect scrollbar release to issue clipboard work, got %v", cmd)
|
|
}
|
|
if model.transcriptScrollbarDrag {
|
|
t.Fatal("expected scrollbar drag to stop on release")
|
|
}
|
|
}
|
|
|
|
func TestRenderTranscriptShowsScrollbarAndTracksScrollPosition(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 108
|
|
model.height = 32
|
|
model.focus = aiFocusTranscript
|
|
model.loading = false
|
|
|
|
messages := make([]*clientpb.AIConversationMessage, 0, 18)
|
|
for i := 0; i < 18; i++ {
|
|
messages = append(messages, &clientpb.AIConversationMessage{
|
|
Role: "assistant",
|
|
Content: fmt.Sprintf("Message %02d detail", i),
|
|
})
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: messages,
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
|
|
viewportWidth, viewportHeight := model.currentTranscriptViewportSize()
|
|
contentLines := model.renderTranscriptDisplayContentLines(viewportWidth)
|
|
bottomCells := model.transcriptScrollbarCells(viewportHeight, len(contentLines))
|
|
if len(bottomCells) != viewportHeight {
|
|
t.Fatalf("expected scrollbar height %d, got %d", viewportHeight, len(bottomCells))
|
|
}
|
|
if !bottomCells[len(bottomCells)-1] {
|
|
t.Fatalf("expected bottom-pinned scrollbar thumb to reach the last row, got %#v", bottomCells)
|
|
}
|
|
|
|
paneWidth, paneHeight := model.currentTranscriptPaneSize()
|
|
rendered := ansi.Strip(model.renderTranscript(paneWidth, paneHeight))
|
|
if !strings.Contains(rendered, "#") || !strings.Contains(rendered, ":") {
|
|
t.Fatalf("expected rendered transcript to include scrollbar thumb and track, got %q", rendered)
|
|
}
|
|
for _, line := range strings.Split(rendered, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ":") {
|
|
t.Fatalf("expected scrollbar to stay on the right edge without wrapping into content, got %q", rendered)
|
|
}
|
|
}
|
|
for i, line := range strings.Split(rendered, "\n") {
|
|
if i == 0 || i == paneHeight-1 {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(line, "│") {
|
|
t.Fatalf("expected transcript pane right border to stay visible on line %d, got %q in %q", i, line, rendered)
|
|
}
|
|
}
|
|
|
|
model.scrollTranscriptToTop()
|
|
topCells := model.transcriptScrollbarCells(viewportHeight, len(contentLines))
|
|
if !topCells[0] {
|
|
t.Fatalf("expected top-scrolled scrollbar thumb to reach the first row, got %#v", topCells)
|
|
}
|
|
if topCells[len(topCells)-1] {
|
|
t.Fatalf("expected top-scrolled scrollbar thumb to move away from the last row, got %#v", topCells)
|
|
}
|
|
}
|
|
|
|
func TestViewKeepsTranscriptRightBorderVisibleWhileScrolled(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 120
|
|
model.height = 32
|
|
model.focus = aiFocusTranscript
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
|
|
|
messages := make([]*clientpb.AIConversationMessage, 0, 18)
|
|
for i := 0; i < 18; i++ {
|
|
messages = append(messages, &clientpb.AIConversationMessage{
|
|
Role: "assistant",
|
|
Content: fmt.Sprintf("Message %02d detail", i),
|
|
})
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: messages,
|
|
}
|
|
|
|
renderCmd := model.scheduleTranscriptRender()
|
|
if renderCmd == nil {
|
|
t.Fatal("expected transcript render command")
|
|
}
|
|
|
|
msg := renderCmd()
|
|
renderedMsg, ok := msg.(aiTranscriptRenderedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected transcript render message, got %T", msg)
|
|
}
|
|
|
|
updated, _ := model.Update(renderedMsg)
|
|
model = updated.(*aiModel)
|
|
model.scrollTranscript(-6)
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
lines := strings.Split(view, "\n")
|
|
headerHeight, _, _, _ := model.layoutHeights()
|
|
sidebarWidth := clampInt(model.width/4, 24, 28)
|
|
transcriptWidth := maxInt(40, model.width-sidebarWidth)
|
|
rightEdge := sidebarWidth + transcriptWidth - 1
|
|
_, transcriptPaneHeight := model.currentTranscriptPaneSize()
|
|
topRow := headerHeight
|
|
bottomRow := headerHeight + transcriptPaneHeight - 1
|
|
|
|
if topRow >= len(lines) {
|
|
t.Fatalf("expected transcript top row %d within view height %d", topRow, len(lines))
|
|
}
|
|
if []rune(lines[topRow])[rightEdge] != '╮' {
|
|
t.Fatalf("expected transcript top-right corner at row %d col %d, got %q in %q", topRow, rightEdge, string([]rune(lines[topRow])[rightEdge]), lines[topRow])
|
|
}
|
|
|
|
for row := headerHeight + 1; row < headerHeight+transcriptPaneHeight-1 && row < len(lines); row++ {
|
|
line := []rune(lines[row])
|
|
if rightEdge >= len(line) {
|
|
t.Fatalf("expected transcript right edge index %d within line %d: %q", rightEdge, row, lines[row])
|
|
}
|
|
if line[rightEdge] != '│' {
|
|
t.Fatalf("expected transcript right border at row %d col %d, got %q in %q", row, rightEdge, string(line[rightEdge]), lines[row])
|
|
}
|
|
}
|
|
if bottomRow >= len(lines) {
|
|
t.Fatalf("expected transcript bottom row %d within view height %d", bottomRow, len(lines))
|
|
}
|
|
if []rune(lines[bottomRow])[rightEdge] != '╯' {
|
|
t.Fatalf("expected transcript bottom-right corner at row %d col %d, got %q in %q", bottomRow, rightEdge, string([]rune(lines[bottomRow])[rightEdge]), lines[bottomRow])
|
|
}
|
|
}
|
|
|
|
func TestComposerEnterStartsAwaitingResponseImmediately(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
}
|
|
model.input = []rune("hello")
|
|
model.cursor = len(model.input)
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected enter to queue submit work")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if !updatedModel.awaitingResponse {
|
|
t.Fatal("expected enter to start awaiting-response state immediately")
|
|
}
|
|
if !updatedModel.submittingPrompt {
|
|
t.Fatal("expected enter to mark prompt submission as in flight")
|
|
}
|
|
if updatedModel.pendingPrompt != "hello" {
|
|
t.Fatalf("expected enter to stage the pending prompt, got %q", updatedModel.pendingPrompt)
|
|
}
|
|
if len(updatedModel.input) != 0 || updatedModel.cursor != 0 {
|
|
t.Fatal("expected enter to clear the composer immediately")
|
|
}
|
|
}
|
|
|
|
func TestComposerSlashExitQuitsProgram(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.loading = true
|
|
model.input = []rune("/exit")
|
|
model.cursor = len(model.input)
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected /exit to quit the TUI")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.submittingPrompt {
|
|
t.Fatal("expected /exit to avoid prompt submission")
|
|
}
|
|
if updatedModel.pendingPrompt != "" {
|
|
t.Fatalf("expected /exit to avoid staging a prompt, got %q", updatedModel.pendingPrompt)
|
|
}
|
|
if len(updatedModel.input) != 0 || updatedModel.cursor != 0 {
|
|
t.Fatal("expected /exit to clear the composer before quitting")
|
|
}
|
|
|
|
msg := cmd()
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("expected tea.QuitMsg, got %#v", msg)
|
|
}
|
|
}
|
|
|
|
func TestComposerUnknownSlashCommandStaysLocal(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.input = []rune("/wat")
|
|
model.cursor = len(model.input)
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: tea.KeyEnter})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect unknown slash command to queue work, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.submittingPrompt {
|
|
t.Fatal("expected unknown slash command to avoid prompt submission")
|
|
}
|
|
if updatedModel.awaitingResponse {
|
|
t.Fatal("expected unknown slash command to avoid awaiting-response state")
|
|
}
|
|
if updatedModel.status != `Unknown composer command "/wat". Available: /exit.` {
|
|
t.Fatalf("unexpected unknown command status: %q", updatedModel.status)
|
|
}
|
|
if string(updatedModel.input) != "/wat" {
|
|
t.Fatalf("expected unknown slash command to remain in the composer, got %q", string(updatedModel.input))
|
|
}
|
|
}
|
|
|
|
func TestComposerCtrlOOpensContextModal(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
target: aiTargetSummary{
|
|
Label: "Session demo",
|
|
Host: "demo-host",
|
|
OS: "linux",
|
|
Arch: "amd64",
|
|
C2: "mtls",
|
|
Mode: "interactive session",
|
|
},
|
|
connection: aiConnectionSummary{
|
|
Profile: "default",
|
|
Server: "127.0.0.1:31337",
|
|
Operator: "alice",
|
|
State: "ready",
|
|
},
|
|
}, nil)
|
|
model.focus = aiFocusComposer
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect context modal to queue work, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil || updatedModel.modal.kind != aiModalKindContext {
|
|
t.Fatalf("expected context modal, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.title != "Context" {
|
|
t.Fatalf("expected context modal title, got %+v", updatedModel.modal)
|
|
}
|
|
}
|
|
|
|
func TestComposerCtrlTOpensThinkingModal(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
ThinkingLevel: "medium",
|
|
}
|
|
model.config = &clientpb.AIConfigSummary{ThinkingLevel: "high"}
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: 't', Mod: tea.ModCtrl})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect thinking modal to queue work, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil || updatedModel.modal.kind != aiModalKindThinkingLevel {
|
|
t.Fatalf("expected thinking modal, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.title != "Thinking Level" {
|
|
t.Fatalf("expected thinking modal title, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.selectedOption != aiThinkingLevelOptionIndex("medium") {
|
|
t.Fatalf("expected current override to be selected, got %+v", updatedModel.modal)
|
|
}
|
|
}
|
|
|
|
func TestComposerEscDoesNotBlurConversation(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.focus = aiFocusComposer
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: tea.KeyEsc})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect esc to queue work, got %v", cmd)
|
|
}
|
|
if got := updated.(*aiModel).focus; got != aiFocusComposer {
|
|
t.Fatalf("expected esc to leave composer focus unchanged, got %s", got.String())
|
|
}
|
|
}
|
|
|
|
func TestComposerCtrlSOpensTargetSelectModal(t *testing.T) {
|
|
server := &aiRPCServer{
|
|
sessionsResp: &clientpb.Sessions{
|
|
Sessions: []*clientpb.Session{
|
|
{ID: "session-1", Name: "alpha", Hostname: "alpha-host", Username: "alice", OS: "linux", Arch: "amd64", ActiveC2: "mtls", RemoteAddress: "10.0.0.1:31337", PID: 1111},
|
|
},
|
|
},
|
|
beaconsResp: &clientpb.Beacons{
|
|
Beacons: []*clientpb.Beacon{
|
|
{ID: "beacon-1", Name: "bravo", Hostname: "bravo-host", Username: "bob", OS: "windows", Arch: "amd64", ActiveC2: "https", RemoteAddress: "10.0.0.2:443", PID: 2222, Interval: int64(time.Minute), NextCheckin: time.Now().Add(2 * time.Minute).Unix()},
|
|
},
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
model := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{
|
|
target: aiTargetSummary{SessionID: "session-1", Label: "Session alpha"},
|
|
}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread", TargetSessionID: "session-1"}
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: 's', Mod: tea.ModCtrl})
|
|
if cmd == nil {
|
|
t.Fatal("expected ctrl+s to load target options")
|
|
}
|
|
|
|
model = updated.(*aiModel)
|
|
if model.modal == nil || model.modal.kind != aiModalKindTargetSelect {
|
|
t.Fatalf("expected target selection modal, got %+v", model.modal)
|
|
}
|
|
if model.modal.title != "Active Target" {
|
|
t.Fatalf("expected target modal title, got %+v", model.modal)
|
|
}
|
|
|
|
msg := cmd()
|
|
updated, _ = model.Update(msg)
|
|
model = updated.(*aiModel)
|
|
if len(model.targetSelectionOptions) != 2 {
|
|
t.Fatalf("expected session and beacon options, got %d", len(model.targetSelectionOptions))
|
|
}
|
|
if model.modal.selectedOption != 0 {
|
|
t.Fatalf("expected active session to be preselected, got %d", model.modal.selectedOption)
|
|
}
|
|
|
|
rendered := ansi.Strip(model.renderTargetSelectModal())
|
|
if !strings.Contains(rendered, "Session alpha [active]") {
|
|
t.Fatalf("expected active target annotation in modal, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "alpha-host") || !strings.Contains(rendered, "10.0.0.1:31337") {
|
|
t.Fatalf("expected session metadata in modal, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestShowDeleteConversationModalTargetsSelectedConversation(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
model.focus = aiFocusSidebar
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "One"},
|
|
{ID: "conv-2", Title: "Two"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "One"}
|
|
model.selectedConversation = 1
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "x"})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect delete modal to start RPC work yet, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil || updatedModel.modal.kind != aiModalKindDeleteConfirm {
|
|
t.Fatalf("expected delete confirmation modal, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.conversationID != "conv-2" {
|
|
t.Fatalf("expected selected conversation to be targeted, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.selectedID != "conv-1" {
|
|
t.Fatalf("expected delete modal to preselect the remaining conversation, got %+v", updatedModel.modal)
|
|
}
|
|
}
|
|
|
|
func TestShowThinkingLevelModalOnTKey(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
ThinkingLevel: "high",
|
|
}
|
|
model.config = &clientpb.AIConfigSummary{ThinkingLevel: "medium"}
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "t", Code: 't'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect thinking modal to start RPC work yet, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil || updatedModel.modal.kind != aiModalKindThinkingLevel {
|
|
t.Fatalf("expected thinking modal, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.selectedOption != aiThinkingLevelOptionIndex("high") {
|
|
t.Fatalf("expected high option to be preselected, got %+v", updatedModel.modal)
|
|
}
|
|
}
|
|
|
|
func TestTargetSelectionModalSavesConversationTarget(t *testing.T) {
|
|
server := &aiRPCServer{
|
|
sessionsResp: &clientpb.Sessions{
|
|
Sessions: []*clientpb.Session{
|
|
{ID: "session-1", Name: "alpha", Hostname: "alpha-host", Username: "alice", OS: "linux", Arch: "amd64", ActiveC2: "mtls", RemoteAddress: "10.0.0.1:31337", PID: 1111},
|
|
},
|
|
},
|
|
beaconsResp: &clientpb.Beacons{
|
|
Beacons: []*clientpb.Beacon{
|
|
{ID: "beacon-1", Name: "bravo", Hostname: "bravo-host", Username: "bob", OS: "windows", Arch: "amd64", ActiveC2: "https", RemoteAddress: "10.0.0.2:443", PID: 2222, Interval: int64(time.Minute), NextCheckin: time.Now().Add(2 * time.Minute).Unix()},
|
|
},
|
|
},
|
|
saveConversationResp: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Title: "Thread",
|
|
TargetBeaconID: "beacon-1",
|
|
TargetSessionID: "",
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
model := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{
|
|
target: aiTargetSummary{
|
|
SessionID: "session-1",
|
|
Label: "Session alpha",
|
|
Host: "alpha-host",
|
|
OS: "linux",
|
|
Arch: "amd64",
|
|
C2: "mtls",
|
|
Mode: "interactive session",
|
|
},
|
|
}, nil)
|
|
model.focus = aiFocusComposer
|
|
model.loading = false
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
TargetSessionID: "session-1",
|
|
}
|
|
|
|
updated, cmd := model.handleComposerKey(tea.Key{Code: 's', Mod: tea.ModCtrl})
|
|
if cmd == nil {
|
|
t.Fatal("expected ctrl+s to load target options")
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
msg := cmd()
|
|
updated, _ = model.Update(msg)
|
|
model = updated.(*aiModel)
|
|
if len(model.targetSelectionOptions) != 2 {
|
|
t.Fatalf("expected two target options, got %d", len(model.targetSelectionOptions))
|
|
}
|
|
|
|
model.modal.selectedOption = 1
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected enter to save the selected target")
|
|
}
|
|
model = updated.(*aiModel)
|
|
if !model.loading {
|
|
t.Fatal("expected target save to mark the model as loading")
|
|
}
|
|
if model.modal != nil {
|
|
t.Fatalf("expected target modal to close after confirm, got %+v", model.modal)
|
|
}
|
|
if model.ctx.target.BeaconID != "beacon-1" || model.ctx.target.SessionID != "" {
|
|
t.Fatalf("expected local target context to switch to beacon, got %+v", model.ctx.target)
|
|
}
|
|
|
|
msg = cmd()
|
|
updated, _ = model.Update(msg)
|
|
model = updated.(*aiModel)
|
|
if model.currentConversation.GetTargetBeaconID() != "beacon-1" || model.currentConversation.GetTargetSessionID() != "" {
|
|
t.Fatalf("expected conversation target to switch to beacon, got %+v", model.currentConversation)
|
|
}
|
|
if model.status != "Active target set to Beacon bravo." {
|
|
t.Fatalf("unexpected target update status: %q", model.status)
|
|
}
|
|
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if server.getSessionsCalls != 1 {
|
|
t.Fatalf("expected GetSessions to be called once, got %d", server.getSessionsCalls)
|
|
}
|
|
if server.getBeaconsCalls != 1 {
|
|
t.Fatalf("expected GetBeacons to be called once, got %d", server.getBeaconsCalls)
|
|
}
|
|
if len(server.saveConversationReqs) != 1 {
|
|
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
|
|
}
|
|
if got := server.saveConversationReqs[0].GetTargetBeaconID(); got != "beacon-1" {
|
|
t.Fatalf("expected target beacon %q, got %q", "beacon-1", got)
|
|
}
|
|
if got := server.saveConversationReqs[0].GetTargetSessionID(); got != "" {
|
|
t.Fatalf("expected target session to clear, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestThinkingLevelModalSavesConversationOverride(t *testing.T) {
|
|
server := &aiRPCServer{
|
|
saveConversationResp: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Title: "Thread",
|
|
ThinkingLevel: "high",
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
model := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{}, nil)
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"}}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"}
|
|
model.config = &clientpb.AIConfigSummary{ThinkingLevel: "medium"}
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "t", Code: 't'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect modal open to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyDown})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect selection change to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyDown})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect selection change to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyDown})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect selection change to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected enter to save the thinking level")
|
|
}
|
|
model = updated.(*aiModel)
|
|
if !model.loading {
|
|
t.Fatal("expected save to mark the model as loading")
|
|
}
|
|
if model.modal != nil {
|
|
t.Fatalf("expected modal to close after save, got %+v", model.modal)
|
|
}
|
|
|
|
msg := cmd()
|
|
updated, _ = model.Update(msg)
|
|
model = updated.(*aiModel)
|
|
|
|
if model.currentConversation.GetThinkingLevel() != "high" {
|
|
t.Fatalf("expected current conversation thinking level to update, got %+v", model.currentConversation)
|
|
}
|
|
if model.status != "Thinking level set to high." {
|
|
t.Fatalf("unexpected status: %q", model.status)
|
|
}
|
|
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if len(server.saveConversationReqs) != 1 {
|
|
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
|
|
}
|
|
if got := server.saveConversationReqs[0].GetThinkingLevel(); got != "high" {
|
|
t.Fatalf("expected thinking level %q, got %q", "high", got)
|
|
}
|
|
}
|
|
|
|
func TestThinkingLevelModalResetsConversationToProviderDefault(t *testing.T) {
|
|
server := &aiRPCServer{
|
|
saveConversationResp: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Title: "Thread",
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
model := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{}, nil)
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test", ThinkingLevel: "high"}}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test", ThinkingLevel: "high"}
|
|
model.config = &clientpb.AIConfigSummary{ThinkingLevel: "medium"}
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "t", Code: 't'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect modal open to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
model.modal.selectedOption = 0
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected enter to save the reset thinking level")
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
msg := cmd()
|
|
updated, _ = model.Update(msg)
|
|
model = updated.(*aiModel)
|
|
|
|
if model.currentConversation.GetThinkingLevel() != "" {
|
|
t.Fatalf("expected thinking level to reset, got %+v", model.currentConversation)
|
|
}
|
|
if model.status != "Thinking level reset to provider default." {
|
|
t.Fatalf("unexpected status: %q", model.status)
|
|
}
|
|
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if len(server.saveConversationReqs) != 1 {
|
|
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
|
|
}
|
|
if got := server.saveConversationReqs[0].GetThinkingLevel(); got != "" {
|
|
t.Fatalf("expected empty thinking level override, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestShowNewConversationModalOnNKey(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "n", Code: 'n'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect new conversation modal to start RPC work yet, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil || updatedModel.modal.kind != aiModalKindNewConversation {
|
|
t.Fatalf("expected new conversation modal, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.modal.title != "New Conversation" {
|
|
t.Fatalf("expected new conversation modal title, got %+v", updatedModel.modal)
|
|
}
|
|
if got := string(updatedModel.modal.input); got != "New conversation" {
|
|
t.Fatalf("expected default modal input, got %q", got)
|
|
}
|
|
if got := string(updatedModel.modal.systemPrompt); got != "" {
|
|
t.Fatalf("expected empty default system prompt, got %q", got)
|
|
}
|
|
if updatedModel.modal.focus != aiModalFocusInput {
|
|
t.Fatalf("expected modal input focus, got %v", updatedModel.modal.focus)
|
|
}
|
|
if updatedModel.modal.cursor != len([]rune("New conversation")) {
|
|
t.Fatalf("expected cursor at end of default title, got %d", updatedModel.modal.cursor)
|
|
}
|
|
if updatedModel.modal.systemCursor != 0 {
|
|
t.Fatalf("expected system prompt cursor at start, got %d", updatedModel.modal.systemCursor)
|
|
}
|
|
}
|
|
|
|
func TestShowNewConversationModalPrefillsConfiguredSystemPrompt(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
model.config = &clientpb.AIConfigSummary{SystemPrompt: "Stay concise."}
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "n", Code: 'n'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect new conversation modal to start RPC work yet, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil {
|
|
t.Fatal("expected new conversation modal")
|
|
}
|
|
if got := string(updatedModel.modal.systemPrompt); got != "Stay concise." {
|
|
t.Fatalf("expected configured default system prompt, got %q", got)
|
|
}
|
|
if updatedModel.modal.systemCursor != len([]rune("Stay concise.")) {
|
|
t.Fatalf("expected system prompt cursor at end of default prompt, got %d", updatedModel.modal.systemCursor)
|
|
}
|
|
if updatedModel.modal.focus != aiModalFocusInput {
|
|
t.Fatalf("expected modal input focus, got %v", updatedModel.modal.focus)
|
|
}
|
|
}
|
|
|
|
func TestShowNewConversationModalPrefillsCurrentConversationSystemPromptBeforeConfigDefault(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
model.config = &clientpb.AIConfigSummary{SystemPrompt: "Global default"}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
SystemPrompt: "Current conversation prompt",
|
|
}
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "n", Code: 'n'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect new conversation modal to start RPC work yet, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil {
|
|
t.Fatal("expected new conversation modal")
|
|
}
|
|
if got := string(updatedModel.modal.systemPrompt); got != "Current conversation prompt" {
|
|
t.Fatalf("expected current conversation system prompt, got %q", got)
|
|
}
|
|
if updatedModel.modal.systemCursor != len([]rune("Current conversation prompt")) {
|
|
t.Fatalf("expected system prompt cursor at end of current prompt, got %d", updatedModel.modal.systemCursor)
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalCreatesConversationWithTypedTitle(t *testing.T) {
|
|
server := &aiRPCServer{
|
|
saveConversationResp: &clientpb.AIConversation{
|
|
ID: "conv-created",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Title: "Operator notes",
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
model := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{}, nil)
|
|
model.loading = false
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "n", Code: 'n'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect modal open to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
for range len([]rune("New conversation")) {
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect backspace to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
}
|
|
|
|
for _, r := range "Operator notes" {
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Text: string(r), Code: r})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect typing to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
}
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected enter to create the conversation")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal != nil {
|
|
t.Fatalf("expected modal to close after create, got %+v", updatedModel.modal)
|
|
}
|
|
if !updatedModel.loading {
|
|
t.Fatal("expected create to mark the model as loading")
|
|
}
|
|
|
|
msg := cmd()
|
|
created, ok := msg.(aiConversationCreatedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected aiConversationCreatedMsg, got %T", msg)
|
|
}
|
|
if created.conversationID != "conv-created" {
|
|
t.Fatalf("unexpected created conversation id: %q", created.conversationID)
|
|
}
|
|
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if len(server.saveConversationReqs) != 1 {
|
|
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
|
|
}
|
|
if got := server.saveConversationReqs[0].GetTitle(); got != "Operator notes" {
|
|
t.Fatalf("expected created title %q, got %q", "Operator notes", got)
|
|
}
|
|
if got := server.saveConversationReqs[0].GetSystemPrompt(); got != "" {
|
|
t.Fatalf("expected empty system prompt by default, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalRequiresTitle(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindNewConversation,
|
|
title: "New Conversation",
|
|
focus: aiModalFocusInput,
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect empty title submit to queue work, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil {
|
|
t.Fatal("expected modal to stay open when the title is empty")
|
|
}
|
|
if updatedModel.status != "Type a conversation name first." {
|
|
t.Fatalf("unexpected status: %q", updatedModel.status)
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalCtrlUClearsInput(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindNewConversation,
|
|
title: "New Conversation",
|
|
focus: aiModalFocusConfirm,
|
|
input: []rune("Operator notes"),
|
|
cursor: len([]rune("Operator notes")),
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect ctrl+u to queue work, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil {
|
|
t.Fatal("expected modal to remain open after clearing the title")
|
|
}
|
|
if got := string(updatedModel.modal.input); got != "" {
|
|
t.Fatalf("expected ctrl+u to clear the title, got %q", got)
|
|
}
|
|
if updatedModel.modal.cursor != 0 {
|
|
t.Fatalf("expected ctrl+u to reset the cursor, got %d", updatedModel.modal.cursor)
|
|
}
|
|
if updatedModel.modal.focus != aiModalFocusInput {
|
|
t.Fatalf("expected ctrl+u to return focus to the input, got %v", updatedModel.modal.focus)
|
|
}
|
|
if updatedModel.status != "Conversation name cleared." {
|
|
t.Fatalf("unexpected status: %q", updatedModel.status)
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalCtrlRResetsSystemPromptToConfiguredDefault(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.config = &clientpb.AIConfigSummary{SystemPrompt: "Global default"}
|
|
model.currentConversation = &clientpb.AIConversation{SystemPrompt: "Current conversation prompt"}
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindNewConversation,
|
|
title: "New Conversation",
|
|
focus: aiModalFocusConfirm,
|
|
systemPrompt: []rune("Custom prompt"),
|
|
systemCursor: len([]rune("Custom prompt")),
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect ctrl+r to queue work, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil {
|
|
t.Fatal("expected modal to remain open after resetting the system prompt")
|
|
}
|
|
if got := string(updatedModel.modal.systemPrompt); got != "Global default" {
|
|
t.Fatalf("expected ctrl+r to reset the system prompt to the server default, got %q", got)
|
|
}
|
|
if updatedModel.modal.systemCursor != len([]rune("Global default")) {
|
|
t.Fatalf("expected ctrl+r to place the cursor at the end of the server default, got %d", updatedModel.modal.systemCursor)
|
|
}
|
|
if updatedModel.modal.focus != aiModalFocusSystemPrompt {
|
|
t.Fatalf("expected ctrl+r to focus the system prompt, got %v", updatedModel.modal.focus)
|
|
}
|
|
if updatedModel.status != "System prompt reset to the server default." {
|
|
t.Fatalf("unexpected status: %q", updatedModel.status)
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalCreatesConversationWithSystemPrompt(t *testing.T) {
|
|
server := &aiRPCServer{
|
|
saveConversationResp: &clientpb.AIConversation{
|
|
ID: "conv-created",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Title: "Operator notes",
|
|
SystemPrompt: "Stay concise.",
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
model := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{}, nil)
|
|
model.loading = false
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "n", Code: 'n'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect modal open to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
for range len([]rune("New conversation")) {
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect backspace to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
}
|
|
for _, r := range "Operator notes" {
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Text: string(r), Code: r})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect typing to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
}
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyUp})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect focus change to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
if model.modal.focus != aiModalFocusSystemPrompt {
|
|
t.Fatalf("expected system prompt focus, got %v", model.modal.focus)
|
|
}
|
|
|
|
for _, r := range "Stay concise." {
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Text: string(r), Code: r})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect typing to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
}
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyDown})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect focus change to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
if model.modal.focus != aiModalFocusInput {
|
|
t.Fatalf("expected conversation name focus, got %v", model.modal.focus)
|
|
}
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected enter to create the conversation")
|
|
}
|
|
|
|
msg := cmd()
|
|
if _, ok := msg.(aiConversationCreatedMsg); !ok {
|
|
t.Fatalf("expected aiConversationCreatedMsg, got %T", msg)
|
|
}
|
|
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if len(server.saveConversationReqs) != 1 {
|
|
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
|
|
}
|
|
if got := server.saveConversationReqs[0].GetSystemPrompt(); got != "Stay concise." {
|
|
t.Fatalf("expected system prompt %q, got %q", "Stay concise.", got)
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalCancelActionClosesModal(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
|
|
updated, cmd := model.handleGlobalKey(tea.Key{Text: "n", Code: 'n'})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect modal open to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect tab to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
if model.modal == nil || model.modal.focus != aiModalFocusCancel {
|
|
t.Fatalf("expected cancel action to be focused, got %+v", model.modal)
|
|
}
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect cancel to queue work, got %v", cmd)
|
|
}
|
|
if updated.(*aiModel).modal != nil {
|
|
t.Fatal("expected cancel action to close the new conversation modal")
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalCancelsOnEscape(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindNewConversation,
|
|
title: "New Conversation",
|
|
focus: aiModalFocusInput,
|
|
input: []rune("New conversation"),
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect escape to queue work, got %v", cmd)
|
|
}
|
|
if updated.(*aiModel).modal != nil {
|
|
t.Fatal("expected escape to close the new conversation modal")
|
|
}
|
|
}
|
|
|
|
func TestDeleteConversationModalCancelsOnEscape(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindDeleteConfirm,
|
|
conversationID: "conv-1",
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect cancel to queue work, got %v", cmd)
|
|
}
|
|
if updated.(*aiModel).modal != nil {
|
|
t.Fatal("expected escape to close the delete confirmation modal")
|
|
}
|
|
}
|
|
|
|
func TestContextModalCancelsOnEscape(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindContext,
|
|
title: "Context",
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect context modal escape to queue work, got %v", cmd)
|
|
}
|
|
if updated.(*aiModel).modal != nil {
|
|
t.Fatal("expected escape to close the context modal")
|
|
}
|
|
}
|
|
|
|
func TestShowExperimentalWarningModalDefaultsToCancelFocus(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
|
|
model.showExperimentalWarningModal()
|
|
|
|
if model.modal == nil || model.modal.kind != aiModalKindExperimentalWarning {
|
|
t.Fatalf("expected experimental warning modal, got %+v", model.modal)
|
|
}
|
|
if model.modal.title != aiExperimentalWarningTitle {
|
|
t.Fatalf("unexpected warning title: %q", model.modal.title)
|
|
}
|
|
if model.modal.body != aiExperimentalWarningBody {
|
|
t.Fatalf("unexpected warning body: %q", model.modal.body)
|
|
}
|
|
if model.modal.focus != aiModalFocusCancel {
|
|
t.Fatalf("expected cancel focus by default, got %v", model.modal.focus)
|
|
}
|
|
}
|
|
|
|
func TestExperimentalWarningModalCancelsOnEnterFromDefaultFocus(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.showExperimentalWarningModal()
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if updated == nil {
|
|
t.Fatal("expected model to be returned")
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected cancel action to quit")
|
|
}
|
|
|
|
msg := cmd()
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("expected tea.QuitMsg, got %#v", msg)
|
|
}
|
|
}
|
|
|
|
func TestExperimentalWarningModalAcceptsAfterTabFocus(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{status: "Loading AI conversations from the server..."}, nil)
|
|
model.showExperimentalWarningModal()
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect focus change to queue work, got %v", cmd)
|
|
}
|
|
model = updated.(*aiModel)
|
|
if model.modal == nil || model.modal.focus != aiModalFocusConfirm {
|
|
t.Fatalf("expected confirm focus, got %+v", model.modal)
|
|
}
|
|
|
|
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatal("expected accept to start AI startup")
|
|
}
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal != nil {
|
|
t.Fatalf("expected warning modal to close after accept, got %+v", updatedModel.modal)
|
|
}
|
|
if updatedModel.status != "Loading AI conversations from the server..." {
|
|
t.Fatalf("unexpected status after accept: %q", updatedModel.status)
|
|
}
|
|
}
|
|
|
|
func TestModalViewRetainsBackgroundContent(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 120
|
|
model.height = 30
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread"}
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindDeleteConfirm,
|
|
title: "Delete Conversation?",
|
|
body: `Delete "Thread" and all of its stored messages from the server? This cannot be undone.`,
|
|
conversationID: "conv-1",
|
|
}
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
if !strings.Contains(view, "Conversations") {
|
|
t.Fatalf("expected modal view to retain the background TUI, got %q", view)
|
|
}
|
|
if !strings.Contains(view, "Delete Conversation?") {
|
|
t.Fatalf("expected modal view to include the overlay content, got %q", view)
|
|
}
|
|
}
|
|
|
|
func TestContextModalViewIncludesStyledContextContent(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
target: aiTargetSummary{
|
|
Label: "Session demo",
|
|
Host: "demo-host",
|
|
OS: "linux",
|
|
Arch: "amd64",
|
|
C2: "mtls",
|
|
Mode: "interactive session",
|
|
Details: []string{"User: alice"},
|
|
},
|
|
connection: aiConnectionSummary{
|
|
Profile: "default",
|
|
Server: "127.0.0.1:31337",
|
|
Operator: "alice",
|
|
State: "ready",
|
|
},
|
|
}, nil)
|
|
model.width = 120
|
|
model.height = 30
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
}
|
|
model.providers = []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}}
|
|
model.config = &clientpb.AIConfigSummary{Provider: "openai", Model: "gpt-test", ThinkingLevel: "high"}
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindContext,
|
|
title: "Context",
|
|
}
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
expected := []string{"Composer", "focus: sidebar", "Context", "Target", "Connection", "Thread", "Session demo", "127.0.0.1:31337"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(view, fragment) {
|
|
t.Fatalf("expected context modal view to contain %q, got %q", fragment, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewConversationModalViewIncludesOverlayContent(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 120
|
|
model.height = 30
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread"}
|
|
model.modal = &aiModalState{
|
|
kind: aiModalKindNewConversation,
|
|
title: "New Conversation",
|
|
focus: aiModalFocusInput,
|
|
input: []rune("New conversation"),
|
|
cursor: len([]rune("New conversation")),
|
|
}
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
expected := []string{"Conversations", "New Conversation", "System Prompt", "Conversation Name", "New conversation", "Cancel", "Create", "ctrl+u: clear", "ctrl+r: server default"}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(view, fragment) {
|
|
t.Fatalf("expected new conversation modal view to contain %q, got %q", fragment, view)
|
|
}
|
|
}
|
|
if strings.Index(view, "System Prompt") > strings.Index(view, "Conversation Name") {
|
|
t.Fatalf("expected system prompt field to render above conversation name, got %q", view)
|
|
}
|
|
}
|
|
|
|
func TestExperimentalWarningModalViewIncludesDangerContent(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.width = 120
|
|
model.height = 30
|
|
model.loading = false
|
|
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
|
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread"}
|
|
model.showExperimentalWarningModal()
|
|
|
|
view := ansi.Strip(model.View().Content)
|
|
expected := []string{
|
|
"Conversations",
|
|
aiExperimentalWarningTitle,
|
|
"provided on an EXPERIMENTAL basis",
|
|
"reliability or data integrity",
|
|
aiExperimentalWarningCancelLabel,
|
|
aiExperimentalWarningConfirmLabel,
|
|
}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(view, fragment) {
|
|
t.Fatalf("expected warning modal view to contain %q, got %q", fragment, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeletedConversationMsgRemovesConversationBeforeReload(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
model.awaitingResponse = true
|
|
model.pendingPrompt = "waiting"
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "One"},
|
|
{ID: "conv-2", Title: "Two"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "One",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "waiting"},
|
|
},
|
|
}
|
|
|
|
updated, cmd := model.Update(aiConversationDeletedMsg{
|
|
conversationID: "conv-1",
|
|
selectedID: "conv-2",
|
|
status: `Deleted "One".`,
|
|
})
|
|
if cmd == nil {
|
|
t.Fatal("expected delete completion to reload AI state")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if len(updatedModel.conversations) != 1 || updatedModel.conversations[0].GetID() != "conv-2" {
|
|
t.Fatalf("expected deleted conversation to be removed locally, got %+v", updatedModel.conversations)
|
|
}
|
|
if updatedModel.currentConversation != nil {
|
|
t.Fatalf("expected deleted current conversation to be cleared, got %+v", updatedModel.currentConversation)
|
|
}
|
|
if updatedModel.awaitingResponse {
|
|
t.Fatal("expected delete to clear pending response state for the removed conversation")
|
|
}
|
|
if updatedModel.pendingPrompt != "" {
|
|
t.Fatalf("expected delete to clear pending prompt, got %q", updatedModel.pendingPrompt)
|
|
}
|
|
if updatedModel.status != `Deleted "One".` {
|
|
t.Fatalf("expected delete status to be preserved, got %q", updatedModel.status)
|
|
}
|
|
}
|
|
|
|
func TestPromptSubmittedStartsAwaitingResponseImmediately(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
connection: aiConnectionSummary{Operator: "alice"},
|
|
}, nil)
|
|
model.loading = false
|
|
model.submittingPrompt = true
|
|
model.pendingPrompt = "What changed?"
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
OperatorName: "alice",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "Previous reply"},
|
|
},
|
|
}
|
|
|
|
updated, cmd := model.Update(aiPromptSubmittedMsg{
|
|
conversationID: "conv-1",
|
|
conversation: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
},
|
|
message: &clientpb.AIConversationMessage{
|
|
ID: "msg-1",
|
|
ConversationID: "conv-1",
|
|
Role: "user",
|
|
Content: "What changed?",
|
|
},
|
|
status: "Saved prompt to Thread. Waiting for AI response...",
|
|
})
|
|
if cmd == nil {
|
|
t.Fatal("expected prompt submit to keep the pending animation active")
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if !updatedModel.awaitingResponse {
|
|
t.Fatal("expected prompt submit to enter awaiting-response state immediately")
|
|
}
|
|
last := lastConversationMessage(updatedModel.currentConversation)
|
|
if last == nil || last.GetRole() != "user" || last.GetContent() != "What changed?" {
|
|
t.Fatalf("expected optimistic user message to be appended, got %#v", last)
|
|
}
|
|
if updatedModel.selectedConversation != 0 {
|
|
t.Fatalf("expected submitted conversation to remain selected, got %d", updatedModel.selectedConversation)
|
|
}
|
|
if updatedModel.pendingPrompt != "" {
|
|
t.Fatalf("expected prompt submit to clear the local pending prompt, got %q", updatedModel.pendingPrompt)
|
|
}
|
|
if updatedModel.submittingPrompt {
|
|
t.Fatal("expected prompt submit to clear the submitting state")
|
|
}
|
|
}
|
|
|
|
func TestPromptSubmittedDoesNotDuplicateUserMessageWhenEventArrivesFirst(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
connection: aiConnectionSummary{Operator: "<unknown>"},
|
|
}, nil)
|
|
model.loading = false
|
|
model.submittingPrompt = true
|
|
model.pendingPrompt = "What changed?"
|
|
model.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"},
|
|
}
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{ID: "assistant-1", Role: "assistant", Content: "Previous reply"},
|
|
{
|
|
ID: "msg-1",
|
|
ConversationID: "conv-1",
|
|
Role: "user",
|
|
Content: "What changed?",
|
|
},
|
|
},
|
|
}
|
|
|
|
updated, _ := model.Update(aiPromptSubmittedMsg{
|
|
conversationID: "conv-1",
|
|
conversation: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
},
|
|
message: &clientpb.AIConversationMessage{
|
|
ID: "msg-1",
|
|
ConversationID: "conv-1",
|
|
Role: "user",
|
|
Content: "What changed?",
|
|
},
|
|
status: "Saved prompt to Thread. Waiting for AI response...",
|
|
})
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if got := len(updatedModel.currentConversation.GetMessages()); got != 2 {
|
|
t.Fatalf("expected submit replay to preserve 2 messages without duplication, got %d", got)
|
|
}
|
|
last := lastConversationMessage(updatedModel.currentConversation)
|
|
if last == nil || last.GetID() != "msg-1" {
|
|
t.Fatalf("expected the existing saved user message to remain the last message, got %#v", last)
|
|
}
|
|
if label := messageBlockLabel(updatedModel.currentConversation, last); label != "User" {
|
|
t.Fatalf("expected unknown operator placeholders to fall back to User, got %q", label)
|
|
}
|
|
}
|
|
|
|
func TestAsyncErrClearsAwaitingResponseWhenSubmitFails(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.awaitingResponse = true
|
|
model.loading = true
|
|
model.submittingPrompt = true
|
|
model.pendingPrompt = "hello"
|
|
|
|
updated, _ := model.Update(aiAsyncErrMsg{err: assertErr("submit failed")})
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.awaitingResponse {
|
|
t.Fatal("expected submit failure to clear awaiting-response state")
|
|
}
|
|
if updatedModel.submittingPrompt {
|
|
t.Fatal("expected submit failure to clear submitting state")
|
|
}
|
|
if updatedModel.pendingPrompt != "" {
|
|
t.Fatalf("expected submit failure to clear pending prompt, got %q", updatedModel.pendingPrompt)
|
|
}
|
|
}
|
|
|
|
func TestStartAwaitingResponseShowsAnimatedFrameImmediately(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.loading = false
|
|
|
|
model.startAwaitingResponse()
|
|
|
|
rendered := ansi.Strip(strings.Join(model.renderAwaitingResponseLines(80), "\n"))
|
|
if !strings.Contains(rendered, "Working.") {
|
|
t.Fatalf("expected pending placeholder to start with an animated frame, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestStartAwaitingResponseCreatesFreshThinkingAnim(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
original := model.thinkingAnim
|
|
|
|
model.startAwaitingResponse()
|
|
|
|
if model.thinkingAnim == nil {
|
|
t.Fatal("expected pending state to allocate a thinking animation")
|
|
}
|
|
if model.thinkingAnim == original {
|
|
t.Fatal("expected pending state to create a fresh thinking animation instance")
|
|
}
|
|
}
|
|
|
|
func TestLoadedReplyClearsAwaitingResponse(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.awaitingResponse = true
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "assistant", Content: "Older reply"},
|
|
{Role: "user", Content: "Pending question"},
|
|
},
|
|
}
|
|
|
|
updated, cmd := model.Update(aiLoadedMsg{
|
|
config: &clientpb.AIConfigSummary{Valid: true},
|
|
conversations: []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"},
|
|
},
|
|
conversation: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "Pending question"},
|
|
{Role: "assistant", Content: "Rendered answer"},
|
|
},
|
|
},
|
|
selectedID: "conv-1",
|
|
})
|
|
if cmd != nil {
|
|
t.Fatalf("expected settled conversation load to stop without queuing animation, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.awaitingResponse {
|
|
t.Fatal("expected awaiting-response state to clear once the assistant reply is loaded")
|
|
}
|
|
last := lastConversationMessage(updatedModel.currentConversation)
|
|
if last == nil || last.GetRole() != "assistant" || last.GetContent() != "Rendered answer" {
|
|
t.Fatalf("expected loaded assistant reply to be current, got %#v", last)
|
|
}
|
|
}
|
|
|
|
func TestRenderTranscriptContentLinesIncludesPendingAssistantBlock(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.awaitingResponse = true
|
|
model.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
OperatorName: "alice",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "hello"},
|
|
},
|
|
}
|
|
|
|
lines := model.renderTranscriptContentLines(48)
|
|
rendered := ansi.Strip(strings.Join(lines, "\n"))
|
|
if !strings.Contains(rendered, "AI") {
|
|
t.Fatalf("expected pending assistant block label in transcript, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "╭") || !strings.Contains(rendered, "│") || !strings.Contains(rendered, "╰") {
|
|
t.Fatalf("expected pending assistant block to use box framing, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, ".") {
|
|
t.Fatalf("expected pending assistant animation content in transcript, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "hello") {
|
|
t.Fatalf("expected existing transcript content to remain, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestRenderTranscriptContentLinesIncludesPendingUserPrompt(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{
|
|
connection: aiConnectionSummary{Operator: "alice"},
|
|
}, nil)
|
|
model.pendingPrompt = "still saving"
|
|
model.awaitingResponse = true
|
|
|
|
lines := model.renderTranscriptContentLines(48)
|
|
rendered := ansi.Strip(strings.Join(lines, "\n"))
|
|
if !strings.Contains(rendered, "alice") {
|
|
t.Fatalf("expected pending prompt to render the operator label, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "╭") || !strings.Contains(rendered, "╰") {
|
|
t.Fatalf("expected pending prompt to use box framing, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "still saving") {
|
|
t.Fatalf("expected pending prompt content in transcript, got %q", rendered)
|
|
}
|
|
var wrapped bool
|
|
for _, line := range strings.Split(rendered, "\n") {
|
|
if strings.Contains(line, "still saving") {
|
|
wrapped = true
|
|
if !strings.HasPrefix(line, "│") {
|
|
t.Fatalf("expected pending prompt content to stay inside the box, got %q", line)
|
|
}
|
|
}
|
|
}
|
|
if !wrapped {
|
|
t.Fatalf("expected pending prompt line in transcript, got %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "AI") {
|
|
t.Fatalf("expected pending assistant placeholder to stay visible, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestLoadedConversationClearsPendingPromptWhenUserMessagePersists(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.pendingPrompt = "What changed?"
|
|
|
|
updated, _ := model.Update(aiLoadedMsg{
|
|
config: &clientpb.AIConfigSummary{Valid: true},
|
|
conversations: []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"},
|
|
},
|
|
conversation: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "What changed?"},
|
|
},
|
|
},
|
|
selectedID: "conv-1",
|
|
})
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.pendingPrompt != "" {
|
|
t.Fatalf("expected loaded conversation to clear the persisted pending prompt, got %q", updatedModel.pendingPrompt)
|
|
}
|
|
}
|
|
|
|
func TestLooksLikeTerminalResponseFragment(t *testing.T) {
|
|
tests := []struct {
|
|
text string
|
|
want bool
|
|
}{
|
|
{text: "]11;rgb:0000/0000/0000", want: true},
|
|
{text: "[39;17R", want: true},
|
|
{text: "[35;39R", want: true},
|
|
{text: "rgb:0000/0000/0000", want: true},
|
|
{text: "hello world", want: false},
|
|
{text: "[User]", want: false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
if got := looksLikeTerminalResponseFragment(tc.text); got != tc.want {
|
|
t.Fatalf("looksLikeTerminalResponseFragment(%q) = %v, want %v", tc.text, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIProgramAnimatesWhileSubmitPending(t *testing.T) {
|
|
releaseSubmit := make(chan struct{})
|
|
submitStarted := make(chan struct{}, 1)
|
|
server := &aiRPCServer{
|
|
saveMessageStarted: submitStarted,
|
|
saveMessageRelease: releaseSubmit,
|
|
saveMessageResp: &clientpb.AIConversationMessage{
|
|
ID: "msg-1",
|
|
ConversationID: "conv-1",
|
|
Role: "user",
|
|
Content: "hello",
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
inner := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{
|
|
connection: aiConnectionSummary{Operator: "alice"},
|
|
}, nil)
|
|
inner.loading = false
|
|
inner.focus = aiFocusComposer
|
|
inner.width = 100
|
|
inner.height = 30
|
|
inner.config = &clientpb.AIConfigSummary{
|
|
Valid: true,
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
}
|
|
inner.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
OperatorName: "alice",
|
|
}
|
|
inner.input = []rune("hello")
|
|
inner.cursor = len(inner.input)
|
|
|
|
observed := &observingAIProgramModel{
|
|
inner: inner,
|
|
started: make(chan struct{}),
|
|
stepSeen: make(chan struct{}, 4),
|
|
submitted: make(chan struct{}, 1),
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
program := tea.NewProgram(
|
|
observed,
|
|
tea.WithContext(ctx),
|
|
tea.WithInput(nil),
|
|
tea.WithOutput(&out),
|
|
tea.WithWindowSize(inner.width, inner.height),
|
|
tea.WithoutSignals(),
|
|
tea.WithoutSignalHandler(),
|
|
)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := program.Run()
|
|
errCh <- err
|
|
}()
|
|
|
|
select {
|
|
case <-observed.started:
|
|
case <-ctx.Done():
|
|
t.Fatal("program did not start")
|
|
}
|
|
|
|
program.Send(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
select {
|
|
case <-submitStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal("submit RPC did not start")
|
|
}
|
|
|
|
select {
|
|
case <-observed.stepSeen:
|
|
case <-time.After(750 * time.Millisecond):
|
|
t.Fatal("expected thinking animation to advance while submit was still pending")
|
|
}
|
|
|
|
select {
|
|
case <-observed.submitted:
|
|
t.Fatal("submit finished before the blocked RPC was released")
|
|
default:
|
|
}
|
|
|
|
close(releaseSubmit)
|
|
|
|
select {
|
|
case <-observed.submitted:
|
|
case <-ctx.Done():
|
|
t.Fatal("submit result did not return after release")
|
|
}
|
|
|
|
program.Quit()
|
|
if err := <-errCh; err != nil {
|
|
t.Fatalf("program run failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAIProgramSkipsRedundantPendingConversationReload(t *testing.T) {
|
|
blockConversationLoad := make(chan struct{})
|
|
getConversationStarted := make(chan struct{}, 1)
|
|
server := &aiRPCServer{
|
|
getConversationStart: getConversationStarted,
|
|
getConversationWait: blockConversationLoad,
|
|
providersResp: &clientpb.AIProviderConfigs{
|
|
Config: &clientpb.AIConfigSummary{
|
|
Valid: true,
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
},
|
|
},
|
|
conversationsResp: &clientpb.AIConversations{
|
|
Conversations: []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test", UpdatedAt: 100},
|
|
},
|
|
},
|
|
conversationByID: map[string]*clientpb.AIConversation{
|
|
"conv-1": {
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
OperatorName: "alice",
|
|
UpdatedAt: 100,
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "hello", CreatedAt: 100, UpdatedAt: 100},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rpcClient, cleanup := newAITestRPCClient(t, server)
|
|
defer cleanup()
|
|
|
|
inner := newAIModel(&console.SliverClient{Rpc: rpcClient}, aiContext{
|
|
connection: aiConnectionSummary{Operator: "alice"},
|
|
}, nil)
|
|
inner.loading = false
|
|
inner.width = 100
|
|
inner.height = 30
|
|
inner.currentConversation = &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
Title: "Thread",
|
|
Provider: "openai",
|
|
Model: "gpt-test",
|
|
OperatorName: "alice",
|
|
UpdatedAt: 100,
|
|
Messages: []*clientpb.AIConversationMessage{
|
|
{Role: "user", Content: "hello", CreatedAt: 100, UpdatedAt: 100},
|
|
},
|
|
}
|
|
inner.conversations = []*clientpb.AIConversation{
|
|
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test", UpdatedAt: 100},
|
|
}
|
|
initCmd := inner.startAwaitingResponse()
|
|
|
|
observed := &observingAIProgramModel{
|
|
inner: inner,
|
|
initCmd: initCmd,
|
|
started: make(chan struct{}),
|
|
stepSeen: make(chan struct{}, 8),
|
|
submitted: make(chan struct{}, 1),
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
program := tea.NewProgram(
|
|
observed,
|
|
tea.WithContext(ctx),
|
|
tea.WithInput(nil),
|
|
tea.WithOutput(&out),
|
|
tea.WithWindowSize(inner.width, inner.height),
|
|
tea.WithoutSignals(),
|
|
tea.WithoutSignalHandler(),
|
|
)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := program.Run()
|
|
errCh <- err
|
|
}()
|
|
|
|
select {
|
|
case <-observed.started:
|
|
case <-ctx.Done():
|
|
t.Fatal("program did not start")
|
|
}
|
|
|
|
select {
|
|
case <-observed.stepSeen:
|
|
case <-time.After(750 * time.Millisecond):
|
|
t.Fatal("expected animation to start before redundant event processing")
|
|
}
|
|
|
|
program.Send(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
|
|
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
|
|
Conversation: &clientpb.AIConversation{
|
|
ID: "conv-1",
|
|
OperatorName: "alice",
|
|
UpdatedAt: 100,
|
|
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
|
|
},
|
|
}})
|
|
|
|
select {
|
|
case <-getConversationStarted:
|
|
t.Fatal("expected redundant pending event to avoid reloading the conversation")
|
|
case <-time.After(250 * time.Millisecond):
|
|
}
|
|
|
|
select {
|
|
case <-observed.stepSeen:
|
|
case <-time.After(750 * time.Millisecond):
|
|
t.Fatal("expected animation to keep advancing after the redundant pending event")
|
|
}
|
|
|
|
program.Quit()
|
|
if err := <-errCh; err != nil {
|
|
t.Fatalf("program run failed: %v", err)
|
|
}
|
|
close(blockConversationLoad)
|
|
}
|
|
|
|
type testErr string
|
|
|
|
func (e testErr) Error() string {
|
|
return string(e)
|
|
}
|
|
|
|
func assertErr(message string) error {
|
|
return testErr(message)
|
|
}
|
|
|
|
type observingAIProgramModel struct {
|
|
inner *aiModel
|
|
initCmd tea.Cmd
|
|
started chan struct{}
|
|
stepSeen chan struct{}
|
|
submitted chan struct{}
|
|
startedOnce sync.Once
|
|
}
|
|
|
|
func (m *observingAIProgramModel) Init() tea.Cmd {
|
|
return m.initCmd
|
|
}
|
|
|
|
func (m *observingAIProgramModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg.(type) {
|
|
case aithinking.StepMsg:
|
|
select {
|
|
case m.stepSeen <- struct{}{}:
|
|
default:
|
|
}
|
|
case aiPromptSubmittedMsg:
|
|
select {
|
|
case m.submitted <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
_, cmd := m.inner.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *observingAIProgramModel) View() tea.View {
|
|
m.startedOnce.Do(func() {
|
|
close(m.started)
|
|
})
|
|
return m.inner.View()
|
|
}
|
|
|
|
func TestStartupConfigErrorModalIgnoresImmediateKeypress(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
|
|
updated, cmd := model.Update(aiStartupConfigInvalidMsg{err: "server AI configuration is invalid"})
|
|
if cmd != nil {
|
|
t.Fatalf("did not expect command when showing modal, got %v", cmd)
|
|
}
|
|
|
|
updatedModel := updated.(*aiModel)
|
|
if updatedModel.modal == nil {
|
|
t.Fatal("expected modal to be visible")
|
|
}
|
|
|
|
updated, cmd = updatedModel.Update(tea.KeyPressMsg{})
|
|
if cmd != nil {
|
|
t.Fatalf("expected immediate keypress to be ignored, got %v", cmd)
|
|
}
|
|
|
|
stillOpen := updated.(*aiModel)
|
|
if stillOpen.modal == nil {
|
|
t.Fatal("expected modal to remain visible after immediate keypress")
|
|
}
|
|
}
|
|
|
|
func TestStartupConfigErrorModalQuitsAfterDismissDelay(t *testing.T) {
|
|
model := newAIModel(nil, aiContext{}, nil)
|
|
model.modal = &aiModalState{
|
|
title: "AI Configuration Error",
|
|
body: "server AI configuration is invalid",
|
|
dismissReadyAt: time.Now().Add(-time.Second),
|
|
}
|
|
|
|
updated, cmd := model.Update(tea.KeyPressMsg{})
|
|
if updated == nil {
|
|
t.Fatal("expected model to be returned")
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected keypress after dismiss delay to quit")
|
|
}
|
|
|
|
msg := cmd()
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("expected tea.QuitMsg, got %#v", msg)
|
|
}
|
|
}
|