on main help sign in
perf: eliminate per-frame RPCs, redundant work, and hot-path allocations

Wide performance pass across the codebase, all behavior-preserving. UI (biggest wins in client mode): - sidebar/header no longer issue blocking LSP/model RPCs on every frame; diagnostics and selected model are mirrored from the event stream, so the render cache is no longer defeated - FilterableList only re-filters when items/query change (dirty flag) - permission dialog caches non-diff content and reuses a persistent DiffView across horizontal scroll - model-picker fuzzy filter O(N*groups) -> O(N) - completions no longer runs two fuzzy passes per keystroke - assistant: single alloc-free FNV hash per section, one focus prefix - shell item caches xOffset-independent render work - gradient / chroma-style / arguments-caser / compact-header memoized - View() single-pass trim builder; misc dead-code and alloc trims Agent/tools: - MCP: 30s liveness TTL instead of pinging before every tool call - getCacheControlOptions memoized; grep stats each path once - copilot transport short-circuits sub-agent requests; safe/view/base64 allocation trims Server/message/permission: - workspace sessions counted from a single client snapshot - permission Request does os.Stat outside the global lock - jsonBody avoids a body copy; ToAIMessage drops a duplicate scan fsext/lsp: - single stat per lookup probe - LSP gates the cheap filetype check before PATH scan; root markers memoized Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit66c79118dc11c13bf0a4d07d0af95028c6269cc8 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 04:18 (3 months ago) parent3f54541
37 files changed+865−290
internal/agent/agent.go+8−1 internal/agent/tools/grep.go+11−4 internal/agent/tools/mcp/init.go+22−3 internal/agent/tools/mcp/tools.go+14 internal/agent/tools/safe.go+12−2 internal/agent/tools/view.go+15−14 internal/backend/backend.go+18 internal/client/proto.go+2−4 internal/fsext/lookup.go+2−7 internal/fsext/owner_others.go+8−5 internal/fsext/owner_windows.go+7 internal/lsp/manager.go+25−7 internal/message/content.go+3−2 internal/oauth/copilot/client.go+18−5 internal/permission/permission.go+29−24 internal/server/proto.go+13−3 internal/ui/chat/assistant.go+124−59 internal/ui/chat/assistant_section_cache_test.go+3−3 internal/ui/chat/shell.go+83−30 internal/ui/common/diff.go+20−1 internal/ui/common/highlight.go+7−5 internal/ui/completions/completions.go+6−4 internal/ui/dialog/arguments.go+20−10 internal/ui/dialog/models_list.go+32−37 internal/ui/dialog/permissions.go+43−11 internal/ui/diffview/util.go+8−1 internal/ui/list/filterable.go+11−1 internal/ui/list/highlight.go+2−1 internal/ui/model/chat.go−4 internal/ui/model/header.go+62−10 internal/ui/model/landing.go+57−1 internal/ui/model/lsp.go+22−1 internal/ui/model/mcp.go+8−1 internal/ui/model/sidebar.go+27−13 internal/ui/model/skills.go+15−5 internal/ui/model/ui.go+59−11 internal/ui/styles/grad.go+49
internal/agent/agent.go+8−1
@@ -1449,7 +1449,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
14491449 return qErr
14501450}
14511451
1452−func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
1452+// cacheControlOptions is computed once for the process lifetime: the env check
1453+// is constant and callers only read (never mutate) the returned map, so the
1454+// same shared value can be reused across the ~3 calls per step.
1455+var cacheControlOptions = sync.OnceValue(func() fantasy.ProviderOptions {
14531456 if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
14541457 return fantasy.ProviderOptions{}
14551458 }
@@ -1464,6 +1467,10 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
14641467 CacheControl: anthropic.CacheControl{Type: "ephemeral"},
14651468 },
14661469 }
1470+})
1471+
1472+func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
1473+ return cacheControlOptions()
14671474}
14681475
14691476func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
internal/agent/tools/grep.go+11−4
@@ -235,6 +235,8 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
235235 }
236236
237237 var matches []grepMatch
238+ // modTime is only used for sorting; stat each unique path once.
239+ modTimes := make(map[string]time.Time)
238240 for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
239241 if len(line) == 0 {
240242 continue
@@ -247,13 +249,18 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
247249 continue
248250 }
249251 for _, m := range match.Data.Submatches {
250− fi, err := os.Stat(match.Data.Path.Text)
251− if err != nil {
252− continue // Skip files we can't access
252+ modTime, ok := modTimes[match.Data.Path.Text]
253+ if !ok {
254+ fi, err := os.Stat(match.Data.Path.Text)
255+ if err != nil {
256+ continue // Skip files we can't access
257+ }
258+ modTime = fi.ModTime()
259+ modTimes[match.Data.Path.Text] = modTime
253260 }
254261 matches = append(matches, grepMatch{
255262 path: match.Data.Path.Text,
256− modTime: fi.ModTime(),
263+ modTime: modTime,
257264 lineNum: match.Data.LineNumber,
258265 charNum: m.Start + 1, // ensure 1-based
259266 lineText: strings.TrimSpace(match.Data.Lines.Text),
internal/agent/tools/mcp/init.go+22−3
@@ -55,11 +55,19 @@ func (s *ClientSession) Close() error {
5555var (
5656 sessions = csync.NewMap[string, *ClientSession]()
5757 states = csync.NewMap[string, ClientInfo]()
58− broker = pubsub.NewBroker[Event]()
59− initOnce sync.Once
60− initDone = make(chan struct{})
58+ // sessionLastOK records the last time a session was confirmed alive
59+ // (successful ping or freshly created). Used to skip redundant pings
60+ // within livenessTTL before each MCP tool call.
61+ sessionLastOK = csync.NewMap[string, time.Time]()
62+ broker = pubsub.NewBroker[Event]()
63+ initOnce sync.Once
64+ initDone = make(chan struct{})
6165)
6266
67+// livenessTTL is how long a session is trusted to be alive after a successful
68+// ping/create before getOrRenewClient pings it again.
69+const livenessTTL = 30 * time.Second
70+
6371// State represents the current state of an MCP client
6472type State int
6573
@@ -260,6 +268,7 @@ func initClient(ctx context.Context, cfg *config.ConfigStore, name string, m con
260268 toolCount := updateTools(cfg, name, tools)
261269 updatePrompts(name, prompts)
262270 sessions.Set(name, session)
271+ sessionLastOK.Set(name, time.Now())
263272
264273 updateState(name, StateConnected, nil, session, Counts{
265274 Tools: toolCount,
@@ -280,6 +289,7 @@ func DisableSingle(cfg *config.ConfigStore, name string) error {
280289 slog.Warn("Error closing MCP session", "name", name, "error", err)
281290 }
282291 sessions.Del(name)
292+ sessionLastOK.Del(name)
283293 }
284294
285295 // Clear tools and prompts for this MCP.
@@ -299,6 +309,11 @@ func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string)
299309 return nil, fmt.Errorf("mcp '%s' not available", name)
300310 }
301311
312+ // Skip the ping if this session was confirmed alive very recently.
313+ if last, ok := sessionLastOK.Get(name); ok && time.Since(last) < livenessTTL {
314+ return sess, nil
315+ }
316+
302317 m := cfg.Config().MCP[name]
303318 state, _ := states.Get(name)
304319
@@ -307,8 +322,10 @@ func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string)
307322 defer cancel()
308323 err := sess.Ping(pingCtx, nil)
309324 if err == nil {
325+ sessionLastOK.Set(name, time.Now())
310326 return sess, nil
311327 }
328+ sessionLastOK.Del(name)
312329 updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, state.Counts)
313330
314331 sess, err = createSession(ctx, name, m, cfg.Resolver())
@@ -318,6 +335,7 @@ func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string)
318335
319336 updateState(name, StateConnected, nil, sess, state.Counts)
320337 sessions.Set(name, sess)
338+ sessionLastOK.Set(name, time.Now())
321339 return sess, nil
322340}
323341
@@ -335,6 +353,7 @@ func updateState(name string, state State, err error, client *ClientSession, cou
335353 info.ConnectedAt = time.Now()
336354 case StateError:
337355 sessions.Del(name)
356+ sessionLastOK.Del(name)
338357 }
339358 states.Set(name, info)
340359
internal/agent/tools/mcp/tools.go+14
@@ -206,6 +206,20 @@ func ensureRawBytes(data []byte) []byte {
206206}
207207
208208func normalizeBase64Input(data []byte) []byte {
209+ // Fast path: if the data is all-ASCII and contains no whitespace, there is
210+ // nothing for strings.Fields to strip, so avoid copying the (potentially
211+ // multi-MB) payload. Bytes >= 0x80 could be part of a non-ASCII Unicode
212+ // space that strings.Fields would strip, so fall back to the slow path.
213+ fast := true
214+ for _, b := range data {
215+ if b >= 0x80 || b == ' ' || b == '\t' || b == '\n' || b == '\v' || b == '\f' || b == '\r' {
216+ fast = false
217+ break
218+ }
219+ }
220+ if fast {
221+ return data
222+ }
209223 normalized := strings.Join(strings.Fields(string(data)), "")
210224 return []byte(normalized)
211225}
internal/agent/tools/safe.go+12−2
@@ -72,8 +72,19 @@ func init() {
7272 "where",
7373 )
7474 }
75+
76+ // Precompute the tokenized form of each safe command once, so
77+ // isSafeReadOnly doesn't re-split on every call.
78+ safeCommandTokens = make([][]string, len(safeCommands))
79+ for i, safeCmd := range safeCommands {
80+ safeCommandTokens[i] = strings.Fields(safeCmd)
81+ }
7582}
7683
84+// safeCommandTokens holds the tokenized form of safeCommands, populated in
85+// init after any platform-specific entries are appended.
86+var safeCommandTokens [][]string
87+
7788// isSafeReadOnly reports whether command is a single, simple, read-only
7889// command that can run without a permission prompt.
7990//
@@ -133,8 +144,7 @@ func isSafeReadOnly(command string) bool {
133144 return false
134145 }
135146
136− for _, safeCmd := range safeCommands {
137− tokens := strings.Fields(safeCmd)
147+ for _, tokens := range safeCommandTokens {
138148 if len(argv) < len(tokens) {
139149 continue
140150 }
internal/agent/tools/view.go+15−14
@@ -12,6 +12,7 @@ import (
1212 "net/http"
1313 "os"
1414 "path/filepath"
15+ "strconv"
1516 "strings"
1617 "time"
1718 "unicode/utf8"
@@ -252,7 +253,7 @@ func NewViewTool(
252253
253254 if hasMore {
254255 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
255− params.Offset+len(strings.Split(content, "\n")))
256+ params.Offset+strings.Count(content, "\n")+1)
256257 }
257258 output += "\n</file>\n"
258259 output += getDiagnostics(filePath, lspManager)
@@ -284,24 +285,24 @@ func addLineNumbers(content string, startLine int) string {
284285 return ""
285286 }
286287
287− lines := strings.Split(content, "\n")
288−
289− var result []string
290− for i, line := range lines {
288+ var b strings.Builder
289+ for i, line := range strings.Split(content, "\n") {
290+ if i > 0 {
291+ b.WriteByte('\n')
292+ }
291293 line = strings.TrimSuffix(line, "\r")
292294
293− lineNum := i + startLine
294− numStr := fmt.Sprintf("%d", lineNum)
295−
296− if len(numStr) >= 6 {
297− result = append(result, fmt.Sprintf("%s|%s", numStr, line))
298− } else {
299− paddedNum := fmt.Sprintf("%6s", numStr)
300− result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
295+ numStr := strconv.Itoa(i + startLine)
296+ // Right-align to a width of 6 (numbers with >=6 digits are unpadded).
297+ for pad := 6 - len(numStr); pad > 0; pad-- {
298+ b.WriteByte(' ')
301299 }
300+ b.WriteString(numStr)
301+ b.WriteByte('|')
302+ b.WriteString(line)
302303 }
303304
304− return strings.Join(result, "\n")
305+ return b.String()
305306}
306307
307308func readTextFile(filePath string, offset, limit, maxContentSize int) (string, bool, error) {
internal/backend/backend.go+18
@@ -649,6 +649,24 @@ func (w *Workspace) AttachedClientsForSession(sessionID string) int {
649649 return n
650650}
651651
652+// AttachedClientCountsBySession returns, for every session with at least
653+// one attached client, the number of clients currently viewing it. Only
654+// clients with a live SSE stream (streams > 0) are counted; hold-only
655+// clients (streams == 0) do not contribute. It acquires [clientsMu] once
656+// and iterates the client set a single time, so callers that need counts
657+// for many sessions avoid an O(sessions x clients) scan.
658+func (w *Workspace) AttachedClientCountsBySession() map[string]int {
659+ w.clientsMu.Lock()
660+ defer w.clientsMu.Unlock()
661+ counts := make(map[string]int)
662+ for _, cs := range w.clients {
663+ if cs.streams > 0 {
664+ counts[cs.currentSessionID]++
665+ }
666+ }
667+ return counts
668+}
669+
652670// GetWorkspaceProto returns the proto representation of a workspace.
653671func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
654672 ws, err := b.GetWorkspace(id)
internal/client/proto.go+2−4
@@ -661,11 +661,9 @@ func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, erro
661661 return &cfg, nil
662662}
663663
664−func jsonBody(v any) *bytes.Buffer {
665− b := new(bytes.Buffer)
664+func jsonBody(v any) io.Reader {
666665 m, _ := json.Marshal(v)
667− b.Write(m)
668− return b
666+ return bytes.NewReader(m)
669667}
670668
671669// SaveSession updates a session in a workspace, returning a proto type.
internal/fsext/lookup.go+2−7
@@ -258,7 +258,7 @@ func canonicalize(path string) string {
258258
259259// probeEnt checks if entity at given path exists and belongs to given owner
260260func probeEnt(fspath string, owner int) error {
261− _, err := os.Stat(fspath)
261+ info, err := os.Stat(fspath)
262262 if err != nil {
263263 return fmt.Errorf("cannot stat %s: %w", fspath, err)
264264 }
@@ -268,12 +268,7 @@ func probeEnt(fspath string, owner int) error {
268268 return nil
269269 }
270270
271− fowner, err := Owner(fspath)
272− if err != nil {
273− return fmt.Errorf("cannot get ownership for %s: %w", fspath, err)
274− }
275−
276− if fowner != owner {
271+ if ownerFromInfo(info) != owner {
277272 return os.ErrPermission
278273 }
279274
internal/fsext/owner_others.go+8−5
@@ -14,11 +14,14 @@ func Owner(path string) (int, error) {
1414 if err != nil {
1515 return 0, err
1616 }
17− var uid int
17+ return ownerFromInfo(info), nil
18+}
19+
20+// ownerFromInfo derives the owner user ID from an already-obtained
21+// os.FileInfo, avoiding a redundant stat.
22+func ownerFromInfo(info os.FileInfo) int {
1823 if stat, ok := info.Sys().(*syscall.Stat_t); ok {
19− uid = int(stat.Uid)
20− } else {
21− uid = os.Getuid()
24+ return int(stat.Uid)
2225 }
23− return uid, nil
26+ return os.Getuid()
2427}
internal/fsext/owner_windows.go+7
@@ -13,3 +13,10 @@ func Owner(path string) (int, error) {
1313 }
1414 return -1, nil
1515}
16+
17+// ownerFromInfo derives the owner user ID from an already-obtained
18+// os.FileInfo, avoiding a redundant stat. Windows does not resolve
19+// ownership, so it always returns the bypass sentinel.
20+func ownerFromInfo(_ os.FileInfo) int {
21+ return -1
22+}
internal/lsp/manager.go+25−7
@@ -27,6 +27,7 @@ const unavailableRetryDelay = 30 * time.Second
2727type Manager struct {
2828 clients *csync.Map[string, *Client]
2929 unavailable *csync.Map[string, time.Time]
30+ rootMarkers *csync.Map[string, bool]
3031 cfg *config.ConfigStore
3132 manager *powernapconfig.Manager
3233 callback func(name string, client *Client)
@@ -63,6 +64,7 @@ func NewManager(cfg *config.ConfigStore) *Manager {
6364 return &Manager{
6465 clients: csync.NewMap[string, *Client](),
6566 unavailable: csync.NewMap[string, time.Time](),
67+ rootMarkers: csync.NewMap[string, bool](),
6668 cfg: cfg,
6769 manager: manager,
6870 callback: func(string, *Client) {}, // default no-op callback
@@ -168,6 +170,14 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
168170 }
169171 }
170172
173+ // Cheap, pure string check first: if this server does not handle the
174+ // file type at all, skip the expensive PATH lookup and workspace
175+ // root-marker glob below. This gates the costly I/O on the zero-I/O
176+ // filetype match, without changing whether the server ultimately starts.
177+ if !handlesFiletype(server.Command, server.FileTypes, filepath) {
178+ return
179+ }
180+
171181 if !isUserConfigured {
172182 if s.recentlyUnavailable(name) {
173183 return
@@ -184,8 +194,8 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
184194 }
185195 }
186196
187− // this is the slowest bit, so we do it last.
188− if !handles(server, filepath, s.cfg.WorkingDir()) {
197+ // hasRootMarkers is the slowest bit (a workspace glob), so we do it last.
198+ if !s.hasRootMarkers(s.cfg.WorkingDir(), server.RootMarkers) {
189199 // nothing to do
190200 return
191201 }
@@ -329,6 +339,19 @@ func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
329339 return false
330340}
331341
342+// hasRootMarkers reports whether dir contains any of the given root
343+// markers. The result is invariant for a fixed (dir, markers) pair, so it
344+// is memoized to avoid re-running the workspace glob on every file view.
345+func (s *Manager) hasRootMarkers(dir string, markers []string) bool {
346+ if len(markers) == 0 {
347+ return true
348+ }
349+ key := dir + "\x00" + strings.Join(markers, "\x00")
350+ return s.rootMarkers.GetOrSet(key, func() bool {
351+ return hasRootMarkers(dir, markers)
352+ })
353+}
354+
332355func hasRootMarkers(dir string, markers []string) bool {
333356 if len(markers) == 0 {
334357 return true
@@ -345,11 +368,6 @@ func hasRootMarkers(dir string, markers []string) bool {
345368 return false
346369}
347370
348−func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
349− return handlesFiletype(server.Command, server.FileTypes, filePath) &&
350− hasRootMarkers(workDir, server.RootMarkers)
351−}
352−
353371// KillAll force-kills all the LSP clients.
354372//
355373// This is generally faster than [Manager.StopAll] because it doesn't wait for
internal/message/content.go+3−2
@@ -547,8 +547,9 @@ func (m *Message) ToAIMessage() []fantasy.Message {
547547 case User:
548548 var parts []fantasy.MessagePart
549549 text := strings.TrimSpace(m.Content().Text)
550+ binaryContent := m.BinaryContent()
550551 var textAttachments []Attachment
551− for _, content := range m.BinaryContent() {
552+ for _, content := range binaryContent {
552553 if !strings.HasPrefix(content.MIMEType, "text/") {
553554 continue
554555 }
@@ -571,7 +572,7 @@ func (m *Message) ToAIMessage() []fantasy.Message {
571572 if text != "" {
572573 parts = append(parts, fantasy.TextPart{Text: text})
573574 }
574− for _, content := range m.BinaryContent() {
575+ for _, content := range binaryContent {
575576 // skip text attachements
576577 if strings.HasPrefix(content.MIMEType, "text/") {
577578 continue
internal/oauth/copilot/client.go+18−5
@@ -17,14 +17,20 @@ var assistantRolePattern = regexp.MustCompile(`"role"\s*:\s*"assistant"`)
1717// NewClient creates a new HTTP client with a custom transport that adds the
1818// X-Initiator header based on message history in the request body.
1919func NewClient(isSubAgent, debug bool) *http.Client {
20+ t := &initiatorTransport{debug: debug, isSubAgent: isSubAgent}
21+ if debug {
22+ // Build the debug HTTP client once and reuse it on every RoundTrip.
23+ t.debugClient = log.NewHTTPClient()
24+ }
2025 return &http.Client{
21− Transport: &initiatorTransport{debug: debug, isSubAgent: isSubAgent},
26+ Transport: t,
2227 }
2328}
2429
2530type initiatorTransport struct {
26− debug bool
27− isSubAgent bool
31+ debug bool
32+ isSubAgent bool
33+ debugClient *http.Client
2834}
2935
3036func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -37,6 +43,13 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
3743 if req == nil {
3844 return nil, fmt.Errorf("HTTP request is nil")
3945 }
46+ // Sub-agents are always attributed to the agent initiator regardless of
47+ // body content, so short-circuit before reading/cloning the body.
48+ if t.isSubAgent {
49+ req.Header.Set(xInitiatorHeader, agentInitiator)
50+ slog.Debug("Setting X-Initiator header to agent (sub-agent)")
51+ return t.roundTrip(req)
52+ }
4053 if req.Body == nil || req.Body == http.NoBody {
4154 // No body to inspect; default to user. A nil Body is valid for
4255 // bodyless requests (e.g. GET), and is distinct from http.NoBody,
@@ -62,7 +75,7 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
6275 // Check for assistant messages using regex to handle whitespace
6376 // variations in the JSON while avoiding full unmarshalling overhead.
6477 initiator := userInitiator
65− if assistantRolePattern.Match(bodyBytes) || t.isSubAgent {
78+ if assistantRolePattern.Match(bodyBytes) {
6679 slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)")
6780 initiator = agentInitiator
6881 } else {
@@ -75,7 +88,7 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
7588
7689func (t *initiatorTransport) roundTrip(req *http.Request) (*http.Response, error) {
7790 if t.debug {
78− return log.NewHTTPClient().Transport.RoundTrip(req)
91+ return t.debugClient.Transport.RoundTrip(req)
7992 }
8093 return http.DefaultTransport.RoundTrip(req)
8194}
internal/permission/permission.go+29−24
@@ -201,6 +201,35 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
201201 // sub-agents each needing approval would queue behind one another.
202202 // Per-request resolution is scoped by the pendingRequests map (keyed
203203 // by request ID), so the wait needs no global lock.
204+ // Resolve the target path and build the request before taking
205+ // requestMu. os.Stat is a blocking syscall and none of this setup
206+ // touches shared state (workingDir is immutable, uuid.New is
207+ // self-contained), so it does not need to serialize with other
208+ // in-flight requests.
209+ fileInfo, err := os.Stat(opts.Path)
210+ dir := opts.Path
211+ if err == nil {
212+ if fileInfo.IsDir() {
213+ dir = opts.Path
214+ } else {
215+ dir = filepath.Dir(opts.Path)
216+ }
217+ }
218+
219+ if dir == "." {
220+ dir = s.workingDir
221+ }
222+ permission := PermissionRequest{
223+ ID: uuid.New().String(),
224+ Path: dir,
225+ SessionID: opts.SessionID,
226+ ToolCallID: opts.ToolCallID,
227+ ToolName: opts.ToolName,
228+ Description: opts.Description,
229+ Action: opts.Action,
230+ Params: opts.Params,
231+ }
232+
204233 respCh, requestID, resolved, granted := func() (chan bool, string, bool, bool) {
205234 s.requestMu.Lock()
206235 defer s.requestMu.Unlock()
@@ -222,30 +251,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
222251 return nil, "", true, true
223252 }
224253
225− fileInfo, err := os.Stat(opts.Path)
226− dir := opts.Path
227− if err == nil {
228− if fileInfo.IsDir() {
229− dir = opts.Path
230− } else {
231− dir = filepath.Dir(opts.Path)
232− }
233− }
234−
235− if dir == "." {
236− dir = s.workingDir
237− }
238− permission := PermissionRequest{
239− ID: uuid.New().String(),
240− Path: dir,
241− SessionID: opts.SessionID,
242− ToolCallID: opts.ToolCallID,
243− ToolName: opts.ToolName,
244− Description: opts.Description,
245− Action: opts.Action,
246− Params: opts.Params,
247− }
248−
249254 if _, ok := s.sessionPermissions.Get(PermissionKey{
250255 SessionID: permission.SessionID,
251256 ToolName: permission.ToolName,
internal/server/proto.go+13−3
@@ -3,7 +3,6 @@ package server
33import (
44 "encoding/json"
55 "errors"
6− "fmt"
76 "net/http"
87
98 "github.com/charmbracelet/crush/internal/backend"
@@ -306,7 +305,12 @@ func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.R
306305 continue
307306 }
308307
309− fmt.Fprintf(w, "data: %s\n\n", data)
308+ // Write the SSE frame in three chunks to avoid the
309+ // []byte->string copy that a "%s"-formatted write would
310+ // incur on the (potentially large) marshaled payload.
311+ w.Write([]byte("data: "))
312+ w.Write(data)
313+ w.Write([]byte("\n\n"))
310314 flusher.Flush()
311315 }
312316 }
@@ -382,11 +386,17 @@ func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http
382386 return
383387 }
384388 ws, _ := c.backend.GetWorkspace(id)
389+ // Snapshot the client->session attachment counts once instead of
390+ // re-scanning every client for each session (O(sessions x clients)).
391+ var attachedCounts map[string]int
392+ if ws != nil {
393+ attachedCounts = ws.AttachedClientCountsBySession()
394+ }
385395 result := make([]proto.Session, len(sessions))
386396 for i, s := range sessions {
387397 result[i] = sessionToProto(s)
388398 result[i].IsBusy = isSessionBusy(ws, s.ID)
389− result[i].AttachedClients = attachedClients(ws, s.ID)
399+ result[i].AttachedClients = attachedCounts[s.ID]
390400 }
391401 jsonEncode(w, result)
392402}
internal/ui/chat/assistant.go+124−59
@@ -1,9 +1,7 @@
11package chat
22
33import (
4− "encoding/binary"
54 "fmt"
6− "hash/fnv"
75 "strings"
86
97 tea "charm.land/bubbletea/v2"
@@ -93,11 +91,47 @@ func (s *assistantSection) reset() {
9391 *s = assistantSection{}
9492}
9593
96−// fnv64 hashes a single string with FNV-64.
94+// FNV-64a constants. We fold bytes into a plain uint64 accumulator
95+// rather than allocating a fnv.New64a per call: the returned
96+// hash.Hash64 escapes to the heap, which is wasteful on the per-render
97+// hashing hot path. The algorithm (offset basis, prime, xor-then-multiply)
98+// is identical to hash/fnv, so hashes are byte-for-byte the same.
99+const (
100+ fnvOffset64 = 14695981039346656037
101+ fnvPrime64 = 1099511628211
102+)
103+
104+// fnvAddString folds s into an in-progress FNV-64a hash.
105+func fnvAddString(h uint64, s string) uint64 {
106+ for i := 0; i < len(s); i++ {
107+ h ^= uint64(s[i])
108+ h *= fnvPrime64
109+ }
110+ return h
111+}
112+
113+// fnvAddBytes folds b into an in-progress FNV-64a hash.
114+func fnvAddBytes(h uint64, b []byte) uint64 {
115+ for _, c := range b {
116+ h ^= uint64(c)
117+ h *= fnvPrime64
118+ }
119+ return h
120+}
121+
122+// fnvAddU64 folds v's 8 little-endian bytes into an in-progress hash,
123+// matching binary.LittleEndian.PutUint64 followed by a Write.
124+func fnvAddU64(h, v uint64) uint64 {
125+ for i := 0; i < 8; i++ {
126+ h ^= uint64(byte(v >> (8 * i)))
127+ h *= fnvPrime64
128+ }
129+ return h
130+}
131+
132+// fnv64 hashes a single string with FNV-64a.
97133func fnv64(s string) uint64 {
98− h := fnv.New64a()
99− _, _ = h.Write([]byte(s))
100− return h.Sum64()
134+ return fnvAddString(fnvOffset64, s)
101135}
102136
103137// fnvFields hashes a list of byte fields with length-prefix framing
@@ -106,14 +140,41 @@ func fnv64(s string) uint64 {
106140// boundary between two fields). Each field is preceded by its
107141// length encoded as 8 bytes little-endian.
108142func fnvFields(fields ...[]byte) uint64 {
109− h := fnv.New64a()
110− var lenBuf [8]byte
143+ h := uint64(fnvOffset64)
111144 for _, f := range fields {
112− binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(f)))
113− _, _ = h.Write(lenBuf[:])
114− _, _ = h.Write(f)
145+ h = fnvAddU64(h, uint64(len(f)))
146+ h = fnvAddBytes(h, f)
147+ }
148+ return h
149+}
150+
151+// assistantRenderKeys bundles the per-render cache keys and tool-call
152+// count computed once at the top of Render/RawRender and threaded
153+// through the render path, so each section's source text is hashed only
154+// once and ToolCalls() is materialized only once per render.
155+type assistantRenderKeys struct {
156+ thinkSrc, thinkExtra uint64
157+ contentSrc, contentExtra uint64
158+ errSrc, errExtra uint64
159+ toolCallCount int
160+}
161+
162+// renderKeys computes the per-render keys once. It calls ToolCalls()
163+// exactly once and reuses its length everywhere the render path needs it.
164+func (a *AssistantMessageItem) renderKeys() assistantRenderKeys {
165+ toolCallCount := len(a.message.ToolCalls())
166+ thinkSrc, thinkExtra := a.thinkingKey(toolCallCount)
167+ contentSrc, contentExtra := a.contentKey()
168+ errSrc, errExtra := a.errorKey()
169+ return assistantRenderKeys{
170+ thinkSrc: thinkSrc,
171+ thinkExtra: thinkExtra,
172+ contentSrc: contentSrc,
173+ contentExtra: contentExtra,
174+ errSrc: errSrc,
175+ errExtra: errExtra,
176+ toolCallCount: toolCallCount,
115177 }
116− return h.Sum64()
117178}
118179
119180// AssistantMessageItem represents an assistant message in the chat UI.
@@ -208,14 +269,20 @@ func (a *AssistantMessageItem) ID() string {
208269
209270// RawRender implements [MessageItem].
210271func (a *AssistantMessageItem) RawRender(width int) string {
272+ return a.rawRender(width, a.renderKeys())
273+}
274+
275+// rawRender renders the item using per-render keys computed once by the
276+// caller, so the hashing/tool-call work is shared with Render.
277+func (a *AssistantMessageItem) rawRender(width int, keys assistantRenderKeys) string {
211278 cappedWidth := cappedMessageWidth(width)
212279
213280 var spinner string
214− if a.isSpinning() {
281+ if a.isSpinningWithToolCalls(keys.toolCallCount) {
215282 spinner = a.renderSpinning()
216283 }
217284
218− content, height := a.renderMessageContent(cappedWidth)
285+ content, height := a.renderMessageContent(cappedWidth, keys)
219286 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
220287 if spinner != "" {
221288 if highlightedContent != "" {
@@ -243,21 +310,24 @@ func (a *AssistantMessageItem) Render(width int) string {
243310 // drop. Bypass the cache while spinning (RawRender's spinner
244311 // suffix changes every animation frame) or while a highlight
245312 // range is active (selection drag).
246− useCache := !a.isSpinning() && !a.isHighlighted()
313+ keys := a.renderKeys()
314+ useCache := !a.isSpinningWithToolCalls(keys.toolCallCount) && !a.isHighlighted()
247315 cappedWidth := cappedMessageWidth(width)
248− key := a.prefixCacheKey(cappedWidth)
316+ key := a.prefixCacheKey(cappedWidth, keys)
249317 if useCache {
250318 if cached, ok := a.getCachedPrefixedRender(width, key); ok {
251319 return cached
252320 }
253321 }
254− focused := a.sty.Messages.AssistantFocused.Render()
255− blurred := a.sty.Messages.AssistantBlurred.Render()
256− prefix := blurred
322+ // Only render the style actually used; rendering both wastes work
323+ // (lipgloss.Render is not free) when one is discarded.
324+ var prefix string
257325 if a.focused {
258− prefix = focused
326+ prefix = a.sty.Messages.AssistantFocused.Render()
327+ } else {
328+ prefix = a.sty.Messages.AssistantBlurred.Render()
259329 }
260− rendered := a.RawRender(width)
330+ rendered := a.rawRender(width, keys)
261331 lines := strings.Split(rendered, "\n")
262332 var sb strings.Builder
263333 for i, line := range lines {
@@ -283,27 +353,17 @@ func (a *AssistantMessageItem) Render(width int) string {
283353// folded in too because it controls the composition of
284354// renderMessageContent (e.g. appending the constant "Canceled"
285355// string) — that decision lives outside any section's own hash.
286−func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
287− thinkSrc, thinkExtra := a.thinkingKey()
288− contentSrc, contentExtra := a.contentKey()
289− errSrc, errExtra := a.errorKey()
290− h := fnv.New64a()
291− var buf [8]byte
292− writeU64 := func(v uint64) {
293− for i := range 8 {
294− buf[i] = byte(v >> (8 * i))
295− }
296− _, _ = h.Write(buf[:])
297− }
298− writeU64(uint64(cappedWidth))
299− writeU64(thinkSrc)
300− writeU64(thinkExtra)
301− writeU64(contentSrc)
302− writeU64(contentExtra)
303− writeU64(errSrc)
304− writeU64(errExtra)
305− writeU64(a.compositionKey())
306− fingerprint := h.Sum64()
356+func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int, keys assistantRenderKeys) uint64 {
357+ h := uint64(fnvOffset64)
358+ h = fnvAddU64(h, uint64(cappedWidth))
359+ h = fnvAddU64(h, keys.thinkSrc)
360+ h = fnvAddU64(h, keys.thinkExtra)
361+ h = fnvAddU64(h, keys.contentSrc)
362+ h = fnvAddU64(h, keys.contentExtra)
363+ h = fnvAddU64(h, keys.errSrc)
364+ h = fnvAddU64(h, keys.errExtra)
365+ h = fnvAddU64(h, a.compositionKey())
366+ fingerprint := h
307367 var focusBit uint64
308368 if a.focused {
309369 focusBit = 1
@@ -332,20 +392,20 @@ func (a *AssistantMessageItem) compositionKey() uint64 {
332392// content, and finish reason. Each section is served from its own cache;
333393// only the section whose source text or extras changed since the last
334394// render is recomputed.
335−func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
395+func (a *AssistantMessageItem) renderMessageContent(width int, keys assistantRenderKeys) (string, int) {
336396 var messageParts []string
337397 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
338398 content := strings.TrimSpace(a.message.Content().Text)
339399
340400 if thinking != "" {
341− messageParts = append(messageParts, a.cachedThinking(width))
401+ messageParts = append(messageParts, a.cachedThinking(width, keys))
342402 }
343403
344404 if content != "" {
345405 if thinking != "" {
346406 messageParts = append(messageParts, "")
347407 }
348− messageParts = append(messageParts, a.cachedContent(width))
408+ messageParts = append(messageParts, a.cachedContent(width, keys))
349409 }
350410
351411 if a.message.IsFinished() {
@@ -353,7 +413,7 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
353413 case message.FinishReasonCanceled:
354414 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
355415 case message.FinishReasonError:
356− messageParts = append(messageParts, a.cachedError(width))
416+ messageParts = append(messageParts, a.cachedError(width, keys))
357417 }
358418 }
359419
@@ -366,11 +426,11 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
366426// thinking text that affects the rendered output: the view mode
367427// (collapsed / tail-window / full) and the footer state (which
368428// depends on IsThinking, ToolCalls, and ThinkingDuration).
369−func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
429+func (a *AssistantMessageItem) thinkingKey(toolCallCount int) (uint64, uint64) {
370430 thinking := a.message.ReasoningContent().Thinking
371431 srcHash := fnv64(thinking)
372432
373− showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
433+ showFooter := !a.message.IsThinking() || toolCallCount > 0
374434 var durationStr string
375435 if showFooter {
376436 duration := a.message.ThinkingDuration()
@@ -417,20 +477,20 @@ func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
417477// caching it on miss. The thinking-box height (used for click target
418478// detection) is preserved across hits via assistantSection.aux so the
419479// cached path never desyncs click detection.
420−func (a *AssistantMessageItem) cachedThinking(width int) string {
421− srcHash, extra := a.thinkingKey()
480+func (a *AssistantMessageItem) cachedThinking(width int, keys assistantRenderKeys) string {
481+ srcHash, extra := keys.thinkSrc, keys.thinkExtra
422482 if a.thinkingSec.hit(width, srcHash, extra) {
423483 a.thinkingBoxHeight = a.thinkingSec.aux
424484 return a.thinkingSec.out
425485 }
426− out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
486+ out := a.renderThinking(a.message.ReasoningContent().Thinking, width, keys.toolCallCount)
427487 a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
428488 return out
429489}
430490
431491// cachedContent returns the rendered content section.
432−func (a *AssistantMessageItem) cachedContent(width int) string {
433− srcHash, extra := a.contentKey()
492+func (a *AssistantMessageItem) cachedContent(width int, keys assistantRenderKeys) string {
493+ srcHash, extra := keys.contentSrc, keys.contentExtra
434494 if a.contentSec.hit(width, srcHash, extra) {
435495 return a.contentSec.out
436496 }
@@ -440,8 +500,8 @@ func (a *AssistantMessageItem) cachedContent(width int) string {
440500}
441501
442502// cachedError returns the rendered error section.
443−func (a *AssistantMessageItem) cachedError(width int) string {
444− srcHash, extra := a.errorKey()
503+func (a *AssistantMessageItem) cachedError(width int, keys assistantRenderKeys) string {
504+ srcHash, extra := keys.errSrc, keys.errExtra
445505 if a.errorSec.hit(width, srcHash, extra) {
446506 return a.errorSec.out
447507 }
@@ -457,7 +517,7 @@ func (a *AssistantMessageItem) cachedError(width int) string {
457517// boundary problem §4.4 of the design note flags. The bordered
458518// ThinkingBox style is applied on top of the (already-windowed)
459519// lines so the visual box matches what the user sees today.
460−func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
520+func (a *AssistantMessageItem) renderThinking(thinking string, width int, toolCallCount int) string {
461521 renderer := common.QuietMarkdownRenderer(a.sty, width)
462522
463523 // Cap the raw text handed to glamour while the block is windowed.
@@ -509,7 +569,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
509569
510570 var footer string
511571 // if thinking is done add the thought for footer
512− if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
572+ if !a.message.IsThinking() || toolCallCount > 0 {
513573 duration := a.message.ThinkingDuration()
514574 if duration.String() != "0s" {
515575 footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
@@ -647,11 +707,16 @@ func (a *AssistantMessageItem) renderError(width int) string {
647707
648708// isSpinning returns true if the assistant message is still generating.
649709func (a *AssistantMessageItem) isSpinning() bool {
710+ return a.isSpinningWithToolCalls(len(a.message.ToolCalls()))
711+}
712+
713+// isSpinningWithToolCalls is isSpinning given an already-computed
714+// tool-call count, so the render path avoids re-materializing ToolCalls().
715+func (a *AssistantMessageItem) isSpinningWithToolCalls(toolCallCount int) bool {
650716 isThinking := a.message.IsThinking()
651717 isFinished := a.message.IsFinished()
652718 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
653− hasToolCalls := len(a.message.ToolCalls()) > 0
654− return (isThinking || !isFinished) && !hasContent && !hasToolCalls
719+ return (isThinking || !isFinished) && !hasContent && toolCallCount == 0
655720}
656721
657722// SetMessage is used to update the underlying message. Only the
internal/ui/chat/assistant_section_cache_test.go+3−3
@@ -149,8 +149,8 @@ func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
149149 itemA := NewAssistantMessageItem(&sty, msgA).(*AssistantMessageItem)
150150 itemB := NewAssistantMessageItem(&sty, msgB).(*AssistantMessageItem)
151151
152− thinkSrcA, _ := itemA.thinkingKey()
153− thinkSrcB, _ := itemB.thinkingKey()
152+ thinkSrcA, _ := itemA.thinkingKey(0)
153+ thinkSrcB, _ := itemB.thinkingKey(0)
154154 require.NotEqual(t, thinkSrcA, thinkSrcB,
155155 "distinct thinking text must produce distinct FNV-64 source hashes")
156156
@@ -162,7 +162,7 @@ func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
162162 // Identical source text on a fresh item must produce the same
163163 // hashes — keying invariant for cache hits.
164164 itemAClone := NewAssistantMessageItem(&sty, thinkingMessage("a3", "thinking A", "content A")).(*AssistantMessageItem)
165− thinkSrcAClone, _ := itemAClone.thinkingKey()
165+ thinkSrcAClone, _ := itemAClone.thinkingKey(0)
166166 contentSrcAClone, _ := itemAClone.contentKey()
167167 require.Equal(t, thinkSrcA, thinkSrcAClone)
168168 require.Equal(t, contentSrcA, contentSrcAClone)
internal/ui/chat/shell.go+83−30
@@ -42,6 +42,21 @@ type ShellItem struct {
4242 sty *styles.Styles
4343 pending bool
4444 anim *anim.Anim
45+
46+ // xOffset-independent render cache. The expensive part of RawRender
47+ // (ANSI-reset trim, RemapANSI16, split, per-line width) does not
48+ // depend on the horizontal scroll offset, yet hscroll/highlight
49+ // Bump() forces a full re-render on every keystroke. Cache the
50+ // prepared display lines keyed by the inputs that actually affect
51+ // them, and reapply only the xOffset windowing per render.
52+ shellCacheValid bool
53+ shellCacheOutput string
54+ shellCacheExpanded bool
55+ shellCacheWidth int
56+ shellCachePending bool
57+ shellCacheLines []string
58+ shellCacheTruncated int
59+ shellCacheMaxWidth int
4560}
4661
4762var (
@@ -181,6 +196,14 @@ func (s *ShellItem) ScrollHorizontal(delta int) {
181196 s.Bump()
182197}
183198
199+// clearCache drops the base render cache and the xOffset-independent
200+// output cache. The latter embeds the current theme's remapped ANSI
201+// colors (via RemapANSI16), so a style change must re-run the remap.
202+func (s *ShellItem) clearCache() {
203+ s.cachedMessageItem.clearCache()
204+ s.shellCacheValid = false
205+}
206+
184207// ToggleExpanded toggles the expanded state and invalidates the cache.
185208func (s *ShellItem) ToggleExpanded() bool {
186209 s.expandedContent = !s.expandedContent
@@ -188,33 +211,19 @@ func (s *ShellItem) ToggleExpanded() bool {
188211 return s.expandedContent
189212}
190213
191−func (s *ShellItem) RawRender(width int) string {
192− cappedWidth := cappedMessageWidth(width)
193−
194− cmd := strings.ReplaceAll(s.command, "\n", " ")
195− cmd = strings.ReplaceAll(cmd, "\t", " ")
196−
197− var prompt string
198− if s.focused {
199− prompt = s.sty.Messages.ShellPrompt.Render("$")
200− } else {
201− prompt = s.sty.Messages.ShellPromptBlurred.Render("$")
202− }
203−
204− highlighted := s.sty.Messages.ShellCommand.Render(cmd)
205− header := prompt + " " + highlighted
206−
207− if s.pending {
208− if s.output == "" {
209− // Nothing streamed yet: show the spinner under the header.
210− return header + "\n" + s.anim.Render()
211− }
212− } else if s.exitCode != 0 {
213− header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
214− }
215−
216− if s.output == "" {
217− return header
214+// prepareOutput computes the xOffset-independent render intermediate:
215+// the trimmed, ANSI-remapped, split-and-truncated display lines plus the
216+// max-line-width clamp value. The result is cached and only recomputed
217+// when the output, expansion, capped width, or pending state change, so a
218+// horizontal-scroll keystroke (which Bumps the list cache) no longer
219+// re-runs the ANSI trim, RemapANSI16, split, and per-line width scan.
220+func (s *ShellItem) prepareOutput(cappedWidth int) (displayLines []string, truncatedCount, maxLineWidth int) {
221+ if s.shellCacheValid &&
222+ s.shellCacheOutput == s.output &&
223+ s.shellCacheExpanded == s.expandedContent &&
224+ s.shellCacheWidth == cappedWidth &&
225+ s.shellCachePending == s.pending {
226+ return s.shellCacheLines, s.shellCacheTruncated, s.shellCacheMaxWidth
218227 }
219228
220229 // Remap raw ANSI 16-color codes onto legible Charmtone colors so
@@ -243,8 +252,8 @@ func (s *ShellItem) RawRender(width int) string {
243252 maxLines = len(lines)
244253 }
245254
246− displayLines := lines
247− truncatedCount := 0
255+ displayLines = lines
256+ truncatedCount = 0
248257 if len(lines) > maxLines {
249258 if s.pending {
250259 // Show the most recent lines while still running.
@@ -263,7 +272,51 @@ func (s *ShellItem) RawRender(width int) string {
263272 maxW = w
264273 }
265274 }
266− s.maxLineWidth = max(0, maxW-cappedWidth)
275+ maxLineWidth = max(0, maxW-cappedWidth)
276+
277+ s.shellCacheValid = true
278+ s.shellCacheOutput = s.output
279+ s.shellCacheExpanded = s.expandedContent
280+ s.shellCacheWidth = cappedWidth
281+ s.shellCachePending = s.pending
282+ s.shellCacheLines = displayLines
283+ s.shellCacheTruncated = truncatedCount
284+ s.shellCacheMaxWidth = maxLineWidth
285+
286+ return displayLines, truncatedCount, maxLineWidth
287+}
288+
289+func (s *ShellItem) RawRender(width int) string {
290+ cappedWidth := cappedMessageWidth(width)
291+
292+ cmd := strings.ReplaceAll(s.command, "\n", " ")
293+ cmd = strings.ReplaceAll(cmd, "\t", " ")
294+
295+ var prompt string
296+ if s.focused {
297+ prompt = s.sty.Messages.ShellPrompt.Render("$")
298+ } else {
299+ prompt = s.sty.Messages.ShellPromptBlurred.Render("$")
300+ }
301+
302+ highlighted := s.sty.Messages.ShellCommand.Render(cmd)
303+ header := prompt + " " + highlighted
304+
305+ if s.pending {
306+ if s.output == "" {
307+ // Nothing streamed yet: show the spinner under the header.
308+ return header + "\n" + s.anim.Render()
309+ }
310+ } else if s.exitCode != 0 {
311+ header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
312+ }
313+
314+ if s.output == "" {
315+ return header
316+ }
317+
318+ displayLines, truncatedCount, maxLineWidth := s.prepareOutput(cappedWidth)
319+ s.maxLineWidth = maxLineWidth
267320
268321 var body strings.Builder
269322
internal/ui/common/diff.go+20−1
@@ -1,16 +1,35 @@
11package common
22
33import (
4+ "sync"
5+
46 "github.com/alecthomas/chroma/v2"
57 "github.com/charmbracelet/crush/internal/ui/diffview"
68 "github.com/charmbracelet/crush/internal/ui/styles"
79)
810
11+// diffStyleCache memoizes the *chroma.Style built for a given theme. Building
12+// the style (MustNewStyle) parses colors for every entry and was previously
13+// done on every DiffFormatter call. The Styles struct is mutated in place on
14+// theme change, so we key on the theme's content fingerprint rather than its
15+// pointer identity.
16+var diffStyleCache sync.Map // map[uint64]*chroma.Style
17+
918// DiffFormatter returns a diff formatter with the given styles that can be
1019// used to format diff outputs.
1120func DiffFormatter(s *styles.Styles) *diffview.DiffView {
1221 formatDiff := diffview.New()
13− style := chroma.MustNewStyle("crush", s.ChromaTheme())
22+
23+ theme := s.ChromaTheme()
24+ key := chromaStyleKey(theme, nil)
25+ var style *chroma.Style
26+ if cached, ok := diffStyleCache.Load(key); ok {
27+ style = cached.(*chroma.Style)
28+ } else {
29+ style = chroma.MustNewStyle("crush", theme)
30+ diffStyleCache.Store(key, style)
31+ }
32+
1433 diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
1534 return diff
1635}
internal/ui/common/highlight.go+7−5
@@ -38,12 +38,14 @@ func chromaStyleKey(theme chroma.StyleEntries, bg color.Color) uint64 {
3838 h.Write([]byte(theme[chroma.TokenType(tt)]))
3939 h.Write([]byte{0})
4040 }
41− r, g, b, a := bg.RGBA()
4241 var buf [8]byte
43− buf[0] = byte(r >> 8)
44− buf[1] = byte(g >> 8)
45− buf[2] = byte(b >> 8)
46− buf[3] = byte(a >> 8)
42+ if bg != nil {
43+ r, g, b, a := bg.RGBA()
44+ buf[0] = byte(r >> 8)
45+ buf[1] = byte(g >> 8)
46+ buf[2] = byte(b >> 8)
47+ buf[3] = byte(a >> 8)
48+ }
4749 h.Write(buf[:4])
4850 return h.Sum64()
4951}
internal/ui/completions/completions.go+6−4
@@ -230,11 +230,13 @@ func (c *Completions) applyNamePriorityFilter(query string) {
230230 }
231231
232232 c.list.SetItems(c.allItems...)
233+ // SetFilter computes the filtered set (a single fuzzy pass) and stores it
234+ // in the list; read it back instead of recomputing via FilteredItems.
233235 c.list.SetFilter(query)
234− raw := c.list.FilteredItems()
235− filtered := make([]list.FilterableItem, 0, len(raw))
236− for _, item := range raw {
237− filterable, ok := item.(list.FilterableItem)
236+ n := c.list.Len()
237+ filtered := make([]list.FilterableItem, 0, n)
238+ for i := range n {
239+ filterable, ok := c.list.ItemAt(i).(list.FilterableItem)
238240 if !ok {
239241 continue
240242 }
internal/ui/dialog/arguments.go+20−10
@@ -23,6 +23,21 @@ import (
2323// ArgumentsID is the identifier for the arguments dialog.
2424const ArgumentsID = "arguments"
2525
26+// argumentTitleCaser title-cases argument labels. It is stateless and safe to
27+// reuse, so it is kept package-level to avoid rebuilding it on every Draw.
28+var argumentTitleCaser = cases.Title(language.English)
29+
30+// prettyArgumentLabel converts an argument title into a human-friendly label.
31+func prettyArgumentLabel(title string) string {
32+ title = strings.ReplaceAll(title, "_", " ")
33+ title = strings.ReplaceAll(title, "-", " ")
34+ titleParts := strings.Fields(title)
35+ for i, part := range titleParts {
36+ titleParts[i] = argumentTitleCaser.String(strings.ToLower(part))
37+ }
38+ return strings.Join(titleParts, " ")
39+}
40+
2641// Dialog sizing for arguments.
2742const (
2843 maxInputWidth = 120
@@ -36,6 +51,7 @@ type Arguments struct {
3651 com *common.Common
3752 title string
3853 arguments []commands.Argument
54+ labels []string // pretty labels, precomputed from argument titles
3955 inputs []textinput.Model
4056 focused int
4157 spinner spinner.Model
@@ -88,7 +104,9 @@ func NewArguments(com *common.Common, title, description string, arguments []com
88104
89105 // Create input fields for each argument.
90106 a.inputs = make([]textinput.Model, len(arguments))
107+ a.labels = make([]string, len(arguments))
91108 for i, arg := range arguments {
109+ a.labels[i] = prettyArgumentLabel(arg.Title)
92110 input := textinput.New()
93111 input.SetVirtualCursor(false)
94112 input.SetStyles(com.Styles.TextInput)
@@ -262,20 +280,12 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
262280 dialogContentStyle := s.Dialog.Arguments.Content
263281 possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
264282 // Build fields with label and input.
265− caser := cases.Title(language.English)
266−
267283 var fields []string
268284 for i, arg := range a.arguments {
269285 isFocused := i == a.focused
270286
271− // Try to pretty up the title for the label.
272− title := strings.ReplaceAll(arg.Title, "_", " ")
273− title = strings.ReplaceAll(title, "-", " ")
274− titleParts := strings.Fields(title)
275− for i, part := range titleParts {
276− titleParts[i] = caser.String(strings.ToLower(part))
277− }
278− labelText := strings.Join(titleParts, " ")
287+ // Label was pretty-printed once at construction.
288+ labelText := a.labels[i]
279289
280290 markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
281291
internal/ui/dialog/models_list.go+32−37
@@ -1,9 +1,6 @@
11package dialog
22
33import (
4− "fmt"
5− "slices"
6− "sort"
74 "strings"
85
96 "github.com/charmbracelet/crush/internal/ui/list"
@@ -190,58 +187,56 @@ func (f *ModelsList) VisibleItems() []list.Item {
190187 return items
191188 }
192189
193− filterableItems := make([]list.FilterableItem, 0, f.Len())
190+ // Build the match text once across all items (O(N) rather than O(N*G)).
191+ // Each entry is prefixed with its group's lowercased title, exactly as the
192+ // previous per-group code matched against.
193+ names := make([]string, 0, f.Len())
194194 for _, g := range f.groups {
195+ prefix := strings.ToLower(g.Title) + " "
195196 for _, item := range g.Items {
196− filterableItems = append(filterableItems, item)
197+ names = append(names, prefix+item.Filter())
197198 }
198199 }
199200
201+ // Single fuzzy pass, then bucket matches back to their group by index.
202+ matches := fuzzy.Find(query, names)
203+ matched := make([]*fuzzy.Match, len(names))
204+ for i := range matches {
205+ matched[matches[i].Index] = &matches[i]
206+ }
207+
200208 items := []list.Item{}
201− visitedGroups := map[int]bool{}
202209
203− // Reconstruct groups with matched items
204− // Find which group this item belongs to
205− for gi, g := range f.groups {
210+ // Reconstruct groups with matched items, preserving original group and
211+ // within-group item order.
212+ idx := 0
213+ for _, g := range f.groups {
214+ prefixLen := len(strings.ToLower(g.Title) + " ")
206215 addedCount := 0
207− name := strings.ToLower(g.Title) + " "
208−
209− names := make([]string, len(filterableItems))
210− for i, item := range filterableItems {
211− ms := item.(*ModelItem)
212− names[i] = fmt.Sprintf("%s%s", name, ms.Filter())
213− }
214−
215− matches := fuzzy.Find(query, names)
216−
217− // Sort by original index to preserve order within the group
218− sort.SliceStable(matches, func(i, j int) bool {
219− return matches[i].Index < matches[j].Index
220− })
221−
222− for _, match := range matches {
223− item := filterableItems[match.Index].(*ModelItem)
224− idxs := []int{}
225− for _, idx := range match.MatchedIndexes {
226− // Adjusts removing provider name highlights
227− if idx < len(name) {
228− continue
216+ for _, item := range g.Items {
217+ if m := matched[idx]; m != nil {
218+ idxs := []int{}
219+ for _, mIdx := range m.MatchedIndexes {
220+ // Adjusts removing provider name highlights
221+ if mIdx < prefixLen {
222+ continue
223+ }
224+ idxs = append(idxs, mIdx-prefixLen)
229225 }
230− idxs = append(idxs, idx-len(name))
231− }
232226
233− match.MatchedIndexes = idxs
234− if slices.Contains(g.Items, item) {
235− if !visitedGroups[gi] {
227+ match := *m
228+ match.MatchedIndexes = idxs
229+ if addedCount == 0 {
236230 // Add section header
231+ g := g
237232 items = append(items, &g)
238− visitedGroups[gi] = true
239233 }
240234 // Add the matched item
241235 item.SetMatch(match)
242236 items = append(items, item)
243237 addedCount++
244238 }
239+ idx++
245240 }
246241 if addedCount > 0 {
247242 // Add a space separator after each provider section
internal/ui/dialog/permissions.go+43−11
@@ -15,6 +15,7 @@ import (
1515 "github.com/charmbracelet/crush/internal/permission"
1616 "github.com/charmbracelet/crush/internal/stringext"
1717 "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/ui/diffview"
1819 "github.com/charmbracelet/crush/internal/ui/styles"
1920 uv "github.com/charmbracelet/ultraviolet"
2021)
@@ -73,6 +74,13 @@ type Permissions struct {
7374 diffXOffset int // horizontal scroll offset for diff view
7475 unifiedDiffContent string
7576 splitDiffContent string
77+ // diffView is a persistent diff renderer, reused across scrolls/width
78+ // changes so its syntax and diff caches are preserved.
79+ diffView *diffview.DiffView
80+
81+ // Cached non-diff content, invalidated by viewportDirty or a width change.
82+ cachedContent string
83+ cachedContentWidth int
7684
7785 help help.Model
7886 keyMap permissionsKeyMap
@@ -527,28 +535,44 @@ func prettyName(name string) string {
527535}
528536
529537func (p *Permissions) renderContent(width int) string {
538+ // Diff views manage their own caching (see renderDiff).
530539 switch p.permission.ToolName {
531− case tools.BashToolName:
532− return p.renderBashContent(width)
533540 case tools.EditToolName:
534541 return p.renderEditContent(width)
535542 case tools.WriteToolName:
536543 return p.renderWriteContent(width)
537544 case tools.MultiEditToolName:
538545 return p.renderMultiEditContent(width)
546+ }
547+
548+ // Non-diff content is cached the same way the diff is: recompute only when
549+ // the viewport is dirty or the width changed. This avoids re-running JSON
550+ // (un)marshalling, syntax highlighting and wrapping on every frame.
551+ if !p.viewportDirty && width == p.cachedContentWidth {
552+ return p.cachedContent
553+ }
554+
555+ var content string
556+ switch p.permission.ToolName {
557+ case tools.BashToolName:
558+ content = p.renderBashContent(width)
539559 case tools.DownloadToolName:
540− return p.renderDownloadContent(width)
560+ content = p.renderDownloadContent(width)
541561 case tools.FetchToolName:
542− return p.renderFetchContent(width)
562+ content = p.renderFetchContent(width)
543563 case tools.AgenticFetchToolName:
544− return p.renderAgenticFetchContent(width)
564+ content = p.renderAgenticFetchContent(width)
545565 case tools.ViewToolName:
546− return p.renderViewContent(width)
566+ content = p.renderViewContent(width)
547567 case tools.LSToolName:
548− return p.renderLSContent(width)
568+ content = p.renderLSContent(width)
549569 default:
550− return p.renderDefaultContent(width)
570+ content = p.renderDefaultContent(width)
551571 }
572+
573+ p.cachedContent = content
574+ p.cachedContentWidth = width
575+ return content
552576}
553577
554578func (p *Permissions) renderBashContent(width int) string {
@@ -593,9 +617,17 @@ func (p *Permissions) renderDiff(filePath, oldContent, newContent string, conten
593617 }
594618
595619 isSplitMode := p.isSplitMode()
596− formatter := common.DiffFormatter(p.com.Styles).
597− Before(fsext.PrettyPath(filePath), oldContent).
598− After(fsext.PrettyPath(filePath), newContent).
620+
621+ // Build the diff view once. Its content never changes for the lifetime of
622+ // the dialog, so reusing it across scrolls and width changes preserves the
623+ // syntax/diff caches; only XOffset/Width/layout are updated per render.
624+ if p.diffView == nil {
625+ p.diffView = common.DiffFormatter(p.com.Styles).
626+ Before(fsext.PrettyPath(filePath), oldContent).
627+ After(fsext.PrettyPath(filePath), newContent)
628+ }
629+
630+ formatter := p.diffView.
599631 XOffset(p.diffXOffset).
600632 Width(contentWidth)
601633
internal/ui/diffview/util.go+8−1
@@ -2,13 +2,20 @@ package diffview
22
33import (
44 "fmt"
5+ "strconv"
56 "strings"
67
78 "github.com/charmbracelet/x/ansi"
89)
910
1011func pad(v any, width int) string {
11− s := fmt.Sprintf("%v", v)
12+ var s string
13+ switch n := v.(type) {
14+ case int:
15+ s = strconv.Itoa(n)
16+ default:
17+ s = fmt.Sprintf("%v", v)
18+ }
1219 w := ansi.StringWidth(s)
1320 if w >= width {
1421 return s
internal/ui/list/filterable.go+11−1
@@ -23,6 +23,9 @@ type FilterableList struct {
2323 *List
2424 items []FilterableItem
2525 query string
26+ // dirty tracks whether the filtered set needs to be recomputed. It is set
27+ // by any mutation of items or query and cleared after Render recomputes.
28+ dirty bool
2629}
2730
2831// NewFilterableList creates a new filterable list.
@@ -39,6 +42,7 @@ func NewFilterableList(items ...FilterableItem) *FilterableList {
3942// SetItems sets the list items and updates the filtered items.
4043func (f *FilterableList) SetItems(items ...FilterableItem) {
4144 f.items = items
45+ f.dirty = true
4246 fitems := make([]Item, len(items))
4347 for i, item := range items {
4448 fitems[i] = item
@@ -49,6 +53,7 @@ func (f *FilterableList) SetItems(items ...FilterableItem) {
4953// AppendItems appends items to the list and updates the filtered items.
5054func (f *FilterableList) AppendItems(items ...FilterableItem) {
5155 f.items = append(f.items, items...)
56+ f.dirty = true
5257 itms := make([]Item, len(f.items))
5358 for i, item := range f.items {
5459 itms[i] = item
@@ -59,6 +64,7 @@ func (f *FilterableList) AppendItems(items ...FilterableItem) {
5964// PrependItems prepends items to the list and updates the filtered items.
6065func (f *FilterableList) PrependItems(items ...FilterableItem) {
6166 f.items = append(items, f.items...)
67+ f.dirty = true
6268 itms := make([]Item, len(f.items))
6369 for i, item := range f.items {
6470 itms[i] = item
@@ -69,6 +75,7 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) {
6975// SetFilter sets the filter query and updates the list items.
7076func (f *FilterableList) SetFilter(q string) {
7177 f.query = q
78+ f.dirty = true
7279 f.List.SetItems(f.FilteredItems()...)
7380 f.ScrollToTop()
7481}
@@ -120,6 +127,9 @@ func (f *FilterableList) FilteredItems() []Item {
120127
121128// Render renders the filterable list.
122129func (f *FilterableList) Render() string {
123− f.List.SetItems(f.FilteredItems()...)
130+ if f.dirty {
131+ f.List.SetItems(f.FilteredItems()...)
132+ f.dirty = false
133+ }
124134 return f.List.Render()
125135}
internal/ui/list/highlight.go+2−1
@@ -136,9 +136,10 @@ func HighlightBuffer(content string, area image.Rectangle, startLine, startCol,
136136
137137// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
138138func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
139+ uvStyle := ToStyle(lgStyle)
139140 return func(_ int, _ int, c *uv.Cell) *uv.Cell {
140141 if c != nil {
141− c.Style = ToStyle(lgStyle)
142+ c.Style = uvStyle
142143 }
143144 return c
144145 }
internal/ui/model/chat.go−4
@@ -1106,10 +1106,6 @@ func findWordBoundaries(line string, col int) (startCol, endCol int) {
11061106 return 0, 0
11071107 }
11081108
1109− i := displaywidth.StringGraphemes(line)
1110− for i.Next() {
1111− }
1112−
11131109 // Segment the line into words using UAX#29.
11141110 lineCol := 0 // tracks the visited column widths
11151111 lastCol := 0 // tracks the start of the current token
internal/ui/model/header.go+62−10
@@ -30,6 +30,15 @@ type header struct {
3030 com *common.Common
3131 width int
3232 compact bool
33+
34+ // compact-header render cache. drawHeader otherwise rebuilds the whole
35+ // compact header (LSP loop, config/model lookups, several
36+ // lipgloss.Render calls, PrettyPath+DirTrim, ansi.Truncate) every
37+ // frame; memoize the rendered string keyed by a cheap fingerprint of
38+ // everything it depends on.
39+ compactCacheKey string
40+ compactCacheView string
41+ hasCompactCache bool
3342}
3443
3544// newHeader creates a new header model.
@@ -59,9 +68,13 @@ func (h *header) refresh() {
5968 // Force drawHeader to re-render the wide logo on the next frame.
6069 h.width = 0
6170 h.logo = ""
71+ // The compact-header cache embeds the old theme's styles; drop it.
72+ h.hasCompactCache = false
6273}
6374
64−// drawHeader draws the header for the given session.
75+// drawHeader draws the header for the given session. lspErrorCount is the
76+// total diagnostic count summed by the caller from local LSP state, so
77+// this render path never issues an LSPGetStates RPC.
6578func (h *header) drawHeader(
6679 scr uv.Screen,
6780 area uv.Rectangle,
@@ -70,8 +83,8 @@ func (h *header) drawHeader(
7083 detailsOpen bool,
7184 width int,
7285 hyperCredits *int,
86+ lspErrorCount int,
7387) {
74− t := h.com.Styles
7588 if width != h.width || compact != h.compact {
7689 h.logo = renderLogo(h.com.Styles, compact, h.com.IsHyper(), width)
7790 }
@@ -88,14 +101,56 @@ func (h *header) drawHeader(
88101 return
89102 }
90103
104+ key := h.compactFingerprint(session, detailsOpen, width, hyperCredits, lspErrorCount)
105+ if !h.hasCompactCache || key != h.compactCacheKey {
106+ h.compactCacheView = h.buildCompactHeader(session, detailsOpen, width, hyperCredits, lspErrorCount)
107+ h.compactCacheKey = key
108+ h.hasCompactCache = true
109+ }
110+
111+ uv.NewStyledString(h.compactCacheView).Draw(scr, area)
112+}
113+
114+// compactFingerprint hashes every input that affects the compact header
115+// render, so drawHeader can reuse the cached string when nothing changed.
116+func (h *header) compactFingerprint(
117+ session *session.Session,
118+ detailsOpen bool,
119+ width int,
120+ hyperCredits *int,
121+ lspErrorCount int,
122+) string {
123+ var contextWindow int64
124+ agentCfg := h.com.Config().Agents[config.AgentCoder]
125+ if model := h.com.Config().GetModelByType(agentCfg.Model); model != nil {
126+ contextWindow = model.ContextWindow
127+ }
128+ credits := 0
129+ hasCredits := hyperCredits != nil
130+ if hasCredits {
131+ credits = *hyperCredits
132+ }
133+ return fmt.Sprintf("w=%d;sid=%s;ct=%d;pt=%d;eu=%t;lsp=%d;det=%t;hyper=%t;hc=%t/%d;cw=%d;cwd=%s",
134+ width, session.ID,
135+ session.CompletionTokens, session.PromptTokens, session.EstimatedUsage,
136+ lspErrorCount, detailsOpen, h.com.IsHyper(), hasCredits, credits,
137+ contextWindow, h.com.Workspace.WorkingDir())
138+}
139+
140+// buildCompactHeader renders the compact header string.
141+func (h *header) buildCompactHeader(
142+ session *session.Session,
143+ detailsOpen bool,
144+ width int,
145+ hyperCredits *int,
146+ lspErrorCount int,
147+) string {
148+ t := h.com.Styles
149+
91150 var b strings.Builder
92151 b.WriteString(h.compactLogo)
93152
94153 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
95− lspErrorCount := 0
96− for _, info := range h.com.Workspace.LSPGetStates() {
97− lspErrorCount += info.DiagnosticCount
98− }
99154 details := renderHeaderDetails(
100155 h.com,
101156 session,
@@ -121,10 +176,7 @@ func (h *header) drawHeader(
121176
122177 b.WriteString(details)
123178
124− view := uv.NewStyledString(
125− t.Header.Wrapper.Padding(0, rightPadding, 0, leftPadding).Render(b.String()),
126− )
127− view.Draw(scr, area)
179+ return t.Header.Wrapper.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
128180}
129181
130182// renderHeaderDetails renders the details section of the header.
internal/ui/model/landing.go+57−1
@@ -1,9 +1,11 @@
11package model
22
33import (
4+ "fmt"
45 "image"
56
67 "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/config"
79 "github.com/charmbracelet/crush/internal/ui/common"
810 "github.com/charmbracelet/crush/internal/workspace"
911 "github.com/charmbracelet/ultraviolet/layout"
@@ -11,14 +13,68 @@ import (
1113
1214// selectedLargeModel returns the currently selected large language model from
1315// the agent coordinator, if one exists.
16+//
17+// Resolving the model hits the agent coordinator, which is an RPC
18+// (AgentIsReady + AgentModel) in client mode. Because this runs on the
19+// per-frame sidebar fingerprint path, the result is cached against a
20+// cheap, locally-cached config fingerprint (largeModelConfigKey) and
21+// only re-fetched when the selection changes. A nil result is never
22+// cached, so the not-ready → ready transition is picked up on the next
23+// frame.
1424func (m *UI) selectedLargeModel() *workspace.AgentModel {
25+ key := m.largeModelConfigKey()
26+ if m.largeModel != nil && key == m.largeModelCacheKey {
27+ return m.largeModel
28+ }
29+ m.largeModelCacheKey = key
1530 if m.com.Workspace.AgentIsReady() {
1631 model := m.com.Workspace.AgentModel()
17− return &model
32+ m.largeModel = &model
33+ return m.largeModel
1834 }
35+ m.largeModel = nil
1936 return nil
2037}
2138
39+// largeModelConfigKey builds a cheap fingerprint of the locally-cached
40+// config that determines the selected large (coder) model. The resolved
41+// model is cached against this key so selectedLargeModel only re-fetches
42+// when the selection actually changes. The cached workspace config is
43+// refreshed on every server-side config change (ConfigChanged), so
44+// remote model switches flip this key too.
45+func (m *UI) largeModelConfigKey() string {
46+ cfg := m.com.Config()
47+ if cfg == nil {
48+ return ""
49+ }
50+ agentCfg, ok := cfg.Agents[config.AgentCoder]
51+ if !ok {
52+ return "no-agent"
53+ }
54+ sm, ok := cfg.Models[agentCfg.Model]
55+ if !ok {
56+ return "no-model:" + string(agentCfg.Model)
57+ }
58+ // Fold in the catwalk-derived fields that the sidebar renders (looked
59+ // up from the same cached config, no RPC), so a provider-config
60+ // change that alters e.g. the context window or reasoning support for
61+ // the same provider+model still flips the key and forces a refresh.
62+ var cwName, provName string
63+ var contextWindow int64
64+ var canReason bool
65+ if cm := cfg.GetModelByType(agentCfg.Model); cm != nil {
66+ cwName = cm.Name
67+ contextWindow = cm.ContextWindow
68+ canReason = cm.CanReason
69+ }
70+ if pc, ok := cfg.Providers.Get(sm.Provider); ok {
71+ provName = pc.Name
72+ }
73+ return fmt.Sprintf("%s|%s|%s|%t|%s|%s|%s|%d|%t",
74+ agentCfg.Model, sm.Provider, sm.Model, sm.Think, sm.ReasoningEffort,
75+ provName, cwName, contextWindow, canReason)
76+}
77+
2278// landingView renders the landing page view showing the current working
2379// directory, model information, and LSP/MCP status in a two-column layout.
2480func (m *UI) landingView() string {
internal/ui/model/lsp.go+22−1
@@ -20,6 +20,23 @@ type LSPInfo struct {
2020 Diagnostics map[protocol.DiagnosticSeverity]int
2121}
2222
23+// refreshLSPState re-fetches the LSP client states and their per-severity
24+// diagnostic counts into the local mirrors (m.lspStates / m.lspDiagnostics).
25+// It is called from the LSP event handlers (state change / diagnostics
26+// change) so the sidebar fingerprint — computed every frame — can read
27+// fresh values from local state instead of issuing per-LSP diagnostics
28+// RPCs on the render hot path. Every diagnostics change publishes an LSP
29+// event (see internal/app/lsp_events.go), so the mirror always changes
30+// whenever the rendered sidebar would.
31+func (m *UI) refreshLSPState() {
32+ m.lspStates = m.com.Workspace.LSPGetStates()
33+ counts := make(map[string]lsp.DiagnosticCounts, len(m.lspStates))
34+ for name := range m.lspStates {
35+ counts[name] = m.com.Workspace.LSPGetDiagnosticCounts(name)
36+ }
37+ m.lspDiagnostics = counts
38+}
39+
2340// lspInfo renders the LSP status section showing active LSP clients and their
2441// diagnostic counts.
2542func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
@@ -32,7 +49,11 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
3249 var lsps []LSPInfo
3350 for _, state := range states {
3451 lspErrs := map[protocol.DiagnosticSeverity]int{}
35− counts := m.com.Workspace.LSPGetDiagnosticCounts(state.Name)
52+ // Read from the local mirror (refreshed on every LSP event) rather
53+ // than issuing a per-LSP diagnostics RPC on the render path. This
54+ // also keeps the rendered counts consistent with the sidebar
55+ // fingerprint, which reads the same mirror.
56+ counts := m.lspDiagnostics[state.Name]
3657 lspErrs[protocol.SeverityError] = counts.Error
3758 lspErrs[protocol.SeverityWarning] = counts.Warning
3859 lspErrs[protocol.SeverityHint] = counts.Hint
internal/ui/model/mcp.go+8−1
@@ -14,10 +14,17 @@ import (
1414// mcpInfo renders the MCP status section showing active MCP clients and their
1515// tool/prompt counts.
1616func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
17+ return m.mcpInfoSorted(m.com.Config().MCP.Sorted(), width, maxItems, isSection)
18+}
19+
20+// mcpInfoSorted renders the MCP status section from an already-sorted MCP
21+// config list, letting callers on the sidebar render path reuse a single
22+// MCP.Sorted() result instead of recomputing it.
23+func (m *UI) mcpInfoSorted(mcpSorted []config.MCP, width, maxItems int, isSection bool) string {
1724 var mcps []mcp.ClientInfo
1825 t := m.com.Styles
1926
20− for _, mcp := range m.com.Config().MCP.Sorted() {
27+ for _, mcp := range mcpSorted {
2128 if state, ok := m.mcpStates[mcp.Name]; ok {
2229 mcps = append(mcps, state)
2330 }
internal/ui/model/sidebar.go+27−13
@@ -8,6 +8,7 @@ import (
88 "sort"
99
1010 "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/config"
1112 "github.com/charmbracelet/crush/internal/ui/common"
1213 "github.com/charmbracelet/crush/internal/ui/logo"
1314 uv "github.com/charmbracelet/ultraviolet"
@@ -130,8 +131,9 @@ func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skil
130131
131132// sidebarFingerprint computes a hash of every input that affects the
132133// rendered sidebar, so drawSidebar can reuse a cached render when nothing
133−// changed.
134−func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
134+// changed. skillItems and mcpSorted are computed once per render by
135+// drawSidebar and reused by buildSidebar to avoid recomputing them.
136+func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem, mcpSorted []config.MCP) uint64 {
135137 h := fnv.New64a()
136138 fmt.Fprintf(h, "w=%d;h=%d;theme=%s;", area.Dx(), area.Dy(), m.themeKey)
137139
@@ -177,7 +179,12 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
177179 sort.Strings(lspNames)
178180 for _, name := range lspNames {
179181 st := m.lspStates[name]
180− counts := m.com.Workspace.LSPGetDiagnosticCounts(name)
182+ // Read the split diagnostic counts from the local mirror instead
183+ // of issuing a per-LSP diagnostics RPC every frame. The mirror is
184+ // refreshed on every LSP event (state or diagnostics change), the
185+ // same events that keep m.lspStates fresh, so this changes exactly
186+ // when the rendered LSP section would.
187+ counts := m.lspDiagnostics[name]
181188 // Hash the error text, not just its presence: the render prints
182189 // the message, so a changed message within the same state must
183190 // invalidate the cache.
@@ -187,7 +194,7 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
187194 }
188195
189196 // MCPs (config order, matching the render).
190− for _, mcpCfg := range m.com.Config().MCP.Sorted() {
197+ for _, mcpCfg := range mcpSorted {
191198 state, ok := m.mcpStates[mcpCfg.Name]
192199 if !ok {
193200 continue
@@ -198,7 +205,7 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
198205 }
199206
200207 // Skills.
201− for _, it := range m.skillStatusItems() {
208+ for _, it := range skillItems {
202209 fmt.Fprintf(h, "s=%s|%s|%s;", it.icon, it.name, it.title)
203210 }
204211
@@ -221,9 +228,15 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
221228 return
222229 }
223230
224− key := m.sidebarFingerprint(area)
231+ // Compute the skill items and sorted MCP list once per render and
232+ // thread them through both the fingerprint and buildSidebar, instead
233+ // of recomputing (clone+sort / map build) them several times.
234+ skillItems := m.skillStatusItems()
235+ mcpSorted := m.com.Config().MCP.Sorted()
236+
237+ key := m.sidebarFingerprint(area, skillItems, mcpSorted)
225238 if !m.hasSidebarCache || key != m.sidebarCacheKey {
226− m.sidebarView = m.buildSidebar(area)
239+ m.sidebarView = m.buildSidebar(area, skillItems, mcpSorted)
227240 m.sidebarCacheKey = key
228241 m.hasSidebarCache = true
229242 }
@@ -231,8 +244,9 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
231244 uv.NewStyledString(m.sidebarView).Draw(scr, area)
232245}
233246
234−// buildSidebar renders the sidebar content block as a string.
235−func (m *UI) buildSidebar(area uv.Rectangle) string {
247+// buildSidebar renders the sidebar content block as a string. skillItems
248+// and mcpSorted are precomputed by drawSidebar and reused here.
249+func (m *UI) buildSidebar(area uv.Rectangle, skillItems []skillStatusItem, mcpSorted []config.MCP) string {
236250 const logoHeightBreakpoint = 30
237251
238252 t := m.com.Styles
@@ -279,19 +293,19 @@ func (m *UI) buildSidebar(area uv.Rectangle) string {
279293 lspsCount := len(m.lspStates)
280294
281295 mcpsCount := 0
282− for _, mcpCfg := range m.com.Config().MCP.Sorted() {
296+ for _, mcpCfg := range mcpSorted {
283297 if _, ok := m.mcpStates[mcpCfg.Name]; ok {
284298 mcpsCount++
285299 }
286300 }
287301
288− skillsCount := len(m.skillStatusItems())
302+ skillsCount := len(skillItems)
289303
290304 maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
291305
292306 lspSection := m.lspInfo(width, maxLSPs, true)
293− mcpSection := m.mcpInfo(width, maxMCPs, true)
294− skillsSection := m.skillsInfo(width, maxSkills, true)
307+ mcpSection := m.mcpInfoSorted(mcpSorted, width, maxMCPs, true)
308+ skillsSection := m.skillsInfoItems(skillItems, width, maxSkills, true)
295309 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
296310
297311 return lipgloss.NewStyle().
internal/ui/model/skills.go+15−5
@@ -28,7 +28,14 @@ var builtinSkillsCache struct {
2828
2929func cachedBuiltinSkills() []*skills.Skill {
3030 builtinSkillsCache.once.Do(func() {
31− builtinSkillsCache.skills = skills.DiscoverBuiltin()
31+ builtin := skills.DiscoverBuiltin()
32+ // Sort once here rather than on every skillStatusItems call: the
33+ // returned slice is shared, so sorting it in place repeatedly is
34+ // wasted work (and mutates shared state on the render hot path).
35+ slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
36+ return strings.Compare(a.Name, b.Name)
37+ })
38+ builtinSkillsCache.skills = builtin
3239 })
3340 return builtinSkillsCache.skills
3441}
@@ -36,6 +43,13 @@ func cachedBuiltinSkills() []*skills.Skill {
3643// skillsInfo renders the skill discovery status section showing loaded and
3744// invalid skills.
3845func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
46+ return m.skillsInfoItems(m.skillStatusItems(), width, maxItems, isSection)
47+}
48+
49+// skillsInfoItems renders the skill status section from an already-computed
50+// item list, letting callers on the sidebar render path reuse a single
51+// skillStatusItems() result instead of recomputing it.
52+func (m *UI) skillsInfoItems(items []skillStatusItem, width, maxItems int, isSection bool) string {
3953 t := m.com.Styles
4054
4155 title := t.Resource.Heading.Render("Skills")
@@ -43,7 +57,6 @@ func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
4357 title = common.Section(t, title, width)
4458 }
4559
46− items := m.skillStatusItems()
4760 if len(items) == 0 {
4861 list := t.Resource.AdditionalText.Render("None")
4962 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
@@ -95,9 +108,6 @@ func (m *UI) skillStatusItems() []skillStatusItem {
95108 }
96109
97110 builtin := cachedBuiltinSkills()
98− slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
99− return strings.Compare(a.Name, b.Name)
100− })
101111 for _, skill := range builtin {
102112 if _, ok := stateNames[skill.Name]; ok {
103113 continue
internal/ui/model/ui.go+59−11
@@ -38,6 +38,7 @@ import (
3838 "github.com/charmbracelet/crush/internal/fsext"
3939 "github.com/charmbracelet/crush/internal/history"
4040 "github.com/charmbracelet/crush/internal/home"
41+ "github.com/charmbracelet/crush/internal/lsp"
4142 "github.com/charmbracelet/crush/internal/message"
4243 "github.com/charmbracelet/crush/internal/permission"
4344 "github.com/charmbracelet/crush/internal/pubsub"
@@ -265,6 +266,13 @@ type UI struct {
265266
266267 // lsp
267268 lspStates map[string]workspace.LSPClientInfo
269+ // lspDiagnostics mirrors the per-LSP diagnostic severity counts
270+ // locally. It is refreshed only when an LSP event arrives (the same
271+ // events that already refresh lspStates), so the sidebar fingerprint
272+ // — computed every frame — can read the split counts without issuing
273+ // a per-LSP diagnostics RPC (which, in client mode, fetches and
274+ // decodes every diagnostic).
275+ lspDiagnostics map[string]lsp.DiagnosticCounts
268276
269277 // mcp
270278 mcpStates map[string]mcp.ClientInfo
@@ -282,6 +290,16 @@ type UI struct {
282290 sidebarCacheKey uint64
283291 hasSidebarCache bool
284292
293+ // selectedLargeModel cache: resolving the selected large model hits
294+ // the agent coordinator, which is an RPC (AgentIsReady + AgentModel)
295+ // in client mode. Because the sidebar fingerprint runs every frame we
296+ // cache the resolved model keyed by a cheap, locally-cached config
297+ // fingerprint and only re-fetch when that changes. A nil result is
298+ // never cached so the not-ready → ready transition is picked up on
299+ // the next frame.
300+ largeModel *workspace.AgentModel
301+ largeModelCacheKey string
302+
285303 // Notification state
286304 notifyBackend notification.Backend
287305 notifyWindowFocused bool
@@ -390,6 +408,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
390408 attachments: attachments,
391409 todoSpinner: todoSpinner,
392410 lspStates: make(map[string]workspace.LSPClientInfo),
411+ lspDiagnostics: make(map[string]lsp.DiagnosticCounts),
393412 mcpStates: make(map[string]mcp.ClientInfo),
394413 notifyBackend: notification.NoopBackend{},
395414 notifyWindowFocused: true,
@@ -783,9 +802,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
783802 case pubsub.Event[history.File]:
784803 cmds = append(cmds, m.handleFileEvent(msg.Payload))
785804 case pubsub.Event[app.LSPEvent]:
786− m.lspStates = m.com.Workspace.LSPGetStates()
805+ m.refreshLSPState()
787806 case pubsub.Event[workspace.LSPEvent]:
788− m.lspStates = m.com.Workspace.LSPGetStates()
807+ m.refreshLSPState()
789808 case pubsub.Event[skills.Event]:
790809 m.skillStates = msg.Payload.States
791810 case pubsub.Event[mcp.Event]:
@@ -2410,6 +2429,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
24102429
24112430// drawHeader draws the header section of the UI.
24122431func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
2432+ // Sum diagnostics from the local LSP mirror instead of an
2433+ // LSPGetStates RPC per frame; m.lspStates is refreshed on every LSP
2434+ // event, so the total changes exactly when the header would.
2435+ lspErrorCount := 0
2436+ for _, info := range m.lspStates {
2437+ lspErrorCount += info.DiagnosticCount
2438+ }
24132439 m.header.drawHeader(
24142440 scr,
24152441 area,
@@ -2418,6 +2444,7 @@ func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
24182444 m.detailsOpen,
24192445 area.Dx(),
24202446 m.hyperCredits,
2447+ lspErrorCount,
24212448 )
24222449}
24232450
@@ -2564,16 +2591,36 @@ func (m *UI) View() tea.View {
25642591 canvas := uv.NewScreenBuffer(m.width, m.height)
25652592 v.Cursor = m.Draw(canvas, canvas.Bounds())
25662593
2567− content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2568− contentLines := strings.Split(content, "\n")
2569− for i, line := range contentLines {
2570− // Trim trailing spaces for concise rendering
2571− contentLines[i] = strings.TrimRight(line, " ")
2594+ // Single pass over the rendered screen: normalize \r\n → \n and trim
2595+ // trailing spaces per line, writing straight into one builder instead
2596+ // of allocating a replaced copy, a line slice, and a joined copy.
2597+ rendered := canvas.Render()
2598+ var sb strings.Builder
2599+ sb.Grow(len(rendered))
2600+ start := 0
2601+ for i := 0; i <= len(rendered); i++ {
2602+ if i < len(rendered) && rendered[i] != '\n' {
2603+ continue
2604+ }
2605+ line := rendered[start:i]
2606+ // Drop a single trailing '\r' immediately before the newline
2607+ // (the \r\n → \n normalization).
2608+ if len(line) > 0 && line[len(line)-1] == '\r' {
2609+ line = line[:len(line)-1]
2610+ }
2611+ // Trim trailing spaces only (matches strings.TrimRight(line, " ")).
2612+ end := len(line)
2613+ for end > 0 && line[end-1] == ' ' {
2614+ end--
2615+ }
2616+ sb.WriteString(line[:end])
2617+ if i < len(rendered) {
2618+ sb.WriteByte('\n')
2619+ }
2620+ start = i + 1
25722621 }
25732622
2574− content = strings.Join(contentLines, "\n")
2575−
2576− v.Content = content
2623+ v.Content = sb.String()
25772624 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
25782625 // HACK: use a random percentage to prevent ghostty from hiding it
25792626 // after a timeout.
@@ -2945,7 +2992,8 @@ func (m *UI) generateLayout(w, h int) uiLayout {
29452992 appRect.Min.X += 1
29462993 appRect.Max.X -= 1
29472994
2948− if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2995+ switch m.state {
2996+ case uiOnboarding, uiInitialize, uiLanding:
29492997 // extra padding on left and right for these states
29502998 appRect.Min.X += 1
29512999 appRect.Max.X -= 1
internal/ui/styles/grad.go+49
@@ -2,13 +2,52 @@ package styles
22
33import (
44 "fmt"
5+ "hash/fnv"
56 "image/color"
67 "strings"
8+ "sync"
79
810 "charm.land/lipgloss/v2"
911 "github.com/rivo/uniseg"
1012)
1113
14+// gradCache memoizes the per-cluster rendered output of ForegroundGrad. The
15+// function walks grapheme clusters (uniseg), blends a color ramp and renders
16+// each cluster on every call, and it is invoked every frame by dialog titles
17+// and similar constant text. Results are keyed by a fingerprint of every input
18+// that affects the output.
19+var gradCache sync.Map // map[uint64][]string
20+
21+// gradKey derives a stable cache key from all inputs that affect the rendered
22+// gradient output.
23+func gradKey(base lipgloss.Style, input string, bold bool, color1, color2 color.Color) uint64 {
24+ h := fnv.New64a()
25+ h.Write([]byte(input))
26+ h.Write([]byte{0})
27+ writeColor := func(c color.Color) {
28+ var buf [8]byte
29+ if c != nil {
30+ r, g, b, a := c.RGBA()
31+ buf[0] = byte(r >> 8)
32+ buf[1] = byte(g >> 8)
33+ buf[2] = byte(b >> 8)
34+ buf[3] = byte(a >> 8)
35+ }
36+ h.Write(buf[:4])
37+ }
38+ writeColor(color1)
39+ writeColor(color2)
40+ if bold {
41+ h.Write([]byte{1})
42+ } else {
43+ h.Write([]byte{0})
44+ }
45+ // base carries the remaining style attributes (e.g. background); include a
46+ // fingerprint so distinct bases never collide.
47+ fmt.Fprintf(h, "%v", base)
48+ return h.Sum64()
49+}
50+
1251// ForegroundGrad returns a slice of strings representing the input string
1352// rendered with a horizontal gradient foreground from color1 to color2. Each
1453// string in the returned slice corresponds to a grapheme cluster in the input
@@ -17,6 +56,16 @@ func ForegroundGrad(base lipgloss.Style, input string, bold bool, color1, color2
1756 if input == "" {
1857 return []string{""}
1958 }
59+ key := gradKey(base, input, bold, color1, color2)
60+ if cached, ok := gradCache.Load(key); ok {
61+ return cached.([]string)
62+ }
63+ result := foregroundGrad(base, input, bold, color1, color2)
64+ gradCache.Store(key, result)
65+ return result
66+}
67+
68+func foregroundGrad(base lipgloss.Style, input string, bold bool, color1, color2 color.Color) []string {
2069 if len(input) == 1 {
2170 style := base.Foreground(color1)
2271 if bold {