mirror of
https://github.com/ollama/ollama.git
synced 2026-08-04 14:56:15 +00:00
agent: address review feedback
This commit is contained in:
parent
5e9c48cc40
commit
183f03d997
25 changed files with 1324 additions and 614 deletions
|
|
@ -10,6 +10,7 @@ import (
|
|||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type ApprovalDecision string
|
||||
|
|
@ -29,13 +30,14 @@ const (
|
|||
)
|
||||
|
||||
type ApprovalRequest struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Args map[string]any
|
||||
WorkingDir string
|
||||
Summary string
|
||||
Risk ApprovalRisk
|
||||
Reasons []string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Args map[string]any
|
||||
WorkingDir string
|
||||
ToolApprovalRequired bool
|
||||
Summary string
|
||||
Risk ApprovalRisk
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
type ApprovalResult struct {
|
||||
|
|
@ -125,16 +127,8 @@ func (m *ApprovalManager) RequiresApproval(ctx context.Context, tool Tool, req A
|
|||
if m == nil || m.autoApprove {
|
||||
return false
|
||||
}
|
||||
evaluation := m.evaluate(ctx, req)
|
||||
if ToolRequiresApproval(tool, req.Args) && evaluation.Decision != ApprovalDeny {
|
||||
evaluation.RequirePrompt = true
|
||||
if evaluation.Summary == "" {
|
||||
evaluation.Summary = fmt.Sprintf("%s wants to run", toolApprovalDisplayName(req.ToolName))
|
||||
}
|
||||
if len(evaluation.Reasons) == 0 {
|
||||
evaluation.Reasons = []string{"tool requires approval"}
|
||||
}
|
||||
}
|
||||
req.ToolApprovalRequired = req.ToolApprovalRequired || ToolRequiresApproval(tool, req.Args)
|
||||
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
|
||||
if evaluation.Decision == ApprovalDeny {
|
||||
return true
|
||||
}
|
||||
|
|
@ -149,7 +143,7 @@ func (m *ApprovalManager) Approve(ctx context.Context, req ApprovalRequest) (App
|
|||
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
|
||||
}
|
||||
|
||||
evaluation := m.evaluate(ctx, req)
|
||||
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
|
||||
req = approvalRequestWithEvaluation(req, evaluation)
|
||||
|
||||
if evaluation.Decision == ApprovalDeny {
|
||||
|
|
@ -198,6 +192,20 @@ func (m *ApprovalManager) evaluate(ctx context.Context, req ApprovalRequest) App
|
|||
return evaluation
|
||||
}
|
||||
|
||||
func applyToolApprovalRequirement(req ApprovalRequest, evaluation ApprovalEvaluation) ApprovalEvaluation {
|
||||
if !req.ToolApprovalRequired || evaluation.Decision == ApprovalDeny {
|
||||
return evaluation
|
||||
}
|
||||
evaluation.RequirePrompt = true
|
||||
if evaluation.Summary == "" {
|
||||
evaluation.Summary = fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName))
|
||||
}
|
||||
if len(evaluation.Reasons) == 0 {
|
||||
evaluation.Reasons = []string{"tool requires approval"}
|
||||
}
|
||||
return evaluation
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) sessionAllowedFor(key string) bool {
|
||||
if m == nil || key == "" {
|
||||
return false
|
||||
|
|
@ -256,9 +264,9 @@ func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalReq
|
|||
return denyApproval(req.ToolName, ApprovalRiskHigh, reason)
|
||||
}
|
||||
}
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", toolApprovalDisplayName(req.ToolName))}
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", ToolDisplayName(req.ToolName))}
|
||||
case "web_search", "web_fetch":
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", toolApprovalDisplayName(req.ToolName))}
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", ToolDisplayName(req.ToolName))}
|
||||
case "edit":
|
||||
return evaluateEditApproval(req)
|
||||
case "bash":
|
||||
|
|
@ -267,7 +275,7 @@ func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalReq
|
|||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("%s wants to run", toolApprovalDisplayName(req.ToolName)),
|
||||
Summary: fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName)),
|
||||
Reasons: []string{"unknown tool effects"},
|
||||
SessionKey: approvalSessionKey(req),
|
||||
}
|
||||
|
|
@ -290,12 +298,31 @@ func evaluateEditApproval(req ApprovalRequest) ApprovalEvaluation {
|
|||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("Edit wants to modify %s", path),
|
||||
Summary: fmt.Sprintf("Edit wants to modify %s", sanitizeApprovalDisplay(path)),
|
||||
Reasons: reasons,
|
||||
SessionKey: "edit:" + path,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeApprovalDisplay(value string) string {
|
||||
value = approvalANSIEscapePattern.ReplaceAllString(value, "")
|
||||
value = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '\n', '\r', '\t':
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
if value == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func evaluateBashApproval(req ApprovalRequest) ApprovalEvaluation {
|
||||
command, ok := stringApprovalArg(req.Args, "command")
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
|
|
@ -319,7 +346,7 @@ func denyApproval(toolName string, risk ApprovalRisk, reason string) ApprovalEva
|
|||
return ApprovalEvaluation{
|
||||
Decision: ApprovalDeny,
|
||||
Risk: risk,
|
||||
Summary: fmt.Sprintf("%s cannot run", toolApprovalDisplayName(toolName)),
|
||||
Summary: fmt.Sprintf("%s cannot run", ToolDisplayName(toolName)),
|
||||
Reasons: []string{reason},
|
||||
SessionKey: approvalSessionKey(ApprovalRequest{ToolName: toolName}),
|
||||
}
|
||||
|
|
@ -552,8 +579,9 @@ type bashToken struct {
|
|||
}
|
||||
|
||||
var (
|
||||
bashFunctionDeclPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])(?:function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(?:\(\)[[:space:]]*)?\{`)
|
||||
bashSubshellPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])\(`)
|
||||
approvalANSIEscapePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))`)
|
||||
bashFunctionDeclPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])(?:function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(?:\(\)[[:space:]]*)?\{`)
|
||||
bashSubshellPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])\(`)
|
||||
)
|
||||
|
||||
func scanBashTokens(command string) ([]bashToken, []string, bool) {
|
||||
|
|
@ -846,23 +874,3 @@ func stableApprovalArgs(args map[string]any) string {
|
|||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func toolApprovalDisplayName(name string) string {
|
||||
switch name {
|
||||
case "web_search":
|
||||
return "Web Search"
|
||||
case "web_fetch":
|
||||
return "Web Fetch"
|
||||
case "bash":
|
||||
return "Bash"
|
||||
case "read":
|
||||
return "Read"
|
||||
case "edit":
|
||||
return "Edit"
|
||||
default:
|
||||
if name == "" {
|
||||
return "Tool"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type recordingApprovalPrompter struct {
|
||||
|
|
@ -11,6 +13,10 @@ type recordingApprovalPrompter struct {
|
|||
results []ApprovalResult
|
||||
}
|
||||
|
||||
type allowWithoutPromptPolicy struct{}
|
||||
|
||||
type approvalRequiredTestTool struct{}
|
||||
|
||||
func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, request ApprovalRequest) (ApprovalResult, error) {
|
||||
p.requests = append(p.requests, request)
|
||||
if len(p.results) == 0 {
|
||||
|
|
@ -21,6 +27,30 @@ func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, request Ap
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (allowWithoutPromptPolicy) EvaluateApproval(context.Context, ApprovalRequest) ApprovalEvaluation {
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow}
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Name() string {
|
||||
return "approval_required"
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Description() string {
|
||||
return "requires approval"
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Schema() api.ToolFunction {
|
||||
return api.ToolFunction{Name: "approval_required"}
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Execute(context.Context, ToolContext, map[string]any) (ToolResult, error) {
|
||||
return ToolResult{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestApprovalManagerAllowsSafeToolsWithoutPrompt(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
|
||||
|
|
@ -41,6 +71,31 @@ func TestApprovalManagerAllowsSafeToolsWithoutPrompt(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerToolRequiredOverridePromptsInApprove(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Policy: allowWithoutPromptPolicy{}, Prompter: prompter})
|
||||
tool := approvalRequiredTestTool{}
|
||||
request := ApprovalRequest{
|
||||
ToolName: tool.Name(),
|
||||
Args: map[string]any{},
|
||||
ToolApprovalRequired: ToolRequiresApproval(tool, nil),
|
||||
}
|
||||
|
||||
if !manager.RequiresApproval(context.Background(), tool, request) {
|
||||
t.Fatal("tool-required approval should require a prompt")
|
||||
}
|
||||
result, err := manager.Approve(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalAllowOnce {
|
||||
t.Fatalf("decision = %q, want allow_once", result.Decision)
|
||||
}
|
||||
if len(prompter.requests) != 1 {
|
||||
t.Fatalf("prompts = %d, want 1", len(prompter.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerDeniesEscapingPath(t *testing.T) {
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{})
|
||||
|
||||
|
|
@ -60,6 +115,20 @@ func TestApprovalManagerDeniesEscapingPath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerSanitizesEditSummary(t *testing.T) {
|
||||
evaluation := evaluateEditApproval(ApprovalRequest{
|
||||
ToolName: "edit",
|
||||
Args: map[string]any{"path": "notes/\x1b[31mred\nfile.txt"},
|
||||
WorkingDir: t.TempDir(),
|
||||
})
|
||||
if strings.ContainsAny(evaluation.Summary, "\n\r\x1b") {
|
||||
t.Fatalf("summary contains control characters: %q", evaluation.Summary)
|
||||
}
|
||||
if !strings.Contains(evaluation.Summary, "notes/red file.txt") {
|
||||
t.Fatalf("summary = %q, want sanitized path", evaluation.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerPromptsForEdit(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
|
||||
|
|
|
|||
|
|
@ -351,24 +351,16 @@ func (s *Store) Chat(ctx context.Context, id string) (*Chat, error) {
|
|||
|
||||
func (s *Store) LatestChat(ctx context.Context) (*Chat, error) {
|
||||
var chatID string
|
||||
if err := s.db.QueryRowContext(ctx, `
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING COALESCE(
|
||||
NULLIF(c.model_name, ''),
|
||||
(
|
||||
SELECT lm.model_name
|
||||
FROM messages lm
|
||||
WHERE lm.chat_id = c.id AND lm.model_name IS NOT NULL AND lm.model_name != ''
|
||||
ORDER BY lm.updated_at DESC, lm.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
) IS NOT NULL
|
||||
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
|
||||
LIMIT 1
|
||||
`).Scan(&chatID); err != nil {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING %[1]s IS NOT NULL
|
||||
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
|
||||
LIMIT 1
|
||||
`, currentModelSelectExpr("c"))
|
||||
if err := s.db.QueryRowContext(ctx, query).Scan(&chatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Chat(ctx, chatID)
|
||||
|
|
@ -380,24 +372,16 @@ func (s *Store) LatestChatForModel(ctx context.Context, model string) (*Chat, er
|
|||
}
|
||||
|
||||
var chatID string
|
||||
if err := s.db.QueryRowContext(ctx, `
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING COALESCE(
|
||||
NULLIF(c.model_name, ''),
|
||||
(
|
||||
SELECT lm.model_name
|
||||
FROM messages lm
|
||||
WHERE lm.chat_id = c.id AND lm.model_name IS NOT NULL AND lm.model_name != ''
|
||||
ORDER BY lm.updated_at DESC, lm.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
) = ?
|
||||
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
|
||||
LIMIT 1
|
||||
`, model).Scan(&chatID); err != nil {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING %[1]s = ?
|
||||
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
|
||||
LIMIT 1
|
||||
`, currentModelSelectExpr("c"))
|
||||
if err := s.db.QueryRowContext(ctx, query, model).Scan(&chatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Chat(ctx, chatID)
|
||||
|
|
@ -408,26 +392,28 @@ func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error)
|
|||
limit = 50
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
c.id,
|
||||
c.title,
|
||||
c.created_at,
|
||||
MAX(m.updated_at) AS updated_at,
|
||||
COUNT(m.id) AS message_count,
|
||||
COALESCE(SUM(
|
||||
LENGTH(m.role) +
|
||||
LENGTH(m.content) +
|
||||
LENGTH(m.thinking) +
|
||||
LENGTH(m.tool_name) +
|
||||
LENGTH(m.tool_call_id)
|
||||
), 0) AS approx_bytes
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
|
||||
GROUP BY c.id
|
||||
ORDER BY updated_at DESC, MAX(m.id) DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.title,
|
||||
c.created_at,
|
||||
MAX(m.updated_at) AS updated_at,
|
||||
COUNT(m.id) AS message_count,
|
||||
COALESCE(SUM(
|
||||
LENGTH(m.role) +
|
||||
LENGTH(m.content) +
|
||||
LENGTH(m.thinking) +
|
||||
LENGTH(m.tool_name) +
|
||||
LENGTH(m.tool_call_id)
|
||||
), 0) AS approx_bytes,
|
||||
%[1]s AS current_model
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
|
||||
GROUP BY c.id
|
||||
ORDER BY updated_at DESC, MAX(m.id) DESC
|
||||
LIMIT ?
|
||||
`, currentModelSelectExpr("c"))
|
||||
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list chats: %w", err)
|
||||
}
|
||||
|
|
@ -437,9 +423,13 @@ func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error)
|
|||
for rows.Next() {
|
||||
var summary ChatSummary
|
||||
var updatedAt string
|
||||
if err := rows.Scan(&summary.ID, &summary.Title, &summary.CreatedAt, &updatedAt, &summary.MessageCount, &summary.ApproxBytes); err != nil {
|
||||
var modelName sql.NullString
|
||||
if err := rows.Scan(&summary.ID, &summary.Title, &summary.CreatedAt, &updatedAt, &summary.MessageCount, &summary.ApproxBytes, &modelName); err != nil {
|
||||
return nil, fmt.Errorf("scan chat summary: %w", err)
|
||||
}
|
||||
if modelName.Valid {
|
||||
summary.Model = modelName.String
|
||||
}
|
||||
summary.UpdatedAt, err = parseSQLiteTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse chat updated_at: %w", err)
|
||||
|
|
@ -450,13 +440,6 @@ func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error)
|
|||
return nil, fmt.Errorf("read chat summaries: %w", err)
|
||||
}
|
||||
|
||||
for i := range summaries {
|
||||
model, err := currentModelForChat(ctx, s.db, summaries[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries[i].Model = model
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
|
|
@ -536,15 +519,17 @@ func latestModelForChat(ctx context.Context, db *sql.DB, chatID string) (string,
|
|||
return modelName, nil
|
||||
}
|
||||
|
||||
func currentModelForChat(ctx context.Context, db *sql.DB, chatID string) (string, error) {
|
||||
var modelName string
|
||||
if err := db.QueryRowContext(ctx, `SELECT model_name FROM chats WHERE id = ?`, chatID).Scan(&modelName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(modelName) != "" {
|
||||
return modelName, nil
|
||||
}
|
||||
return latestModelForChat(ctx, db, chatID)
|
||||
func currentModelSelectExpr(chatAlias string) string {
|
||||
return fmt.Sprintf(`COALESCE(
|
||||
NULLIF(%[1]s.model_name, ''),
|
||||
(
|
||||
SELECT lm.model_name
|
||||
FROM messages lm
|
||||
WHERE lm.chat_id = %[1]s.id AND lm.model_name IS NOT NULL AND lm.model_name != ''
|
||||
ORDER BY lm.updated_at DESC, lm.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)`, chatAlias)
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveForCompaction(ctx context.Context, chatID string, keepUserTurns int, summary string) error {
|
||||
|
|
|
|||
|
|
@ -362,6 +362,22 @@ func estimateCompactionTokens(text string) int {
|
|||
return max(1, (len([]rune(text))+3)/4)
|
||||
}
|
||||
|
||||
// EstimateTokens returns the agent's lightweight token estimate for UI hints.
|
||||
func EstimateTokens(text string) int {
|
||||
return estimateCompactionTokens(text)
|
||||
}
|
||||
|
||||
// EstimatePromptTokens returns the agent's lightweight estimate for the prompt
|
||||
// payload sent to /api/chat.
|
||||
func EstimatePromptTokens(systemPrompt string, messages []api.Message, tools api.Tools, format string) int {
|
||||
return estimateCompactionRequestTokens(CompactionRequest{
|
||||
SystemPrompt: systemPrompt,
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
Format: format,
|
||||
})
|
||||
}
|
||||
|
||||
func estimateMessagesTokens(messages []api.Message) int {
|
||||
var total int
|
||||
for _, msg := range messages {
|
||||
|
|
|
|||
184
agent/session.go
184
agent/session.go
|
|
@ -80,6 +80,14 @@ type toolOutputOverflow struct {
|
|||
content string
|
||||
}
|
||||
|
||||
type toolExecutionStop string
|
||||
|
||||
const (
|
||||
toolExecutionContinue toolExecutionStop = ""
|
||||
toolExecutionDenied toolExecutionStop = "denied"
|
||||
toolExecutionCanceled toolExecutionStop = "canceled"
|
||||
)
|
||||
|
||||
func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("nil session")
|
||||
|
|
@ -217,7 +225,7 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
|
|||
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, err
|
||||
}
|
||||
|
||||
toolMessages, denied, overflows, err := s.executeToolCalls(ctx, runID, opts, messages, pendingToolCalls)
|
||||
toolMessages, stopReason, overflows, err := s.executeToolCalls(ctx, runID, opts, messages, pendingToolCalls)
|
||||
if err != nil {
|
||||
emit(s.Events, Event{Type: EventError, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
||||
return nil, err
|
||||
|
|
@ -233,11 +241,17 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
|
|||
emit(s.Events, Event{Type: EventError, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Error: compactErr.Error()})
|
||||
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, compactErr
|
||||
}
|
||||
if denied {
|
||||
switch stopReason {
|
||||
case toolExecutionDenied:
|
||||
if err := emit(s.Events, Event{Type: EventRunFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "denied", FinishedAt: time.Now(), Response: &latest}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, nil
|
||||
case toolExecutionCanceled:
|
||||
if err := emitIgnoringCanceled(ctx, s.Events, Event{Type: EventRunFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "canceled", FinishedAt: time.Now(), Response: &latest}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, nil
|
||||
}
|
||||
toolRounds++
|
||||
}
|
||||
|
|
@ -372,7 +386,7 @@ func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions,
|
|||
return assistant, pendingToolCalls, false, nil
|
||||
}
|
||||
|
||||
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) ([]api.Message, bool, []toolOutputOverflow, error) {
|
||||
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) ([]api.Message, toolExecutionStop, []toolOutputOverflow, error) {
|
||||
approval := s.Approval
|
||||
if approval == nil {
|
||||
approval = AutoAllowApproval{}
|
||||
|
|
@ -388,17 +402,17 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
if ctx.Err() != nil {
|
||||
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i:], "Tool execution skipped because the run was canceled.")
|
||||
if skipErr != nil {
|
||||
return nil, false, nil, skipErr
|
||||
return nil, toolExecutionContinue, nil, skipErr
|
||||
}
|
||||
toolMessages = append(toolMessages, skipped...)
|
||||
return toolMessages, true, overflows, nil
|
||||
return toolMessages, toolExecutionCanceled, overflows, nil
|
||||
}
|
||||
tool, ok := s.Tools.Get(toolName)
|
||||
if !ok {
|
||||
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, projectedMessages)
|
||||
if err := s.appendToolMessage(persistCtx, opts.ChatID, msg); err != nil {
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
toolMessages = append(toolMessages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
|
|
@ -408,7 +422,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
overflows = append(overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: fmt.Sprintf("Error: unknown tool: %s", toolName)})
|
||||
}
|
||||
if emitErr := emit(s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: fmt.Sprintf("unknown tool: %s", toolName), FinishedAt: finishedAt}); emitErr != nil {
|
||||
return nil, false, nil, emitErr
|
||||
return nil, toolExecutionContinue, nil, emitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -419,18 +433,19 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
Args: args,
|
||||
WorkingDir: s.currentWorkingDir(),
|
||||
}
|
||||
approvalRequest.ToolApprovalRequired = ToolRequiresApproval(tool, args)
|
||||
if toolNeedsApproval(ctx, approval, tool, approvalRequest) {
|
||||
result, err := approval.Approve(ctx, approvalRequest)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i:], "Tool execution skipped because the run was canceled.")
|
||||
if skipErr != nil {
|
||||
return nil, false, nil, skipErr
|
||||
return nil, toolExecutionContinue, nil, skipErr
|
||||
}
|
||||
toolMessages = append(toolMessages, skipped...)
|
||||
return toolMessages, true, overflows, nil
|
||||
return toolMessages, toolExecutionCanceled, overflows, nil
|
||||
}
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
if result.Decision == ApprovalDeny {
|
||||
content := result.Reason
|
||||
|
|
@ -439,13 +454,13 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
}
|
||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, projectedMessages)
|
||||
if err := s.appendToolMessage(persistCtx, opts.ChatID, msg); err != nil {
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
toolMessages = append(toolMessages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
content = msg.Content
|
||||
if emitErr := emit(s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "denied", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: content, FinishedAt: time.Now()}); emitErr != nil {
|
||||
return nil, false, nil, emitErr
|
||||
return nil, toolExecutionContinue, nil, emitErr
|
||||
}
|
||||
for _, skipped := range calls[i+1:] {
|
||||
skippedToolName := skipped.Function.Name
|
||||
|
|
@ -453,22 +468,22 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
skippedContent := "Tool execution skipped because a previous tool call in this assistant message was denied."
|
||||
skippedMsg := s.toolMessageForContext(skippedToolName, skipped.ID, skippedContent, opts, projectedMessages)
|
||||
if err := s.appendToolMessage(persistCtx, opts.ChatID, skippedMsg); err != nil {
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
toolMessages = append(toolMessages, skippedMsg)
|
||||
projectedMessages = append(projectedMessages, skippedMsg)
|
||||
skippedContent = skippedMsg.Content
|
||||
if emitErr := emit(s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "skipped", ToolCallID: skipped.ID, ToolName: skippedToolName, Args: skippedArgs, Content: skippedContent, Error: skippedContent, FinishedAt: time.Now()}); emitErr != nil {
|
||||
return nil, false, nil, emitErr
|
||||
return nil, toolExecutionContinue, nil, emitErr
|
||||
}
|
||||
}
|
||||
return toolMessages, true, overflows, nil
|
||||
return toolMessages, toolExecutionDenied, overflows, nil
|
||||
}
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
if err := emit(s.Events, Event{Type: EventToolStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "running", ToolCallID: call.ID, ToolName: toolName, WorkingDir: s.currentWorkingDir(), Args: args, StartedAt: startedAt}); err != nil {
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
|
||||
result, err := s.Tools.Execute(ctx, s.toolContext(), call)
|
||||
|
|
@ -476,7 +491,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
rawContent := fmt.Sprintf("Error: %v", err)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
|
||||
if appendErr := s.appendToolMessage(persistCtx, opts.ChatID, msg); appendErr != nil {
|
||||
return nil, false, nil, appendErr
|
||||
return nil, toolExecutionContinue, nil, appendErr
|
||||
}
|
||||
toolMessages = append(toolMessages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
|
|
@ -486,15 +501,15 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
overflows = append(overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
||||
}
|
||||
if emitErr := emitIgnoringCanceled(ctx, s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: err.Error(), FinishedAt: finishedAt}); emitErr != nil {
|
||||
return nil, false, nil, emitErr
|
||||
return nil, toolExecutionContinue, nil, emitErr
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i+1:], "Tool execution skipped because the run was canceled.")
|
||||
if skipErr != nil {
|
||||
return nil, false, nil, skipErr
|
||||
return nil, toolExecutionContinue, nil, skipErr
|
||||
}
|
||||
toolMessages = append(toolMessages, skipped...)
|
||||
return toolMessages, true, overflows, nil
|
||||
return toolMessages, toolExecutionCanceled, overflows, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -504,7 +519,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
|
||||
if err := s.appendToolMessage(persistCtx, opts.ChatID, msg); err != nil {
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
toolMessages = append(toolMessages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
|
|
@ -515,18 +530,18 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||
overflows = append(overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
||||
}
|
||||
if err := emitIgnoringCanceled(ctx, s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "done", ToolCallID: call.ID, ToolName: toolName, WorkingDir: s.WorkingDir, Args: args, Content: content, FinishedAt: finishedAt}); err != nil {
|
||||
return nil, false, nil, err
|
||||
return nil, toolExecutionContinue, nil, err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i+1:], "Tool execution skipped because the run was canceled.")
|
||||
if skipErr != nil {
|
||||
return nil, false, nil, skipErr
|
||||
return nil, toolExecutionContinue, nil, skipErr
|
||||
}
|
||||
toolMessages = append(toolMessages, skipped...)
|
||||
return toolMessages, true, overflows, nil
|
||||
return toolMessages, toolExecutionCanceled, overflows, nil
|
||||
}
|
||||
}
|
||||
return toolMessages, false, overflows, nil
|
||||
return toolMessages, toolExecutionContinue, overflows, nil
|
||||
}
|
||||
|
||||
func (s *Session) skipToolCalls(ctx context.Context, runID string, opts RunOptions, calls []api.ToolCall, content string) ([]api.Message, error) {
|
||||
|
|
@ -615,25 +630,10 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
|
|||
if s.Compactor == nil {
|
||||
return messages, skipNotified, nil
|
||||
}
|
||||
req := CompactionRequest{
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
SystemPrompt: opts.SystemPrompt,
|
||||
Messages: messages,
|
||||
Tools: s.runTools(opts),
|
||||
Format: opts.Format,
|
||||
Latest: latest,
|
||||
Options: opts.Options,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
Think: opts.Think,
|
||||
ContinueTask: true,
|
||||
Progress: func(progress CompactionProgress) {
|
||||
_ = emit(s.Events, Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
|
||||
},
|
||||
}
|
||||
req := s.compactionRequest(runID, opts, messages, latest)
|
||||
trigger := s.autoCompactionTrigger(req)
|
||||
if trigger != "" {
|
||||
_ = emit(s.Events, Event{Type: EventCompactionStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), StartedAt: time.Now(), Response: &latest})
|
||||
s.emitCompactionStarted(runID, opts, messages, latest, trigger)
|
||||
}
|
||||
result, err := s.Compactor.MaybeCompact(ctx, req)
|
||||
if err != nil {
|
||||
|
|
@ -641,7 +641,7 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
|
|||
if trigger == "" {
|
||||
trigger = "error"
|
||||
}
|
||||
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
|
||||
s.emitCompactionSkipped(runID, opts, messages, latest, trigger, result.Reason)
|
||||
skipNotified = true
|
||||
}
|
||||
return messages, skipNotified, nil
|
||||
|
|
@ -651,12 +651,12 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
|
|||
if trigger == "" {
|
||||
trigger = "due"
|
||||
}
|
||||
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
|
||||
s.emitCompactionSkipped(runID, opts, messages, latest, trigger, result.Reason)
|
||||
skipNotified = true
|
||||
}
|
||||
return messages, skipNotified, nil
|
||||
}
|
||||
emit(s.Events, Event{Type: EventCompacted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, Content: result.Summary, Messages: result.Messages, PromptTokens: s.estimateRunPromptTokens(opts, result.Messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
|
||||
s.emitCompacted(runID, opts, result.Messages, latest, trigger, result.Summary)
|
||||
if err := s.checkPostCompactionPromptBudget(opts, result.Messages); err != nil {
|
||||
return result.Messages, skipNotified, err
|
||||
}
|
||||
|
|
@ -669,37 +669,22 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
|||
}
|
||||
|
||||
keepUserTurns := 0
|
||||
req := CompactionRequest{
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
SystemPrompt: opts.SystemPrompt,
|
||||
Messages: messages,
|
||||
Tools: s.runTools(opts),
|
||||
Format: opts.Format,
|
||||
Latest: latest,
|
||||
Options: opts.Options,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
Think: opts.Think,
|
||||
Force: true,
|
||||
ContinueTask: true,
|
||||
KeepUserTurns: &keepUserTurns,
|
||||
Progress: func(progress CompactionProgress) {
|
||||
_ = emit(s.Events, Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
|
||||
},
|
||||
}
|
||||
_ = emit(s.Events, Event{Type: EventCompactionStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), StartedAt: time.Now(), Response: &latest})
|
||||
req := s.compactionRequest(runID, opts, messages, latest)
|
||||
req.Force = true
|
||||
req.KeepUserTurns = &keepUserTurns
|
||||
s.emitCompactionStarted(runID, opts, messages, latest, "tool_output")
|
||||
|
||||
result, err := s.Compactor.MaybeCompact(ctx, req)
|
||||
if err != nil {
|
||||
if result.Due && !skipNotified {
|
||||
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
|
||||
s.emitCompactionSkipped(runID, opts, messages, latest, "tool_output", result.Reason)
|
||||
skipNotified = true
|
||||
}
|
||||
return messages, skipNotified, nil
|
||||
}
|
||||
if !result.Compacted {
|
||||
if result.Due && !skipNotified {
|
||||
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
|
||||
s.emitCompactionSkipped(runID, opts, messages, latest, "tool_output", result.Reason)
|
||||
skipNotified = true
|
||||
}
|
||||
return messages, skipNotified, nil
|
||||
|
|
@ -736,13 +721,78 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
|||
compacted = append(compacted, refit)
|
||||
}
|
||||
|
||||
emit(s.Events, Event{Type: EventCompacted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", Content: result.Summary, Messages: compacted, PromptTokens: s.estimateRunPromptTokens(opts, compacted), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
|
||||
s.emitCompacted(runID, opts, compacted, latest, "tool_output", result.Summary)
|
||||
if err := s.checkPostCompactionPromptBudget(opts, compacted); err != nil {
|
||||
return compacted, skipNotified, err
|
||||
}
|
||||
return compacted, skipNotified, nil
|
||||
}
|
||||
|
||||
func (s *Session) compactionRequest(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse) CompactionRequest {
|
||||
return CompactionRequest{
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
SystemPrompt: opts.SystemPrompt,
|
||||
Messages: messages,
|
||||
Tools: s.runTools(opts),
|
||||
Format: opts.Format,
|
||||
Latest: latest,
|
||||
Options: opts.Options,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
Think: opts.Think,
|
||||
ContinueTask: true,
|
||||
Progress: func(progress CompactionProgress) {
|
||||
_ = emit(s.Events, Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, status string) {
|
||||
_ = emit(s.Events, Event{
|
||||
Type: EventCompactionStarted,
|
||||
RunID: runID,
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
Status: status,
|
||||
PromptTokens: s.estimateRunPromptTokens(opts, messages),
|
||||
ContextWindowTokens: s.contextWindowTokens(opts),
|
||||
CompactionThresholdTokens: s.compactionThresholdTokens(opts),
|
||||
StartedAt: time.Now(),
|
||||
Response: &latest,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, status, reason string) {
|
||||
_ = emit(s.Events, Event{
|
||||
Type: EventCompactionSkipped,
|
||||
RunID: runID,
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
Status: status,
|
||||
Content: CompactionSkippedMessage(reason),
|
||||
PromptTokens: s.estimateRunPromptTokens(opts, messages),
|
||||
ContextWindowTokens: s.contextWindowTokens(opts),
|
||||
CompactionThresholdTokens: s.compactionThresholdTokens(opts),
|
||||
Response: &latest,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, status, summary string) {
|
||||
_ = emit(s.Events, Event{
|
||||
Type: EventCompacted,
|
||||
RunID: runID,
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
Status: status,
|
||||
Content: summary,
|
||||
Messages: messages,
|
||||
PromptTokens: s.estimateRunPromptTokens(opts, messages),
|
||||
ContextWindowTokens: s.contextWindowTokens(opts),
|
||||
CompactionThresholdTokens: s.compactionThresholdTokens(opts),
|
||||
Response: &latest,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) autoCompactionTrigger(req CompactionRequest) string {
|
||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
||||
if req.Force {
|
||||
|
|
|
|||
|
|
@ -749,6 +749,7 @@ func TestSessionCancellationAfterToolCallAppendsSkippedToolMessage(t *testing.T)
|
|||
func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
store := &contextAwareStore{}
|
||||
events := &recordingEventSink{}
|
||||
registry := NewRegistry()
|
||||
registry.Register(cancelingTool{cancel: cancel})
|
||||
client := &fakeClient{responses: [][]api.ChatResponse{{
|
||||
|
|
@ -763,6 +764,7 @@ func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T)
|
|||
Client: client,
|
||||
Store: store,
|
||||
Tools: registry,
|
||||
Events: events,
|
||||
}
|
||||
|
||||
result, err := session.Run(ctx, RunOptions{
|
||||
|
|
@ -786,6 +788,18 @@ func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T)
|
|||
if !strings.Contains(result.Messages[2].Content, "context canceled") {
|
||||
t.Fatalf("tool content = %q", result.Messages[2].Content)
|
||||
}
|
||||
var finished *Event
|
||||
for i := range events.events {
|
||||
if events.events[i].Type == EventRunFinished {
|
||||
finished = &events.events[i]
|
||||
}
|
||||
}
|
||||
if finished == nil {
|
||||
t.Fatalf("run finished event missing: %#v", events.events)
|
||||
}
|
||||
if finished.Status != "canceled" {
|
||||
t.Fatalf("run status = %q, want canceled", finished.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionToolLoopAllowsRoundsUnderDefaultCap(t *testing.T) {
|
||||
|
|
|
|||
73
agent/tool_display.go
Normal file
73
agent/tool_display.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToolDisplayName returns the user-facing label for a tool name.
|
||||
func ToolDisplayName(name string) string {
|
||||
switch name {
|
||||
case "web_search":
|
||||
return "Web Search"
|
||||
case "web_fetch":
|
||||
return "Web Fetch"
|
||||
case "bash":
|
||||
return "Bash"
|
||||
case "read":
|
||||
return "Read"
|
||||
case "list":
|
||||
return "List"
|
||||
case "edit":
|
||||
return "Edit"
|
||||
case "skill":
|
||||
return "Skill"
|
||||
default:
|
||||
if name == "" {
|
||||
return "Tool"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// ToolInvocationLabel returns a compact user-facing label for a tool call.
|
||||
func ToolInvocationLabel(name string, args map[string]any) string {
|
||||
displayName := ToolDisplayName(name)
|
||||
for _, key := range []string{"query", "url", "command", "path", "name"} {
|
||||
if value, ok := displayStringArg(args, key); ok {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
|
||||
}
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return displayName
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args))
|
||||
}
|
||||
|
||||
func displayStringArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func formatDisplayArgs(args map[string]any) string {
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value := fmt.Sprintf("%v", args[key])
|
||||
if len([]rune(value)) > 100 {
|
||||
value = string([]rune(value)[:100]) + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, strconv.Quote(value)))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -346,7 +345,7 @@ func GenerateAgentHeadless(cmd *cobra.Command, opts AgentTUIOptions) error {
|
|||
}
|
||||
}()
|
||||
|
||||
headlessSink := &agentHeadlessEventSink{}
|
||||
headlessSink := &agentHeadlessEventSink{hideThinking: opts.HideThinking}
|
||||
eventSink := coreagent.EventSink(headlessSink)
|
||||
traceSink, err := coreagent.NewJSONLTraceSinkFromEnv()
|
||||
if err != nil {
|
||||
|
|
@ -385,6 +384,9 @@ func GenerateAgentHeadless(cmd *cobra.Command, opts AgentTUIOptions) error {
|
|||
if headlessSink.wroteContent {
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
if headlessSink.denied {
|
||||
return errors.New("tool execution denied")
|
||||
}
|
||||
|
||||
verbose := opts.Verbose
|
||||
if cmd != nil && cmd.Flags().Lookup("verbose") != nil {
|
||||
|
|
@ -414,26 +416,45 @@ func skillFromPrompt(catalog *skills.Catalog, prompt string) (skills.Skill, stri
|
|||
}
|
||||
|
||||
type agentHeadlessEventSink struct {
|
||||
wroteContent bool
|
||||
hideThinking bool
|
||||
wroteContent bool
|
||||
wroteThinking bool
|
||||
thinkingEndedWithNewline bool
|
||||
denied bool
|
||||
}
|
||||
|
||||
func (s *agentHeadlessEventSink) Emit(event coreagent.Event) error {
|
||||
switch event.Type {
|
||||
case coreagent.EventThinkingDelta:
|
||||
if event.Thinking != "" && !s.hideThinking {
|
||||
fmt.Fprint(os.Stdout, event.Thinking)
|
||||
s.wroteContent = true
|
||||
s.wroteThinking = true
|
||||
s.thinkingEndedWithNewline = strings.HasSuffix(event.Thinking, "\n")
|
||||
}
|
||||
case coreagent.EventMessageDelta:
|
||||
if event.Content != "" {
|
||||
if s.wroteThinking {
|
||||
if !s.thinkingEndedWithNewline {
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
s.wroteThinking = false
|
||||
}
|
||||
fmt.Fprint(os.Stdout, event.Content)
|
||||
s.wroteContent = true
|
||||
}
|
||||
case coreagent.EventToolStarted:
|
||||
fmt.Fprintf(os.Stderr, "• %s in progress\n", agentHeadlessToolLabel(event.ToolName, event.Args))
|
||||
case coreagent.EventToolFinished:
|
||||
status := "done"
|
||||
if event.Error != "" {
|
||||
if event.Status != "done" || event.Error != "" {
|
||||
status = "failed"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "• %s %s\n", agentHeadlessToolLabel(event.ToolName, event.Args), status)
|
||||
fmt.Fprintf(os.Stderr, "• %s %s\n", coreagent.ToolInvocationLabel(event.ToolName, event.Args), status)
|
||||
case coreagent.EventToolsUnavailable:
|
||||
fmt.Fprintln(os.Stderr, "Tools are unavailable for this model.")
|
||||
case coreagent.EventRunFinished:
|
||||
if event.Status == "denied" {
|
||||
s.denied = true
|
||||
}
|
||||
case coreagent.EventCompactionSkipped:
|
||||
if event.Content != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", event.Content)
|
||||
|
|
@ -446,29 +467,6 @@ func (s *agentHeadlessEventSink) Emit(event coreagent.Event) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func agentHeadlessToolLabel(name string, args map[string]any) string {
|
||||
displayName := name
|
||||
switch name {
|
||||
case "web_search":
|
||||
displayName = "Web Search"
|
||||
case "web_fetch":
|
||||
displayName = "Web Fetch"
|
||||
case "bash":
|
||||
displayName = "Bash"
|
||||
case "read":
|
||||
displayName = "Read"
|
||||
case "edit":
|
||||
displayName = "Edit"
|
||||
}
|
||||
|
||||
for _, key := range []string{"query", "url", "command", "path"} {
|
||||
if value, ok := args[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
|
||||
}
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
func loadAgentSkills() *skills.Catalog {
|
||||
catalog, err := skills.LoadDefault()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,17 @@ package cmd
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/agent/skills"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/tui"
|
||||
|
|
@ -42,6 +45,146 @@ func TestAgentSystemPromptIncludesModel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAgentHeadlessEventSinkPrintsThinkingUnlessHidden(t *testing.T) {
|
||||
t.Run("visible", func(t *testing.T) {
|
||||
output := captureStdout(t, func() {
|
||||
sink := &agentHeadlessEventSink{}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "thinking"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if output != "thinking\nanswer" {
|
||||
t.Fatalf("output = %q, want thinking followed by answer", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden", func(t *testing.T) {
|
||||
output := captureStdout(t, func() {
|
||||
sink := &agentHeadlessEventSink{hideThinking: true}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "thinking"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if output != "answer" {
|
||||
t.Fatalf("output = %q, want answer only", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentHeadlessEventSinkPrintsOnlyFinishedToolEvents(t *testing.T) {
|
||||
output := captureStderr(t, func() {
|
||||
sink := &agentHeadlessEventSink{}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolStarted,
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "pwd"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "done",
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "pwd"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "denied",
|
||||
ToolName: "edit",
|
||||
Args: map[string]any{"path": "main.go"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "done",
|
||||
ToolName: "web_fetch",
|
||||
Args: map[string]any{"url": "https://example.com"},
|
||||
Error: "timeout",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if strings.Contains(output, "in progress") {
|
||||
t.Fatalf("headless output should not include in-progress tool events:\n%s", output)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`• Bash("pwd") done`,
|
||||
`• Edit("main.go") failed`,
|
||||
`• Web Fetch("https://example.com") failed`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("headless output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = oldStdout
|
||||
})
|
||||
|
||||
fn()
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = oldStdout
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func captureStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStderr := os.Stderr
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stderr = w
|
||||
t.Cleanup(func() {
|
||||
os.Stderr = oldStderr
|
||||
})
|
||||
|
||||
fn()
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stderr = oldStderr
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) {
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "1")
|
||||
t.Setenv("OLLAMA_AGENT_DISABLE_BASH", "")
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,96 @@ func TestRunHandlerPromptRunsAgentHeadless(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunHandlerHeadlessDeniedApprovalReturnsError(t *testing.T) {
|
||||
var chatCalls int
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
if err := json.NewEncoder(w).Encode(api.ShowResponse{
|
||||
Capabilities: []model.Capability{model.CapabilityTools},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "/api/generate":
|
||||
if err := json.NewEncoder(w).Encode(api.GenerateResponse{Done: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "/api/ps":
|
||||
if err := json.NewEncoder(w).Encode(api.ProcessResponse{
|
||||
Models: []api.ProcessModelResponse{{
|
||||
Name: "test-model:latest",
|
||||
Model: "test-model:latest",
|
||||
ContextLength: 8192,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "/api/chat":
|
||||
chatCalls++
|
||||
if chatCalls > 1 {
|
||||
t.Fatalf("chat calls = %d, want denied tool run to stop after first call", chatCalls)
|
||||
}
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("command", "pwd")
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
enc := json.NewEncoder(w)
|
||||
if err := enc.Encode(api.ChatResponse{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
Arguments: args,
|
||||
},
|
||||
}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := enc.Encode(api.ChatResponse{Done: true, DoneReason: "stop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "/api/status":
|
||||
if err := json.NewEncoder(w).Encode(api.StatusResponse{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(mockServer.Close)
|
||||
t.Setenv("OLLAMA_HOST", mockServer.URL)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(t.Context())
|
||||
cmd.Flags().String("format", "", "")
|
||||
cmd.Flags().String("think", "", "")
|
||||
cmd.Flags().Bool("hidethinking", false, "")
|
||||
cmd.Flags().Bool("resume", false, "")
|
||||
cmd.Flags().String("keepalive", "", "")
|
||||
cmd.Flags().Bool("nowordwrap", false, "")
|
||||
cmd.Flags().Bool("verbose", false, "")
|
||||
|
||||
oldStdout := os.Stdout
|
||||
stdoutR, stdoutW, _ := os.Pipe()
|
||||
os.Stdout = stdoutW
|
||||
oldStderr := os.Stderr
|
||||
stderrR, stderrW, _ := os.Pipe()
|
||||
os.Stderr = stderrW
|
||||
|
||||
err := RunHandler(cmd, []string{"test-model", "run pwd"})
|
||||
stdoutW.Close()
|
||||
stderrW.Close()
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
_, _ = io.Copy(io.Discard, stdoutR)
|
||||
_, _ = io.Copy(io.Discard, stderrR)
|
||||
if err == nil || !strings.Contains(err.Error(), "tool execution denied") {
|
||||
t.Fatalf("RunHandler error = %v, want tool execution denied", err)
|
||||
}
|
||||
if chatCalls != 1 {
|
||||
t.Fatalf("chat calls = %d, want 1", chatCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandlerHeadlessBudgetsAgainstLoadedContext(t *testing.T) {
|
||||
var chatCalled bool
|
||||
var generateCalled bool
|
||||
|
|
|
|||
202
cmd/tui/chat.go
202
cmd/tui/chat.go
|
|
@ -141,6 +141,7 @@ type chatModel struct {
|
|||
width int
|
||||
height int
|
||||
boundedFrame bool
|
||||
fullScreen bool
|
||||
status string
|
||||
spinner int
|
||||
tickActive bool
|
||||
|
|
@ -165,6 +166,44 @@ type chatSelection struct {
|
|||
cursor chatSelectionPoint
|
||||
}
|
||||
|
||||
func startChatSelection(selection *chatSelection, msg tea.MouseMsg, contains func(tea.MouseMsg) bool, point func(tea.MouseMsg) chatSelectionPoint) {
|
||||
if !contains(msg) {
|
||||
*selection = chatSelection{}
|
||||
return
|
||||
}
|
||||
p := point(msg)
|
||||
*selection = chatSelection{active: true, anchor: p, cursor: p}
|
||||
}
|
||||
|
||||
func dragChatSelection(selection *chatSelection, msg tea.MouseMsg, point func(tea.MouseMsg) chatSelectionPoint, scrollEdge func(tea.MouseMsg)) {
|
||||
if !selection.active {
|
||||
return
|
||||
}
|
||||
selection.cursor = point(msg)
|
||||
scrollEdge(msg)
|
||||
}
|
||||
|
||||
func finishChatSelection(m chatModel, selection *chatSelection, msg tea.MouseMsg, point func(tea.MouseMsg) chatSelectionPoint, selectedText func() string) (tea.Model, tea.Cmd) {
|
||||
if !selection.active {
|
||||
return m, nil
|
||||
}
|
||||
selection.cursor = point(msg)
|
||||
selected := selectedText()
|
||||
if strings.TrimSpace(selected) == "" {
|
||||
*selection = chatSelection{}
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
if m.opts.Clipboard == nil {
|
||||
return nil
|
||||
}
|
||||
if err := m.opts.Clipboard(m.ctx, selected); err != nil {
|
||||
return chatClipboardErrorMsg{err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type chatInputAttachment struct {
|
||||
placeholder string
|
||||
kind string
|
||||
|
|
@ -194,6 +233,8 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) {
|
|||
reviewApproval: reviewApproval,
|
||||
permissionMode: newChatPermissionMode(autoApproveTools),
|
||||
promptHistory: initialPromptHistory(ctx, opts),
|
||||
boundedFrame: true,
|
||||
fullScreen: true,
|
||||
status: "ready",
|
||||
}
|
||||
m.nextImageID, m.nextAudioID = nextInputAttachmentIDsFromMessages(m.messages)
|
||||
|
|
@ -205,7 +246,7 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) {
|
|||
m.preloadingModel = strings.TrimSpace(opts.Model)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithReportFocus())
|
||||
p := tea.NewProgram(m, tea.WithReportFocus(), tea.WithMouseCellMotion())
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -219,22 +260,28 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) {
|
|||
}
|
||||
|
||||
func (m chatModel) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{tea.EnterAltScreen}
|
||||
if m.preloadingModel != "" && m.opts.PreloadModel != nil {
|
||||
return tea.Batch(preloadModelCmd(m.ctx, m.opts.PreloadModel, m.preloadingModel, m.opts.Think), chatTickCmd())
|
||||
cmds = append(cmds, preloadModelCmd(m.ctx, m.opts.PreloadModel, m.preloadingModel, m.opts.Think), chatTickCmd())
|
||||
}
|
||||
return nil
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.canEditInput() && isShiftEnterCSI(msg) {
|
||||
m.insertInputNewline()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
wasSet := m.width > 0 || m.height > 0
|
||||
resized := m.width != msg.Width || m.height != msg.Height
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
if wasSet {
|
||||
m.boundedFrame = true
|
||||
m.scroll = m.maxScroll()
|
||||
return m, tea.ClearScreen
|
||||
if wasSet && resized {
|
||||
m.resetRenderAfterResize()
|
||||
return m, tea.Batch(tea.EnterAltScreen, tea.ClearScreen)
|
||||
}
|
||||
return m.withFlowTranscriptFlush(nil)
|
||||
|
||||
|
|
@ -278,6 +325,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.applyAgentEvent(msg.event)
|
||||
return m.withFlowTranscriptFlush(waitForChatMsg(m.events))
|
||||
|
||||
case chatClipboardErrorMsg:
|
||||
if msg.err != nil {
|
||||
m.status = "clipboard error: " + msg.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case chatApprovalPromptMsg:
|
||||
m.resumePicker = nil
|
||||
m.modelPicker = nil
|
||||
|
|
@ -310,10 +363,10 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
m.groupCompletedToolHistory()
|
||||
if msg.result == nil {
|
||||
m.finishLiveMessagesForStoppedRun(msg.newMessagesPersisted, msg.persistedMessages)
|
||||
}
|
||||
if wasCanceling || isChatContextCanceledError(msg.err) {
|
||||
if msg.result == nil {
|
||||
m.promoteLiveMessagesForCanceledRun()
|
||||
}
|
||||
m.status = "Tell the model what to do instead."
|
||||
return m.withFlowTranscriptFlush(m.startNextQueued())
|
||||
}
|
||||
|
|
@ -344,7 +397,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.Update(chatCompactDoneMsg{err: context.Canceled})
|
||||
}
|
||||
if m.running {
|
||||
return m.Update(chatRunDoneMsg{err: context.Canceled})
|
||||
return m.Update(chatRunDoneMsg{err: context.Canceled, newMessagesPersisted: true})
|
||||
}
|
||||
return m, nil
|
||||
|
||||
|
|
@ -361,6 +414,19 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
func (m *chatModel) resetRenderAfterResize() {
|
||||
m.enterFullScreen()
|
||||
m.scroll = m.maxScroll()
|
||||
m.toolDetailsScroll = clamp(m.toolDetailsScroll, 0, m.maxToolDetailsScroll())
|
||||
}
|
||||
|
||||
func (m *chatModel) enterFullScreen() {
|
||||
m.boundedFrame = true
|
||||
m.fullScreen = true
|
||||
m.flowPrintedLines = 0
|
||||
m.selection = chatSelection{}
|
||||
}
|
||||
|
||||
func (m chatModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.toolDetailsOpen {
|
||||
switch msg.Type {
|
||||
|
|
@ -433,49 +499,24 @@ func (m chatModel) mouseTranscriptPoint(msg tea.MouseMsg) chatSelectionPoint {
|
|||
}
|
||||
|
||||
func (m *chatModel) startTranscriptSelection(msg tea.MouseMsg) {
|
||||
if !m.mouseInTranscript(msg) {
|
||||
m.selection = chatSelection{}
|
||||
return
|
||||
}
|
||||
point := m.mouseTranscriptPoint(msg)
|
||||
m.selection = chatSelection{active: true, anchor: point, cursor: point}
|
||||
startChatSelection(&m.selection, msg, m.mouseInTranscript, m.mouseTranscriptPoint)
|
||||
}
|
||||
|
||||
func (m *chatModel) dragTranscriptSelection(msg tea.MouseMsg) {
|
||||
if !m.selection.active {
|
||||
return
|
||||
}
|
||||
point := m.mouseTranscriptPoint(msg)
|
||||
m.selection.cursor = point
|
||||
top, height := m.transcriptLayout()
|
||||
if msg.Y <= top {
|
||||
m.scrollBy(1)
|
||||
} else if msg.Y >= top+height-1 {
|
||||
m.scrollBy(-1)
|
||||
}
|
||||
dragChatSelection(&m.selection, msg, m.mouseTranscriptPoint, func(msg tea.MouseMsg) {
|
||||
top, height := m.transcriptLayout()
|
||||
if msg.Y <= top {
|
||||
m.scrollBy(1)
|
||||
} else if msg.Y >= top+height-1 {
|
||||
m.scrollBy(-1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m chatModel) finishTranscriptSelection(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if !m.selection.active {
|
||||
return m, nil
|
||||
}
|
||||
point := m.mouseTranscriptPoint(msg)
|
||||
m.selection.cursor = point
|
||||
selected := m.selectedTranscriptText(m.viewWidth())
|
||||
if strings.TrimSpace(selected) == "" {
|
||||
m.selection = chatSelection{}
|
||||
return m, nil
|
||||
}
|
||||
m.status = "selection copied"
|
||||
return m, func() tea.Msg {
|
||||
if m.opts.Clipboard == nil {
|
||||
return nil
|
||||
}
|
||||
if err := m.opts.Clipboard(m.ctx, selected); err != nil {
|
||||
return chatAgentMsg{event: coreagent.Event{Type: coreagent.EventError, Error: err.Error()}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return finishChatSelection(m, &m.selection, msg, m.mouseTranscriptPoint, func() string {
|
||||
return m.selectedTranscriptText(m.viewWidth())
|
||||
})
|
||||
}
|
||||
|
||||
func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
|
@ -483,8 +524,7 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m.updateToolDetailsKey(msg)
|
||||
}
|
||||
if msg.Type == tea.KeyCtrlO {
|
||||
m.toolDetailsOpen = true
|
||||
m.toolDetailsScroll = 0
|
||||
m.toggleInlineToolOutput()
|
||||
m.disarmQuit()
|
||||
m.disarmEsc()
|
||||
return m, nil
|
||||
|
|
@ -594,6 +634,19 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
func (m *chatModel) toggleInlineToolOutput() {
|
||||
m.toolOutputMode = true
|
||||
m.toolOutputOpen = !m.toolOutputOpen
|
||||
m.applyToolOutputMode()
|
||||
m.selection = chatSelection{}
|
||||
m.scroll = clamp(m.scroll, 0, m.maxScroll())
|
||||
if m.toolOutputOpen {
|
||||
m.status = "tool output shown"
|
||||
return
|
||||
}
|
||||
m.status = "tool output hidden"
|
||||
}
|
||||
|
||||
func (m chatModel) updateToolDetailsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlO, tea.KeyEsc:
|
||||
|
|
@ -615,16 +668,10 @@ func (m chatModel) updateToolDetailsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m chatModel) closeToolDetailsWindow() (tea.Model, tea.Cmd) {
|
||||
wasFlowMode := !m.boundedFrame
|
||||
m.toolDetailsOpen = false
|
||||
m.toolDetailsScroll = 0
|
||||
if !wasFlowMode {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.boundedFrame = true
|
||||
m.scroll = m.maxScroll()
|
||||
m.flowPrintedLines = 0
|
||||
m.enterFullScreen()
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
|
||||
|
|
@ -647,7 +694,7 @@ func (m chatModel) updateCtrlC() (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
return m, m.quitCmd()
|
||||
}
|
||||
|
||||
func (m chatModel) updateCtrlD() (tea.Model, tea.Cmd) {
|
||||
|
|
@ -662,7 +709,14 @@ func (m chatModel) updateCtrlD() (tea.Model, tea.Cmd) {
|
|||
if (m.running || m.compacting) && m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
return m, tea.Quit
|
||||
return m, m.quitCmd()
|
||||
}
|
||||
|
||||
func (m chatModel) quitCmd() tea.Cmd {
|
||||
if m.fullScreen {
|
||||
return tea.Batch(tea.ExitAltScreen, tea.Quit)
|
||||
}
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
func (m *chatModel) armQuit(key, status string) {
|
||||
|
|
@ -778,7 +832,7 @@ func (m chatModel) View() string {
|
|||
return m.flowView(width)
|
||||
}
|
||||
|
||||
headerLines := []string{}
|
||||
headerLines := m.headerLines()
|
||||
allTranscriptLines := m.transcriptLines(width)
|
||||
contentLineCount := len(allTranscriptLines)
|
||||
|
||||
|
|
@ -801,7 +855,7 @@ func (m chatModel) View() string {
|
|||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, bottomLines...)
|
||||
return strings.Join(lines, "\n")
|
||||
return renderFrameLines(lines, width, height)
|
||||
}
|
||||
|
||||
func (m chatModel) flowView(width int) string {
|
||||
|
|
@ -1037,7 +1091,7 @@ func (m *chatModel) startRun(input string) (tea.Model, tea.Cmd) {
|
|||
m.status = "error"
|
||||
return *m, nil
|
||||
}
|
||||
return m.startRunWithMessages(displayInput, []api.Message{message}, "")
|
||||
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "")
|
||||
}
|
||||
|
||||
func (m *chatModel) userMessageFromInput(displayInput, userInput string) (string, api.Message, error) {
|
||||
|
|
@ -1096,10 +1150,10 @@ func pluralSuffix(count int) string {
|
|||
return "s"
|
||||
}
|
||||
|
||||
func (m *chatModel) startRunWithMessages(displayInput string, newMessages []api.Message, extraSystemPrompt string) (tea.Model, tea.Cmd) {
|
||||
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt string) (tea.Model, tea.Cmd) {
|
||||
m.ensurePermissionMode()
|
||||
m.refreshContextWindowTokens(m.opts.Model)
|
||||
m.addPromptHistory(displayInput)
|
||||
m.addPromptHistory(historyInput)
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
|
||||
if len(newMessages) > 1 {
|
||||
m.entries = append(m.entries, entriesFromMessages(newMessages[1:])...)
|
||||
|
|
@ -1122,7 +1176,8 @@ func (m *chatModel) startRunWithMessages(displayInput string, newMessages []api.
|
|||
events := make(chan tea.Msg, 128)
|
||||
m.events = events
|
||||
|
||||
eventSink := coreagent.EventSink(chatEventSink{ctx: runCtx, ch: events})
|
||||
var newMessagesPersisted bool
|
||||
eventSink := coreagent.EventSink(chatEventSink{ctx: runCtx, ch: events, newMessagesPersisted: &newMessagesPersisted})
|
||||
if m.opts.EventSink != nil {
|
||||
eventSink = coreagent.MultiEventSink{eventSink, m.opts.EventSink}
|
||||
}
|
||||
|
|
@ -1149,10 +1204,13 @@ func (m *chatModel) startRunWithMessages(displayInput string, newMessages []api.
|
|||
UseTools: m.opts.Tools != nil,
|
||||
}
|
||||
|
||||
persistedMessages := make([]api.Message, 0, len(m.messages)+len(newMessages))
|
||||
persistedMessages = append(persistedMessages, slices.Clone(m.messages)...)
|
||||
persistedMessages = append(persistedMessages, slices.Clone(newMessages)...)
|
||||
go func() {
|
||||
defer close(events)
|
||||
result, err := session.Run(runCtx, opts)
|
||||
events <- chatRunDoneMsg{result: result, err: err}
|
||||
events <- chatRunDoneMsg{result: result, err: err, newMessagesPersisted: newMessagesPersisted, persistedMessages: persistedMessages}
|
||||
}()
|
||||
|
||||
tickCmd := m.scheduleTick()
|
||||
|
|
@ -1185,11 +1243,17 @@ func (m *chatModel) startNextQueued() tea.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *chatModel) promoteLiveMessagesForCanceledRun() {
|
||||
if len(m.liveMessages) == 0 || messagesHavePendingToolCalls(m.liveMessages) {
|
||||
func (m *chatModel) finishLiveMessagesForStoppedRun(promote bool, persistedMessages []api.Message) {
|
||||
if len(m.liveMessages) == 0 {
|
||||
return
|
||||
}
|
||||
m.messages = slices.Clone(m.liveMessages)
|
||||
if promote {
|
||||
if len(persistedMessages) > 0 {
|
||||
m.messages = slices.Clone(persistedMessages)
|
||||
} else if !messagesHavePendingToolCalls(m.liveMessages) {
|
||||
m.messages = slices.Clone(m.liveMessages)
|
||||
}
|
||||
}
|
||||
m.liveMessages = nil
|
||||
m.contextTokens = m.estimatePromptTokens(m.messages, "")
|
||||
m.contextEstimate = true
|
||||
|
|
|
|||
|
|
@ -250,22 +250,22 @@ func (m chatModel) renderApprovalPromptLines(width int) []string {
|
|||
func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string {
|
||||
switch request.ToolName {
|
||||
case "bash":
|
||||
command, ok := stringArg(request.Args, "command")
|
||||
command, ok := rawStringArg(request.Args, "command")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(wrapChatText("$ "+command, width), "\n")
|
||||
case "edit":
|
||||
path, ok := stringArg(request.Args, "path")
|
||||
path, ok := rawStringArg(request.Args, "path")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
lines = append(lines, "path: "+path)
|
||||
if oldText, ok := stringArg(request.Args, "old_text"); ok {
|
||||
if oldText, ok := rawStringArg(request.Args, "old_text"); ok {
|
||||
lines = append(lines, fmt.Sprintf("old_text: %d chars", len([]rune(oldText))))
|
||||
}
|
||||
if newText, ok := stringArg(request.Args, "new_text"); ok {
|
||||
if newText, ok := rawStringArg(request.Args, "new_text"); ok {
|
||||
lines = append(lines, fmt.Sprintf("new_text: %d chars", len([]rune(newText))))
|
||||
}
|
||||
return chatMetaStyle.Render(strings.Join(lines, "\n"))
|
||||
|
|
@ -273,7 +273,7 @@ func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string
|
|||
if len(request.Args) == 0 {
|
||||
return ""
|
||||
}
|
||||
return chatMetaStyle.Render(formatToolArgs(request.Args))
|
||||
return strings.Join(renderToolCallArgs(request.Args, width), "\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ func (h chatPermissionApprovalHandler) RequiresApproval(ctx context.Context, too
|
|||
if h.review != nil {
|
||||
return h.review.RequiresApproval(ctx, tool, req)
|
||||
}
|
||||
return coreagent.ToolRequiresApproval(tool, req.Args)
|
||||
return req.ToolApprovalRequired || coreagent.ToolRequiresApproval(tool, req.Args)
|
||||
}
|
||||
|
||||
func (h chatPermissionApprovalHandler) Approve(ctx context.Context, req coreagent.ApprovalRequest) (coreagent.ApprovalResult, error) {
|
||||
|
|
|
|||
|
|
@ -77,12 +77,14 @@ func TestChatApprovalPromptRendersAndApprovesOnce(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatApprovalPromptCtrlOOpensToolDetails(t *testing.T) {
|
||||
func TestChatApprovalPromptCtrlOExpandsToolDetailsInline(t *testing.T) {
|
||||
reply := make(chan coreagent.ApprovalResult, 1)
|
||||
m := chatModel{
|
||||
width: 100,
|
||||
height: 24,
|
||||
events: make(chan tea.Msg),
|
||||
width: 100,
|
||||
height: 24,
|
||||
boundedFrame: true,
|
||||
fullScreen: true,
|
||||
events: make(chan tea.Msg),
|
||||
}
|
||||
m.openApprovalPrompt(chatApprovalPromptMsg{
|
||||
request: coreagent.ApprovalRequest{
|
||||
|
|
@ -100,20 +102,19 @@ func TestChatApprovalPromptCtrlOOpensToolDetails(t *testing.T) {
|
|||
}
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
if cmd != nil {
|
||||
t.Fatal("ctrl+o should open tool details without starting a command")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if !m.toolDetailsOpen {
|
||||
t.Fatal("ctrl+o should open tool details")
|
||||
if cmd != nil {
|
||||
t.Fatal("ctrl+o should not switch screens")
|
||||
}
|
||||
if m.toolDetailsOpen {
|
||||
t.Fatal("ctrl+o should not open a separate tool details view")
|
||||
}
|
||||
if !m.fullScreen || !m.boundedFrame {
|
||||
t.Fatal("ctrl+o should keep managed fullscreen rendering")
|
||||
}
|
||||
transcript = stripANSI(m.renderTranscript(100))
|
||||
if strings.Contains(transcript, "$ git status --short --branch --untracked-files=all") {
|
||||
t.Fatalf("main transcript should stay collapsed: %q", transcript)
|
||||
}
|
||||
view := stripANSI(m.View())
|
||||
if !strings.Contains(view, "Bash") || !strings.Contains(view, "$ git status --short --branch --untracked-files=all") {
|
||||
t.Fatalf("approval tool details view should show full command: %q", view)
|
||||
if !strings.Contains(transcript, "Bash") || !strings.Contains(transcript, "$ git status --short --branch --untracked-files=all") {
|
||||
t.Fatalf("approval tool details should show inline: %q", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,14 +16,20 @@ type chatAgentMsg struct {
|
|||
event coreagent.Event
|
||||
}
|
||||
|
||||
type chatClipboardErrorMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type chatApprovalPromptMsg struct {
|
||||
request coreagent.ApprovalRequest
|
||||
reply chan<- coreagent.ApprovalResult
|
||||
}
|
||||
|
||||
type chatRunDoneMsg struct {
|
||||
result *coreagent.RunResult
|
||||
err error
|
||||
result *coreagent.RunResult
|
||||
err error
|
||||
newMessagesPersisted bool
|
||||
persistedMessages []api.Message
|
||||
}
|
||||
|
||||
type chatCompactDoneMsg struct {
|
||||
|
|
@ -65,7 +71,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
|||
m.thinking = true
|
||||
m.thinkingTokens = max(m.thinkingTokens, eventEvalCount(event))
|
||||
if eventEvalCount(event) <= 0 {
|
||||
m.thinkingTokens += estimateTokenCount(event.Thinking)
|
||||
m.thinkingTokens += coreagent.EstimateTokens(event.Thinking)
|
||||
}
|
||||
idx := m.ensureLiveAssistantMessage()
|
||||
m.liveMessages[idx].Thinking += event.Thinking
|
||||
|
|
@ -247,11 +253,15 @@ func (m *chatModel) refreshLiveContextEstimate() {
|
|||
|
||||
//nolint:containedctx // event sinks need the session context to unblock sends on cancellation.
|
||||
type chatEventSink struct {
|
||||
ctx context.Context
|
||||
ch chan<- tea.Msg
|
||||
ctx context.Context
|
||||
ch chan<- tea.Msg
|
||||
newMessagesPersisted *bool
|
||||
}
|
||||
|
||||
func (s chatEventSink) Emit(event coreagent.Event) error {
|
||||
if event.Type == coreagent.EventLoopStep && s.newMessagesPersisted != nil {
|
||||
*s.newMessagesPersisted = true
|
||||
}
|
||||
select {
|
||||
case s.ch <- chatAgentMsg{event: event}:
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -515,7 +515,7 @@ func TestChatRunDoneSuppressesDuplicateEventError(t *testing.T) {
|
|||
m := chatModel{running: true}
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventError, Error: eventErr.Error()})
|
||||
|
||||
updated, _ := m.Update(chatRunDoneMsg{err: err})
|
||||
updated, _ := m.Update(chatRunDoneMsg{err: err, newMessagesPersisted: true})
|
||||
fm := updated.(chatModel)
|
||||
|
||||
var errorEntries int
|
||||
|
|
@ -532,6 +532,75 @@ func TestChatRunDoneSuppressesDuplicateEventError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatRunDoneKeepsOnlyPersistedMessagesAfterRunError(t *testing.T) {
|
||||
err := errors.New("model connection failed")
|
||||
persisted := []api.Message{
|
||||
{Role: "user", Content: "old prompt"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "new prompt"},
|
||||
}
|
||||
m := chatModel{
|
||||
running: true,
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "old prompt"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
},
|
||||
liveMessages: []api.Message{
|
||||
{Role: "user", Content: "old prompt"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "new prompt"},
|
||||
{Role: "assistant", Content: "partial assistant not persisted"},
|
||||
},
|
||||
}
|
||||
|
||||
updated, _ := m.Update(chatRunDoneMsg{err: err, newMessagesPersisted: true, persistedMessages: persisted})
|
||||
fm := updated.(chatModel)
|
||||
|
||||
if len(fm.messages) != 3 || fm.messages[2].Content != "new prompt" {
|
||||
t.Fatalf("messages = %#v, want persisted submitted messages after error", fm.messages)
|
||||
}
|
||||
for _, msg := range fm.messages {
|
||||
if strings.Contains(msg.Content, "partial assistant") {
|
||||
t.Fatalf("messages = %#v, should not include unpersisted assistant text", fm.messages)
|
||||
}
|
||||
}
|
||||
if fm.liveMessages != nil {
|
||||
t.Fatalf("liveMessages = %#v, want cleared after error", fm.liveMessages)
|
||||
}
|
||||
if fm.status != "error" {
|
||||
t.Fatalf("status = %q, want error", fm.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRunDoneDoesNotPromoteLiveMessagesBeforePersistence(t *testing.T) {
|
||||
err := errors.New("prompt is too large for the current context")
|
||||
m := chatModel{
|
||||
running: true,
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "old prompt"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
},
|
||||
liveMessages: []api.Message{
|
||||
{Role: "user", Content: "old prompt"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "oversized prompt"},
|
||||
},
|
||||
}
|
||||
|
||||
updated, _ := m.Update(chatRunDoneMsg{err: err})
|
||||
fm := updated.(chatModel)
|
||||
|
||||
if len(fm.messages) != 2 || fm.messages[1].Content != "old answer" {
|
||||
t.Fatalf("messages = %#v, want previous persisted messages only", fm.messages)
|
||||
}
|
||||
if fm.liveMessages != nil {
|
||||
t.Fatalf("liveMessages = %#v, want cleared after failed preflight", fm.liveMessages)
|
||||
}
|
||||
if fm.status != "error" {
|
||||
t.Fatalf("status = %q, want error", fm.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatStreamingMetricsDoNotDropLiveContextEstimate(t *testing.T) {
|
||||
m := chatModel{
|
||||
running: true,
|
||||
|
|
@ -1349,9 +1418,9 @@ func TestChatCompactCommandShowsSummary(t *testing.T) {
|
|||
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
fm = updated.(chatModel)
|
||||
view := stripANSI(fm.renderToolDetailsWindow(100, 24))
|
||||
view := stripANSI(fm.renderTranscript(100))
|
||||
if !strings.Contains(view, "old work summary") {
|
||||
t.Fatalf("details view should show compacted summary body: %q", view)
|
||||
t.Fatalf("expanded transcript should show compacted summary body: %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
"github.com/ollama/ollama/agent/skills"
|
||||
agenttools "github.com/ollama/ollama/agent/tools"
|
||||
|
|
@ -24,6 +25,7 @@ import (
|
|||
type chatSlashCommand struct {
|
||||
name string
|
||||
description string
|
||||
aliases []string
|
||||
hidden bool
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +56,11 @@ var chatSlashCommands = []chatSlashCommand{
|
|||
{name: "/think", description: "set thinking mode"},
|
||||
{name: "/verbose", description: "toggle model metrics"},
|
||||
{name: "/compact", description: "summarize older context"},
|
||||
{name: "/help", description: "show commands"},
|
||||
{name: "/bye", description: "exit"},
|
||||
{name: "/help", description: "show commands", aliases: []string{"/?"}},
|
||||
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
|
||||
{name: "/load", hidden: true},
|
||||
{name: "/set", hidden: true},
|
||||
{name: "/show", hidden: true},
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
|
|
@ -103,47 +108,52 @@ func (m chatModel) selectedSlashCommand() (string, bool) {
|
|||
}
|
||||
|
||||
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||||
command, args, hasSlashCommand := slashCommandInvocation(input)
|
||||
if hasSlashCommand {
|
||||
input = strings.TrimSpace(command + " " + args)
|
||||
}
|
||||
|
||||
switch {
|
||||
case input == "/bye" || input == "/exit":
|
||||
case command == "/bye":
|
||||
m.quitting = true
|
||||
return *m, tea.Quit
|
||||
case input == "/?" || input == "/help":
|
||||
return *m, m.quitCmd()
|
||||
case command == "/help" && args == "":
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: m.helpSummary()}))
|
||||
return *m, nil
|
||||
case strings.HasPrefix(input, "/? ") || strings.HasPrefix(input, "/help "):
|
||||
case command == "/help":
|
||||
return m.handleLegacyHelpCommand(input)
|
||||
case input == "/clear":
|
||||
case command == "/clear" && args == "":
|
||||
return m.resetChat("cleared")
|
||||
case input == "/model" || strings.HasPrefix(input, "/model "):
|
||||
filter := strings.TrimSpace(strings.TrimPrefix(input, "/model"))
|
||||
return m.openModelPicker(filter)
|
||||
case input == "/load" || strings.HasPrefix(input, "/load "):
|
||||
case command == "/model":
|
||||
return m.openModelPicker(args)
|
||||
case command == "/load":
|
||||
return m.handleLegacyLoadCommand(input)
|
||||
case input == "/think":
|
||||
case command == "/think" && args == "":
|
||||
return m.openThinkPicker()
|
||||
case strings.HasPrefix(input, "/think "):
|
||||
return m.handleThinkCommand(strings.TrimSpace(strings.TrimPrefix(input, "/think")))
|
||||
case input == "/set" || strings.HasPrefix(input, "/set "):
|
||||
case command == "/think":
|
||||
return m.handleThinkCommand(args)
|
||||
case command == "/set":
|
||||
return m.handleLegacySetCommand(input)
|
||||
case input == "/show" || strings.HasPrefix(input, "/show "):
|
||||
case command == "/show":
|
||||
return m.handleLegacyShowCommand(input)
|
||||
case input == "/history":
|
||||
case command == "/history" && args == "":
|
||||
return m.openHistoryPopup()
|
||||
case input == "/skills" || strings.HasPrefix(input, "/skills "):
|
||||
case command == "/skills":
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: m.handleSkillsCommand(input)}))
|
||||
return *m, nil
|
||||
case input == "/new":
|
||||
case command == "/new" && args == "":
|
||||
return m.resetChat("new chat")
|
||||
case input == "/resume":
|
||||
case command == "/resume" && args == "":
|
||||
return m.openResumePicker()
|
||||
case input == "/verbose" || strings.HasPrefix(input, "/verbose "):
|
||||
case command == "/verbose":
|
||||
return m.handleVerboseCommand(input)
|
||||
case input == "/compact":
|
||||
case command == "/compact" && args == "":
|
||||
return m.startManualCompaction()
|
||||
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
|
||||
return m.startRun(input)
|
||||
case strings.HasPrefix(input, "/"):
|
||||
if skill, request, ok := m.skillTrigger(input); ok {
|
||||
historyInput := m.expandPastedTextPlaceholders(input)
|
||||
displayInput, userMessage, err := m.userMessageFromInput(input, request)
|
||||
if err != nil {
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
|
||||
|
|
@ -158,7 +168,7 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
|||
manualMessages[0].Content = userMessage.Content
|
||||
}
|
||||
manualMessages[0].Images = userMessage.Images
|
||||
return m.startRunWithMessages(displayInput, manualMessages, "")
|
||||
return m.startRunWithMessages(displayInput, historyInput, manualMessages, "")
|
||||
}
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Unknown command %q", strings.Fields(input)[0])}))
|
||||
return *m, nil
|
||||
|
|
@ -270,6 +280,7 @@ func (m *chatModel) movePromptHistory(delta int) bool {
|
|||
m.inputCursor = len(m.input)
|
||||
m.inputCursorSet = true
|
||||
m.inputAttachments = nil
|
||||
m.inputPastedTexts = nil
|
||||
m.resetPromptHistoryCursor()
|
||||
m.complete = 0
|
||||
return true
|
||||
|
|
@ -279,7 +290,12 @@ func (m *chatModel) movePromptHistory(delta int) bool {
|
|||
}
|
||||
}
|
||||
|
||||
m.input = []rune(m.promptHistory[m.promptCursor])
|
||||
m.inputPastedTexts = nil
|
||||
input := m.promptHistory[m.promptCursor]
|
||||
if placeholder, ok := m.pastedTextPlaceholder(input); ok {
|
||||
input = placeholder
|
||||
}
|
||||
m.input = []rune(input)
|
||||
m.inputCursor = len(m.input)
|
||||
m.inputCursorSet = true
|
||||
m.inputAttachments = nil
|
||||
|
|
@ -848,7 +864,7 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac
|
|||
}
|
||||
if len(raw) > maxBodyLines {
|
||||
raw = slices.Clone(raw[len(raw)-maxBodyLines:])
|
||||
raw[0] = truncateInputLine(continuationPrefix+"... "+trimInputPromptPrefix(raw[0]), width)
|
||||
raw[0] = truncateInputLine(continuationPrefix+trimInputPromptPrefix(raw[0]), width)
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(raw))
|
||||
|
|
@ -926,11 +942,10 @@ func truncateInputLine(line string, width int) string {
|
|||
if width <= 0 {
|
||||
return line
|
||||
}
|
||||
runes := []rune(line)
|
||||
if len(runes) <= width {
|
||||
if runewidth.StringWidth(line) <= width {
|
||||
return line
|
||||
}
|
||||
return string(runes[:width])
|
||||
return runewidth.Truncate(line, width, "")
|
||||
}
|
||||
|
||||
func renderPromptRow(text string, width int) []string {
|
||||
|
|
@ -1045,13 +1060,40 @@ func matchingSlashCommands(input string) []chatSlashCommand {
|
|||
if command.hidden {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(command.name, prefix) {
|
||||
if command.matchesPrefix(prefix) {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func (c chatSlashCommand) matchesPrefix(prefix string) bool {
|
||||
if strings.HasPrefix(c.name, prefix) {
|
||||
return true
|
||||
}
|
||||
for _, alias := range c.aliases {
|
||||
if strings.HasPrefix(alias, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func slashCommandInvocation(input string) (string, string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if !strings.HasPrefix(input, "/") {
|
||||
return "", "", false
|
||||
}
|
||||
token, args, _ := strings.Cut(input, " ")
|
||||
token = strings.ToLower(token)
|
||||
for _, command := range chatSlashCommands {
|
||||
if command.name == token || slices.Contains(command.aliases, token) {
|
||||
return command.name, strings.TrimSpace(args), true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func (m chatModel) mentionCompletions() []chatCompletion {
|
||||
input := string(m.input)
|
||||
_, query, ok := activeMentionToken(input)
|
||||
|
|
@ -1212,29 +1254,30 @@ func completionIsSelectable(completions []chatCompletion) bool {
|
|||
}
|
||||
|
||||
func (m chatModel) helpSummary() string {
|
||||
return strings.Join([]string{
|
||||
lines := []string{
|
||||
"**Commands**",
|
||||
"",
|
||||
"- `/model`: switch models",
|
||||
"- `/think`: set thinking mode",
|
||||
"- `/skills`: show or import skills",
|
||||
"- `/<skill>`: run the next message with a skill",
|
||||
"- `/new`: start a new chat",
|
||||
"- `/resume`: resume a saved chat",
|
||||
"- `/verbose`: toggle model metrics",
|
||||
"- `/compact`: summarize older context",
|
||||
"- `/clear`: clear this chat",
|
||||
"- `/help`: show commands",
|
||||
"- `/bye`: exit",
|
||||
}
|
||||
for _, command := range chatSlashCommands {
|
||||
if command.hidden || strings.TrimSpace(command.description) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- `%s`: %s", command.name, command.description))
|
||||
if command.name == "/skills" {
|
||||
lines = append(lines, "- `/<skill>`: run the next message with a skill")
|
||||
}
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"**Shortcuts**",
|
||||
"",
|
||||
"- `ctrl+o`: open tool details",
|
||||
"- `ctrl+o`: toggle tool output",
|
||||
"- `shift+enter`: insert a newline",
|
||||
"- `shift+tab`: toggle permission mode",
|
||||
"- `↑/↓`: previous or next prompt",
|
||||
"- `ctrl+a/e`: move to line start or end",
|
||||
}, "\n")
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m chatModel) historyMessages() []api.Message {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ func TestChatHelpCommandShowsCommands(t *testing.T) {
|
|||
!strings.Contains(fm.entries[0].content, "- `/help`: show commands") ||
|
||||
!strings.Contains(fm.entries[0].content, "- `/bye`: exit") ||
|
||||
!strings.Contains(fm.entries[0].content, "**Shortcuts**") ||
|
||||
!strings.Contains(fm.entries[0].content, "- `ctrl+o`: open tool details") ||
|
||||
!strings.Contains(fm.entries[0].content, "- `ctrl+o`: toggle tool output") ||
|
||||
!strings.Contains(fm.entries[0].content, "- `shift+enter`: insert a newline") ||
|
||||
!strings.Contains(fm.entries[0].content, "- `shift+tab`: toggle permission mode") ||
|
||||
!strings.Contains(fm.entries[0].content, "- `ctrl+a/e`: move to line start or end") {
|
||||
|
|
@ -49,6 +50,40 @@ func TestChatHelpCommandShowsCommands(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTruncateInputLineUsesDisplayWidth(t *testing.T) {
|
||||
line := truncateInputLine(strings.Repeat("界", 10), 10)
|
||||
if got := lipgloss.Width(line); got > 10 {
|
||||
t.Fatalf("line %q width = %d, want <= 10", line, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInputBoxTruncationUsesSingleContinuationMarker(t *testing.T) {
|
||||
lines := renderInputBoxLines("one two three four five six seven", len("one two three four five six seven"), 16, 1, "")
|
||||
rendered := strings.Join(lines, "\n")
|
||||
if strings.Contains(rendered, "... ...") {
|
||||
t.Fatalf("input rendered duplicate continuation marker: %q", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "...") {
|
||||
t.Fatalf("input should include continuation marker: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
type shiftEnterCSITestMsg string
|
||||
|
||||
func (m shiftEnterCSITestMsg) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
func TestChatInputHandlesShiftEnterCSIMessage(t *testing.T) {
|
||||
m := chatModel{input: []rune("line one")}
|
||||
|
||||
updated, _ := m.Update(shiftEnterCSITestMsg("?CSI[49 51 59 50 117]?"))
|
||||
m = updated.(chatModel)
|
||||
if got := string(m.input); got != "line one\n" {
|
||||
t.Fatalf("input = %q, want newline inserted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatInputAcceptsSpace(t *testing.T) {
|
||||
m := chatModel{}
|
||||
|
||||
|
|
@ -94,6 +129,21 @@ func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) {
|
|||
if got := m.liveMessages[0].Content; got != pasted {
|
||||
t.Fatalf("model content = %q, want pasted text", got)
|
||||
}
|
||||
if len(m.promptHistory) != 1 || m.promptHistory[0] != pasted {
|
||||
t.Fatalf("prompt history = %#v, want expanded pasted text", m.promptHistory)
|
||||
}
|
||||
|
||||
m.running = false
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||||
m = updated.(chatModel)
|
||||
if got := string(m.input); got != "[Pasted text #2 +8 lines]" {
|
||||
t.Fatalf("recalled input = %q, want pasted text placeholder", got)
|
||||
}
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(chatModel)
|
||||
if got := m.liveMessages[0].Content; got != pasted {
|
||||
t.Fatalf("recalled model content = %q, want pasted text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBackspaceDeletesWholePastedTextPlaceholder(t *testing.T) {
|
||||
|
|
@ -896,6 +946,9 @@ func TestChatSkillSlashCompletionAndTrigger(t *testing.T) {
|
|||
if len(m.entries) == 0 || m.entries[0].content != "/go-code write a test" {
|
||||
t.Fatalf("displayed entries = %#v", m.entries)
|
||||
}
|
||||
if len(m.promptHistory) != 1 || m.promptHistory[0] != "/go-code write a test" {
|
||||
t.Fatalf("prompt history = %#v, want slash skill command preserved", m.promptHistory)
|
||||
}
|
||||
runDone := waitForRunDone(t, m.events)
|
||||
if runDone.err != nil {
|
||||
t.Fatal(runDone.err)
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ func legacyShortcutUsage() string {
|
|||
return strings.Join([]string{
|
||||
"**Shortcuts**",
|
||||
"",
|
||||
"- `ctrl+o`: open tool details",
|
||||
"- `ctrl+o`: toggle tool output",
|
||||
"- `shift+enter`: insert a newline",
|
||||
"- `shift+tab`: toggle permission mode",
|
||||
"- `cmd+backspace`, `option+backspace`, `ctrl+w`: delete previous word",
|
||||
|
|
|
|||
|
|
@ -525,13 +525,7 @@ func wrapTableCell(text string, width int) []string {
|
|||
line := strings.TrimRight(rawLine, "\r")
|
||||
for lipgloss.Width(line) > width {
|
||||
runes := []rune(line)
|
||||
cut := min(width, len(runes))
|
||||
for i := cut; i > max(1, cut/2); i-- {
|
||||
if unicode.IsSpace(runes[i-1]) {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
}
|
||||
cut := tableCellWrapCut(runes, width)
|
||||
out = append(out, strings.TrimSpace(string(runes[:cut])))
|
||||
line = strings.TrimSpace(string(runes[cut:]))
|
||||
}
|
||||
|
|
@ -543,6 +537,34 @@ func wrapTableCell(text string, width int) []string {
|
|||
return out
|
||||
}
|
||||
|
||||
func tableCellWrapCut(runes []rune, width int) int {
|
||||
if len(runes) == 0 {
|
||||
return 0
|
||||
}
|
||||
cut := 0
|
||||
lineWidth := 0
|
||||
for i, r := range runes {
|
||||
nextWidth := lipgloss.Width(string(r))
|
||||
if cut > 0 && lineWidth+nextWidth > width {
|
||||
break
|
||||
}
|
||||
lineWidth += nextWidth
|
||||
cut = i + 1
|
||||
}
|
||||
if cut <= 0 {
|
||||
return 1
|
||||
}
|
||||
preferred := cut
|
||||
spaceWidth := 0
|
||||
for i := 0; i < cut; i++ {
|
||||
spaceWidth += lipgloss.Width(string(runes[i]))
|
||||
if unicode.IsSpace(runes[i]) && spaceWidth >= max(1, width/2) {
|
||||
preferred = i + 1
|
||||
}
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
func markdownTableRenderedWidth(widths []int) int {
|
||||
if len(widths) == 0 {
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package tui
|
|||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func TestChatMarkdownRendersAssistantAndSystemOutput(t *testing.T) {
|
||||
|
|
@ -109,6 +111,15 @@ func TestChatMarkdownRendersTableWithinWidth(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWrapTableCellUsesDisplayWidth(t *testing.T) {
|
||||
lines := wrapTableCell(strings.Repeat("界", 6), 4)
|
||||
for _, line := range lines {
|
||||
if width := lipgloss.Width(line); width > 4 {
|
||||
t.Fatalf("line %q width = %d, want <= 4", line, width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMarkdownRendersTableWithoutOuterPipes(t *testing.T) {
|
||||
markdown := strings.Join([]string{
|
||||
"Here is a summary table:",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/agent/chatstore"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
|
@ -58,6 +57,9 @@ func (m chatModel) updateHistoryPopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
m.historyPopup = nil
|
||||
m.status = "ready"
|
||||
if m.fullScreen {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.ExitAltScreen
|
||||
case tea.KeyUp:
|
||||
m.moveHistoryPopup(-1)
|
||||
|
|
@ -141,47 +143,28 @@ func (m *chatModel) startHistoryPopupSelection(msg tea.MouseMsg) {
|
|||
if m.historyPopup == nil {
|
||||
return
|
||||
}
|
||||
if !m.mouseInHistoryPopupBody(msg) {
|
||||
m.historyPopup.selection = chatSelection{}
|
||||
return
|
||||
}
|
||||
point := m.mouseHistoryPopupPoint(msg)
|
||||
m.historyPopup.selection = chatSelection{active: true, anchor: point, cursor: point}
|
||||
startChatSelection(&m.historyPopup.selection, msg, m.mouseInHistoryPopupBody, m.mouseHistoryPopupPoint)
|
||||
}
|
||||
|
||||
func (m *chatModel) dragHistoryPopupSelection(msg tea.MouseMsg) {
|
||||
if m.historyPopup == nil || !m.historyPopup.selection.active {
|
||||
if m.historyPopup == nil {
|
||||
return
|
||||
}
|
||||
m.historyPopup.selection.cursor = m.mouseHistoryPopupPoint(msg)
|
||||
top, height := m.historyPopupLayout()
|
||||
if msg.Y <= top {
|
||||
m.moveHistoryPopup(-1)
|
||||
} else if msg.Y >= top+height-1 {
|
||||
m.moveHistoryPopup(1)
|
||||
}
|
||||
dragChatSelection(&m.historyPopup.selection, msg, m.mouseHistoryPopupPoint, func(msg tea.MouseMsg) {
|
||||
top, height := m.historyPopupLayout()
|
||||
if msg.Y <= top {
|
||||
m.moveHistoryPopup(-1)
|
||||
} else if msg.Y >= top+height-1 {
|
||||
m.moveHistoryPopup(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m chatModel) finishHistoryPopupSelection(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.historyPopup == nil || !m.historyPopup.selection.active {
|
||||
if m.historyPopup == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.historyPopup.selection.cursor = m.mouseHistoryPopupPoint(msg)
|
||||
selected := m.selectedHistoryPopupText()
|
||||
if strings.TrimSpace(selected) == "" {
|
||||
m.historyPopup.selection = chatSelection{}
|
||||
return m, nil
|
||||
}
|
||||
m.status = "selection copied"
|
||||
return m, func() tea.Msg {
|
||||
if m.opts.Clipboard == nil {
|
||||
return nil
|
||||
}
|
||||
if err := m.opts.Clipboard(m.ctx, selected); err != nil {
|
||||
return chatAgentMsg{event: coreagent.Event{Type: coreagent.EventError, Error: err.Error()}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return finishChatSelection(m, &m.historyPopup.selection, msg, m.mouseHistoryPopupPoint, m.selectedHistoryPopupText)
|
||||
}
|
||||
|
||||
func (m chatModel) historyPopupMaxScroll() int {
|
||||
|
|
|
|||
|
|
@ -221,8 +221,8 @@ func TestChatHistoryMouseDragSelectsAndCopiesText(t *testing.T) {
|
|||
if copied != "alpha" {
|
||||
t.Fatalf("copied = %q, want alpha", copied)
|
||||
}
|
||||
if m.status != "selection copied" {
|
||||
t.Fatalf("status = %q, want selection copied", m.status)
|
||||
if m.status == "selection copied" {
|
||||
t.Fatalf("selection should not surface a copied status")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
|
|
@ -1209,55 +1211,11 @@ func toolStatusStyle(status string) lipgloss.Style {
|
|||
}
|
||||
|
||||
func toolInvocationLabel(name string, args map[string]any) string {
|
||||
displayName := toolDisplayName(name)
|
||||
switch name {
|
||||
case "web_search":
|
||||
if query, ok := stringArg(args, "query"); ok {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(query))
|
||||
}
|
||||
case "web_fetch":
|
||||
if targetURL, ok := stringArg(args, "url"); ok {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(targetURL))
|
||||
}
|
||||
case "bash":
|
||||
if command, ok := stringArg(args, "command"); ok {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(command))
|
||||
}
|
||||
case "read", "list":
|
||||
if path, ok := stringArg(args, "path"); ok {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(path))
|
||||
}
|
||||
case "edit":
|
||||
if path, ok := stringArg(args, "path"); ok {
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(path))
|
||||
}
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return displayName
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", displayName, formatToolArgs(args))
|
||||
return coreagent.ToolInvocationLabel(name, args)
|
||||
}
|
||||
|
||||
func toolDisplayName(name string) string {
|
||||
switch name {
|
||||
case "web_search":
|
||||
return "Web Search"
|
||||
case "web_fetch":
|
||||
return "Web Fetch"
|
||||
case "bash":
|
||||
return "Bash"
|
||||
case "read":
|
||||
return "Read"
|
||||
case "list":
|
||||
return "List"
|
||||
case "edit":
|
||||
return "Edit"
|
||||
default:
|
||||
if name == "" {
|
||||
return "Tool"
|
||||
}
|
||||
return name
|
||||
}
|
||||
return coreagent.ToolDisplayName(name)
|
||||
}
|
||||
|
||||
func toolElapsedSuffix(startedAt, finishedAt time.Time) string {
|
||||
|
|
@ -1280,20 +1238,6 @@ func toolOutputUsesMarkdown(name string) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func formatToolArgs(args map[string]any) string {
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, quoteToolArg(args[key])))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func renderToolCallDetailLines(entry chatEntry, width int) []string {
|
||||
if len(entry.args) == 0 {
|
||||
return nil
|
||||
|
|
@ -1357,23 +1301,6 @@ func toolArgDisplayValue(value any) string {
|
|||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func quoteToolArg(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strconv.Quote(truncateRunes(v, 100))
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func stringArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", false
|
||||
}
|
||||
return truncateRunes(value, 120), true
|
||||
}
|
||||
|
||||
func rawStringArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
|
|
@ -1709,82 +1636,7 @@ func (m chatModel) estimatePromptTokens(messages []api.Message, systemPrompt str
|
|||
}
|
||||
|
||||
func estimatePromptTokenCount(systemPrompt string, messages []api.Message, tools api.Tools, format string) int {
|
||||
requestMessages := slices.Clone(messages)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
requestMessages = make([]api.Message, 0, len(messages)+1)
|
||||
requestMessages = append(requestMessages, api.Message{Role: "system", Content: strings.TrimSpace(systemPrompt)})
|
||||
requestMessages = append(requestMessages, messages...)
|
||||
}
|
||||
if len(requestMessages) == 0 && len(tools) == 0 && strings.TrimSpace(format) == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
payload := struct {
|
||||
Messages []api.Message `json:"messages,omitempty"`
|
||||
Tools api.Tools `json:"tools,omitempty"`
|
||||
Format json.RawMessage `json:"format,omitempty"`
|
||||
}{
|
||||
Messages: requestMessages,
|
||||
Tools: tools,
|
||||
}
|
||||
if rawFormat, ok := promptFormatForEstimate(format); ok {
|
||||
payload.Format = rawFormat
|
||||
}
|
||||
|
||||
if b, err := json.Marshal(payload); err == nil {
|
||||
return estimateTokenCount(string(b))
|
||||
}
|
||||
|
||||
var runes int
|
||||
for _, msg := range requestMessages {
|
||||
runes += estimateMessageRunes(msg)
|
||||
}
|
||||
runes += len([]rune(tools.String()))
|
||||
runes += len([]rune(strings.TrimSpace(format)))
|
||||
if runes == 0 {
|
||||
return 0
|
||||
}
|
||||
return max(1, (runes+3)/4)
|
||||
}
|
||||
|
||||
func promptFormatForEstimate(format string) (json.RawMessage, bool) {
|
||||
format = strings.TrimSpace(format)
|
||||
if format == "" {
|
||||
return nil, false
|
||||
}
|
||||
if format == "json" {
|
||||
format = `"` + format + `"`
|
||||
}
|
||||
if !json.Valid([]byte(format)) {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(format), true
|
||||
}
|
||||
|
||||
func estimateMessageRunes(msg api.Message) int {
|
||||
var runes int
|
||||
runes += len([]rune(msg.Role))
|
||||
runes += len([]rune(msg.Content))
|
||||
runes += len([]rune(msg.Thinking))
|
||||
runes += len([]rune(msg.ToolName))
|
||||
runes += len([]rune(msg.ToolCallID))
|
||||
for _, image := range msg.Images {
|
||||
runes += len(image)
|
||||
}
|
||||
for _, call := range msg.ToolCalls {
|
||||
runes += len([]rune(call.ID))
|
||||
runes += len([]rune(call.Function.Name))
|
||||
runes += len([]rune(fmt.Sprint(call.Function.Arguments.ToMap())))
|
||||
}
|
||||
return runes
|
||||
}
|
||||
|
||||
func estimateTokenCount(text string) int {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
return max(1, (len([]rune(text))+3)/4)
|
||||
return coreagent.EstimatePromptTokens(systemPrompt, messages, tools, format)
|
||||
}
|
||||
|
||||
func formatTokenCount(count int) string {
|
||||
|
|
@ -1922,6 +1774,26 @@ func renderFullFrame(content string, width, height int) string {
|
|||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderFrameLines(lines []string, width, height int) string {
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 24
|
||||
}
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
out := make([]string, 0, height)
|
||||
for _, line := range lines {
|
||||
out = append(out, padRenderedLine(clipRenderedLine(line, width), width))
|
||||
}
|
||||
for len(out) < height {
|
||||
out = append(out, strings.Repeat(" ", width))
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func truncateRenderedLine(line string, width int) string {
|
||||
if width <= 0 || lipgloss.Width(line) <= width {
|
||||
return line
|
||||
|
|
@ -2187,17 +2059,10 @@ func wrapChatText(text string, width int) []string {
|
|||
var out []string
|
||||
for _, rawLine := range strings.Split(text, "\n") {
|
||||
line := strings.TrimRight(rawLine, "\r")
|
||||
for len([]rune(line)) > width {
|
||||
runes := []rune(line)
|
||||
cut := width
|
||||
for i := width; i > width/2; i-- {
|
||||
if runes[i-1] == ' ' || runes[i-1] == '\t' {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, strings.TrimSpace(string(runes[:cut])))
|
||||
line = strings.TrimSpace(string(runes[cut:]))
|
||||
for runewidth.StringWidth(line) > width {
|
||||
cut := chatDisplayWidthCut(line, width)
|
||||
out = append(out, strings.TrimSpace(line[:cut]))
|
||||
line = strings.TrimSpace(line[cut:])
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
|
|
@ -2206,3 +2071,32 @@ func wrapChatText(text string, width int) []string {
|
|||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func chatDisplayWidthCut(line string, width int) int {
|
||||
hardCut := 0
|
||||
currentWidth := 0
|
||||
spaceCut := 0
|
||||
spaceWidth := 0
|
||||
for i := 0; i < len(line); {
|
||||
r, size := utf8.DecodeRuneInString(line[i:])
|
||||
nextWidth := currentWidth + runewidth.RuneWidth(r)
|
||||
if nextWidth > width {
|
||||
break
|
||||
}
|
||||
currentWidth = nextWidth
|
||||
hardCut = i + size
|
||||
if (r == ' ' || r == '\t') && currentWidth > width/2 {
|
||||
spaceCut = i
|
||||
spaceWidth = currentWidth
|
||||
}
|
||||
i += size
|
||||
}
|
||||
if spaceCut > 0 && spaceWidth > 0 {
|
||||
return spaceCut
|
||||
}
|
||||
if hardCut > 0 {
|
||||
return hardCut
|
||||
}
|
||||
_, size := utf8.DecodeRuneInString(line)
|
||||
return size
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package tui
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
|
|
@ -207,7 +209,7 @@ func TestChatViewCapsTallInputBox(t *testing.T) {
|
|||
if got := inputPromptLineCount(t, view); got > maxInputBoxBodyLines {
|
||||
t.Fatalf("input body lines = %d, want <= %d:\n%s", got, maxInputBoxBodyLines, view)
|
||||
}
|
||||
if !strings.Contains(view, "... ... ") {
|
||||
if !strings.Contains(view, "... ") || strings.Contains(view, "... ... ") {
|
||||
t.Fatalf("truncated pasted prompt should show an omission marker:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
|
@ -671,12 +673,19 @@ func TestChatResizeAndScrollsLongAssistantOutput(t *testing.T) {
|
|||
if cmd == nil || !m.boundedFrame {
|
||||
t.Fatal("terminal resize should switch to bounded rendering and clear the stale flow view")
|
||||
}
|
||||
if !m.fullScreen {
|
||||
t.Fatal("terminal resize should enter fullscreen managed rendering")
|
||||
}
|
||||
if m.flowPrintedLines != 0 {
|
||||
t.Fatalf("resize should clear flow state, printed=%d", m.flowPrintedLines)
|
||||
}
|
||||
if m.maxScroll() == 0 {
|
||||
t.Fatal("long assistant output should be scrollable after resize")
|
||||
}
|
||||
if !strings.Contains(stripANSI(m.View()), "generated line 00") {
|
||||
t.Fatalf("bounded view should reset to earliest generated content after resize:\n%s", stripANSI(m.View()))
|
||||
}
|
||||
assertChatFrameSize(t, m.View(), 72, 10)
|
||||
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlHome})
|
||||
m = updated.(chatModel)
|
||||
|
|
@ -689,6 +698,20 @@ func TestChatResizeAndScrollsLongAssistantOutput(t *testing.T) {
|
|||
if !strings.Contains(stripANSI(m.View()), "generated line 79") {
|
||||
t.Fatalf("scrolling back to bottom should restore latest generated content:\n%s", stripANSI(m.View()))
|
||||
}
|
||||
assertChatFrameSize(t, m.View(), 72, 10)
|
||||
}
|
||||
|
||||
func assertChatFrameSize(t *testing.T, view string, width, height int) {
|
||||
t.Helper()
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) != height {
|
||||
t.Fatalf("frame rendered %d lines, want %d:\n%s", len(lines), height, stripANSI(view))
|
||||
}
|
||||
for i, line := range lines {
|
||||
if got := lipgloss.Width(line); got > width {
|
||||
t.Fatalf("frame line %d width = %d, want <= %d: %q", i, got, width, stripANSI(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
|
||||
|
|
@ -734,10 +757,12 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
|
|||
|
||||
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
|
||||
m := chatModel{
|
||||
width: 80,
|
||||
height: 10,
|
||||
boundedFrame: true,
|
||||
running: true,
|
||||
width: 80,
|
||||
height: 10,
|
||||
boundedFrame: true,
|
||||
running: true,
|
||||
input: []rune("current draft"),
|
||||
promptHistory: []string{"previous one", "previous two"},
|
||||
}
|
||||
for range 12 {
|
||||
m.entries = append(m.entries, chatEntry{role: "user", content: "line"})
|
||||
|
|
@ -751,12 +776,18 @@ func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
|
|||
if m.scroll == 0 {
|
||||
t.Fatal("mouse wheel up should scroll transcript while running")
|
||||
}
|
||||
if got := string(m.input); got != "current draft" {
|
||||
t.Fatalf("mouse wheel should not navigate prompt history, input = %q", got)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseWheelDown})
|
||||
m = updated.(chatModel)
|
||||
if m.scroll != 0 {
|
||||
t.Fatalf("mouse wheel down should return to bottom, got scroll %d", m.scroll)
|
||||
}
|
||||
if got := string(m.input); got != "current draft" {
|
||||
t.Fatalf("mouse wheel should leave draft alone, input = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMouseDragSelectsAndCopiesTranscriptText(t *testing.T) {
|
||||
|
|
@ -799,8 +830,68 @@ func TestChatMouseDragSelectsAndCopiesTranscriptText(t *testing.T) {
|
|||
if copied != "alpha" {
|
||||
t.Fatalf("copied = %q, want alpha", copied)
|
||||
}
|
||||
if m.status != "selection copied" {
|
||||
t.Fatalf("status = %q, want selection copied", m.status)
|
||||
if m.status == "selection copied" {
|
||||
t.Fatalf("selection should not surface a copied status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBoundedViewHeaderMatchesTranscriptLayout(t *testing.T) {
|
||||
m := chatModel{
|
||||
width: 80,
|
||||
height: 6,
|
||||
boundedFrame: true,
|
||||
}
|
||||
for i := range 12 {
|
||||
m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)})
|
||||
}
|
||||
m.scroll = m.maxScroll()
|
||||
top, _ := m.transcriptLayout()
|
||||
lines := strings.Split(stripANSI(m.View()), "\n")
|
||||
if top <= 0 {
|
||||
t.Fatalf("transcript top = %d, want header offset", top)
|
||||
}
|
||||
if !strings.Contains(lines[0], "↓ more") {
|
||||
t.Fatalf("view should render status header at top: %q", lines[0])
|
||||
}
|
||||
if strings.TrimSpace(lines[top]) == "" {
|
||||
t.Fatalf("transcript should start at layout top %d, line=%q view=%q", top, lines[top], strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMouseCopyFailureDoesNotClearRunningState(t *testing.T) {
|
||||
m := chatModel{
|
||||
ctx: context.Background(),
|
||||
opts: ChatOptions{
|
||||
Clipboard: func(context.Context, string) error {
|
||||
return errors.New("copy failed")
|
||||
},
|
||||
},
|
||||
width: 80,
|
||||
height: 10,
|
||||
running: true,
|
||||
entries: []chatEntry{
|
||||
{role: "user", content: "alpha beta"},
|
||||
},
|
||||
}
|
||||
top, _ := m.transcriptLayout()
|
||||
|
||||
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top})
|
||||
m = updated.(chatModel)
|
||||
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top})
|
||||
m = updated.(chatModel)
|
||||
updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top})
|
||||
m = updated.(chatModel)
|
||||
if cmd == nil {
|
||||
t.Fatal("mouse release should return clipboard command")
|
||||
}
|
||||
msg := cmd()
|
||||
updated, _ = m.Update(msg)
|
||||
m = updated.(chatModel)
|
||||
if !m.running {
|
||||
t.Fatal("clipboard failure should not clear running state")
|
||||
}
|
||||
if m.status != "clipboard error: copy failed" {
|
||||
t.Fatalf("status = %q, want clipboard error", m.status)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1079,15 +1170,19 @@ func TestChatToolOutputIsHiddenUntilExpanded(t *testing.T) {
|
|||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
|
||||
body = stripANSI(m.renderToolDetailsWindow(100, 40))
|
||||
if !strings.Contains(body, "line 24") || !strings.Contains(body, "↑ more") {
|
||||
t.Fatalf("details window should show latest output with scroll affordance: %q", body)
|
||||
if !m.toolOutputMode || !m.toolOutputOpen || !m.entries[0].expanded {
|
||||
t.Fatalf("ctrl+o should expand tool output inline: %#v", m.entries[0])
|
||||
}
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlHome})
|
||||
body = stripANSI(m.renderTranscript(100))
|
||||
if !strings.Contains(body, "line 00") || !strings.Contains(body, "line 24") {
|
||||
t.Fatalf("expanded transcript should show full tool output: %q", body)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
body = stripANSI(m.renderToolDetailsWindow(100, 40))
|
||||
if !strings.Contains(body, "line 00") {
|
||||
t.Fatalf("details window should scroll to earlier output: %q", body)
|
||||
body = stripANSI(m.renderTranscript(100))
|
||||
if m.toolOutputOpen || m.entries[0].expanded || strings.Contains(body, "line 00") {
|
||||
t.Fatalf("second ctrl+o should collapse tool output: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1114,7 +1209,7 @@ func TestChatCompletedToolsGroupWhenNextStepStarts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCtrlOOpensToolDetailsWindow(t *testing.T) {
|
||||
func TestChatCtrlOTogglesInlineToolOutput(t *testing.T) {
|
||||
m := chatModel{
|
||||
width: 100,
|
||||
height: 20,
|
||||
|
|
@ -1127,26 +1222,29 @@ func TestChatCtrlOOpensToolDetailsWindow(t *testing.T) {
|
|||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if !m.toolDetailsOpen {
|
||||
t.Fatal("ctrl+o should open tool details")
|
||||
if m.toolDetailsOpen {
|
||||
t.Fatal("ctrl+o should not open a separate tool details view")
|
||||
}
|
||||
for _, index := range []int{0, 2} {
|
||||
if m.entries[index].expanded {
|
||||
t.Fatalf("tool entry %d should not be mutated by ctrl+o", index)
|
||||
if !m.entries[index].expanded {
|
||||
t.Fatalf("tool entry %d should be expanded inline", index)
|
||||
}
|
||||
}
|
||||
view := stripANSI(m.View())
|
||||
if !strings.Contains(view, "Tool details") || !strings.Contains(view, "one") || !strings.Contains(view, "two") {
|
||||
t.Fatalf("details view missing expanded tool output: %q", view)
|
||||
if strings.Contains(view, "Tool details") {
|
||||
t.Fatalf("ctrl+o should keep tool output inline: %q", view)
|
||||
}
|
||||
if !strings.Contains(view, "one") || !strings.Contains(view, "two") {
|
||||
t.Fatalf("view missing inline expanded tool output: %q", view)
|
||||
}
|
||||
if !strings.Contains(view, "between") {
|
||||
t.Fatalf("details window should keep surrounding chat visible: %q", view)
|
||||
t.Fatalf("inline tool output should keep surrounding chat visible: %q", view)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if m.toolDetailsOpen {
|
||||
t.Fatal("second ctrl+o should close tool details")
|
||||
t.Fatal("second ctrl+o should not open tool details")
|
||||
}
|
||||
for _, index := range []int{0, 2} {
|
||||
if m.entries[index].expanded {
|
||||
|
|
@ -1155,10 +1253,12 @@ func TestChatCtrlOOpensToolDetailsWindow(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCtrlOReturnsToManagedRedrawWithoutReprinting(t *testing.T) {
|
||||
func TestChatCtrlOTogglesInlineOutputWithoutLeavingFullscreen(t *testing.T) {
|
||||
m := chatModel{
|
||||
width: 100,
|
||||
height: 24,
|
||||
boundedFrame: true,
|
||||
fullScreen: true,
|
||||
flowPrintedLines: 4,
|
||||
entries: []chatEntry{
|
||||
{role: "user", content: "who is parth sareen"},
|
||||
|
|
@ -1170,32 +1270,38 @@ func TestChatCtrlOReturnsToManagedRedrawWithoutReprinting(t *testing.T) {
|
|||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if cmd != nil {
|
||||
t.Fatal("opening tool details should not force a redraw command")
|
||||
t.Fatal("inline tool toggle should not switch screens")
|
||||
}
|
||||
if !m.toolDetailsOpen {
|
||||
t.Fatal("ctrl+o should open tool details")
|
||||
if m.toolDetailsOpen {
|
||||
t.Fatal("ctrl+o should not open tool details")
|
||||
}
|
||||
if m.boundedFrame {
|
||||
t.Fatal("opening tool details from terminal-flow mode should not permanently enter bounded mode")
|
||||
if !m.fullScreen || !m.boundedFrame {
|
||||
t.Fatal("inline tool toggle should keep managed fullscreen mode")
|
||||
}
|
||||
if !m.toolOutputOpen || !m.entries[1].expanded {
|
||||
t.Fatalf("tool output should be expanded inline: %#v", m.entries[1])
|
||||
}
|
||||
|
||||
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if m.toolDetailsOpen {
|
||||
t.Fatal("second ctrl+o should close tool details")
|
||||
t.Fatal("second ctrl+o should not open tool details")
|
||||
}
|
||||
if !m.boundedFrame {
|
||||
t.Fatal("closing tool details should enter managed redraw mode")
|
||||
t.Fatal("closing tool details should keep managed redraw mode")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("closing tool details from terminal-flow mode should clear stale output")
|
||||
if !m.fullScreen {
|
||||
t.Fatal("closing tool details should stay fullscreen")
|
||||
}
|
||||
if m.flowPrintedLines != 0 {
|
||||
t.Fatalf("closing tool details should not reprint transcript into scrollback, printed=%d", m.flowPrintedLines)
|
||||
if cmd != nil {
|
||||
t.Fatal("inline tool toggle should not switch screens")
|
||||
}
|
||||
if m.toolOutputOpen || m.entries[1].expanded {
|
||||
t.Fatalf("tool output should be collapsed inline: %#v", m.entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCtrlOShowsRunningToolOutputInWindow(t *testing.T) {
|
||||
func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) {
|
||||
args := map[string]any{"command": "pwd"}
|
||||
m := chatModel{width: 100, height: 20, running: true}
|
||||
m.applyAgentEvent(coreagent.Event{
|
||||
|
|
@ -1207,11 +1313,11 @@ func TestChatCtrlOShowsRunningToolOutputInWindow(t *testing.T) {
|
|||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if !m.toolDetailsOpen {
|
||||
t.Fatalf("ctrl+o should open tool details while tool is running")
|
||||
if m.toolDetailsOpen {
|
||||
t.Fatalf("ctrl+o should not open a tool details screen")
|
||||
}
|
||||
if m.entries[0].expanded {
|
||||
t.Fatalf("ctrl+o should not record expanded state on the running tool")
|
||||
if !m.entries[0].expanded {
|
||||
t.Fatalf("ctrl+o should expand the running tool inline")
|
||||
}
|
||||
|
||||
m.applyAgentEvent(coreagent.Event{
|
||||
|
|
@ -1222,17 +1328,13 @@ func TestChatCtrlOShowsRunningToolOutputInWindow(t *testing.T) {
|
|||
Content: "/tmp/project\n",
|
||||
})
|
||||
|
||||
view := stripANSI(m.View())
|
||||
view := stripANSI(m.renderTranscript(100))
|
||||
if !strings.Contains(view, "/tmp/project") {
|
||||
t.Fatalf("finished tool output should be visible in details window: %q", view)
|
||||
}
|
||||
transcript := stripANSI(m.renderTranscript(100))
|
||||
if strings.Contains(transcript, "/tmp/project") {
|
||||
t.Fatalf("main transcript should stay collapsed after ctrl+o: %q", transcript)
|
||||
t.Fatalf("finished tool output should be visible inline: %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCtrlODetailsSurvivesToolGrouping(t *testing.T) {
|
||||
func TestChatCtrlOInlineOutputSurvivesToolGrouping(t *testing.T) {
|
||||
firstArgs := map[string]any{"command": "pwd"}
|
||||
secondArgs := map[string]any{"command": "ls"}
|
||||
m := chatModel{
|
||||
|
|
@ -1251,13 +1353,13 @@ func TestChatCtrlODetailsSurvivesToolGrouping(t *testing.T) {
|
|||
if len(m.entries) != 2 {
|
||||
t.Fatalf("entries = %d, want tool group plus assistant: %#v", len(m.entries), m.entries)
|
||||
}
|
||||
if m.entries[0].role != "tool_group" || m.entries[0].expanded {
|
||||
t.Fatalf("grouped tool history should stay collapsed in main transcript: %#v", m.entries[0])
|
||||
if m.entries[0].role != "tool_group" || !m.entries[0].expanded {
|
||||
t.Fatalf("grouped tool history should stay expanded inline: %#v", m.entries[0])
|
||||
}
|
||||
|
||||
view := stripANSI(m.View())
|
||||
if !strings.Contains(view, "one") || !strings.Contains(view, "two") {
|
||||
t.Fatalf("grouped tool output should be visible in details window: %q", view)
|
||||
t.Fatalf("grouped tool output should be visible inline: %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1531,3 +1633,15 @@ func TestWrapChatTextSplitsLongLines(t *testing.T) {
|
|||
t.Fatalf("first line was not wrapped: %#v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapChatTextUsesDisplayWidth(t *testing.T) {
|
||||
lines := wrapChatText(strings.Repeat("界", 20), 20)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("lines = %#v, want full-width text split", lines)
|
||||
}
|
||||
for _, line := range lines {
|
||||
if got := lipgloss.Width(line); got > 20 {
|
||||
t.Fatalf("line %q width = %d, want <= 20", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
|||
}
|
||||
|
||||
view := menu.View()
|
||||
for _, want := range []string{"Agent", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
|
||||
for _, want := range []string{"Chat and Code", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue