From fea878e4d4c315f91c190d589891eddbeb8f7ac4 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Wed, 28 Jan 2026 10:41:57 -0300
Subject: [PATCH 01/33] feat(mcp): support server side instructions (#2015)
* feat(mcp): support server side instructions
Signed-off-by: Carlos Alexandro Becker
* fix: empty lines
Signed-off-by: Carlos Alexandro Becker
---------
Signed-off-by: Carlos Alexandro Becker
---
internal/agent/agent.go | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 815ba2fa8f3c78db8de593849a83ed161e1ee008..74a1a9f0c94483268b4b3558c7d4ca7a9899c7ef 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -32,6 +32,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/message"
@@ -167,6 +168,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
largeModel := a.largeModel.Get()
systemPrompt := a.systemPrompt.Get()
promptPrefix := a.systemPromptPrefix.Get()
+ var instructions strings.Builder
+
+ for _, server := range mcp.GetStates() {
+ if server.State != mcp.StateConnected {
+ continue
+ }
+ if s := server.Client.InitializeResult().Instructions; s != "" {
+ instructions.WriteString(s)
+ instructions.WriteString("\n\n")
+ }
+ }
+
+ if s := instructions.String(); s != "" {
+ systemPrompt += "\n\n\n" + s + "\n"
+ }
if len(agentTools) > 0 {
// Add Anthropic caching to the last tool.
From 9602140845188f053c30d980cc85cdacb99f0f6f Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Wed, 28 Jan 2026 11:27:29 -0300
Subject: [PATCH 02/33] ci: format nix (#2009)
Signed-off-by: Carlos Alexandro Becker
---
.goreleaser.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 784201677ed863e460818d98ac54e651bbfb7fee..0ba2b1eccdf6de70c3e39d9111074a84658bd2a3 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -268,6 +268,7 @@ nix:
name: "Charm"
email: "charmcli@users.noreply.github.com"
license: fsl11Mit
+ formatter: nixfmt
skip_upload: "{{ with .Prerelease }}true{{ end }}"
extra_install: |-
installManPage ./manpages/crush.1.gz
From 008be3f20fedc4eeda40e6a142883fa47dc9ecba Mon Sep 17 00:00:00 2001
From: Charm <124303983+charmcli@users.noreply.github.com>
Date: Wed, 28 Jan 2026 11:42:39 -0300
Subject: [PATCH 03/33] chore(legal): @oug-t has signed the CLA
---
.github/cla-signatures.json | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index 5b5e74252b831d49bdec16557311a8e39de71b16..7d0cf20f6a37d57d7da19c324d836d784a881811 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -1127,6 +1127,14 @@
"created_at": "2026-01-24T22:42:46Z",
"repoId": 987670088,
"pullRequestNo": 1978
+ },
+ {
+ "name": "oug-t",
+ "id": 252025851,
+ "comment_id": 3811704206,
+ "created_at": "2026-01-28T14:42:29Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2022
}
]
}
\ No newline at end of file
From 5011ba264a8b2c854d0f72d1364bb2a78f89e01e Mon Sep 17 00:00:00 2001
From: Tommy Guo
Date: Wed, 28 Jan 2026 09:58:35 -0500
Subject: [PATCH 04/33] docs: improve clarity and fluency of mandarin tagline
(#2022)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index cd68cb962de3518cce6f86ac3513d388bf9bfcd0..6e167345dd92ffb7a4d56241e9da7258a7c89b97 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
Your new coding bestie, now available in your favourite terminal.
Your tools, your code, and your workflows, wired into your LLM of choice.
-你的新编程伙伴,现在就在你最爱的终端中。
你的工具、代码和工作流,都与您选择的 LLM 模型紧密相连。
+终端里的编程新搭档,
无缝接入你的工具、代码与工作流,全面兼容主流 LLM 模型。

From de64b00392249ff77ab1a178234ab4e223f11fa6 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Wed, 28 Jan 2026 14:35:03 -0300
Subject: [PATCH 05/33] fix: decouple thinking/reasoning from provider type
(#2032)
Signed-off-by: Carlos Alexandro Becker
---
internal/tui/components/chat/sidebar/sidebar.go | 7 ++-----
internal/tui/components/dialogs/commands/commands.go | 4 +---
internal/ui/dialog/commands.go | 4 +---
internal/ui/model/sidebar.go | 6 ++----
4 files changed, 6 insertions(+), 15 deletions(-)
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index a454605c8ee2938fa02d98d9770704388d0bd38a..40bc8821e0a3dc7c3dec62bbcde34a5241ec4aa7 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/internal/tui/components/chat/sidebar/sidebar.go
@@ -8,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/diff"
@@ -548,7 +547,6 @@ func (s *sidebarCmp) currentModelBlock() string {
selectedModel := cfg.Models[agentCfg.Model]
model := config.Get().GetModelByType(agentCfg.Model)
- modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
t := styles.CurrentTheme()
@@ -560,15 +558,14 @@ func (s *sidebarCmp) currentModelBlock() string {
}
if model.CanReason {
reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
- switch modelProvider.Type {
- case catwalk.TypeAnthropic:
+ if len(model.ReasoningLevels) == 0 {
formatter := cases.Title(language.English, cases.NoLower)
if selectedModel.Think {
parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
} else {
parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
}
- default:
+ } else {
reasoningEffort := model.DefaultReasoningEffort
if selectedModel.ReasoningEffort != "" {
reasoningEffort = selectedModel.ReasoningEffort
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index cde5b203ca985f81c390d02725ef04d11a5cd518..3c86c984561f96350b2b621c15ae14be9649ae36 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -10,10 +10,8 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
@@ -364,7 +362,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
selectedModel := cfg.Models[agentCfg.Model]
// Anthropic models: thinking toggle
- if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+ if model.CanReason && len(model.ReasoningLevels) == 0 {
status := "Enable"
if selectedModel.Think {
status = "Disable"
diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go
index 6595b56fb702069b6a0f0786ee25cd4e94f13642..2422c39cc79b9ce1b71b5891ad55c2f4107c9295 100644
--- a/internal/ui/dialog/commands.go
+++ b/internal/ui/dialog/commands.go
@@ -9,8 +9,6 @@ import (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -405,7 +403,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
selectedModel := cfg.Models[agentCfg.Model]
// Anthropic models: thinking toggle
- if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+ if model.CanReason && len(model.ReasoningLevels) == 0 {
status := "Enable"
if selectedModel.Think {
status = "Disable"
diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go
index 7e6a61864a42f37ba7bf1c955b6844f4c488b942..7316025aaedad67688b226cf1c7c37314f3b7a30 100644
--- a/internal/ui/model/sidebar.go
+++ b/internal/ui/model/sidebar.go
@@ -5,7 +5,6 @@ import (
"fmt"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/logo"
uv "github.com/charmbracelet/ultraviolet"
@@ -28,14 +27,13 @@ func (m *UI) modelInfo(width int) string {
// Only check reasoning if model can reason
if model.CatwalkCfg.CanReason {
- switch providerConfig.Type {
- case catwalk.TypeAnthropic:
+ if model.ModelCfg.ReasoningEffort == "" {
if model.ModelCfg.Think {
reasoningInfo = "Thinking On"
} else {
reasoningInfo = "Thinking Off"
}
- default:
+ } else {
formatter := cases.Title(language.English, cases.NoLower)
reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
From daf786fe3df633bf146b5a3246866c173e9d8370 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Wed, 28 Jan 2026 14:35:22 -0300
Subject: [PATCH 06/33] fix(stats): resizing breaks pie charts (#2030)
resizing the browser would "break" the pie charts, cutting them off
Signed-off-by: Carlos Alexandro Becker
---
internal/cmd/stats/index.css | 13 ++++---------
1 file changed, 4 insertions(+), 9 deletions(-)
diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css
index b01c84442f6cbe1675f46ec02a65d801d0abed2d..0216f9f79bd6bd16f77a5fd0ec14e9c142815436 100644
--- a/internal/cmd/stats/index.css
+++ b/internal/cmd/stats/index.css
@@ -189,20 +189,15 @@ body {
}
.chart-row {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
+ display: flex;
+ flex-wrap: wrap;
gap: 1.5rem;
width: 100%;
}
.chart-row .chart-card {
- width: 100%;
-}
-
-@media (max-width: 1024px) {
- .chart-row {
- grid-template-columns: 1fr;
- }
+ flex: 1 1 300px;
+ max-width: calc((100% - 1.5rem) / 2);
}
.chart-card h2 {
From a81443ca4d7fd90bbb1efc83834cf45cd0d72c05 Mon Sep 17 00:00:00 2001
From: Charm <124303983+charmcli@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:05:20 -0300
Subject: [PATCH 07/33] chore(legal): @liannnix has signed the CLA
---
.github/cla-signatures.json | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index 7d0cf20f6a37d57d7da19c324d836d784a881811..e03ad52ee49a9b000bd8cb935f4da628158ed0ef 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -1135,6 +1135,14 @@
"created_at": "2026-01-28T14:42:29Z",
"repoId": 987670088,
"pullRequestNo": 2022
+ },
+ {
+ "name": "liannnix",
+ "id": 779758,
+ "comment_id": 3815867093,
+ "created_at": "2026-01-29T07:05:12Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2043
}
]
}
\ No newline at end of file
From 3a929ffcff89aba677c2fb7620e93870f1c47f5b Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Thu, 29 Jan 2026 10:19:00 -0300
Subject: [PATCH 08/33] feat: filetracker per session (#2033)
* feat: filetracker per session
Signed-off-by: Carlos Alexandro Becker
* fix: only in the new ui
Signed-off-by: Carlos Alexandro Becker
* fix: tests, lint
Signed-off-by: Carlos Alexandro Becker
* fix: old tui
Signed-off-by: Carlos Alexandro Becker
* test: added test, improve schema
Signed-off-by: Carlos Alexandro Becker
* test: synctest
Signed-off-by: Carlos Alexandro Becker
* test: fix race
Signed-off-by: Carlos Alexandro Becker
* fix: relpath
Signed-off-by: Carlos Alexandro Becker
* fix: simplify
Signed-off-by: Carlos Alexandro Becker
* chore: trigger ci
Signed-off-by: Carlos Alexandro Becker
---------
Signed-off-by: Carlos Alexandro Becker
---
internal/agent/agentic_fetch_tool.go | 2 +-
internal/agent/common_test.go | 12 +-
internal/agent/coordinator.go | 12 +-
internal/agent/tools/edit.go | 53 ++++----
internal/agent/tools/multiedit.go | 35 +++---
internal/agent/tools/multiedit_test.go | 14 ---
internal/agent/tools/view.go | 20 +--
internal/agent/tools/write.go | 25 ++--
internal/app/app.go | 4 +
internal/db/db.go | 20 +++
.../20260127000000_add_read_files_table.sql | 20 +++
internal/db/models.go | 6 +
internal/db/querier.go | 2 +
internal/db/read_files.sql.go | 57 +++++++++
internal/db/sql/read_files.sql | 15 +++
internal/filetracker/filetracker.go | 70 -----------
internal/filetracker/service.go | 70 +++++++++++
internal/filetracker/service_test.go | 116 ++++++++++++++++++
internal/tui/components/chat/editor/editor.go | 27 +++-
internal/tui/page/chat/chat.go | 3 +
internal/ui/model/ui.go | 27 ++--
21 files changed, 446 insertions(+), 164 deletions(-)
create mode 100644 internal/db/migrations/20260127000000_add_read_files_table.sql
create mode 100644 internal/db/read_files.sql.go
create mode 100644 internal/db/sql/read_files.sql
delete mode 100644 internal/filetracker/filetracker.go
create mode 100644 internal/filetracker/service.go
create mode 100644 internal/filetracker/service_test.go
diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go
index 89d3535720f8452111f12f4df4eb691e39253bed..08da0e870187f537c9c88ac6a2b6ada97ff6fc88 100644
--- a/internal/agent/agentic_fetch_tool.go
+++ b/internal/agent/agentic_fetch_tool.go
@@ -168,7 +168,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
tools.NewGlobTool(tmpDir),
tools.NewGrepTool(tmpDir),
tools.NewSourcegraphTool(client),
- tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
}
agent := NewSessionAgent(SessionAgentOptions{
diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go
index 3f4e8daddbd4de34e788bce59a9573c00d940252..2bb5e5650bcb3280ddb95bdcea7d588a2eea7643 100644
--- a/internal/agent/common_test.go
+++ b/internal/agent/common_test.go
@@ -20,6 +20,7 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/message"
@@ -37,6 +38,7 @@ type fakeEnv struct {
messages message.Service
permissions permission.Service
history history.Service
+ filetracker *filetracker.Service
lspClients *csync.Map[string, *lsp.Client]
}
@@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv {
permissions := permission.NewPermissionService(workingDir, true, []string{})
history := history.NewService(q, conn)
+ filetrackerService := filetracker.NewService(q)
lspClients := csync.NewMap[string, *lsp.Client]()
t.Cleanup(func() {
@@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv {
messages,
permissions,
history,
+ &filetrackerService,
lspClients,
}
}
@@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
allTools := []fantasy.AgentTool{
tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
- tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
- tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+ tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+ tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
tools.NewGlobTool(env.workingDir),
tools.NewGrepTool(env.workingDir),
tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
tools.NewSourcegraphTool(r.GetDefaultClient()),
- tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
- tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
+ tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
+ tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
}
return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 8c2a785b2f8ffeb77bbf52bb9653e8a98369303b..fd65072fd4eb297b8eddcb38aafe50d595601f82 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -22,6 +22,7 @@ import (
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/lsp"
@@ -64,6 +65,7 @@ type coordinator struct {
messages message.Service
permissions permission.Service
history history.Service
+ filetracker filetracker.Service
lspClients *csync.Map[string, *lsp.Client]
currentAgent SessionAgent
@@ -79,6 +81,7 @@ func NewCoordinator(
messages message.Service,
permissions permission.Service,
history history.Service,
+ filetracker filetracker.Service,
lspClients *csync.Map[string, *lsp.Client],
) (Coordinator, error) {
c := &coordinator{
@@ -87,6 +90,7 @@ func NewCoordinator(
messages: messages,
permissions: permissions,
history: history,
+ filetracker: filetracker,
lspClients: lspClients,
agents: make(map[string]SessionAgent),
}
@@ -393,16 +397,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
tools.NewJobOutputTool(),
tools.NewJobKillTool(),
tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
- tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
- tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+ tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+ tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
tools.NewGlobTool(c.cfg.WorkingDir()),
tools.NewGrepTool(c.cfg.WorkingDir()),
tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
tools.NewSourcegraphTool(nil),
tools.NewTodosTool(c.sessions),
- tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
- tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
+ tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
)
if len(c.cfg.LSP) > 0 {
diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go
index 2c9b15abfe148fb881ee90f75f207c1134776281..74b84c784796a97db2f379cf61fb3eb8b18934d4 100644
--- a/internal/agent/tools/edit.go
+++ b/internal/agent/tools/edit.go
@@ -56,10 +56,17 @@ type editContext struct {
ctx context.Context
permissions permission.Service
files history.Service
+ filetracker filetracker.Service
workingDir string
}
-func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewEditTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ files history.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
EditToolName,
string(editDescription),
@@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
var response fantasy.ToolResponse
var err error
- editCtx := editContext{ctx, permissions, files, workingDir}
+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
if params.OldString == "" {
response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
@@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse("File created: "+filePath),
@@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
}
- if filetracker.LastReadTime(filePath).IsZero() {
+ sessionID := GetSessionFromContext(edit.ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
+ }
+
+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+ if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(filePath)
+ modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -236,12 +247,6 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
newContent = oldContent[:index] + oldContent[index+len(oldString):]
}
- sessionID := GetSessionFromContext(edit.ctx)
-
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
- }
-
_, additions, removals := diff.GenerateDiff(
oldContent,
newContent,
@@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
}
- if filetracker.LastReadTime(filePath).IsZero() {
+ sessionID := GetSessionFromContext(edit.ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
+ }
+
+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+ if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(filePath)
+ modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -369,11 +378,6 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
if oldContent == newContent {
return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
}
- sessionID := GetSessionFromContext(edit.ctx)
-
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
- }
_, additions, removals := diff.GenerateDiff(
oldContent,
newContent,
@@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse("Content replaced in file: "+filePath),
diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go
index 0640228d23230e6a49d8e1405f371c099031fbf7..48736ebf311230a28b51702e0ddd3ff8df19b284 100644
--- a/internal/agent/tools/multiedit.go
+++ b/internal/agent/tools/multiedit.go
@@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit"
//go:embed multiedit.md
var multieditDescription []byte
-func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewMultiEditTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ files history.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
MultiEditToolName,
string(multieditDescription),
@@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe
var response fantasy.ToolResponse
var err error
- editCtx := editContext{ctx, permissions, files, workingDir}
+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
// Handle file creation case (first edit has empty old_string)
if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
response, err = processMultiEditWithCreation(editCtx, params, call)
@@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(params.FilePath)
- filetracker.RecordRead(params.FilePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
var message string
if len(failedEdits) > 0 {
@@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
}
+ sessionID := GetSessionFromContext(edit.ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
+ }
+
// Check if file was read before editing
- if filetracker.LastReadTime(params.FilePath).IsZero() {
+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
+ if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}
- // Check if file was modified since last read
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(params.FilePath)
+ // Check if file was modified since last read.
+ modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -301,12 +311,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
}
- // Get session and message IDs
- sessionID := GetSessionFromContext(edit.ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
- }
-
// Generate diff and check permissions
_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
@@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(params.FilePath)
- filetracker.RecordRead(params.FilePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
var message string
if len(failedEdits) > 0 {
diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go
index b6d575435e63dcd62a4dc9a7efb76cf13c14ad05..1ca2a6f7689e345ac944889f1f92284de0652f90 100644
--- a/internal/agent/tools/multiedit_test.go
+++ b/internal/agent/tools/multiedit_test.go
@@ -6,10 +6,7 @@ import (
"path/filepath"
"testing"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/stretchr/testify/require"
@@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) {
err := os.WriteFile(testFile, []byte(content), 0o644)
require.NoError(t, err)
- // Mock components.
- lspClients := csync.NewMap[string, *lsp.Client]()
- permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
- files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
-
- // Create multiedit tool.
- _ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
-
- // Simulate reading the file first.
- filetracker.RecordRead(testFile)
-
// Manually test the sequential application logic.
currentContent := content
diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go
index 35865cf43f7c587d60764b3ed177374940bbe2dc..b26267fcef3b296babc3c9dbcee64336ef162b75 100644
--- a/internal/agent/tools/view.go
+++ b/internal/agent/tools/view.go
@@ -47,7 +47,13 @@ const (
MaxLineLength = 2000
)
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
+func NewViewTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+ skillsPaths ...string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
ViewToolName,
string(viewDescription),
@@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
+ sessionID := GetSessionFromContext(ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
+ }
+
// Request permission for files outside working directory, unless it's a skill file.
if isOutsideWorkDir && !isSkillFile {
- sessionID := GetSessionFromContext(ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
- }
-
granted, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
@@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
}
output += "\n\n"
output += getDiagnostics(filePath, lspClients)
- filetracker.RecordRead(filePath)
+ filetracker.RecordRead(ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse(output),
ViewResponseMetadata{
diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go
index 8becaea3c08157897dcece7b3d5d4de5cb2ee929..c2f5c7d1c83efd0731e8623c1e9cbb98b9bfdd2f 100644
--- a/internal/agent/tools/write.go
+++ b/internal/agent/tools/write.go
@@ -44,7 +44,13 @@ type WriteResponseMetadata struct {
const WriteToolName = "write"
-func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewWriteTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ files history.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
WriteToolName,
string(writeDescription),
@@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
return fantasy.NewTextErrorResponse("content is required"), nil
}
+ sessionID := GetSessionFromContext(ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+ }
+
filePath := filepathext.SmartJoin(workingDir, params.FilePath)
fileInfo, err := os.Stat(filePath)
@@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
}
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(filePath)
+ modTime := fileInfo.ModTime().Truncate(time.Second)
+ lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
}
}
- sessionID := GetSessionFromContext(ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
- }
-
diff, additions, removals := diff.GenerateDiff(
oldContent,
params.Content,
@@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ filetracker.RecordRead(ctx, sessionID, filePath)
notifyLSPs(ctx, lspClients, params.FilePath)
diff --git a/internal/app/app.go b/internal/app/app.go
index ef6e636e44eeea9407557ca48f8ba9bd8eba72b2..647d90c9cfe29402b00ef5743f3a84f5e1b681ab 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/format"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/log"
@@ -53,6 +54,7 @@ type App struct {
Messages message.Service
History history.Service
Permissions permission.Service
+ FileTracker filetracker.Service
AgentCoordinator agent.Coordinator
@@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
Messages: messages,
History: files,
Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
+ FileTracker: filetracker.NewService(q),
LSPClients: csync.NewMap[string, *lsp.Client](),
globalCtx: ctx,
@@ -460,6 +463,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
app.Messages,
app.Permissions,
app.History,
+ app.FileTracker,
app.LSPClients,
)
if err != nil {
diff --git a/internal/db/db.go b/internal/db/db.go
index a4e430c720f33f4cd3c0b9710633595ef5c5fa1f..739c2087e1c1e125875d5006c86f85de37fed3be 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
}
+ if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil {
+ return nil, fmt.Errorf("error preparing query GetFileRead: %w", err)
+ }
if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
}
@@ -111,6 +114,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
}
+ if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
+ return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
+ }
if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
}
@@ -180,6 +186,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
}
}
+ if q.getFileReadStmt != nil {
+ if cerr := q.getFileReadStmt.Close(); cerr != nil {
+ err = fmt.Errorf("error closing getFileReadStmt: %w", cerr)
+ }
+ }
if q.getHourDayHeatmapStmt != nil {
if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
@@ -270,6 +281,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
}
}
+ if q.recordFileReadStmt != nil {
+ if cerr := q.recordFileReadStmt.Close(); cerr != nil {
+ err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
+ }
+ }
if q.updateMessageStmt != nil {
if cerr := q.updateMessageStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -335,6 +351,7 @@ type Queries struct {
getAverageResponseTimeStmt *sql.Stmt
getFileStmt *sql.Stmt
getFileByPathAndSessionStmt *sql.Stmt
+ getFileReadStmt *sql.Stmt
getHourDayHeatmapStmt *sql.Stmt
getMessageStmt *sql.Stmt
getRecentActivityStmt *sql.Stmt
@@ -353,6 +370,7 @@ type Queries struct {
listNewFilesStmt *sql.Stmt
listSessionsStmt *sql.Stmt
listUserMessagesBySessionStmt *sql.Stmt
+ recordFileReadStmt *sql.Stmt
updateMessageStmt *sql.Stmt
updateSessionStmt *sql.Stmt
updateSessionTitleAndUsageStmt *sql.Stmt
@@ -373,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
getAverageResponseTimeStmt: q.getAverageResponseTimeStmt,
getFileStmt: q.getFileStmt,
getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
+ getFileReadStmt: q.getFileReadStmt,
getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
getMessageStmt: q.getMessageStmt,
getRecentActivityStmt: q.getRecentActivityStmt,
@@ -391,6 +410,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
listNewFilesStmt: q.listNewFilesStmt,
listSessionsStmt: q.listSessionsStmt,
listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
+ recordFileReadStmt: q.recordFileReadStmt,
updateMessageStmt: q.updateMessageStmt,
updateSessionStmt: q.updateSessionStmt,
updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
diff --git a/internal/db/migrations/20260127000000_add_read_files_table.sql b/internal/db/migrations/20260127000000_add_read_files_table.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1161f1992885fc66e309024a0d874565ea276229
--- /dev/null
+++ b/internal/db/migrations/20260127000000_add_read_files_table.sql
@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE IF NOT EXISTS read_files (
+ session_id TEXT NOT NULL CHECK (session_id != ''),
+ path TEXT NOT NULL CHECK (path != ''),
+ read_at INTEGER NOT NULL, -- Unix timestamp in seconds when file was last read
+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
+ PRIMARY KEY (path, session_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_read_files_session_id ON read_files (session_id);
+CREATE INDEX IF NOT EXISTS idx_read_files_path ON read_files (path);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP INDEX IF EXISTS idx_read_files_path;
+DROP INDEX IF EXISTS idx_read_files_session_id;
+DROP TABLE IF EXISTS read_files;
+-- +goose StatementEnd
diff --git a/internal/db/models.go b/internal/db/models.go
index 317e7c92e09c857ee610832e365af2c4ecc90181..a105074ab9e6320bd92b90121e7694b1f8cd1e5a 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -31,6 +31,12 @@ type Message struct {
IsSummaryMessage int64 `json:"is_summary_message"`
}
+type ReadFile struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+ ReadAt int64 `json:"read_at"` // Unix timestamp when file was last read
+}
+
type Session struct {
ID string `json:"id"`
ParentSessionID sql.NullString `json:"parent_session_id"`
diff --git a/internal/db/querier.go b/internal/db/querier.go
index 394ba1f71aea47c93956e91fcaf07e02f65098b8..c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f 100644
--- a/internal/db/querier.go
+++ b/internal/db/querier.go
@@ -20,6 +20,7 @@ type Querier interface {
GetAverageResponseTime(ctx context.Context) (int64, error)
GetFile(ctx context.Context, id string) (File, error)
GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
+ GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
GetMessage(ctx context.Context, id string) (Message, error)
GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
@@ -38,6 +39,7 @@ type Querier interface {
ListNewFiles(ctx context.Context) ([]File, error)
ListSessions(ctx context.Context) ([]Session, error)
ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
+ RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
diff --git a/internal/db/read_files.sql.go b/internal/db/read_files.sql.go
new file mode 100644
index 0000000000000000000000000000000000000000..b18907c1f27a3c753b6b1a2cf1ca0563c3fd78d5
--- /dev/null
+++ b/internal/db/read_files.sql.go
@@ -0,0 +1,57 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: read_files.sql
+
+package db
+
+import (
+ "context"
+)
+
+const getFileRead = `-- name: GetFileRead :one
+SELECT session_id, path, read_at FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1
+`
+
+type GetFileReadParams struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+}
+
+func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
+ row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
+ var i ReadFile
+ err := row.Scan(
+ &i.SessionID,
+ &i.Path,
+ &i.ReadAt,
+ )
+ return i, err
+}
+
+const recordFileRead = `-- name: RecordFileRead :exec
+INSERT INTO read_files (
+ session_id,
+ path,
+ read_at
+) VALUES (
+ ?,
+ ?,
+ strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+ read_at = excluded.read_at
+`
+
+type RecordFileReadParams struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+}
+
+func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
+ _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
+ arg.SessionID,
+ arg.Path,
+ )
+ return err
+}
diff --git a/internal/db/sql/read_files.sql b/internal/db/sql/read_files.sql
new file mode 100644
index 0000000000000000000000000000000000000000..f607312c2ba8660aa2c7030e415ce2ca7320cd6d
--- /dev/null
+++ b/internal/db/sql/read_files.sql
@@ -0,0 +1,15 @@
+-- name: RecordFileRead :exec
+INSERT INTO read_files (
+ session_id,
+ path,
+ read_at
+) VALUES (
+ ?,
+ ?,
+ strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+ read_at = excluded.read_at;
+
+-- name: GetFileRead :one
+SELECT * FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1;
diff --git a/internal/filetracker/filetracker.go b/internal/filetracker/filetracker.go
deleted file mode 100644
index 534a19dacdc209f7ef2d9c5b107cb5f88a665ee5..0000000000000000000000000000000000000000
--- a/internal/filetracker/filetracker.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// Package filetracker tracks file read/write times to prevent editing files
-// that haven't been read, and to detect external modifications.
-//
-// TODO: Consider moving this to persistent storage (e.g., the database) to
-// preserve file access history across sessions.
-// We would need to make sure to handle the case where we reload a session and the underlying files did change.
-package filetracker
-
-import (
- "sync"
- "time"
-)
-
-// record tracks when a file was read/written.
-type record struct {
- path string
- readTime time.Time
- writeTime time.Time
-}
-
-var (
- records = make(map[string]record)
- recordMutex sync.RWMutex
-)
-
-// RecordRead records when a file was read.
-func RecordRead(path string) {
- recordMutex.Lock()
- defer recordMutex.Unlock()
-
- rec, exists := records[path]
- if !exists {
- rec = record{path: path}
- }
- rec.readTime = time.Now()
- records[path] = rec
-}
-
-// LastReadTime returns when a file was last read. Returns zero time if never
-// read.
-func LastReadTime(path string) time.Time {
- recordMutex.RLock()
- defer recordMutex.RUnlock()
-
- rec, exists := records[path]
- if !exists {
- return time.Time{}
- }
- return rec.readTime
-}
-
-// RecordWrite records when a file was written.
-func RecordWrite(path string) {
- recordMutex.Lock()
- defer recordMutex.Unlock()
-
- rec, exists := records[path]
- if !exists {
- rec = record{path: path}
- }
- rec.writeTime = time.Now()
- records[path] = rec
-}
-
-// Reset clears all file tracking records. Useful for testing.
-func Reset() {
- recordMutex.Lock()
- defer recordMutex.Unlock()
- records = make(map[string]record)
-}
diff --git a/internal/filetracker/service.go b/internal/filetracker/service.go
new file mode 100644
index 0000000000000000000000000000000000000000..8f080d124e49dfc32f43796194c09ac22beaa9f1
--- /dev/null
+++ b/internal/filetracker/service.go
@@ -0,0 +1,70 @@
+// Package filetracker provides functionality to track file reads in sessions.
+package filetracker
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/db"
+)
+
+// Service defines the interface for tracking file reads in sessions.
+type Service interface {
+ // RecordRead records when a file was read.
+ RecordRead(ctx context.Context, sessionID, path string)
+
+ // LastReadTime returns when a file was last read.
+ // Returns zero time if never read.
+ LastReadTime(ctx context.Context, sessionID, path string) time.Time
+}
+
+type service struct {
+ q *db.Queries
+}
+
+// NewService creates a new file tracker service.
+func NewService(q *db.Queries) Service {
+ return &service{q: q}
+}
+
+// RecordRead records when a file was read.
+func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
+ if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
+ SessionID: sessionID,
+ Path: relpath(path),
+ }); err != nil {
+ slog.Error("Error recording file read", "error", err, "file", path)
+ }
+}
+
+// LastReadTime returns when a file was last read.
+// Returns zero time if never read.
+func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
+ readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
+ SessionID: sessionID,
+ Path: relpath(path),
+ })
+ if err != nil {
+ return time.Time{}
+ }
+
+ return time.Unix(readFile.ReadAt, 0)
+}
+
+func relpath(path string) string {
+ path = filepath.Clean(path)
+ basepath, err := os.Getwd()
+ if err != nil {
+ slog.Warn("Error getting basepath", "error", err)
+ return path
+ }
+ relpath, err := filepath.Rel(basepath, path)
+ if err != nil {
+ slog.Warn("Error getting relpath", "error", err)
+ return path
+ }
+ return relpath
+}
diff --git a/internal/filetracker/service_test.go b/internal/filetracker/service_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..c7fb15090dd31e9591c5c3b9c2a256c839aea3f6
--- /dev/null
+++ b/internal/filetracker/service_test.go
@@ -0,0 +1,116 @@
+package filetracker
+
+import (
+ "context"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/db"
+ "github.com/stretchr/testify/require"
+)
+
+type testEnv struct {
+ ctx context.Context
+ q *db.Queries
+ svc Service
+}
+
+func setupTest(t *testing.T) *testEnv {
+ t.Helper()
+
+ conn, err := db.Connect(t.Context(), t.TempDir())
+ require.NoError(t, err)
+ t.Cleanup(func() { conn.Close() })
+
+ q := db.New(conn)
+ return &testEnv{
+ ctx: t.Context(),
+ q: q,
+ svc: NewService(q),
+ }
+}
+
+func (e *testEnv) createSession(t *testing.T, sessionID string) {
+ t.Helper()
+ _, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
+ ID: sessionID,
+ Title: "Test Session",
+ })
+ require.NoError(t, err)
+}
+
+func TestService_RecordRead(t *testing.T) {
+ env := setupTest(t)
+
+ sessionID := "test-session-1"
+ path := "/path/to/file.go"
+ env.createSession(t, sessionID)
+
+ env.svc.RecordRead(env.ctx, sessionID, path)
+
+ lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+ require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
+ require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
+}
+
+func TestService_LastReadTime_NotFound(t *testing.T) {
+ env := setupTest(t)
+
+ lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
+ require.True(t, lastRead.IsZero(), "expected zero time for unread file")
+}
+
+func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
+ env := setupTest(t)
+
+ sessionID := "test-session-2"
+ path := "/path/to/file.go"
+ env.createSession(t, sessionID)
+
+ env.svc.RecordRead(env.ctx, sessionID, path)
+ firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+ require.False(t, firstRead.IsZero())
+
+ synctest.Test(t, func(t *testing.T) {
+ time.Sleep(100 * time.Millisecond)
+ synctest.Wait()
+ env.svc.RecordRead(env.ctx, sessionID, path)
+ secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+
+ require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
+ })
+}
+
+func TestService_RecordRead_DifferentSessions(t *testing.T) {
+ env := setupTest(t)
+
+ path := "/shared/file.go"
+ session1, session2 := "session-1", "session-2"
+ env.createSession(t, session1)
+ env.createSession(t, session2)
+
+ env.svc.RecordRead(env.ctx, session1, path)
+
+ lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
+ require.False(t, lastRead1.IsZero())
+
+ lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
+ require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
+}
+
+func TestService_RecordRead_DifferentPaths(t *testing.T) {
+ env := setupTest(t)
+
+ sessionID := "test-session-3"
+ path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
+ env.createSession(t, sessionID)
+
+ env.svc.RecordRead(env.ctx, sessionID, path1)
+
+ lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
+ require.False(t, lastRead1.IsZero())
+
+ lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
+ require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
+}
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index ba832b415133305fccbefa37da6b749405feb2c6..575c23114a9115209db7a2a02e642fe5f2246541 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -1,6 +1,7 @@
package editor
import (
+ "context"
"fmt"
"math/rand"
"net/http"
@@ -17,7 +18,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/session"
@@ -66,6 +66,7 @@ type editorCmp struct {
x, y int
app *app.App
session session.Session
+ sessionFileReads []string
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
@@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
+ case chat.SessionClearedMsg:
+ m.session = session.Session{}
+ m.sessionFileReads = nil
case tea.WindowSizeMsg:
return m, m.repositionCompletions
case filepicker.FilePickedMsg:
@@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
m.completionsStartIndex = 0
}
absPath, _ := filepath.Abs(item.Path)
+
+ ctx := context.Background()
+
// Skip attachment if file was already read and hasn't been modified.
- lastRead := filetracker.LastReadTime(absPath)
- if !lastRead.IsZero() {
- if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
- return m, nil
+ if m.session.ID != "" {
+ lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
+ if !lastRead.IsZero() {
+ if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
+ return m, nil
+ }
}
+ } else if slices.Contains(m.sessionFileReads, absPath) {
+ return m, nil
}
+
+ m.sessionFileReads = append(m.sessionFileReads, absPath)
content, err := os.ReadFile(item.Path)
if err != nil {
// if it fails, let the LLM handle it later.
return m, nil
}
- filetracker.RecordRead(absPath)
m.attachments = append(m.attachments, message.Attachment{
FilePath: item.Path,
FileName: filepath.Base(item.Path),
@@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding {
// we need to move some functionality to the page level
func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
c.session = session
+ for _, path := range c.sessionFileReads {
+ c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
+ }
return nil
}
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index 9a4b69f5507fbb62b7ee93df6326f94cf79d22ad..bb2eb755bf80995dd41d9ac564174de5b90262bb 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
u, cmd = p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
cmds = append(cmds, cmd)
+ u, cmd = p.editor.Update(msg)
+ p.editor = u.(editor.Editor)
+ cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
case filepicker.FilePickedMsg,
completions.CompletionsClosedMsg,
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 1f2d7f86ef1953bf97e98109cbbe5d791c94122f..e100b6605fceceded84da8cd6cfb16507ddf64a4 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -28,7 +28,6 @@ import (
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/home"
@@ -118,6 +117,9 @@ type UI struct {
session *session.Session
sessionFiles []SessionFile
+ // keeps track of read files while we don't have a session id
+ sessionFileReads []string
+
lastUserMessageTime int64
// The width and height of the terminal in cells.
@@ -2414,21 +2416,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
return func() tea.Msg {
absPath, _ := filepath.Abs(path)
- // Skip attachment if file was already read and hasn't been modified.
- lastRead := filetracker.LastReadTime(absPath)
- if !lastRead.IsZero() {
- if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
- return nil
+
+ if m.hasSession() {
+ // Skip attachment if file was already read and hasn't been modified.
+ lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
+ if !lastRead.IsZero() {
+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
+ return nil
+ }
}
+ } else if slices.Contains(m.sessionFileReads, absPath) {
+ return nil
}
+ m.sessionFileReads = append(m.sessionFileReads, absPath)
+
// Add file as attachment.
content, err := os.ReadFile(path)
if err != nil {
// If it fails, let the LLM handle it later.
return nil
}
- filetracker.RecordRead(absPath)
return message.Attachment{
FilePath: path,
@@ -2555,6 +2563,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
m.setState(uiChat, m.focus)
}
+ for _, path := range m.sessionFileReads {
+ m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
+ }
+
// Capture session ID to avoid race with main goroutine updating m.session.
sessionID := m.session.ID
cmds = append(cmds, func() tea.Msg {
@@ -2801,6 +2813,7 @@ func (m *UI) newSession() tea.Cmd {
m.session = nil
m.sessionFiles = nil
+ m.sessionFileReads = nil
m.setState(uiLanding, uiFocusEditor)
m.textarea.Focus()
m.chat.Blur()
From 87fad188fca6f37acea2bc6e6dcdab7ccf8e606d Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Thu, 29 Jan 2026 10:29:01 -0300
Subject: [PATCH 09/33] fix: make the commands dialog less taller (#2035)
---
internal/ui/dialog/commands.go | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go
index 2422c39cc79b9ce1b71b5891ad55c2f4107c9295..416f5a0131e2dc7cf36561f118daed248ceebd08 100644
--- a/internal/ui/dialog/commands.go
+++ b/internal/ui/dialog/commands.go
@@ -27,8 +27,9 @@ type CommandType uint
func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
const (
- sidebarCompactModeBreakpoint = 120
- defaultCommandsDialogMaxWidth = 70
+ sidebarCompactModeBreakpoint = 120
+ defaultCommandsDialogMaxHeight = 20
+ defaultCommandsDialogMaxWidth = 70
)
const (
@@ -240,7 +241,7 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo
func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t := c.com.Styles
width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()))
- height := max(0, min(defaultDialogHeight, area.Dy()))
+ height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()))
if area.Dx() != c.windowWidth && c.selected == SystemCommands {
c.windowWidth = area.Dx()
// since some items in the list depend on width (e.g. toggle sidebar command),
From aae4c3082281f9233484609f73169e60257da73c Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Thu, 29 Jan 2026 10:29:46 -0300
Subject: [PATCH 10/33] fix(ui): fix selection of code blocks with tabs inside
markdown (#2039)
Yes, this is very specific. You need a code block, inside markdown,
that uses tabs instead of spaces for indentation, like Go code.
This affected both how the code is present on the TUI as well as the text
copied to clipboard.
We need to convert tabs into 4 spaces on the highlighter to match how
it's shown in the TUI.
Centralized this into a function to ensure we're doing the exact same
thing everywhere.
---
internal/stringext/string.go | 12 ++++++++++++
internal/ui/chat/tools.go | 12 ++++--------
internal/ui/list/highlight.go | 3 +++
3 files changed, 19 insertions(+), 8 deletions(-)
diff --git a/internal/stringext/string.go b/internal/stringext/string.go
index 03456db93bc148f7c77e52da3c493c94fa79624f..8be28ccc2096c3d54b9f3106ed30d584503acdf4 100644
--- a/internal/stringext/string.go
+++ b/internal/stringext/string.go
@@ -1,6 +1,8 @@
package stringext
import (
+ "strings"
+
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -8,3 +10,13 @@ import (
func Capitalize(text string) string {
return cases.Title(language.English, cases.Compact).String(text)
}
+
+// NormalizeSpace normalizes whitespace in the given content string.
+// It replaces Windows-style line endings with Unix-style line endings,
+// converts tabs to four spaces, and trims leading and trailing whitespace.
+func NormalizeSpace(content string) string {
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+ return content
+}
diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go
index 8aac1c1401fe299b24bd2cda81e18113bfd6176d..3ae403160b241eca6f5d74fb9841c2b10a7735b9 100644
--- a/internal/ui/chat/tools.go
+++ b/internal/ui/chat/tools.go
@@ -15,6 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/diff"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/stringext"
"github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
@@ -531,9 +532,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
// toolOutputPlainContent renders plain text with optional expansion support.
func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
+ content = stringext.NormalizeSpace(content)
lines := strings.Split(content, "\n")
maxLines := responseContextHeight
@@ -566,8 +565,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
// toolOutputCodeContent renders code with syntax highlighting and line numbers.
func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
+ content = stringext.NormalizeSpace(content)
lines := strings.Split(content, "\n")
maxLines := responseContextHeight
@@ -776,9 +774,7 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator {
// toolOutputMarkdownContent renders markdown content with optional truncation.
func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
+ content = stringext.NormalizeSpace(content)
// Cap width for readability.
if width > maxTextWidth {
diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go
index fefe836d110b52496028d21071fffc5262189d92..631181db29ce5bc3a2087de30341342f0374b229 100644
--- a/internal/ui/list/highlight.go
+++ b/internal/ui/list/highlight.go
@@ -5,6 +5,7 @@ import (
"strings"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/stringext"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -53,6 +54,8 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
// HighlightBuffer highlights a region of text within the given content and
// region, returning a [uv.ScreenBuffer].
func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+ content = stringext.NormalizeSpace(content)
+
if startLine < 0 || startCol < 0 {
return nil
}
From e57687f170b9744abfcc7dc83ccca0e0d4272116 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Thu, 29 Jan 2026 10:30:07 -0300
Subject: [PATCH 11/33] fix(ui): fix wrong color on selected item info on
dialogs (#2041)
---
internal/ui/dialog/commands_item.go | 2 +-
internal/ui/dialog/models_item.go | 2 +-
internal/ui/dialog/reasoning.go | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go
index b1977545ded8e8eeb8fc1e59c5a0a31e18ce8610..1099a8b435f0ed31d9f4c81dfbb4cb2b33a3d910 100644
--- a/internal/ui/dialog/commands_item.go
+++ b/internal/ui/dialog/commands_item.go
@@ -70,7 +70,7 @@ func (c *CommandItem) Render(width int) string {
ItemBlurred: c.t.Dialog.NormalItem,
ItemFocused: c.t.Dialog.SelectedItem,
InfoTextBlurred: c.t.Base,
- InfoTextFocused: c.t.Subtle,
+ InfoTextFocused: c.t.Base,
}
return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
}
diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go
index bfe30c0e3a04c24c71579bfbdbd06b576e1ad033..e61359d065a895ec508083198e5530977091366b 100644
--- a/internal/ui/dialog/models_item.go
+++ b/internal/ui/dialog/models_item.go
@@ -110,7 +110,7 @@ func (m *ModelItem) Render(width int) string {
ItemBlurred: m.t.Dialog.NormalItem,
ItemFocused: m.t.Dialog.SelectedItem,
InfoTextBlurred: m.t.Base,
- InfoTextFocused: m.t.Subtle,
+ InfoTextFocused: m.t.Base,
}
return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
}
diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go
index 4c5dad086bb01eb3dc12f2f6d379c87a5638d297..f11c59e48702ea3bc419afa62e9ee7fce8c52632 100644
--- a/internal/ui/dialog/reasoning.go
+++ b/internal/ui/dialog/reasoning.go
@@ -297,7 +297,7 @@ func (r *ReasoningItem) Render(width int) string {
ItemBlurred: r.t.Dialog.NormalItem,
ItemFocused: r.t.Dialog.SelectedItem,
InfoTextBlurred: r.t.Base,
- InfoTextFocused: r.t.Subtle,
+ InfoTextFocused: r.t.Base,
}
return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
}
From aa2cacd24af953a858dfb17f84ef37ff1db74ff3 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Thu, 29 Jan 2026 10:31:05 -0300
Subject: [PATCH 12/33] feat: open commands dialog on pressing `/` (#2034)
---
internal/ui/model/keys.go | 5 +++++
internal/ui/model/ui.go | 8 ++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go
index cf2fdcaa431b2a9c43a9612ef99ec8ce696216ca..a42b1e7aa0ac9ac474de626b55ceb3a91824cdff 100644
--- a/internal/ui/model/keys.go
+++ b/internal/ui/model/keys.go
@@ -10,6 +10,7 @@ type KeyMap struct {
Newline key.Binding
AddImage key.Binding
MentionFile key.Binding
+ Commands key.Binding
// Attachments key maps
AttachmentDeleteMode key.Binding
@@ -123,6 +124,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("@"),
key.WithHelp("@", "mention file"),
)
+ km.Editor.Commands = key.NewBinding(
+ key.WithKeys("/"),
+ key.WithHelp("/", "commands"),
+ )
km.Editor.AttachmentDeleteMode = key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index e100b6605fceceded84da8cd6cfb16507ddf64a4..9eb7f01f881e70ad82597820dac8e3161f4cd684 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -1542,6 +1542,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
if cmd != nil {
cmds = append(cmds, cmd)
}
+ case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
+ if cmd := m.openCommandsDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
default:
if handleGlobalKeys(msg) {
// Handle global keys first before passing to textarea.
@@ -1865,7 +1869,7 @@ func (m *UI) ShortHelp() []key.Binding {
k := &m.keyMap
tab := k.Tab
commands := k.Commands
- if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+ if m.focus == uiFocusEditor && m.textarea.Value() == "" {
commands.SetHelp("/ or ctrl+p", "commands")
}
@@ -1941,7 +1945,7 @@ func (m *UI) FullHelp() [][]key.Binding {
hasAttachments := len(m.attachments.List()) > 0
hasSession := m.hasSession()
commands := k.Commands
- if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+ if m.focus == uiFocusEditor && m.textarea.Value() == "" {
commands.SetHelp("/ or ctrl+p", "commands")
}
From 7643d6ac14d0471ce4d114d8df0d3a5e72064bf2 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Thu, 29 Jan 2026 10:48:11 -0300
Subject: [PATCH 13/33] ci: use goreleaser nightly on snapshot build
Signed-off-by: Carlos Alexandro Becker
---
.github/workflows/snapshot.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml
index 0c3d5ce6d437a39471003018545d8546fa220ef6..a5a45d8fdeeaf8f0c1374366e7c1d34839c1acc5 100644
--- a/.github/workflows/snapshot.yml
+++ b/.github/workflows/snapshot.yml
@@ -27,7 +27,7 @@ jobs:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
- version: "~> v2"
+ version: "nightly"
distribution: goreleaser-pro
args: build --snapshot --clean
env:
From c0a8c7e8219b39d47ab6400438b61ff2dd357164 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Thu, 29 Jan 2026 15:33:52 -0300
Subject: [PATCH 14/33] feat: allow to disable indeterminate progress bar
(#2048)
Signed-off-by: Carlos Alexandro Becker
---
internal/app/app.go | 7 +++++--
internal/config/config.go | 3 ++-
internal/ui/model/ui.go | 8 ++++++--
3 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/internal/app/app.go b/internal/app/app.go
index 647d90c9cfe29402b00ef5743f3a84f5e1b681ab..f914600457061056648cb23baa7901ca8d946f24 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -152,6 +152,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
stdoutTTY bool
stderrTTY bool
stdinTTY bool
+ progress bool
)
if f, ok := output.(*os.File); ok {
@@ -160,6 +161,8 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
stderrTTY = term.IsTerminal(os.Stderr.Fd())
stdinTTY = term.IsTerminal(os.Stdin.Fd())
+ progress = app.config.Options.Progress == nil || *app.config.Options.Progress
+
if !quiet && stderrTTY {
t := styles.CurrentTheme()
@@ -244,7 +247,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
messageReadBytes := make(map[string]int)
defer func() {
- if stderrTTY {
+ if progress && stderrTTY {
_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
}
@@ -254,7 +257,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
}()
for {
- if stderrTTY {
+ if progress && stderrTTY {
// HACK: Reinitialize the terminal progress bar on every iteration
// so it doesn't get hidden by the terminal due to inactivity.
_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
diff --git a/internal/config/config.go b/internal/config/config.go
index d18d2d9c61d2f791ab9c6f9a0b7cd41029b70e60..b9bc5259d36390d53aa21befd524ea7043261905 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -257,7 +257,8 @@ type Options struct {
Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
- AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"`
+ AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
+ Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
}
type MCPs map[string]MCPConfig
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 9eb7f01f881e70ad82597820dac8e3161f4cd684..eca2cf80a343214babbcfbdb37916a61d07a1ced 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -144,7 +144,8 @@ type UI struct {
// sendProgressBar instructs the TUI to send progress bar updates to the
// terminal.
- sendProgressBar bool
+ sendProgressBar bool
+ progressBarEnabled bool
// caps hold different terminal capabilities that we query for.
caps common.Capabilities
@@ -295,6 +296,9 @@ func New(com *common.Common) *UI {
// set initial state
ui.setState(desiredState, desiredFocus)
+ // disable indeterminate progress bar
+ ui.progressBarEnabled = com.Config().Options.Progress == nil || *com.Config().Options.Progress
+
return ui
}
@@ -1854,7 +1858,7 @@ func (m *UI) View() tea.View {
content = strings.Join(contentLines, "\n")
v.Content = content
- if m.sendProgressBar && m.isAgentBusy() {
+ if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
// HACK: use a random percentage to prevent ghostty from hiding it
// after a timeout.
v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
From 40869ecb5974d716d0f61316aae25f80eda43ab2 Mon Sep 17 00:00:00 2001
From: Charm <124303983+charmcli@users.noreply.github.com>
Date: Thu, 29 Jan 2026 18:34:28 +0000
Subject: [PATCH 15/33] chore: auto-update files
---
schema.json | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/schema.json b/schema.json
index 47b19589f29cdf6165f0b5c93a97168e3396e6bd..7a32f612e64a20d0393f74471c1fbdb8863c2365 100644
--- a/schema.json
+++ b/schema.json
@@ -432,7 +432,13 @@
},
"auto_lsp": {
"type": "boolean",
- "description": "Automatically setup LSPs based on root markers"
+ "description": "Automatically setup LSPs based on root markers",
+ "default": true
+ },
+ "progress": {
+ "type": "boolean",
+ "description": "Show indeterminate progress updates during long operations",
+ "default": true
}
},
"additionalProperties": false,
From c3ae2306d5d8163d428685a13e71f08212f7e9e6 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 Jan 2026 19:12:27 -0500
Subject: [PATCH 16/33] fix: respect disabled indeterminate progress bar
setting on app start (#2054)
---
internal/cmd/root.go | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 577d4ccb4abaa79275a5a556c463cb52b16aab11..b33303d1bbabb408988d50378ea2370896fb929b 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -179,12 +179,19 @@ func supportsProgressBar() bool {
}
func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
- if supportsProgressBar() {
+ app, err := setupApp(cmd)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if progress bar is enabled in config (defaults to true if nil)
+ progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
+ if progressEnabled && supportsProgressBar() {
_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
}
- return setupApp(cmd)
+ return app, nil
}
// setupApp handles the common setup logic for both interactive and non-interactive modes.
From 3a2a045c3edb8e53b36cb71951f91213e4c3fb5c Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Thu, 29 Jan 2026 21:12:43 -0300
Subject: [PATCH 17/33] fix: improve logs, standardize capitalized (#2047)
* fix: improve logs, standarize capitalized
Signed-off-by: Carlos Alexandro Becker
* Update Taskfile.yaml
Co-authored-by: Andrey Nering
* chore: lint
Signed-off-by: Carlos Alexandro Becker
---------
Signed-off-by: Carlos Alexandro Becker
Co-authored-by: Andrey Nering
---
AGENTS.md | 2 ++
Taskfile.yaml | 6 ++++++
internal/agent/agent.go | 28 ++++++++++++++--------------
internal/agent/coordinator.go | 12 ++++++------
internal/agent/hyper/provider.go | 2 +-
internal/agent/tools/mcp/init.go | 12 ++++++------
internal/agent/tools/mcp/prompts.go | 2 +-
internal/agent/tools/mcp/tools.go | 2 +-
internal/app/app.go | 26 +++++++++++++++-----------
internal/app/lsp.go | 2 +-
internal/cmd/run.go | 12 +++++++++++-
internal/config/config.go | 4 ++--
internal/fsext/ls.go | 12 ++++++------
internal/home/home.go | 2 +-
internal/lsp/client.go | 8 ++++----
internal/session/session.go | 2 +-
internal/ui/image/image.go | 2 +-
internal/ui/model/history.go | 2 +-
internal/ui/model/ui.go | 6 +++---
scripts/check_log_capitalization.sh | 5 +++++
20 files changed, 88 insertions(+), 61 deletions(-)
create mode 100644 scripts/check_log_capitalization.sh
diff --git a/AGENTS.md b/AGENTS.md
index 7fab72afb836136020500b7f27e905f3dcfc72da..654f1cd0a7fe1cbb50a3026f86f31b68e04f8043 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,6 +26,8 @@
need of a temporary directory. This directory does not need to be removed.
- **JSON tags**: Use snake_case for JSON field names
- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
+- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session")
+ - This is enforced by `task lint:log` which runs as part of `task lint`
- **Comments**: End comments in periods unless comments are at the end of the line.
## Testing with Mock Providers
diff --git a/Taskfile.yaml b/Taskfile.yaml
index 9ffe8923d6bbd92caf441d872726de48352b2faa..bf22d6593bfd099972a7a11a806cfee939511df2 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -23,10 +23,16 @@ tasks:
lint:
desc: Run base linters
cmds:
+ - task: lint:log
- golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
env:
GOEXPERIMENT: null
+ lint:log:
+ desc: Check that log messages start with capital letters
+ cmds:
+ - ./scripts/check_log_capitalization.sh
+
lint:fix:
desc: Run base linters and fix issues
cmds:
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 74a1a9f0c94483268b4b3558c7d4ca7a9899c7ef..d46f42334f9bb5de656cd2f6e442cb4c414a6968 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -802,22 +802,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
resp, err := agent.Stream(ctx, streamCall)
if err == nil {
// We successfully generated a title with the small model.
- slog.Info("generated title with small model")
+ slog.Debug("Generated title with small model")
} else {
// It didn't work. Let's try with the big model.
- slog.Error("error generating title with small model; trying big model", "err", err)
+ slog.Error("Error generating title with small model; trying big model", "err", err)
model = largeModel
agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
resp, err = agent.Stream(ctx, streamCall)
if err == nil {
- slog.Info("generated title with large model")
+ slog.Debug("Generated title with large model")
} else {
// Welp, the large model didn't work either. Use the default
// session name and return.
- slog.Error("error generating title with large model", "err", err)
+ slog.Error("Error generating title with large model", "err", err)
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
}
return
}
@@ -826,10 +826,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
if resp == nil {
// Actually, we didn't get a response so we can't. Use the default
// session name and return.
- slog.Error("response is nil; can't generate title")
+ slog.Error("Response is nil; can't generate title")
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
}
return
}
@@ -843,7 +843,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
title = strings.TrimSpace(title)
if title == "" {
- slog.Warn("empty title; using fallback")
+ slog.Debug("Empty title; using fallback")
title = defaultSessionName
}
@@ -878,7 +878,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
// concurrent session updates.
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
return
}
}
@@ -921,25 +921,25 @@ func (a *sessionAgent) Cancel(sessionID string) {
// fully completes (including error handling that may access the DB).
// The defer in processRequest will clean up the entry.
if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
- slog.Info("Request cancellation initiated", "session_id", sessionID)
+ slog.Debug("Request cancellation initiated", "session_id", sessionID)
cancel()
}
// Also check for summarize requests.
if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
- slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+ slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
cancel()
}
if a.QueuedPrompts(sessionID) > 0 {
- slog.Info("Clearing queued prompts", "session_id", sessionID)
+ slog.Debug("Clearing queued prompts", "session_id", sessionID)
a.messageQueue.Del(sessionID)
}
}
func (a *sessionAgent) ClearQueue(sessionID string) {
if a.QueuedPrompts(sessionID) > 0 {
- slog.Info("Clearing queued prompts", "session_id", sessionID)
+ slog.Debug("Clearing queued prompts", "session_id", sessionID)
a.messageQueue.Del(sessionID)
}
}
@@ -1099,7 +1099,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
decoded, err := base64.StdEncoding.DecodeString(media.Data)
if err != nil {
- slog.Warn("failed to decode media data", "error", err)
+ slog.Warn("Failed to decode media data", "error", err)
textParts = append(textParts, part)
continue
}
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index fd65072fd4eb297b8eddcb38aafe50d595601f82..40b7e029e465cc40f285ced7b5b77dd61109a2a0 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -151,7 +151,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
- slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
+ slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
return nil, err
}
@@ -176,18 +176,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
if c.isUnauthorized(originalErr) {
switch {
case providerCfg.OAuthToken != nil:
- slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+ slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
return nil, originalErr
}
- slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
+ slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
return run()
case strings.Contains(providerCfg.APIKeyTemplate, "$"):
- slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
+ slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
return nil, originalErr
}
- slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
+ slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
return run()
}
}
@@ -428,7 +428,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
}
if len(agent.AllowedMCP) == 0 {
// No MCPs allowed
- slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
+ slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
break
}
diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go
index 03278ae99f87608c65263b0ffef7fb473cd58e31..6194593a719b388d1676f51568e72f45628fdae4 100644
--- a/internal/agent/hyper/provider.go
+++ b/internal/agent/hyper/provider.go
@@ -49,7 +49,7 @@ var Enabled = sync.OnceValue(func() bool {
var Embedded = sync.OnceValue(func() catwalk.Provider {
var provider catwalk.Provider
if err := json.Unmarshal(embedded, &provider); err != nil {
- slog.Error("could not use embedded provider data", "err", err)
+ slog.Error("Could not use embedded provider data", "err", err)
}
return provider
})
diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go
index e1e7d609efc86d0dcb510fa5963552f7d487a134..05ac2eaeba29c2ce4411c8acc355d645037a6f55 100644
--- a/internal/agent/tools/mcp/init.go
+++ b/internal/agent/tools/mcp/init.go
@@ -140,7 +140,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
for name, m := range cfg.MCP {
if m.Disabled {
updateState(name, StateDisabled, nil, nil, Counts{})
- slog.Debug("skipping disabled mcp", "name", name)
+ slog.Debug("Skipping disabled MCP", "name", name)
continue
}
@@ -162,7 +162,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
err = fmt.Errorf("panic: %v", v)
}
updateState(name, StateError, err, nil, Counts{})
- slog.Error("panic in mcp client initialization", "error", err, "name", name)
+ slog.Error("Panic in MCP client initialization", "error", err, "name", name)
}
}()
@@ -174,7 +174,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
tools, err := getTools(ctx, session)
if err != nil {
- slog.Error("error listing tools", "error", err)
+ slog.Error("Error listing tools", "error", err)
updateState(name, StateError, err, nil, Counts{})
session.Close()
return
@@ -182,7 +182,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
prompts, err := getPrompts(ctx, session)
if err != nil {
- slog.Error("error listing prompts", "error", err)
+ slog.Error("Error listing prompts", "error", err)
updateState(name, StateError, err, nil, Counts{})
session.Close()
return
@@ -277,7 +277,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
transport, err := createTransport(mcpCtx, m, resolver)
if err != nil {
updateState(name, StateError, err, nil, Counts{})
- slog.Error("error creating mcp client", "error", err, "name", name)
+ slog.Error("Error creating MCP client", "error", err, "name", name)
cancel()
cancelTimer.Stop()
return nil, err
@@ -319,7 +319,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
}
cancelTimer.Stop()
- slog.Info("MCP client initialized", "name", name)
+ slog.Debug("MCP client initialized", "name", name)
return session, nil
}
diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go
index 0bd6e665dd80dad90c844d7d31c61c506ea83803..ea208a57716d2a273fde1b6faa3988ca2e57b012 100644
--- a/internal/agent/tools/mcp/prompts.go
+++ b/internal/agent/tools/mcp/prompts.go
@@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args
func RefreshPrompts(ctx context.Context, name string) {
session, ok := sessions.Get(name)
if !ok {
- slog.Warn("refresh prompts: no session", "name", name)
+ slog.Warn("Refresh prompts: no session", "name", name)
return
}
diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go
index 779baa55d93bc54523bac81c5094bacee7fc68fb..65ef5a9d8b3e7304a49bd708ecdd53a3cc400b17 100644
--- a/internal/agent/tools/mcp/tools.go
+++ b/internal/agent/tools/mcp/tools.go
@@ -111,7 +111,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
func RefreshTools(ctx context.Context, name string) {
session, ok := sessions.Get(name)
if !ok {
- slog.Warn("refresh tools: no session", "name", name)
+ slog.Warn("Refresh tools: no session", "name", name)
return
}
diff --git a/internal/app/app.go b/internal/app/app.go
index f914600457061056648cb23baa7901ca8d946f24..88af5345eb55cc7f3e6c3c5923806967cc0a1632 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -135,7 +135,7 @@ func (app *App) Config() *config.Config {
// RunNonInteractive runs the application in non-interactive mode with the
// given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
slog.Info("Running in non-interactive mode")
ctx, cancel := context.WithCancel(ctx)
@@ -160,10 +160,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
}
stderrTTY = term.IsTerminal(os.Stderr.Fd())
stdinTTY = term.IsTerminal(os.Stdin.Fd())
-
progress = app.config.Options.Progress == nil || *app.config.Options.Progress
- if !quiet && stderrTTY {
+ if !hideSpinner && stderrTTY {
t := styles.CurrentTheme()
// Detect background color to set the appropriate color for the
@@ -188,7 +187,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
// Helper function to stop spinner once.
stopSpinner := func() {
- if !quiet && spinner != nil {
+ if !hideSpinner && spinner != nil {
spinner.Stop()
spinner = nil
}
@@ -245,6 +244,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
messageEvents := app.Messages.Subscribe(ctx)
messageReadBytes := make(map[string]int)
+ var printed bool
defer func() {
if progress && stderrTTY {
@@ -268,7 +268,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
stopSpinner()
if result.err != nil {
if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
- slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
+ slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID)
return nil
}
return fmt.Errorf("agent processing failed: %w", result.err)
@@ -294,7 +294,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
if readBytes == 0 {
part = strings.TrimLeft(part, " \t")
}
- fmt.Fprint(output, part)
+ // Ignore initial whitespace-only messages.
+ if printed || strings.TrimSpace(part) != "" {
+ printed = true
+ fmt.Fprint(output, part)
+ }
messageReadBytes[msg.ID] = len(content)
}
@@ -433,20 +437,20 @@ func setupSubscriber[T any](
select {
case event, ok := <-subCh:
if !ok {
- slog.Debug("subscription channel closed", "name", name)
+ slog.Debug("Subscription channel closed", "name", name)
return
}
var msg tea.Msg = event
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
- slog.Warn("message dropped due to slow consumer", "name", name)
+ slog.Debug("Message dropped due to slow consumer", "name", name)
case <-ctx.Done():
- slog.Debug("subscription cancelled", "name", name)
+ slog.Debug("Subscription cancelled", "name", name)
return
}
case <-ctx.Done():
- slog.Debug("subscription cancelled", "name", name)
+ slog.Debug("Subscription cancelled", "name", name)
return
}
}
@@ -511,7 +515,7 @@ func (app *App) Subscribe(program *tea.Program) {
// Shutdown performs a graceful shutdown of the application.
func (app *App) Shutdown() {
start := time.Now()
- defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
+ defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }()
// First, cancel all agents and wait for them to finish. This must complete
// before closing the DB so agents can finish writing their state.
diff --git a/internal/app/lsp.go b/internal/app/lsp.go
index 39e03d3cb4f2f5a9dc7720f8ce1f7286d4efd6b2..14f1c99587bf4bfe052f9ac2078cdf03d859cfa1 100644
--- a/internal/app/lsp.go
+++ b/internal/app/lsp.go
@@ -140,7 +140,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
}
- slog.Info("LSP client initialized", "name", name)
+ slog.Debug("LSP client initialized", "name", name)
// Add to map with mutex protection before starting goroutine
app.LSPClients.Set(name, lspClient)
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index e4d72b41be13684e28ca6c2b85b79bfdcea52fc7..50005a548bad0308bdca3a2afbe17503c1f86c56 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -8,6 +8,7 @@ import (
"os/signal"
"strings"
+ "charm.land/log/v2"
"github.com/charmbracelet/crush/internal/event"
"github.com/spf13/cobra"
)
@@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go
# Run in quiet mode (hide the spinner)
crush run --quiet "Generate a README for this project"
+
+# Run in verbose mode
+crush run --verbose "Generate a README for this project"
`,
RunE: func(cmd *cobra.Command, args []string) error {
quiet, _ := cmd.Flags().GetBool("quiet")
+ verbose, _ := cmd.Flags().GetBool("verbose")
largeModel, _ := cmd.Flags().GetString("model")
smallModel, _ := cmd.Flags().GetString("small-model")
@@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project"
return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
}
+ if verbose {
+ slog.SetDefault(slog.New(log.New(os.Stderr)))
+ }
+
prompt := strings.Join(args, " ")
prompt, err = MaybePrependStdin(prompt)
@@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project"
event.SetNonInteractive(true)
event.AppInitialized()
- return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
+ return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
},
PostRun: func(cmd *cobra.Command, args []string) {
event.AppExited()
@@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project"
func init() {
runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+ runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
}
diff --git a/internal/config/config.go b/internal/config/config.go
index b9bc5259d36390d53aa21befd524ea7043261905..ca585d80e8a9dcdc0f9f7b2d38999ce2cac74243 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -317,7 +317,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string {
var err error
m.Headers[e], err = resolver.ResolveValue(v)
if err != nil {
- slog.Error("error resolving header variable", "error", err, "variable", e, "value", v)
+ slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
continue
}
}
@@ -840,7 +840,7 @@ func resolveEnvs(envs map[string]string) []string {
var err error
envs[e], err = resolver.ResolveValue(v)
if err != nil {
- slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+ slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
continue
}
}
diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go
index c22b960ad02a42bf6adac7768b7d99e55a9390ee..b541a4a0fedd78c866fa274fc183fabe4c833edd 100644
--- a/internal/fsext/ls.go
+++ b/internal/fsext/ls.go
@@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
}
if commonIgnorePatterns().MatchesPath(relPath) {
- slog.Debug("ignoring common pattern", "path", relPath)
+ slog.Debug("Ignoring common pattern", "path", relPath)
return true
}
parentDir := filepath.Dir(path)
ignoreParser := dl.getIgnore(parentDir)
if ignoreParser.MatchesPath(relPath) {
- slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
+ slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
return true
}
// For directories, also check with trailing slash (gitignore convention)
if ignoreParser.MatchesPath(relPath + "/") {
- slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
+ slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
return true
}
@@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
}
if homeIgnore().MatchesPath(relPath) {
- slog.Debug("ignoring home dir pattern", "path", relPath)
+ slog.Debug("Ignoring home dir pattern", "path", relPath)
return true
}
@@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool {
parent := filepath.Dir(filepath.Dir(path))
for parent != "." && path != "." {
if dl.getIgnore(parent).MatchesPath(path) {
- slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
+ slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
return true
}
if parent == dl.rootPath {
@@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
found := csync.NewSlice[string]()
dl := NewDirectoryLister(initialPath)
- slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+ slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
conf := fastwalk.Config{
Follow: true,
diff --git a/internal/home/home.go b/internal/home/home.go
index e44649235ff5bb24c8bb644ae90e9002add45237..80fb1ea2e01630597c2547eaa8e4e55150ec6976 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir()
func init() {
if homedirErr != nil {
- slog.Error("failed to get user home directory", "error", homedirErr)
+ slog.Error("Failed to get user home directory", "error", homedirErr)
}
}
diff --git a/internal/lsp/client.go b/internal/lsp/client.go
index 98aa75966160ba97af8c431d98c642fb558e5dc7..05ee570b9d5ad7a0d667b48084289bf0fe5d3dde 100644
--- a/internal/lsp/client.go
+++ b/internal/lsp/client.go
@@ -317,12 +317,12 @@ func (c *Client) HandlesFile(path string) bool {
// Check if file is within working directory.
absPath, err := filepath.Abs(path)
if err != nil {
- slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err)
+ slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
return false
}
relPath, err := filepath.Rel(c.workDir, absPath)
if err != nil || strings.HasPrefix(relPath, "..") {
- slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+ slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
return false
}
@@ -339,11 +339,11 @@ func (c *Client) HandlesFile(path string) bool {
suffix = "." + suffix
}
if strings.HasSuffix(name, suffix) || filetype == string(kind) {
- slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
+ slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
return true
}
}
- slog.Debug("doesn't handle file", "name", c.name, "file", name)
+ slog.Debug("Doesn't handle file", "name", c.name, "file", name)
return false
}
diff --git a/internal/session/session.go b/internal/session/session.go
index 905ee1cf1417b148019d9688985c1f5200209d69..0ef6cfe22bebbf35df48f0db1fbe00c6d128251b 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -203,7 +203,7 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
func (s service) fromDBItem(item db.Session) Session {
todos, err := unmarshalTodos(item.Todos.String)
if err != nil {
- slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err)
+ slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
}
return Session{
ID: item.ID,
diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go
index 5644146fec5b1e4e1e3a96c92a315c0bf986180d..07039433dded1647646704959791dfcad7d3d69f 100644
--- a/internal/ui/image/image.go
+++ b/internal/ui/image/image.go
@@ -168,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
return chunk
},
}); err != nil {
- slog.Error("failed to encode image for kitty graphics", "err", err)
+ slog.Error("Failed to encode image for kitty graphics", "err", err)
return uiutil.InfoMsg{
Type: uiutil.InfoTypeError,
Msg: "failed to encode image",
diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go
index 5acc6ef5feabdab2bcb7a81ba8a60f5f224dab11..5d2284ab1756257cc06b76de4621849f1e3071ba 100644
--- a/internal/ui/model/history.go
+++ b/internal/ui/model/history.go
@@ -27,7 +27,7 @@ func (m *UI) loadPromptHistory() tea.Cmd {
messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
}
if err != nil {
- slog.Error("failed to load prompt history", "error", err)
+ slog.Error("Failed to load prompt history", "error", err)
return promptHistoryLoadedMsg{messages: nil}
}
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index eca2cf80a343214babbcfbdb37916a61d07a1ced..1b828dffd1ce86db8ae6efb53e1b23465dfa20f0 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -330,7 +330,7 @@ func (m *UI) loadCustomCommands() tea.Cmd {
return func() tea.Msg {
customCommands, err := commands.LoadCustomCommands(m.com.Config())
if err != nil {
- slog.Error("failed to load custom commands", "error", err)
+ slog.Error("Failed to load custom commands", "error", err)
}
return userCommandsLoadedMsg{Commands: customCommands}
}
@@ -341,7 +341,7 @@ func (m *UI) loadMCPrompts() tea.Cmd {
return func() tea.Msg {
prompts, err := commands.LoadMCPPrompts()
if err != nil {
- slog.Error("failed to load mcp prompts", "error", err)
+ slog.Error("Failed to load MCP prompts", "error", err)
}
if prompts == nil {
// flag them as loaded even if there is none or an error
@@ -683,7 +683,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case uv.KittyGraphicsEvent:
if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
- slog.Warn("unexpected Kitty graphics response",
+ slog.Warn("Unexpected Kitty graphics response",
"response", string(msg.Payload),
"options", msg.Options)
}
diff --git a/scripts/check_log_capitalization.sh b/scripts/check_log_capitalization.sh
new file mode 100644
index 0000000000000000000000000000000000000000..fa5f651dfb1a7dc53876018029599edd3479d94f
--- /dev/null
+++ b/scripts/check_log_capitalization.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then
+ echo "❌ Log messages must start with a capital letter. Found lowercase logs above."
+ exit 1
+fi
From 02bb76b4098479a3efe3326d550163afba52a924 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Thu, 29 Jan 2026 21:13:11 -0300
Subject: [PATCH 18/33] fix: allow HYPER_URL with embedded provider (#2031)
Signed-off-by: Carlos Alexandro Becker
---
internal/agent/hyper/provider.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go
index 6194593a719b388d1676f51568e72f45628fdae4..eaac14c6a8100c548288e66ddb8faabcdffa980b 100644
--- a/internal/agent/hyper/provider.go
+++ b/internal/agent/hyper/provider.go
@@ -51,6 +51,9 @@ var Embedded = sync.OnceValue(func() catwalk.Provider {
if err := json.Unmarshal(embedded, &provider); err != nil {
slog.Error("Could not use embedded provider data", "err", err)
}
+ if e := os.Getenv("HYPER_URL"); e != "" {
+ provider.APIEndpoint = e + "/api/v1/fantasy"
+ }
return provider
})
From 7ace8d58f38e755d2d844b983aab5cff1e64ae4e Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 Jan 2026 15:30:12 -0500
Subject: [PATCH 19/33] fix: panic when matching titles in session dialogue
---
internal/ui/dialog/sessions_item.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go
index 87a2627daa3b63eca309feeb914ec80c33e2ef1f..119d3efb9cba1ee0a700b0b4bc22fee94289af76 100644
--- a/internal/ui/dialog/sessions_item.go
+++ b/internal/ui/dialog/sessions_item.go
@@ -141,7 +141,7 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
titleWidth := lipgloss.Width(title)
gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
content := title
- if matches := len(m.MatchedIndexes); matches > 0 {
+ if m != nil && len(m.MatchedIndexes) > 0 {
var lastPos int
parts := make([]string, 0)
ranges := matchedRanges(m.MatchedIndexes)
From 4228f7506d72a0a381c011539de17635f578f7d7 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 Jan 2026 15:33:33 -0500
Subject: [PATCH 20/33] fix: slice string at the grapheme level, not byte level
---
internal/ui/dialog/sessions_item.go | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go
index 119d3efb9cba1ee0a700b0b4bc22fee94289af76..6b1fe27580bba28aa02b06e2738e1882b7144107 100644
--- a/internal/ui/dialog/sessions_item.go
+++ b/internal/ui/dialog/sessions_item.go
@@ -148,7 +148,7 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
for _, rng := range ranges {
start, stop := bytePosToVisibleCharPos(title, rng)
if start > lastPos {
- parts = append(parts, title[lastPos:start])
+ parts = append(parts, ansi.Cut(title, lastPos, start))
}
// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
// because we can control the underline start and stop more
@@ -157,13 +157,13 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
// with other style
parts = append(parts,
ansi.NewStyle().Underline(true).String(),
- title[start:stop+1],
+ ansi.Cut(title, start, stop+1),
ansi.NewStyle().Underline(false).String(),
)
lastPos = stop + 1
}
- if lastPos < len(title) {
- parts = append(parts, title[lastPos:])
+ if lastPos < ansi.StringWidth(title) {
+ parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
}
content = strings.Join(parts, "")
From ac03cb02b28265074bbd001291b793633f822395 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Wed, 28 Jan 2026 15:57:11 -0500
Subject: [PATCH 21/33] fix(ui): typo in ListItemStyles type name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
💘 Generated with Crush
Assisted-by: Kimi K2.5 via Crush
---
internal/ui/dialog/commands_item.go | 2 +-
internal/ui/dialog/models_item.go | 2 +-
internal/ui/dialog/reasoning.go | 2 +-
internal/ui/dialog/sessions_item.go | 6 +++---
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go
index 1099a8b435f0ed31d9f4c81dfbb4cb2b33a3d910..89cd552f8ef5acfb326e9fbcae87b0a542b35022 100644
--- a/internal/ui/dialog/commands_item.go
+++ b/internal/ui/dialog/commands_item.go
@@ -66,7 +66,7 @@ func (c *CommandItem) Shortcut() string {
// Render implements ListItem.
func (c *CommandItem) Render(width int) string {
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: c.t.Dialog.NormalItem,
ItemFocused: c.t.Dialog.SelectedItem,
InfoTextBlurred: c.t.Base,
diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go
index e61359d065a895ec508083198e5530977091366b..937ab0cb3ec473ab343837350aa590fbffcb0fc2 100644
--- a/internal/ui/dialog/models_item.go
+++ b/internal/ui/dialog/models_item.go
@@ -106,7 +106,7 @@ func (m *ModelItem) Render(width int) string {
if m.showProvider {
providerInfo = string(m.prov.Name)
}
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: m.t.Dialog.NormalItem,
ItemFocused: m.t.Dialog.SelectedItem,
InfoTextBlurred: m.t.Base,
diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go
index f11c59e48702ea3bc419afa62e9ee7fce8c52632..2a333f155cdc1499993f05411d7090793f74f54e 100644
--- a/internal/ui/dialog/reasoning.go
+++ b/internal/ui/dialog/reasoning.go
@@ -293,7 +293,7 @@ func (r *ReasoningItem) Render(width int) string {
if r.isCurrent {
info = "current"
}
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: r.t.Dialog.NormalItem,
ItemFocused: r.t.Dialog.SelectedItem,
InfoTextBlurred: r.t.Base,
diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go
index 6b1fe27580bba28aa02b06e2738e1882b7144107..f4e7f061a83ec171940c02832d3b1bfe4d5b7ef7 100644
--- a/internal/ui/dialog/sessions_item.go
+++ b/internal/ui/dialog/sessions_item.go
@@ -76,7 +76,7 @@ func (s *SessionItem) Cursor() *tea.Cursor {
// Render returns the string representation of the session item.
func (s *SessionItem) Render(width int) string {
info := humanize.Time(time.Unix(s.UpdatedAt, 0))
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: s.t.Dialog.NormalItem,
ItemFocused: s.t.Dialog.SelectedItem,
InfoTextBlurred: s.t.Subtle,
@@ -101,14 +101,14 @@ func (s *SessionItem) Render(width int) string {
return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
}
-type ListIemStyles struct {
+type ListItemStyles struct {
ItemBlurred lipgloss.Style
ItemFocused lipgloss.Style
InfoTextBlurred lipgloss.Style
InfoTextFocused lipgloss.Style
}
-func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
if cache == nil {
cache = make(map[int]string)
}
From 857cc282d54af48d2b85b4e485c3b0829e9272b2 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Wed, 28 Jan 2026 16:09:18 -0500
Subject: [PATCH 22/33] chore(ui): string efficiency
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
💘 Generated with Crush
Assisted-by: Kimi K2.5 via Crush
---
internal/ui/chat/tools.go | 20 ++++++++++----------
internal/ui/styles/styles.go | 32 +++++++++++++++++---------------
2 files changed, 27 insertions(+), 25 deletions(-)
diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go
index 3ae403160b241eca6f5d74fb9841c2b10a7735b9..69ba5efff7bbe02c7b322ba940ecfefadf299eea 100644
--- a/internal/ui/chat/tools.go
+++ b/internal/ui/chat/tools.go
@@ -1119,7 +1119,7 @@ func (t *baseToolMessageItem) formatViewResultForCopy() string {
var result strings.Builder
if lang != "" {
- result.WriteString(fmt.Sprintf("```%s\n", lang))
+ fmt.Fprintf(&result, "```%s\n", lang)
} else {
result.WriteString("```\n")
}
@@ -1155,7 +1155,7 @@ func (t *baseToolMessageItem) formatEditResultForCopy() string {
}
diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
- result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+ fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
result.WriteString("```diff\n")
result.WriteString(diffContent)
result.WriteString("\n```")
@@ -1189,7 +1189,7 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
}
diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
- result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+ fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
result.WriteString("```diff\n")
result.WriteString(diffContent)
result.WriteString("\n```")
@@ -1247,9 +1247,9 @@ func (t *baseToolMessageItem) formatWriteResultForCopy() string {
}
var result strings.Builder
- result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
+ fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
if lang != "" {
- result.WriteString(fmt.Sprintf("```%s\n", lang))
+ fmt.Fprintf(&result, "```%s\n", lang)
} else {
result.WriteString("```\n")
}
@@ -1272,13 +1272,13 @@ func (t *baseToolMessageItem) formatFetchResultForCopy() string {
var result strings.Builder
if params.URL != "" {
- result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+ fmt.Fprintf(&result, "URL: %s\n", params.URL)
}
if params.Format != "" {
- result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+ fmt.Fprintf(&result, "Format: %s\n", params.Format)
}
if params.Timeout > 0 {
- result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+ fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
}
result.WriteString("\n")
@@ -1300,10 +1300,10 @@ func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
var result strings.Builder
if params.URL != "" {
- result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+ fmt.Fprintf(&result, "URL: %s\n", params.URL)
}
if params.Prompt != "" {
- result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+ fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
}
result.WriteString("```markdown\n")
diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go
index 455658e7f4900196f7c03dcc1564ea734f780a64..474a50c9934ce9363d640a4dd95a2a49ea57efc5 100644
--- a/internal/ui/styles/styles.go
+++ b/internal/ui/styles/styles.go
@@ -2,6 +2,7 @@ package styles
import (
"image/color"
+ "strings"
"charm.land/bubbles/v2/filepicker"
"charm.land/bubbles/v2/help"
@@ -1347,35 +1348,36 @@ func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
func uintPtr(u uint) *uint { return &u }
func chromaStyle(style ansi.StylePrimitive) string {
- var s string
+ var s strings.Builder
if style.Color != nil {
- s = *style.Color
+ s.WriteString(*style.Color)
}
if style.BackgroundColor != nil {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "bg:" + *style.BackgroundColor
+ s.WriteString("bg:")
+ s.WriteString(*style.BackgroundColor)
}
if style.Italic != nil && *style.Italic {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "italic"
+ s.WriteString("italic")
}
if style.Bold != nil && *style.Bold {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "bold"
+ s.WriteString("bold")
}
if style.Underline != nil && *style.Underline {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "underline"
+ s.WriteString("underline")
}
- return s
+ return s.String()
}
From 87c2165cd5cd5afa897d4e2c4a95ff590c785ef4 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Fri, 30 Jan 2026 12:55:07 -0300
Subject: [PATCH 23/33] chore: `chmod +x scripts/check_log_capitalization.sh`
---
scripts/check_log_capitalization.sh | 0
1 file changed, 0 insertions(+), 0 deletions(-)
mode change 100644 => 100755 scripts/check_log_capitalization.sh
diff --git a/scripts/check_log_capitalization.sh b/scripts/check_log_capitalization.sh
old mode 100644
new mode 100755
From 1696e72e92298d205e74384de36f94143ecabc38 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Fri, 30 Jan 2026 12:55:30 -0300
Subject: [PATCH 24/33] chore: update catwalk and its import paths to
`charm.land/catwalk`
---
Taskfile.yaml | 2 +-
go.mod | 2 +-
go.sum | 4 ++--
internal/agent/agent.go | 2 +-
internal/agent/common_test.go | 2 +-
internal/agent/coordinator.go | 2 +-
internal/agent/hyper/provider.go | 2 +-
internal/app/app.go | 2 +-
internal/app/provider_test.go | 2 +-
internal/cmd/models.go | 2 +-
internal/config/catwalk.go | 4 ++--
internal/config/catwalk_test.go | 2 +-
internal/config/config.go | 2 +-
internal/config/copilot.go | 2 +-
internal/config/hyper.go | 2 +-
internal/config/hyper_test.go | 2 +-
internal/config/load.go | 2 +-
internal/config/load_test.go | 2 +-
internal/config/provider.go | 4 ++--
internal/config/provider_empty_test.go | 2 +-
internal/config/provider_test.go | 2 +-
internal/message/content.go | 2 +-
internal/tui/components/chat/messages/messages.go | 2 +-
internal/tui/components/chat/splash/splash.go | 2 +-
internal/tui/components/dialogs/models/list.go | 2 +-
internal/tui/components/dialogs/models/list_recent_test.go | 2 +-
internal/tui/components/dialogs/models/models.go | 2 +-
internal/ui/chat/messages.go | 2 +-
internal/ui/dialog/actions.go | 2 +-
internal/ui/dialog/api_key_input.go | 2 +-
internal/ui/dialog/models.go | 2 +-
internal/ui/dialog/models_item.go | 2 +-
internal/ui/dialog/oauth.go | 2 +-
internal/ui/dialog/oauth_copilot.go | 2 +-
internal/ui/dialog/oauth_hyper.go | 2 +-
internal/ui/model/ui.go | 2 +-
36 files changed, 39 insertions(+), 39 deletions(-)
diff --git a/Taskfile.yaml b/Taskfile.yaml
index bf22d6593bfd099972a7a11a806cfee939511df2..bff27387d6be353ccd02cf6437b4acafb30334c9 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -153,5 +153,5 @@ tasks:
desc: Update Fantasy and Catwalk
cmds:
- go get charm.land/fantasy
- - go get github.com/charmbracelet/catwalk
+ - go get charm.land/catwalk
- go mod tidy
diff --git a/go.mod b/go.mod
index 9281f1b44966d5ce00d19264b6ad29dfc4cb4aa4..4ea501fd125ce2a8ef62b4555229218b5d65ff19 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.25.5
require (
charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
+ charm.land/catwalk v0.16.0
charm.land/fantasy v0.6.1
charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
@@ -19,7 +20,6 @@ require (
github.com/aymanbagabas/go-udiff v0.3.1
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charlievieth/fastwalk v1.0.14
- github.com/charmbracelet/catwalk v0.15.0
github.com/charmbracelet/colorprofile v0.4.1
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
diff --git a/go.sum b/go.sum
index c0d8fdcc25091a334f0f79fcf2e5f91247496fdc..af4bd1eaab7bb4696ef0d344113a435f90a7a4ac 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv
charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
+charm.land/catwalk v0.16.0 h1:NP6lPz086OAsFdyYTRE6x1CyAosX6MpqdY303ntwsX0=
+charm.land/catwalk v0.16.0/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc=
charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0=
charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
@@ -96,8 +98,6 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
-github.com/charmbracelet/catwalk v0.15.0 h1:5oWJdvchTPfF7855A0n40+XbZQz4+vouZ/NhQ661JKI=
-github.com/charmbracelet/catwalk v0.15.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index d46f42334f9bb5de656cd2f6e442cb4c414a6968..20ca25f89421b8f1fd2927b1162c412d56becdc4 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -22,6 +22,7 @@ import (
"sync"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/bedrock"
@@ -29,7 +30,6 @@ import (
"charm.land/fantasy/providers/openai"
"charm.land/fantasy/providers/openrouter"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go
index 2bb5e5650bcb3280ddb95bdcea7d588a2eea7643..4f96c3cfbb1728f533c71a7c05b7e1ab85975b45 100644
--- a/internal/agent/common_test.go
+++ b/internal/agent/common_test.go
@@ -8,13 +8,13 @@ import (
"testing"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/openai"
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
"charm.land/x/vcr"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 40b7e029e465cc40f285ced7b5b77dd61109a2a0..60da01e08c668f641c11f79c36c29b5fc2186c78 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -15,8 +15,8 @@ import (
"slices"
"strings"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go
index eaac14c6a8100c548288e66ddb8faabcdffa980b..8ba3a538e4a97b4691dff4eb9aba46f83b523912 100644
--- a/internal/agent/hyper/provider.go
+++ b/internal/agent/hyper/provider.go
@@ -21,9 +21,9 @@ import (
"sync"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/object"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/event"
)
diff --git a/internal/app/app.go b/internal/app/app.go
index 88af5345eb55cc7f3e6c3c5923806967cc0a1632..c5294c2ae21f91486861a037b639cb1c00bd531f 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -15,9 +15,9 @@ import (
"time"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go
index c3acae64d1057f3bb8bd8f9a0cb6443dbe9731b7..8430211e0067810523a713a07a343ac546248830 100644
--- a/internal/app/provider_test.go
+++ b/internal/app/provider_test.go
@@ -3,7 +3,7 @@ package app
import (
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/stretchr/testify/require"
)
diff --git a/internal/cmd/models.go b/internal/cmd/models.go
index 3267469638ee83463e1785774d37c5d281d37de9..e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d 100644
--- a/internal/cmd/models.go
+++ b/internal/cmd/models.go
@@ -7,8 +7,8 @@ import (
"sort"
"strings"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2/tree"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
diff --git a/internal/config/catwalk.go b/internal/config/catwalk.go
index c3cc2eb69d47e1a85e35164fda09d0f73761b820..0c12c899c7ee34d6515410cccab13ac850a361a7 100644
--- a/internal/config/catwalk.go
+++ b/internal/config/catwalk.go
@@ -7,8 +7,8 @@ import (
"sync"
"sync/atomic"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/catwalk/pkg/embedded"
+ "charm.land/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/embedded"
)
type catwalkClient interface {
diff --git a/internal/config/catwalk_test.go b/internal/config/catwalk_test.go
index 55322b34eb7252f8cae75fb46996f45bd31abe5e..df6aea475811adfe3e4fb8935185842c7c81d145 100644
--- a/internal/config/catwalk_test.go
+++ b/internal/config/catwalk_test.go
@@ -7,7 +7,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
diff --git a/internal/config/config.go b/internal/config/config.go
index ca585d80e8a9dcdc0f9f7b2d38999ce2cac74243..19133928bd8f7e1da08b54024b4f80d41d01dc1a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -14,7 +14,7 @@ import (
"strings"
"time"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
diff --git a/internal/config/copilot.go b/internal/config/copilot.go
index ee50bec43d6ce5754799adf4bfe99ba9b357d690..d72e7d5048ba4d31c88d7f7152a6b3a9510960a2 100644
--- a/internal/config/copilot.go
+++ b/internal/config/copilot.go
@@ -6,7 +6,7 @@ import (
"log/slog"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/oauth/copilot"
)
diff --git a/internal/config/hyper.go b/internal/config/hyper.go
index 5fe6fc5a1ee54bd19902ef4c9cc6034a6b294b6f..6772f27b3bd3be136d001139a8505a7bb3fedef3 100644
--- a/internal/config/hyper.go
+++ b/internal/config/hyper.go
@@ -11,7 +11,7 @@ import (
"sync/atomic"
"time"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
xetag "github.com/charmbracelet/x/etag"
)
diff --git a/internal/config/hyper_test.go b/internal/config/hyper_test.go
index 7141eaa1e97888b5ee6f84afc8e9658825547b46..e4b6ac8acdfcdeb19f0d600baf88a337d40c230d 100644
--- a/internal/config/hyper_test.go
+++ b/internal/config/hyper_test.go
@@ -7,7 +7,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
diff --git a/internal/config/load.go b/internal/config/load.go
index 25139cb5f4b2ba8013525bfde025f04cb267d1b8..3ad4b909cb16cf5672dcadc9322a476854350632 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -16,7 +16,7 @@ import (
"strings"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
diff --git a/internal/config/load_test.go b/internal/config/load_test.go
index 08c888318724104935b9e92403f09f54f8ae20a4..60a0b7379501a7d766b33c4828c644cdb390bada 100644
--- a/internal/config/load_test.go
+++ b/internal/config/load_test.go
@@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
"github.com/stretchr/testify/assert"
diff --git a/internal/config/provider.go b/internal/config/provider.go
index 253d6f658a567ed5302887ecb87415de0a89c504..6ca981e5a73cbf3e3472b05f55c7b911a4a857c3 100644
--- a/internal/config/provider.go
+++ b/internal/config/provider.go
@@ -15,8 +15,8 @@ import (
"sync"
"time"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/catwalk/pkg/embedded"
+ "charm.land/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/embedded"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/home"
diff --git a/internal/config/provider_empty_test.go b/internal/config/provider_empty_test.go
index 7c37a9afb9694f0ea4352faee1b11d7e40d9480e..9bc62f5c3141d239aaadc3947dce539a4dcf4810 100644
--- a/internal/config/provider_empty_test.go
+++ b/internal/config/provider_empty_test.go
@@ -5,7 +5,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go
index e8790e286c3ffc8db77edb0ef8353e54ad519458..283c18c8ab68c013dadf6f4fc8174f4947210f3a 100644
--- a/internal/config/provider_test.go
+++ b/internal/config/provider_test.go
@@ -7,7 +7,7 @@ import (
"sync"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
diff --git a/internal/message/content.go b/internal/message/content.go
index 3fed1f06019c855d30af9d5583e6a7b63fcbd508..02f949334b688e4dd40c832d5f68d52523ac9953 100644
--- a/internal/message/content.go
+++ b/internal/message/content.go
@@ -8,11 +8,11 @@ import (
"strings"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/google"
"charm.land/fantasy/providers/openai"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
)
type MessageRole string
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index b4db149946fe0a1f67c957eeb04da2966e1f5f28..3c91f9f41485b439b8c25ca0692c7265ccafb14a 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -9,8 +9,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/ordered"
"github.com/google/uuid"
diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go
index 517f6d0930c46cf3d2e9f656c22515de4e9785fd..886fe5e530978678246ab120b21e0f943018fd1a 100644
--- a/internal/tui/components/chat/splash/splash.go
+++ b/internal/tui/components/chat/splash/splash.go
@@ -8,8 +8,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/config"
diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go
index 581122525a89dd308bb57a30e6b15a4cd0896708..50469a132aab60c3e63a77d9169c47688d5d9151 100644
--- a/internal/tui/components/dialogs/models/list.go
+++ b/internal/tui/components/dialogs/models/list.go
@@ -7,7 +7,7 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go
index 9b738a4b17fbaa2de18de080a769cce41a676007..5afdde98502d3d26d46dce00ab1825ca07f36831 100644
--- a/internal/tui/components/dialogs/models/list_recent_test.go
+++ b/internal/tui/components/dialogs/models/list_recent_test.go
@@ -9,7 +9,7 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/tui/exp/list"
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
index b06b4b475a9ababbda9e0702fc5552b0959741ba..34f91d060cf7b7a7fd0a3a6fe678a23ed8439530 100644
--- a/internal/tui/components/dialogs/models/models.go
+++ b/internal/tui/components/dialogs/models/models.go
@@ -9,8 +9,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/components/core"
diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go
index 45314347187e7018445d30753ffd05d24dbc716a..ad8aad399cf40809f16779dd277536d9ad47d5e3 100644
--- a/internal/ui/chat/messages.go
+++ b/internal/ui/chat/messages.go
@@ -7,8 +7,8 @@ import (
"time"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/ui/anim"
diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go
index b5db01692437dbee4b11b77da47b68f258b090e9..7c11cbd91b202cfc16e1988027f9eed657368620 100644
--- a/internal/ui/dialog/actions.go
+++ b/internal/ui/dialog/actions.go
@@ -7,7 +7,7 @@ import (
"path/filepath"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/message"
diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go
index 65fe5cfb9cb14eb60f4399b0477d6cd071315750..0ca50b8fe7f8899f16aac8428caa796c5da89610 100644
--- a/internal/ui/dialog/api_key_input.go
+++ b/internal/ui/dialog/api_key_input.go
@@ -10,7 +10,7 @@ import (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go
index 450ee8b99b75f13c1c9885281a1dfd1a0a3d9867..354d02434a6623b5a9833bc010f4eaa8d1efdc7a 100644
--- a/internal/ui/dialog/models.go
+++ b/internal/ui/dialog/models.go
@@ -10,7 +10,7 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/uiutil"
diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go
index 937ab0cb3ec473ab343837350aa590fbffcb0fc2..645b26e987b38baabd27338d43a19a4652144788 100644
--- a/internal/ui/dialog/models_item.go
+++ b/internal/ui/dialog/models_item.go
@@ -1,8 +1,8 @@
package dialog
import (
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go
index e4f7a521cacb51d215ca405883351558ed7179d6..6fbb039255144ad14b15a39f34942e504dea3f2c 100644
--- a/internal/ui/dialog/oauth.go
+++ b/internal/ui/dialog/oauth.go
@@ -9,8 +9,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/ui/common"
diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go
index 4b671852d476578f94653393796056d630ba23a5..8afb0df23134bb9e820ae2385d6b9b6838e07d98 100644
--- a/internal/ui/dialog/oauth_copilot.go
+++ b/internal/ui/dialog/oauth_copilot.go
@@ -6,7 +6,7 @@ import (
"time"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/ui/common"
diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go
index bddf4d78ef2c920855f21e056e7ee48f985b0b68..d90c385db782478721fd3e9efa49a2984f34304d 100644
--- a/internal/ui/dialog/oauth_hyper.go
+++ b/internal/ui/dialog/oauth_hyper.go
@@ -6,7 +6,7 @@ import (
"time"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth/hyper"
"github.com/charmbracelet/crush/internal/ui/common"
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 1b828dffd1ce86db8ae6efb53e1b23465dfa20f0..d44c96da32630baa086e520c2c10fcef145eb772 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -22,8 +22,8 @@ import (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/commands"
From b51c2e02e52ea23e4b5284b97dbfbc22510dca53 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Fri, 30 Jan 2026 13:46:28 -0300
Subject: [PATCH 25/33] fix: do not scroll to bottom if user has scrolled up
(#2049)
---
internal/ui/list/list.go | 4 +++-
internal/ui/model/ui.go | 20 ++++++++++++++++----
2 files changed, 19 insertions(+), 5 deletions(-)
diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go
index 0883ab2b56c5bb7ab26073301890c832e7c4e441..33a5087c9ceae3f03bb2c8f78b2cc8089f87057c 100644
--- a/internal/ui/list/list.go
+++ b/internal/ui/list/list.go
@@ -77,6 +77,8 @@ func (l *List) Gap() int {
// AtBottom returns whether the list is showing the last item at the bottom.
func (l *List) AtBottom() bool {
+ const margin = 2
+
if len(l.items) == 0 {
return true
}
@@ -92,7 +94,7 @@ func (l *List) AtBottom() bool {
totalHeight += itemHeight
}
- return totalHeight-l.offsetLine <= l.height
+ return totalHeight-l.offsetLine-margin <= l.height
}
// SetReverse shows the list in reverse order.
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index d44c96da32630baa086e520c2c10fcef145eb772..1396cae66f35152a8e9bd3dbce66631125482432 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -830,11 +830,14 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
// if the message is a tool result it will update the corresponding tool call message
func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
var cmds []tea.Cmd
+ atBottom := m.chat.list.AtBottom()
+
existing := m.chat.MessageItem(msg.ID)
if existing != nil {
// message already exists, skip
return nil
}
+
switch msg.Role {
case message.User:
m.lastUserMessageTime = msg.CreatedAt
@@ -860,14 +863,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
}
}
m.chat.AppendMessages(items...)
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
+ if atBottom {
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
}
if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
m.chat.AppendMessages(infoItem)
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
+ if atBottom {
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
}
}
case message.Tool:
@@ -879,6 +886,11 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
}
if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
toolMsgItem.SetResult(&tr)
+ if atBottom {
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
}
}
}
From 6c26f2a97cca5562159c532977147caa7c9deca4 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Fri, 30 Jan 2026 13:47:39 -0300
Subject: [PATCH 26/33] fix(ui): switch focus on click (#2055)
Ignore sidebar clicks when sidebar is visible.
Assisted-by: GPT-5.2 via Crush
---
internal/ui/model/ui.go | 25 ++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 1396cae66f35152a8e9bd3dbce66631125482432..8eb3fb1b454c81607e7979234076b9cc52c3a5b2 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -529,13 +529,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
+
+ if cmd := m.handleClickFocus(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
switch m.state {
case uiChat:
x, y := msg.X, msg.Y
// Adjust for chat area position
x -= m.layout.main.Min.X
y -= m.layout.main.Min.Y
- if m.chat.HandleMouseDown(x, y) {
+ if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) {
m.lastClickTime = time.Now()
}
}
@@ -897,6 +902,24 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
return tea.Batch(cmds...)
}
+func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
+ switch {
+ case m.state != uiChat:
+ return nil
+ case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
+ return nil
+ case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
+ m.focus = uiFocusEditor
+ cmd = m.textarea.Focus()
+ m.chat.Blur()
+ case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
+ m.focus = uiFocusMain
+ m.textarea.Blur()
+ m.chat.Focus()
+ }
+ return cmd
+}
+
// updateSessionMessage updates an existing message in the current session in the chat
// when an assistant message is updated it may include updated tool calls as well
// that is why we need to handle creating/updating each tool call message too
From 230b99c7bd158dee449848fa4a671cfb0c58edbd Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Fri, 30 Jan 2026 13:48:10 -0300
Subject: [PATCH 27/33] fix(ui): arrow navigation wasnt working when todo view
is open (#2052)
Signed-off-by: Carlos Alexandro Becker
---
internal/ui/model/ui.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 8eb3fb1b454c81607e7979234076b9cc52c3a5b2..db7f6f26d5dfbae75b94f8e825d37520b1ece818 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -1424,14 +1424,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return true
}
case key.Matches(msg, m.keyMap.Chat.PillLeft):
- if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+ if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
if cmd := m.switchPillSection(-1); cmd != nil {
cmds = append(cmds, cmd)
}
return true
}
case key.Matches(msg, m.keyMap.Chat.PillRight):
- if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+ if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
if cmd := m.switchPillSection(1); cmd != nil {
cmds = append(cmds, cmd)
}
From 216f904749612ce82fe078ddbe4b03c73f823144 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Fri, 30 Jan 2026 11:57:24 -0500
Subject: [PATCH 28/33] fix(posthog): check correct error; prevent panic
(#2036)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
💘 Generated with Crush
Assisted-by: GLM 4.7 via Crush
---
internal/event/event.go | 10 ++---
internal/event/event_test.go | 74 ++++++++++++++++++++++++++++++++++++
2 files changed, 79 insertions(+), 5 deletions(-)
create mode 100644 internal/event/event_test.go
diff --git a/internal/event/event.go b/internal/event/event.go
index 674586b06bee03f22c1bd880a5bd39b740c75f66..10b054ce0b21fb3c0db441746827a20739963315 100644
--- a/internal/event/event.go
+++ b/internal/event/event.go
@@ -82,18 +82,18 @@ func send(event string, props ...any) {
}
// Error logs an error event to PostHog with the error type and message.
-func Error(err any, props ...any) {
+func Error(errToLog any, props ...any) {
if client == nil {
return
}
posthogErr := client.Enqueue(posthog.NewDefaultException(
time.Now(),
distinctId,
- reflect.TypeOf(err).String(),
- fmt.Sprintf("%v", err),
+ reflect.TypeOf(errToLog).String(),
+ fmt.Sprintf("%v", errToLog),
))
- if err != nil {
- slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr)
+ if posthogErr != nil {
+ slog.Error("Failed to enqueue PostHog error", "err", errToLog, "props", props, "posthogErr", posthogErr)
return
}
}
diff --git a/internal/event/event_test.go b/internal/event/event_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..7cd22248f19ca072853cd4270ae6fc36e4c124f5
--- /dev/null
+++ b/internal/event/event_test.go
@@ -0,0 +1,74 @@
+package event
+
+// These tests verify that the Error function correctly handles various
+// scenarios. These tests will not log anything.
+
+import (
+ "testing"
+)
+
+func TestError(t *testing.T) {
+ t.Run("returns early when client is nil", func(t *testing.T) {
+ // This test verifies that when the PostHog client is not initialized
+ // the Error function safely returns early without attempting to
+ // enqueue any events. This is important during initialization or when
+ // metrics are disabled, as we don't want the error reporting mechanism
+ // itself to cause panics.
+ originalClient := client
+ defer func() {
+ client = originalClient
+ }()
+
+ client = nil
+ Error("test error", "key", "value")
+ })
+
+ t.Run("handles nil client without panicking", func(t *testing.T) {
+ // This test covers various edge cases where the error value might be
+ // nil, a string, or an error type.
+ originalClient := client
+ defer func() {
+ client = originalClient
+ }()
+
+ client = nil
+ Error(nil)
+ Error("some error")
+ Error(newDefaultTestError("runtime error"), "key", "value")
+ })
+
+ t.Run("handles error with properties", func(t *testing.T) {
+ // This test verifies that the Error function can handle additional
+ // key-value properties that provide context about the error. These
+ // properties are typically passed when recovering from panics (i.e.,
+ // panic name, function name).
+ //
+ // Even with these additional properties, the function should handle
+ // them gracefully without panicking.
+ originalClient := client
+ defer func() {
+ client = originalClient
+ }()
+
+ client = nil
+ Error("test error",
+ "type", "test",
+ "severity", "high",
+ "source", "unit-test",
+ )
+ })
+}
+
+// newDefaultTestError creates a test error that mimics runtime panic
+// errors. This helps us testing that the Error function can handle various
+// error types, including those that might be passed from a panic recovery
+// scenario.
+func newDefaultTestError(s string) error {
+ return testError(s)
+}
+
+type testError string
+
+func (e testError) Error() string {
+ return string(e)
+}
From 7119b7e6981ceff5764bd3903ac8cb995a65759c Mon Sep 17 00:00:00 2001
From: Ayman Bagabas
Date: Fri, 30 Jan 2026 20:06:31 +0300
Subject: [PATCH 30/33] fix(ui): update layout and size after session switch
---
internal/ui/model/ui.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index db7f6f26d5dfbae75b94f8e825d37520b1ece818..e26323f551c7099fd579c303b80f1b764a98f242 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -396,6 +396,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reload prompt history for the new session.
m.historyReset()
cmds = append(cmds, m.loadPromptHistory())
+ m.updateLayoutAndSize()
case sendMessageMsg:
cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
From 133cb6f9b03d769e5328e5124506e1c6e321c075 Mon Sep 17 00:00:00 2001
From: Charm <124303983+charmcli@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:52:25 -0300
Subject: [PATCH 31/33] chore(legal): @bittoby has signed the CLA
---
.github/cla-signatures.json | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index e03ad52ee49a9b000bd8cb935f4da628158ed0ef..b4295ce22cbc508fbb7e012ad12f0c1057c02ced 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -1143,6 +1143,14 @@
"created_at": "2026-01-29T07:05:12Z",
"repoId": 987670088,
"pullRequestNo": 2043
+ },
+ {
+ "name": "bittoby",
+ "id": 218712309,
+ "comment_id": 3824931235,
+ "created_at": "2026-01-30T17:52:15Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2065
}
]
}
\ No newline at end of file
From 77a241fa4ee618cce390d2805c0a6741f942726d Mon Sep 17 00:00:00 2001
From: Charm <124303983+charmcli@users.noreply.github.com>
Date: Mon, 2 Feb 2026 00:06:32 -0300
Subject: [PATCH 32/33] chore(legal): @ijt has signed the CLA
---
.github/cla-signatures.json | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index b4295ce22cbc508fbb7e012ad12f0c1057c02ced..027afeb8dba1ea9fdf4e20ef263eb6ed973ea2d1 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -1151,6 +1151,14 @@
"created_at": "2026-01-30T17:52:15Z",
"repoId": 987670088,
"pullRequestNo": 2065
+ },
+ {
+ "name": "ijt",
+ "id": 15530,
+ "comment_id": 3832667774,
+ "created_at": "2026-02-02T03:06:23Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2080
}
]
}
\ No newline at end of file
From a8b62b11d508b0e4b9bf6619115846dd65a370b0 Mon Sep 17 00:00:00 2001
From: Charm <124303983+charmcli@users.noreply.github.com>
Date: Mon, 2 Feb 2026 01:04:15 -0300
Subject: [PATCH 33/33] chore(legal): @khalilgharbaoui has signed the CLA
---
.github/cla-signatures.json | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index 027afeb8dba1ea9fdf4e20ef263eb6ed973ea2d1..5c18e45ad2a8120191a89d58eb101f84303902b0 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -1159,6 +1159,14 @@
"created_at": "2026-02-02T03:06:23Z",
"repoId": 987670088,
"pullRequestNo": 2080
+ },
+ {
+ "name": "khalilgharbaoui",
+ "id": 8024057,
+ "comment_id": 3832796060,
+ "created_at": "2026-02-02T04:04:04Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2081
}
]
}
\ No newline at end of file