feat: crush-inspired TUI improvements, activity bar polish, startup fresh session
TUI improvements: - semantic icon constants (✓ ◆ ▣ ✕ ⌘ ◌ ◔ ◉) - pill-style metadata bar with model/provider/session/context - fuzzy command palette with deterministic subsequence scoring - input history navigation (ctrl+p / ctrl+n) - structured tool log cards (▣ tool_name + args preview) - no-background activity indicator and meta bar for cleaner rendering Startup behavior: - default startup creates a fresh session - explicit --resume <id> restores saved context - added main_test.go for initializeSession coverage
This commit is contained in:
+295
-55
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/pkg/llm"
|
||||
@@ -19,8 +21,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
minInputLines = 1
|
||||
maxInputLines = 6
|
||||
minInputLines = 1
|
||||
maxInputLines = 6
|
||||
maxCommandPaletteItems = 8
|
||||
maxInputHistory = 100
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -89,9 +93,13 @@ type model struct {
|
||||
messages []message
|
||||
status string
|
||||
contextUsage string
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
events <-chan tea.Msg
|
||||
inputHistory []string
|
||||
historyIndex int
|
||||
draftInput string
|
||||
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
events <-chan tea.Msg
|
||||
|
||||
// Session picker state
|
||||
picking bool
|
||||
@@ -120,6 +128,11 @@ type slashCommand struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
type commandMatch struct {
|
||||
command slashCommand
|
||||
score int
|
||||
}
|
||||
|
||||
var slashCommands = []slashCommand{
|
||||
{Name: "/clear", Description: "reset context"},
|
||||
{Name: "/compact", Description: "compress context"},
|
||||
@@ -162,18 +175,19 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
vp.SetContent("")
|
||||
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
agent: assistant,
|
||||
modelName: opts.ModelName,
|
||||
yolo: opts.Yolo,
|
||||
models: opts.ModelManager,
|
||||
workingDir: opts.WorkingDir,
|
||||
theme: th,
|
||||
styles: st,
|
||||
viewport: vp,
|
||||
input: input,
|
||||
spinner: spin,
|
||||
status: "ready",
|
||||
ctx: ctx,
|
||||
agent: assistant,
|
||||
modelName: opts.ModelName,
|
||||
yolo: opts.Yolo,
|
||||
models: opts.ModelManager,
|
||||
workingDir: opts.WorkingDir,
|
||||
theme: th,
|
||||
styles: st,
|
||||
viewport: vp,
|
||||
input: input,
|
||||
spinner: spin,
|
||||
status: "ready",
|
||||
historyIndex: -1,
|
||||
}
|
||||
m.refreshContextUsage()
|
||||
return m
|
||||
@@ -249,6 +263,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.submit()
|
||||
case "ctrl+p":
|
||||
if m.running {
|
||||
return m, nil
|
||||
}
|
||||
m.previousInputHistory()
|
||||
m.afterInputChanged()
|
||||
return m, nil
|
||||
case "ctrl+n":
|
||||
if m.running {
|
||||
return m, nil
|
||||
}
|
||||
m.nextInputHistory()
|
||||
m.afterInputChanged()
|
||||
return m, nil
|
||||
case "alt+enter", "ctrl+j":
|
||||
return m.insertInputNewline()
|
||||
}
|
||||
@@ -297,6 +325,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !m.running {
|
||||
nextInput, cmd := m.input.Update(msg)
|
||||
m.input = nextInput
|
||||
m.historyIndex = -1
|
||||
m.draftInput = ""
|
||||
m.afterInputChanged()
|
||||
return m, cmd
|
||||
}
|
||||
@@ -343,6 +373,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
||||
if input == "" {
|
||||
return *m, nil
|
||||
}
|
||||
m.rememberInput(input)
|
||||
|
||||
switch input {
|
||||
case "/exit", "/quit":
|
||||
@@ -402,6 +433,8 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
|
||||
enter := tea.KeyMsg{Type: tea.KeyEnter}
|
||||
nextInput, cmd := m.input.Update(enter)
|
||||
m.input = nextInput
|
||||
m.historyIndex = -1
|
||||
m.draftInput = ""
|
||||
m.afterInputChanged()
|
||||
return *m, cmd
|
||||
}
|
||||
@@ -472,6 +505,10 @@ func (m model) renderMessages() string {
|
||||
}
|
||||
|
||||
func (m model) messageView(r role, content string, width int) string {
|
||||
if r == roleTool {
|
||||
return m.toolMessageView(content, width)
|
||||
}
|
||||
|
||||
label := roleLabel(r)
|
||||
style := m.styles.AssistantMsg
|
||||
labelStyle := m.styles.SystemLabel
|
||||
@@ -534,39 +571,112 @@ func roleLabel(r role) string {
|
||||
}
|
||||
}
|
||||
|
||||
type toolLogParts struct {
|
||||
name string
|
||||
args string
|
||||
}
|
||||
|
||||
func parseToolLog(content string) toolLogParts {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return toolLogParts{}
|
||||
}
|
||||
idx := strings.IndexFunc(content, unicode.IsSpace)
|
||||
if idx < 0 {
|
||||
return toolLogParts{name: content}
|
||||
}
|
||||
return toolLogParts{
|
||||
name: strings.TrimSpace(content[:idx]),
|
||||
args: strings.TrimSpace(content[idx+1:]),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) toolMessageView(content string, width int) string {
|
||||
parts := parseToolLog(content)
|
||||
header := m.styles.ToolName.Render(strings.TrimSpace(iconTool + " " + parts.name))
|
||||
lines := []string{header}
|
||||
if parts.args != "" {
|
||||
args := fitLine(parts.args, max(1, width-4))
|
||||
lines = append(lines, m.styles.ToolValue.Render(args))
|
||||
}
|
||||
bodyContent := strings.Join(lines, "\n")
|
||||
bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage)
|
||||
return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent)
|
||||
}
|
||||
|
||||
type metaPill struct {
|
||||
text string
|
||||
accent bool
|
||||
}
|
||||
|
||||
func (m model) renderMetaPills(parts []metaPill) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
plain := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
plain[i] = part.text
|
||||
}
|
||||
|
||||
width := max(1, m.width-2)
|
||||
visible := len(plain)
|
||||
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||
visible--
|
||||
}
|
||||
if lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||
return m.styles.PillAccent.Render(fitLine(plain[0], width))
|
||||
}
|
||||
|
||||
rendered := make([]string, 0, visible)
|
||||
for i := 0; i < visible; i++ {
|
||||
style := m.styles.Pill
|
||||
if parts[i].accent {
|
||||
style = m.styles.PillAccent
|
||||
}
|
||||
rendered = append(rendered, style.Render(parts[i].text))
|
||||
}
|
||||
return strings.Join(rendered, " ")
|
||||
}
|
||||
|
||||
func (m model) modelMetaView() string {
|
||||
mode := "read-only tools"
|
||||
if m.yolo {
|
||||
mode = "yolo tools"
|
||||
}
|
||||
|
||||
modelName := m.modelName
|
||||
parts := []string{}
|
||||
if m.models != nil {
|
||||
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
||||
parts = append(parts, "session "+sessionName)
|
||||
if currentModel := m.models.CurrentModel(); currentModel != "" {
|
||||
modelName = currentModel
|
||||
}
|
||||
}
|
||||
|
||||
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
|
||||
if m.models != nil {
|
||||
if provider := m.models.CurrentProvider(); provider != "" {
|
||||
parts = append(parts, "provider "+provider)
|
||||
parts = append(parts, metaPill{text: "provider " + provider})
|
||||
}
|
||||
modelName = m.models.CurrentModel()
|
||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||
parts = append(parts, "thinking "+currentThinking)
|
||||
parts = append(parts, metaPill{text: "thinking " + currentThinking})
|
||||
}
|
||||
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
||||
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
|
||||
}
|
||||
}
|
||||
if m.contextUsage != "" {
|
||||
parts = append(parts, m.contextUsage)
|
||||
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
|
||||
}
|
||||
parts = append([]string{modelName, mode}, parts...)
|
||||
parts = append(parts, "theme "+string(m.theme.Mode))
|
||||
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
|
||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
|
||||
parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
|
||||
|
||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts))
|
||||
}
|
||||
|
||||
func (m model) inputView() string {
|
||||
suggestions := m.slashSuggestionsView()
|
||||
palette := m.commandPaletteView()
|
||||
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
||||
if suggestions != "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
|
||||
if palette != "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, palette, box)
|
||||
}
|
||||
return box
|
||||
}
|
||||
@@ -579,12 +689,12 @@ func (m model) activityView() string {
|
||||
if status == "" || status == "ready" {
|
||||
status = "working..."
|
||||
}
|
||||
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
|
||||
return m.styles.Activity.Width(max(1, m.width)).Render(line)
|
||||
line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
|
||||
return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
|
||||
hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands"
|
||||
if m.running {
|
||||
hint = "esc cancel · ctrl+c quit"
|
||||
}
|
||||
@@ -639,6 +749,51 @@ func (m *model) afterInputChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) rememberInput(input string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return
|
||||
}
|
||||
if len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input {
|
||||
m.historyIndex = -1
|
||||
m.draftInput = ""
|
||||
return
|
||||
}
|
||||
m.inputHistory = append(m.inputHistory, input)
|
||||
if len(m.inputHistory) > maxInputHistory {
|
||||
m.inputHistory = m.inputHistory[len(m.inputHistory)-maxInputHistory:]
|
||||
}
|
||||
m.historyIndex = -1
|
||||
m.draftInput = ""
|
||||
}
|
||||
|
||||
func (m *model) previousInputHistory() {
|
||||
if len(m.inputHistory) == 0 {
|
||||
return
|
||||
}
|
||||
if m.historyIndex == -1 {
|
||||
m.draftInput = m.input.Value()
|
||||
m.historyIndex = len(m.inputHistory) - 1
|
||||
} else if m.historyIndex > 0 {
|
||||
m.historyIndex--
|
||||
}
|
||||
m.input.SetValue(m.inputHistory[m.historyIndex])
|
||||
}
|
||||
|
||||
func (m *model) nextInputHistory() {
|
||||
if len(m.inputHistory) == 0 || m.historyIndex == -1 {
|
||||
return
|
||||
}
|
||||
if m.historyIndex < len(m.inputHistory)-1 {
|
||||
m.historyIndex++
|
||||
m.input.SetValue(m.inputHistory[m.historyIndex])
|
||||
return
|
||||
}
|
||||
m.input.SetValue(m.draftInput)
|
||||
m.historyIndex = -1
|
||||
m.draftInput = ""
|
||||
}
|
||||
|
||||
func (m *model) syncInputHeight() {
|
||||
m.input.SetHeight(m.desiredInputHeight())
|
||||
}
|
||||
@@ -661,47 +816,132 @@ func (m model) desiredInputHeight() int {
|
||||
|
||||
func (m model) inputBlockHeight() int {
|
||||
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||
if m.showSlashSuggestions() {
|
||||
height += m.slashSuggestionCount()
|
||||
if m.showCommandPalette() {
|
||||
height += m.commandPaletteHeight()
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
func (m model) slashSuggestionCount() int {
|
||||
prefix := strings.TrimSpace(m.input.Value())
|
||||
count := 0
|
||||
for _, command := range slashCommands {
|
||||
if strings.HasPrefix(command.Name, prefix) {
|
||||
count++
|
||||
}
|
||||
func (m model) commandPaletteHeight() int {
|
||||
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||
count := len(matches)
|
||||
if count > maxCommandPaletteItems {
|
||||
count = maxCommandPaletteItems
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1 // "No matching commands"
|
||||
count = 1
|
||||
}
|
||||
return count
|
||||
return 1 + count + m.styles.Palette.GetVerticalFrameSize()
|
||||
}
|
||||
|
||||
func (m model) showSlashSuggestions() bool {
|
||||
func (m model) showCommandPalette() bool {
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
||||
}
|
||||
|
||||
func (m model) slashSuggestionsView() string {
|
||||
if !m.showSlashSuggestions() {
|
||||
func (m model) commandPaletteView() string {
|
||||
if !m.showCommandPalette() {
|
||||
return ""
|
||||
}
|
||||
prefix := strings.TrimSpace(m.input.Value())
|
||||
var parts []string
|
||||
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||
lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
|
||||
if len(matches) == 0 {
|
||||
lines = append(lines, m.styles.Muted.Render("No matching commands"))
|
||||
} else {
|
||||
for i, match := range matches {
|
||||
if i >= maxCommandPaletteItems {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description)))
|
||||
}
|
||||
}
|
||||
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func commandPaletteQuery(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(value, "/")
|
||||
}
|
||||
|
||||
func commandMatches(query string) []commandMatch {
|
||||
matches := make([]commandMatch, 0, len(slashCommands))
|
||||
for _, command := range slashCommands {
|
||||
if !strings.HasPrefix(command.Name, prefix) {
|
||||
score, ok := scoreCommand(command, query)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description))
|
||||
matches = append(matches, commandMatch{command: command, score: score})
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "No matching commands")
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].score != matches[j].score {
|
||||
return matches[i].score < matches[j].score
|
||||
}
|
||||
return matches[i].command.Name < matches[j].command.Name
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func scoreCommand(command slashCommand, query string) (int, bool) {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
name := strings.TrimPrefix(strings.ToLower(command.Name), "/")
|
||||
if query == "" || query == name {
|
||||
return 0, true
|
||||
}
|
||||
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n"))
|
||||
if strings.HasPrefix(name, query) {
|
||||
return 100 + len(name) - len(query), true
|
||||
}
|
||||
if score, ok := subsequenceScore(name, query, 200); ok {
|
||||
return score, true
|
||||
}
|
||||
candidate := name + " " + strings.ToLower(command.Description)
|
||||
if score, ok := subsequenceScore(candidate, query, 300); ok {
|
||||
return score, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func subsequenceScore(candidate string, query string, base int) (int, bool) {
|
||||
candidate = strings.ToLower(candidate)
|
||||
query = strings.ToLower(query)
|
||||
if query == "" {
|
||||
return base, true
|
||||
}
|
||||
start := 0
|
||||
last := -1
|
||||
gaps := 0
|
||||
for _, r := range query {
|
||||
found := -1
|
||||
for i, c := range candidate[start:] {
|
||||
if c == r {
|
||||
found = start + i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
return 0, false
|
||||
}
|
||||
if last >= 0 {
|
||||
gaps += found - last - 1
|
||||
}
|
||||
last = found
|
||||
start = found + len(string(r))
|
||||
}
|
||||
return base + gaps, true
|
||||
}
|
||||
|
||||
func (m model) slashSuggestionCount() int {
|
||||
return m.commandPaletteHeight()
|
||||
}
|
||||
|
||||
func (m model) showSlashSuggestions() bool {
|
||||
return m.showCommandPalette()
|
||||
}
|
||||
|
||||
func (m model) slashSuggestionsView() string {
|
||||
return m.commandPaletteView()
|
||||
}
|
||||
|
||||
func (m model) modelInfo() string {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
|
||||
m = next.(model)
|
||||
rendered := m.View()
|
||||
|
||||
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
|
||||
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
|
||||
}
|
||||
@@ -123,11 +123,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
|
||||
m.resize()
|
||||
|
||||
rendered := m.View()
|
||||
for _, want := range []string{"Working", "thinking...", "esc cancels"} {
|
||||
for _, want := range []string{iconWorking, "Working", "thinking...", "esc cancels"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
if backgroundPattern.MatchString(m.activityView()) {
|
||||
t.Fatalf("activity view should not paint a background:\n%q", m.activityView())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
|
||||
@@ -276,17 +279,71 @@ func TestInputStartsAtOneLineAndGrows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandSuggestions(t *testing.T) {
|
||||
func TestCommandPaletteFuzzyMatches(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
m.input.SetValue("/")
|
||||
m.input.SetValue("/mdl")
|
||||
m.afterInputChanged()
|
||||
|
||||
rendered := m.View()
|
||||
for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered)
|
||||
plain := stripANSI(m.View())
|
||||
for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
|
||||
matches := commandMatches("m")
|
||||
modelIndex := -1
|
||||
renameIndex := -1
|
||||
for i, match := range matches {
|
||||
switch match.command.Name {
|
||||
case "/model":
|
||||
modelIndex = i
|
||||
case "/rename":
|
||||
renameIndex = i
|
||||
}
|
||||
}
|
||||
if modelIndex < 0 || renameIndex < 0 || modelIndex > renameIndex {
|
||||
t.Fatalf("/model should sort before /rename for /m query: %#v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputHistoryNavigation(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.rememberInput("first")
|
||||
m.rememberInput("second")
|
||||
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
||||
m = next.(model)
|
||||
if got := m.input.Value(); got != "second" {
|
||||
t.Fatalf("after ctrl+p input = %q", got)
|
||||
}
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
||||
m = next.(model)
|
||||
if got := m.input.Value(); got != "first" {
|
||||
t.Fatalf("after second ctrl+p input = %q", got)
|
||||
}
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||
m = next.(model)
|
||||
if got := m.input.Value(); got != "second" {
|
||||
t.Fatalf("after ctrl+n input = %q", got)
|
||||
}
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||
m = next.(model)
|
||||
if got := m.input.Value(); got != "" {
|
||||
t.Fatalf("after second ctrl+n input = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolMessageRendersStructuredCard(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`})
|
||||
|
||||
plain := stripANSI(m.renderMessages())
|
||||
for _, want := range []string{iconTool + " file_read", "README.md"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("tool card missing %q:\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+71
-5
@@ -15,6 +15,17 @@ const (
|
||||
ThemeDark ThemeMode = "dark"
|
||||
)
|
||||
|
||||
const (
|
||||
iconReady = "✓"
|
||||
iconWorking = "◆"
|
||||
iconTool = "▣"
|
||||
iconError = "✕"
|
||||
iconCommand = "⌘"
|
||||
iconSession = "◌"
|
||||
iconContext = "◔"
|
||||
iconModel = "◉"
|
||||
)
|
||||
|
||||
func ParseThemeMode(value string) (ThemeMode, error) {
|
||||
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
|
||||
case "", ThemeLight:
|
||||
@@ -48,11 +59,23 @@ type styles struct {
|
||||
App lipgloss.Style
|
||||
Muted lipgloss.Style
|
||||
Activity lipgloss.Style
|
||||
Header lipgloss.Style
|
||||
ModelMeta lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
Viewport lipgloss.Style
|
||||
InputBox lipgloss.Style
|
||||
CommandHint lipgloss.Style
|
||||
StatusReady lipgloss.Style
|
||||
StatusWarn lipgloss.Style
|
||||
StatusError lipgloss.Style
|
||||
StatusInfo lipgloss.Style
|
||||
Pill lipgloss.Style
|
||||
PillAccent lipgloss.Style
|
||||
Palette lipgloss.Style
|
||||
PaletteMatch lipgloss.Style
|
||||
ToolName lipgloss.Style
|
||||
ToolKey lipgloss.Style
|
||||
ToolValue lipgloss.Style
|
||||
UserLabel lipgloss.Style
|
||||
ToolLabel lipgloss.Style
|
||||
ErrorLabel lipgloss.Style
|
||||
@@ -116,14 +139,15 @@ func (t theme) styles() styles {
|
||||
Foreground(lipgloss.Color(t.Muted)),
|
||||
|
||||
Activity: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.Background)).
|
||||
Background(lipgloss.Color(t.Title)).
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Padding(0, 1),
|
||||
|
||||
Header: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.Title)),
|
||||
|
||||
ModelMeta: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Padding(0, 1),
|
||||
|
||||
Footer: lipgloss.NewStyle().
|
||||
@@ -141,9 +165,51 @@ func (t theme) styles() styles {
|
||||
|
||||
CommandHint: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Padding(0, 1),
|
||||
|
||||
StatusReady: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Assistant)),
|
||||
|
||||
StatusWarn: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Tool)),
|
||||
|
||||
StatusError: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Error)),
|
||||
|
||||
StatusInfo: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.User)),
|
||||
|
||||
Pill: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||
Padding(0, 1),
|
||||
|
||||
PillAccent: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.Title)).
|
||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||
Padding(0, 1),
|
||||
|
||||
Palette: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(lipgloss.Color(t.Border)).
|
||||
Padding(0, 2),
|
||||
|
||||
PaletteMatch: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.User)),
|
||||
|
||||
ToolName: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.Tool)),
|
||||
|
||||
ToolKey: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)),
|
||||
|
||||
ToolValue: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)),
|
||||
|
||||
UserLabel: labelStyle(t.User),
|
||||
ToolLabel: labelStyle(t.Tool),
|
||||
ErrorLabel: labelStyle(t.Error),
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/pkg/config"
|
||||
"agentu/pkg/llm"
|
||||
"agentu/internal/session"
|
||||
"agentu/internal/tools"
|
||||
"agentu/internal/tui"
|
||||
"agentu/pkg/config"
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
const defaultConfigContent = `# agentu configuration
|
||||
@@ -112,15 +112,8 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resume session
|
||||
if *resumeID != "" {
|
||||
if err := modelManager.Resume(*resumeID); err != nil {
|
||||
return fmt.Errorf("resume session: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := modelManager.ResumeLatest(); err != nil {
|
||||
return fmt.Errorf("resume latest session: %w", err)
|
||||
}
|
||||
if err := initializeSession(modelManager, *resumeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
@@ -141,6 +134,19 @@ func run() error {
|
||||
return repl(ctx, assistant, modelManager)
|
||||
}
|
||||
|
||||
func initializeSession(modelManager *session.Manager, resumeID string) error {
|
||||
if resumeID != "" {
|
||||
if err := modelManager.Resume(resumeID); err != nil {
|
||||
return fmt.Errorf("resume session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := modelManager.NewSession(); err != nil {
|
||||
return fmt.Errorf("start new session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bootstrapConfig(configPath string) error {
|
||||
resolved, err := config.ResolvePath(configPath)
|
||||
if err != nil {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/internal/session"
|
||||
"agentu/pkg/config"
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
type startupProvider struct{}
|
||||
|
||||
func (p *startupProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
return emit(llm.StreamEvent{Content: "ok"})
|
||||
}
|
||||
|
||||
func startupTestManager(t *testing.T) (*session.Manager, *agent.Agent) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
Providers: map[string]config.ProviderConfig{
|
||||
"one": {
|
||||
Name: "one",
|
||||
BaseURL: "https://one.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "model-a",
|
||||
Models: []string{"model-a"},
|
||||
Thinking: "none",
|
||||
ThinkingParam: "thinking",
|
||||
},
|
||||
},
|
||||
}
|
||||
assistant := agent.New(agent.Options{
|
||||
Provider: &startupProvider{},
|
||||
ProviderName: "one",
|
||||
Model: "model-a",
|
||||
})
|
||||
store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := session.NewManager(cfg, assistant, nil, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manager, assistant
|
||||
}
|
||||
|
||||
func TestInitializeSessionStartsNewByDefault(t *testing.T) {
|
||||
manager, assistant := startupTestManager(t)
|
||||
if _, err := manager.NewSession(); err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
savedID := manager.CurrentSessionID()
|
||||
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||
if err := manager.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
if err := initializeSession(manager, ""); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if manager.CurrentSessionID() == savedID {
|
||||
t.Fatalf("default startup resumed latest session %s", savedID)
|
||||
}
|
||||
for _, msg := range assistant.Messages() {
|
||||
if msg.Content == "old context" {
|
||||
t.Fatal("default startup carried old context into new session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeSessionResumesExplicitID(t *testing.T) {
|
||||
manager, assistant := startupTestManager(t)
|
||||
if _, err := manager.NewSession(); err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
savedID := manager.CurrentSessionID()
|
||||
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||
if err := manager.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
if err := initializeSession(manager, savedID); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if manager.CurrentSessionID() != savedID {
|
||||
t.Fatalf("expected resumed session %s, got %s", savedID, manager.CurrentSessionID())
|
||||
}
|
||||
found := false
|
||||
for _, msg := range assistant.Messages() {
|
||||
if msg.Content == "old context" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("explicit resume did not restore saved context")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user