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:
loveuer
2026-06-24 18:16:53 -07:00
parent f235b8ce90
commit 7b8bad5e62
5 changed files with 549 additions and 79 deletions
+295 -55
View File
@@ -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 {