feat(tui): Tier 3-4 UX improvements — split, routing, session naming, context bar
- Split app.go (2091→1378 lines) into rendering.go, events.go, init.go - Add EventRouting stream event for router arm transparency - Add session auto-naming from first user message - Add context window progress bar in status bar - Add /keys cheatsheet, /replay for resumed sessions - Add inline cost-per-turn after assistant responses - Add diff previews in fs.write/fs.edit permission prompts - Collapse tool output to 3 lines by default (ctrl+o expands) - Use AddPrefix for system context instead of InjectMessage - Handle ContentThinking and ContentToolResult in session resume - Show session title in resume picker - Add /model numeric selection snapshot safety
This commit is contained in:
@@ -100,6 +100,13 @@ func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) {
|
||||
"tools", len(req.Tools),
|
||||
"round", turn.Rounds,
|
||||
)
|
||||
if turn.Rounds == 1 {
|
||||
cb(stream.Event{
|
||||
Type: stream.EventRouting,
|
||||
RoutingModel: string(decision.Arm.ID),
|
||||
RoutingTask: task.Type.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
e.logger.Debug("streaming request",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -40,6 +41,7 @@ type Local struct {
|
||||
// Stats
|
||||
provider string
|
||||
model string
|
||||
title string
|
||||
turnCount int
|
||||
|
||||
// Persistence
|
||||
@@ -95,6 +97,9 @@ func (s *Local) SendWithOptions(input string, opts engine.TurnOptions) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.turnCount++
|
||||
if s.title == "" {
|
||||
s.title = sessionTitle(input)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Run engine in background goroutine
|
||||
@@ -130,6 +135,7 @@ func (s *Local) SendWithOptions(input string, opts engine.TurnOptions) error {
|
||||
ID: s.sessionID,
|
||||
Metadata: Metadata{
|
||||
ID: s.sessionID,
|
||||
Title: s.title,
|
||||
Provider: s.provider,
|
||||
Model: s.model,
|
||||
TurnCount: s.turnCount,
|
||||
@@ -208,3 +214,21 @@ func (s *Local) Status() Status {
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
// sessionTitle derives a short title from the first user message.
|
||||
func sessionTitle(input string) string {
|
||||
// Take first line, trim whitespace
|
||||
line := input
|
||||
if idx := strings.IndexByte(line, '\n'); idx >= 0 {
|
||||
line = line[:idx]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return ""
|
||||
}
|
||||
const maxLen = 60
|
||||
if len(line) > maxLen {
|
||||
line = line[:maxLen] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -326,5 +327,25 @@ func TestLocal_SessionID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"fix the login bug", "fix the login bug"},
|
||||
{"first line\nsecond line", "first line"},
|
||||
{" whitespace ", "whitespace"},
|
||||
{"", ""},
|
||||
{strings.Repeat("a", 80), strings.Repeat("a", 60) + "…"},
|
||||
{strings.Repeat("b", 60), strings.Repeat("b", 60)}, // exactly 60 — no truncation
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := sessionTitle(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sessionTitle(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused import
|
||||
var _ = json.Marshal
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
// Metadata holds session summary information persisted alongside messages.
|
||||
type Metadata struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"` // auto-set from first user message
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
TurnCount int `json:"turn_count"`
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
EventToolResult // tool execution output
|
||||
EventPermissionReq // permission prompt needed
|
||||
EventUsage
|
||||
EventRouting // router arm selection
|
||||
EventError
|
||||
)
|
||||
|
||||
@@ -40,6 +41,8 @@ func (et EventType) String() string {
|
||||
return "permission_req"
|
||||
case EventUsage:
|
||||
return "usage"
|
||||
case EventRouting:
|
||||
return "routing"
|
||||
case EventError:
|
||||
return "error"
|
||||
default:
|
||||
@@ -72,6 +75,10 @@ type Event struct {
|
||||
// Usage
|
||||
Usage *message.Usage
|
||||
|
||||
// Routing — arm selected by router
|
||||
RoutingModel string // e.g. "anthropic/claude-sonnet-4-20250514"
|
||||
RoutingTask string // classified task type
|
||||
|
||||
// Error
|
||||
Err error
|
||||
|
||||
|
||||
1024
internal/tui/app.go
1024
internal/tui/app.go
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ var builtinCommands = []string{
|
||||
"/help",
|
||||
"/incognito",
|
||||
"/init",
|
||||
"/keys",
|
||||
"/model",
|
||||
"/new",
|
||||
"/perm",
|
||||
@@ -23,6 +24,7 @@ var builtinCommands = []string{
|
||||
"/plugins",
|
||||
"/provider",
|
||||
"/quit",
|
||||
"/replay",
|
||||
"/resume",
|
||||
"/skills",
|
||||
"/usage",
|
||||
|
||||
117
internal/tui/events.go
Normal file
117
internal/tui/events.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||
)
|
||||
|
||||
func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) {
|
||||
switch evt.Type {
|
||||
case stream.EventTextDelta:
|
||||
if evt.Text != "" {
|
||||
text := filterModelCodeBlocks(&m.streamFilterClose, evt.Text)
|
||||
if text != "" {
|
||||
m.streamBuf.WriteString(text)
|
||||
}
|
||||
}
|
||||
case stream.EventThinkingDelta:
|
||||
// Accumulate reasoning in a separate buffer so it stays frozen/dim
|
||||
// while regular text content streams normally below it.
|
||||
if m.streamBuf.Len() == 0 {
|
||||
m.thinkingBuf.WriteString(evt.Text)
|
||||
} else {
|
||||
// Text has already started; treat additional thinking as text.
|
||||
m.streamBuf.WriteString(evt.Text)
|
||||
}
|
||||
case stream.EventToolCallStart:
|
||||
// Flush both buffers before tool call label
|
||||
if m.thinkingBuf.Len() > 0 {
|
||||
m.messages = append(m.messages, chatMessage{role: "thinking", content: m.thinkingBuf.String()})
|
||||
m.thinkingBuf.Reset()
|
||||
}
|
||||
if m.streamBuf.Len() > 0 {
|
||||
m.messages = append(m.messages, chatMessage{role: m.currentRole, content: m.streamBuf.String()})
|
||||
m.streamBuf.Reset()
|
||||
}
|
||||
if m.initPending {
|
||||
m.initHadToolCalls = true
|
||||
}
|
||||
case stream.EventToolCallDone:
|
||||
if evt.ToolCallName == "agent" || evt.ToolCallName == "spawn_elfs" {
|
||||
// Suppress tool message — elf tree view handles display
|
||||
m.elfToolActive = true
|
||||
} else {
|
||||
// Track running tools transiently — not in permanent chat history
|
||||
m.runningTools = append(m.runningTools, evt.ToolCallName)
|
||||
}
|
||||
case stream.EventRouting:
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: "cost",
|
||||
content: fmt.Sprintf("routed → %s (task: %s)", evt.RoutingModel, evt.RoutingTask),
|
||||
})
|
||||
case stream.EventToolResult:
|
||||
if m.elfToolActive {
|
||||
// Suppress raw elf output — tree shows progress, LLM summarizes
|
||||
m.elfToolActive = false
|
||||
} else {
|
||||
// Pop first running tool (FIFO — results arrive in call order)
|
||||
if len(m.runningTools) > 0 {
|
||||
m.runningTools = m.runningTools[1:]
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: "toolresult", content: evt.ToolOutput,
|
||||
})
|
||||
}
|
||||
}
|
||||
return m, m.listenForEvents()
|
||||
}
|
||||
|
||||
func (m Model) listenForEvents() tea.Cmd {
|
||||
ch := m.session.Events()
|
||||
permReqCh := m.config.PermReqCh
|
||||
|
||||
elfProgressCh := m.config.ElfProgress
|
||||
|
||||
return func() tea.Msg {
|
||||
// Listen for stream events, permission requests, and elf progress
|
||||
if permReqCh != nil || elfProgressCh != nil {
|
||||
// Build select dynamically — always listen on ch
|
||||
select {
|
||||
case evt, ok := <-ch:
|
||||
if !ok {
|
||||
turn, err := m.session.TurnResult()
|
||||
var usage message.Usage
|
||||
if turn != nil {
|
||||
usage = turn.Usage
|
||||
}
|
||||
return turnDoneMsg{err: err, usage: usage}
|
||||
}
|
||||
return streamEventMsg{event: evt}
|
||||
case req, ok := <-permReqCh:
|
||||
if ok {
|
||||
return req
|
||||
}
|
||||
return nil
|
||||
case progress, ok := <-elfProgressCh:
|
||||
if ok {
|
||||
return elfProgressMsg{progress: progress}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
evt, ok := <-ch
|
||||
if !ok {
|
||||
turn, err := m.session.TurnResult()
|
||||
var usage message.Usage
|
||||
if turn != nil {
|
||||
usage = turn.Usage
|
||||
}
|
||||
return turnDoneMsg{err: err, usage: usage}
|
||||
}
|
||||
return streamEventMsg{event: evt}
|
||||
}
|
||||
}
|
||||
149
internal/tui/init.go
Normal file
149
internal/tui/init.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
gnomacfg "somegit.dev/Owlibou/gnoma/internal/config"
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
)
|
||||
|
||||
// initPrompt builds the prompt sent to the LLM for /init.
|
||||
// existingPath is the absolute path to an existing AGENTS.md, or "" if none exists.
|
||||
// The 3 base elfs always run. When existingPath is set, a 4th elf reads the current file.
|
||||
// The LLM is free to spawn additional elfs if it identifies gaps.
|
||||
func initPrompt(root, existingPath string) string {
|
||||
baseElfs := fmt.Sprintf(`IMPORTANT: Use only fs.ls, fs.glob, fs.grep, and fs.read for all analysis. Do NOT use bash — it will be denied and will cause you to fail. Your first action must be spawn_elfs.
|
||||
|
||||
Use spawn_elfs to analyze the project in parallel. Spawn at least these elfs simultaneously:
|
||||
|
||||
- Elf 1 (task_type: "explain"): Explore project structure at %s.
|
||||
- Run fs.ls on root and every immediate subdirectory.
|
||||
- Read go.mod (or package.json/Cargo.toml/pyproject.toml): extract module path, Go/runtime version, and key external dependencies with exact import paths. List TUI/UI framework deps (e.g. charm.land/*, tview) separately from backend/LLM deps.
|
||||
- Read Makefile or build scripts: note targets beyond the standard (build/test/lint/fmt/vet/clean/tidy/install). Note non-standard flags, multi-step sequences, or env vars they require.
|
||||
- Read existing AI config files if present: CLAUDE.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .gnoma/GNOMA.md. These will be loaded at runtime — do NOT copy their content into AGENTS.md. Only note what topics they cover so the synthesis step knows what to skip.
|
||||
- Build a domain glossary: read the primary type-definition files in these packages (use fs.ls to find them): internal/message, internal/engine, internal/router, internal/elf, internal/provider, internal/context, internal/security, internal/session. For each exported type, struct, or interface whose name would be ambiguous or non-obvious to an outside AI, add a one-line entry: Name → what it is in this project. Specifically look for: Arm, Turn, Elf, Accumulator, Firewall, LimitPool, TaskType, Incognito, Stream, Event, Session, Router. Do not list generic config struct fields.
|
||||
- Report: module path, runtime version, non-standard Makefile targets only (skip standard ones: build/test/lint/cover/fmt/vet/clean/tidy/install/run), full dependency list (TUI + backend separated), domain glossary.
|
||||
|
||||
- Elf 2 (task_type: "explain"): Discover non-standard code conventions at %s.
|
||||
- Use fs.glob **/*.go (or language equivalent) to find source files. Read at least 8 files spanning different packages — prefer non-trivial ones (engine, provider, tool implementations, tests).
|
||||
- Use fs.grep to locate each pattern below. NEVER use internal/tui as a source for code examples — it is application glue, not where idioms live. For each match found: read the file, then paste the relevant lines with the file path as the first comment (e.g. '// internal/foo/bar.go'). If fs.grep returns no matches outside internal/tui, omit that pattern entirely. Do NOT invent or paraphrase.
|
||||
* new(expr): fs.grep '= new(' across **/*.go, exclude internal/tui
|
||||
* errors.AsType: fs.grep 'errors.AsType' across **/*.go
|
||||
* WaitGroup.Go: fs.grep '\.Go(func' across **/*.go
|
||||
* testing/synctest: fs.grep 'synctest' across **/*.go
|
||||
* Discriminated union: fs.grep 'Content|EventType|ContentType' across internal/message, internal/stream — look for a struct with a Type field switched on by callers
|
||||
* Pull-based iterator: fs.grep 'func.*Next\(\)' across **/*.go — look for Next/Current/Err/Close pattern
|
||||
* json.RawMessage passthrough: fs.grep 'json.RawMessage' across internal/tool — find a Parameters() or Execute() signature
|
||||
* errgroup: fs.grep 'errgroup' across **/*.go
|
||||
* Channel semaphore: fs.grep 'chan struct{}' across **/*.go, look for concurrency-limiting usage
|
||||
- Error handling: fs.grep 'var Err' across **/*.go — paste a real sentinel definition. fs.grep 'fmt.Errorf' across **/*.go and look for error-wrapping calls — paste a real one. File path required on each.
|
||||
- Test conventions: fs.grep '//go:build' across **/*_test.go for build tags. fs.grep 't.Helper()' across **/*_test.go for helper convention. fs.grep 't.TempDir()' across **/*_test.go. Paste one real example each with file path.
|
||||
- Report ONLY what differs from standard language knowledge. Skip obvious conventions.
|
||||
|
||||
- Elf 3 (task_type: "explain"): Extract setup requirements and gotchas at %s.
|
||||
- Read README.md, CONTRIBUTING.md, docs/ contents if they exist.
|
||||
- Find required environment variables: use fs.grep to search for os.Getenv and os.LookupEnv across all .go files. List every unique variable name found and what it configures based on surrounding context. Also check .env.example if it exists.
|
||||
- Note non-obvious setup steps (token scopes, local service dependencies, build prerequisites not in the Makefile).
|
||||
- Note repo etiquette ONLY if not already covered by CLAUDE.md — skip commit format and co-signing if CLAUDE.md documents them.
|
||||
- Note architectural gotchas explicitly called out in comments or docs — skip generic advice.
|
||||
- Skip anything obvious for a project of this type.`, root, root, root)
|
||||
|
||||
synthRules := fmt.Sprintf(`After all elfs complete, you may spawn additional focused elfs with agent tool if specific gaps need investigation.
|
||||
|
||||
Then synthesize and write AGENTS.md to %s/AGENTS.md using fs.write.
|
||||
|
||||
CRITICAL RULE — DO NOT DUPLICATE LOADED FILES:
|
||||
CLAUDE.md (and other AI config files) are loaded directly into the AI's context at runtime.
|
||||
Writing their content into AGENTS.md is pure noise — it will be read twice and adds nothing.
|
||||
AGENTS.md must only contain information those files do not already cover.
|
||||
If CLAUDE.md thoroughly covers a topic (e.g. Go style, commit format, provider list), skip it.
|
||||
|
||||
QUALITY TEST: Before writing each line — would removing this cause an AI assistant to make a mistake on this codebase? If no, cut it.
|
||||
|
||||
INCLUDE (only if not already in CLAUDE.md or equivalent):
|
||||
- Module path and key dependencies with exact import paths (especially non-obvious or private ones)
|
||||
- Build/test commands the AI cannot guess from manifest files alone (non-standard targets, flags, sequences)
|
||||
- Language-version-specific idioms in use: e.g. Go 1.26 new(expr), errors.AsType, WaitGroup.Go; show code examples
|
||||
- Non-standard type patterns: discriminated unions, pull-based iterators, json.RawMessage passthrough — with examples
|
||||
- Domain terminology: project-specific names that differ from industry-standard meanings
|
||||
- Testing quirks: build tags, helper conventions, concurrency test tools, mock policy
|
||||
- Required env var names and what they configure (not "see .env.example" — list them)
|
||||
- Non-obvious architectural constraints or gotchas not derivable from reading the code
|
||||
|
||||
EXCLUDE:
|
||||
- Anything already documented in CLAUDE.md or other AI config files that will be loaded at runtime
|
||||
- File-by-file directory listing (discoverable via fs.ls)
|
||||
- Standard language conventions the AI already knows
|
||||
- Generic advice ("write clean code", "handle errors", "use descriptive names")
|
||||
- Standard Makefile/build targets (build, test, lint, cover, fmt, vet, clean, tidy, install, run) — do not list them at all, not even as a summary line; only write non-standard targets
|
||||
- The "Standard Targets: ..." line itself — it adds nothing and must not appear
|
||||
- Planned features not yet in code
|
||||
- Vague statements ("see config files for details", "follow project conventions") — include the actual detail or nothing
|
||||
|
||||
Do not fabricate. Only write what was observed in files you actually read.
|
||||
Format: terse directive-style bullets. Short code examples where the pattern is non-obvious. No prose paragraphs.`, root)
|
||||
|
||||
if existingPath != "" {
|
||||
return fmt.Sprintf(`You are updating the AGENTS.md project documentation file for the project at %s.
|
||||
|
||||
%s
|
||||
- Elf 4 (task_type: "review"): Read the existing AGENTS.md at %s.
|
||||
- For each section: accurate (keep), stale (update), missing (add), bloat (cut — fails quality test).
|
||||
- Specifically flag: anything duplicated from CLAUDE.md or other loaded AI config files (remove it), fabricated content (remove it), and missing language-version-specific idioms.
|
||||
- Report a structured diff: keep / update / add / remove.
|
||||
|
||||
%s
|
||||
|
||||
When updating: tighten as well as correct. Remove duplication and bloat even if it was in the old version.`,
|
||||
root, baseElfs, existingPath, synthRules)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`You are creating an AGENTS.md project documentation file for the project at %s.
|
||||
|
||||
%s
|
||||
|
||||
%s`, root, baseElfs, synthRules)
|
||||
}
|
||||
|
||||
// loadAgentsMD reads AGENTS.md from disk and appends it to the context window prefix.
|
||||
func (m Model) loadAgentsMD() Model {
|
||||
root := gnomacfg.ProjectRoot()
|
||||
path := filepath.Join(root, "AGENTS.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
if m.config.Engine != nil {
|
||||
if w := m.config.Engine.ContextWindow(); w != nil {
|
||||
w.AddPrefix(
|
||||
message.NewUserText(fmt.Sprintf("[Project docs: AGENTS.md]\n\n%s", string(data))),
|
||||
message.NewAssistantText("I've read the project documentation and will follow these guidelines."),
|
||||
)
|
||||
}
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("AGENTS.md written to %s — loaded into context for this session.", path)})
|
||||
return m
|
||||
}
|
||||
|
||||
// extractMarkdownDoc strips preamble and returns everything from the first heading onward.
|
||||
func extractMarkdownDoc(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
idx := strings.Index(s, line)
|
||||
return strings.TrimSpace(s[idx:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// looksLikeAgentsMD returns true if s appears to be a real markdown document
|
||||
// (not a refusal or planning response): substantial length and at least one
|
||||
// section heading.
|
||||
func looksLikeAgentsMD(s string) bool {
|
||||
return len(s) >= 300 && strings.Contains(s, "##")
|
||||
}
|
||||
620
internal/tui/rendering.go
Normal file
620
internal/tui/rendering.go
Normal file
@@ -0,0 +1,620 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xansi "github.com/charmbracelet/x/ansi"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/session"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
if m.width == 0 {
|
||||
return tea.NewView("")
|
||||
}
|
||||
|
||||
status := m.renderStatus()
|
||||
input := m.renderInput()
|
||||
topLine, bottomLine := m.renderSeparators()
|
||||
|
||||
// Fixed: status bar + separator + input + separator = bottom area
|
||||
statusH := lipgloss.Height(status)
|
||||
inputH := lipgloss.Height(input)
|
||||
chatH := m.height - statusH - inputH - 2
|
||||
|
||||
chat := m.renderChat(chatH)
|
||||
|
||||
// Show "new content below" indicator when scrolled up during streaming
|
||||
if m.scrollOffset > 0 && m.streaming {
|
||||
indicator := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true).Render(" ⬇ new content below")
|
||||
topLine = indicator + topLine[len(indicator):]
|
||||
}
|
||||
|
||||
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||
chat,
|
||||
topLine,
|
||||
input,
|
||||
bottomLine,
|
||||
status,
|
||||
))
|
||||
if m.copyMode {
|
||||
v.MouseMode = tea.MouseModeNone
|
||||
} else {
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
}
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m Model) shortCwd() string {
|
||||
dir := m.cwd
|
||||
home, _ := os.UserHomeDir()
|
||||
if strings.HasPrefix(dir, home) {
|
||||
dir = "~" + dir[len(home):]
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (m Model) renderChat(height int) string {
|
||||
var lines []string
|
||||
|
||||
// Header info — scrolls with content
|
||||
status := m.session.Status()
|
||||
lines = append(lines,
|
||||
sHeaderBrand.Render(" gnoma ")+" "+sHeaderDim.Render("gnoma "+version),
|
||||
" "+sHeaderModel.Render(fmt.Sprintf("%s/%s", status.Provider, status.Model))+
|
||||
sHeaderDim.Render(" · ")+sHeaderDim.Render(m.shortCwd()),
|
||||
"",
|
||||
)
|
||||
|
||||
if len(m.messages) == 0 && !m.streaming {
|
||||
lines = append(lines,
|
||||
sHint.Render(" Type a message and press Enter."),
|
||||
sHint.Render(" /help for commands, Ctrl+C to cancel or quit."),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
for _, msg := range m.messages {
|
||||
lines = append(lines, m.renderMessage(msg)...)
|
||||
}
|
||||
|
||||
// Elf tree view — shows active elfs with structured progress
|
||||
if m.streaming && len(m.elfStates) > 0 {
|
||||
lines = append(lines, m.renderElfTree()...)
|
||||
}
|
||||
|
||||
// Transient: running tools (disappear when tool completes)
|
||||
for _, name := range m.runningTools {
|
||||
lines = append(lines, " "+sToolOutput.Render(fmt.Sprintf("⚙ [%s] running...", name)))
|
||||
}
|
||||
|
||||
// Transient: permission prompt (disappear when approved/denied)
|
||||
if m.permPending {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, sSystem.Render("• "+formatPermissionPrompt(m.permToolName, m.permArgs)))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Transient: session resume picker
|
||||
if m.resumePending && len(m.resumeSessions) > 0 {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, sSystem.Render(" Sessions ↑↓ · Enter to load · Esc to cancel"))
|
||||
lines = append(lines, "")
|
||||
for i, s := range m.resumeSessions {
|
||||
age := time.Since(s.UpdatedAt).Truncate(time.Minute)
|
||||
label := s.ID
|
||||
if s.Title != "" {
|
||||
label = s.Title
|
||||
if len(label) > 40 {
|
||||
label = label[:40] + "…"
|
||||
}
|
||||
}
|
||||
row := fmt.Sprintf("%-42s %s/%s %d turns %s ago",
|
||||
label, s.Provider, s.Model, s.TurnCount, age)
|
||||
if i == m.resumeSelected {
|
||||
lines = append(lines, sText.Render("→ "+row))
|
||||
} else {
|
||||
lines = append(lines, sHint.Render(" "+row))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Streaming: show frozen thinking above live text content
|
||||
if m.streaming {
|
||||
maxWidth := m.width - 2
|
||||
if m.thinkingBuf.Len() > 0 {
|
||||
// Thinking is frozen once text starts; show dim with hollow diamond.
|
||||
// Cap at 3 lines while streaming (ctrl+o expands).
|
||||
const liveThinkMax = 3
|
||||
thinkLines := strings.Split(wrapText(m.thinkingBuf.String(), maxWidth), "\n")
|
||||
showN := len(thinkLines)
|
||||
if !m.expandOutput && showN > liveThinkMax {
|
||||
showN = liveThinkMax
|
||||
}
|
||||
for i, line := range thinkLines[:showN] {
|
||||
if i == 0 {
|
||||
lines = append(lines, sThinkingLabel.Render("◇ ")+sThinkingBody.Render(line))
|
||||
} else {
|
||||
lines = append(lines, sThinkingBody.Render(" "+line))
|
||||
}
|
||||
}
|
||||
if !m.expandOutput && len(thinkLines) > liveThinkMax {
|
||||
lines = append(lines, sHint.Render(fmt.Sprintf(" +%d lines (ctrl+o to expand)", len(thinkLines)-liveThinkMax)))
|
||||
}
|
||||
}
|
||||
if m.streamBuf.Len() > 0 {
|
||||
// Regular text content — strip model artifacts before display
|
||||
liveText := sanitizeAssistantText(m.streamBuf.String())
|
||||
for i, line := range strings.Split(wrapText(liveText, maxWidth), "\n") {
|
||||
if i == 0 {
|
||||
lines = append(lines, styleAssistantLabel.Render("◆ ")+line)
|
||||
} else {
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
}
|
||||
} else if m.thinkingBuf.Len() == 0 {
|
||||
lines = append(lines, styleAssistantLabel.Render("◆ ")+sCursor.Render("█"))
|
||||
}
|
||||
}
|
||||
|
||||
// Join all logical lines then split by newlines
|
||||
raw := strings.Join(lines, "\n")
|
||||
rawLines := strings.Split(raw, "\n")
|
||||
|
||||
// Hard-wrap any remaining overlong lines to get accurate physical line count
|
||||
// for the scroll logic. Content should already be word-wrapped by renderMessage,
|
||||
// but ANSI escape overhead can push a styled line past m.width.
|
||||
var physLines []string
|
||||
for _, line := range rawLines {
|
||||
if lipgloss.Width(line) <= m.width {
|
||||
physLines = append(physLines, line)
|
||||
} else {
|
||||
// Actually split the line using ANSI-aware hard wrap so the scroll
|
||||
// offset math and the rendered content agree.
|
||||
split := strings.Split(xansi.Hardwrap(line, m.width, false), "\n")
|
||||
physLines = append(physLines, split...)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply scroll: offset from bottom
|
||||
if len(physLines) > height && height > 0 {
|
||||
maxScroll := len(physLines) - height
|
||||
offset := m.scrollOffset
|
||||
if offset > maxScroll {
|
||||
offset = maxScroll
|
||||
}
|
||||
end := len(physLines) - offset
|
||||
start := end - height
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
physLines = physLines[start:end]
|
||||
}
|
||||
|
||||
// Hard truncate to exactly height lines — prevent overflow
|
||||
if len(physLines) > height && height > 0 {
|
||||
physLines = physLines[:height]
|
||||
}
|
||||
|
||||
content := strings.Join(physLines, "\n")
|
||||
|
||||
// Pad to fill height if content is shorter
|
||||
contentH := strings.Count(content, "\n") + 1
|
||||
if contentH < height {
|
||||
content += strings.Repeat("\n", height-contentH)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
func (m Model) renderMessage(msg chatMessage) []string {
|
||||
var lines []string
|
||||
indent := " " // 2-space indent for continuation lines
|
||||
|
||||
switch msg.role {
|
||||
case "user":
|
||||
// ❯ first line, indented continuation — word-wrapped to terminal width
|
||||
maxWidth := m.width - 2 // 2 for the "❯ " / " " prefix
|
||||
msgLines := strings.Split(wrapText(msg.content, maxWidth), "\n")
|
||||
for i, line := range msgLines {
|
||||
if i == 0 {
|
||||
lines = append(lines, sUserLabel.Render("❯ ")+sUserLabel.Render(line))
|
||||
} else {
|
||||
lines = append(lines, sUserLabel.Render(indent+line))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
case "thinking":
|
||||
// Thinking/reasoning content — dim italic with hollow diamond label.
|
||||
// Collapsed to 3 lines by default; ctrl+o expands.
|
||||
const thinkingMaxLines = 3
|
||||
maxWidth := m.width - 2
|
||||
msgLines := strings.Split(wrapText(msg.content, maxWidth), "\n")
|
||||
showLines := len(msgLines)
|
||||
if !m.expandOutput && showLines > thinkingMaxLines {
|
||||
showLines = thinkingMaxLines
|
||||
}
|
||||
for i, line := range msgLines[:showLines] {
|
||||
if i == 0 {
|
||||
lines = append(lines, sThinkingLabel.Render("◇ ")+sThinkingBody.Render(line))
|
||||
} else {
|
||||
lines = append(lines, sThinkingBody.Render(indent+line))
|
||||
}
|
||||
}
|
||||
if !m.expandOutput && len(msgLines) > thinkingMaxLines {
|
||||
remaining := len(msgLines) - thinkingMaxLines
|
||||
lines = append(lines, sHint.Render(indent+fmt.Sprintf("+%d lines (ctrl+o to expand)", remaining)))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
case "assistant":
|
||||
// Render markdown with glamour; strip model-specific artifacts first.
|
||||
clean := sanitizeAssistantText(msg.content)
|
||||
rendered := clean
|
||||
if m.mdRenderer != nil {
|
||||
if md, err := m.mdRenderer.Render(clean); err == nil {
|
||||
rendered = strings.TrimSpace(md)
|
||||
}
|
||||
}
|
||||
renderedLines := strings.Split(rendered, "\n")
|
||||
for i, line := range renderedLines {
|
||||
if i == 0 {
|
||||
lines = append(lines, styleAssistantLabel.Render("◆ ")+line)
|
||||
} else {
|
||||
lines = append(lines, indent+line)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
case "tool":
|
||||
maxW := m.width - len([]rune(indent))
|
||||
for _, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
|
||||
lines = append(lines, indent+sToolOutput.Render(line))
|
||||
}
|
||||
|
||||
case "toolresult":
|
||||
resultLines := strings.Split(msg.content, "\n")
|
||||
maxShow := 3 // collapsed by default
|
||||
if m.expandOutput {
|
||||
maxShow = len(resultLines)
|
||||
}
|
||||
maxW := m.width - 4 // indent(2) + indent(2)
|
||||
for i, line := range resultLines {
|
||||
if i >= maxShow {
|
||||
remaining := len(resultLines) - maxShow
|
||||
lines = append(lines, indent+indent+sHint.Render(
|
||||
fmt.Sprintf("+%d lines (ctrl+o to expand)", remaining)))
|
||||
break
|
||||
}
|
||||
// Wrap this logical line into sub-lines, then diff-color each sub-line
|
||||
for _, subLine := range strings.Split(wrapText(line, maxW), "\n") {
|
||||
trimmed := strings.TrimSpace(subLine)
|
||||
if strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "++") && len(trimmed) > 1 {
|
||||
lines = append(lines, indent+indent+sDiffAdd.Render(subLine))
|
||||
} else if strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "--") && len(trimmed) > 1 {
|
||||
lines = append(lines, indent+indent+sDiffRemove.Render(subLine))
|
||||
} else {
|
||||
lines = append(lines, indent+indent+sToolResult.Render(subLine))
|
||||
}
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
case "system":
|
||||
maxW := m.width - 4 // "• "(2) + indent(2)
|
||||
for i, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
|
||||
if i == 0 {
|
||||
lines = append(lines, sSystem.Render("• "+line))
|
||||
} else {
|
||||
lines = append(lines, sSystem.Render(indent+line))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
case "error":
|
||||
maxW := m.width - 2 // "✗ " = 2
|
||||
for _, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
|
||||
lines = append(lines, sError.Render("✗ "+line))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
case "cost":
|
||||
lines = append(lines, sHint.Render(indent+msg.content))
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) renderElfTree() []string {
|
||||
if len(m.elfOrder) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Count running vs done
|
||||
running := 0
|
||||
for _, id := range m.elfOrder {
|
||||
if p, ok := m.elfStates[id]; ok && !p.Done {
|
||||
running++
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
if running > 0 {
|
||||
header := fmt.Sprintf("● Running %d elf", len(m.elfOrder))
|
||||
if len(m.elfOrder) != 1 {
|
||||
header += "s"
|
||||
}
|
||||
header += "…"
|
||||
lines = append(lines, sStatusStreaming.Render(header))
|
||||
} else {
|
||||
header := fmt.Sprintf("● %d elf", len(m.elfOrder))
|
||||
if len(m.elfOrder) != 1 {
|
||||
header += "s"
|
||||
}
|
||||
header += " completed"
|
||||
lines = append(lines, sToolOutput.Render(header))
|
||||
}
|
||||
|
||||
for i, elfID := range m.elfOrder {
|
||||
p, ok := m.elfStates[elfID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isLast := i == len(m.elfOrder)-1
|
||||
|
||||
// Branch character
|
||||
branch := "├─"
|
||||
childPrefix := "│ "
|
||||
if isLast {
|
||||
branch = "└─"
|
||||
childPrefix = " "
|
||||
}
|
||||
|
||||
// Main line: branch + description + stats
|
||||
var stats []string
|
||||
if p.ToolUses > 0 {
|
||||
stats = append(stats, fmt.Sprintf("%d tool uses", p.ToolUses))
|
||||
}
|
||||
if p.Tokens > 0 {
|
||||
stats = append(stats, formatTokens(p.Tokens))
|
||||
}
|
||||
|
||||
statsStr := ""
|
||||
if len(stats) > 0 {
|
||||
statsStr = " · " + strings.Join(stats, " · ")
|
||||
}
|
||||
desc := p.Description
|
||||
if len(statsStr) > 0 {
|
||||
// Truncate description so the combined line fits on one terminal row
|
||||
maxDescW := m.width - 4 - len([]rune(branch)) - len([]rune(statsStr))
|
||||
if maxDescW > 10 && len([]rune(desc)) > maxDescW {
|
||||
desc = string([]rune(desc)[:maxDescW-1]) + "…"
|
||||
}
|
||||
}
|
||||
line := sToolOutput.Render(branch+" ") + sText.Render(desc)
|
||||
if len(statsStr) > 0 {
|
||||
line += sToolResult.Render(statsStr)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
|
||||
// Activity sub-line
|
||||
var activity string
|
||||
if p.Done {
|
||||
if p.Error != "" {
|
||||
activity = sError.Render("Error: " + p.Error)
|
||||
} else {
|
||||
dur := p.Duration.Round(time.Millisecond)
|
||||
activity = sToolOutput.Render(fmt.Sprintf("Done (%s)", dur))
|
||||
}
|
||||
} else {
|
||||
activity = p.Activity
|
||||
if activity == "" {
|
||||
activity = "working…"
|
||||
}
|
||||
activity = sToolResult.Render(activity)
|
||||
}
|
||||
// Wrap activity so long error/path strings don't overflow the terminal.
|
||||
actPrefix := childPrefix + "└─ "
|
||||
actMaxW := m.width - len([]rune(actPrefix))
|
||||
actLines := strings.Split(wrapText(activity, actMaxW), "\n")
|
||||
for j, al := range actLines {
|
||||
if j == 0 {
|
||||
lines = append(lines, sToolResult.Render(actPrefix)+al)
|
||||
} else {
|
||||
lines = append(lines, sToolResult.Render(childPrefix+" ")+al)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "") // spacing after tree
|
||||
return lines
|
||||
}
|
||||
|
||||
func formatTokens(tokens int) string {
|
||||
if tokens >= 1_000_000 {
|
||||
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
|
||||
}
|
||||
if tokens >= 1_000 {
|
||||
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
|
||||
}
|
||||
return fmt.Sprintf("%d tokens", tokens)
|
||||
}
|
||||
|
||||
func (m Model) renderSeparators() (string, string) {
|
||||
lineColor := cSurface // default dim
|
||||
modeLabel := ""
|
||||
|
||||
if m.config.Permissions != nil {
|
||||
mode := m.config.Permissions.Mode()
|
||||
lineColor = ModeColor(mode)
|
||||
modeLabel = string(mode)
|
||||
}
|
||||
|
||||
// Incognito adds amber overlay but keeps mode visible
|
||||
if m.incognito {
|
||||
lineColor = cYellow
|
||||
modeLabel = "🔒 " + modeLabel
|
||||
}
|
||||
|
||||
// Permission pending — flash the line with command summary
|
||||
if m.permPending {
|
||||
lineColor = cRed
|
||||
hint := shortPermHint(m.permToolName, m.permArgs)
|
||||
modeLabel = "⚠ " + hint + " [y/n]"
|
||||
}
|
||||
|
||||
lineStyle := lipgloss.NewStyle().Foreground(lineColor)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lineColor).Bold(true)
|
||||
|
||||
// Top line: ─── with mode label on right ─── bypass ───
|
||||
label := " " + modeLabel + " "
|
||||
labelW := lipgloss.Width(labelStyle.Render(label))
|
||||
lineW := m.width - labelW
|
||||
if lineW < 4 {
|
||||
lineW = 4
|
||||
}
|
||||
leftW := lineW - 2
|
||||
rightW := 2
|
||||
|
||||
topLine := lineStyle.Render(strings.Repeat("─", leftW)) +
|
||||
labelStyle.Render(label) +
|
||||
lineStyle.Render(strings.Repeat("─", rightW))
|
||||
|
||||
// Bottom line: plain colored line
|
||||
bottomLine := lineStyle.Render(strings.Repeat("─", m.width))
|
||||
|
||||
return topLine, bottomLine
|
||||
}
|
||||
|
||||
func (m Model) renderInput() string {
|
||||
view := m.input.View()
|
||||
if m.suggestion != "" {
|
||||
// Show the untyped remainder as dim ghost text.
|
||||
rest := strings.TrimPrefix(m.suggestion, m.input.Value())
|
||||
if rest != "" {
|
||||
ghost := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(rest + " (tab)")
|
||||
view += ghost
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (m Model) renderStatus() string {
|
||||
status := m.session.Status()
|
||||
|
||||
// Left: provider + model + incognito
|
||||
provModel := fmt.Sprintf(" %s/%s", status.Provider, status.Model)
|
||||
if m.incognito {
|
||||
provModel += " " + sStatusIncognito.Render("🔒")
|
||||
}
|
||||
left := sStatusHighlight.Render(provModel)
|
||||
|
||||
// Center: cwd + git branch
|
||||
dir := filepath.Base(m.cwd)
|
||||
centerParts := []string{"📁 " + dir}
|
||||
if m.gitBranch != "" {
|
||||
centerParts = append(centerParts, sStatusBranch.Render(" "+m.gitBranch))
|
||||
}
|
||||
center := sStatusDim.Render(strings.Join(centerParts, ""))
|
||||
|
||||
// Right: context bar + tokens + turns
|
||||
right := renderContextBar(status) + sStatusDim.Render(fmt.Sprintf(" │ turns: %d ", status.TurnCount))
|
||||
|
||||
if m.quitHint {
|
||||
right = lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("ctrl+c to quit ") + sStatusDim.Render("│ ") + right
|
||||
}
|
||||
if m.copyMode {
|
||||
right = lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render("✂ COPY ") + sStatusDim.Render("│ ") + right
|
||||
}
|
||||
if m.streaming {
|
||||
right = sStatusStreaming.Render("● streaming ") + sStatusDim.Render("│ ") + right
|
||||
}
|
||||
|
||||
// Compose with spacing
|
||||
leftW := lipgloss.Width(left)
|
||||
centerW := lipgloss.Width(center)
|
||||
rightW := lipgloss.Width(right)
|
||||
|
||||
gap1 := (m.width-leftW-centerW-rightW)/2 - 1
|
||||
if gap1 < 1 {
|
||||
gap1 = 1
|
||||
}
|
||||
gap2 := m.width - leftW - gap1 - centerW - rightW
|
||||
if gap2 < 0 {
|
||||
gap2 = 0
|
||||
}
|
||||
|
||||
bar := left + strings.Repeat(" ", gap1) + center + strings.Repeat(" ", gap2) + right
|
||||
return sStatusBar.Width(m.width).Render(bar)
|
||||
}
|
||||
|
||||
// renderContextBar draws a compact [████░░░░] 45% progress bar for the context window.
|
||||
func renderContextBar(s session.Status) string {
|
||||
pct := s.TokenPercent
|
||||
if pct <= 0 && s.TokensUsed == 0 {
|
||||
return sStatusDim.Render("ctx: —")
|
||||
}
|
||||
|
||||
const barWidth = 8
|
||||
filled := (pct * barWidth) / 100
|
||||
if filled > barWidth {
|
||||
filled = barWidth
|
||||
}
|
||||
empty := barWidth - filled
|
||||
|
||||
var barColor lipgloss.Style
|
||||
switch s.TokenState {
|
||||
case "critical":
|
||||
barColor = lipgloss.NewStyle().Foreground(cRed).Bold(true)
|
||||
case "warning":
|
||||
barColor = lipgloss.NewStyle().Foreground(cYellow)
|
||||
default:
|
||||
barColor = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
|
||||
}
|
||||
dimBlock := sStatusDim
|
||||
|
||||
bar := barColor.Render(strings.Repeat("█", filled)) + dimBlock.Render(strings.Repeat("░", empty))
|
||||
label := fmt.Sprintf(" %d%%", pct)
|
||||
|
||||
var labelStyle lipgloss.Style
|
||||
switch s.TokenState {
|
||||
case "critical":
|
||||
labelStyle = lipgloss.NewStyle().Foreground(cRed).Bold(true)
|
||||
case "warning":
|
||||
labelStyle = lipgloss.NewStyle().Foreground(cYellow)
|
||||
default:
|
||||
labelStyle = sStatusDim
|
||||
}
|
||||
return "[" + bar + "]" + labelStyle.Render(label)
|
||||
}
|
||||
|
||||
// formatTurnUsage produces a compact token summary for a single turn.
|
||||
func formatTurnUsage(u message.Usage) string {
|
||||
parts := []string{fmt.Sprintf("in: %d", u.InputTokens), fmt.Sprintf("out: %d", u.OutputTokens)}
|
||||
if u.CacheReadTokens > 0 {
|
||||
parts = append(parts, fmt.Sprintf("cache: %d", u.CacheReadTokens))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// wrapText word-wraps text at word boundaries, preserving existing newlines.
|
||||
// Uses ANSI-aware wrapping so lipgloss-styled text is measured correctly.
|
||||
func wrapText(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return text
|
||||
}
|
||||
return xansi.Wordwrap(text, width, "")
|
||||
}
|
||||
103
internal/tui/statusbar_test.go
Normal file
103
internal/tui/statusbar_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/session"
|
||||
)
|
||||
|
||||
func TestRenderContextBar_Zero(t *testing.T) {
|
||||
s := session.Status{TokensUsed: 0, TokenPercent: 0}
|
||||
got := renderContextBar(s)
|
||||
if !strings.Contains(got, "—") {
|
||||
t.Errorf("zero usage should show dash, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContextBar_Half(t *testing.T) {
|
||||
s := session.Status{TokensUsed: 50000, TokenPercent: 50, TokenState: "ok"}
|
||||
got := renderContextBar(s)
|
||||
if !strings.Contains(got, "50%") {
|
||||
t.Errorf("should contain 50%%, got %q", got)
|
||||
}
|
||||
// 8-wide bar at 50% → 4 filled + 4 empty
|
||||
if strings.Count(got, "█") != 4 {
|
||||
t.Errorf("expected 4 filled blocks, got %d in %q", strings.Count(got, "█"), got)
|
||||
}
|
||||
if strings.Count(got, "░") != 4 {
|
||||
t.Errorf("expected 4 empty blocks, got %d in %q", strings.Count(got, "░"), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContextBar_Full(t *testing.T) {
|
||||
s := session.Status{TokensUsed: 100000, TokenPercent: 100, TokenState: "critical"}
|
||||
got := renderContextBar(s)
|
||||
if !strings.Contains(got, "100%") {
|
||||
t.Errorf("should contain 100%%, got %q", got)
|
||||
}
|
||||
if strings.Count(got, "█") != 8 {
|
||||
t.Errorf("expected 8 filled blocks at 100%%, got %d", strings.Count(got, "█"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContextBar_Warning(t *testing.T) {
|
||||
s := session.Status{TokensUsed: 70000, TokenPercent: 75, TokenState: "warning"}
|
||||
got := renderContextBar(s)
|
||||
if !strings.Contains(got, "75%") {
|
||||
t.Errorf("should contain 75%%, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTurnUsage_Basic(t *testing.T) {
|
||||
u := message.Usage{InputTokens: 1500, OutputTokens: 200}
|
||||
got := formatTurnUsage(u)
|
||||
if !strings.Contains(got, "in: 1500") {
|
||||
t.Errorf("should contain input tokens, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "out: 200") {
|
||||
t.Errorf("should contain output tokens, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "cache") {
|
||||
t.Errorf("should not mention cache when zero, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTurnUsage_WithCache(t *testing.T) {
|
||||
u := message.Usage{InputTokens: 5000, OutputTokens: 800, CacheReadTokens: 3000}
|
||||
got := formatTurnUsage(u)
|
||||
if !strings.Contains(got, "cache: 3000") {
|
||||
t.Errorf("should contain cache tokens, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffPreviewEdit(t *testing.T) {
|
||||
got := diffPreviewEdit("old line", "new line")
|
||||
if !strings.Contains(got, "- old line") {
|
||||
t.Errorf("should show removed line, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "+ new line") {
|
||||
t.Errorf("should show added line, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffPreviewWrite(t *testing.T) {
|
||||
content := "line1\nline2\nline3"
|
||||
got := diffPreviewWrite(content)
|
||||
if !strings.Contains(got, "+ line1") {
|
||||
t.Errorf("should show first line, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffPreviewWrite_TruncatesLong(t *testing.T) {
|
||||
lines := make([]string, 20)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line %d", i+1)
|
||||
}
|
||||
got := diffPreviewWrite(strings.Join(lines, "\n"))
|
||||
if !strings.Contains(got, "more lines") {
|
||||
t.Errorf("should truncate with '...more lines', got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user