From cd55c64dfe6b1da738f5cfbbbfecb70f1ef0a60c Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 24 Sep 2026 14:51:40 +0300 Subject: [PATCH] cli: theme every screen from terminal colors, add speaker rails and kaomoji --- cli/cmd/albedo/main.go | 2 + cli/go.mod | 4 +- cli/internal/daemon/client.go | 41 ++ cli/internal/daemon/client_test.go | 52 ++ cli/internal/daemon/events.go | 3 + cli/internal/tui/app.go | 5 +- cli/internal/tui/bounded_history.go | 17 + cli/internal/tui/capability_pages.go | 32 +- cli/internal/tui/chat.go | 589 +++++++++++++----- cli/internal/tui/commands.go | 7 +- cli/internal/tui/context_inspector.go | 34 +- cli/internal/tui/extension_picker.go | 62 +- cli/internal/tui/ink.go | 242 +++++++ cli/internal/tui/ink_other.go | 5 + cli/internal/tui/ink_test.go | 93 +++ cli/internal/tui/ink_unix.go | 64 ++ cli/internal/tui/login.go | 34 +- cli/internal/tui/markdown.go | 332 ++++------ cli/internal/tui/mcp_form.go | 21 +- cli/internal/tui/model_picker.go | 14 +- cli/internal/tui/moods.go | 124 ++++ cli/internal/tui/page_view.go | 46 +- cli/internal/tui/pending_test.go | 47 ++ cli/internal/tui/picker.go | 24 +- cli/internal/tui/replay_test.go | 32 + cli/internal/tui/selection.go | 13 +- cli/internal/tui/session_viewer.go | 5 +- cli/internal/tui/session_viewer_view.go | 233 +++---- cli/internal/tui/stretch_test.go | 35 ++ cli/internal/tui/styles.go | 63 -- cli/internal/tui/theme.go | 266 ++++++++ cli/internal/tui/theme_test.go | 34 + cli/internal/tui/transcript.go | 396 +++++++++--- .../tui/transcript_readability_test.go | 32 + cli/internal/tui/tree_picker.go | 31 +- cli/internal/tui/turns_test.go | 78 +++ 36 files changed, 2269 insertions(+), 843 deletions(-) create mode 100644 cli/internal/tui/ink.go create mode 100644 cli/internal/tui/ink_other.go create mode 100644 cli/internal/tui/ink_test.go create mode 100644 cli/internal/tui/ink_unix.go create mode 100644 cli/internal/tui/moods.go create mode 100644 cli/internal/tui/pending_test.go create mode 100644 cli/internal/tui/replay_test.go create mode 100644 cli/internal/tui/stretch_test.go delete mode 100644 cli/internal/tui/styles.go create mode 100644 cli/internal/tui/theme.go create mode 100644 cli/internal/tui/theme_test.go create mode 100644 cli/internal/tui/transcript_readability_test.go create mode 100644 cli/internal/tui/turns_test.go diff --git a/cli/cmd/albedo/main.go b/cli/cmd/albedo/main.go index c4d448b..155e384 100644 --- a/cli/cmd/albedo/main.go +++ b/cli/cmd/albedo/main.go @@ -217,6 +217,7 @@ func open(id, workspace string, fresh bool) error { return nil } + tui.DetectInk() appModel := tui.NewAppModel(conn, profs, initial, absWorkspace, !configured) appModel.SessionPicker.LoadPrefs(filepath.Join(config.HomeDir(), "picker.json")) if os.Getenv("ALBEDO_NO_BROWSER") == "" { @@ -411,6 +412,7 @@ func run(args []string) error { if err != nil { return err } + tui.DetectInk() appModel := tui.NewAppModel(conn, profs, nil, cwd, true) appModel.StandaloneLogin = true if os.Getenv("ALBEDO_NO_BROWSER") == "" { diff --git a/cli/go.mod b/cli/go.mod index 2658e55..af4409e 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -8,14 +8,15 @@ require ( github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/x/ansi v0.8.0 + github.com/charmbracelet/x/term v0.2.1 github.com/mattn/go-isatty v0.0.20 github.com/rivo/uniseg v0.4.7 + golang.org/x/sys v0.30.0 golang.org/x/text v0.3.8 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -24,5 +25,4 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect ) diff --git a/cli/internal/daemon/client.go b/cli/internal/daemon/client.go index 0c4934f..23d34b1 100644 --- a/cli/internal/daemon/client.go +++ b/cli/internal/daemon/client.go @@ -347,6 +347,43 @@ func (c *ChatClient) Interrupt(ctx context.Context) (bool, error) { return data.Interrupted, nil } +// ContextWindow reads the model's context window from the session's last +// prepared request. It is nil when neither a catalog nor the configuration +// knows it, or when no request has been prepared yet. +func (c *ChatClient) ContextWindow(ctx context.Context) (*int, error) { + reqCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.agentURL("/context"), nil) + if err != nil { + return nil, err + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, c.parseResponseError(res) + } + body, err := readBounded(res.Body, 1024*1024) + if err != nil { + return nil, err + } + var data struct { + Window *int `json:"context_window_tokens"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + if data.Window != nil && *data.Window <= 0 { + return nil, nil + } + return data.Window, nil +} + func (c *ChatClient) GetStatus(ctx context.Context) (*AgentStatus, error) { reqCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() @@ -687,6 +724,8 @@ func (c *ChatClient) Stream(ctx context.Context, onOpen func(), onEvent func(Str if cursorVal, hasCursor := rawMap["cursor"]; hasCursor { if cursorNum, ok := cursorVal.(float64); ok { if eventsArr, ok := rawMap["events"].([]any); ok { + // a reset opens the snapshot, and the snapshot is the rest of its page + snapshot := false for _, evRaw := range eventsArr { evMap, ok := evRaw.(map[string]any) if !ok { @@ -704,6 +743,7 @@ func (c *ChatClient) Stream(ctx context.Context, onOpen func(), onEvent func(Str } if itemType == "reset" { + snapshot = true if err := onEvent(StreamEvent{Type: EventReset}); err != nil { return err } @@ -791,6 +831,7 @@ func (c *ChatClient) Stream(ctx context.Context, onOpen func(), onEvent func(Str } if ev := parseStreamEvent(evMap); ev != nil { + ev.Replayed = snapshot if err := onEvent(*ev); err != nil { return err } diff --git a/cli/internal/daemon/client_test.go b/cli/internal/daemon/client_test.go index cadc631..91d968d 100644 --- a/cli/internal/daemon/client_test.go +++ b/cli/internal/daemon/client_test.go @@ -771,3 +771,55 @@ func TestStreamProgressCallbackErrorBackpressure(t *testing.T) { t.Fatalf("expected %v, got %v", expectedErr, err) } } + +func TestContextWindowReadsThePreparedRequest(t *testing.T) { + bodies := map[string]string{ + "known": `{"state":"ready","model":"m","context_window_tokens":1048576,"sections":[]}`, + "unknown": `{"state":"ready","model":"m","sections":[]}`, + "pending": `{"state":"pending","reason":"no request yet"}`, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/sessions/"), "/context") + _, _ = w.Write([]byte(bodies[id])) + })) + defer server.Close() + for id, want := range map[string]int{"known": 1048576, "unknown": 0, "pending": 0} { + window, err := NewChatClient(ChatClientOptions{BaseURL: server.URL, AgentID: id}).ContextWindow(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := 0; window != nil { + got = *window + if got != want { + t.Fatalf("%s: window %d, want %d", id, got, want) + } + } else if want != 0 { + t.Fatalf("%s: no window, want %d", id, want) + } + } +} + +func TestSnapshotEventsAreMarkedReplayed(t *testing.T) { + pages := formatPage(2, []any{ + map[string]any{"type": "reset"}, + map[string]any{"type": "thinking", "text": "earlier"}, + }) + formatPage(3, []any{ + map[string]any{"type": "thinking", "text": "now"}, + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(pages)) + })) + defer server.Close() + client := NewChatClient(ChatClientOptions{BaseURL: server.URL, AgentID: "session"}) + replayed := map[string]bool{} + _ = client.Stream(context.Background(), nil, func(event StreamEvent) error { + if event.Type == EventThinking { + replayed[event.Text] = event.Replayed + } + return nil + }) + if !replayed["earlier"] || replayed["now"] { + t.Fatalf("only the snapshot after a reset is history: %v", replayed) + } +} diff --git a/cli/internal/daemon/events.go b/cli/internal/daemon/events.go index 3a11f0e..dcb310b 100644 --- a/cli/internal/daemon/events.go +++ b/cli/internal/daemon/events.go @@ -149,6 +149,9 @@ type StreamEvent struct { ToolResult string `json:"result,omitempty"` ToolTrace *ToolTrace `json:"trace,omitempty"` Usage *Usage `json:"usage,omitempty"` + // Replayed marks an event from the transcript snapshot that follows a + // reset: history, which says nothing about what the session does now. + Replayed bool `json:"-"` } func sanitizeControlRunes(s string) string { diff --git a/cli/internal/tui/app.go b/cli/internal/tui/app.go index 4dcb00c..f6393c8 100644 --- a/cli/internal/tui/app.go +++ b/cli/internal/tui/app.go @@ -12,7 +12,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) type AppState int @@ -1003,10 +1002,10 @@ func (m AppModel) View() string { var prefix strings.Builder if m.State == AppStateSessionPicker { if m.Notice != "" { - prefix.WriteString(DefaultStyles.Dim.Render(m.Notice) + "\n") + prefix.WriteString(DefaultStyles.Faint.Render(m.Notice) + "\n") } if m.Error != "" { - prefix.WriteString(DefaultStyles.Error.Foreground(lipgloss.Color("1")).Render(m.Error) + "\n") + prefix.WriteString(DefaultStyles.Error.Render(m.Error) + "\n") } } diff --git a/cli/internal/tui/bounded_history.go b/cli/internal/tui/bounded_history.go index 71e47c4..549e8e2 100644 --- a/cli/internal/tui/bounded_history.go +++ b/cli/internal/tui/bounded_history.go @@ -16,6 +16,8 @@ const ( EntryNote EntryKind = "note" EntryError EntryKind = "error" EntryCompacted EntryKind = "compacted" + // EntryTurnEnd closes a turn with its outcome, length, and tool count. + EntryTurnEnd EntryKind = "turn_end" ) type HistoryEntry struct { @@ -30,8 +32,23 @@ type HistoryEntry struct { ToolTrace *daemon.ToolTrace Evicted int SizeBytes int64 + Mood mood + ElapsedMs int64 + Tools int + // Pending is a message of yours the daemon has not echoed back yet. + Pending pending } +// pending is how far a message of yours has got: sent and waiting for the +// daemon, or accepted and queued behind the running turn. +type pending int + +const ( + settled pending = iota + sending + queued +) + func (e *HistoryEntry) ComputeSize() int64 { size := int64(len(e.Speaker) + len(e.Text) + len(e.ClientID) + len(e.ToolName) + len(e.ToolResult) + 64) if e.ToolArgs != nil { diff --git a/cli/internal/tui/capability_pages.go b/cli/internal/tui/capability_pages.go index cc4edaa..201a3e4 100644 --- a/cli/internal/tui/capability_pages.go +++ b/cli/internal/tui/capability_pages.go @@ -489,7 +489,7 @@ func (m CapabilityPageModel) View() string { if m.Global { scope = "global default" } - rows := []string{"albedo /" + m.Kind + " · " + scope, ""} + rows := []string{titleRule(width, brand("albedo")+" "+DefaultStyles.Muted.Render("/"+m.Kind), DefaultStyles.Faint.Render(scope)), ""} if m.Error != "" { rows = append(rows, DefaultStyles.Error.Render(ansi.Truncate(m.Error, width, "…"))) } @@ -497,32 +497,36 @@ func (m CapabilityPageModel) View() string { rows = append(rows, DefaultStyles.Faint.Render(m.Notice)) } if m.Loading { - rows = append(rows, "loading "+heading+"…") + rows = append(rows, DefaultStyles.Faint.Render("loading "+heading+"…")) return strings.Join(rows, "\n") } if !m.ExtensionEnabled { - rows = append(rows, "extension off · E enable (restarts Python worker)") + rows = append(rows, DefaultStyles.Warning.Render("extension off")+DefaultStyles.Decor.Render(" · ")+keyHints(hint{"E", "enable (restarts Python worker)"})) } if len(m.Items) == 0 { - rows = append(rows, "no "+strings.ToLower(heading)+" found") + rows = append(rows, DefaultStyles.Faint.Render("no "+strings.ToLower(heading)+" found")) } listRows := max(1, m.Height-12) start := max(0, m.Cursor-listRows+1) for i := start; i < min(len(m.Items), start+listRows); i++ { item := m.Items[i] - label := "on " + label := DefaultStyles.Success.Render("on ") if !m.selectedEnabled(item) || m.Kind == "mcp" && item.Server.Enabled != nil && !*item.Server.Enabled { - label = "off" + label = DefaultStyles.Faint.Render("off") } mark := " " if i == m.Cursor { - mark = "› " + mark = selectBar() + " " } detail := "" if m.Kind == "mcp" { - detail = " · " + item.Detail + detail = DefaultStyles.Faint.Render(" · " + item.Detail) } - rows = append(rows, ansi.Truncate(mark+label+" "+item.Title+detail, width, "…")) + row := ansi.Truncate(mark+label+" "+item.Title+detail, width, "…") + if i == m.Cursor { + row = selectedLine(row, width) + } + rows = append(rows, row) } if m.Form != nil { rows = append(rows, "") @@ -531,9 +535,9 @@ func (m CapabilityPageModel) View() string { rows = append(rows, "connecting and reloading…") } } else if m.ConfirmExtension { - rows = append(rows, "", "enable extension and reload workers? enter confirm · esc cancel") + rows = append(rows, "", DefaultStyles.Warning.Render("enable extension and reload workers?")+" "+keyHints(hint{"enter", "confirm"}, hint{"esc", "cancel"})) } else if m.ConfirmDelete && len(m.Items) > 0 { - rows = append(rows, "", "delete MCP server "+m.Items[m.Cursor].ID+" and its stored credentials? enter confirm · any other key cancels") + rows = append(rows, "", DefaultStyles.Warning.Render("delete MCP server "+m.Items[m.Cursor].ID+" and its stored credentials?")+" "+keyHints(hint{"enter", "confirm"}, hint{"any other key", "cancels"})) } else if m.Saving { rows = append(rows, "saving and reloading…") } else { @@ -550,11 +554,11 @@ func (m CapabilityPageModel) View() string { detail += " · disabled in extensions.json · e enable" } } - rows = append(rows, "", ansi.Truncate(detail, width, "…")) + rows = append(rows, "", DefaultStyles.Faint.Render(ansi.Truncate(detail, width, "…"))) } - rows = append(rows, "", "↑↓ select · space toggle · s session · g global · r refresh · esc back") + rows = append(rows, "", keyHints(hint{"↑↓", "select"}, hint{"space", "toggle"}, hint{"s", "session"}, hint{"g", "global"}, hint{"r", "refresh"}, hint{"esc", "back"})) if m.Kind == "mcp" { - rows = append(rows, "n add server · enter edit · d delete") + rows = append(rows, keyHints(hint{"n", "add server"}, hint{"enter", "edit"}, hint{"d", "delete"})) } } if m.Height > 0 && len(rows) > m.Height { diff --git a/cli/internal/tui/chat.go b/cli/internal/tui/chat.go index f25b214..bffb1cd 100644 --- a/cli/internal/tui/chat.go +++ b/cli/internal/tui/chat.go @@ -5,7 +5,10 @@ import ( "context" "errors" "fmt" + "math" + "math/rand/v2" "regexp" + "slices" "strconv" "strings" "time" @@ -62,6 +65,15 @@ type ChatStatusMsg struct { Err error } +// ChatWindowMsg carries the context window for the model a usage event named. +type ChatWindowMsg struct { + SessionID string + Generation int64 + Model string + Tokens *int + Err error +} + type ChatStatusPollMsg struct { SessionID string Generation int64 @@ -117,6 +129,7 @@ type PendingUserTurn struct { Text string Image *daemon.ImageAttachment Queued bool + At int64 } const ( @@ -200,25 +213,33 @@ func wrapOrChunkLine(line string, width int) []string { } type ChatModel struct { - SessionID string - Generation int64 - AgentName string - Workspace string - Model string - Provider string - Client *daemon.ChatClient - History *BoundedHistory - Renderer TranscriptRenderer - Viewport viewport.Model - TextArea textarea.Model - CommandMenu CommandMenuModel - Flags DisplayFlags - Follow bool - TurnFailed bool - Stopping bool - Stopped bool - Status daemon.AgentStatus - Usage *daemon.Usage + SessionID string + Generation int64 + AgentName string + Workspace string + Model string + Provider string + Client *daemon.ChatClient + History *BoundedHistory + Renderer TranscriptRenderer + Viewport viewport.Model + TextArea textarea.Model + CommandMenu CommandMenuModel + Flags DisplayFlags + Follow bool + TurnFailed bool + Stopping bool + Stopped bool + Status daemon.AgentStatus + Usage *daemon.Usage + // window is the context window of windowModel, read once per model so + // the footer can say how full the context is. + window *int + windowModel *string + // stretch is the phase run the status face animates; moodSeed picks its + // animation. + stretch stretch + moodSeed int64 Glances []PageGlance AttachedImage *daemon.ImageAttachment Notice string @@ -263,6 +284,12 @@ type ChatModel struct { dragAnchor *Point dragHead Point + turn *openTurn + // userRows are the settled rows where your messages start, and + // settledOffset is how many notice rows sit above the settled rows. + userRows []int + settledOffset int + streamCtx context.Context streamCancel context.CancelFunc eventChan chan daemon.StreamEvent @@ -271,13 +298,13 @@ type ChatModel struct { func NewChatModel(session *daemon.Session, client *daemon.ChatClient) ChatModel { ta := textarea.New() ta.Placeholder = "" - ta.Prompt = "› " + ta.Prompt = promptMark ta.ShowLineNumbers = false ta.KeyMap.InsertNewline.SetKeys("enter", "ctrl+m", "alt+enter", "shift+enter") ta.SetHeight(1) - ta.FocusedStyle.Prompt = DefaultStyles.PromptBright + ta.FocusedStyle.Prompt = DefaultStyles.Prompt ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.Cursor.Style = lipgloss.NewStyle().Reverse(true) + ta.Cursor.Style = DefaultStyles.Cursor ta.Cursor.SetMode(cursor.CursorStatic) ta.Focus() @@ -331,9 +358,9 @@ func (m ChatModel) chromeRows() int { } notice := 0 if m.Notice != "" || m.ErrorNotice != "" || m.ExternalNotice != "" || m.ExternalError != "" { - notice = 1 + notice = 2 } - return menu + notice + min(3, len(m.pendingUsers)) + return menu + notice } func (m *ChatModel) SetSize(width, height int) { @@ -358,7 +385,6 @@ func (m *ChatModel) SetSize(width, height int) { transcriptWidth -= m.sidebarWidth() + 2 } m.Viewport.Width = transcriptWidth - m.Renderer.HeadingWidth = transcriptWidth m.Renderer.BodyWidth = min(100, available) m.Viewport.Height = max(1, height-6-chromeRows) m.TextArea.SetHeight(1) @@ -372,26 +398,22 @@ func (m *ChatModel) SetSize(width, height int) { func (m *ChatModel) rebuildSettledLines() { m.settledLines = nil m.settledLinesBytes = 0 + m.userRows = nil entries := m.History.Entries() - for _, entry := range entries { - block := m.Renderer.RenderEntry(entry, m.Flags, m.Viewport.Width) - if len(m.settledLines) > 0 { - m.appendSettledLines([]string{""}, false) - } - m.appendSettledLines(strings.Split(block, "\n"), entry.Kind == EntryUser || entry.Kind == EntryAssistant) + for i := range entries { + m.appendBlock(entries[:i], entries[i]) } } -func (m *ChatModel) appendSettledLines(rawLines []string, heading bool) { - for i, line := range rawLines { - width := m.Renderer.BodyWidth - if heading && i == 0 { - width = m.Viewport.Width - } - for _, chunk := range wrapOrChunkLine(line, width) { - m.settledLines = append(m.settledLines, chunk) - m.settledLinesBytes += int64(len(chunk)) - } +// appendBlock renders entry after the settled entries before it. +func (m *ChatModel) appendBlock(before []HistoryEntry, entry HistoryEntry) { + rows, head := m.Renderer.Block(before, entry, m.Flags) + if entry.Kind == EntryUser { + m.userRows = append(m.userRows, len(m.settledLines)+head) + } + for _, row := range rows { + m.settledLines = append(m.settledLines, row) + m.settledLinesBytes += int64(len(row)) } m.trimSettledLines() } @@ -412,6 +434,13 @@ func (m *ChatModel) trimSettledLines() { if dropped > 0 { m.droppedSettledLines += dropped m.scrollOffset = max(0, m.scrollOffset-dropped) + kept := m.userRows[:0] + for _, row := range m.userRows { + if row >= dropped { + kept = append(kept, row-dropped) + } + } + m.userRows = kept fresh := make([]string, len(m.settledLines)) for i := range m.settledLines { fresh[i] = strings.Clone(m.settledLines[i]) @@ -422,12 +451,9 @@ func (m *ChatModel) trimSettledLines() { } func (m *ChatModel) appendSettledEntry(entry HistoryEntry) { + before := m.History.Entries() + m.appendBlock(before, entry) m.History.Append(entry) - block := m.Renderer.RenderEntry(entry, m.Flags, m.Viewport.Width) - if len(m.settledLines) > 0 { - m.appendSettledLines([]string{""}, false) - } - m.appendSettledLines(strings.Split(block, "\n"), entry.Kind == EntryUser || entry.Kind == EntryAssistant) } func (m *ChatModel) settleActiveStream() { @@ -458,40 +484,41 @@ func (m *ChatModel) refreshViewportContent() int { } var allLines []string + indent := m.Renderer.rail(laneNone) if notice := m.History.TruncationNotice(); notice != "" { - allLines = append(allLines, m.Styles.Warning.Render(notice), "") + allLines = append(allLines, indent+m.Styles.Warning.Render(notice), "") } if m.droppedSettledLines > 0 { - allLines = append(allLines, m.Styles.Faint.Render(fmt.Sprintf("[%d scrollback lines truncated]", m.droppedSettledLines)), "") + allLines = append(allLines, indent+m.Styles.Faint.Render(fmt.Sprintf("[%d scrollback lines truncated]", m.droppedSettledLines)), "") } + m.settledOffset = len(allLines) allLines = append(allLines, m.settledLines...) + last, stacks := laneNone, false + if entries := m.History.Entries(); len(entries) > 0 { + last = laneOf(entries[len(entries)-1]) + stacks = Compact(entries[len(entries)-1], m.Flags) + } if m.activeKind != StreamKindNone && m.activeText != "" { - if len(m.settledLines) > 0 { - allLines = append(allLines, "") - } var kind EntryKind = EntryAssistant if m.activeKind == StreamKindThinking { kind = EntryThinking } activeEntry := HistoryEntry{Kind: kind, Speaker: m.AgentName, Text: m.activeText} - activeBlock := m.Renderer.RenderEntry(activeEntry, m.Flags, m.Viewport.Width) - for i, line := range strings.Split(activeBlock, "\n") { - width := m.Renderer.BodyWidth - if i == 0 { - width = m.Viewport.Width - } - allLines = append(allLines, wrapOrChunkLine(line, width)...) - } + rows, _ := m.Renderer.Block(m.History.Entries(), activeEntry, m.Flags) + allLines = append(allLines, rows...) + last, stacks = laneOf(activeEntry), false } if m.ToolProgressText != "" { - if len(allLines) > 0 { - allLines = append(allLines, "") + // the live row stacks under tool rows like the row it settles into + if len(allLines) > 0 && !stacks { + allLines = append(allLines, strings.TrimRight(m.Renderer.rail(joint(last, laneBusy)), " ")) } - allLines = append(allLines, m.renderProgress()) + allLines = append(allLines, m.Renderer.rail(laneBusy)+m.renderProgress()) } + allLines = append(allLines, m.pendingRows()...) totalLines := len(allLines) vpHeight := max(1, m.Viewport.Height) @@ -516,6 +543,62 @@ func (m *ChatModel) refreshViewportContent() int { return maxScroll } +// pendingRows are your messages the daemon has not echoed yet, greyed out at +// the end of the transcript where they will settle. Each follows whatever is +// live above it, so the rails and names join up as they will once settled. +func (m ChatModel) pendingRows() []string { + if len(m.pendingUsers) == 0 { + return nil + } + before := slices.Clone(m.History.Entries()) + if m.activeKind != StreamKindNone && m.activeText != "" { + before = append(before, HistoryEntry{Kind: EntryAssistant, Speaker: m.AgentName}) + } + if m.ToolProgressText != "" { + before = append(before, HistoryEntry{Kind: EntryTool, Speaker: m.AgentName}) + } + var rows []string + for _, p := range m.pendingUsers { + state := sending + if p.Queued { + state = queued + } + entry := HistoryEntry{Kind: EntryUser, Speaker: "You", Text: p.Text, Timestamp: p.At, Pending: state} + block, _ := m.Renderer.Block(before, entry, m.Flags) + rows = append(rows, block...) + before = append(before, entry) + } + return rows +} + +// jumpToYou scrolls to the start of your previous or next message. Past +// your newest message it follows the live transcript again. +func (m *ChatModel) jumpToYou(back bool) { + at := m.scrollOffset + if m.Follow { + at = m.scrollLimit + } + target := -1 + for _, row := range m.userRows { + row += m.settledOffset + if back && row < at { + target = row + } + if !back && row > at { + target = row + break + } + } + switch { + case target >= 0 && target < m.scrollLimit: + m.Follow = false + m.scrollOffset = target + case !back || target >= 0: + m.Follow = true + } + m.refreshViewportContent() +} + // scrollBy moves through the complete rendered transcript, including live output. func (m *ChatModel) scrollBy(rows int) { if rows < 0 { @@ -621,6 +704,19 @@ func (m ChatModel) statusCmd() tea.Cmd { } } +// windowCmd reads the context window when usage names a model whose window +// has not been read yet. +func (m ChatModel) windowCmd() tea.Cmd { + if m.Client == nil || m.Usage == nil || m.windowModel != nil && *m.windowModel == m.Usage.Model { + return nil + } + client, ctx, id, generation, model := m.Client, m.streamCtx, m.SessionID, m.Generation, m.Usage.Model + return func() tea.Msg { + tokens, err := client.ContextWindow(ctx) + return ChatWindowMsg{SessionID: id, Generation: generation, Model: model, Tokens: tokens, Err: err} + } +} + func (m ChatModel) statusPollCmd() tea.Cmd { id, generation := m.SessionID, m.Generation return tea.Tick(750*time.Millisecond, func(time.Time) tea.Msg { return ChatStatusPollMsg{SessionID: id, Generation: generation} }) @@ -739,6 +835,10 @@ func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { m.scrollBy(1) return m, nil } + if msg.Type == tea.KeyShiftUp || msg.Type == tea.KeyShiftDown { + m.jumpToYou(msg.Type == tea.KeyShiftUp) + return m, nil + } if msg.Type == tea.KeyCtrlHome { m.Follow = false m.scrollOffset = 0 @@ -777,14 +877,14 @@ func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { case tea.MouseMsg: firstRow := 2 if m.Notice != "" || m.ErrorNotice != "" || m.ExternalNotice != "" || m.ExternalError != "" { - firstRow++ + firstRow += 2 } point := func() Point { return Point{Row: max(0, min(m.Viewport.Height-1, msg.Y-firstRow)), Col: max(0, min(m.Viewport.Width, msg.X-m.padding()))} } if msg.Action == tea.MouseActionRelease && m.dragAnchor != nil { m.dragHead = point() - sel := Selection{Anchor: *m.dragAnchor, Head: m.dragHead} + sel := Selection{Anchor: *m.dragAnchor, Head: m.dragHead, Gutter: railWidth} m.dragAnchor = nil if !sel.IsEmpty() { lines := strings.Split(m.Viewport.View(), "\n") @@ -847,10 +947,14 @@ func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { } if msg.Err == nil && msg.Status != nil && msg.Revision == m.statusRevision { m.Status = *msg.Status + defer m.reseedMood() if !m.Status.Running || m.Status.Idle { m.Progress = nil m.ToolProgressText = "" m.settleActiveStream() + if m.turn != nil && m.turn.begun() { + m.closeTurn(false) + } m.refreshViewportContent() } } @@ -860,14 +964,23 @@ func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } - m.statusRevision++ + // only a live event outdates a status reply in flight + if !msg.Event.Replayed { + m.statusRevision++ + } m.handleStreamEvent(msg.Event) m.refreshViewportContent() if m.loginRequested { m.loginRequested = false return m, tea.Batch(m.waitForNextEvent(), func() tea.Msg { return ChatOpenLoginMsg{} }) } - return m, tea.Batch(m.waitForNextEvent(), m.startAnimation()) + return m, tea.Batch(m.waitForNextEvent(), m.startAnimation(), m.windowCmd()) + + case ChatWindowMsg: + if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Err == nil { + m.window, m.windowModel = msg.Tokens, &msg.Model + } + return m, nil case ChatClearCopyStatusMsg: if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Revision == m.copyStatusRevision { @@ -917,8 +1030,8 @@ func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { ti.SetValue(wsErr.Workspace) ti.Focus() ti.Prompt = "new workspace › " - ti.PromptStyle = m.Styles.PromptBright - ti.Cursor.Style = lipgloss.NewStyle().Reverse(true) + ti.PromptStyle = m.Styles.Prompt + ti.Cursor.Style = DefaultStyles.Cursor ti.Cursor.SetMode(cursor.CursorStatic) m.RecoveryInput = ti m.WorkspaceRecovery = &WorkspaceRecoveryState{ @@ -1110,8 +1223,10 @@ func (m *ChatModel) submitInput(input string, cmds *[]tea.Cmd) { m.pendingUsers = append(m.pendingUsers, PendingUserTurn{ Text: input, Image: img, + At: time.Now().UnixMilli(), }) m.isSending = true + m.reseedMood() m.sentHere = true m.Follow = true m.refreshViewportContent() @@ -1190,6 +1305,13 @@ func (m *ChatModel) handleSubmittedCommand(input string, cmds *[]tea.Cmd) { } func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { + defer m.reseedMood() + if evt.Replayed { + // the snapshot rebuilds the transcript; whether albedo is working + // now is for /status to say, so reopening a session never flashes + // the phase its last turn ended in + defer func(live daemon.AgentStatus) { m.Status = live }(m.Status) + } if evt.Type != daemon.EventToolProgress { m.Progress = nil } @@ -1214,6 +1336,7 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { m.Follow = true m.scrollOffset = 0 m.pendingUsers = nil + m.turn = nil case daemon.EventRetry: m.activeKind = StreamKindNone @@ -1255,9 +1378,22 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { ClientID: evt.ClientID, Timestamp: ts, } + // Your message starts a turn and closes the one before it. Another + // source only opens a turn when none is in flight. + opens := m.turn == nil || speaker == "You" + if opens { + m.closeTurn(false) + } m.appendSettledEntry(entry) + if opens { + if ts == 0 { + ts = time.Now().UnixMilli() + } + m.turn = &openTurn{start: ts, last: ts} + } case daemon.EventText: + m.turnIsLive() m.ToolProgressText = "" m.Status.Running = true m.Status.Idle = false @@ -1296,6 +1432,7 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { } case daemon.EventToolProgress: + m.turnIsLive() m.Progress = evt.Progress if evt.Progress != nil { m.settleActiveStream() @@ -1323,6 +1460,10 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry) + if m.turn != nil { + m.turn.tools++ + m.turn.touch(evt.Timestamp) + } case daemon.EventMessage: m.ToolProgressText = "" @@ -1349,6 +1490,9 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { Timestamp: ts, } m.appendSettledEntry(entry) + if m.turn != nil { + m.turn.touch(evt.Timestamp) + } case daemon.EventNote: m.settleActiveStream() @@ -1374,6 +1518,10 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry) + if m.turn != nil { + m.turn.failed = true + m.closeTurn(false) + } // The daemon ends errors that only a new sign-in can fix with "run /login". if m.sentHere && strings.HasSuffix(evt.Text, "run /login") { m.loginRequested = true @@ -1406,15 +1554,87 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { phase := daemon.PhaseResting m.Status.Phase = &phase - entry := HistoryEntry{ + if m.turn != nil { + m.closeTurn(true) + return + } + m.appendSettledEntry(HistoryEntry{ Kind: EntryNote, Text: "stopped by you", Timestamp: time.Now().UnixMilli(), - } - m.appendSettledEntry(entry) + }) + } +} + +// openTurn follows the turn in flight until its signoff. +type openTurn struct { + // start and last are daemon timestamps in milliseconds. + start, last int64 + tools int + // live marks a turn streamed to this client, which ends now rather + // than at its last replayed event. + live bool + failed bool +} + +func (t *openTurn) touch(ts *int64) { + if ts != nil && *ts > t.last { + t.last = *ts } } +// begun reports whether the turn has done anything, so an idle status +// that races its first event cannot sign it off early. +func (t *openTurn) begun() bool { + return t.live || t.tools > 0 || t.last > t.start +} + +func (m *ChatModel) turnIsLive() { + if m.turn != nil { + m.turn.live = true + } +} + +// stretch is one run of a phase: a thinking or replying spell, or one tool +// call, which keeps a single animation from start to end. +type stretch struct { + mood mood + call string +} + +// reseedMood draws a new animation when a new stretch starts, at random, so +// the same one may well come up twice. +func (m *ChatModel) reseedMood() { + now := stretch{mood: m.phaseMood()} + if m.Progress != nil { + now.call = m.Progress.CallID + } + if now != m.stretch { + m.stretch, m.moodSeed = now, rand.Int64() + } +} + +// closeTurn signs off the turn in flight, if there is one. +func (m *ChatModel) closeTurn(stopped bool) { + t := m.turn + if t == nil { + return + } + m.turn = nil + end := t.last + if t.live { + end = max(end, time.Now().UnixMilli()) + } + elapsed := end - t.start + m.appendSettledEntry(HistoryEntry{ + Kind: EntryTurnEnd, + Mood: outcome(t.failed, stopped, elapsed), + ElapsedMs: elapsed, + Tools: t.tools, + Timestamp: end, + }) +} + func formatUsage(u *daemon.Usage) string { if u == nil { return "—" @@ -1476,33 +1696,25 @@ func progressLabel(progress daemon.ToolProgress) string { return "running " + progress.Name } +// renderProgress is the live row for the tool call in flight. It is shaped +// like the tool row it settles into, and code being written shows its +// newest end. func (m ChatModel) renderProgress() string { if m.Progress == nil { return "" } - width := m.Renderer.BodyWidth - label := strings.Map(func(r rune) rune { - if r < 32 || r == 127 { - return ' ' + width := max(1, m.Renderer.BodyWidth-railWidth) + row := oneLine(m.ToolProgressText) + if code := m.Progress.Code; m.Progress.Phase == "generating" && code != nil { + if text := oneLine(code.Text); text != "" { + row += " · " + if room := width - ansi.StringWidth(row); room >= 12 && ansi.StringWidth(text) > room { + text = ansi.TruncateLeft(text, ansi.StringWidth(text)-room+1, "…") + } + row += text } - return r - }, m.ToolProgressText) - codeWidth := 0 - if m.Progress.Phase == "generating" && m.Progress.Code != nil { - labelWidth := min(ansi.StringWidth(label), max(0, (width-5)/2)) - codeWidth = max(0, width-2-labelWidth-3) - } - spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}[m.ProgressFrame%10] - prefix := spinner + " " - if width < 2 { - return "\x1b[36m" + spinner + "\x1b[0m" - } - label = ansi.Truncate(label, max(0, width-ansi.StringWidth(prefix)-max(0, codeWidth+3)), "…") - line := "\x1b[36m" + prefix + "\x1b[0m\x1b[37m" + label + "\x1b[0m" - if codeWidth > 0 { - line += "\x1b[90m · \x1b[0m\x1b[37m" + ansi.Truncate(m.Progress.Code.Text, codeWidth, "…") + "\x1b[0m" } - return line + return m.Styles.Faint.Render(ansi.Truncate(row, width, "…")) } func (m ChatModel) statusLine() string { @@ -1512,8 +1724,10 @@ func (m ChatModel) statusLine() string { if m.Stopping { return "stopping…" } + // an idle session says nothing unless verbose: the transcript already + // ends in the turn's signoff. if m.Stopped { - return "stopped" + return m.verbose("stopped") } if m.isSending || len(m.pendingUsers) > 0 && !m.Status.Running { return "preparing" @@ -1546,7 +1760,35 @@ func (m ChatModel) statusLine() string { if m.Status.Phase == nil { return "opening session…" } - return "ready" + return m.verbose("ready") +} + +func (m ChatModel) verbose(status string) string { + if m.Flags.Tools { + return status + } + return "" +} + +// phaseMood is the face class for the phase statusLine names. +func (m ChatModel) phaseMood() mood { + switch { + case m.Stopping: + return moodStopping + case m.isSending || len(m.pendingUsers) > 0 && !m.Status.Running: + return moodPreparing + case m.Progress != nil: + return moodWorking + case m.activeKind == StreamKindText: + return moodResponding + case m.Status.Phase != nil && *m.Status.Phase == daemon.PhaseTool: + return moodWorking + case m.Status.Phase != nil && *m.Status.Phase == daemon.PhaseCompacting: + return moodCompacting + case m.Status.Phase != nil && *m.Status.Phase == daemon.PhasePreparing: + return moodPreparing + } + return moodThinking } func truncateMiddle(text string, width int) string { @@ -1584,14 +1826,8 @@ func (m ChatModel) View() string { if glance != nil && width-100 < 26 { right = fmt.Sprintf("%s %d", glance.Title, len(glance.Rows)) + " " + right } - nameWidth := lipgloss.Width(m.AgentName) - workspaceWidth := max(1, width-nameWidth-2-lipgloss.Width(right)-2) - workspace = truncateMiddle(workspace, workspaceWidth) - header := m.Styles.Bold.Render(m.AgentName) + " " + m.Styles.Faint.Render(workspace) - if right != "" { - space := max(1, width-lipgloss.Width(header)-lipgloss.Width(right)) - header += strings.Repeat(" ", space) + m.Styles.Faint.Render(right) - } + room := width - lipgloss.Width("✦ "+m.AgentName+" on ") - lipgloss.Width(right) - 5 + header := titleRule(width, located(m.AgentName, truncateMiddle(homePath(workspace), max(1, room))), m.Styles.Faint.Render(right)) rows = append(rows, header, "") errorNotice := m.ExternalError if m.ErrorNotice != "" { @@ -1602,14 +1838,14 @@ func (m ChatModel) View() string { notice = m.Notice } if errorNotice != "" { - rows = append(rows, m.Styles.Error.Render("error: "+errorNotice)) + rows = append(rows, m.Renderer.errorRow(errorNotice), "") } else if notice != "" { - rows = append(rows, lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Render(notice)) + rows = append(rows, m.Styles.Faint.Render(notice), "") } view := m.Viewport.View() if m.History.Len() == 0 && m.activeText == "" && m.ToolProgressText == "" { - view = m.Styles.Faint.Render("what are we working on?") + view = m.Renderer.rail(laneNone) + m.Styles.Faint.Render("what are we working on?") } if m.sidebarWidth() > 0 { view = lipgloss.JoinHorizontal(lipgloss.Top, view, " ", m.renderGlances()) @@ -1620,27 +1856,15 @@ func (m ChatModel) View() string { } content = content[:min(len(content), m.Viewport.Height)] if m.dragAnchor != nil { - content = HighlightSelection(content, Selection{Anchor: *m.dragAnchor, Head: m.dragHead}) + content = HighlightSelection(content, Selection{Anchor: *m.dragAnchor, Head: m.dragHead, Gutter: railWidth}) } rows = append(rows, content...) - pendingRows := min(3, len(m.pendingUsers)) - for i := 0; i < pendingRows; i++ { - if i == 2 && len(m.pendingUsers) > 2 { - rows = append(rows, m.Styles.ChatWarning.Render(fmt.Sprintf("+%d more pending", len(m.pendingUsers)-2))) - break - } - label := "sending" - if m.pendingUsers[i].Queued { - label = fmt.Sprintf("queued %d", i+1) - } - rows = append(rows, m.Styles.ChatWarning.Render(label+" · "+strings.Join(strings.Fields(m.pendingUsers[i].Text), " "))) - } status := m.statusLine() - if m.Follow && m.animating() && m.Progress == nil && !m.TurnFailed && m.ErrorNotice == "" && m.ExternalError == "" { - spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}[m.ProgressFrame%10] - status = spinner + " " + status + // the face trails the text, so its frames never move anything + if m.animating() && !m.TurnFailed && m.ErrorNotice == "" && m.ExternalError == "" { + status = m.Styles.Faint.Render(status) + " " + m.Styles.Agent.Render(m.phaseMood().frame(m.moodSeed, m.ProgressFrame)) } - if !m.Follow { + if !m.Follow && m.Flags.Tools { status = fmt.Sprintf("history · %d rows below · pgdn", max(0, m.scrollLimit-m.scrollOffset)) } if m.AttachedImage != nil { @@ -1654,9 +1878,9 @@ func (m ChatModel) View() string { } else { rows = append(rows, m.Styles.Faint.Render(status)) } - rows = append(rows, m.Styles.Faint.Render(strings.Repeat("─", width))) + rows = append(rows, m.Styles.Decor.Render(strings.Repeat("─", width))) if m.WorkspaceRecovery != nil { - rows = append(rows, m.Styles.ChatWarning.Render("workspace not found: "+m.WorkspaceRecovery.Missing), m.RecoveryInput.View()) + rows = append(rows, m.Styles.Warning.Render("workspace not found: "+m.WorkspaceRecovery.Missing), m.RecoveryInput.View()) help := "enter confirms and retries · esc cancels" if m.WorkspaceRecovery.Saving { help = "updating workspace…" @@ -1707,7 +1931,7 @@ func (m ChatModel) composerView() string { if value == "" && m.waitingForInput() { after = m.Styles.Faint.Render(ansi.Truncate("waiting for user input", max(0, columns-1), "…")) } - return m.Styles.PromptBright.Render("› ") + before + lipgloss.NewStyle().Reverse(true).Render(block) + after + return promptLead() + before + m.Styles.Cursor.Render(block) + after } // waitingForInput reports an opened session with no turn in flight, so an @@ -1733,10 +1957,10 @@ func (m ChatModel) renderGlances() string { switch item.Tone { case ToneActive: mark = "●" - style = m.Styles.ChatSuccess + style = m.Styles.Success case ToneWarning: mark = "!" - style = m.Styles.ChatWarning + style = m.Styles.Warning case ToneMuted: mark = "✓" } @@ -1752,55 +1976,86 @@ func (m ChatModel) renderGlances() string { func (m ChatModel) renderFooter() string { width := m.chatWidth() - context, cache := "—", "—/—" + right, compact := m.contextStat() + modes := []hint{{"thinking", onOff(m.Flags.Thinking)}, {"verbose", onOff(m.Flags.Tools)}} + brief := []hint{{"t", onOff(m.Flags.Thinking)}, {"v", onOff(m.Flags.Tools)}} + commands := hint{"/", "commands"} + join := func(h ...[]hint) []hint { return slices.Concat(h...) } + candidates := []struct { + left []hint + right string + }{ + {join([]hint{commands, {"shift+↑↓", "turns"}, {"drag", "copy"}, {"ctrl+j", "diffs"}}, modes), right}, + {join([]hint{commands, {"drag", "copy"}, {"ctrl+j", "diffs"}}, modes), right}, + {join([]hint{commands, {"drag", "copy"}}, modes), right}, + {join([]hint{commands, {"ctrl+j", "diffs"}}, brief), compact}, + {join([]hint{commands}, brief), compact}, + {[]hint{{"/", ""}}, compact}, + } + for _, c := range candidates { + left := keyHints(c.left...) + if gap := width - lipgloss.Width(left) - lipgloss.Width(c.right); gap > 0 { + return left + strings.Repeat(" ", gap) + c.right + } + } + return ansi.Truncate(keyHints(commands), width, "…") +} + +// contextStat is how much of the prompt was cached and how full the context +// is: "392k/396k cached (38%)", with a shorter form for narrow footers. The +// share turns yellow near the window, where compaction starts. +func (m ChatModel) contextStat() (full, short string) { usage := m.Usage - if usage != nil && (usage.Model == "" || m.Model == "" || usage.Model == m.Model) { - total := usage.TotalTokens - if total == nil && usage.PromptTokens != nil { - fallback := *usage.PromptTokens - if usage.CompletionTokens != nil { - fallback += *usage.CompletionTokens - } - total = &fallback - } - if total != nil && *total >= 0 { - context = formatTokens(*total) - } - prompt, cached := "—", "—" - if usage.PromptTokens != nil && *usage.PromptTokens >= 0 { - prompt = formatGroupedCount(*usage.PromptTokens) + if usage == nil || usage.Model != "" && m.Model != "" && usage.Model != m.Model { + return m.Styles.Faint.Render("—/— cached"), m.Styles.Faint.Render("—/—") + } + count := func(n *int) string { + if n == nil || *n < 0 { + return "—" } - if usage.CachedPromptTokens != nil && *usage.CachedPromptTokens >= 0 { - cached = formatGroupedCount(*usage.CachedPromptTokens) + return shortCount(*n) + } + ratio := m.Styles.Muted.Render(count(usage.CachedPromptTokens) + "/" + count(usage.PromptTokens)) + share := "" + if total := contextTokens(usage); total != nil && m.window != nil && m.windowModel != nil && *m.windowModel == usage.Model { + pct := int(math.Round(100 * float64(*total) / float64(*m.window))) + style := m.Styles.Faint + if pct >= 90 { + style = m.Styles.Warning } - cache = cached + "/" + prompt + share = " " + style.Render(fmt.Sprintf("(%d%%)", pct)) } - modes := fmt.Sprintf("thinking %s · verbose %s", onOff(m.Flags.Thinking), onOff(m.Flags.Tools)) - brief := fmt.Sprintf("t:%s v:%s", onOff(m.Flags.Thinking), onOff(m.Flags.Tools)) - right := "ctx " + context + " · cached " + cache - compact := context + " · " + cache - candidates := [][2]string{ - {"/ commands · drag to copy · ctrl+j diffs · " + modes, right}, - {"/ commands · drag to copy · " + modes, right}, - {"/ commands · ctrl+j diffs · " + brief, compact}, - {"/ commands · drag copy · " + brief, compact}, - {"/ commands · " + brief, compact}, - {"/", compact}, + return ratio + m.Styles.Faint.Render(" cached") + share, ratio + share +} + +// contextTokens is what the context holds after a reply: the prompt and the +// completion, when the provider did not report a total. +func contextTokens(u *daemon.Usage) *int { + if u.TotalTokens != nil && *u.TotalTokens >= 0 { + return u.TotalTokens } - for _, pair := range candidates { - if lipgloss.Width(pair[0])+lipgloss.Width(pair[1]) < width { - return m.Styles.Footer.Render(pair[0] + strings.Repeat(" ", width-lipgloss.Width(pair[0])-lipgloss.Width(pair[1])) + pair[1]) - } + if u.PromptTokens == nil || *u.PromptTokens < 0 { + return nil } - return m.Styles.Footer.Render(string([]rune("/ commands")[:min(width, len([]rune("/ commands")))])) + total := *u.PromptTokens + if u.CompletionTokens != nil && *u.CompletionTokens > 0 { + total += *u.CompletionTokens + } + return &total } -func formatGroupedCount(count int) string { - digits := strconv.Itoa(count) - for i := len(digits) - 3; i > 0; i -= 3 { - digits = digits[:i] + "," + digits[i:] +// shortCount keeps token counts to about three digits: 812, 4.2k, 392k, 1.2m. +func shortCount(n int) string { + switch { + case n < 1_000: + return strconv.Itoa(n) + case n < 10_000: + return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0") + "k" + case n < 999_500: + return fmt.Sprintf("%dk", int(math.Round(float64(n)/1_000))) + default: + return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "m" } - return digits } func onOff(enabled bool) string { diff --git a/cli/internal/tui/commands.go b/cli/internal/tui/commands.go index d8ec0c1..f0f5b1e 100644 --- a/cli/internal/tui/commands.go +++ b/cli/internal/tui/commands.go @@ -147,12 +147,11 @@ func (m CommandMenuModel) View(input string) string { for i := start; i < end; i++ { cmd := matches[i] isSel := (i == idx) - line := cmd.Name + " " + cmd.Description + line := " " + cmd.Name + " " + m.Styles.Faint.Render(cmd.Description) if isSel { - b.WriteString(m.Styles.Selected.Render(line)) - } else { - b.WriteString(line) + line = selectedLine(selectBar()+" "+cmd.Name+" "+m.Styles.Faint.Render(cmd.Description), 0) } + b.WriteString(line) b.WriteString("\n") } return b.String() diff --git a/cli/internal/tui/context_inspector.go b/cli/internal/tui/context_inspector.go index 552c96f..88fad4a 100644 --- a/cli/internal/tui/context_inspector.go +++ b/cli/internal/tui/context_inspector.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "github.com/charmbracelet/lipgloss" "math" "net/url" "strings" @@ -347,18 +346,18 @@ func contextCount(n int, unit string) string { func (m ContextInspectorModel) View() string { var b strings.Builder line := func(text string) { b.WriteString(text); b.WriteByte('\n') } - faint := func(text string) { line(m.Styles.Dim.Render(text)) } + faint := func(text string) { line(m.Styles.Faint.Render(text)) } if m.Detail != nil { sec, d := m.Detail.Section, m.Detail - line("albedo /context · " + sec.Label) + line(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/context"), m.Styles.Faint.Render(sec.Label))) faint(fmt.Sprintf("%s · page %d/%d · %s", sec.Source, d.Page+1, sec.Pages, contextCount(sec.ByteCount, "byte"))) if d.Error != "" { - line(inkRed.Render(d.Error)) + line(DefaultStyles.Error.Render(d.Error)) } else if d.Value == nil { faint("loading inspectable prepared content…") } else { if d.Value.Omitted != "" { - line(inkYellow.Render("omitted: " + d.Value.Omitted)) + line(DefaultStyles.Warning.Render("omitted: " + d.Value.Omitted)) } line("") rows := wrapContextContent(d.Value.Content, m.Width-4) @@ -370,22 +369,22 @@ func (m ContextInspectorModel) View() string { line(row) } } - faint("↑↓ scroll · pgup/pgdn jump · ←→ page · esc sections") + line(keyHints(hint{"↑↓", "scroll"}, hint{"pgup/pgdn", "jump"}, hint{"←→", "page"}, hint{"esc", "sections"})) return strings.TrimSuffix(b.String(), "\n") } - line("albedo /context · prepared request") + line(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/context"), m.Styles.Faint.Render("prepared request"))) faint(inkWrap("read-only · durable transcript and request-only context are separate", m.Width)) if m.Error != "" { - line(inkRed.Render(m.Error)) + line(DefaultStyles.Error.Render(m.Error)) } if m.Loading && m.Snapshot == nil && m.Error == "" { faint("loading prepared request snapshot…") } else if m.Snapshot == nil { - faint("r retry · esc return to chat") + line(keyHints(hint{"r", "retry"}, hint{"esc", "return to chat"})) } else if m.Snapshot.State == "pending" { - line(inkYellow.Render("pending · no request has been prepared for this runtime session")) + line(DefaultStyles.Warning.Render("pending · no request has been prepared for this runtime session")) faint(m.Snapshot.Reason) - faint("r refresh · esc return to chat") + line(keyHints(hint{"r", "refresh"}, hint{"esc", "return to chat"})) } else { snap := m.Snapshot label := snap.Model @@ -427,17 +426,12 @@ func (m ContextInspectorModel) View() string { sec := snap.Sections[i] marker := " " if i == m.Cursor { - marker = ">" + marker = selectBar() } - label := fmt.Sprintf("%s %d. %s ", marker, i+1, sec.Label) - source := "· " + sec.Source + label := fmt.Sprintf("%s %d. %s ", marker, i+1, sec.Label) + DefaultStyles.Faint.Render("· "+sec.Source) if i == m.Cursor { - label = inkCyan.Render(label) - source = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Faint(true).Render(source) - } else { - source = lipgloss.NewStyle().Faint(true).Render(source) + label = selectedLine(label, m.Width) } - label += source line(label) available := "content unavailable" if sec.Pages > 0 { @@ -454,7 +448,7 @@ func (m ContextInspectorModel) View() string { if len(snap.Sections) > capacity { faint(fmt.Sprintf("showing %d–%d of %d sections", first+1, min(len(snap.Sections), first+capacity), len(snap.Sections))) } - faint(inkWrap("↑↓ select · enter inspect content · r refresh · esc return to chat", m.Width)) + line(inkWrap(keyHints(hint{"↑↓", "select"}, hint{"enter", "inspect content"}, hint{"r", "refresh"}, hint{"esc", "return to chat"}), m.Width)) } return strings.TrimSuffix(b.String(), "\n") } diff --git a/cli/internal/tui/extension_picker.go b/cli/internal/tui/extension_picker.go index 7c8b84a..2c8c15d 100644 --- a/cli/internal/tui/extension_picker.go +++ b/cli/internal/tui/extension_picker.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "github.com/charmbracelet/lipgloss" "strings" tea "github.com/charmbracelet/bubbletea" @@ -281,38 +280,39 @@ func (m ExtensionPickerModel) Update(msg tea.Msg) (ExtensionPickerModel, tea.Cmd func (m ExtensionPickerModel) View() string { var b strings.Builder + head := func(scope string) string { + return titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/extensions"), m.Styles.Faint.Render(scope)) + "\n" + } if m.Session { - b.WriteString("albedo /extensions · this session") - b.WriteString("\n") + b.WriteString(head("this session")) hint := "choices here apply to this session only · g global defaults" if m.NoGlobal { hint = "this daemon only supports per-session choices; restart it for global defaults" } - b.WriteString(m.Styles.Dim.Render(inkWrap(hint, m.Width))) + b.WriteString(m.Styles.Faint.Render(inkWrap(hint, m.Width))) } else { - b.WriteString("albedo /extensions · global defaults") - b.WriteString("\n") - b.WriteString(m.Styles.Dim.Render(inkWrap("every session without its own choice follows these · s this session only", m.Width))) + b.WriteString(head("global defaults")) + b.WriteString(m.Styles.Faint.Render(inkWrap("every session without its own choice follows these · s this session only", m.Width))) } b.WriteString("\n") - b.WriteString(inkYellow.Render(ansi.Wrap("changes reload workers and available plugins, bust prompt-cache reuse, and may reset unsavable python variables", max(1, m.Width), " "))) + b.WriteString(DefaultStyles.Warning.Render(ansi.Wrap("changes reload workers and available plugins, bust prompt-cache reuse, and may reset unsavable python variables", max(1, m.Width), " "))) b.WriteString("\n") if m.Error != "" { - b.WriteString(inkRed.Render(m.Error)) + b.WriteString(DefaultStyles.Error.Render(m.Error)) b.WriteString("\n") } if m.Loading { - b.WriteString(m.Styles.Dim.Render("loading extensions…\n")) + b.WriteString(m.Styles.Faint.Render("loading extensions…\n")) return b.String() } if len(m.Extensions) == 0 { if m.Error != "" { - b.WriteString(m.Styles.Dim.Render("r retry · esc return to chat\n")) + b.WriteString(keyHints(hint{"r", "retry"}, hint{"esc", "return to chat"}) + "\n") } else { - b.WriteString(m.Styles.Dim.Render("no matches\nno extensions installed for this session\n" + inkWrap("↑↓ select · enter/space toggle · esc return to chat", m.Width))) + b.WriteString(m.Styles.Faint.Render("no matches\nno extensions installed for this session") + "\n" + inkWrap(keyHints(hint{"↑↓", "select"}, hint{"enter/space", "toggle"}, hint{"esc", "return to chat"}), m.Width)) } return b.String() } @@ -332,13 +332,13 @@ func (m ExtensionPickerModel) View() string { } scope = " this session: " + state } - status := "off" + status := m.Styles.Faint.Render("off") if on { - status = inkGreen.Render("on ") + status = DefaultStyles.Success.Render("on ") } - lines[i] = status + " " + ext.Name + m.Styles.Dim.Render(scope) + lines[i] = status + " " + ext.Name + m.Styles.Faint.Render(scope) if ext.Description != "" { - lines[i] += lipgloss.NewStyle().Faint(true).Render(" " + ext.Description) + lines[i] += DefaultStyles.Faint.Render(" " + ext.Description) } } b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width, m.Styles)) @@ -357,7 +357,7 @@ func (m ExtensionPickerModel) View() string { if len(current.Plugins) > 0 { pluginsStr = strings.Join(current.Plugins, ", ") } - b.WriteString(m.Styles.Dim.Render("plugins: " + pluginsStr)) + b.WriteString(m.Styles.Faint.Render("plugins: " + pluginsStr)) b.WriteString("\n") caps := m.capabilitiesList(current) @@ -365,14 +365,14 @@ func (m ExtensionPickerModel) View() string { if len(caps) > 0 { capsStr = strings.Join(caps, " · ") } - b.WriteString(m.Styles.Dim.Render("capabilities: " + capsStr)) + b.WriteString(m.Styles.Faint.Render("capabilities: " + capsStr)) b.WriteString("\n") reqStr := "none" if len(current.Requires) > 0 { reqStr = strings.Join(current.Requires, ", ") } - b.WriteString(m.Styles.Dim.Render("requires: " + reqStr)) + b.WriteString(m.Styles.Faint.Render("requires: " + reqStr)) b.WriteString("\n") if m.Confirming { @@ -384,20 +384,20 @@ func (m ExtensionPickerModel) View() string { if on { actionWord = "disable" } - choice := "enter confirm" + choice := hint{"enter", "confirm"} if m.Error != "" { - choice = "enter retry" + choice = hint{"enter", "retry"} } var confirmMsg string switch { case m.Inheriting: - confirmMsg = fmt.Sprintf("drop this session's choice for %s and follow the global default? %s · esc cancel", current.Name, choice) + confirmMsg = fmt.Sprintf("drop this session's choice for %s and follow the global default?", current.Name) case m.Session: - confirmMsg = fmt.Sprintf("%s %s for this session only and reload its workers? %s · esc cancel", actionWord, current.Name, choice) + confirmMsg = fmt.Sprintf("%s %s for this session only and reload its workers?", actionWord, current.Name) default: - confirmMsg = fmt.Sprintf("%s %s for every session that follows the global default? %s · esc cancel", actionWord, current.Name, choice) + confirmMsg = fmt.Sprintf("%s %s for every session that follows the global default?", actionWord, current.Name) } - b.WriteString(inkYellow.Render(confirmMsg)) + b.WriteString(DefaultStyles.Warning.Render(confirmMsg) + " " + keyHints(choice, hint{"esc", "cancel"})) b.WriteString("\n") } } @@ -407,18 +407,18 @@ func (m ExtensionPickerModel) View() string { if m.Cursor < len(m.Extensions) { currName = m.Extensions[m.Cursor].Name } - b.WriteString(m.Styles.Dim.Render(fmt.Sprintf("reloading workers for %s…", currName))) + b.WriteString(m.Styles.Faint.Render(fmt.Sprintf("reloading workers for %s…", currName))) } else if m.Confirming { - b.WriteString(m.Styles.Dim.Render("waiting for confirmation")) + b.WriteString(m.Styles.Faint.Render("waiting for confirmation")) } else { - keys := "↑↓ select · enter/space toggle · s this session · esc return to chat" + keys := []hint{{"↑↓", "select"}, {"enter/space", "toggle"}, {"s", "this session"}, {"esc", "return to chat"}} if m.Session { - keys = "↑↓ select · enter/space toggle · x follow global · g global defaults · esc return to chat" + keys = []hint{{"↑↓", "select"}, {"enter/space", "toggle"}, {"x", "follow global"}, {"g", "global defaults"}, {"esc", "return to chat"}} if m.NoGlobal { - keys = "↑↓ select · enter/space toggle · esc return to chat" + keys = []hint{{"↑↓", "select"}, {"enter/space", "toggle"}, {"esc", "return to chat"}} } } - b.WriteString(m.Styles.Dim.Render(inkWrap(keys, m.Width))) + b.WriteString(inkWrap(keyHints(keys...), m.Width)) } return b.String() diff --git a/cli/internal/tui/ink.go b/cli/internal/tui/ink.go new file mode 100644 index 0000000..eaebb11 --- /dev/null +++ b/cli/internal/tui/ink.go @@ -0,0 +1,242 @@ +package tui + +import ( + "fmt" + "math" + "os" + "regexp" + "strconv" +) + +// ink is the transcript's brightness ramp. Each step is mixed from the +// terminal's own text and background colors to a contrast target, so the +// ramp keeps its spacing on any theme instead of trusting palette slots. +type ink struct { + // code steps down from prose for inline and fenced code. + code string + // secondary is text read at a glance: tool rows, timings, notices. + secondary string + // decor is structure that is seen, not read: list markers, gutters. + decor string + // busy is albedo's rail color while it works in tools. + busy string + // codeBg tints the cells under inline code, so a span keeps prose + // contrast and still reads as code. + codeBg string + // surface lifts a selected row off the background. + surface string + // added and removed are the diff row tints. + added, removed string + // brandFrom and brandTo are the ends of the brand gradient. + brandFrom, brandTo string +} + +// APCA contrast targets (Lc) for each step: about 60 suits content text, +// 45 text read at a glance, and 15 to 30 marks that are not read. +// The code tint is a luminance step from the background, measured as a +// contrast ratio with the flare term so a black background gets a larger lift +// than a gray one. Dark themes tint toward the palette's blue, light themes +// step toward the text, since a blue wash on a light background reads as a +// marker highlight. +const ( + darkCodeBgRatio = 1.3 + lightCodeBgRatio = 1.65 + surfaceRatio = 1.25 + diffRatio = 1.35 +) + +const ( + codeLc = 64 + secondaryLc = 45 + decorLc = 22 + busyLc = 30 +) + +type rgb struct{ r, g, b float64 } + +func (c rgb) hex() string { + b := func(v float64) int { return int(math.Round(math.Max(0, math.Min(1, v)) * 255)) } + return "#" + strconv.FormatInt(int64(1<<24|b(c.r)<<16|b(c.g)<<8|b(c.b)), 16)[1:] +} + +func (c rgb) mix(to rgb, t float64) rgb { + return rgb{c.r + (to.r-c.r)*t, c.g + (to.g-c.g)*t, c.b + (to.b-c.b)*t} +} + +// apca is the magnitude of the APCA 0.0.98G lightness contrast of text on bg. +func apca(text, bg rgb) float64 { + y := func(c rgb) float64 { + v := 0.2126729*math.Pow(c.r, 2.4) + 0.7151522*math.Pow(c.g, 2.4) + 0.0721750*math.Pow(c.b, 2.4) + if v < 0.022 { + v += math.Pow(0.022-v, 1.414) + } + return v + } + yt, yb := y(text), y(bg) + var s float64 + if yb > yt { + s = (math.Pow(yb, 0.56) - math.Pow(yt, 0.57)) * 1.14 + } else { + s = (math.Pow(yb, 0.65) - math.Pow(yt, 0.62)) * 1.14 + } + if math.Abs(s) < 0.1 { + return 0 + } + return (math.Abs(s) - 0.027) * 100 +} + +// toward mixes from into bg until it reaches the target contrast. A color +// already under the target stays as it is. +func toward(from, bg rgb, target float64) rgb { + if apca(from, bg) <= target { + return from + } + lo, hi := 0.0, 1.0 + for range 24 { + mid := (lo + hi) / 2 + if apca(from.mix(bg, mid), bg) > target { + lo = mid + } else { + hi = mid + } + } + return from.mix(bg, lo) +} + +// luminance is relative luminance as the WCAG contrast ratio defines it. +func luminance(c rgb) float64 { + lin := func(v float64) float64 { + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) + } + return 0.2126*lin(c.r) + 0.7152*lin(c.g) + 0.0722*lin(c.b) +} + +// ratio is the WCAG contrast ratio between two colors. +func ratio(a, b rgb) float64 { + la, lb := luminance(a)+0.05, luminance(b)+0.05 + return max(la, lb) / min(la, lb) +} + +// step mixes bg toward to until the two differ by the given contrast ratio, +// or reaches to when it cannot. +func step(bg, to rgb, target float64) rgb { + if ratio(bg, to) <= target { + return to + } + lo, hi := 0.0, 1.0 + for range 24 { + mid := (lo + hi) / 2 + if ratio(bg, bg.mix(to, mid)) < target { + lo = mid + } else { + hi = mid + } + } + return bg.mix(to, hi) +} + +// termColors is what the terminal reported. A missing color is nil, and +// palette holds only the entries that were reported. +type termColors struct { + fg, bg *rgb + palette map[int]rgb +} + +// The palette entries the theme mixes from. +const ( + ansiRed = 1 + ansiGreen = 2 + ansiBlue = 4 + ansiMagenta = 5 + ansiCyan = 6 +) + +var queriedPalette = []int{ansiRed, ansiGreen, ansiBlue, ansiMagenta, ansiCyan} + +// minTrustLc is the least contrast a reported text color may have with the +// reported background. Below it the reply describes something other than +// what is on screen, like a multiplexer that answers with black for colors +// it does not know, and the palette fallbacks are safer. +const minTrustLc = 60 + +func mixInk(c termColors) (ink, bool) { + if c.fg == nil || c.bg == nil || apca(*c.fg, *c.bg) < minTrustLc { + return ink{}, false + } + fg, bg := *c.fg, *c.bg + out := ink{ + code: toward(fg, bg, codeLc).hex(), + secondary: toward(fg, bg, secondaryLc).hex(), + decor: toward(fg, bg, decorLc).hex(), + } + out.surface = step(bg, fg, surfaceRatio).hex() + blue, hasBlue := c.palette[ansiBlue] + switch { + case luminance(bg) > luminance(fg): + out.codeBg = step(bg, fg, lightCodeBgRatio).hex() + case hasBlue: + out.codeBg = step(bg, blue, darkCodeBgRatio).hex() + default: + out.codeBg = step(bg, fg, darkCodeBgRatio).hex() + } + if magenta, ok := c.palette[ansiMagenta]; ok && apca(magenta, bg) >= busyLc { + out.busy = toward(magenta, bg, busyLc).hex() + } + if red, ok := c.palette[ansiRed]; ok { + out.removed = step(bg, red, diffRatio).hex() + } + if green, ok := c.palette[ansiGreen]; ok { + out.added = step(bg, green, diffRatio).hex() + } + // the brand runs from your color toward violet, halfway between the + // palette's blue and magenta + cyan, hasCyan := c.palette[ansiCyan] + magenta, hasMagenta := c.palette[ansiMagenta] + if hasCyan && hasBlue && hasMagenta { + out.brandFrom, out.brandTo = cyan.hex(), blue.mix(magenta, 0.5).hex() + } + return out, true +} + +var oscColor = regexp.MustCompile(`\x1b\](10|11|4;(\d+));rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})`) + +// parseColors reads OSC 10, 11 and 4 replies, whose channels carry one to +// four hex digits each. +func parseColors(reply string) termColors { + c := termColors{palette: map[int]rgb{}} + for _, m := range oscColor.FindAllStringSubmatch(reply, -1) { + channel := func(h string) float64 { + v, _ := strconv.ParseUint(h, 16, 16) + return float64(v) / float64(uint64(1)<<(4*len(h))-1) + } + col := &rgb{channel(m[3]), channel(m[4]), channel(m[5])} + switch m[1] { + case "10": + c.fg = col + case "11": + c.bg = col + default: + index, _ := strconv.Atoi(m[2]) + c.palette[index] = *col + } + } + return c +} + +var transcriptInk ink + +// DetectInk asks the terminal for its colors and mixes the transcript's +// ramp from them. It must run before the TUI owns the terminal's input. +func DetectInk() { + reply := queryColors() + detected, ok := mixInk(parseColors(reply)) + if path := os.Getenv("ALBEDO_INK_DEBUG"); path != "" { + _ = os.WriteFile(path, fmt.Appendf(nil, "reply %q\ntrusted %v\nink %+v\n", reply, ok, detected), 0o644) + } + if ok { + useInk(detected) + } +} diff --git a/cli/internal/tui/ink_other.go b/cli/internal/tui/ink_other.go new file mode 100644 index 0000000..c61ebc9 --- /dev/null +++ b/cli/internal/tui/ink_other.go @@ -0,0 +1,5 @@ +//go:build !(darwin || dragonfly || freebsd || linux || netbsd || openbsd) + +package tui + +func queryColors() string { return "" } diff --git a/cli/internal/tui/ink_test.go b/cli/internal/tui/ink_test.go new file mode 100644 index 0000000..767f899 --- /dev/null +++ b/cli/internal/tui/ink_test.go @@ -0,0 +1,93 @@ +package tui + +import ( + "math" + "testing" +) + +func hexColor(t *testing.T, h string) rgb { + c := parseColors("\x1b]10;rgb:" + h[1:3] + "/" + h[3:5] + "/" + h[5:7] + "\x07").fg + if c == nil { + t.Fatalf("could not parse %s", h) + } + return *c +} + +func TestParseColorReplies(t *testing.T) { + c := parseColors("\x1b]10;rgb:cdcd/d6d6/f4f4\x1b\\\x1b]11;rgb:1e/1e/2e\x07\x1b]4;4;rgb:8989/b4b4/fafa\x1b\\\x1b]4;5;rgb:f5f5/c2c2/e7e7\x1b\\\x1b[?62;22c") + if _, blue := c.palette[ansiBlue]; c.fg == nil || c.bg == nil || !blue || len(c.palette) != 2 { + t.Fatalf("missing colors: %+v", c) + } + if got := c.fg.hex(); got != "#cdd6f4" { + t.Fatalf("fg %s", got) + } + if got := c.bg.hex(); got != "#1e1e2e" { + t.Fatalf("bg %s", got) + } + if got := parseColors("\x1b[?62;22c"); got.fg != nil || got.bg != nil { + t.Fatal("a terminal that only answers device attributes reports no colors") + } +} + +func TestInkHitsItsContrastTargets(t *testing.T) { + themes := map[string][2]string{ + "mocha": {"#cdd6f4", "#1e1e2e"}, + "latte": {"#4c4f69", "#eff1f5"}, + "black": {"#ffffff", "#000000"}, + } + for name, theme := range themes { + fg, bg := hexColor(t, theme[0]), hexColor(t, theme[1]) + mixed, ok := mixInk(termColors{fg: &fg, bg: &bg}) + if !ok { + t.Fatal("colors were reported") + } + for _, step := range []struct { + hex string + target float64 + }{{mixed.code, codeLc}, {mixed.secondary, secondaryLc}, {mixed.decor, decorLc}} { + if got := apca(hexColor(t, step.hex), bg); math.Abs(got-step.target) > 1.5 { + t.Errorf("%s: %s has Lc %.1f, want %v", name, step.hex, got, step.target) + } + } + } + soft, bg := hexColor(t, "#b3bbd7"), hexColor(t, "#1e1e2e") + if mixed, _ := mixInk(termColors{fg: &soft, bg: &bg}); mixed.code != soft.hex() { + t.Fatalf("text already under a target keeps its color, got %s", mixed.code) + } + black := hexColor(t, "#000000") + if _, ok := mixInk(termColors{fg: &black, bg: &bg}); ok { + t.Fatal("a text color barely apart from the background is not trusted") + } + if _, ok := mixInk(termColors{}); ok { + t.Fatal("no reply leaves the palette fallbacks in place") + } +} + +func TestCodeTintSteps(t *testing.T) { + cases := []struct { + name string + fg, bg, blue string + to string + want float64 + }{ + {"black bg tints toward blue", "#cdd6f4", "#000000", "#89b4fa", "#89b4fa", darkCodeBgRatio}, + {"mocha tints toward blue", "#cdd6f4", "#1e1e2e", "#89b4fa", "#89b4fa", darkCodeBgRatio}, + {"latte steps toward the text", "#4c4f69", "#eff1f5", "#1e66f5", "#4c4f69", lightCodeBgRatio}, + {"no blue reported steps toward the text", "#cdd6f4", "#1e1e2e", "", "#cdd6f4", darkCodeBgRatio}, + } + for _, tc := range cases { + fg, bg := hexColor(t, tc.fg), hexColor(t, tc.bg) + colors := termColors{fg: &fg, bg: &bg, palette: map[int]rgb{}} + if tc.blue != "" { + colors.palette[ansiBlue] = hexColor(t, tc.blue) + } + mixed, _ := mixInk(colors) + tint := hexColor(t, mixed.codeBg) + if got := ratio(bg, tint); math.Abs(got-tc.want) > 0.03 { + t.Errorf("%s: %s is %.2f:1 from the background, want %v", tc.name, mixed.codeBg, got, tc.want) + } + if want := step(bg, hexColor(t, tc.to), tc.want).hex(); mixed.codeBg != want { + t.Errorf("%s: tint %s, want %s stepped toward %s", tc.name, mixed.codeBg, want, tc.to) + } + } +} diff --git a/cli/internal/tui/ink_unix.go b/cli/internal/tui/ink_unix.go new file mode 100644 index 0000000..8422618 --- /dev/null +++ b/cli/internal/tui/ink_unix.go @@ -0,0 +1,64 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package tui + +import ( + "os" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/x/term" + "golang.org/x/sys/unix" +) + +// queryColors asks for the text and background colors and the palette entries the theme mixes from, then asks +// for the device attributes every terminal answers, so a terminal that +// ignores the color queries ends the read instead of stalling it. +func queryColors() string { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return "" + } + defer tty.Close() + fd := tty.Fd() + state, err := term.MakeRaw(fd) + if err != nil { + return "" + } + defer term.Restore(fd, state) //nolint:errcheck + query := "\x1b]10;?\x1b\\\x1b]11;?\x1b\\" + for _, i := range queriedPalette { + query += "\x1b]4;" + strconv.Itoa(i) + ";?\x1b\\" + } + if _, err := tty.WriteString(query + "\x1b[c"); err != nil { + return "" + } + var reply strings.Builder + deadline := time.Now().Add(300 * time.Millisecond) + buf := make([]byte, 256) + for { + left := time.Until(deadline) + if left <= 0 { + return reply.String() + } + tv := unix.NsecToTimeval(int64(left)) + var ready unix.FdSet + ready.Set(int(fd)) + n, err := unix.Select(int(fd)+1, &ready, nil, nil, &tv) + if err == unix.EINTR { + continue + } + if err != nil || n == 0 { + return reply.String() + } + n, err = tty.Read(buf) + if err != nil { + return reply.String() + } + reply.Write(buf[:n]) + if da := strings.Index(reply.String(), "\x1b[?"); da >= 0 && strings.Contains(reply.String()[da:], "c") { + return reply.String() + } + } +} diff --git a/cli/internal/tui/login.go b/cli/internal/tui/login.go index a8a9dc2..f7f5eb3 100644 --- a/cli/internal/tui/login.go +++ b/cli/internal/tui/login.go @@ -14,7 +14,6 @@ import ( "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) @@ -871,19 +870,16 @@ func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { func (m LoginModel) View() string { var b strings.Builder - b.WriteString("albedo /login") - if m.Name != "" { - b.WriteString(" " + m.Name) - } + b.WriteString(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/login"), m.Styles.Faint.Render(m.Name))) b.WriteByte('\n') location := "model auth extensions · saved in " + config.HomeDir() if m.Width > 0 && ansi.StringWidth(location) > m.Width && ansi.StringWidth(config.HomeDir()) <= m.Width { location = "model auth extensions · saved in\n" + config.HomeDir() } - b.WriteString(m.Styles.Dim.Render(ansi.Wrap(location, m.Width, ""))) + b.WriteString(m.Styles.Faint.Render(ansi.Wrap(location, m.Width, ""))) b.WriteByte('\n') if m.Error != "" { - b.WriteString(m.Styles.Error.Foreground(lipgloss.Color("1")).Render(ansi.Wrap(m.Error, m.Width, ""))) + b.WriteString(m.Styles.Error.Render(ansi.Wrap(m.Error, m.Width, ""))) b.WriteByte('\n') } @@ -891,18 +887,18 @@ func (m LoginModel) View() string { case StepChoose: b.WriteString(m.ChoosePicker.View()) b.WriteByte('\n') - b.WriteString(m.Styles.Dim.Render(ansi.Wrap("enter select · d remove · esc cancel", m.Width, ""))) + b.WriteString(ansi.Wrap(keyHints(hint{"enter", "select"}, hint{"d", "remove"}, hint{"esc", "cancel"}), m.Width, "")) case StepProtocol: b.WriteString(m.ProtocolPicker.View()) case StepRemove: b.WriteString(m.ConfirmPicker.View()) case StepModels, StepOAuthModels: if m.Catalog == nil { - b.WriteString(m.Styles.Dim.Render("loading models…")) + b.WriteString(m.Styles.Faint.Render("loading models…")) break } if m.CatalogNote != "" { - b.WriteString(m.Styles.Dim.Render(m.CatalogNote)) + b.WriteString(m.Styles.Faint.Render(m.CatalogNote)) b.WriteByte('\n') } b.WriteString(m.ModelPicker.View()) @@ -914,19 +910,19 @@ func (m LoginModel) View() string { b.WriteString(status) b.WriteByte('\n') if m.SignInURL != "" { - b.WriteString(m.Styles.Dim.Render(ansi.Hardwrap(m.SignInURL, m.Width, true))) + b.WriteString(m.Styles.Faint.Render(ansi.Hardwrap(m.SignInURL, m.Width, true))) b.WriteByte('\n') } b.WriteString("callback url or code: ") b.WriteString(m.TextInput.View()) b.WriteByte('\n') - b.WriteString(m.Styles.Dim.Render(ansi.Hardwrap("browser callback completes automatically · enter pastes manually · esc cancel", m.Width, true))) + b.WriteString(ansi.Wrap(m.Styles.Faint.Render("browser callback completes automatically")+m.Styles.Decor.Render(" · ")+keyHints(hint{"enter", "pastes manually"}, hint{"esc", "cancel"}), m.Width, "")) case StepSaving: if m.Removing.Kind != "" { - b.WriteString(m.Styles.Dim.Render("removing " + m.Removing.Kind + "…")) + b.WriteString(m.Styles.Faint.Render("removing " + m.Removing.Kind + "…")) break } - b.WriteString(m.Styles.Dim.Render("saving provider…")) + b.WriteString(m.Styles.Faint.Render("saving provider…")) default: stepLabels := map[LoginStep]string{ StepName: "provider name", StepBaseURL: "api base url", @@ -935,17 +931,17 @@ func (m LoginModel) View() string { b.WriteString(stepLabels[m.Step] + ": ") b.WriteString(m.TextInput.View()) b.WriteByte('\n') - var hints []string + var hints []hint if m.Step == StepAPIKey && m.Draft.APIKey != "" { - hints = append(hints, "enter keeps the saved key") + hints = append(hints, hint{"enter", "keeps the saved key"}) } if m.Step == StepName { for _, login := range m.SignIns { - hints = append(hints, "use "+login.Provider+" to sign in") + hints = append(hints, hint{"", "use " + login.Provider + " to sign in"}) } } - hints = append(hints, "enter continue", "esc cancel") - b.WriteString(m.Styles.Dim.Render(ansi.Wrap(strings.Join(hints, " · "), m.Width, ""))) + hints = append(hints, hint{"enter", "continue"}, hint{"esc", "cancel"}) + b.WriteString(ansi.Wrap(keyHints(hints...), m.Width, "")) } return b.String() } diff --git a/cli/internal/tui/markdown.go b/cli/internal/tui/markdown.go index 1f969f4..f9b653f 100644 --- a/cli/internal/tui/markdown.go +++ b/cli/internal/tui/markdown.go @@ -3,7 +3,8 @@ package tui import ( "regexp" "strings" - "unicode" + + "github.com/charmbracelet/x/ansi" ) var ( @@ -11,277 +12,202 @@ var ( ulRegex = regexp.MustCompile(`^(\s*)[*\-+]\s+(.*)`) olRegex = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)`) hrRegex = regexp.MustCompile(`^(\*\*\*|---|___)$`) - - kwPy = map[string]bool{ - "if": true, "elif": true, "else": true, "for": true, "while": true, "in": true, "not": true, "and": true, - "or": true, "is": true, "import": true, "from": true, "as": true, "def": true, "class": true, "return": true, - "yield": true, "pass": true, "break": true, "continue": true, "try": true, "except": true, "finally": true, - "raise": true, "with": true, "lambda": true, "True": true, "False": true, "None": true, "async": true, "await": true, - } - - kwJS = map[string]bool{ - "if": true, "else": true, "for": true, "while": true, "do": true, "switch": true, "case": true, "break": true, - "continue": true, "return": true, "function": true, "class": true, "const": true, "let": true, "var": true, - "new": true, "import": true, "export": true, "default": true, "from": true, "async": true, "await": true, - "try": true, "catch": true, "finally": true, "throw": true, "null": true, "undefined": true, "true": true, "false": true, - } - - kwGo = map[string]bool{ - "break": true, "default": true, "func": true, "interface": true, "select": true, "case": true, "defer": true, - "go": true, "map": true, "struct": true, "chan": true, "else": true, "goto": true, "package": true, - "switch": true, "const": true, "fallthrough": true, "if": true, "range": true, "type": true, "continue": true, - "for": true, "import": true, "return": true, "var": true, "nil": true, "true": true, "false": true, - } ) -const ( - ansiReset = "\x1b[0m" - ansiBold = "\x1b[1m" - ansiDim = "\x1b[2m" - ansiItalic = "\x1b[3m" - ansiGreen = "\x1b[32m" - ansiCyan = "\x1b[36m" - ansiGray = "\x1b[90m" - ansiBYellow = "\x1b[93m" - ansiBBlue = "\x1b[94m" - ansiBMagenta = "\x1b[95m" - ansiBCyan = "\x1b[96m" -) - -func getKeywords(lang string) map[string]bool { - switch strings.ToLower(lang) { - case "python", "py": - return kwPy - default: - return kwJS - } +// hashComments are the languages whose line comments start with "#". +var hashComments = map[string]bool{ + "python": true, "py": true, "sh": true, "bash": true, "zsh": true, "shell": true, "nu": true, + "nix": true, "toml": true, "yaml": true, "yml": true, "ruby": true, "rb": true, "elixir": true, } +// HighlightCode marks only comments. Every other token keeps the code's own +// ink: highlighting has shown little measured benefit for comprehension, and +// a hue per token class drowns the few hues that carry meaning here. +// Comments stay readable because they are the notes in the code. func HighlightCode(code string, lang string) string { l := strings.ToLower(lang) if l == "" || l == "text" || l == "plain" || l == "txt" { return code } - keywords := getKeywords(l) - isPy := l == "python" || l == "py" - + hash := hashComments[l] + comment := faintInk() + ansiItalic var b strings.Builder chars := []rune(code) - n := len(chars) - i := 0 - - for i < n { + for i := 0; i < len(chars); { ch := chars[i] - - // Single line comments - if (!isPy && ch == '/' && i+1 < n && chars[i+1] == '/') || (isPy && ch == '#') { + if (hash && ch == '#') || (!hash && ch == '/' && i+1 < len(chars) && chars[i+1] == '/') { j := i - for j < n && chars[j] != '\n' { + for j < len(chars) && chars[j] != '\n' { j++ } - b.WriteString(ansiGray + ansiItalic) - b.WriteString(string(chars[i:j])) - b.WriteString(ansiReset) + b.WriteString(comment + string(chars[i:j]) + ansiReset + codeInk()) i = j continue } - - // Strings if ch == '"' || ch == '\'' || ch == '`' { - quote := ch j := i + 1 - for j < n { - if chars[j] == '\\' && j+1 < n { - j += 2 - continue - } - if chars[j] == quote { + for j < len(chars) && chars[j] != ch && chars[j] != '\n' { + if chars[j] == '\\' { j++ - break } j++ } - b.WriteString(ansiGreen) + j = min(j+1, len(chars)) b.WriteString(string(chars[i:j])) - b.WriteString(ansiReset) i = j continue } - - // Numbers - if unicode.IsDigit(ch) && (i == 0 || !unicode.IsLetter(chars[i-1])) { - j := i - for j < n && (unicode.IsDigit(chars[j]) || chars[j] == '.' || chars[j] == '_' || chars[j] == 'x' || chars[j] == 'b') { - j++ - } - b.WriteString(ansiBYellow) - b.WriteString(string(chars[i:j])) - b.WriteString(ansiReset) - i = j - continue - } - - // Identifiers / keywords - if unicode.IsLetter(ch) || ch == '_' { - j := i - for j < n && (unicode.IsLetter(chars[j]) || unicode.IsDigit(chars[j]) || chars[j] == '_') { - j++ - } - word := string(chars[i:j]) - if keywords[word] { - b.WriteString(ansiBMagenta + word + ansiReset) - } else if unicode.IsUpper(chars[i]) { - b.WriteString(ansiBYellow + word + ansiReset) - } else if j < n && chars[j] == '(' { - b.WriteString(ansiBBlue + word + ansiReset) - } else { - b.WriteString(word) - } - i = j - continue - } - b.WriteRune(ch) i++ } - return b.String() } -func RenderInlineMarkdown(text string) string { - // Inline code: `code` - res := text - for { - start := strings.Index(res, "`") - if start == -1 { - break - } - end := strings.Index(res[start+1:], "`") - if end == -1 { - break - } - end += start + 1 - code := res[start+1 : end] - res = res[:start] + ansiCyan + code + ansiReset + res[end+1:] +// A bold lead before a colon or dash labels a list item, and a wholly bold +// item is a label too. Models bold nearly every item, and a signal on every +// line stops signalling, so these render plain. +var ( + boldLeadThenMark = regexp.MustCompile(`^\*\*([^*]+)\*\*(\s*(?::|—|–|\s-\s))`) + boldLeadWithMark = regexp.MustCompile(`^\*\*([^*]+(?::|—|–))\*\*`) + boldWhole = regexp.MustCompile(`^\*\*([^*]+)\*\*\s*$`) +) + +func plainLead(item string) string { + switch { + case boldWhole.MatchString(item): + return boldWhole.ReplaceAllString(item, "$1") + case boldLeadThenMark.MatchString(item): + return boldLeadThenMark.ReplaceAllString(item, "$1$2") } + return boldLeadWithMark.ReplaceAllString(item, "$1") +} - // Bold: **text** - for { - start := strings.Index(res, "**") - if start == -1 { - break - } - end := strings.Index(res[start+2:], "**") - if end == -1 { - break - } - end += start + 2 - content := res[start+2 : end] - res = res[:start] + ansiBold + content + ansiReset + res[end+2:] +type emphasis struct{ strong, em, code bool } + +// sgr restates the whole style after a full reset, so a closing span never +// strips an enclosing one and wrapOrChunkLine can reopen it on a later row. +func (e emphasis) sgr() string { + s := ansiReset + if e.code { + s += inlineCodeInk() + } + if e.strong { + s += ansiBold + } + if e.em { + s += ansiItalic } + return s +} - // Italic: *text* - for { - start := strings.Index(res, "*") - if start == -1 { - break - } - end := strings.Index(res[start+1:], "*") - if end == -1 { - break +func inline(text string, base emphasis) string { + var b strings.Builder + strong, em := false, false + style := func() emphasis { return emphasis{strong: base.strong || strong, em: base.em || em} } + b.WriteString(base.sgr()) + for i := 0; i < len(text); { + rest := text[i:] + switch { + case rest[0] == '`': + if j := strings.IndexByte(rest[1:], '`'); j >= 0 { + code := style() + code.code = true + // dim ticks mark the edges and pad the tint, and copied text keeps its markdown + tick := decorInk() + "`" + b.WriteString(code.sgr() + tick + code.sgr() + rest[1:1+j] + tick + style().sgr()) + i += j + 2 + continue + } + case strings.HasPrefix(rest, "**"): + if strong || strings.Contains(rest[2:], "**") { + strong = !strong + b.WriteString(style().sgr()) + i += 2 + continue + } + case rest[0] == '*': + opens := !em && len(rest) > 1 && rest[1] != ' ' && strings.Contains(rest[2:], "*") + closes := em && text[i-1] != ' ' + if opens || closes { + em = opens + b.WriteString(style().sgr()) + i++ + continue + } } - end += start + 1 - content := res[start+1 : end] - res = res[:start] + ansiItalic + content + ansiReset + res[end+1:] + b.WriteByte(rest[0]) + i++ } + return b.String() + ansiReset +} - return res +func RenderInlineMarkdown(text string) string { + return inline(text, emphasis{}) } func RenderMarkdownAnsi(text string, width int) string { if width <= 0 { width = 80 } + mark := func(s string) string { return decorInk() + s + ansiReset } + // hang wraps body under its lead so continuation rows keep the list, + // quote, or code gutter instead of falling back to column zero. + hang := func(lead, cont, body string) []string { + rows := wrapOrChunkLine(body, max(1, width-ansi.StringWidth(lead))) + for i := range rows { + if i == 0 { + rows[i] = lead + rows[i] + } else { + rows[i] = cont + rows[i] + } + } + return rows + } lines := strings.Split(text, "\n") var out []string - i := 0 - - for i < len(lines) { + for i := 0; i < len(lines); i++ { line := lines[i] - - // Fenced code block if strings.HasPrefix(line, "```") { lang := strings.TrimSpace(strings.TrimPrefix(line, "```")) - var codeLines []string - i++ - for i < len(lines) && !strings.HasPrefix(lines[i], "```") { - codeLines = append(codeLines, lines[i]) - i++ + start := i + 1 + for i = start; i < len(lines) && !strings.HasPrefix(lines[i], "```"); i++ { } - if i < len(lines) { - i++ - } - - code := strings.Join(codeLines, "\n") - highlighted := HighlightCode(code, lang) - + code := strings.Join(lines[start:min(i, len(lines))], "\n") if lang != "" { - out = append(out, ansiDim+ansiCyan+"┌─ "+lang+ansiReset) + out = append(out, mark("┌─ "+lang)) } - for _, hl := range strings.Split(highlighted, "\n") { - out = append(out, ansiDim+"│"+ansiReset+" "+hl) + for _, hl := range strings.Split(HighlightCode(code, lang), "\n") { + out = append(out, hang(mark("│")+" ", mark("│")+" ", codeInk()+hl+ansiReset)...) } - out = append(out, ansiDim+"└"+ansiReset) + out = append(out, mark("└")) continue } - - // Headers - if hMatch := headerRegex.FindStringSubmatch(line); hMatch != nil { - title := RenderInlineMarkdown(hMatch[2]) - colors := []string{ansiBCyan, ansiCyan, ansiBBlue, ansiBBlue, ansiBMagenta, ansiBMagenta} - out = append(out, ansiBold+colors[len(hMatch[1])-1]+title+ansiReset) - i++ + if h := headerRegex.FindStringSubmatch(line); h != nil { + out = append(out, inline(h[2], emphasis{strong: true})) continue } - - // Horizontal rule if hrRegex.MatchString(strings.TrimSpace(line)) { - barLen := width - out = append(out, ansiDim+strings.Repeat("─", barLen)+ansiReset) - i++ + out = append(out, mark(strings.Repeat("─", width))) continue } - - // Bullet list - if ulMatch := ulRegex.FindStringSubmatch(line); ulMatch != nil { - indent := ulMatch[1] - item := RenderInlineMarkdown(ulMatch[2]) - out = append(out, indent+ansiDim+"•"+ansiReset+" "+item) - i++ + if ul := ulRegex.FindStringSubmatch(line); ul != nil { + out = append(out, hang(ul[1]+mark("•")+" ", ul[1]+" ", inline(plainLead(ul[2]), emphasis{}))...) continue } - - // Numbered list - if olMatch := olRegex.FindStringSubmatch(line); olMatch != nil { - indent := olMatch[1] - num := olMatch[2] - item := RenderInlineMarkdown(olMatch[3]) - out = append(out, indent+ansiDim+num+"."+ansiReset+" "+item) - i++ + if ol := olRegex.FindStringSubmatch(line); ol != nil { + lead := ol[1] + ol[2] + ". " + out = append(out, hang(ol[1]+mark(ol[2]+".")+" ", strings.Repeat(" ", len(lead)), inline(plainLead(ol[3]), emphasis{}))...) continue } - - // Blockquote - if strings.HasPrefix(line, "> ") { - quote := RenderInlineMarkdown(strings.TrimPrefix(line, "> ")) - out = append(out, ansiDim+"│"+ansiReset+" "+ansiItalic+quote+ansiReset) - i++ + if quote, ok := strings.CutPrefix(line, "> "); ok { + out = append(out, hang(mark("│")+" ", mark("│")+" ", inline(quote, emphasis{}))...) continue } - - out = append(out, RenderInlineMarkdown(line)) - i++ + if line == "" { + out = append(out, "") + continue + } + out = append(out, inline(line, emphasis{})) } - return strings.Join(out, "\n") } diff --git a/cli/internal/tui/mcp_form.go b/cli/internal/tui/mcp_form.go index 10d72d0..ea97d5e 100644 --- a/cli/internal/tui/mcp_form.go +++ b/cli/internal/tui/mcp_form.go @@ -267,11 +267,11 @@ func (f *mcpForm) view(width int) []string { if f.Editing != "" { title = "edit " + f.Editing } - rows := []string{title} + rows := []string{DefaultStyles.Bold.Render(title)} for i, key := range f.fields() { mark := " " if i == f.Focus { - mark = "› " + mark = promptLead() } var value string if key == fieldTransport { @@ -290,20 +290,25 @@ func (f *mcpForm) view(width int) []string { } rows = append(rows, ansi.Truncate(mark+padRight(mcpFieldLabels[key], 13)+value, width, "…")) } - hint := "tab/↑↓ move · enter next · ctrl+s save · esc cancel" + keys := []hint{{"tab/↑↓", "move"}, {"enter", "next"}, {"ctrl+s", "save"}, {"esc", "cancel"}} + note := "" switch f.current() { case fieldTransport: if f.Editing == "" { - hint = "←→ http or stdio · " + hint + keys = append([]hint{{"←→", "http or stdio"}}, keys...) } case fieldToken: - hint = "optional · sent as Authorization: Bearer · " + hint + note = "optional · sent as Authorization: Bearer" case fieldHeader, fieldValue: - hint = "optional custom header, stored privately · " + hint + note = "optional custom header, stored privately" case fieldEnv: - hint = "optional, stored privately · " + hint + note = "optional, stored privately" } - return append(rows, "", hint) + line := keyHints(keys...) + if note != "" { + line = DefaultStyles.Faint.Render(note) + DefaultStyles.Decor.Render(" · ") + line + } + return append(rows, "", line) } func padRight(s string, n int) string { diff --git a/cli/internal/tui/model_picker.go b/cli/internal/tui/model_picker.go index 6323901..7a46e84 100644 --- a/cli/internal/tui/model_picker.go +++ b/cli/internal/tui/model_picker.go @@ -355,18 +355,18 @@ func (m ModelPickerModel) View() string { if titleProvider == "" { titleProvider = "session provider" } - b.WriteString(fmt.Sprintf("albedo /model · %s", titleProvider)) + b.WriteString(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/model"), m.Styles.Faint.Render(titleProvider))) b.WriteString("\n") - b.WriteString(m.Styles.Dim.Render(inkWrap("changes this idle session and the default for new sessions", m.Width))) + b.WriteString(m.Styles.Faint.Render(inkWrap("changes this idle session and the default for new sessions", m.Width))) b.WriteByte('\n') if m.Error != "" { - b.WriteString(inkRed.Render(m.Error)) + b.WriteString(DefaultStyles.Error.Render(m.Error)) b.WriteByte('\n') } if m.Saving { - b.WriteString(m.Styles.Dim.Render("switching model…")) + b.WriteString(m.Styles.Faint.Render("switching model…")) return b.String() } @@ -378,17 +378,17 @@ func (m ModelPickerModel) View() string { if m.EnteringManual { b.WriteString(m.ManualInput.View()) b.WriteByte('\n') - b.WriteString(m.Styles.Dim.Render("enter choose · esc cancel")) + b.WriteString(keyHints(hint{"enter", "choose"}, hint{"esc", "cancel"})) return b.String() } if m.LoadingModels { - b.WriteString(m.Styles.Dim.Render("loading models…")) + b.WriteString(m.Styles.Faint.Render("loading models…")) b.WriteByte('\n') } if m.CatalogNote != "" { - b.WriteString(m.Styles.Dim.Render(inkWrap(m.CatalogNote, m.Width))) + b.WriteString(m.Styles.Faint.Render(inkWrap(m.CatalogNote, m.Width))) b.WriteByte('\n') } diff --git a/cli/internal/tui/moods.go b/cli/internal/tui/moods.go new file mode 100644 index 0000000..18446ab --- /dev/null +++ b/cli/internal/tui/moods.go @@ -0,0 +1,124 @@ +package tui + +import "fmt" + +// A mood is a class of moment in a turn. Each class owns its own faces, so a +// face always means something: what albedo is doing, or how a turn ended. +type mood string + +const ( + moodPreparing mood = "preparing" + moodThinking mood = "thinking" + moodResponding mood = "responding" + moodWorking mood = "working" + moodCompacting mood = "compacting" + moodStopping mood = "stopping" + + moodDone mood = "done" + moodQuick mood = "quick" + moodLong mood = "long" + moodTired mood = "tired" + moodFailed mood = "failed" + moodStopped mood = "stopped" +) + +// Faces avoid combining marks and right-to-left letters, which terminals +// disagree on. +// +// A phase owns a pool of animations and each turn plays one of them. A face +// keeps its parentheses in place across frames: only eyes, mouth, and what +// follows the face move. +var animations = map[mood][][]string{ + moodPreparing: {{"(・・ )", "( ・・)"}}, + moodThinking: { + {"( ˘ω˘ )", "( ˘ω˘ ).", "( ˘ω˘ )..", "( ˘ω˘ )..."}, + {"(・ω・ )", "( ・ω・)", "(・ω・ )?", "( ・ω・)?"}, + {"(´-ω-`)", "(´・ω・`)", "(´-ω-`)", "(´・ω・`)!"}, + }, + moodResponding: { + {"(ˊᗜˋ )", "(ˊ-ˋ )", "( ˊᗜˋ)", "( ˊ-ˋ)"}, + {"(˶ᵔ ᗜ ᵔ˶)", "(˶ᵔ ᵕ ᵔ˶)"}, + {"(・ᗜ・)", "(・-・)", "(・ᗜ・)ノ", "(・-・)ノ"}, + }, + moodWorking: { + {"(˶•ᴗ•)カタ", "(˶•ᴗ•)カタカタ", "(˶•ᴗ•)カタカタカタ"}, + {"(・_・ )", "( ・_・)", "(・ᴗ・ )✧"}, + {"(っ•ᴗ•)っ・", "(っ•ᴗ•)っ ・", "(っ•ᴗ•)っ ・"}, + {"ヽ(・ω・)ノ", "ヾ(・ω・)ノ"}, + {"(๑•ᴗ•)", "(๑•ᴗ•)✧", "(๑•ᴗ•) ✧"}, + }, + moodCompacting: {{"(>_<)", "(>.<)"}}, + moodStopping: {{"(・_・;)"}}, +} + +// An outcome owns a pool of faces and each turn shows one of them. +var faces = map[mood][]string{ + moodDone: {"(˶ᵔ ᵕ ᵔ˶)", "(๑˃ᴗ˂)", "ヽ(・∀・)ノ", "(ᵔᴥᵔ)", "(っ˘ω˘ς)"}, + moodQuick: {"(・ω・)ノ", "(`・ω・´)", "(^_−)☆"}, + moodLong: {"(ง ˃ᴗ˂)ง", "(๑˃ᴗ˂)✧", "(ˊᗜˋ*)✧"}, + moodTired: {"( ̄ー ̄;)ゞ", "(・ω・;)ゞ", "( ˘ᴗ˘ )フゥ", "(´-ω-`)zzZ", "_(:3 」∠)_"}, + moodFailed: {"(╥﹏╥)", "(。•︿•。)", "(っ- ‸ – ς)"}, + moodStopped: {"(・_・;)", "(°ロ°)", "( ̄□ ̄;)"}, +} + +// pick chooses from n by seed, salted by the mood so one turn's picks for +// different moods do not move in step. +func (m mood) pick(seed int64, n int) int { + return int(fnv1a(uint64(seed), string(m)) % uint64(n)) +} + +// face picks a stable face for seed, so a rebuilt transcript keeps its faces. +func (m mood) face(seed int64) string { + set := faces[m] + return set[m.pick(seed, len(set))] +} + +// framesPerFace slows faces against the 80ms spinner tick, about 0.5s each. +const framesPerFace = 6 + +// frame is the turn's animation at spinner tick. +func (m mood) frame(seed int64, tick int) string { + pool := animations[m] + set := pool[m.pick(seed, len(pool))] + return set[(tick/framesPerFace)%len(set)] +} + +// outcome classes a finished turn by how it ended and how long it took. +func outcome(failed, stopped bool, elapsedMs int64) mood { + switch { + case stopped: + return moodStopped + case failed: + return moodFailed + case elapsedMs > 10*60_000: + return moodTired + case elapsedMs >= 3*60_000: + return moodLong + case elapsedMs > 0 && elapsedMs < 4_000: + return moodQuick + } + return moodDone +} + +func formatElapsed(ms int64) string { + s := ms / 1000 + switch { + case s < 60: + return fmt.Sprintf("%ds", s) + case s < 3600: + return fmt.Sprintf("%dm %ds", s/60, s%60) + } + return fmt.Sprintf("%dh %dm", s/3600, s/60%60) +} + +// formatGap is how long you were away, in the largest whole unit. +func formatGap(ms int64) string { + m := ms / 60_000 + switch { + case m < 60: + return fmt.Sprintf("%dm later", m) + case m < 48*60: + return fmt.Sprintf("%dh later", m/60) + } + return fmt.Sprintf("%dd later", m/(24*60)) +} diff --git a/cli/internal/tui/page_view.go b/cli/internal/tui/page_view.go index 59a13b6..6f3ce62 100644 --- a/cli/internal/tui/page_view.go +++ b/cli/internal/tui/page_view.go @@ -532,17 +532,17 @@ func (m PageViewModel) Update(msg tea.Msg) (PageViewModel, tea.Cmd) { func (m PageViewModel) View() string { var b strings.Builder line := func(text string) { b.WriteString(text); b.WriteByte('\n') } - faint := func(text string) { line(m.Styles.Dim.Render(text)) } - title := "albedo " + m.Command - if m.Doc != nil && m.Doc.Summary != "" { - title += " · " + m.Doc.Summary + faint := func(text string) { line(m.Styles.Faint.Render(text)) } + summary := "" + if m.Doc != nil { + summary = m.Styles.Faint.Render(m.Doc.Summary) } - line(title) + line(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render(m.Command), summary)) if m.Error != "" { - line(inkRed.Render(m.Error)) + line(DefaultStyles.Error.Render(m.Error)) } if m.Notice != "" && m.Error == "" { - line(inkBrightCyan.Render(m.Notice)) + line(m.Styles.Faint.Render(m.Notice)) } if m.Doc == nil { if m.Error != "" { @@ -561,9 +561,9 @@ func (m PageViewModel) View() string { var style lipgloss.Style switch item.Tone { case ToneActive: - style = inkGreen + style = DefaultStyles.Success case ToneWarning: - style = inkYellow + style = DefaultStyles.Warning case ToneMuted: style = m.Styles.Faint default: @@ -571,7 +571,7 @@ func (m PageViewModel) View() string { } rows[i] = style.Render(fmt.Sprintf("%-9s", item.Badge)) + " " + item.Text if item.ID != item.Text { - rows[i] += lipgloss.NewStyle().Faint(true).Render(" #" + item.ID) + rows[i] += DefaultStyles.Faint.Render(" #" + item.ID) } } line(selectableRows(rows, m.currentIndex(), m.Height, m.Height, m.Width, m.Styles)) @@ -592,14 +592,14 @@ func (m PageViewModel) View() string { } switch m.Mode { case modeConfirm: - line(inkYellow.Render(act.Label + actionTarget + "? enter confirm · esc cancel")) + line(DefaultStyles.Warning.Render(act.Label + actionTarget + "? enter confirm · esc cancel")) case modeChoice: var choice strings.Builder - choice.WriteString(inkBrightCyan.Render(act.Label + target + " › ")) + choice.WriteString(m.Styles.Prompt.Render(act.Label+target) + " " + promptLead()) for i, opt := range act.Options { label := " " + opt + " " if i == m.ChoiceIndex { - label = m.Styles.Selected.Render(label) + label = selectedLine(label, 0) } choice.WriteString(label) } @@ -609,33 +609,33 @@ func (m PageViewModel) View() string { if prompt == "" { prompt = act.Label } - b.WriteString(inkBrightCyan.Render(act.Label + actionTarget + " · " + prompt + " › ")) + b.WriteString(m.Styles.Prompt.Render(act.Label+actionTarget+" · "+prompt) + " " + promptLead()) line(m.TextInput.View()) } } - var hints []string + var hints []hint switch { case m.Busy: - hints = []string{"working…"} + hints = []hint{{"working…", ""}} case m.Mode == modeBrowse: if len(m.Doc.Rows) > 1 { - hints = append(hints, "↑↓ select") + hints = append(hints, hint{"↑↓", "select"}) } for _, act := range m.Doc.Actions { if !act.Row || row != nil { - hints = append(hints, act.Key+" "+act.Label) + hints = append(hints, hint{act.Key, act.Label}) } } - hints = append(hints, "esc return to chat") + hints = append(hints, hint{"esc", "return to chat"}) case m.Mode == modeText: - hints = []string{"enter save · esc cancel"} + hints = []hint{{"enter", "save"}, {"esc", "cancel"}} case m.Mode == modeChoice: - hints = []string{"←→ choose · enter apply · esc cancel"} + hints = []hint{{"←→", "choose"}, {"enter", "apply"}, {"esc", "cancel"}} } - footer := strings.Join(hints, " · ") + footer := keyHints(hints...) if m.Width > 0 { footer = ansi.Truncate(footer, m.Width, "…") } - faint(footer) + line(footer) return strings.TrimSuffix(b.String(), "\n") } diff --git a/cli/internal/tui/pending_test.go b/cli/internal/tui/pending_test.go new file mode 100644 index 0000000..b4f8dff --- /dev/null +++ b/cli/internal/tui/pending_test.go @@ -0,0 +1,47 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "albedo/cli/internal/daemon" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +// A message you send shows at once as a greyed-out copy where it will +// settle, not as a "sending" row under the transcript. +func TestPendingMessageIsAGreyedOutCopyInTheTranscript(t *testing.T) { + lipgloss.SetColorProfile(termenv.ANSI256) + t.Cleanup(func() { lipgloss.SetColorProfile(termenv.Ascii) }) + m := NewChatModel(&daemon.Session{ID: "s"}, nil) + m.Client = daemon.NewChatClient(daemon.ChatClientOptions{BaseURL: "http://127.0.0.1:1", AgentID: "s"}) + m.SetSize(80, 30) + m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Speaker: "albedo", Text: "ready", Timestamp: time.Now().UnixMilli() - 1000}) + var cmds []tea.Cmd + m.submitInput("hello there", &cmds) + + view := ansi.Strip(m.View()) + if strings.Contains(view, "sending") || !strings.Contains(ansi.Strip(m.Viewport.View()), "│ hello there") { + t.Fatalf("the message should be in the transcript, not in a sending row:\n%s", view) + } + rows := m.pendingRows() + if len(rows) != 3 || strings.TrimSpace(ansi.Strip(rows[1])) != "│ you" || strings.TrimSpace(ansi.Strip(rows[2])) != "│ hello there" { + t.Fatalf("pending rows: %q", plainRows(rows)) + } + + // the echo replaces the copy in place + pendingView := strings.TrimRight(ansi.Strip(m.Viewport.View()), " \n") + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Source: "chat", Text: "hello there", ClientID: m.Client.ClientID()}) + m.refreshViewportContent() + if len(m.pendingUsers) != 0 || len(m.pendingRows()) != 0 { + t.Fatal("the echo should settle the pending copy") + } + settled := strings.TrimRight(ansi.Strip(m.Viewport.View()), " \n") + if settled != pendingView { + t.Fatalf("settling moved the transcript:\n%s", settled) + } +} diff --git a/cli/internal/tui/picker.go b/cli/internal/tui/picker.go index 2bc8ce4..bc83918 100644 --- a/cli/internal/tui/picker.go +++ b/cli/internal/tui/picker.go @@ -1,7 +1,6 @@ package tui import ( - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "strings" @@ -105,7 +104,8 @@ func (m *PickerModel) applyFilter() { } } -// selectableRows shares Ink's bottom-anchored list window and full-row inverse marker. +// selectableRows is a bottom-anchored list window with the selected row on +// the selection surface. func inkWrap(text string, width int) string { if width <= 0 { return text @@ -113,12 +113,6 @@ func inkWrap(text string, width int) string { return ansi.Wrap(text, width, " ") } -var inkRed = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) -var inkYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) -var inkGreen = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) -var inkCyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) -var inkBrightCyan = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - func selectableRows(lines []string, selected, height, limit, width int, styles Styles) string { if height <= 0 { height = 24 @@ -129,20 +123,20 @@ func selectableRows(lines []string, selected, height, limit, width int, styles S available := max(1, min(limit, height-8)) first := min(max(0, selected-available+1), max(0, len(lines)-available)) if len(lines) == 0 { - return styles.Dim.Render("no matches") + return styles.Faint.Render("no matches") } var b strings.Builder for i := first; i < min(len(lines), first+available); i++ { prefix := " " if i == selected { - prefix = "> " + prefix = selectBar() + " " } line := prefix + lines[i] if width > 0 { line = ansi.Truncate(line, width, "…") } if i == selected { - line = "\x1b[7m" + strings.ReplaceAll(line, "\x1b[0m", "\x1b[0m\x1b[7m") + "\x1b[0m" + line = selectedLine(line, width) } if i > first { b.WriteByte('\n') @@ -155,7 +149,7 @@ func selectableRows(lines []string, selected, height, limit, width int, styles S func pickerRow(item PickerItem, styles Styles) string { line := item.Label if item.Detail != "" { - line += lipgloss.NewStyle().Faint(true).Render(" " + item.Detail) + line += DefaultStyles.Faint.Render(" " + item.Detail) } return line } @@ -217,12 +211,10 @@ func (m PickerModel) View() string { b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width, m.Styles)) b.WriteByte('\n') if m.WithSearch { - b.WriteString(m.Styles.Dim.Render("search: ")) + b.WriteString(promptLead()) b.WriteString(m.SearchInput.View()) - } else { - b.WriteString(m.Styles.Selected.Render(" ")) } b.WriteByte('\n') - b.WriteString(m.Styles.Dim.Render("↑↓ select · enter choose · esc cancel")) + b.WriteString(keyHints(hint{"↑↓", "select"}, hint{"enter", "choose"}, hint{"esc", "cancel"})) return b.String() } diff --git a/cli/internal/tui/replay_test.go b/cli/internal/tui/replay_test.go new file mode 100644 index 0000000..3a2c896 --- /dev/null +++ b/cli/internal/tui/replay_test.go @@ -0,0 +1,32 @@ +package tui + +import ( + "testing" + + "albedo/cli/internal/daemon" +) + +// Opening a session replays its transcript. The replay is history, so it +// must not claim albedo is thinking; only /status and live events say that. +func TestReplayedSnapshotLeavesTheStatusAlone(t *testing.T) { + m := NewChatModel(&daemon.Session{ID: "s"}, nil) + m.SetSize(80, 30) + send := func(e daemon.StreamEvent) { + next, _ := m.Update(ChatStreamEventMsg{SessionID: m.SessionID, Generation: m.Generation, Event: e}) + m = next + } + revision := m.statusRevision + send(daemon.StreamEvent{Type: daemon.EventReset}) + send(daemon.StreamEvent{Type: daemon.EventUser, Source: "chat", Text: "hi", Replayed: true}) + send(daemon.StreamEvent{Type: daemon.EventThinking, Text: "hmm", Replayed: true}) + if m.Status.Running || m.statusLine() == "thinking" { + t.Fatalf("a replayed thought marked the session running: %+v, %q", m.Status, m.statusLine()) + } + if m.statusRevision != revision+1 { + t.Fatalf("replayed events outdated the status reply in flight: revision %d → %d", revision, m.statusRevision) + } + send(daemon.StreamEvent{Type: daemon.EventThinking, Text: "live"}) + if !m.Status.Running || m.statusLine() != "thinking" { + t.Fatalf("a live thought should still mark it running: %q", m.statusLine()) + } +} diff --git a/cli/internal/tui/selection.go b/cli/internal/tui/selection.go index 99ce062..204c9ee 100644 --- a/cli/internal/tui/selection.go +++ b/cli/internal/tui/selection.go @@ -14,6 +14,9 @@ type Point struct { type Selection struct { Anchor Point Head Point + // Gutter is the decoration at the start of every row, which neither + // the highlight nor the copied text includes. + Gutter int } func (s Selection) IsEmpty() bool { return s.Anchor == s.Head } @@ -55,9 +58,9 @@ func SelectedText(lines []string, sel Selection) string { } var rows []string for row := max(0, start.Row); row <= end.Row && row < len(lines); row++ { - from, to := 0, ansi.StringWidth(lines[row]) + from, to := sel.Gutter, ansi.StringWidth(lines[row]) if row == start.Row { - from = start.Col + from = max(from, start.Col) } if row == end.Row { to = end.Col @@ -75,16 +78,16 @@ func HighlightSelection(lines []string, sel Selection) []string { start, end := sel.Normalized() out := append([]string(nil), lines...) for row := max(0, start.Row); row <= end.Row && row < len(out); row++ { - from, to := 0, ansi.StringWidth(out[row]) + from, to := sel.Gutter, ansi.StringWidth(out[row]) if row == start.Row { - from = start.Col + from = max(from, start.Col) } if row == end.Row { to = end.Col } _, before, text, after := selectedRange(out[row], from, to) if text != "" { - out[row] = before + "\x1b[7m" + text + "\x1b[0m" + after + out[row] = before + DefaultStyles.Cursor.Render(text) + after } } return out diff --git a/cli/internal/tui/session_viewer.go b/cli/internal/tui/session_viewer.go index 15f26d7..2c4f377 100644 --- a/cli/internal/tui/session_viewer.go +++ b/cli/internal/tui/session_viewer.go @@ -7,7 +7,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) @@ -87,8 +86,8 @@ type SessionViewer struct { func NewSessionViewer(workspace string) SessionViewer { p := NewPickerModel("", sessionViewerActions(workspace), true, "new") p.SearchInput.Placeholder = "search sessions, models, folders…" - p.SearchInput.PlaceholderStyle = svDim - p.SearchInput.Cursor.Style = lipgloss.NewStyle().Foreground(svCyan) + p.SearchInput.PlaceholderStyle = DefaultStyles.Faint + p.SearchInput.Cursor.Style = DefaultStyles.Cursor return SessionViewer{ PickerModel: p, Workspace: workspace, diff --git a/cli/internal/tui/session_viewer_view.go b/cli/internal/tui/session_viewer_view.go index 133bf84..716894e 100644 --- a/cli/internal/tui/session_viewer_view.go +++ b/cli/internal/tui/session_viewer_view.go @@ -3,7 +3,6 @@ package tui import ( "albedo/cli/internal/daemon" "fmt" - "os" "path/filepath" "strings" "time" @@ -12,42 +11,6 @@ import ( "github.com/charmbracelet/x/ansi" ) -// ── palette ──────────────────────────────────────────────────────────────── - -var ( - svCyan = lipgloss.Color("#67e8f9") - svViolet = lipgloss.Color("#a78bfa") - svPink = lipgloss.Color("#f472b6") - svGreen = lipgloss.Color("#86efac") - svSelBg = lipgloss.AdaptiveColor{Light: "#e4e4f0", Dark: "#2e3047"} - - svText = lipgloss.NewStyle() - svMuted = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#5c5f77", Dark: "#a6adc8"}) - svDim = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#8c8fa1", Dark: "#6c7086"}) - svBold = lipgloss.NewStyle().Bold(true) -) - -// svGradient interpolates cyan → violet → pink for t in [0, 1]. -func svGradient(t float64) lipgloss.Color { - stops := [][3]float64{{0x67, 0xe8, 0xf9}, {0xa7, 0x8b, 0xfa}, {0xf4, 0x72, 0xb6}} - t = min(1, max(0, t)) * float64(len(stops)-1) - i := min(int(t), len(stops)-2) - f := t - float64(i) - a, b := stops[i], stops[i+1] - c := func(k int) int { return int(a[k] + (b[k]-a[k])*f + 0.5) } - return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", c(0), c(1), c(2))) -} - -func svGradientText(s string, bold bool) string { - runes := []rune(s) - var b strings.Builder - for i, r := range runes { - st := lipgloss.NewStyle().Foreground(svGradient(float64(i) / float64(max(1, len(runes)-1)))).Bold(bold) - b.WriteString(st.Render(string(r))) - } - return b.String() -} - // ── logo ─────────────────────────────────────────────────────────────────── // A four-point sparkle with a small companion star, drawn with half blocks so @@ -95,7 +58,7 @@ func svLogo() []string { } // Diagonal sweep so the gradient reads as light glancing off the mark. t := (float64(c)/float64(width) + float64(r)/float64(rows)) / 2 - b.WriteString(lipgloss.NewStyle().Foreground(svGradient(t)).Render(ch)) + b.WriteString(brandInk(t).Render(ch)) } lines[r] = b.String() } @@ -124,18 +87,6 @@ func svCompactAge(ts *int64, now time.Time) string { return fmt.Sprintf("%dmo", int(d.Hours()/24/30)) } -func svHomePath(p string) string { - if home, err := os.UserHomeDir(); err == nil && home != "" { - if p == home { - return "~" - } - if strings.HasPrefix(p, home+string(filepath.Separator)) { - return "~" + p[len(home):] - } - } - return p -} - // svCell fits plain text into exactly w columns. func svCell(s string, w int, right bool) string { if w <= 0 { @@ -173,12 +124,8 @@ func svLayout(width int) svColumns { return cols } -var ( - svGold = lipgloss.Color("#fcd34d") - svCyanBold = lipgloss.NewStyle().Foreground(svCyan).Bold(true) - svGreenStyle = lipgloss.NewStyle().Foreground(svGreen) - svSep = svDim.Render(" │ ") -) +// svSep divides the columns. +func svSep() string { return DefaultStyles.Decor.Render(" │ ") } // ── view ─────────────────────────────────────────────────────────────────── @@ -201,7 +148,7 @@ func (m SessionViewer) View() string { if roomy { lines = append(lines, "") } - lines = append(lines, " "+svCyanBold.Render("❯ ")+m.SearchInput.View()) + lines = append(lines, " "+promptLead()+m.SearchInput.View()) if height >= 9 { lines = append(lines, "") } @@ -226,7 +173,7 @@ func (m SessionViewer) View() string { case width >= 150 && height >= 14: favW := min(44, max(30, width*24/100)) recW := min(64, max(40, width*34/100)) - prevW := width - favW - recW - 2*ansi.StringWidth(svSep) + prevW := width - favW - recW - 2*ansi.StringWidth(svSep()) columns = [][]string{ m.column(favourites, favW, body, now, true), m.column(recent, recW, body, now, false), @@ -236,7 +183,7 @@ func (m SessionViewer) View() string { listW := width / 2 columns = [][]string{ m.column(all, listW, body, now, false), - m.preview(width-listW-ansi.StringWidth(svSep), body, now), + m.preview(width-listW-ansi.StringWidth(svSep()), body, now), } default: columns = [][]string{m.column(all, width, body, now, false)} @@ -245,7 +192,7 @@ func (m SessionViewer) View() string { var b strings.Builder for c, col := range columns { if c > 0 { - b.WriteString(svSep) + b.WriteString(svSep()) } b.WriteString(col[row]) } @@ -268,27 +215,9 @@ func svFit(s string, w int) string { return s + strings.Repeat(" ", max(0, w-ansi.StringWidth(s))) } -// titleRule is a single line: brand on the left, workspace on the right, -// joined by a rule that fades from the brand gradient into the background. +// titleRule is the brand at the workspace over a rule. func (m SessionViewer) titleRule(width int) string { - brand := " " + svGradientText("✦ albedo", true) + " " - cwd := "" - if width >= 30 { - cwd = " " + sessionText(svHomePath(m.Workspace)) + " " - } - n := width - ansi.StringWidth(brand) - ansi.StringWidth(cwd) - 1 - if n < 3 { - return brand - } - var rule strings.Builder - for i := 0; i < n; i++ { - if t := float64(i) / float64(n); t < 0.35 { - rule.WriteString(lipgloss.NewStyle().Foreground(svGradient(0.5 + t)).Render("─")) - } else { - rule.WriteString(svDim.Render("─")) - } - } - return brand + rule.String() + svMuted.Render(cwd) + return " " + titleRule(width-1, located("albedo", sessionText(homePath(m.Workspace))), "") } func (m SessionViewer) footer(width int, now time.Time) string { @@ -296,18 +225,13 @@ func (m SessionViewer) footer(width int, now time.Time) string { if m.HasActive { esc = "back" } - keys := [][2]string{{"↑↓", "move"}, {"tab", "switch"}, {"enter", "open"}, {"^s", "pin"}, {"esc", esc}} - var parts []string - for _, k := range keys { - parts = append(parts, svMuted.Render(k[0])+" "+svDim.Render(k[1])) - } - left := " " + strings.Join(parts, svDim.Render(" · ")) + left := " " + keyHints(hint{"↑↓", "move"}, hint{"tab", "switch"}, hint{"enter", "open"}, hint{"^s", "pin"}, hint{"esc", esc}) var right string if m.notice != "" { - right = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(m.notice) + right = DefaultStyles.Error.Render(m.notice) } else if m.Loading { - right = svDim.Render("loading…") + right = DefaultStyles.Faint.Render("loading…") } else { today := 0 for _, s := range m.Sessions { @@ -315,9 +239,9 @@ func (m SessionViewer) footer(width int, now time.Time) string { today++ } } - right = svDim.Render(fmt.Sprintf("%d sessions", len(m.Sessions))) + right = DefaultStyles.Faint.Render(fmt.Sprintf("%d sessions", len(m.Sessions))) if today > 0 { - right += svDim.Render(" · ") + svGreenStyle.Render(fmt.Sprintf("%d today", today)) + right += DefaultStyles.Decor.Render(" · ") + DefaultStyles.Success.Render(fmt.Sprintf("%d today", today)) } } gap := width - ansi.StringWidth(left) - ansi.StringWidth(right) - 1 @@ -345,10 +269,7 @@ func (m SessionViewer) column(indices []int, width, height int, now time.Time, f if len(all) > 0 && height >= 10 { all = append(all, "") } - label := " " + sectionTitles[sec] + " " - count := fmt.Sprintf("%d ", counts[sec]) - rule := strings.Repeat("─", max(0, width-2-ansi.StringWidth(label+count))) - all = append(all, svDim.Render(" ─")+svMuted.Bold(true).Render(label)+svDim.Render(count+rule)) + all = append(all, sectionRule(sectionTitles[sec], counts[sec], width)) } section = sec if i == m.Cursor { @@ -359,15 +280,15 @@ func (m SessionViewer) column(indices []int, width, height int, now time.Time, f } switch { case favourites && counts[secPinned]+counts[secFrequent] == 0 && m.SearchInput.Value() == "": - all = append(all, "", svDim.Render(" ★ nothing pinned yet"), "", - svDim.Render(" highlight a session and press"), - svDim.Render(" ")+svMuted.Render("ctrl+s")+svDim.Render(" to keep it here."), - "", svDim.Render(" sessions you open often"), - svDim.Render(" show up here on their own.")) + all = append(all, "", DefaultStyles.Faint.Render(" ★ nothing pinned yet"), "", + DefaultStyles.Faint.Render(" highlight a session and press"), + DefaultStyles.Faint.Render(" ")+DefaultStyles.Muted.Render("ctrl+s")+DefaultStyles.Faint.Render(" to keep it here."), + "", DefaultStyles.Faint.Render(" sessions you open often"), + DefaultStyles.Faint.Render(" show up here on their own.")) case len(indices) == 0 && m.Loading: - all = append(all, svDim.Render(" loading sessions…")) + all = append(all, DefaultStyles.Faint.Render(" loading sessions…")) case len(indices) == 0: - all = append(all, svDim.Render(" no matches")) + all = append(all, DefaultStyles.Faint.Render(" no matches")) } if len(all) > height { @@ -378,7 +299,7 @@ func (m SessionViewer) column(indices []int, width, height int, now time.Time, f start = min(max(0, start), len(all)-height) window := append([]string(nil), all[start:start+height]...) if height >= 3 { - more := svDim.Render(" ···") + more := DefaultStyles.Faint.Render(" ···") if start > 0 { window[0] = more } @@ -402,71 +323,70 @@ func (m SessionViewer) column(indices []int, width, height int, now time.Time, f func (m SessionViewer) row(item PickerItem, s daemon.Session, sec int, selected bool, width int, cols svColumns, now time.Time) string { st := func(style lipgloss.Style) lipgloss.Style { if selected { - return style.Background(svSelBg) + return style.Inherit(DefaultStyles.Selected) } return style } - bar := st(svText).Render(" ") + bar := st(lipgloss.NewStyle()).Render(" ") if selected { - bar = st(lipgloss.NewStyle().Foreground(svPink)).Render("▌") + bar = st(DefaultStyles.Agent).Render("▌") } - titleStyle := svText + titleStyle := lipgloss.NewStyle() if selected { - titleStyle = svBold + titleStyle = DefaultStyles.Bold } fill := func(line string) string { if pad := width - ansi.StringWidth(line); pad > 0 { - line += st(svText).Render(strings.Repeat(" ", pad)) + line += st(lipgloss.NewStyle()).Render(strings.Repeat(" ", pad)) } return line } if sec == secAction { - glyph, title, hint, color := "✦ ", "New session", "in "+sessionText(filepath.Base(m.Workspace)), svCyan + glyph, title, hint, fg := "✦ ", "New session", "in "+sessionText(filepath.Base(m.Workspace)), DefaultStyles.You if item.ID == "login" { - glyph, title, hint, color = "◇ ", "Accounts", "providers", svViolet + glyph, title, hint, fg = "◇ ", "Accounts", "providers", DefaultStyles.Muted } - fg := lipgloss.NewStyle().Foreground(color) titleWidth := min(ansi.StringWidth(title), cols.title) - return fill(bar + st(fg).Render(glyph) + st(svText).Render(" ") + - st(titleStyle.Foreground(color)).Render(svCell(title, titleWidth, false)) + - st(svDim).Render(svCell(" "+hint, max(0, width-4-titleWidth), false))) + return fill(bar + st(fg).Render(glyph) + st(lipgloss.NewStyle()).Render(" ") + + st(titleStyle.Inherit(fg)).Render(svCell(title, titleWidth, false)) + + st(DefaultStyles.Faint).Render(svCell(" "+hint, max(0, width-4-titleWidth), false))) } recent := s.LastAssistantAt != nil && now.Sub(time.Unix(*s.LastAssistantAt, 0)) < time.Hour - glyph, iconStyle := "· ", svDim + glyph, iconStyle := "· ", DefaultStyles.Faint switch { case selected: - glyph, iconStyle = "◆ ", lipgloss.NewStyle().Foreground(svPink) + glyph, iconStyle = "◆ ", DefaultStyles.Agent case sec == secPinned: - glyph, iconStyle = "★ ", lipgloss.NewStyle().Foreground(svGold) + glyph, iconStyle = "★ ", DefaultStyles.Agent case recent: - glyph, iconStyle = "● ", svGreenStyle + glyph, iconStyle = "● ", DefaultStyles.Success } - gap := st(svText).Render(" ") - line := bar + st(iconStyle).Render(glyph) + st(svText).Render(" ") + st(titleStyle).Render(svCell(item.Label, cols.title, false)) + gap := st(lipgloss.NewStyle()).Render(" ") + line := bar + st(iconStyle).Render(glyph) + st(lipgloss.NewStyle()).Render(" ") + st(titleStyle).Render(svCell(item.Label, cols.title, false)) if cols.model > 0 { model := sessionText(s.Model) if model == "" { model = "—" } - ms := svMuted + ms := DefaultStyles.Muted if selected { - ms = svText + ms = lipgloss.NewStyle() } line += gap + st(ms).Render(svCell(model, cols.model, false)) } if cols.workspace > 0 { - wsStyle := svMuted + wsStyle := DefaultStyles.Muted if s.Workspace == m.Workspace { - wsStyle = svDim + wsStyle = DefaultStyles.Faint } line += gap + st(wsStyle).Render(svCell(sessionText(filepath.Base(s.Workspace)), cols.workspace, false)) } if cols.age > 0 { - as := svDim + as := DefaultStyles.Faint if recent { - as = svGreenStyle + as = DefaultStyles.Success } line += gap + st(as).Render(svCell(svCompactAge(s.LastAssistantAt, now), cols.age, true)) } @@ -484,28 +404,28 @@ func (m SessionViewer) preview(width, height int, now time.Time) []string { s, isSession := m.session(item.ID) switch { case !ok: - top = []string{"", svMuted.Render("nothing matches"), svDim.Render("try fewer words")} + top = []string{"", DefaultStyles.Muted.Render("nothing matches"), DefaultStyles.Faint.Render("try fewer words")} case !isSession: top = m.actionPreview(item.ID, inner, height) default: for _, l := range svWrap(sessionTitle(s), inner, 2) { - top = append(top, svBold.Render(l)) + top = append(top, DefaultStyles.Bold.Render(l)) } meta := []string{} if m.prefs.pinned(s.ID) { - meta = append(meta, lipgloss.NewStyle().Foreground(svGold).Render("★ pinned")) + meta = append(meta, DefaultStyles.Agent.Render("★ pinned")) } if s.Model != "" { - meta = append(meta, svMuted.Render(sessionText(s.Model))) + meta = append(meta, DefaultStyles.Muted.Render(sessionText(s.Model))) } if s.Workspace != "" { - meta = append(meta, svMuted.Render(sessionText(svHomePath(s.Workspace)))) + meta = append(meta, DefaultStyles.Muted.Render(sessionText(homePath(s.Workspace)))) } - meta = append(meta, svDim.Render(daemon.AssistantAge(s.LastAssistantAt, now))) + meta = append(meta, DefaultStyles.Faint.Render(daemon.AssistantAge(s.LastAssistantAt, now))) if c := m.previews[s.ID]; c != nil && !c.loading && !c.err { - meta = append(meta, svDim.Render(fmt.Sprintf("%d messages", c.Total))) + meta = append(meta, DefaultStyles.Faint.Render(fmt.Sprintf("%d messages", c.Total))) } - top = append(top, strings.Join(meta, svDim.Render(" · ")), svDim.Render(strings.Repeat("─", inner))) + top = append(top, strings.Join(meta, DefaultStyles.Decor.Render(" · ")), DefaultStyles.Decor.Render(strings.Repeat("─", inner))) bottom = m.transcript(s, inner, max(0, height-len(top)-1)) } @@ -529,13 +449,13 @@ func (m SessionViewer) actionPreview(id string, inner, height int) []string { if logo := svLogo(); height >= len(logo)+8 { lines = append(append(lines, ""), logo...) } - lines = append(lines, "", svGradientText("New session", true), "", - svMuted.Render("start fresh in ")+svText.Render(sessionText(svHomePath(m.Workspace))), "", - svDim.Render("enter ")+svMuted.Render("begin")) + lines = append(lines, "", gradientText("New session", true), "", + DefaultStyles.Muted.Render("start fresh in ")+lipgloss.NewStyle().Render(sessionText(homePath(m.Workspace))), "", + DefaultStyles.Faint.Render("enter ")+DefaultStyles.Muted.Render("begin")) } else { - lines = append(lines, "", "", lipgloss.NewStyle().Foreground(svViolet).Bold(true).Render("◇ Accounts"), "", - svMuted.Render("add a provider, sign in, or pick"), svMuted.Render("which one new sessions use."), "", - svDim.Render("enter ")+svMuted.Render("manage")) + lines = append(lines, "", "", DefaultStyles.Muted.Bold(true).Render("◇ Accounts"), "", + DefaultStyles.Muted.Render("add a provider, sign in, or pick"), DefaultStyles.Muted.Render("which one new sessions use."), "", + DefaultStyles.Faint.Render("enter ")+DefaultStyles.Muted.Render("manage")) } for i, l := range lines { lines[i] = strings.Repeat(" ", max(0, (inner-ansi.StringWidth(l))/2)) + l @@ -552,7 +472,7 @@ func (m SessionViewer) transcript(s daemon.Session, width, height int) []string note := func(lines ...string) []string { out := []string{""} for _, l := range lines { - out = append(out, svDim.Render(l)) + out = append(out, DefaultStyles.Faint.Render(l)) } return out } @@ -605,32 +525,29 @@ func (m SessionViewer) transcript(s daemon.Session, width, height int) []string } } - const gutter = 7 + // the same rails as the transcript: one per turn, dimmer through tools + r := NewTranscriptRenderer() + lanes := map[string]lane{"user": laneYou, "assistant": laneAgent, "tool": laneBusy} var lines []string + prev := laneNone for bi, b := range blocks { - label, textStyle, maxLines := svDim.Render(" ⚙"), svDim, 2 - switch b.kind { - case "user": - label, textStyle, maxLines = svCyanBold.Render(" you"), svText, 3 - case "assistant": - label, textStyle, maxLines = lipgloss.NewStyle().Foreground(svPink).Bold(true).Render(" ✦"), svMuted, 3 - } - // One exchange per group: a gap only where the user speaks again. - if bi > 0 && b.kind == "user" { + own := lanes[b.kind] + textStyle, maxLines := lipgloss.NewStyle(), 3 + if b.kind == "tool" { + textStyle, maxLines = DefaultStyles.Faint, 2 + } + if bi > 0 && joint(prev, own) == laneNone { lines = append(lines, "") } - for i, l := range svWrap(b.text, max(1, width-gutter), maxLines) { - prefix := strings.Repeat(" ", gutter) - if i == 0 { - prefix = svFit(label, gutter) - } - lines = append(lines, prefix+textStyle.Render(l)) + for _, l := range svWrap(b.text, max(1, width-railWidth), maxLines) { + lines = append(lines, r.rail(own)+textStyle.Render(l)) } + prev = own } lines = append([]string{""}, lines...) if len(lines) > height { lines = lines[len(lines)-height:] - lines[0] = svDim.Render(" ··· earlier") + lines[0] = DefaultStyles.Faint.Render("··· earlier") } return lines } diff --git a/cli/internal/tui/stretch_test.go b/cli/internal/tui/stretch_test.go new file mode 100644 index 0000000..c0edfc9 --- /dev/null +++ b/cli/internal/tui/stretch_test.go @@ -0,0 +1,35 @@ +package tui + +import ( + "testing" + + "albedo/cli/internal/daemon" +) + +// Each tool call and each thinking spell draws its animation again, and one +// call keeps its animation from generating through running. +func TestEveryToolCallAndThoughtDrawsAgain(t *testing.T) { + m := NewChatModel(&daemon.Session{ID: "s"}, nil) + call := func(id, phase string) { + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: &daemon.ToolProgress{CallID: id, Name: "python", Phase: phase}}) + } + for i := range 20 { + id := string(rune('a' + i)) + call(id, "generating") + seed := m.moodSeed + call(id, "running") + if m.moodSeed != seed { + t.Fatal("one tool call changed its animation between generating and running") + } + call(id+"'", "generating") + if m.moodSeed == seed { + t.Fatal("the next tool call should draw again") + } + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ToolName: "python"}) + thought := m.moodSeed + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: "hm"}) + if m.phaseMood() != moodThinking || m.moodSeed == thought { + t.Fatal("a thinking spell after a tool call should draw a new animation") + } + } +} diff --git a/cli/internal/tui/styles.go b/cli/internal/tui/styles.go deleted file mode 100644 index 9fb4465..0000000 --- a/cli/internal/tui/styles.go +++ /dev/null @@ -1,63 +0,0 @@ -package tui - -import "github.com/charmbracelet/lipgloss" - -type Styles struct { - Faint lipgloss.Style - Dim lipgloss.Style - Bold lipgloss.Style - Prompt lipgloss.Style - PromptBright lipgloss.Style - Error lipgloss.Style - Warning lipgloss.Style - ChatWarning lipgloss.Style - ChatSuccess lipgloss.Style - Success lipgloss.Style - Header lipgloss.Style - Selected lipgloss.Style - Unselected lipgloss.Style - Detail lipgloss.Style - Thinking lipgloss.Style - ToolName lipgloss.Style - ToolDetail lipgloss.Style - DiffAdd lipgloss.Style - DiffRemove lipgloss.Style - DiffHeader lipgloss.Style - BadgePlain lipgloss.Style - BadgeActive lipgloss.Style - BadgeWarning lipgloss.Style - BadgeMuted lipgloss.Style - Footer lipgloss.Style - GlanceTitle lipgloss.Style - GlanceBorder lipgloss.Style -} - -var DefaultStyles = Styles{ - Faint: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), - Dim: lipgloss.NewStyle().Faint(true), - Bold: lipgloss.NewStyle().Bold(true), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), - PromptBright: lipgloss.NewStyle().Foreground(lipgloss.Color("14")), - Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), - Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("11")), - ChatWarning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), - ChatSuccess: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), - Success: lipgloss.NewStyle().Foreground(lipgloss.Color("10")), - Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")), - Selected: lipgloss.NewStyle().Reverse(true), - Unselected: lipgloss.NewStyle(), - Detail: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), - Thinking: lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Italic(true), - ToolName: lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true), - ToolDetail: lipgloss.NewStyle().Foreground(lipgloss.Color("242")), - DiffAdd: lipgloss.NewStyle().Foreground(lipgloss.Color("10")), - DiffRemove: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), - DiffHeader: lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true), - BadgePlain: lipgloss.NewStyle().Foreground(lipgloss.Color("15")), - BadgeActive: lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true), - BadgeWarning: lipgloss.NewStyle().Foreground(lipgloss.Color("11")), - BadgeMuted: lipgloss.NewStyle().Foreground(lipgloss.Color("242")), - Footer: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), - GlanceTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("13")), - GlanceBorder: lipgloss.NewStyle().Foreground(lipgloss.Color("238")), -} diff --git a/cli/internal/tui/theme.go b/cli/internal/tui/theme.go new file mode 100644 index 0000000..d4a9b2b --- /dev/null +++ b/cli/internal/tui/theme.go @@ -0,0 +1,266 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// The theme is the only place colors are chosen. Screens compose these +// styles and components, so the transcript, the session screen, and the +// pickers cannot drift into their own palettes. +// +// Tokens come in three kinds: +// - ink, a brightness ramp mixed from the terminal's own colors: prose +// keeps the terminal foreground, then Muted, Faint and Decor step down +// - identity: You and Agent, and Busy for albedo working in tools +// - state: Error, Warning and Success +// +// Without reported terminal colors every token falls back to a palette slot. +type Styles struct { + // Muted is text worth reading that steps back from prose: keys, + // metadata, section labels. + Muted lipgloss.Style + // Faint is text read at a glance: tool rows, timings, hints. + Faint lipgloss.Style + // Decor is structure seen, not read: rules, separators, markers. + Decor lipgloss.Style + Bold lipgloss.Style + + You lipgloss.Style + Agent lipgloss.Style + Busy lipgloss.Style + + Error lipgloss.Style + Warning lipgloss.Style + Success lipgloss.Style + + // Prompt marks where you type. + Prompt lipgloss.Style + // Selected is the surface under a selected row: a tint where the + // terminal reported its colors, reverse video where it did not. + Selected lipgloss.Style + // Cursor is the text cursor and a drag selection. + Cursor lipgloss.Style +} + +var DefaultStyles = Styles{ + Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Faint: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + Decor: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + Bold: lipgloss.NewStyle().Bold(true), + You: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), + Agent: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), + Busy: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Faint(true), + Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), + Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), + Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), + Selected: lipgloss.NewStyle().Reverse(true), + Cursor: lipgloss.NewStyle().Reverse(true), +} + +func useInk(detected ink) { + transcriptInk = detected + fg := func(hex string) lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) } + DefaultStyles.Muted = fg(detected.code) + DefaultStyles.Faint = fg(detected.secondary) + DefaultStyles.Decor = fg(detected.decor) + if detected.busy != "" { + DefaultStyles.Busy = fg(detected.busy) + } + DefaultStyles.Selected = lipgloss.NewStyle().Background(lipgloss.Color(detected.surface)) +} + +const ( + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiItalic = "\x1b[3m" + ansiGray = "\x1b[90m" +) + +// sgr is a foreground color as an escape sequence for the active color +// profile, or fallback when the ramp is unknown. +func sgr(hex, fallback string) string { return sgrLayer(hex, fallback, false) } + +func sgrLayer(hex, fallback string, background bool) string { + if hex == "" { + return fallback + } + seq := lipgloss.ColorProfile().Color(hex).Sequence(background) + if seq == "" { + return "" + } + return "\x1b[" + seq + "m" +} + +// codeInk steps code down from prose so a reply that decorates every +// identifier stays quiet. +func codeInk() string { return sgr(transcriptInk.code, "\x1b[37m") } + +// faintInk is the Faint token as a raw sequence, for markdown's own spans. +func faintInk() string { return sgr(transcriptInk.secondary, ansiGray) } + +// decorInk is the Decor token as a raw sequence. +func decorInk() string { return sgr(transcriptInk.decor, ansiGray) } + +// inlineCodeInk sets inline code on a tint in the prose color, where the +// terminal reported its colors. Without them it falls back to the code ink. +func inlineCodeInk() string { + if bg := sgrLayer(transcriptInk.codeBg, "", true); bg != "" { + return bg + } + return codeInk() +} + +// Diff rows sit on tints mixed toward the palette's red and green. The +// fallbacks are dark tints, the only case without reported colors. +func diffPanel() string { return sgrLayer(transcriptInk.surface, "\x1b[48;2;37;40;50m", true) } +func diffAdded() string { return sgrLayer(transcriptInk.added, "\x1b[48;2;24;53;39m", true) } +func diffRemoved() string { return sgrLayer(transcriptInk.removed, "\x1b[48;2;59;35;40m", true) } + +// keepBackground turns full resets into foreground and attribute resets, so +// a styled span inside a tinted row keeps the row's background. +func keepBackground(s string) string { + return strings.ReplaceAll(s, ansiReset, "\x1b[39;22;23m") +} + +// gradient is the brand ramp at t in [0, 1], or nil when the terminal did +// not report the colors it is mixed from. +func gradient(t float64) lipgloss.TerminalColor { + from, to := parseHex(transcriptInk.brandFrom), parseHex(transcriptInk.brandTo) + if from == nil || to == nil { + return nil + } + return lipgloss.Color(from.mix(*to, min(1, max(0, t))).hex()) +} + +func parseHex(hex string) *rgb { + var r, g, b int + if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err != nil { + return nil + } + return &rgb{float64(r) / 255, float64(g) / 255, float64(b) / 255} +} + +// gradientText sweeps the brand ramp across s. Without it s takes the agent +// color. +func gradientText(s string, bold bool) string { + if gradient(0) == nil { + return DefaultStyles.Agent.Bold(bold).Render(s) + } + runes := []rune(s) + var b strings.Builder + for i, r := range runes { + t := float64(i) / float64(max(1, len(runes)-1)) + b.WriteString(lipgloss.NewStyle().Foreground(gradient(t)).Bold(bold).Render(string(r))) + } + return b.String() +} + +// brandInk colors a mark along the brand ramp, or in the agent color. +func brandInk(t float64) lipgloss.Style { + if c := gradient(t); c != nil { + return lipgloss.NewStyle().Foreground(c) + } + return DefaultStyles.Agent +} + +func brand(name string) string { return gradientText("✦ "+name, true) } + +// titleRule heads a screen: left, a rule that fades from the brand's end +// into Decor, then right. +func titleRule(width int, left, right string) string { + if right != "" { + right = " " + right + } + n := width - ansi.StringWidth(left) - ansi.StringWidth(right) - 1 + if n < 3 { + return left + } + var rule strings.Builder + from, to := parseHex(transcriptInk.brandTo), parseHex(transcriptInk.decor) + for i := range n { + t := float64(i) / float64(n) + if from != nil && to != nil && t < 0.35 { + rule.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(from.mix(*to, t/0.35).hex())).Render("─")) + continue + } + rule.WriteString(DefaultStyles.Decor.Render("─")) + } + return left + " " + rule.String() + right +} + +// located is the brand at a place: "✦ albedo on ~/path". +func located(name, place string) string { + if place == "" { + return brand(name) + } + return brand(name) + DefaultStyles.Faint.Render(" on ") + DefaultStyles.Muted.Render(place) +} + +// hint is a key and what it does. +type hint struct{ key, does string } + +// keyHints lists keys in Muted and what they do in Faint. A hint without a +// key is a plain note. +func keyHints(hints ...hint) string { + parts := make([]string, 0, len(hints)) + for _, h := range hints { + var part string + switch { + case h.key == "": + part = DefaultStyles.Faint.Render(h.does) + case h.does == "": + part = DefaultStyles.Muted.Render(h.key) + default: + part = DefaultStyles.Muted.Render(h.key) + " " + DefaultStyles.Faint.Render(h.does) + } + parts = append(parts, part) + } + return strings.Join(parts, DefaultStyles.Decor.Render(" · ")) +} + +// sectionRule heads a group in a list: " ─ label count ────". +func sectionRule(label string, count, width int) string { + head := " " + label + " " + tail := fmt.Sprintf("%d ", count) + rule := strings.Repeat("─", max(0, width-2-ansi.StringWidth(head+tail))) + return DefaultStyles.Decor.Render(" ─") + DefaultStyles.Muted.Bold(true).Render(head) + DefaultStyles.Decor.Render(tail+rule) +} + +// promptMark is where you type. +const promptMark = "› " + +func promptLead() string { return DefaultStyles.Prompt.Render(promptMark) } + +// selectBar marks the selected row. +func selectBar() string { return DefaultStyles.Agent.Render("▌") } + +// selectedLine lays the selection surface under a styled line, reopening it +// after every reset inside the line, and fills it out to width. +func selectedLine(line string, width int) string { + marked := DefaultStyles.Selected.Render("\x00") + open := marked[:strings.IndexByte(marked, 0)] + if pad := width - ansi.StringWidth(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + return open + strings.ReplaceAll(line, ansiReset, ansiReset+open) + ansiReset +} + +// homePath shortens a path under your home directory to start with ~. +func homePath(p string) string { + if home, err := os.UserHomeDir(); err == nil && home != "" { + if p == home { + return "~" + } + if strings.HasPrefix(p, home+string(filepath.Separator)) { + return "~" + p[len(home):] + } + } + return p +} diff --git a/cli/internal/tui/theme_test.go b/cli/internal/tui/theme_test.go new file mode 100644 index 0000000..d881563 --- /dev/null +++ b/cli/internal/tui/theme_test.go @@ -0,0 +1,34 @@ +package tui + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// Colors are chosen in the theme alone. A screen that picks its own drifts +// from the rest, which is how the session screen grew a second palette. +func TestColorsComeFromTheTheme(t *testing.T) { + owners := map[string]bool{"theme.go": true, "ink.go": true, "ink_unix.go": true, "ink_other.go": true} + raw := regexp.MustCompile(`lipgloss\.Color\(|AdaptiveColor|\\x1b\[(3\d|4\d|9\d|7|38;|48;)|Faint\(true\)|Reverse\(true\)|"#[0-9a-fA-F]{6}"|\.Foreground\(|\)\.Background\(`) + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || owners[name] { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(src), "\n") { + if raw.MatchString(line) { + t.Errorf("%s:%d picks its own color, use a theme token: %s", name, i+1, strings.TrimSpace(line)) + } + } + } +} diff --git a/cli/internal/tui/transcript.go b/cli/internal/tui/transcript.go index 0bd6fb6..1ee02a5 100644 --- a/cli/internal/tui/transcript.go +++ b/cli/internal/tui/transcript.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode" ) type DisplayFlags struct { @@ -20,9 +21,8 @@ type DisplayFlags struct { } type TranscriptRenderer struct { - Styles Styles - HeadingWidth int - BodyWidth int + Styles Styles + BodyWidth int } func NewTranscriptRenderer() TranscriptRenderer { @@ -33,25 +33,102 @@ func formatClock(timestamp int64) string { if timestamp <= 0 { return "" } - t := time.UnixMilli(timestamp) - return t.Format("15:04:05") + return time.UnixMilli(timestamp).Format("15:04:05") } -func (r TranscriptRenderer) RenderHeading(label string, width int, timestamp int64) string { - clock := formatClock(timestamp) - if width < len(clock)+3 { - clock = "" +// speaker names who wrote a chat entry, or "" for tool and status rows. +func speaker(entry HistoryEntry) string { + switch { + case entry.Kind == EntryUser && entry.Speaker == "": + return "You" + case entry.Kind == EntryAssistant && entry.Speaker == "": + return "albedo" + case entry.Kind == EntryUser || entry.Kind == EntryAssistant: + return entry.Speaker } - name := label - if width < len([]rune(name))+len(clock)+1 && width > len(clock)+1 { - runes := []rune(name) - name = string(runes[:max(0, width-len(clock)-2)]) + "…" + return "" +} + +// prior is what a new entry follows: the newest chat author, and when the +// conversation last moved, which a turn's end also marks. +type prior struct { + speaker string + at int64 +} + +func priorOf(entries []HistoryEntry) prior { + var p prior + for i := len(entries) - 1; i >= 0 && p.speaker == ""; i-- { + e := entries[i] + if p.at == 0 && (e.Kind == EntryTurnEnd || speaker(e) != "") { + p.at = e.Timestamp + } + p.speaker = speaker(e) } - heading := r.Styles.Bold.Render(name) - if clock != "" { - heading += strings.Repeat(" ", max(1, width-len([]rune(name))-len(clock))) + r.Styles.Faint.Render(clock) + return p +} + +// awayMs is the pause before your message that earns a "later" note. +const awayMs = 30 * 60_000 + +// nameplate labels a change of speaker, so a turn that resumes after tool +// rows keeps its author's name once. Verbose mode labels every message. +func (r TranscriptRenderer) nameplate(entry HistoryEntry, flags DisplayFlags, before prior) string { + who := speaker(entry) + if !flags.Tools && who == before.speaker { + return "" + } + style := r.Styles.Agent + if entry.Kind == EntryUser { + style = r.Styles.You + } + var meta []string + if clock := formatClock(entry.Timestamp); flags.Tools && clock != "" { + meta = append(meta, clock) + } + if gap := entry.Timestamp - before.at; entry.Kind == EntryUser && before.at > 0 && gap >= awayMs { + meta = append(meta, formatGap(gap)) } - return heading + if entry.Pending == queued { + meta = append(meta, "queued") + } + plate := style.Render(strings.ToLower(who)) + if len(meta) > 0 { + plate += r.Styles.Faint.Render(" · " + strings.Join(meta, " · ")) + } + return plate +} + +// errorRow marks only the label red. Long red text tires the eye, and the +// message reads best in the prose color. +func (r TranscriptRenderer) errorRow(text string) string { + return r.Styles.Error.Render("error:") + " " + text +} + +// signoff closes a turn: a face for how it ended, then how long it took. +func (r TranscriptRenderer) signoff(entry HistoryEntry) string { + style := r.Styles.Agent + switch entry.Mood { + case moodFailed: + style = r.Styles.Error + case moodStopped: + style = r.Styles.Warning + } + var meta []string + if entry.Mood == moodStopped { + meta = append(meta, "stopped by you") + } + if entry.ElapsedMs > 0 { + meta = append(meta, formatElapsed(entry.ElapsedMs)) + } + if entry.Tools > 0 { + meta = append(meta, fmt.Sprintf("%d %s", entry.Tools, map[bool]string{true: "tool", false: "tools"}[entry.Tools == 1])) + } + row := style.Render(entry.Mood.face(entry.Timestamp)) + if len(meta) > 0 { + row += " " + r.Styles.Faint.Render(strings.Join(meta, " · ")) + } + return row } var diffHunk = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) @@ -91,10 +168,9 @@ func (r TranscriptRenderer) renderDiffPath(diff, path string, width int) string }, text) } row := func(gutter, content, bg string, syntax bool) { - body := "\x1b[90m" + content + "\x1b[39m" + body := keepBackground(r.Styles.Faint.Render(content)) if syntax { - body = HighlightCode(content, lang) - body = strings.ReplaceAll(body, "\x1b[0m", "\x1b[39;22;23m") + body = keepBackground(HighlightCode(content, lang)) } for i, part := range strings.Split(ansi.Hardwrap(body, max(1, width-ansi.StringWidth(gutter)), true), "\n") { prefix := gutter @@ -102,10 +178,13 @@ func (r TranscriptRenderer) renderDiffPath(diff, path string, width int) string prefix = strings.Repeat(" ", ansi.StringWidth(gutter)) } cell := prefix + part - rows = append(rows, bg+cell+strings.Repeat(" ", max(0, width-ansi.StringWidth(cell)))+"\x1b[0m") + rows = append(rows, bg+cell+strings.Repeat(" ", max(0, width-ansi.StringWidth(cell)))+ansiReset) } } - panel, add, remove := "\x1b[48;2;37;40;50m", "\x1b[48;2;24;53;39m", "\x1b[48;2;59;35;40m" + panel, add, remove := diffPanel(), diffAdded(), diffRemoved() + gutter := func(style lipgloss.Style, n int, sign string) string { + return keepBackground(style.Render(fmt.Sprintf("%4d %s ", n, sign))) + } row(" ", clean(path), panel, false) for _, line := range strings.Split(diff, "\n") { if line == "" || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { @@ -119,13 +198,13 @@ func (r TranscriptRenderer) renderDiffPath(diff, path string, width int) string } switch line[0] { case '+': - row(fmt.Sprintf("\x1b[32m%4d + \x1b[39m", newLine), clean(line[1:]), add, true) + row(gutter(r.Styles.Success, newLine, "+"), clean(line[1:]), add, true) newLine++ case '-': - row(fmt.Sprintf("\x1b[31m%4d - \x1b[39m", oldLine), clean(line[1:]), remove, true) + row(gutter(r.Styles.Error, oldLine, "-"), clean(line[1:]), remove, true) oldLine++ case ' ': - row(fmt.Sprintf("\x1b[90m%4d \x1b[39m", newLine), clean(line[1:]), panel, true) + row(gutter(r.Styles.Decor, newLine, " "), clean(line[1:]), panel, true) newLine++ oldLine++ default: @@ -142,6 +221,15 @@ func lineUnit(n int) string { return "lines" } +func firstLine(text string) string { + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimSpace(line); line != "" { + return line + } + } + return "" +} + func toolSummary(entry HistoryEntry) string { arg := func(key string) string { if entry.ToolArgs == nil { @@ -151,10 +239,9 @@ func toolSummary(entry HistoryEntry) string { } switch entry.ToolName { case "python": - lines := strings.Count(arg("code"), "\n") + 1 - return fmt.Sprintf("python · %d %s", lines, lineUnit(lines)) + return "python · " + firstLine(arg("code")) case "shell": - return "$ " + arg("command") + return "$ " + firstLine(arg("command")) case "read_file": return "read " + arg("path") case "write_file": @@ -166,6 +253,77 @@ func toolSummary(entry HistoryEntry) string { } } +// toolOutput is what a tool printed. Python cells answer with a JSON +// envelope, and counting the envelope reports one line for every cell. +func toolOutput(entry HistoryEntry) string { + if entry.ToolName == "python" { + var cell struct{ Output, Value, Error string } + if json.Unmarshal([]byte(entry.ToolResult), &cell) == nil { + var parts []string + for _, part := range []string{cell.Output, cell.Value, cell.Error} { + if part = strings.TrimSpace(part); part != "" { + parts = append(parts, part) + } + } + return strings.Join(parts, "\n") + } + } + return strings.TrimSpace(entry.ToolResult) +} + +// oneLine folds text onto one row: control characters and runs of +// whitespace become single spaces. +func oneLine(text string) string { + return strings.Join(strings.Fields(strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, text)), " ") +} + +// fit truncates head so the row fits width, keeping tail whole when there is +// room, so a long target never hides the counts after it. +func fit(head, tail string, width int) string { + if room := width - ansi.StringWidth(tail); room >= 12 { + return ansi.Truncate(head, room, "…") + tail + } + return ansi.Truncate(head+tail, max(1, width), "…") +} + +func countLines(text string) string { + n := strings.Count(text, "\n") + 1 + return fmt.Sprintf("%d %s", n, lineUnit(n)) +} + +// toolRow is the collapsed tool line: what ran, then how much. +func toolRow(entry HistoryEntry, failed bool, clock string, width int) string { + var tail []string + if code, ok := entry.ToolArgs["code"].(string); ok && entry.ToolName == "python" { + if n := strings.Count(strings.TrimSpace(code), "\n") + 1; n > 1 { + tail = append(tail, countLines(strings.TrimSpace(code))) + } + } + if output := toolOutput(entry); output != "" { + tail = append(tail, countLines(output)+" out") + } + if failed { + tail = append(tail, "failed") + } + if clock != "" { + tail = append(tail, clock) + } + suffix := "" + if len(tail) > 0 { + suffix = " · " + strings.Join(tail, " · ") + } + return fit(oneLine(toolSummary(entry)), suffix, width) +} + +func (r TranscriptRenderer) diffCounts(change daemon.FileChange) string { + return r.Styles.Success.Render(fmt.Sprintf("+%d", change.Added)) + " " + r.Styles.Error.Render(fmt.Sprintf("−%d", change.Removed)) +} + func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags DisplayFlags, width int) string { if trace == nil { return "" @@ -184,11 +342,11 @@ func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags Displ if activity.Failed { label += " failed" } - style := r.Styles.Prompt + style := r.Styles.Faint if activity.Failed { style = r.Styles.Error } - rows = append(rows, style.Render(label+" "+activity.Target)) + rows = append(rows, style.Render(fit(label+" "+oneLine(activity.Target), "", width))) } for _, change := range trace.Changes { label := "edited " + change.Path @@ -198,10 +356,11 @@ func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags Displ break } } + counts := "" if change.Kind == "diff" { - label += fmt.Sprintf(" +%d −%d", change.Added, change.Removed) + counts = " " + r.diffCounts(change) } - rows = append(rows, r.Styles.Prompt.Render(label)) + rows = append(rows, fit(r.Styles.Faint.Render(oneLine(label)), counts, width)) if flags.Diffs { if change.Kind == "diff" { rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width)) @@ -225,21 +384,21 @@ func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags Displ } rows = append(rows, r.Styles.Bold.Render(verb)) for _, act := range trace.Activities { - style := r.Styles.Prompt + style := r.Styles.Faint label := act.Kind if act.Failed { style = r.Styles.Error label += " failed" } - rows = append(rows, " "+style.Render(label)+" "+act.Target) + rows = append(rows, fit(" "+style.Render(label)+" "+oneLine(act.Target), "", width)) } } for _, change := range trace.Changes { - line := r.Styles.Bold.Render("edited " + change.Path) + counts := "" if change.Kind == "diff" { - line += fmt.Sprintf(" %s %s", lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("+%d", change.Added)), lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(fmt.Sprintf("−%d", change.Removed))) + counts = " " + r.diffCounts(change) } - rows = append(rows, line) + rows = append(rows, fit(r.Styles.Bold.Render(oneLine("edited "+change.Path)), counts, width)) if flags.Diffs { if change.Kind == "diff" { rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width)) @@ -282,62 +441,49 @@ func (r TranscriptRenderer) faintMarkdownRows(text string, width int) []string { return rows } +// RenderEntry renders an entry on its own, so a chat entry always names its author. func (r TranscriptRenderer) RenderEntry(entry HistoryEntry, flags DisplayFlags, width int) string { - if r.BodyWidth > 0 { - width = r.BodyWidth - } + return r.RenderAfter(prior{}, entry, flags, width) +} + +// RenderAfter renders an entry in the context of what came before it. +func (r TranscriptRenderer) RenderAfter(before prior, entry HistoryEntry, flags DisplayFlags, width int) string { var rows []string switch entry.Kind { case EntryUser, EntryAssistant: - speaker := entry.Speaker - if speaker == "" { - if entry.Kind == EntryUser { - speaker = "You" - } else { - speaker = "albedo" - } + if plate := r.nameplate(entry, flags, before); plate != "" { + rows = append(rows, plate) } - headingWidth := width - if r.HeadingWidth > 0 { - headingWidth = r.HeadingWidth - } - heading := r.RenderHeading(speaker, headingWidth, entry.Timestamp) - if entry.Kind == EntryUser { - clock := formatClock(entry.Timestamp) - if clock != "" && headingWidth >= len([]rune(speaker))+len(clock)+2 { - heading = "\x1b[96m" + strings.ToLower(speaker) + "\x1b[0m" + strings.Repeat(" ", headingWidth-len([]rune(speaker))-len(clock)) + r.Styles.Faint.Render(clock) - } else { - heading = "\x1b[96m" + strings.ToLower(speaker) + "\x1b[0m" - } + if entry.Pending != settled { + // greyed out until the daemon echoes it back + rows = append(rows, r.faintMarkdownRows(entry.Text, width)...) + break } - rows = []string{heading, RenderMarkdownAnsi(entry.Text, width)} + rows = append(rows, RenderMarkdownAnsi(entry.Text, width)) case EntryThinking: if flags.Thinking { rows = append([]string{r.Styles.Faint.Render("thinking")}, r.faintMarkdownRows(entry.Text, width)...) } else { - rows = []string{r.Styles.Faint.Render(ansi.Truncate("thinking · /t expand", max(1, width), "…"))} + rows = []string{r.Styles.Faint.Render("thinking")} } case EntryTool: trace := entry.ToolTrace hasTrace := trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) failed := toolFailed(entry) if !hasTrace || flags.Tools || failed { + // a glance in normal mode, read in full in verbose mode style := r.Styles.Faint - if failed { - style = r.Styles.Error + if flags.Tools { + style = lipgloss.NewStyle() } - label := toolSummary(entry) if failed { - label += " · failed" + style = r.Styles.Error } - if !flags.Tools && !hasTrace { - if output := strings.TrimSpace(entry.ToolResult); output != "" { - label += fmt.Sprintf(" · %d output %s", strings.Count(output, "\n")+1, lineUnit(strings.Count(output, "\n")+1)) - } - label += " · /v expand" - label = ansi.Truncate(strings.Join(strings.Fields(label), " "), max(1, width), "…") + clock := "" + if flags.Tools { + clock = formatClock(entry.Timestamp) } - rows = append(rows, style.Render(label)) + rows = append(rows, style.Render(toolRow(entry, failed, clock, width))) } if hasTrace { rows = append(rows, r.RenderToolTrace(trace, flags, width)) @@ -351,16 +497,18 @@ func (r TranscriptRenderer) RenderEntry(entry HistoryEntry, flags DisplayFlags, } } if flags.Tools { - output := strings.TrimRight(entry.ToolResult, "\n") + output := toolOutput(entry) if output == "" { output = "(no output)" } rows = append(rows, strings.Split(output, "\n")...) } + case EntryTurnEnd: + rows = []string{r.signoff(entry)} case EntryNote: rows = []string{r.Styles.Faint.Render(entry.Text)} case EntryError: - rows = []string{r.Styles.Error.Render("error: " + entry.Text)} + rows = []string{r.errorRow(entry.Text)} case EntryCompacted: action := "view" if flags.Compaction { @@ -374,16 +522,102 @@ func (r TranscriptRenderer) RenderEntry(entry HistoryEntry, flags DisplayFlags, return strings.Join(rows, "\n") } -func (r TranscriptRenderer) RenderHistory(history *BoundedHistory, flags DisplayFlags, width int) string { +// Compact entries are one-line summaries. A run of them stacks without blank +// rows so a burst of tool calls reads as one block. +func Compact(entry HistoryEntry, flags DisplayFlags) bool { + switch entry.Kind { + case EntryTool: + return !flags.Tools + case EntryThinking: + return !flags.Thinking + } + return false +} + +// Separated reports whether a blank row belongs between two adjacent entries. +// A turn's signoff hangs directly under the turn it closes. +func Separated(prev *HistoryEntry, next HistoryEntry, flags DisplayFlags) bool { + return prev != nil && next.Kind != EntryTurnEnd && !(Compact(*prev, flags) && Compact(next, flags)) +} + +// railWidth is the rail and the space after it, at the start of every row. +const railWidth = 2 + +// lane is the rail an entry hangs on. A turn is one unbroken rail: yours in +// your color, albedo's in its color, dimmer while it works in tools. +type lane int + +const ( + laneNone lane = iota + laneYou + laneAgent + laneBusy +) + +func laneOf(entry HistoryEntry) lane { + switch entry.Kind { + case EntryUser: + return laneYou + case EntryAssistant, EntryTurnEnd, EntryError: + return laneAgent + case EntryTool, EntryThinking: + return laneBusy + } + return laneNone +} + +// joint is the lane of the blank row between two entries, so the rail +// carries through a turn and breaks between turns. +func joint(above, below lane) lane { + switch { + case above == laneNone || below == laneNone || (above == laneYou) != (below == laneYou): + return laneNone + case above == laneBusy || below == laneBusy: + return laneBusy + } + return above +} + +func (r TranscriptRenderer) rail(l lane) string { + switch l { + case laneYou: + return r.Styles.You.Render("│") + " " + case laneAgent: + return r.Styles.Agent.Render("│") + " " + case laneBusy: + return r.Styles.Busy.Render("│") + " " + } + return strings.Repeat(" ", railWidth) +} + +// Block is an entry as finished transcript rows: the blank row that +// separates it from the entry before, when one belongs, then its rows +// wrapped beside its rail. head indexes the entry's first row. +func (r TranscriptRenderer) Block(before []HistoryEntry, entry HistoryEntry, flags DisplayFlags) (rows []string, head int) { + own := laneOf(entry) + if n := len(before); n > 0 && Separated(&before[n-1], entry, flags) { + rows = append(rows, strings.TrimRight(r.rail(joint(laneOf(before[n-1]), own)), " ")) + } + head = len(rows) + width := max(1, r.BodyWidth-railWidth) + gutter := r.rail(own) + for _, line := range strings.Split(r.RenderAfter(priorOf(before), entry, flags, width), "\n") { + for _, chunk := range wrapOrChunkLine(line, width) { + rows = append(rows, gutter+chunk) + } + } + return rows, head +} + +func (r TranscriptRenderer) RenderHistory(history *BoundedHistory, flags DisplayFlags) string { var rows []string if notice := history.TruncationNotice(); notice != "" { - rows = append(rows, r.Styles.Warning.Render(notice), "") + rows = append(rows, r.rail(laneNone)+r.Styles.Warning.Render(notice), "") } - for _, entry := range history.Entries() { - if len(rows) > 0 { - rows = append(rows, "") - } - rows = append(rows, r.RenderEntry(entry, flags, width)) + entries := history.Entries() + for i, entry := range entries { + block, _ := r.Block(entries[:i], entry, flags) + rows = append(rows, block...) } return strings.Join(rows, "\n") } diff --git a/cli/internal/tui/transcript_readability_test.go b/cli/internal/tui/transcript_readability_test.go new file mode 100644 index 0000000..c5e29b7 --- /dev/null +++ b/cli/internal/tui/transcript_readability_test.go @@ -0,0 +1,32 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestInlineSpansRestateEnclosingEmphasis(t *testing.T) { + got := inline("**a `b` c**", emphasis{}) + after := got[strings.LastIndex(got, "`")+1:] + if !strings.HasPrefix(after, emphasis{strong: true}.sgr()) { + t.Fatalf("closing code span dropped the enclosing bold: %q", got) + } + if got := ansi.Strip(inline("2 * 3 * 4 and `a*b*c`", emphasis{})); got != "2 * 3 * 4 and `a*b*c`" { + t.Fatalf("spaced or code asterisks became emphasis: %q", got) + } +} + +func TestSelectionSkipsTheRail(t *testing.T) { + lines := []string{"│ you", "│ can you list the tools", "", "│ albedo"} + sel := Selection{Anchor: Point{Row: 0, Col: 0}, Head: Point{Row: 3, Col: 8}, Gutter: railWidth} + if got, want := SelectedText(lines, sel), "you\ncan you list the tools\n\nalbedo"; got != want { + t.Fatalf("copied %q, want %q", got, want) + } + for _, row := range HighlightSelection(lines, sel) { + if strings.HasPrefix(row, "\x1b[7m") { + t.Fatalf("highlight covers the rail: %q", row) + } + } +} diff --git a/cli/internal/tui/tree_picker.go b/cli/internal/tui/tree_picker.go index 234ba4e..0c08393 100644 --- a/cli/internal/tui/tree_picker.go +++ b/cli/internal/tui/tree_picker.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "github.com/charmbracelet/lipgloss" "regexp" "strings" @@ -251,31 +250,31 @@ func (m TreePickerModel) Update(msg tea.Msg) (TreePickerModel, tea.Cmd) { func (m TreePickerModel) View() string { var b strings.Builder - b.WriteString("albedo /tree · branch history") + b.WriteString(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/tree"), m.Styles.Faint.Render("branch history"))) b.WriteString("\n") - b.WriteString(m.Styles.Dim.Render(inkWrap("choose the checkpoint the new session should end after", m.Width))) + b.WriteString(m.Styles.Faint.Render(inkWrap("choose the checkpoint the new session should end after", m.Width))) b.WriteString("\n") if m.Error != "" { - b.WriteString(inkRed.Render("Error: " + m.Error)) + b.WriteString(DefaultStyles.Error.Render("error:") + " " + m.Error) b.WriteByte('\n') } if m.Loading || len(m.Checkpoints) == 0 { if m.Error == "" && !m.Loading { - b.WriteString(m.Styles.Dim.Render("no branchable history in this session")) + b.WriteString(m.Styles.Faint.Render("no branchable history in this session")) b.WriteByte('\n') } if m.Error == "" && m.Loading { - b.WriteString(m.Styles.Dim.Render("loading history…")) + b.WriteString(m.Styles.Faint.Render("loading history…")) b.WriteByte('\n') } - b.WriteString(m.Styles.Dim.Render("enter confirm · esc cancel")) + b.WriteString(keyHints(hint{"enter", "confirm"}, hint{"esc", "cancel"})) return b.String() } lines := make([]string, len(m.Checkpoints)) for i, cp := range m.Checkpoints { - lines[i] = lipgloss.NewStyle().Faint(true).Render(fmt.Sprintf("%-9s", cp.Type)) + " " + readablePreview(cp.Preview) + lines[i] = DefaultStyles.Faint.Render(fmt.Sprintf("%-9s", cp.Type)) + " " + readablePreview(cp.Preview) } b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width, m.Styles)) b.WriteByte('\n') @@ -283,25 +282,25 @@ func (m TreePickerModel) View() string { if m.Confirming && m.Cursor < len(m.Checkpoints) { current := m.Checkpoints[m.Cursor] b.WriteString("\n") - b.WriteString(inkYellow.Render(fmt.Sprintf("branch after %s · %s?", current.Type, readablePreview(current.Preview)))) + b.WriteString(DefaultStyles.Warning.Render(fmt.Sprintf("branch after %s · %s?", current.Type, readablePreview(current.Preview)))) b.WriteString("\n") - b.WriteString("new session · fresh python namespace · workspace files stay unchanged\n") + b.WriteString(m.Styles.Faint.Render("new session · fresh python namespace · workspace files stay unchanged") + "\n") if m.ForkError != "" { - b.WriteString(inkRed.Render(m.ForkError)) + b.WriteString(DefaultStyles.Error.Render(m.ForkError)) b.WriteByte('\n') } } if m.Forking { - b.WriteString(m.Styles.Dim.Render("creating branch…")) + b.WriteString(m.Styles.Faint.Render("creating branch…")) } else if m.Confirming { - confirmAction := "enter confirm" + confirm := hint{"enter", "confirm"} if m.ForkError != "" { - confirmAction = "enter retry" + confirm = hint{"enter", "retry"} } - b.WriteString(m.Styles.Dim.Render(fmt.Sprintf("%s · esc cancel", confirmAction))) + b.WriteString(keyHints(confirm, hint{"esc", "cancel"})) } else { - b.WriteString(m.Styles.Dim.Render(inkWrap("↑↓ select · ←→/pgup/pgdn page · enter branch · esc return to chat", m.Width))) + b.WriteString(inkWrap(keyHints(hint{"↑↓", "select"}, hint{"←→/pgup/pgdn", "page"}, hint{"enter", "branch"}, hint{"esc", "return to chat"}), m.Width)) } return b.String() diff --git a/cli/internal/tui/turns_test.go b/cli/internal/tui/turns_test.go new file mode 100644 index 0000000..18ac6ec --- /dev/null +++ b/cli/internal/tui/turns_test.go @@ -0,0 +1,78 @@ +package tui + +import ( + "strings" + "testing" + + "albedo/cli/internal/daemon" + "github.com/charmbracelet/x/ansi" +) + +func ms(v int64) *int64 { return &v } + +func plainRows(lines []string) []string { + var rows []string + for _, row := range lines { + plain := []rune(strings.TrimRight(ansi.Strip(row), " ")) + rows = append(rows, string(plain[min(railWidth, len(plain)):])) + } + return rows +} + +func replayTurn(m *ChatModel, start int64, text string) { + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: text, Source: "chat", Timestamp: ms(start)}) + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ToolName: "python", ToolArgs: map[string]any{"code": "x"}}) + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventMessage, Text: "answer to " + text, Timestamp: ms(start + 23_000)}) +} + +func TestTurnSignoffUsesReplayedTimestamps(t *testing.T) { + m := NewChatModel(&daemon.Session{ID: "s"}, nil) + m.SetSize(100, 40) + replayTurn(&m, 1_000_000, "first") + replayTurn(&m, 1_000_000+2*3600_000, "second") + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventInterrupted}) + rows := plainRows(m.settledLines) + var signoffs []string + for _, row := range rows { + if strings.Contains(row, "tool") && strings.Contains(row, "s ·") || strings.Contains(row, "stopped by you") { + signoffs = append(signoffs, row) + } + } + if len(signoffs) != 2 || !strings.HasSuffix(signoffs[0], " 23s · 1 tool") || !strings.Contains(signoffs[1], "stopped by you · 23s · 1 tool") { + t.Fatalf("signoffs %q in:\n%s", signoffs, strings.Join(rows, "\n")) + } + if !strings.Contains(strings.Join(rows, "\n"), "you · 1h later") { + t.Fatalf("second message should note the pause since the first turn ended:\n%s", strings.Join(rows, "\n")) + } + for i, row := range rows { + if row == "answer to first" && rows[i+1] != signoffs[0] { + t.Fatalf("signoff should hang under the reply it closes:\n%s", strings.Join(rows, "\n")) + } + } +} + +func TestJumpToYouLandsOnYourMessages(t *testing.T) { + m := NewChatModel(&daemon.Session{ID: "s"}, nil) + m.SetSize(100, 20) + for i := int64(0); i < 4; i++ { + m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Speaker: "You", Text: "question", Timestamp: 1 + i}) + m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Speaker: "albedo", Text: strings.Repeat("line\n", 15), Timestamp: 1 + i}) + } + m.refreshViewportContent() + var landed []string + for range 3 { + m.jumpToYou(true) + landed = append(landed, plainRows(strings.Split(m.Viewport.View(), "\n")[:1])[0]) + } + for _, top := range landed { + if top != "you" { + t.Fatalf("jump should put your message at the top, got %q", landed) + } + } + for range 4 { + m.jumpToYou(false) + } + if !m.Follow { + t.Fatal("jumping forward past your newest message should follow the live transcript") + } +} -- 2.54.0